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 := ` +
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 @@ + +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=