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 JobsAddCmd = &cli.Command{ Name: "add", Usage: "Add a job to a space", Description: "Add a job to a space. The schedule is a 5-field cron expression (minute hour day month weekday); omit it for a manual-only job.", Arguments: []cli.Argument{ &cli.StringArg{ Name: "space", Usage: "The name of the space", Required: true, }, &cli.StringArg{ Name: "job", Usage: "The name of the job", Required: true, }, }, Flags: []cli.Flag{ &cli.StringFlag{ Name: "command", Usage: "The shell command the job runs", Required: true, }, &cli.StringFlag{ Name: "schedule", Usage: `Cron expression, e.g. "*/5 * * * *" or "30 9 * * 1-5"; empty for a manual-only job`, }, &cli.BoolFlag{ Name: "disabled", Usage: "Add the job disabled (it will not fire automatically)", }, }, MaxArgs: cli.NoArgs, Run: func(ctx context.Context, cmd *cli.Command) error { spaceName := cmd.GetStringArg("space") jobName := cmd.GetStringArg("job") client, err := jobsClient(cmd) if err != nil { return err } spaceId, err := jobsSpaceId(ctx, client, spaceName) if err != nil { return err } job := model.SpaceJob{ Name: jobName, Command: cmd.GetString("command"), Schedule: cmd.GetString("schedule"), Enabled: !cmd.GetBool("disabled"), } err = jobsSaveDefinitions(ctx, client, spaceId, func(jobs *[]model.SpaceJob) error { for _, j := range *jobs { if j.Name == job.Name { return fmt.Errorf("job '%s' already exists in space '%s'", job.Name, spaceName) } } *jobs = append(*jobs, job) return nil }) if err != nil { return err } fmt.Printf("Job '%s' added to space '%s'.\n", jobName, spaceName) return nil }, }
var JobsCmd = &cli.Command{ Name: "jobs", Usage: "Manage a space's scheduled jobs", Description: `Manage the jobs defined on a space. Job definitions are stored on the space and pushed to the agent, so they survive restarts.`, MaxArgs: cli.NoArgs, Commands: []*cli.Command{ JobsListCmd, JobsRunCmd, JobsAddCmd, JobsUpdateCmd, JobsRemoveCmd, JobsEnableCmd, JobsDisableCmd, }, }
JobsCmd is the `knot space jobs` group: manage the jobs defined on a space. The definitions are stored on the space and pushed to its agent, so they survive restarts and can be edited while the space is stopped. These commands also work from inside a space: cmdutil.GetClient picks up the agent's own credentials via the agentlink socket.
var JobsDisableCmd = &cli.Command{ Name: "disable", Usage: "Disable the job runner of a space", Description: "Stop a space's scheduled jobs from firing. Manual triggering still works. Persisted on the 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 { return setJobRunner(ctx, cmd, false) }, }
var JobsEnableCmd = &cli.Command{ Name: "enable", Usage: "Enable the job runner of a space", Description: "Start a space's scheduled jobs firing. Persisted on the space and pushed to the agent.", 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 { return setJobRunner(ctx, cmd, true) }, }
var JobsListCmd = &cli.Command{ Name: "list", Usage: "List the scheduled jobs of a space", Description: "List a space's jobs with their schedule, next run and last run. Definitions come from the space record; run status needs the space to be running.", 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 := jobsClient(cmd) if err != nil { return err } spaceId, err := jobsSpaceId(ctx, client, spaceName) if err != nil { return err } live, _, liveErr := client.ListJobs(ctx, spaceId) if liveErr != nil { live = nil } definitions, code, err := client.GetSpaceJobs(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 jobs") } else if code == 404 { return fmt.Errorf("space not found") } return fmt.Errorf("failed to list jobs: %w", err) } if len(definitions.Jobs) == 0 { fmt.Printf("No jobs defined in space '%s'.\n", spaceName) return nil } fmt.Printf("Jobs in space '%s':\n", spaceName) if definitions.Enabled { fmt.Printf(" job runner: enabled\n") } else { fmt.Printf(" job runner: disabled (scheduled jobs are not firing, manual runs still work)\n") } for _, job := range definitions.Jobs { fmt.Printf(" %s", job.Name) if job.Schedule == "" { fmt.Print(" (manual only)") } else { fmt.Printf(" (%s)", job.Schedule) } if !job.Enabled { fmt.Print(" [disabled]") } fmt.Println() fmt.Printf(" command: %s\n", job.Command) if live != nil { for _, status := range live.Jobs { if status.Name != job.Name { continue } if status.Running { fmt.Printf(" running now\n") } if status.NextRun != nil { fmt.Printf(" next run: %s\n", status.NextRun.Local().Format(time.RFC3339)) } if status.LastRun != nil { lastRun := fmt.Sprintf(" last run: %s (%s", status.LastRun.StartedAt.Local().Format(time.RFC3339), status.LastRun.Status) if status.LastRun.DurationMs > 0 { lastRun += fmt.Sprintf(", %s", (time.Duration(status.LastRun.DurationMs) * time.Millisecond).Round(time.Second)) } fmt.Println(lastRun + ")") } if status.Error != "" { fmt.Printf(" error: %s\n", status.Error) } } } } if live == nil && liveErr != nil { fmt.Printf("\n(space not running, showing definitions without run status)\n") } return nil }, }
var JobsRemoveCmd = &cli.Command{ Name: "remove", Usage: "Remove a job from a space", Description: "Remove a job definition from a space. Any running instance is unaffected.", Arguments: []cli.Argument{ &cli.StringArg{ Name: "space", Usage: "The name of the space", Required: true, }, &cli.StringArg{ Name: "job", Usage: "The name of the job", Required: true, }, }, MaxArgs: cli.NoArgs, Run: func(ctx context.Context, cmd *cli.Command) error { spaceName := cmd.GetStringArg("space") jobName := cmd.GetStringArg("job") client, err := jobsClient(cmd) if err != nil { return err } spaceId, err := jobsSpaceId(ctx, client, spaceName) if err != nil { return err } err = jobsSaveDefinitions(ctx, client, spaceId, func(jobs *[]model.SpaceJob) error { kept := (*jobs)[:0] found := false for _, j := range *jobs { if j.Name == jobName { found = true continue } kept = append(kept, j) } if !found { return fmt.Errorf("job '%s' not found in space '%s'", jobName, spaceName) } *jobs = kept return nil }) if err != nil { return err } fmt.Printf("Job '%s' removed from space '%s'.\n", jobName, spaceName) return nil }, }
var JobsRunCmd = &cli.Command{ Name: "run", Usage: "Run a job of a space now", Description: "Trigger a job immediately by name. Works for disabled and manual-only jobs too.", Arguments: []cli.Argument{ &cli.StringArg{ Name: "space", Usage: "The name of the space", Required: true, }, &cli.StringArg{ Name: "job", Usage: "The name of the job to run", Required: true, }, }, MaxArgs: cli.NoArgs, Run: func(ctx context.Context, cmd *cli.Command) error { spaceName := cmd.GetStringArg("space") jobName := cmd.GetStringArg("job") client, err := jobsClient(cmd) if err != nil { return err } spaceId, err := jobsSpaceId(ctx, client, spaceName) if err != nil { return err } request := &apiclient.JobRunRequest{ Name: jobName, } response, code, err := client.RunJob(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 run commands") } 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 run job: %w", err) } if !response.Success { return fmt.Errorf("failed to run job: %s", response.Error) } fmt.Printf("Job '%s' started in space '%s'.\n", jobName, spaceName) return nil }, }
var JobsUpdateCmd = &cli.Command{ Name: "update", Usage: "Update a job of a space", Description: "Change a job's command, schedule or enabled state. Only the given flags are changed.", Arguments: []cli.Argument{ &cli.StringArg{ Name: "space", Usage: "The name of the space", Required: true, }, &cli.StringArg{ Name: "job", Usage: "The name of the job", Required: true, }, }, Flags: []cli.Flag{ &cli.StringFlag{ Name: "command", Usage: "The shell command the job runs", }, &cli.StringFlag{ Name: "schedule", Usage: `Cron expression, e.g. "*/5 * * * *"; empty string for a manual-only job`, }, &cli.BoolFlag{ Name: "enable", Usage: "Enable the job so it fires automatically", }, &cli.BoolFlag{ Name: "disable", Usage: "Disable the job so it does not fire automatically", }, }, MaxArgs: cli.NoArgs, Run: func(ctx context.Context, cmd *cli.Command) error { spaceName := cmd.GetStringArg("space") jobName := cmd.GetStringArg("job") client, err := jobsClient(cmd) if err != nil { return err } spaceId, err := jobsSpaceId(ctx, client, spaceName) if err != nil { return err } err = jobsSaveDefinitions(ctx, client, spaceId, func(jobs *[]model.SpaceJob) error { for i := range *jobs { if (*jobs)[i].Name != jobName { continue } if cmd.HasFlag("command") { (*jobs)[i].Command = cmd.GetString("command") } if cmd.HasFlag("schedule") { (*jobs)[i].Schedule = cmd.GetString("schedule") } if cmd.GetBool("disable") { (*jobs)[i].Enabled = false } else if cmd.GetBool("enable") { (*jobs)[i].Enabled = true } return nil } return fmt.Errorf("job '%s' not found in space '%s'", jobName, spaceName) }) if err != nil { return err } fmt.Printf("Job '%s' updated in space '%s'.\n", jobName, spaceName) return nil }, }
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, JobsCmd, 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.
Source Files
¶
- copy.go
- create.go
- delete.go
- delete_file.go
- eval.go
- file_ops.go
- get-field.go
- jobs.go
- jobs_add.go
- jobs_disable.go
- jobs_edit.go
- jobs_enable.go
- jobs_list.go
- jobs_run.go
- jobs_update_def.go
- list.go
- logs.go
- mirror.go
- mirror_watch.go
- port.go
- port_forward.go
- port_list.go
- port_stop.go
- port_throttle.go
- restart.go
- run.go
- runscript.go
- search.go
- set-field.go
- spaces.go
- start.go
- stop.go
- tunnel.go