diff --git a/cli/app.go b/cli/app.go index 8db2c1f835e..a4b56c6cc3f 100644 --- a/cli/app.go +++ b/cli/app.go @@ -97,6 +97,9 @@ const ( moduleFlagVisibility = "visibility" moduleFlagResourceType = "resource-type" moduleFlagRegister = "register" + moduleFlagGenerateType = "generate-type" + moduleFlagAppName = "app-name" + moduleFlagAppType = "app-type" moduleFlagUpload = "upload" moduleFlagAnnotation = "annotation" @@ -3439,13 +3442,17 @@ After creation, use 'viam module update' to push your new module to app.viam.com Usage: "generate a new modular resource via prompts", UsageText: createUsageText("module generate", nil, true, false), Flags: []cli.Flag{ + &cli.StringFlag{ + Name: moduleFlagGenerateType, + Usage: formatAcceptedValues("type of project to generate", "module", "app"), + }, &cli.StringFlag{ Name: generalFlagName, - Usage: "name to use for module. for example, a module that contains sensor implementations might be named 'sensors'", + Usage: "(module only) name to use for module. for example, a module that contains sensor implementations might be named 'sensors'", }, &cli.StringFlag{ Name: moduleFlagLanguage, - Usage: formatAcceptedValues("language to use for module", supportedModuleGenLanguages...), + Usage: formatAcceptedValues("(module only) language to use for module", supportedModuleGenLanguages...), }, &cli.StringFlag{ Name: moduleFlagVisibility, @@ -3458,7 +3465,7 @@ After creation, use 'viam module update' to push your new module to app.viam.com }, &cli.StringFlag{ Name: generalFlagResourceSubtype, - Usage: "resource subtype to use in module, for example arm, camera, or motion. see " + + Usage: "(module only) resource subtype to use in module, for example arm, camera, or motion. see " + "https://docs.viam.com/dev/reference/glossary/#term-subtype for more details", }, // This is unnecessary and creates a gotcha for users. Kept here @@ -3470,7 +3477,7 @@ After creation, use 'viam module update' to push your new module to app.viam.com }, &cli.StringFlag{ Name: generalFlagModelName, - Usage: "name for the particular resource subtype implementation." + + Usage: "(module only) name for the particular resource subtype implementation." + " for example, a sensor model that detects moisture might be named 'moisture'", }, &cli.BoolFlag{ @@ -3482,6 +3489,14 @@ After creation, use 'viam module update' to push your new module to app.viam.com Usage: "indicate a dry test run, so skip regular checks", Hidden: true, }, + &cli.StringFlag{ + Name: moduleFlagAppName, + Usage: "(app only) name for the app", + }, + &cli.StringFlag{ + Name: moduleFlagAppType, + Usage: formatAcceptedValues("(app only) app type", "single_machine", "multi_machine"), + }, }, Action: createActionCommandWithT[generateModuleArgs](GenerateModuleAction), }, diff --git a/cli/module_build.go b/cli/module_build.go index 8b454c256a3..f3ff16f1d8e 100644 --- a/cli/module_build.go +++ b/cli/module_build.go @@ -744,6 +744,13 @@ func (c *viamClient) createGitArchive(repoPath string) (string, error) { return nil } + // Skip symlinks — filepath.Walk doesn't follow them, so they appear as + // non-directory entries, but os.ReadFile would follow the link and fail + // if the target is a directory (common in pnpm node_modules). + if info.Mode()&os.ModeSymlink != 0 { + return nil + } + if c.shouldIgnore(relPath, matcher, false) { return nil } diff --git a/cli/module_generate.go b/cli/module_generate.go index 52c5ac90b89..94918b5b81d 100644 --- a/cli/module_generate.go +++ b/cli/module_generate.go @@ -65,6 +65,7 @@ var ( var unauthenticatedMode = false type generateModuleArgs struct { + GenerateType string Name string Language string Visibility string @@ -73,6 +74,8 @@ type generateModuleArgs struct { ModelName string Register bool DryRun bool + AppName string + AppType string } // GenerateModuleAction runs the module generate cli and generates necessary module templates based on user input. @@ -114,7 +117,289 @@ func promptUnauthenticated() bool { return true } +func promptGenerateType() (string, error) { + var generateType string + form := huh.NewForm( + huh.NewGroup( + huh.NewSelect[string](). + Title("What would you like to generate?"). + DescriptionFunc(func() string { + switch generateType { + case "module": + return "A modular resource to add custom components or services to your machine." + case "app": + return "A web application that connects to your machine via the Viam SDK." + default: + return "" + } + }, &generateType). + Options( + huh.NewOption("Module", "module"), + huh.NewOption("App", "app"), + ). + Value(&generateType), + ), + ).WithWidth(77) + if err := form.Run(); err != nil { + return "", err + } + return generateType, nil +} + func (c *viamClient) generateModuleAction(ctx context.Context, cmd *cli.Command, args generateModuleArgs) error { + generateType := args.GenerateType + if generateType == "" { + var err error + generateType, err = promptGenerateType() + if err != nil { + return err + } + } + + shared := &sharedInputs{ + Visibility: args.Visibility, + Namespace: args.PublicNamespace, + RegisterOnApp: args.Register, + } + if shared.Visibility == "" || shared.Namespace == "" { + if err := promptSharedInputs(shared); err != nil { + return err + } + } + + switch generateType { + case "module", "": + return c.generateModule(ctx, cmd, args, shared) + case "app": + return c.generateApp(ctx, cmd, args, shared) + default: + return fmt.Errorf("invalid generate type %q: must be module or app", generateType) + } +} + +type appInputs struct { + AppName string + AppType string +} + +// appTemplateData is the struct passed to app template rendering. +type appTemplateData struct { + ModuleName string + ModuleLowercase string + AppName string + AppType string + Namespace string + Visibility string + SDKVersion string +} + +func (c *viamClient) generateApp(ctx context.Context, cmd *cli.Command, args generateModuleArgs, shared *sharedInputs) error { + app := &appInputs{ + AppName: args.AppName, + AppType: args.AppType, + } + + if app.AppName == "" || app.AppType == "" { + if err := promptAppUser(app); err != nil { + return err + } + } + + gArgs, err := getGlobalArgs(cmd) + if err != nil { + return err + } + globalArgs := *gArgs + + // Use app name as the module name (the app module wraps the webapp) + moduleName := app.AppName + + // Resolve org and optionally register with Viam + moduleInputs := &modulegen.ModuleInputs{ + ModuleName: moduleName, + Namespace: shared.Namespace, + RegisterOnApp: shared.RegisterOnApp, + } + if !args.DryRun { + if err := wrapResolveOrg(ctx, cmd, c, moduleInputs); err != nil { + return err + } + } + + var registryURL string + if shared.RegisterOnApp { + debugf(cmd.Root().Writer, globalArgs.Debug, "Registering app with Viam") + moduleResponse, err := c.createModule(ctx, moduleName, moduleInputs.OrgID) + if err != nil { + return errors.Wrap(err, "failed to register app") + } + registryURL = moduleResponse.GetUrl() + } + + data := appTemplateData{ + ModuleName: moduleName, + ModuleLowercase: strings.ReplaceAll(strings.ToLower(moduleName), "-", ""), + AppName: app.AppName, + AppType: app.AppType, + Namespace: moduleInputs.Namespace, + Visibility: shared.Visibility, + } + + // Get latest SDK version + version, err := getLatestSDKTag(ctx, cmd, golang, globalArgs) + if err != nil { + return err + } + data.SDKVersion = version[1:] + + // Create root directory + if err := setupDirectories(cmd, moduleName, globalArgs); err != nil { + return err + } + + // Generate meta.json using the shared manifest function with app fields + var modID moduleID + if shared.RegisterOnApp { + parsedID, err := parseModuleID(fmt.Sprintf("%s:%s", moduleInputs.Namespace, moduleName)) + if err != nil { + return errors.Wrap(err, "failed to parse module identifier") + } + modID = parsedID + } else { + modID.name = moduleName + modID.prefix = moduleInputs.Namespace + } + appModuleInputs := modulegen.ModuleInputs{ + ModuleName: moduleName, + Language: golang, + Visibility: shared.Visibility, + Namespace: moduleInputs.Namespace, + } + if err := renderManifest(cmd, modID.String(), appModuleInputs, globalArgs, app); err != nil { + return err + } + + // Copy non-template files and render template files (skip meta.json since we generated it above) + if err := copyLanguageTemplate(cmd, "app", moduleName, globalArgs); err != nil { + return err + } + if err := renderAppTemplate(cmd, moduleName, data, globalArgs); err != nil { + return err + } + + cwd, err := os.Getwd() + if err != nil { + cwd = "." + } + appPath := filepath.Join(cwd, moduleName) + printf(cmd.Root().Writer, "App module successfully generated at %s", appPath) + if registryURL != "" { + printf(cmd.Root().Writer, "You can view it here: %s", registryURL) + } + printf(cmd.Root().Writer, "\n--- Build your frontend ---") + printf(cmd.Root().Writer, "This module has no frontend yet. Bring your own using any framework (e.g. Svelte, Vue, React).") + printf(cmd.Root().Writer, "\n1. Install the Viam SDK:") + printf(cmd.Root().Writer, " npm install @viamrobotics/sdk typescript-cookie") + printf(cmd.Root().Writer, "\n2. Build your frontend into the dist/ directory.") + printf(cmd.Root().Writer, "\n3. Build the module:") + printf(cmd.Root().Writer, " make setup") + printf(cmd.Root().Writer, " make") + printf(cmd.Root().Writer, "\nSee %s for full details, including how to test during development and upload to viamapplications.com.", + filepath.Join(appPath, "README.md")) + return nil +} + +// renderAppTemplate renders tmpl- prefixed files from _templates/app/ with app-specific data. +func renderAppTemplate(cmd *cli.Command, moduleName string, data appTemplateData, globalArgs globalArgs) error { + debugf(cmd.Root().Writer, globalArgs.Debug, "Rendering app template files") + appPath := path.Join(templatesPath, "app") + tempDir, err := fs.Sub(templates, appPath) + if err != nil { + return err + } + return fs.WalkDir(tempDir, ".", func(filePath string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if !d.IsDir() && strings.HasPrefix(d.Name(), templatePrefix) { + destPath := filepath.Join(moduleName, strings.ReplaceAll(filePath, templatePrefix, "")) + debugf(cmd.Root().Writer, globalArgs.Debug, "\tRendering file %s", destPath) + + tFile, err := templates.Open(path.Join(appPath, filePath)) + if err != nil { + return err + } + defer utils.UncheckedErrorFunc(tFile.Close) + tBytes, err := io.ReadAll(tFile) + if err != nil { + return err + } + + tmpl, err := template.New(filePath).Parse(string(tBytes)) + if err != nil { + return err + } + + //nolint:gosec + destFile, err := os.Create(destPath) + if err != nil { + return err + } + defer utils.UncheckedErrorFunc(destFile.Close) + + if err := tmpl.Execute(destFile, data); err != nil { + return errors.Wrapf(err, "error rendering template %s", destPath) + } + } + return nil + }) +} + +func promptAppUser(app *appInputs) error { + form := huh.NewForm( + huh.NewGroup( + huh.NewNote(). + Title("Generate a new Viam app"). + Description("This will generate a web app module that connects to your machine via the Viam SDK.\n"+ + "For more details, view the documentation at \nhttps://docs.viam.com/registry/"), + huh.NewInput(). + Title("Set an app name:"). + Description("The app name can contain only alphanumeric characters, dashes, and underscores."). + Value(&app.AppName). + Placeholder("my-app"). + Suggestions([]string{"my-app"}). + Validate(func(s string) error { + if s == "" { + return errors.New("app name must not be empty") + } + match, err := regexp.MatchString("^[a-zA-Z]+(?:[_\\-a-zA-Z0-9]+)*$", s) + if !match || err != nil { + return errors.New("app names can only contain alphanumeric characters, dashes, and underscores,\nand must start with a letter") + } + if _, err := os.Stat(s); err == nil { + return errors.New("this app directory already exists") + } + return nil + }), + huh.NewSelect[string](). + Title("App type:"). + Description("Single machine apps connect to one machine.\n"+ + "Multi machine apps can connect to multiple machines in your fleet."). + Options( + huh.NewOption("Single Machine", "single_machine"), + huh.NewOption("Multi Machine", "multi_machine"), + ). + Value(&app.AppType), + ), + ).WithHeight(25).WithWidth(88) + if err := form.Run(); err != nil { + return errors.Wrap(err, "encountered an error generating app") + } + + return nil +} + +func (c *viamClient) generateModule(ctx context.Context, cmd *cli.Command, args generateModuleArgs, shared *sharedInputs) error { var newModule *modulegen.ModuleInputs var err error @@ -133,10 +418,12 @@ func (c *viamClient) generateModuleAction(ctx context.Context, cmd *cli.Command, } if newModule.HasEmptyInput() { - err = promptUser(newModule) - if err != nil { + if err := promptModuleInputs(newModule); err != nil { return err } + newModule.Visibility = shared.Visibility + newModule.Namespace = shared.Namespace + newModule.RegisterOnApp = shared.RegisterOnApp } if err := checkLanguageVersion(newModule.Language); err != nil { return err @@ -257,9 +544,65 @@ func modelName(module *modulegen.ModuleInputs) string { return resourceName } -// Prompt the user for information regarding the module they want to create -// returns the modulegen.ModuleInputs struct that contains the information the user entered. -func promptUser(module *modulegen.ModuleInputs) error { +// sharedInputs holds fields common to both module and app generation. +type sharedInputs struct { + Visibility string + Namespace string + RegisterOnApp bool +} + +// promptSharedInputs prompts for visibility, namespace, and registration — shared across module and app flows. +func promptSharedInputs(shared *sharedInputs) error { + var registerWidget huh.Field + if unauthenticatedMode { + registerWidget = huh.NewSelect[bool](). + Title("Register with Viam"). + Description("You are unauthenticated and cannot register with Viam.\n\nThis will be local-only."). + Options( + huh.NewOption("Continue", false), + ). + Value(&shared.RegisterOnApp) + } else { + registerWidget = huh.NewConfirm(). + Title("Register with Viam"). + Description("Register with Viam.\nIf selected, " + + "this will associate with your organization.\n" + + "Otherwise, this will be local-only.", + ). + Value(&shared.RegisterOnApp) + } + + form := huh.NewForm( + huh.NewGroup( + huh.NewSelect[string](). + Title("Visibility:"). + Options( + huh.NewOption("Public", moduleVisibilityPublic), + huh.NewOption("Private", moduleVisibilityPrivate), + huh.NewOption("Public Unlisted", moduleVisibilityPublicUnlisted), + ). + Value(&shared.Visibility), + huh.NewInput(). + Title("Namespace/Organization ID"). + Value(&shared.Namespace). + Placeholder("my-namespace"). + Validate(func(s string) error { + if s == "" { + return errors.New("namespace or org ID must not be empty") + } + return nil + }), + registerWidget, + ), + ).WithHeight(25).WithWidth(88) + if err := form.Run(); err != nil { + return errors.Wrap(err, "encountered an error in shared prompts") + } + return nil +} + +// promptModuleInputs prompts for module-specific fields: name, language, resource subtype, model name. +func promptModuleInputs(module *modulegen.ModuleInputs) error { titleCaser := cases.Title(language.Und) resourceOptions := []huh.Option[string]{} for _, resource := range modulegen.Resources { @@ -290,25 +633,6 @@ func promptUser(module *modulegen.ModuleInputs) error { resourceOptions = append(resourceOptions, huh.NewOption(resType, resource)) } - var registerWidget huh.Field - if unauthenticatedMode { - registerWidget = huh.NewSelect[bool](). - Title("Register module"). - Description("You are unauthenticated and cannot register this module with Viam.\n\nThis module will be a local-only module."). - Options( - huh.NewOption("Continue", false), - ). - Value(&module.RegisterOnApp) - } else { - registerWidget = huh.NewConfirm(). - Title("Register module"). - Description("Register this module with Viam.\nIf selected, " + - "this will associate the module with your organization.\n" + - "Otherwise, this will be a local-only module.", - ). - Value(&module.RegisterOnApp) - } - form := huh.NewForm( huh.NewGroup( huh.NewNote(). @@ -343,24 +667,6 @@ func promptUser(module *modulegen.ModuleInputs) error { huh.NewOption("C++", cpp), ). Value(&module.Language), - huh.NewSelect[string](). - Title("Visibility:"). - Options( - huh.NewOption("Public", moduleVisibilityPublic), - huh.NewOption("Private", moduleVisibilityPrivate), - huh.NewOption("Public Unlisted", moduleVisibilityPublicUnlisted), - ). - Value(&module.Visibility), - huh.NewInput(). - Title("Namespace/Organization ID"). - Value(&module.Namespace). - Placeholder("my-namespace"). - Validate(func(s string) error { - if s == "" { - return errors.New("namespace or org ID must not be empty") - } - return nil - }), huh.NewSelect[string](). Title("Select a resource to be added to the module:"). Description("A resource is a component or service that provides functionality to your machine.\n"+ @@ -390,14 +696,11 @@ func promptUser(module *modulegen.ModuleInputs) error { } return nil }), - registerWidget, ), ).WithHeight(25).WithWidth(88) - err := form.Run() - if err != nil { + if err := form.Run(); err != nil { return errors.Wrap(err, "encountered an error generating module") } - return nil } @@ -616,7 +919,7 @@ func copyLanguageTemplate(cmd *cli.Command, language, moduleName string, globalA if d.IsDir() { if d.Name() != language { debugf(cmd.Root().Writer, globalArgs.Debug, "\tCopying %s directory", d.Name()) - err = os.Mkdir(filepath.Join(moduleName, filePath), 0o750) + err = os.MkdirAll(filepath.Join(moduleName, filePath), 0o750) if err != nil { return err } @@ -1001,7 +1304,7 @@ func createModuleAndManifest( moduleID.name = module.ModuleName moduleID.prefix = module.Namespace } - err := renderManifest(cmd, moduleID.String(), module, globalArgs) + err := renderManifest(cmd, moduleID.String(), module, globalArgs, nil) if err != nil { return "", errors.Wrap(err, "failed to render manifest") } @@ -1074,8 +1377,10 @@ func renderModelDoc(module modulegen.ModuleInputs) error { return nil } -// Create the meta.json manifest. -func renderManifest(cmd *cli.Command, moduleID string, module modulegen.ModuleInputs, globalArgs globalArgs) error { +// Create the meta.json manifest. If appInfo is non-nil, app-specific fields are added. +func renderManifest( + cmd *cli.Command, moduleID string, module modulegen.ModuleInputs, globalArgs globalArgs, appInfo *appInputs, +) error { debugf(cmd.Root().Writer, globalArgs.Debug, "Rendering module manifest") visibility := module.Visibility @@ -1091,6 +1396,25 @@ func renderManifest(cmd *cli.Command, moduleID string, module modulegen.ModuleIn Description: fmt.Sprintf("Modular %s %s: %s", module.ResourceSubtype, module.ResourceType, module.ModelName), MarkdownLink: &module.ModuleReadmeLink, } + + if appInfo != nil { + manifest.Description = fmt.Sprintf("%s app", appInfo.AppName) + manifest.MarkdownLink = nil + manifest.Models = []ModuleComponent{ + { + API: "rdk:component:generic", + Model: fmt.Sprintf("%s:%s:webapp", module.Namespace, module.ModuleName), + }, + } + manifest.Apps = []AppComponent{ + { + Name: appInfo.AppName, + Type: appInfo.AppType, + Entrypoint: "dist/index.html", + }, + } + } + switch module.Language { case python: if runtime.GOOS == osWindows { diff --git a/cli/module_generate/_templates/app/.gitignore b/cli/module_generate/_templates/app/.gitignore new file mode 100644 index 00000000000..b4c95ec7dd7 --- /dev/null +++ b/cli/module_generate/_templates/app/.gitignore @@ -0,0 +1,26 @@ +# Node.js dependencies +node_modules/ + +# Build outputs +dist/ +bin/ + +# Archive files +*.tar.gz +.VIAM_RELOAD_ARCHIVE.tar.gz +reload-*/ + +# OS files +.DS_Store +Thumbs.db + +# Go +*.exe +*.exe~ +*.dll +*.so +*.dylib +*.test +*.out +go.work +go.work.sum diff --git a/cli/module_generate/_templates/app/auth.js b/cli/module_generate/_templates/app/auth.js new file mode 100644 index 00000000000..b33c5fe4c40 --- /dev/null +++ b/cli/module_generate/_templates/app/auth.js @@ -0,0 +1,70 @@ +import { getCookie, setCookie } from 'typescript-cookie'; + +const DEFAULT_HOST = 'default-host'; + +export function getHostAndCredentials() { + const host = getCookie('host'); + const apiKeyId = getCookie('api-key-id'); + const apiKeySecret = getCookie('api-key'); + if (host && apiKeyId && apiKeySecret) { + return { + host, + credentials: { + type: 'api-key', + payload: apiKeySecret, + authEntity: apiKeyId + }, + machineId: null + }; + } + + const parts = window.location.pathname.split('/'); + if (parts && parts.length >= 3 && parts[1] == 'machine') { + const machineCookieKey = parts[2]; + const cookieData = getCookie(machineCookieKey); + if (cookieData) { + try { + const parsed = JSON.parse(cookieData); + const id = parsed?.apiKey?.id; + const key = parsed?.apiKey?.key; + const h = parsed?.hostname; + const machineId = parsed?.machineId || null; + if (h && id && key) { + return { + host: h, + credentials: { type: 'api-key', payload: key, authEntity: id }, + machineId + }; + } + } catch { + // Invalid cookie data + } + } + } + + const savedInputCookie = getCookie(DEFAULT_HOST); + if (savedInputCookie) { + try { + const { host, id: apiKeyId, key: apiKeySecret } = JSON.parse(savedInputCookie); + if (host && apiKeyId && apiKeySecret) { + return { + host, + credentials: { type: 'api-key', payload: apiKeySecret, authEntity: apiKeyId }, + machineId: null + }; + } + } catch { + // Invalid cookie data + } + } + + return { + host: '', + credentials: { type: 'api-key', payload: '', authEntity: '' }, + machineId: null + }; +} + +export function saveHostInfo(host, id, key) { + setCookie(DEFAULT_HOST, JSON.stringify({ host, key, id })); +} diff --git a/cli/module_generate/_templates/app/cmd/module/tmpl-main.go b/cli/module_generate/_templates/app/cmd/module/tmpl-main.go new file mode 100644 index 00000000000..cb1feb158b6 --- /dev/null +++ b/cli/module_generate/_templates/app/cmd/module/tmpl-main.go @@ -0,0 +1,12 @@ +package main + +import ( + "{{ .ModuleLowercase }}" + "go.viam.com/rdk/components/generic" + "go.viam.com/rdk/module" + "go.viam.com/rdk/resource" +) + +func main() { + module.ModularMain(resource.APIModel{API: generic.API, Model: {{ .ModuleLowercase }}.Model}) +} diff --git a/cli/module_generate/_templates/app/dist/index.html b/cli/module_generate/_templates/app/dist/index.html new file mode 100644 index 00000000000..822d5e69352 --- /dev/null +++ b/cli/module_generate/_templates/app/dist/index.html @@ -0,0 +1,4 @@ + +
Replace this with your own frontend. See README.md for next steps.
+ diff --git a/cli/module_generate/_templates/app/tmpl-Makefile b/cli/module_generate/_templates/app/tmpl-Makefile new file mode 100644 index 00000000000..98e42d094f9 --- /dev/null +++ b/cli/module_generate/_templates/app/tmpl-Makefile @@ -0,0 +1,49 @@ + +GO_BUILD_ENV := +GO_BUILD_FLAGS := +MODULE_BINARY := bin/{{ .ModuleName }} +ENTRYPOINT := dist/index.html + +ifeq ($(VIAM_TARGET_OS), windows) + GO_BUILD_ENV += GOOS=windows GOARCH=amd64 + GO_BUILD_FLAGS := -tags no_cgo + MODULE_BINARY = bin/{{ .ModuleName }}.exe +endif + +.DEFAULT_GOAL := all + +$(MODULE_BINARY): Makefile go.mod *.go cmd/module/*.go $(ENTRYPOINT) + $(GO_BUILD_ENV) go build $(GO_BUILD_FLAGS) -o $(MODULE_BINARY) cmd/module/main.go + +lint: + gofmt -s -w . + +update: + go get go.viam.com/rdk@latest + go get github.com/erh/vmodutils@v0.3.11-rc3 + go mod tidy + +test: + go test ./... + +module.tar.gz: meta.json $(MODULE_BINARY) $(ENTRYPOINT) +ifeq ($(VIAM_TARGET_OS), windows) + jq '.entrypoint = "./bin/{{ .ModuleName }}.exe"' meta.json > temp.json && mv temp.json meta.json +else + strip $(MODULE_BINARY) +endif + tar czf $@ --exclude=node_modules meta.json $(MODULE_BINARY) dist +ifeq ($(VIAM_TARGET_OS), windows) + git checkout meta.json +endif + +module: test module.tar.gz + +all: test module.tar.gz + +setup: + go get github.com/erh/vmodutils@v0.3.11-rc3 + go mod tidy + +clean: + rm -rf bin module.tar.gz diff --git a/cli/module_generate/_templates/app/tmpl-README.md b/cli/module_generate/_templates/app/tmpl-README.md new file mode 100644 index 00000000000..456520f23cd --- /dev/null +++ b/cli/module_generate/_templates/app/tmpl-README.md @@ -0,0 +1,119 @@ +# {{ .AppName }} + +A Viam app module that serves your web app from your machine via the Viam SDK. + +## Build your frontend + +This module doesn't provide any frontend - bring your own using whatever framework you like (e.g. Svelte, Vue, React). This README assumes `npm` usage. For other package managers, adapt accordingly. + +> **Note:** Viam provides a set of utilities for Svelte that make it easy to integrate Viam into Svelte apps. See the [Viam TypeScript SDK](https://github.com/viamrobotics/viam-typescript-sdk) for details. + +To connect to your machine, install the Viam SDK and cookie helper with your package manager: + +``` +npm install @viamrobotics/sdk typescript-cookie +``` + +A utility file is included at `auth.js` in the project root. Copy it into your frontend's source directory alongside the file that will import it. + +```js +import { createRobotClient } from '@viamrobotics/sdk'; +import { getHostAndCredentials } from './auth.js'; + +const { host, credentials } = getHostAndCredentials(); +const machine = await createRobotClient({ + host, + credentials, + signalingAddress: 'https://app.viam.com', +}); +const resources = await machine.resourceNames(); +``` + +Viam Apps expects the entrypoint of your app to be at `dist/index.html`. If your frontend builds into a subdirectory (e.g. `dist/build/index.html`), update the path in three places: +- `meta.json`: the `entrypoint` field under `applications` +- `Makefile`: the `ENTRYPOINT` variable +- `module.go`: both the `//go:embed` path and the `fs.Sub` path in `distFS()` + +**Important:** Your frontend must use relative paths in its build output (e.g. `./static/js/main.js`, not `/static/js/main.js`). Absolute paths will break when served from viamapplications.com or the local server. For Create React App, add `"homepage": "."` to your `package.json` to enable this. + +After building your frontend into `dist/`, run `make` to build the module. +``` +make setup +make +``` + +## Test during development + +Test your frontend against a real machine during development: + +1. Start your frontend dev server from your frontend's directory and note the port it starts on (the command depends on your framework, e.g. `npm run dev` for Vite or `npm start` for Create React App) +2. In another terminal: + ``` + viam module local-app-testing --app-url=http://localhost: