Documentation
¶
Index ¶
Constants ¶
This section is empty.
Variables ¶
var CopyCmd = &cli.Command{ Name: "copy", Usage: "Copy a file between local machine and space", Description: "Copy a file to or from a running space. Use spacename:path format for space files.", Flags: []cli.Flag{ &cli.StringFlag{ Name: "workdir", Aliases: []string{"w"}, Usage: "Working directory for relative paths in space", DefaultValue: "", }, }, Arguments: []cli.Argument{ &cli.StringArg{ Name: "source", Required: true, Usage: "Source file path (use spacename:path for space files)", }, &cli.StringArg{ Name: "dest", Required: true, Usage: "Destination file path (use spacename:path for space files)", }, }, MaxArgs: cli.NoArgs, Run: func(ctx context.Context, cmd *cli.Command) error { workdir := cmd.GetString("workdir") source := cmd.GetStringArg("source") dest := cmd.GetStringArg("dest") // Determine direction and extract space name from the path with space: prefix var direction, localPath, spacePath, spaceName string var sourceSpaceName, sourceSpacePath, destSpaceName, destSpacePath string sourceColonIndex := strings.Index(source, ":") destColonIndex := strings.Index(dest, ":") sourceIsSpace := sourceColonIndex > 1 destIsSpace := destColonIndex > 1 if sourceIsSpace && destIsSpace { direction = "space_to_space" sourceSpaceName = source[:sourceColonIndex] sourceSpacePath = source[sourceColonIndex+1:] destSpaceName = dest[:destColonIndex] destSpacePath = dest[destColonIndex+1:] if sourceSpacePath == "" { return fmt.Errorf("Source space path cannot be empty after '%s:'", sourceSpaceName) } if destSpacePath == "" { return fmt.Errorf("Destination space path cannot be empty after '%s:'", destSpaceName) } } else if sourceIsSpace { direction = "from_space" spaceName = source[:sourceColonIndex] spacePath = source[sourceColonIndex+1:] localPath = dest if spacePath == "" { return fmt.Errorf("Space path cannot be empty after '%s:'", spaceName) } } else if destIsSpace { direction = "to_space" spaceName = dest[:destColonIndex] spacePath = dest[destColonIndex+1:] localPath = source if spacePath == "" { return fmt.Errorf("Space path cannot be empty after '%s:'", spaceName) } } else { return fmt.Errorf("One path must use the format 'spacename:path' (space name must be more than 1 character)") } alias := cmd.GetString("alias") cfg := config.GetServerAddr(alias, cmd) client, err := apiclient.NewClient(cfg.HttpServer, cfg.ApiToken, cmd.GetBool("tls-skip-verify")) if err != nil { return fmt.Errorf("Failed to create API client: %w", err) } user, err := client.WhoAmI(context.Background()) if err != nil { return fmt.Errorf("Error getting user: %w", err) } spaces, _, err := client.GetSpaces(context.Background(), user.Id, false) if err != nil { return fmt.Errorf("Error getting spaces: %w", err) } findSpaceId := func(name string) (string, error) { for _, space := range spaces.Spaces { if space.Name == name { return space.Id, nil } } return "", fmt.Errorf("Space not found: %s", name) } connectToSpace := func(spaceId string) (*websocket.Conn, error) { wsUrl := fmt.Sprintf("%s/space-io/%s/copy", cfg.WsServer, spaceId) header := http.Header{ "Authorization": []string{fmt.Sprintf("Bearer %s", cfg.ApiToken)}, } dialer := websocket.DefaultDialer dialer.TLSClientConfig = &tls.Config{InsecureSkipVerify: cmd.GetBool("tls-skip-verify")} dialer.HandshakeTimeout = 5 * time.Second ws, response, err := dialer.Dial(wsUrl, header) if err != nil { if response != nil && response.StatusCode == http.StatusUnauthorized { return nil, fmt.Errorf("failed to authenticate with server, check remote token") } else if response != nil && response.StatusCode == http.StatusForbidden { return nil, fmt.Errorf("no permission to copy files in this space") } return nil, fmt.Errorf("Error connecting to websocket: %w", err) } return ws, nil } if direction == "space_to_space" { sourceSpaceId, err := findSpaceId(sourceSpaceName) if err != nil { return err } destSpaceId, err := findSpaceId(destSpaceName) if err != nil { return err } sourceWs, err := connectToSpace(sourceSpaceId) if err != nil { return err } defer sourceWs.Close() destWs, err := connectToSpace(destSpaceId) if err != nil { return err } defer destWs.Close() sourceRequest := apiclient.CopyFileRequest{ Direction: "from_space", SourcePath: sourceSpacePath, Workdir: workdir, } fmt.Printf("Copying %s:%s to %s:%s...\n", sourceSpaceName, sourceSpacePath, destSpaceName, destSpacePath) err = sourceWs.WriteJSON(sourceRequest) if err != nil { return fmt.Errorf("Error sending source copy request: %w", err) } var sourceResult map[string]interface{} err = sourceWs.ReadJSON(&sourceResult) if err != nil { return fmt.Errorf("Error reading source response: %w", err) } success, ok := sourceResult["success"].(bool) if !ok || !success { errorMsg, _ := sourceResult["error"].(string) return fmt.Errorf("Source read failed: %s", errorMsg) } // Extract content var content []byte if contentStr, ok := sourceResult["content"].(string); ok { content, err = base64.StdEncoding.DecodeString(contentStr) if err != nil { return fmt.Errorf("Error decoding file content: %w", err) } } else { return fmt.Errorf("Invalid content format in response") } destRequest := apiclient.CopyFileRequest{ Direction: "to_space", DestPath: destSpacePath, Content: content, Workdir: workdir, } err = destWs.WriteJSON(destRequest) if err != nil { return fmt.Errorf("Error sending destination copy request: %w", err) } var destResult map[string]interface{} err = destWs.ReadJSON(&destResult) if err != nil { return fmt.Errorf("Error reading destination response: %w", err) } success, ok = destResult["success"].(bool) if !ok || !success { errorMsg, _ := destResult["error"].(string) return fmt.Errorf("Destination write failed: %s", errorMsg) } fmt.Println("Copy completed successfully") return nil } spaceId, err := findSpaceId(spaceName) if err != nil { return err } ws, err := connectToSpace(spaceId) if err != nil { return err } defer ws.Close() var copyRequest apiclient.CopyFileRequest copyRequest.Direction = direction copyRequest.Workdir = workdir if direction == "to_space" { content, err := os.ReadFile(localPath) if err != nil { return fmt.Errorf("Error reading local file: %w", err) } copyRequest.DestPath = spacePath copyRequest.Content = content fmt.Printf("Copying %s to %s:%s...\n", localPath, spaceName, spacePath) } else { copyRequest.SourcePath = spacePath fmt.Printf("Copying %s:%s to %s...\n", spaceName, spacePath, localPath) } err = ws.WriteJSON(copyRequest) if err != nil { return fmt.Errorf("Error sending copy request: %w", err) } // Read the response var result map[string]interface{} err = ws.ReadJSON(&result) if err != nil { return fmt.Errorf("Error reading response: %w", err) } success, ok := result["success"].(bool) if !ok || !success { errorMsg, _ := result["error"].(string) return fmt.Errorf("Copy failed: %s", errorMsg) } if direction == "from_space" { // Write content to local file var content []byte if contentStr, ok := result["content"].(string); ok { // Decode base64 content var err error content, err = base64.StdEncoding.DecodeString(contentStr) if err != nil { return fmt.Errorf("Error decoding file content: %w", err) } } else { return fmt.Errorf("Invalid content format in response") } localDir := filepath.Dir(localPath) if err := os.MkdirAll(localDir, 0755); err != nil { return fmt.Errorf("Error creating local directory: %w", err) } err = os.WriteFile(localPath, content, 0644) if err != nil { return fmt.Errorf("Error writing local file: %w", err) } } fmt.Println("Copy completed successfully") return nil }, }
var CreateCmd = &cli.Command{ Name: "create", Usage: "Create a space", Description: `Create a new space from the given template. The new space is not started automatically.`, Arguments: []cli.Argument{ &cli.StringArg{ Name: "space", Usage: "The name of the new space to create", Required: true, }, &cli.StringArg{ Name: "template", Usage: "The name of the template to use for the space", Required: true, }, }, MaxArgs: cli.NoArgs, Flags: []cli.Flag{ &cli.StringFlag{ Name: "shell", Usage: "The shell to use for the space (sh, bash, zsh or fish).", ConfigPath: []string{"shell"}, EnvVars: []string{config.CONFIG_ENV_PREFIX + "_SHELL"}, DefaultValue: "bash", }, &cli.StringSliceFlag{ Name: "custom-field", Usage: "Custom field as name=value (can be specified multiple times).", }, }, Run: func(ctx context.Context, cmd *cli.Command) error { shell := cmd.GetString("shell") if shell != "bash" && shell != "zsh" && shell != "fish" && shell != "sh" { return fmt.Errorf("Invalid shell: %s", shell) } customFields, err := parseCustomFields(cmd.GetStringSlice("custom-field")) if err != nil { return err } fmt.Println("Creating space: ", cmd.GetStringArg("space"), " from template: ", cmd.GetStringArg("template")) alias := cmd.GetString("alias") cfg := config.GetServerAddr(alias, cmd) client, err := apiclient.NewClient(cfg.HttpServer, cfg.ApiToken, cmd.GetBool("tls-skip-verify")) if err != nil { return fmt.Errorf("Failed to create API client: %w", err) } templates, _, err := client.GetTemplates(context.Background()) if err != nil { return fmt.Errorf("Error getting templates: %w", err) } // Find the ID of the template from the name var templateId string = "" for _, template := range templates.Templates { if template.Name == cmd.GetStringArg("template") { templateId = template.Id break } } if templateId == "" { return fmt.Errorf("Template not found: %s", cmd.GetStringArg("template")) } space := &apiclient.SpaceRequest{ Name: cmd.GetStringArg("space"), Description: "", TemplateId: templateId, Shell: shell, UserId: "", AltNames: []model.AltNameEntry{}, CustomFields: customFields, } _, _, err = client.CreateSpace(context.Background(), space) if err != nil { return fmt.Errorf("Error creating space: %w", err) } fmt.Println("Space created: ", cmd.GetStringArg("space")) return nil }, }
var DeleteCmd = &cli.Command{ Name: "delete", Usage: "Delete a space", Description: "Delete a stopped space, all data will be lost.", Arguments: []cli.Argument{ &cli.StringArg{ Name: "space", Usage: "The name of the new space to create", Required: true, }, }, MaxArgs: cli.NoArgs, Flags: []cli.Flag{ &cli.BoolFlag{ Name: "force", Aliases: []string{"f"}, Usage: "Skip confirmation prompt.", }, }, Run: func(ctx context.Context, cmd *cli.Command) error { spaceName := cmd.GetStringArg("space") if !cmd.GetBool("force") { var confirm string fmt.Printf("Are you sure you want to delete the space %s and all data? (yes/no): ", spaceName) fmt.Scanln(&confirm) if confirm != "yes" { fmt.Println("Deletion cancelled.") return nil } } alias := cmd.GetString("alias") cfg := config.GetServerAddr(alias, cmd) client, err := apiclient.NewClient(cfg.HttpServer, cfg.ApiToken, cmd.GetBool("tls-skip-verify")) if err != nil { return fmt.Errorf("Failed to create API client: %w", err) } _, err = client.DeleteSpace(context.Background(), spaceName) if err != nil { return fmt.Errorf("Error deleting space: %w", err) } fmt.Println("Space deleting: ", spaceName) return nil }, }
var DeleteFileCmd = &cli.Command{ Name: "delete-file", Usage: "Delete a file or directory in a space", Description: "Delete a file or directory in a running space. Use --recursive to remove a non-empty directory. Missing paths are treated as success (idempotent).", Flags: []cli.Flag{ &cli.BoolFlag{Name: "recursive", Aliases: []string{"r"}, Usage: "Recursively remove a directory and its contents"}, &cli.StringFlag{Name: "workdir", Aliases: []string{"w"}, Usage: "Working directory for relative paths in space", DefaultValue: ""}, }, Arguments: []cli.Argument{ &cli.StringArg{ Name: "space", Required: true, Usage: "Name or ID of the space", }, &cli.StringArg{ Name: "path", Required: true, Usage: "File or directory path in the space", }, }, MaxArgs: cli.NoArgs, Run: func(ctx context.Context, cmd *cli.Command) error { client, err := cmdutil.GetClient(cmd) if err != nil { return err } spaceId, err := resolveSpaceID(ctx, client, cmd.GetStringArg("space")) if err != nil { return err } req := apiclient.DeleteFileRequest{ Path: cmd.GetStringArg("path"), Recursive: cmd.GetBool("recursive"), Workdir: cmd.GetString("workdir"), } result, err := client.DeleteSpaceFile(ctx, spaceId, req) if err != nil { return err } if result.Removed > 0 { fmt.Printf("Removed %d entr%s\n", result.Removed, pluralize(result.Removed)) } else { fmt.Println("Path did not exist (no-op)") } return nil }, }
DeleteFileCmd removes a file or directory from a running space.
var EvalCmd = &cli.Command{ Name: "eval", Usage: "Evaluate inline Scriptling code in a space", Description: "Execute Scriptling source directly in a space without storing a named script.\n\nUsage: space eval <space-name> <code|-> [args...]\n\nPass the code as a quoted argument, or use '-' to read it from stdin. Everything after the code positional is forwarded to the script as argv.\n\nExamples:\n knot space eval web \"print('hello')\"\n knot space eval web \"print(sys.argv)\" one two\n echo 'print(1)' | knot space eval web -\n cat tuned.py | knot space eval web - --flag", MaxArgs: cli.UnlimitedArgs, Arguments: []cli.Argument{ &cli.StringArg{ Name: "space-name", Usage: "Name of the space", Required: true, }, &cli.StringArg{ Name: "code", Usage: "Scriptling source to evaluate, or '-' to read from stdin", Required: true, }, }, Run: func(ctx context.Context, cmd *cli.Command) error { client, err := cmdutil.GetClient(cmd) if err != nil { return fmt.Errorf("failed to create API client: %w", err) } client.SetTimeout(5 * time.Minute) space, err := client.GetSpaceByName(ctx, cmd.GetStringArg("space-name")) if err != nil { return fmt.Errorf("error getting space: %w", err) } code := cmd.GetStringArg("code") if code == "-" { data, err := io.ReadAll(os.Stdin) if err != nil { return fmt.Errorf("failed to read code from stdin: %w", err) } code = string(data) } if code == "" { return fmt.Errorf("no code to evaluate (pass code as an argument or pipe via '-')") } argv := append([]string{"eval"}, cmd.GetArgs()...) exitCode, err := client.ExecuteScriptContentStream(ctx, space.SpaceId, code, argv) if err != nil { return fmt.Errorf("error executing code: %w", err) } if exitCode != 0 { os.Exit(exitCode) } return nil }, }
var FindCmd = &cli.Command{ Name: "find", Usage: "Find files in a space", Description: "Find files and directories in a running space by name, type, or size. Output is one path per line by default; --long adds size, mtime, and type. Recursive by default.", Flags: []cli.Flag{ &cli.StringFlag{Name: "name", Aliases: []string{"n"}, Usage: "Shell-style glob matched against the base name, e.g. '*.md'"}, &cli.StringFlag{Name: "type", Aliases: []string{"t"}, Usage: "Restrict to 'file', 'dir', or 'any' (default 'any')"}, &cli.BoolFlag{Name: "recursive", DefaultValue: true, Usage: "Descend into subdirectories (default true)"}, &cli.BoolFlag{Name: "include-hidden", Usage: "Match entries whose name starts with '.'"}, &cli.IntFlag{Name: "max-depth", Usage: "Maximum recursion depth (0 = unlimited)"}, &cli.IntFlag{Name: "size-min", Usage: "Minimum size in bytes"}, &cli.IntFlag{Name: "size-max", Usage: "Maximum size in bytes"}, &cli.BoolFlag{Name: "long", Aliases: []string{"l"}, Usage: "List size, mtime, and type alongside each path"}, &cli.BoolFlag{Name: "json", Usage: "Emit the raw structured result as JSON"}, }, Arguments: []cli.Argument{ &cli.StringArg{Name: "space", Required: true, Usage: "Name or ID of the space"}, &cli.StringArg{Name: "path", Required: false, Usage: "Directory to search under (default: current directory)"}, }, MaxArgs: cli.NoArgs, Run: func(ctx context.Context, cmd *cli.Command) error { client, err := cmdutil.GetClient(cmd) if err != nil { return err } spaceId, err := resolveSpaceID(ctx, client, cmd.GetStringArg("space")) if err != nil { return err } path := cmd.GetStringArg("path") if path == "" { path = "." } long := cmd.GetBool("long") req := apiclient.FindRequest{ Path: path, Recursive: cmd.GetBool("recursive"), Type: cmd.GetString("type"), Name: cmd.GetString("name"), IncludeHidden: cmd.GetBool("include-hidden"), IncludeMetadata: long, MaxDepth: cmd.GetInt("max-depth"), } if n := int64(cmd.GetInt("size-min")); n != 0 { req.SizeMin = &n } if n := int64(cmd.GetInt("size-max")); n != 0 { req.SizeMax = &n } result, err := client.Find(ctx, spaceId, req) if err != nil { return err } if cmd.GetBool("json") { return printJSON(result) } if long { for _, e := range result.Entries { kind := "f" if e.IsDir { kind = "d" } fmt.Printf("%s %12d %s %s\n", kind, e.Size, time.Unix(0, int64(e.Mtime*1e9)).UTC().Format("2006-01-02 15:04:05"), e.Path) } return nil } for _, p := range result.Paths { fmt.Println(p) } return nil }, }
FindCmd finds files/directories in a space.
var GetFieldCmd = &cli.Command{ Name: "get-field", Usage: "Get a custom field from a space", Description: "Get a custom field value from an existing space.", Arguments: []cli.Argument{ &cli.StringArg{ Name: "space", Usage: "The name of the space to get the field from", Required: true, }, &cli.StringArg{ Name: "field", Usage: "The name of the custom field to get", Required: true, }, }, MaxArgs: cli.NoArgs, Run: func(ctx context.Context, cmd *cli.Command) error { spaceName := cmd.GetStringArg("space") fieldName := cmd.GetStringArg("field") client, err := cmdutil.GetClient(cmd) if err != nil { return fmt.Errorf("Failed to create API client: %w", err) } response, _, err := client.GetSpaceCustomField(context.Background(), spaceName, fieldName) if err != nil { return fmt.Errorf("Error getting custom field: %w", err) } fmt.Println(response.Value) return nil }, }
var GrepCmd = &cli.Command{ Name: "grep", Usage: "Search file contents in a space", Description: "Search for a pattern in files inside a running space. Output is one match per line as 'file:line: text'. Use --json for structured output.", Flags: []cli.Flag{ &cli.BoolFlag{Name: "ignore-case", Aliases: []string{"i"}, Usage: "Case-insensitive matching"}, &cli.BoolFlag{Name: "literal", Usage: "Treat PATTERN as a literal string, not a regex"}, &cli.BoolFlag{Name: "recursive", Aliases: []string{"r"}, Usage: "Recurse into subdirectories"}, &cli.StringFlag{Name: "glob", Usage: "Only search files matching this glob, e.g. '*.py'"}, &cli.BoolFlag{Name: "json", Usage: "Emit the raw structured result as JSON"}, }, Arguments: []cli.Argument{ &cli.StringArg{Name: "space", Required: true, Usage: "Name or ID of the space"}, &cli.StringArg{Name: "pattern", Required: true, Usage: "Regular expression (or literal with --literal)"}, &cli.StringArg{Name: "path", Required: false, Usage: "File or directory to search (default: current directory)"}, }, MaxArgs: cli.NoArgs, Run: func(ctx context.Context, cmd *cli.Command) error { client, err := cmdutil.GetClient(cmd) if err != nil { return err } spaceId, err := resolveSpaceID(ctx, client, cmd.GetStringArg("space")) if err != nil { return err } path := cmd.GetStringArg("path") if path == "" { path = "." } result, err := client.Grep(ctx, spaceId, apiclient.GrepRequest{ Pattern: cmd.GetStringArg("pattern"), Path: path, Literal: cmd.GetBool("literal"), Recursive: cmd.GetBool("recursive"), IgnoreCase: cmd.GetBool("ignore-case"), Glob: cmd.GetString("glob"), }) if err != nil { return err } if cmd.GetBool("json") { return printJSON(result) } for _, m := range result.Matches { fmt.Printf("%s:%d: %s\n", m.File, m.Line, m.Text) } return nil }, }
GrepCmd searches file contents in a space.
var ListCmd = &cli.Command{ Name: "list", Usage: "List the available spaces and their status", Description: "Lists the available spaces for the logged in user, grouped by stack and pool.", MaxArgs: cli.NoArgs, Flags: []cli.Flag{ &cli.BoolFlag{ Name: "all-zones", Usage: "Include spaces from all zones, not just the current server's zone", }, }, Run: func(ctx context.Context, cmd *cli.Command) error { client, err := cmdutil.GetClient(cmd) if err != nil { fmt.Println("Failed to create API client:", err) os.Exit(1) } allZones := cmd.GetBool("all-zones") pingResponse, err := client.Ping(context.Background()) if err != nil { fmt.Println("Error getting server info:", err) os.Exit(1) } zone := pingResponse.Zone user, err := client.WhoAmI(context.Background()) if err != nil { fmt.Println("Error getting user: ", err) return nil } spaces, _, err := client.GetSpaces(context.Background(), user.Id, allZones) if err != nil { fmt.Println("Error getting spaces: ", err) return nil } poolNames := map[string]string{} if poolList, _, err := client.GetPools(context.Background()); err == nil && poolList != nil { for _, pool := range poolList.Pools { poolNames[pool.Id] = pool.Name } } // Partition into regular, stacked, and pooled var regular []spaceRow stackMap := map[string][]spaceRow{} poolMap := map[string][]spaceRow{} var stackOrder, poolOrder []string seenStack := map[string]bool{} seenPool := map[string]bool{} for _, space := range spaces.Spaces { if !allZones && zone != "" && space.Zone != "" && space.Zone != zone { continue } row := spaceRow{ Name: space.Name, TemplateName: space.TemplateName, Zone: space.Zone, Status: spaceStatus(space.IsRemote, space.IsDeployed, space.IsPending, space.IsDeleting), Ports: spacePorts(space.HttpPorts, space.TcpPorts), } if space.PoolId != "" { if !seenPool[space.PoolId] { seenPool[space.PoolId] = true poolOrder = append(poolOrder, space.PoolId) } poolMap[space.PoolId] = append(poolMap[space.PoolId], row) } else if space.Stack != "" { if !seenStack[space.Stack] { seenStack[space.Stack] = true stackOrder = append(stackOrder, space.Stack) } stackMap[space.Stack] = append(stackMap[space.Stack], row) } else { regular = append(regular, row) } } if len(regular) > 0 { printSpaceTable("Spaces", regular) } for _, stack := range stackOrder { printSpaceTable("Stack: "+stack, stackMap[stack]) } for _, poolID := range poolOrder { label := poolNames[poolID] if label == "" { label = poolID } printSpaceTable("Pool: "+label, poolMap[poolID]) } if len(regular) == 0 && len(stackOrder) == 0 && len(poolOrder) == 0 { fmt.Println("No spaces found") } return nil }, }
var LogsCmd = &cli.Command{ Name: "logs", Usage: "Show the logs from a space", Description: "Display the logs for a space.", Arguments: []cli.Argument{ &cli.StringArg{ Name: "space", Usage: "The name of the space to show logs for", Required: true, }, }, MaxArgs: cli.NoArgs, Flags: []cli.Flag{ &cli.BoolFlag{ Name: "follow", Aliases: []string{"f"}, Usage: "Follow the logs.", DefaultValue: false, }, }, Run: func(ctx context.Context, cmd *cli.Command) error { spaceName := cmd.GetStringArg("space") follow := cmd.GetBool("follow") client, err := cmdutil.GetClient(cmd) if err != nil { return fmt.Errorf("Failed to create API client: %w", err) } baseURL := client.GetBaseURL() token := client.GetAuthToken() wsURL := "ws" + baseURL[4:] + fmt.Sprintf("/logs/%s/stream", spaceName) header := http.Header{"Authorization": []string{fmt.Sprintf("Bearer %s", token)}} dialer := websocket.DefaultDialer dialer.TLSClientConfig = &tls.Config{InsecureSkipVerify: cmd.GetBool("tls-skip-verify")} dialer.HandshakeTimeout = 5 * time.Second ws, response, err := dialer.Dial(wsURL, header) if err != nil { if response != nil && response.StatusCode == http.StatusUnauthorized { return fmt.Errorf("failed to authenticate with server, check remote token") } else if response != nil && response.StatusCode == http.StatusForbidden { return fmt.Errorf("no permission to view logs") } return fmt.Errorf("Error connecting to websocket: %w", err) } defer ws.Close() for { _, message, err := ws.ReadMessage() if err != nil { fmt.Println("Error reading message: ", err) break } if len(message) == 1 && message[0] == 0 && !follow { break } fmt.Print(string(message)) } return nil }, }
var MirrorCmd = &cli.Command{ Name: "mirror", Usage: "Mirror a local directory to a space", Description: "Upload <local folder> to <space>:<path>, then delete remote files that don't exist locally. The destination ends up as a mirror of the source. With --watch, keeps running and syncs local changes live. For one-shot upload without deletes, use `knot space copy` per-file.", Flags: []cli.Flag{ &cli.StringSliceFlag{ Name: "exclude", Aliases: []string{"x"}, Usage: "Glob patterns to skip (e.g. node_modules, *.log). Repeatable.", }, &cli.IntFlag{ Name: "parallel", Usage: "Concurrent upload workers (default 8)", DefaultValue: 8, }, &cli.BoolFlag{ Name: "dry-run", Usage: "List what would be uploaded/deleted without performing any I/O", }, &cli.BoolFlag{ Name: "debug", Usage: "Log why each file was uploaded (new / size differs / mtime drift) — for diagnosing idempotence issues", }, &cli.BoolFlag{ Name: "verbose", Aliases: []string{"v"}, Usage: "Print every upload and delete as it happens (default: summary only)", }, &cli.BoolFlag{ Name: "verify", Usage: "Compare local and remote hashes without uploading or deleting; report any mismatches", }, &cli.BoolFlag{ Name: "hash", Usage: "Use crc64 hash comparison instead of mtime+size (slower but definitive — catches content drift that mtime misses)", }, &cli.BoolFlag{ Name: "watch", Usage: "After the initial mirror, watch for local changes and sync them live (one-way: local → space). Ctrl+C to stop", }, }, Arguments: []cli.Argument{ &cli.StringArg{ Name: "local", Required: true, Usage: "Local folder to mirror", }, &cli.StringArg{ Name: "remote", Required: true, Usage: "Destination in the form 'space:path'", }, }, MaxArgs: cli.NoArgs, Run: func(ctx context.Context, cmd *cli.Command) error { local := cmd.GetStringArg("local") remote := cmd.GetStringArg("remote") colon := strings.Index(remote, ":") if colon <= 1 { return fmt.Errorf("remote must be in the form 'space:path'") } spaceName := remote[:colon] remoteDir := remote[colon+1:] if remoteDir == "" { return fmt.Errorf("remote space path cannot be empty after '%s:'", spaceName) } info, err := os.Stat(local) if err != nil { return fmt.Errorf("stat local: %w", err) } if !info.IsDir() { return fmt.Errorf("local path must be a directory (got %q)", local) } client, err := cmdutil.GetClient(cmd) if err != nil { return err } spaceID, err := resolveSpaceID(ctx, client, spaceName) if err != nil { return err } client.SetTimeout(0) defer client.SetTimeout(10 * time.Second) opts := mirrorOptions{ client: client, spaceID: spaceID, spaceName: spaceName, localRoot: local, remoteDir: path.Clean(remoteDir), excludes: cmd.GetStringSlice("exclude"), parallel: cmd.GetInt("parallel"), dryRun: cmd.GetBool("dry-run"), debug: cmd.GetBool("debug"), verbose: cmd.GetBool("verbose") || cmd.GetBool("debug"), verify: cmd.GetBool("verify"), hash: cmd.GetBool("hash") || cmd.GetBool("verify"), watch: cmd.GetBool("watch"), } if opts.parallel < 1 { opts.parallel = 1 } if opts.parallel > 32 { fmt.Fprintf(os.Stderr, "Note: --parallel %d capped to 32 (server connection limit)\n", opts.parallel) opts.parallel = 32 } if opts.verify { fmt.Fprintf(os.Stderr, "Verifying %s ↔ %s:%s\n", local, spaceName, remoteDir) } else { fmt.Fprintf(os.Stderr, "Mirroring %s → %s:%s\n", local, spaceName, remoteDir) } if opts.watch { return opts.runWatch(ctx) } start := time.Now() stats, err := opts.run(ctx) if err != nil { return err } if !opts.verify { fmt.Fprintf(os.Stderr, "Done in %s\n", stats.String(time.Since(start))) } return nil }, }
MirrorCmd mirrors a local directory tree to a space. Source is local, destination is <space>:<path>. Uploads every file in the tree (preserving each file's mtime and permission bits) and deletes any remote file that doesn't exist locally — the destination ends up as a mirror of the source.
knot space mirror ./src myspace:/var/www/html
For one-way upload without deletes, use knot space copy per-file or write your own loop. For continuous two-way sync, mutagen against the space's SSH endpoint is the recommendation; mirror is designed for one-shot publishing of a tree.
var PortCmd = &cli.Command{ Name: "port", Usage: "Manage a space's port forwards", Description: `Manage port forwards from a space to other spaces.`, MaxArgs: cli.NoArgs, Commands: []*cli.Command{ PortForwardCmd, PortListCmd, PortStopCmd, PortThrottleCmd, }, }
PortCmd is the `knot space port` group: remote control of a space's inter-space port forwards (forward/list/stop), driven via the server relay (/space-io/{space}/port/*).
var PortForwardCmd = &cli.Command{ Name: "forward", Usage: "Forward a port from one space to another", Description: "Forward a port from one space to a port in another space.", Arguments: []cli.Argument{ &cli.StringArg{ Name: "from-space", Usage: "The name of the source space", Required: true, }, &cli.IntArg{ Name: "from-port", Usage: "The port in the source space to forward from", Required: true, }, &cli.StringArg{ Name: "to-space", Usage: "The name of the target space", Required: true, }, &cli.IntArg{ Name: "to-port", Usage: "The port in the target space to forward to", Required: true, }, }, Flags: []cli.Flag{ &cli.BoolFlag{ Name: "persistent", Aliases: []string{"p"}, Usage: "Persist the port forward across agent restarts", }, &cli.BoolFlag{ Name: "force", Aliases: []string{"f"}, Usage: "Create the forward even if the target space is not currently running", }, }, MaxArgs: cli.NoArgs, Run: func(ctx context.Context, cmd *cli.Command) error { fromSpace := cmd.GetStringArg("from-space") fromPort := cmd.GetIntArg("from-port") toSpace := cmd.GetStringArg("to-space") toPort := cmd.GetIntArg("to-port") if fromPort < 1 || fromPort > 65535 { return fmt.Errorf("invalid from-port: must be between 1 and 65535") } if toPort < 1 || toPort > 65535 { return fmt.Errorf("invalid to-port: must be between 1 and 65535") } client, err := cmdutil.GetClient(cmd) if err != nil { return fmt.Errorf("failed to create API client: %w", err) } spaces, _, err := client.GetSpaces(ctx, "", false) if err != nil { return fmt.Errorf("failed to get spaces: %w", err) } var fromSpaceInfo *apiclient.SpaceInfo for i := range spaces.Spaces { if spaces.Spaces[i].Name == fromSpace { fromSpaceInfo = &spaces.Spaces[i] break } } if fromSpaceInfo == nil { return fmt.Errorf("space '%s' not found", fromSpace) } if !fromSpaceInfo.IsDeployed || !fromSpaceInfo.HasState { if !cmd.GetBool("persistent") { return fmt.Errorf("space '%s' is not running", fromSpace) } } if !cmd.GetBool("force") { var toSpaceInfo *apiclient.SpaceInfo for i := range spaces.Spaces { if spaces.Spaces[i].Name == toSpace { toSpaceInfo = &spaces.Spaces[i] break } } if toSpaceInfo == nil { return fmt.Errorf("space '%s' not found", toSpace) } if !toSpaceInfo.IsDeployed || !toSpaceInfo.HasState { return fmt.Errorf("space '%s' is not running", toSpace) } } spaceId := fromSpaceInfo.Id // Resolve target space name to ID var toSpaceId string for i := range spaces.Spaces { if spaces.Spaces[i].Name == toSpace { toSpaceId = spaces.Spaces[i].Id break } } if toSpaceId == "" { return fmt.Errorf("space '%s' not found", toSpace) } force := cmd.GetBool("force") request := &apiclient.PortForwardRequest{ LocalPort: uint16(fromPort), Space: toSpaceId, RemotePort: uint16(toPort), Persistent: cmd.GetBool("persistent"), Force: force, } code, err := client.ForwardPort(ctx, spaceId, request) if err != nil { if code == 401 { return fmt.Errorf("failed to authenticate with server, check token") } else if code == 403 { return fmt.Errorf("no permission to forward ports") } else if code == 404 { return fmt.Errorf("space not found") } else if code == 409 { return fmt.Errorf("space is not running, only persistent forwards can be created for stopped spaces") } return fmt.Errorf("port forward failed: %w", err) } fmt.Printf("Port forward established: %s:%d -> %s:%d\n", fromSpace, fromPort, toSpace, toPort) return nil }, }
var PortListCmd = &cli.Command{ Name: "list", Usage: "List active port forwards for a space", Description: "List all active port forwards from a space.", Arguments: []cli.Argument{ &cli.StringArg{ Name: "space", Usage: "The name of the space", Required: true, }, }, MaxArgs: cli.NoArgs, Run: func(ctx context.Context, cmd *cli.Command) error { spaceName := cmd.GetStringArg("space") client, err := cmdutil.GetClient(cmd) if err != nil { return fmt.Errorf("failed to create API client: %w", err) } spaces, _, err := client.GetSpaces(ctx, "", false) if err != nil { return fmt.Errorf("failed to get spaces: %w", err) } var spaceId string for _, s := range spaces.Spaces { if s.Name == spaceName { spaceId = s.Id break } } if spaceId == "" { return fmt.Errorf("space '%s' not found", spaceName) } response, code, err := client.ListPorts(ctx, spaceId) if err != nil { if code == 401 { return fmt.Errorf("failed to authenticate with server, check token") } else if code == 403 { return fmt.Errorf("no permission to list port forwards") } else if code == 404 { return fmt.Errorf("space not found") } return fmt.Errorf("failed to list port forwards: %w", err) } if len(response.Forwards) == 0 { fmt.Printf("No active port forwards in space '%s'.\n", spaceName) return nil } spaceNames := make(map[string]string, len(spaces.Spaces)) for _, s := range spaces.Spaces { spaceNames[s.Id] = s.Name } fmt.Printf("Active port forwards in space '%s':\n", spaceName) for _, fwd := range response.Forwards { persist := "temporary" if fwd.Persistent { persist = "persistent" } mode := fwd.Mode if mode == "" { mode = "relay" } target := fwd.Space if name, ok := spaceNames[fwd.Space]; ok { target = name } line := fmt.Sprintf(" %d -> %s:%d (%s, %s", fwd.LocalPort, target, fwd.RemotePort, persist, mode) // Throttle info var throttle []string if fwd.LatencyMs > 0 { t := fmt.Sprintf("%dms", fwd.LatencyMs) if fwd.JitterMs > 0 { t += fmt.Sprintf(" ±%dms", fwd.JitterMs) } throttle = append(throttle, t) } if fwd.BandwidthKB > 0 { throttle = append(throttle, fmt.Sprintf("%dKB/s", fwd.BandwidthKB)) } if fwd.Down { throttle = append(throttle, "down") } if fwd.TimeoutMs > 0 { throttle = append(throttle, fmt.Sprintf("timeout=%dms", fwd.TimeoutMs)) } if len(throttle) > 0 { line += ", " + strings.Join(throttle, " ") } line += ")" fmt.Println(line) } return nil }, }
var PortStopCmd = &cli.Command{ Name: "stop", Usage: "Stop a port forward", Description: "Stop an active port forward by local port number.", Arguments: []cli.Argument{ &cli.StringArg{ Name: "space", Usage: "The name of the space", Required: true, }, &cli.IntArg{ Name: "local-port", Usage: "The local port to stop forwarding", Required: true, }, }, MaxArgs: cli.NoArgs, Run: func(ctx context.Context, cmd *cli.Command) error { spaceName := cmd.GetStringArg("space") localPort := cmd.GetIntArg("local-port") if localPort < 1 || localPort > 65535 { return fmt.Errorf("invalid local-port: must be between 1 and 65535") } client, err := cmdutil.GetClient(cmd) if err != nil { return fmt.Errorf("failed to create API client: %w", err) } spaces, _, err := client.GetSpaces(ctx, "", false) if err != nil { return fmt.Errorf("failed to get spaces: %w", err) } var spaceId string for _, s := range spaces.Spaces { if s.Name == spaceName { spaceId = s.Id break } } if spaceId == "" { return fmt.Errorf("space '%s' not found", spaceName) } request := &apiclient.PortStopRequest{ LocalPort: uint16(localPort), } code, err := client.StopPort(ctx, spaceId, request) if err != nil { if code == 401 { return fmt.Errorf("failed to authenticate with server, check token") } else if code == 403 { return fmt.Errorf("no permission to stop port forwards") } else if code == 404 { return fmt.Errorf("space not found") } return fmt.Errorf("failed to stop port forward: %w", err) } fmt.Printf("Port forward on port %d stopped in space '%s'.\n", localPort, spaceName) return nil }, }
var PortThrottleCmd = &cli.Command{ Name: "throttle", Usage: "Add latency, jitter, and/or bandwidth limits to a port forward", Description: "Apply network simulation to an existing port forward in a space. All values are optional; pass --reset to clear all limits.", Arguments: []cli.Argument{ &cli.StringArg{ Name: "space", Usage: "The name of the space", Required: true, }, &cli.IntArg{ Name: "local-port", Usage: "The local port of the forward to throttle", Required: true, }, }, Flags: []cli.Flag{ &cli.StringFlag{ Name: "latency", Usage: "Latency in milliseconds (e.g. 50, 200ms)", }, &cli.StringFlag{ Name: "jitter", Usage: "Jitter in milliseconds (e.g. 10, 50ms)", }, &cli.StringFlag{ Name: "bandwidth", Usage: "Bandwidth limit in KB/s (e.g. 100, 1024)", }, &cli.StringFlag{ Name: "timeout", Usage: "Connection timeout in milliseconds (e.g. 5000) — kills the connection after this duration", }, &cli.BoolFlag{ Name: "down", Usage: "Block all traffic on this forward (port definition stays)", }, &cli.BoolFlag{ Name: "reset", Usage: "Clear all throttle settings", }, }, MaxArgs: cli.NoArgs, Run: func(ctx context.Context, cmd *cli.Command) error { spaceName := cmd.GetStringArg("space") localPort := cmd.GetIntArg("local-port") if localPort < 1 || localPort > 65535 { return fmt.Errorf("invalid local port, must be between 1 and 65535") } client, err := cmdutil.GetClient(cmd) if err != nil { return fmt.Errorf("failed to create API client: %w", err) } spaces, _, err := client.GetSpaces(ctx, "", false) if err != nil { return fmt.Errorf("failed to get spaces: %w", err) } var spaceId string for _, s := range spaces.Spaces { if s.Name == spaceName { spaceId = s.Id break } } if spaceId == "" { return fmt.Errorf("space '%s' not found", spaceName) } request := apiclient.PortThrottleRequest{ LocalPort: uint16(localPort), Reset: cmd.GetBool("reset"), } if !request.Reset { if v := cmd.GetString("latency"); v != "" { ms, err := parseMsVal(v) if err != nil { return fmt.Errorf("invalid latency: %w", err) } request.LatencyMs = ms } if v := cmd.GetString("jitter"); v != "" { ms, err := parseMsVal(v) if err != nil { return fmt.Errorf("invalid jitter: %w", err) } request.JitterMs = ms } if v := cmd.GetString("bandwidth"); v != "" { kb, err := strconv.Atoi(v) if err != nil || kb <= 0 { return fmt.Errorf("invalid bandwidth, must be a positive number in KB/s") } request.BandwidthKB = kb } if v := cmd.GetString("timeout"); v != "" { ms, err := parseMsVal(v) if err != nil { return fmt.Errorf("invalid timeout: %w", err) } request.TimeoutMs = ms } request.Down = cmd.GetBool("down") } code, err := client.ThrottlePort(ctx, spaceId, &request) if err != nil { if code == 401 { return fmt.Errorf("failed to authenticate with server, check token") } else if code == 403 { return fmt.Errorf("no permission to throttle port forwards") } else if code == 404 { return fmt.Errorf("space not found") } else if code == 409 { return fmt.Errorf("space is not running") } return fmt.Errorf("failed to set throttle: %w", err) } if request.Reset { fmt.Printf("Throttle cleared for port %d in space '%s'\n", localPort, spaceName) } else { parts := []string{} if request.LatencyMs > 0 { parts = append(parts, fmt.Sprintf("latency=%dms", request.LatencyMs)) } if request.JitterMs > 0 { parts = append(parts, fmt.Sprintf("jitter=%dms", request.JitterMs)) } if request.BandwidthKB > 0 { parts = append(parts, fmt.Sprintf("bandwidth=%dKB/s", request.BandwidthKB)) } if len(parts) == 0 { parts = []string{"no limits set"} } fmt.Printf("Throttle set for port %d in space '%s': %s\n", localPort, spaceName, strings.Join(parts, ", ")) } return nil }, }
var ReadFileCmd = &cli.Command{ Name: "read-file", Usage: "Read a file from a space", Description: "Read file contents from a running space. Use --offset and --limit to read a 1-based line range.", Flags: []cli.Flag{ &cli.IntFlag{Name: "offset", Usage: "1-based line number to start at (0 = from the beginning)"}, &cli.IntFlag{Name: "limit", Usage: "Maximum lines to return (0 = whole file)"}, }, Arguments: []cli.Argument{ &cli.StringArg{ Name: "space", Required: true, Usage: "Name or ID of the space", }, &cli.StringArg{ Name: "path", Required: true, Usage: "File path in the space", }, }, MaxArgs: cli.NoArgs, Run: func(ctx context.Context, cmd *cli.Command) error { spaceName := cmd.GetStringArg("space") filePath := cmd.GetStringArg("path") offset := cmd.GetInt("offset") limit := cmd.GetInt("limit") alias := cmd.GetString("alias") cfg := config.GetServerAddr(alias, cmd) client, err := apiclient.NewClient(cfg.HttpServer, cfg.ApiToken, cmd.GetBool("tls-skip-verify")) if err != nil { return fmt.Errorf("Failed to create API client: %w", err) } user, err := client.WhoAmI(context.Background()) if err != nil { return fmt.Errorf("Error getting user: %w", err) } spaces, _, err := client.GetSpaces(context.Background(), user.Id, false) if err != nil { return fmt.Errorf("Error getting spaces: %w", err) } var spaceId string for _, space := range spaces.Spaces { if space.Name == spaceName || space.Id == spaceName { spaceId = space.Id break } } if spaceId == "" { return fmt.Errorf("Space not found: %s", spaceName) } content, totalLines, err := client.ReadSpaceFileRange(context.Background(), spaceId, filePath, offset, limit) if err != nil { return fmt.Errorf("Error reading file: %w", err) } fmt.Print(content) if offset > 0 || limit > 0 { fmt.Fprintf(os.Stderr, "%d lines (of %d total)\n", strings.Count(content, "\n")+1, totalLines) } return nil }, }
var RestartCmd = &cli.Command{ Name: "restart", Usage: "Restart a space", Description: "Restart the named space.", Arguments: []cli.Argument{ &cli.StringArg{ Name: "space", Usage: "The name of the space to restart", Required: true, }, }, MaxArgs: cli.NoArgs, Run: func(ctx context.Context, cmd *cli.Command) error { spaceName := cmd.GetStringArg("space") fmt.Println("Restarting space: ", spaceName) client, err := cmdutil.GetClient(cmd) if err != nil { return fmt.Errorf("Failed to create API client: %w", err) } _, err = client.RestartSpace(context.Background(), spaceName) if err != nil { return fmt.Errorf("Error restarting space: %w", err) } fmt.Println("Space restarting: ", spaceName) return nil }, }
var RunCmd = &cli.Command{ Name: "run", Usage: "Run a command in a space", Description: "Execute a command within a running space and stream the output.", Flags: []cli.Flag{ &cli.IntFlag{ Name: "timeout", Aliases: []string{"t"}, Usage: "Command timeout in seconds", DefaultValue: 30, }, &cli.StringFlag{ Name: "workdir", Aliases: []string{"w"}, Usage: "Working directory for the command", DefaultValue: "", }, }, Arguments: []cli.Argument{ &cli.StringArg{ Name: "space", Required: true, Usage: "The name of the space to run the command in", }, &cli.StringArg{ Name: "command", Required: true, Usage: "The command to run in the space", }, }, MinArgs: cli.NoArgs, MaxArgs: cli.UnlimitedArgs, Run: func(ctx context.Context, cmd *cli.Command) error { timeout := cmd.GetInt("timeout") workdir := cmd.GetString("workdir") spaceName := cmd.GetStringArg("space") command := cmd.GetStringArg("command") client, err := cmdutil.GetClient(cmd) if err != nil { return fmt.Errorf("Failed to create API client: %w", err) } user, err := client.WhoAmI(context.Background()) if err != nil { return fmt.Errorf("Error getting user: %w", err) } spaces, _, err := client.GetSpaces(context.Background(), user.Id, false) if err != nil { return fmt.Errorf("Error getting spaces: %w", err) } // Find the space by name var spaceId string for _, space := range spaces.Spaces { if space.Name == spaceName { spaceId = space.Id break } } if spaceId == "" { return fmt.Errorf("Space not found: %s", spaceName) } baseURL := client.GetBaseURL() token := client.GetAuthToken() wsURL := "ws" + baseURL[4:] + fmt.Sprintf("/space-io/%s/run", spaceId) header := http.Header{ "Authorization": []string{fmt.Sprintf("Bearer %s", token)}, } dialer := websocket.DefaultDialer dialer.TLSClientConfig = &tls.Config{InsecureSkipVerify: cmd.GetBool("tls-skip-verify")} dialer.HandshakeTimeout = 5 * time.Second ws, response, err := dialer.Dial(wsURL, header) if err != nil { if response != nil && response.StatusCode == http.StatusUnauthorized { return fmt.Errorf("failed to authenticate with server, check remote token") } else if response != nil && response.StatusCode == http.StatusForbidden { return fmt.Errorf("no permission to run commands in this space") } return fmt.Errorf("Error connecting to websocket: %w", err) } defer ws.Close() execRequest := apiclient.RunCommandRequest{ Command: command, Args: cmd.GetArgs(), Timeout: timeout, Workdir: workdir, } err = ws.WriteJSON(execRequest) if err != nil { return fmt.Errorf("Error sending command: %w", err) } for { _, message, err := ws.ReadMessage() if err != nil { if websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) { break } return fmt.Errorf("Error reading message: %w", err) } if len(message) == 1 && message[0] == 0 { break } fmt.Print(string(message)) } return nil }, }
var RunScriptCmd = &cli.Command{ Name: "run-script", Usage: "Run a script in a space", Description: "Execute a named script or local script file in a space. Usage: space run-script <space-name> <script-name-or-file> [args...]", MaxArgs: cli.UnlimitedArgs, Arguments: []cli.Argument{ &cli.StringArg{ Name: "space-name", Usage: "Name of the space", Required: true, }, &cli.StringArg{ Name: "script", Usage: "Name of script or path to .py file", Required: true, }, }, Run: func(ctx context.Context, cmd *cli.Command) error { client, err := cmdutil.GetClient(cmd) if err != nil { return fmt.Errorf("failed to create API client: %w", err) } client.SetTimeout(5 * time.Minute) space, err := client.GetSpaceByName(ctx, cmd.GetStringArg("space-name")) if err != nil { return fmt.Errorf("error getting space: %w", err) } scriptArg := cmd.GetStringArg("script") args := cmd.GetArgs() argv := append([]string{scriptArg}, args...) if _, err := os.Stat(scriptArg); err == nil { content, err := os.ReadFile(scriptArg) if err != nil { return fmt.Errorf("failed to read script file: %w", err) } exitCode, err := client.ExecuteScriptContentStream(ctx, space.SpaceId, string(content), argv) if err != nil { return fmt.Errorf("error executing script: %w", err) } if exitCode != 0 { os.Exit(exitCode) } } else { exitCode, err := client.ExecuteScriptStream(ctx, space.SpaceId, scriptArg, argv) if err != nil { return fmt.Errorf("error executing script: %w", err) } if exitCode != 0 { os.Exit(exitCode) } } return nil }, }
var SedCmd = &cli.Command{ Name: "sed", Usage: "In-place edit or extract from files in a space", Description: "By default performs a literal string replacement (s/OLD/NEW/). Pass --regex to treat OLD as a regular expression (capture groups ${1}, ${name} allowed in NEW). Pass --extract to return capture groups of a regex instead of modifying files.", Flags: []cli.Flag{ &cli.BoolFlag{Name: "regex", Usage: "Treat OLD/PATTERN as a regular expression"}, &cli.BoolFlag{Name: "extract", Usage: "Extract capture groups from PATTERN (no modification); NEW is ignored"}, &cli.BoolFlag{Name: "ignore-case", Aliases: []string{"i"}, Usage: "Case-insensitive matching"}, &cli.BoolFlag{Name: "recursive", Aliases: []string{"r"}, Usage: "Recurse into subdirectories when PATH is a directory"}, &cli.StringFlag{Name: "glob", Usage: "Only touch files matching this glob, e.g. '*.py'"}, &cli.BoolFlag{Name: "json", Usage: "Emit the raw structured result as JSON"}, }, Arguments: []cli.Argument{ &cli.StringArg{Name: "space", Required: true, Usage: "Name or ID of the space"}, &cli.StringArg{Name: "old-or-pattern", Required: true, Usage: "Literal string to replace (or regex with --regex/--extract)"}, &cli.StringArg{Name: "new", Required: false, Usage: "Replacement string (required unless --extract)"}, &cli.StringArg{Name: "path", Required: false, Usage: "File or directory (default: current directory)"}, }, MaxArgs: cli.NoArgs, Run: func(ctx context.Context, cmd *cli.Command) error { client, err := cmdutil.GetClient(cmd) if err != nil { return err } spaceId, err := resolveSpaceID(ctx, client, cmd.GetStringArg("space")) if err != nil { return err } oldOrPattern := cmd.GetStringArg("old-or-pattern") new := cmd.GetStringArg("new") path := cmd.GetStringArg("path") if path == "" { path = "." } req := apiclient.SedRequest{ Pattern: oldOrPattern, Path: path, Recursive: cmd.GetBool("recursive"), IgnoreCase: cmd.GetBool("ignore-case"), Glob: cmd.GetString("glob"), } switch { case cmd.GetBool("extract"): req.Mode = "extract" case cmd.GetBool("regex"): if new == "" { return fmt.Errorf("--regex requires a NEW replacement argument") } req.Mode = "replace_pattern" req.Replacement = new default: if new == "" { return fmt.Errorf("sed replace requires OLD and NEW arguments") } req.Mode = "replace" req.Replacement = new } result, err := client.Sed(ctx, spaceId, req) if err != nil { return err } if cmd.GetBool("json") { return printJSON(result) } if req.Mode == "extract" { for _, m := range result.Matches { fmt.Printf("%s:%d: %s\n", m.File, m.Line, m.Text) if len(m.Groups) > 0 { fmt.Printf(" groups: %v\n", m.Groups) } } } else { fmt.Printf("%s file(s) modified\n", strconv.FormatInt(result.FilesModified, 10)) } return nil }, }
SedCmd performs in-place edits or capture extraction in a space.
var SetFieldCmd = &cli.Command{ Name: "set-field", Usage: "Set a custom field on a space", Description: "Set or update a custom field value on an existing space.", Arguments: []cli.Argument{ &cli.StringArg{ Name: "space", Usage: "The name of the space to update", Required: true, }, &cli.StringArg{ Name: "field", Usage: "The name of the custom field to set", Required: true, }, &cli.StringArg{ Name: "value", Usage: "The value to set for the custom field", Required: true, }, }, MaxArgs: cli.NoArgs, Run: func(ctx context.Context, cmd *cli.Command) error { spaceName := cmd.GetStringArg("space") fieldName := cmd.GetStringArg("field") fieldValue := cmd.GetStringArg("value") client, err := cmdutil.GetClient(cmd) if err != nil { return fmt.Errorf("Failed to create API client: %w", err) } _, err = client.SetSpaceCustomField(context.Background(), spaceName, fieldName, fieldValue) if err != nil { return fmt.Errorf("Error setting custom field: %w", err) } fmt.Printf("Custom field '%s' set to '%s' on space '%s'\n", fieldName, fieldValue, spaceName) return nil }, }
var SpacesCmd = &cli.Command{ Name: "space", Usage: "Manage spaces", Description: "Manage your spaces from the command line.", Flags: []cli.Flag{ &cli.StringFlag{ Name: "server", Aliases: []string{"s"}, Usage: "The address of the remote server to manage spaces on.", EnvVars: []string{config.CONFIG_ENV_PREFIX + "_SERVER"}, Global: true, }, &cli.StringFlag{ Name: "token", Aliases: []string{"t"}, Usage: "The token to use for authentication.", EnvVars: []string{config.CONFIG_ENV_PREFIX + "_TOKEN"}, Global: true, }, &cli.BoolFlag{ Name: "tls-skip-verify", Usage: "Skip TLS verification when talking to server.", ConfigPath: []string{"tls.skip_verify"}, EnvVars: []string{config.CONFIG_ENV_PREFIX + "_TLS_SKIP_VERIFY"}, DefaultValue: true, Global: true, }, &cli.StringFlag{ Name: "alias", Aliases: []string{"a"}, Usage: "The server alias to use.", DefaultValue: "default", Global: true, }, }, Commands: []*cli.Command{ ListCmd, StartCmd, StopCmd, RestartCmd, CreateCmd, DeleteCmd, LogsCmd, RunCmd, RunScriptCmd, EvalCmd, CopyCmd, MirrorCmd, ReadFileCmd, WriteFileCmd, GrepCmd, FindCmd, SedCmd, DeleteFileCmd, PortCmd, TunnelCmd, SetFieldCmd, GetFieldCmd, }, }
var StartCmd = &cli.Command{ Name: "start", Usage: "Start a space", Description: "Start the named space.", Arguments: []cli.Argument{ &cli.StringArg{ Name: "space", Usage: "The name of the space to start", Required: true, }, }, MaxArgs: cli.NoArgs, Run: func(ctx context.Context, cmd *cli.Command) error { spaceName := cmd.GetStringArg("space") fmt.Println("Starting space: ", spaceName) client, err := cmdutil.GetClient(cmd) if err != nil { return fmt.Errorf("Failed to create API client: %w", err) } code, err := client.StartSpace(context.Background(), spaceName) if err != nil { if code == 503 { return fmt.Errorf("Cannot start space as outside of schedule") } else if code == 507 { return fmt.Errorf("Cannot start space as resource quota exceeded") } else { return fmt.Errorf("Error starting space: %w", err) } } fmt.Println("Space started: ", spaceName) return nil }, }
var StopCmd = &cli.Command{ Name: "stop", Usage: "Stop a space", Description: "Stop the named space.", Arguments: []cli.Argument{ &cli.StringArg{ Name: "space", Usage: "The name of the space to stop", Required: true, }, }, MaxArgs: cli.NoArgs, Run: func(ctx context.Context, cmd *cli.Command) error { spaceName := cmd.GetStringArg("space") fmt.Println("Stopping space: ", spaceName) client, err := cmdutil.GetClient(cmd) if err != nil { return fmt.Errorf("Failed to create API client: %w", err) } _, err = client.StopSpace(context.Background(), spaceName) if err != nil { return fmt.Errorf("Error stopping space: %w", err) } fmt.Println("Space stopped: ", spaceName) return nil }, }
var TunnelCmd = &cli.Command{ Name: "tunnel", Usage: "Manage a space's web tunnels", Description: `Start and manage agent-owned web tunnels in a space. A tunnel exposes a port inside the space on the internet as <user>--<name>.<domain>. The tunnel is owned by the space's agent and runs until the agent exits or the tunnel is stopped; it is not persisted.`, MaxArgs: cli.NoArgs, Commands: []*cli.Command{ spaceTunnelHttpCmd, spaceTunnelHttpsCmd, spaceTunnelStopCmd, spaceTunnelListCmd, }, }
TunnelCmd is the `knot space tunnel` group: remote management of a space's agent-owned web tunnels. Tunnels started here are owned by the space's agent (daemon is implied) and run until the agent exits or they are stopped.
var WriteFileCmd = &cli.Command{ Name: "write-file", Usage: "Write a file to a space", Description: "Write content to a file in a running space. Use --mode append or prepend to add to an existing file instead of overwriting.", Flags: []cli.Flag{ &cli.StringFlag{ Name: "content", Aliases: []string{"d"}, Usage: "Content to write (use - to read from stdin)", }, &cli.StringFlag{ Name: "mode", Usage: "Write mode: overwrite (default), append, or prepend", }, }, Arguments: []cli.Argument{ &cli.StringArg{ Name: "space", Required: true, Usage: "Name or ID of the space", }, &cli.StringArg{ Name: "path", Required: true, Usage: "File path in the space", }, }, MaxArgs: cli.NoArgs, Run: func(ctx context.Context, cmd *cli.Command) error { spaceName := cmd.GetStringArg("space") filePath := cmd.GetStringArg("path") content := cmd.GetString("content") if content == "" || content == "-" { bytes, err := io.ReadAll(os.Stdin) if err != nil { return fmt.Errorf("Error reading from stdin: %w", err) } content = string(bytes) } alias := cmd.GetString("alias") cfg := config.GetServerAddr(alias, cmd) client, err := apiclient.NewClient(cfg.HttpServer, cfg.ApiToken, cmd.GetBool("tls-skip-verify")) if err != nil { return fmt.Errorf("Failed to create API client: %w", err) } user, err := client.WhoAmI(context.Background()) if err != nil { return fmt.Errorf("Error getting user: %w", err) } spaces, _, err := client.GetSpaces(context.Background(), user.Id, false) if err != nil { return fmt.Errorf("Error getting spaces: %w", err) } var spaceId string for _, space := range spaces.Spaces { if space.Name == spaceName || space.Id == spaceName { spaceId = space.Id break } } if spaceId == "" { return fmt.Errorf("Space not found: %s", spaceName) } err = client.WriteSpaceFileMode(context.Background(), spaceId, filePath, content, cmd.GetString("mode")) if err != nil { return fmt.Errorf("Error writing file: %w", err) } fmt.Printf("Successfully wrote to %s\n", filePath) return nil }, }
Functions ¶
This section is empty.
Types ¶
This section is empty.