diff --git a/cli/app.go b/cli/app.go index 012dc366c5c..15892f9dee2 100644 --- a/cli/app.go +++ b/cli/app.go @@ -97,7 +97,10 @@ const ( moduleFlagVisibility = "visibility" moduleFlagResourceType = "resource-type" moduleFlagRegister = "register" - moduleFlagUpload = "upload" + moduleFlagGenerateType = "generate-type" + moduleFlagAppName = "app-name" + moduleFlagAppType = "app-type" + moduleFlagUpload = "upload" moduleBuildFlagRef = "ref" moduleBuildFlagWait = "wait" @@ -3438,6 +3441,10 @@ 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", "both"), + }, &cli.StringFlag{ Name: generalFlagName, Usage: "name to use for module. for example, a module that contains sensor implementations might be named 'sensors'", @@ -3481,6 +3488,16 @@ 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: "name for the app", + Hidden: true, + }, + &cli.StringFlag{ + Name: moduleFlagAppType, + Usage: formatAcceptedValues("app type", "single_machine", "multi_machine"), + Hidden: true, + }, }, Action: createActionCommandWithT[generateModuleArgs](GenerateModuleAction), }, diff --git a/cli/module_build.go b/cli/module_build.go index 3b24cd9acb6..0806de460a3 100644 --- a/cli/module_build.go +++ b/cli/module_build.go @@ -667,6 +667,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..cba8b4e78ed 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,533 @@ 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?"). + Options( + huh.NewOption("Module", "module"), + huh.NewOption("App", "app"), + huh.NewOption("Module and App", "both"), + ). + 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) + case "both": + return c.generateBoth(ctx, cmd, args, shared) + default: + return fmt.Errorf("invalid generate type %q: must be module, app, or both", generateType) + } +} + +type appInputs struct { + AppName string + AppType string +} + +func (c *viamClient) generateBoth(ctx context.Context, cmd *cli.Command, args generateModuleArgs, shared *sharedInputs) error { + // Module-specific prompts + newModule := &modulegen.ModuleInputs{} + if err := promptModuleInputs(newModule); err != nil { + return err + } + newModule.Visibility = shared.Visibility + newModule.Namespace = shared.Namespace + newModule.RegisterOnApp = shared.RegisterOnApp + + // App-specific prompts + app := &appInputs{} + if err := promptAppUser(app); err != nil { + return err + } + + // Set up module inputs for generation + if err := newModule.CheckResourceAndSetType(); err != nil { + return err + } + if err := checkLanguageVersion(newModule.Language); err != nil { + return err + } + if !args.DryRun { + if err := wrapResolveOrg(ctx, cmd, c, newModule); err != nil { + return err + } + } + populateAdditionalInfo(newModule) + + gArgs, err := getGlobalArgs(cmd) + if err != nil { + return err + } + globalArgs := *gArgs + + // Get latest SDK version + version, err := getLatestSDKTag(ctx, cmd, newModule.Language, globalArgs) + if err != nil { + return err + } + newModule.SDKVersion = version[1:] + if idx := strings.LastIndex(newModule.SDKVersion, "/"); idx != -1 { + newModule.SDKVersion = strings.TrimPrefix(newModule.SDKVersion[idx+1:], "v") + } + + // Run the module generation pipeline + if err := setupDirectories(cmd, newModule.ModuleName, globalArgs); err != nil { + return err + } + if _, err := createModuleAndManifest(ctx, cmd, c, *newModule, globalArgs); err != nil { + return err + } + if err := renderCommonFiles(cmd, *newModule, globalArgs); err != nil { + return err + } + if err := copyLanguageTemplate(cmd, newModule.Language, newModule.ModuleName, globalArgs); err != nil { + return err + } + if err := renderTemplate(cmd, *newModule, globalArgs); err != nil { + return err + } + if err := generateStubs(cmd, *newModule, globalArgs); err != nil { + warningf(cmd.Root().ErrWriter, err.Error()) + } + + modulePath := newModule.ModuleName + + // 1. Add applications[] to the generated meta.json + manifestPath := filepath.Join(modulePath, defaultManifestFilename) + manifest, err := loadManifest(manifestPath) + if err != nil { + return errors.Wrap(err, "failed to load manifest to add app") + } + manifest.Apps = []AppComponent{ + { + Name: app.AppName, + Type: app.AppType, + Entrypoint: "dist/index.html", + }, + } + if err := writeManifest(manifestPath, manifest); err != nil { + return errors.Wrap(err, "failed to write manifest with app") + } + + // 2. Add webapp.go (vmodutils server) + webappGo := fmt.Sprintf(`package %s + +import ( + "context" + "embed" + "io/fs" + + "github.com/erh/vmodutils" + "go.viam.com/rdk/components/generic" + "go.viam.com/rdk/logging" + "go.viam.com/rdk/resource" +) + +//go:embed dist/** +var staticFS embed.FS + +func distFS() (fs.FS, error) { + return fs.Sub(staticFS, "dist") +} + +var WebappModel = resource.NewModel("%s", "%s", "webapp") + +type WebappConfig struct { + resource.TriviallyValidateConfig + Port *int `+"`"+`json:"port,omitempty"`+"`"+` +} + +func init() { + resource.RegisterComponent(generic.API, WebappModel, + resource.Registration[resource.Resource, *WebappConfig]{ + Constructor: NewWebappServer, + }, + ) +} + +func NewWebappServer( + _ context.Context, _ resource.Dependencies, rawConf resource.Config, logger logging.Logger, +) (resource.Resource, error) { + conf, err := resource.NativeConfig[*WebappConfig](rawConf) + if err != nil { + return nil, err + } + fs, err := distFS() + if err != nil { + return nil, err + } + port := 8888 + if conf.Port != nil { + port = *conf.Port + } + return vmodutils.NewWebModuleAndStart(rawConf.ResourceName(), fs, logger, port) +} +`, newModule.ModuleLowercase, newModule.Namespace, newModule.ModuleName) + + if err := os.WriteFile(filepath.Join(modulePath, "webapp.go"), []byte(webappGo), 0o644); err != nil { //nolint:gosec + return errors.Wrap(err, "failed to write webapp.go") + } + + // 3. Create dist/index.html placeholder for go:embed + if err := os.MkdirAll(filepath.Join(modulePath, "dist"), 0o750); err != nil { + return errors.Wrap(err, "failed to create dist directory") + } + placeholder := ` +Viam App +

Viam App

Replace this with your own frontend. See README.md for next steps.

+ +` + if err := os.WriteFile(filepath.Join(modulePath, "dist", "index.html"), []byte(placeholder), 0o644); err != nil { //nolint:gosec + return errors.Wrap(err, "failed to write dist/index.html") + } + + // 4. Update cmd/module/main.go to register webapp model + mainGoPath := filepath.Join(modulePath, "cmd", "module", "main.go") + mainGoBytes, err := os.ReadFile(mainGoPath) //nolint:gosec + if err != nil { + return errors.Wrap(err, "failed to read main.go") + } + mainGo := string(mainGoBytes) + if !strings.Contains(mainGo, "components/generic") { + mainGo = strings.Replace(mainGo, + "\"go.viam.com/rdk/module\"", + "\"go.viam.com/rdk/components/generic\"\n\t\"go.viam.com/rdk/module\"", + 1) + } + mainGo = strings.Replace(mainGo, + "})", + "},\n\t\tresource.APIModel{API: generic.API, Model: "+newModule.ModuleLowercase+".WebappModel})", + 1) + if err := os.WriteFile(mainGoPath, []byte(mainGo), 0o644); err != nil { //nolint:gosec + return errors.Wrap(err, "failed to write updated main.go") + } + + // 5. Update Makefile to include dist in tar + makefilePath := filepath.Join(modulePath, "Makefile") + makefileBytes, err := os.ReadFile(makefilePath) //nolint:gosec + if err != nil { + return errors.Wrap(err, "failed to read Makefile") + } + makefile := string(makefileBytes) + makefile = strings.Replace(makefile, + "tar czf $@ meta.json $(MODULE_BINARY)", + "tar czf $@ meta.json $(MODULE_BINARY) dist", + 1) + if err := os.WriteFile(makefilePath, []byte(makefile), 0o644); err != nil { //nolint:gosec + return errors.Wrap(err, "failed to write updated Makefile") + } + + // 6. Add vmodutils to go.mod + goModPath := filepath.Join(modulePath, "go.mod") + goModBytes, err := os.ReadFile(goModPath) //nolint:gosec + if err != nil { + return errors.Wrap(err, "failed to read go.mod") + } + goMod := string(goModBytes) + if !strings.Contains(goMod, "erh/vmodutils") { + goMod = strings.Replace(goMod, "require (", "require (\n\tgithub.com/erh/vmodutils v0.3.11-rc3", 1) + if err := os.WriteFile(goModPath, []byte(goMod), 0o644); err != nil { //nolint:gosec + return errors.Wrap(err, "failed to write updated go.mod") + } + } + + cwd, err := os.Getwd() + if err != nil { + cwd = "." + } + fullPath := filepath.Join(cwd, newModule.ModuleName) + printf(cmd.Root().Writer, "Module with app successfully generated at %s", fullPath) + printf(cmd.Root().Writer, "Time to build your frontend! See %s for next steps.", filepath.Join(fullPath, "README.md")) + return nil +} + +// 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 := copyAppTemplate(cmd, 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) + printf(cmd.Root().Writer, "Time to build your frontend! See %s for next steps.", filepath.Join(appPath, "README.md")) + if registryURL != "" { + printf(cmd.Root().Writer, "You can view it here: %s", registryURL) + } + return nil +} + +// copyAppTemplate copies non-template files from _templates/app/ into the output directory. +func copyAppTemplate(cmd *cli.Command, moduleName string, globalArgs globalArgs) error { + debugf(cmd.Root().Writer, globalArgs.Debug, "Copying 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() { + if filePath != "." { + debugf(cmd.Root().Writer, globalArgs.Debug, "\tCreating directory %s", filePath) + if err := os.MkdirAll(filepath.Join(moduleName, filePath), 0o750); err != nil { + return err + } + } + } else if !strings.HasPrefix(d.Name(), templatePrefix) { + debugf(cmd.Root().Writer, globalArgs.Debug, "\tCopying file %s", filePath) + srcFile, err := templates.Open(path.Join(appPath, filePath)) + if err != nil { + return errors.Wrapf(err, "error opening file %s", filePath) + } + defer utils.UncheckedErrorFunc(srcFile.Close) + + destPath := filepath.Join(moduleName, filePath) + //nolint:gosec + destFile, err := os.Create(destPath) + if err != nil { + return errors.Wrapf(err, "failed to create file %s", destPath) + } + defer utils.UncheckedErrorFunc(destFile.Close) + + if _, err := io.Copy(destFile, srcFile); err != nil { + return errors.Wrapf(err, "error copying file %s", destPath) + } + } + 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 +662,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 +788,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 +877,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 +911,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 +940,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 } @@ -1001,7 +1548,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 +1621,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 +1640,24 @@ 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.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 { @@ -1144,3 +1711,4 @@ func renderManifest(cmd *cli.Command, moduleID string, module modulegen.ModuleIn return nil } + 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.ts b/cli/module_generate/_templates/app/auth.ts new file mode 100644 index 00000000000..465f06897b9 --- /dev/null +++ b/cli/module_generate/_templates/app/auth.ts @@ -0,0 +1,77 @@ +import type { Credential } from '@viamrobotics/sdk'; +import { getCookie, setCookie } from 'typescript-cookie'; + +const DEFAULT_HOST = 'default-host'; + +export interface HostAndCredentials { + host: string; + credentials: Credential; + machineId: string | null; +} + +export function getHostAndCredentials(): HostAndCredentials { + 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: string, id: string, key: string) { + 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 @@ + +Viam App +

Viam App

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..07e3d7b1bdd --- /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 $@ 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..6545112e7ec --- /dev/null +++ b/cli/module_generate/_templates/app/tmpl-README.md @@ -0,0 +1,123 @@ +# {{ .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. + +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.ts` that reads machine credentials from cookies. Use it to connect: + +```js +import { createRobotClient } from '@viamrobotics/sdk'; +import { getHostAndCredentials } from './auth'; + +const { host, credentials } = getHostAndCredentials(); +const machine = await createRobotClient({ + host, + credentials, + signalingAddress: 'https://app.viam.com', +}); +const resources = await machine.resourceNames(); +``` + +The default entrypoint is `dist/index.html`. If your frontend build outputs to a different location, update both: +- `ENTRYPOINT` in the `Makefile` +- `"entrypoint"` in the applications section of `meta.json` + +After building your frontend, make sure to run: + +``` +make setup +make +``` + +This installs dependencies and builds the module. + +## Test during development + +Test your frontend against a real machine during development: + +1. Start your frontend dev server (e.g. `npm run dev` on port 5173) +2. In another terminal: + ``` + viam module local-app-testing --app-url=http://localhost:5173 --machine-id= + ``` +3. Open http://localhost:8012/start in your browser + +This injects real machine credentials into your dev server so you can test SDK connections without deploying. + +To check that your HTML/CSS renders without a machine connection, just open your HTML file directly in a browser. + +## Host locally + +Add the module to a machine without uploading to the registry: + +1. Go to app.viam.com → fleet → your machine → configure +2. Add the module as a local module, set executable path to `/bin/{{ .ModuleName }}` +3. Add your webapp as a local component with triplet `{{ .Namespace }}:{{ .ModuleName }}:webapp` + - If you have any other local components your webapp pulls from, they need to be added as well +4. Save your config, and the machine will restart to reconfigure with your module + +## Upload to viamapplications.com + +1. Upload `module.tar.gz` (not the binary) to the registry: + ``` + viam module upload --upload=./module.tar.gz --platform=linux/amd64 + ``` + +2. Add the module to your machine on app.viam.com: + - Go to app.viam.com → your machine → Config + - Add the module by name (`{{ .Namespace }}:{{ .ModuleName }}`) + - Add a component: type `generic`, model `{{ .Namespace }}:{{ .ModuleName }}:webapp` + - Save config + +3. Access your app at: + ``` + https://{{ .AppName }}_{{ .Namespace }}.viamapplications.com + ``` + +**Note:** After re-uploading a new version, you may need to hard-refresh (Cmd+Shift+R / Ctrl+Shift+R) to avoid seeing a cached version. + +## Local server + +When the module is running on a machine (via either host locally or registry upload above), it also serves your app on the local network: + +``` +http://:8888 +``` + +Accessible from any device on the same network. Credentials are injected automatically via cookies. The SDK connects through Viam's cloud for signaling, so internet is required. + +To find your machine's IP: `ipconfig getifaddr en0` (macOS) or `hostname -I` (Linux). + +### Offline mode (no internet) + +To use the local server without internet, viam-server needs an HTTP signaling endpoint. Add this to your machine's network config on app.viam.com (Raw JSON): + +```json +{ + "network": { + "bind_address": "0.0.0.0:8081", + "no_tls": true + } +} +``` + +Then your frontend needs to detect local mode and use local signaling instead of cloud signaling: + +```js +const isLocal = document.cookie.includes('is_local=true'); +const signalingAddress = isLocal + ? `http://${window.location.hostname}:8081` + : 'https://app.viam.com'; +``` + +**Note:** `no_tls` replaces the default HTTPS listener (port 8080) with an HTTP listener on 8081. This means traffic on the local network is unencrypted. This is acceptable for trusted networks (factory floor, home) but not recommended for public networks. + diff --git a/cli/module_generate/_templates/app/tmpl-go.mod b/cli/module_generate/_templates/app/tmpl-go.mod new file mode 100644 index 00000000000..d936b1b8ca7 --- /dev/null +++ b/cli/module_generate/_templates/app/tmpl-go.mod @@ -0,0 +1,8 @@ +module {{ .ModuleLowercase }} + +go 1.23 + +require ( + github.com/erh/vmodutils v0.3.11-rc3 + go.viam.com/rdk v{{ .SDKVersion }} +) diff --git a/cli/module_generate/_templates/app/tmpl-module.go b/cli/module_generate/_templates/app/tmpl-module.go new file mode 100644 index 00000000000..15bc9fe6bb9 --- /dev/null +++ b/cli/module_generate/_templates/app/tmpl-module.go @@ -0,0 +1,63 @@ +package {{ .ModuleLowercase }} + +import ( + "context" + "embed" + "io/fs" + "net/http" + + "github.com/erh/vmodutils" + "go.viam.com/rdk/components/generic" + "go.viam.com/rdk/logging" + "go.viam.com/rdk/resource" +) + +//go:embed dist/** +var staticFS embed.FS + +func distFS() (fs.FS, error) { + return fs.Sub(staticFS, "dist") +} + +var Model = resource.NewModel("{{ .Namespace }}", "{{ .ModuleName }}", "webapp") + +type Config struct { + resource.TriviallyValidateConfig + + Port *int `json:"port,omitempty"` +} + +func init() { + resource.RegisterComponent(generic.API, Model, + resource.Registration[resource.Resource, *Config]{ + Constructor: NewServer, + }, + ) +} + +func NewServer(_ context.Context, _ resource.Dependencies, rawConf resource.Config, logger logging.Logger) (resource.Resource, error) { + conf, err := resource.NativeConfig[*Config](rawConf) + if err != nil { + return nil, err + } + + fs, err := distFS() + if err != nil { + return nil, err + } + + port := 8888 + if conf.Port != nil { + port = *conf.Port + } + + isLocalCookie := &http.Cookie{Name: "is_local", Value: "true"} + m, err := vmodutils.NewWebModuleWithCookies(rawConf.ResourceName(), fs, logger, []*http.Cookie{isLocalCookie}) + if err != nil { + return nil, err + } + if err := m.Start(port); err != nil { + return nil, err + } + return m, nil +} diff --git a/cli/module_generate_app_test.go b/cli/module_generate_app_test.go new file mode 100644 index 00000000000..6a8dab53653 --- /dev/null +++ b/cli/module_generate_app_test.go @@ -0,0 +1,77 @@ +package cli + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "testing" + + "go.viam.com/test" +) + +func TestAppTemplateCompiles(t *testing.T) { + testData := appTemplateData{ + ModuleName: "testapp", + ModuleLowercase: "testapp", + AppName: "testapp", + AppType: "single_machine", + Namespace: "testorg", + Visibility: "private", + SDKVersion: "0.94.0", + } + + cCtx := newTestContext(t, map[string]any{"local": true}) + gArgs, _ := getGlobalArgs(cCtx) + globalArgs := *gArgs + + testDir := t.TempDir() + testChdir(t, testDir) + appPath := filepath.Join(testDir, testData.ModuleName) + + // Generate the app + err := setupDirectories(cCtx, testData.ModuleName, globalArgs) + test.That(t, err, test.ShouldBeNil) + + err = copyAppTemplate(cCtx, testData.ModuleName, globalArgs) + test.That(t, err, test.ShouldBeNil) + + err = renderAppTemplate(cCtx, testData.ModuleName, testData, globalArgs) + test.That(t, err, test.ShouldBeNil) + + // Add a replace directive to use the local rdk so we test against the current interface + _, thisFile, _, ok := runtime.Caller(0) + test.That(t, ok, test.ShouldBeTrue) + rdkRoot := filepath.Dir(filepath.Dir(thisFile)) + goModPath := filepath.Join(appPath, "go.mod") + goMod, err := os.ReadFile(goModPath) + test.That(t, err, test.ShouldBeNil) + goMod = append(goMod, []byte(fmt.Sprintf("\nreplace go.viam.com/rdk => %s\n", rdkRoot))...) + err = os.WriteFile(goModPath, goMod, 0o644) + test.That(t, err, test.ShouldBeNil) + + // Pin vmodutils to rc version that implements Status() + goGet := exec.Command("go", "get", "github.com/erh/vmodutils@v0.3.11-rc3") + goGet.Dir = appPath + goGetOut, err := goGet.CombinedOutput() + if err != nil { + t.Fatalf("go get vmodutils failed: %v\n%s", err, goGetOut) + } + + // Run go mod tidy to resolve dependencies + tidy := exec.Command("go", "mod", "tidy") + tidy.Dir = appPath + tidyOut, err := tidy.CombinedOutput() + if err != nil { + t.Fatalf("go mod tidy failed: %v\n%s", err, tidyOut) + } + + // Verify the generated module.go compiles against current rdk + build := exec.Command("go", "build", "./...") + build.Dir = appPath + buildOut, err := build.CombinedOutput() + if err != nil { + t.Fatalf("generated app module does not compile: %v\n%s", err, buildOut) + } +} diff --git a/cli/module_generate_test.go b/cli/module_generate_test.go index a75148dd099..e97b7537acb 100644 --- a/cli/module_generate_test.go +++ b/cli/module_generate_test.go @@ -214,7 +214,7 @@ func TestGenerateModuleAction(t *testing.T) { t.Run("test render manifest", func(t *testing.T) { setupDirectories(cCtx, testModule.ModuleName, globalArgs) - err := renderManifest(cCtx, "moduleId", testModule, globalArgs) + err := renderManifest(cCtx, "moduleId", testModule, globalArgs, nil) test.That(t, err, test.ShouldBeNil) _, err = os.Stat(filepath.Join(testDir, testModule.ModuleName, "meta.json")) test.That(t, err, test.ShouldBeNil)