command_spaces

package
v0.38.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 12, 2026 License: Apache-2.0 Imports: 29 Imported by: 0

Documentation

Index

Constants

This section is empty.

Variables

View Source
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("the source path is missing after '%s:'", sourceSpaceName)
			}
			if destSpacePath == "" {
				return fmt.Errorf("the destination path is missing after '%s:'", destSpaceName)
			}
		} else if sourceIsSpace {

			direction = "from_space"
			spaceName = source[:sourceColonIndex]
			spacePath = source[sourceColonIndex+1:]
			localPath = dest
			if spacePath == "" {
				return fmt.Errorf("the space path is missing after '%s:'", spaceName)
			}
		} else if destIsSpace {

			direction = "to_space"
			spaceName = dest[:destColonIndex]
			spacePath = dest[destColonIndex+1:]
			localPath = source
			if spacePath == "" {
				return fmt.Errorf("the space path is missing after '%s:'", spaceName)
			}
		} else {
			return fmt.Errorf("one of the paths must be written space:path (with a space name of 2 or more characters)")
		}

		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("couldn't create the API client: %w", err)
		}

		user, err := client.WhoAmI(context.Background())
		if err != nil {
			return fmt.Errorf("couldn't get the current user: %w", err)
		}

		spaces, _, err := client.GetSpaces(context.Background(), user.Id, false)
		if err != nil {
			return fmt.Errorf("couldn't list 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 %q not found", 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 {
				return nil, cmdutil.WebSocketError("couldn't connect to the space to copy files", response, 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("couldn't ask space %q for %s: %w", sourceSpaceName, sourceSpacePath, err)
			}

			var sourceResult map[string]interface{}
			err = sourceWs.ReadJSON(&sourceResult)
			if err != nil {
				return fmt.Errorf("couldn't read %s from space %q: %w", sourceSpacePath, sourceSpaceName, err)
			}

			success, ok := sourceResult["success"].(bool)
			if !ok || !success {
				errorMsg, _ := sourceResult["error"].(string)
				return fmt.Errorf("couldn't read %s from space %q: %s", sourceSpacePath, sourceSpaceName, orUnknown(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("the space sent file content that couldn't be decoded: %w", err)
				}
			} else {
				return fmt.Errorf("the space sent a reply without file content")
			}

			destRequest := apiclient.CopyFileRequest{
				Direction: "to_space",
				DestPath:  destSpacePath,
				Content:   content,
				Workdir:   workdir,
			}

			err = destWs.WriteJSON(destRequest)
			if err != nil {
				return fmt.Errorf("couldn't send %s to space %q: %w", destSpacePath, destSpaceName, err)
			}

			var destResult map[string]interface{}
			err = destWs.ReadJSON(&destResult)
			if err != nil {
				return fmt.Errorf("couldn't write %s in space %q: %w", destSpacePath, destSpaceName, err)
			}

			success, ok = destResult["success"].(bool)
			if !ok || !success {
				errorMsg, _ := destResult["error"].(string)
				return fmt.Errorf("couldn't write %s in space %q: %s", destSpacePath, destSpaceName, orUnknown(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("couldn't read local file %s: %w", localPath, 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("couldn't send the copy request to space %q: %w", spaceName, err)
		}

		// Read the response
		var result map[string]interface{}
		err = ws.ReadJSON(&result)
		if err != nil {
			return fmt.Errorf("couldn't read the reply from space %q: %w", spaceName, err)
		}

		success, ok := result["success"].(bool)
		if !ok || !success {
			errorMsg, _ := result["error"].(string)
			return fmt.Errorf("couldn't copy %s in space %q: %s", spacePath, spaceName, orUnknown(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("the space sent file content that couldn't be decoded: %w", err)
				}
			} else {
				return fmt.Errorf("the space sent a reply without file content")
			}

			localDir := filepath.Dir(localPath)
			if err := os.MkdirAll(localDir, 0755); err != nil {
				return fmt.Errorf("couldn't create local folder %s: %w", localDir, err)
			}

			err = os.WriteFile(localPath, content, 0644)
			if err != nil {
				return fmt.Errorf("couldn't write local file %s: %w", localPath, err)
			}
		}

		fmt.Println("Copy completed successfully")
		return nil
	},
}
View Source
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 %q: use bash, zsh, fish or sh", shell)
		}

		customFields, err := cmdutil.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("couldn't create the API client: %w", err)
		}

		templates, _, err := client.GetTemplates(context.Background())
		if err != nil {
			return fmt.Errorf("couldn't list 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 %q not found", 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("couldn't create space %q: %w", cmd.GetStringArg("space"), err)
		}

		fmt.Println("Space created: ", cmd.GetStringArg("space"))
		return nil
	},
}
View Source
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("couldn't create the API client: %w", err)
		}

		_, err = client.DeleteSpace(context.Background(), spaceName)
		if err != nil {
			return fmt.Errorf("couldn't delete space %q: %w", spaceName, err)
		}

		fmt.Println("Space deleting: ", spaceName)
		return nil
	},
}
View Source
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.

View Source
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 err
		}
		client.SetTimeout(5 * time.Minute)

		space, err := client.GetSpaceByName(ctx, cmd.GetStringArg("space-name"))
		if err != nil {
			return fmt.Errorf("couldn't find space %q: %w", cmd.GetStringArg("space-name"), 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("couldn't run the code in space %q: %w", cmd.GetStringArg("space-name"), err)
		}
		if exitCode != 0 {
			os.Exit(exitCode)
		}
		return nil
	},
}
View Source
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.

View Source
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 err
		}

		response, _, err := client.GetSpaceCustomField(context.Background(), spaceName, fieldName)
		if err != nil {
			return fmt.Errorf("couldn't get custom field %q of space %q: %w", fieldName, spaceName, err)
		}

		fmt.Println(response.Value)
		return nil
	},
}
View Source
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.

View Source
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
	},
}
View Source
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.

View Source
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)
	},
}
View Source
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)
	},
}
View Source
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 {
			return spaceApiError(code, err, "list jobs", spaceName)
		}

		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
	},
}
View Source
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
	},
}
View Source
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 {
			return spaceApiError(code, err, "run the job", spaceName)
		}
		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
	},
}
View Source
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
	},
}
View Source
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 {
			return err
		}

		allZones := cmd.GetBool("all-zones")

		pingResponse, err := client.Ping(context.Background())
		if err != nil {
			return fmt.Errorf("couldn't get the server info: %w", err)
		}
		zone := pingResponse.Zone

		user, err := client.WhoAmI(context.Background())
		if err != nil {
			return fmt.Errorf("couldn't get the current user: %w", err)
		}

		spaces, _, err := client.GetSpaces(context.Background(), user.Id, allZones)
		if err != nil {
			return fmt.Errorf("couldn't list spaces: %w", err)
		}

		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
	},
}
View Source
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 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 {
			return cmdutil.WebSocketError(fmt.Sprintf("couldn't open the logs of space %q", spaceName), response, err)
		}
		defer ws.Close()

		for {
			_, message, err := ws.ReadMessage()
			if err != nil {
				if !websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) {
					fmt.Fprintln(os.Stderr, cmdutil.FormatError(fmt.Errorf("lost the log stream of space %q: %w", spaceName, err)))
				}
				break
			}

			if len(message) == 1 && message[0] == 0 && !follow {
				break
			}

			fmt.Print(string(message))
		}
		return nil
	},
}
View Source
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: %s\n", stats.String(time.Since(start)))
		}
		if failed := stats.failed.Load(); failed > 0 {
			return fmt.Errorf("%d file(s) couldn't be mirrored to %s:%s", failed, spaceName, remoteDir)
		}
		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.

View Source
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/*).

View Source
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 err
		}

		spaces, _, err := client.GetSpaces(ctx, "", false)
		if err != nil {
			return fmt.Errorf("couldn't list 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 %q not found", fromSpace)
		}

		if !fromSpaceInfo.IsDeployed || !fromSpaceInfo.HasState {
			if !cmd.GetBool("persistent") {
				return fmt.Errorf("space %q is not running", fromSpace)
			}
		}

		if !cmd.GetBool("force") && !strings.Contains(toSpace, "--") {
			var toSpaceInfo *apiclient.SpaceInfo
			for i := range spaces.Spaces {
				if spaces.Spaces[i].Name == toSpace {
					toSpaceInfo = &spaces.Spaces[i]
					break
				}
			}
			if toSpaceInfo != nil && !toSpaceInfo.IsDeployed && !toSpaceInfo.HasState {
				return fmt.Errorf("space %q is not running", toSpace)
			}
		}

		spaceId := fromSpaceInfo.Id

		force := cmd.GetBool("force")

		request := &apiclient.PortForwardRequest{
			LocalPort:  uint16(fromPort),
			Space:      toSpace,
			RemotePort: uint16(toPort),
			Persistent: cmd.GetBool("persistent"),
			Force:      force,
		}

		_, err = client.ForwardPort(ctx, spaceId, request)
		if err != nil {

			return fmt.Errorf("couldn't forward %s:%d to %s:%d: %w", fromSpace, fromPort, toSpace, toPort, err)
		}

		fmt.Printf("Port forward established: %s:%d -> %s:%d\n", fromSpace, fromPort, toSpace, toPort)
		return nil
	},
}
View Source
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 err
		}

		spaces, _, err := client.GetSpaces(ctx, "", false)
		if err != nil {
			return fmt.Errorf("couldn't list 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 %q not found", spaceName)
		}

		response, code, err := client.ListPorts(ctx, spaceId)
		if err != nil {
			return spaceApiError(code, err, "list port forwards", spaceName)
		}

		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 = "—"
			}

			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
	},
}
View Source
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 err
		}

		spaces, _, err := client.GetSpaces(ctx, "", false)
		if err != nil {
			return fmt.Errorf("couldn't list 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 %q not found", spaceName)
		}

		request := &apiclient.PortStopRequest{
			LocalPort: uint16(localPort),
		}

		code, err := client.StopPort(ctx, spaceId, request)
		if err != nil {
			return spaceApiError(code, err, "stop the port forward", spaceName)
		}

		fmt.Printf("Port forward on port %d stopped in space '%s'.\n", localPort, spaceName)
		return nil
	},
}
View Source
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 err
		}

		spaces, _, err := client.GetSpaces(ctx, "", false)
		if err != nil {
			return fmt.Errorf("couldn't list 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 %q 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 {
			return spaceApiError(code, err, "set the throttle", spaceName)
		}

		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
	},
}
View Source
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("couldn't create the API client: %w", err)
		}

		user, err := client.WhoAmI(context.Background())
		if err != nil {
			return fmt.Errorf("couldn't get the current user: %w", err)
		}

		spaces, _, err := client.GetSpaces(context.Background(), user.Id, false)
		if err != nil {
			return fmt.Errorf("couldn't list 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 %q not found", spaceName)
		}

		content, totalLines, err := client.ReadSpaceFileRange(context.Background(), spaceId, filePath, offset, limit)
		if err != nil {
			return fmt.Errorf("couldn't read %s in space %q: %w", filePath, spaceName, 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
	},
}
View Source
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 err
		}

		_, err = client.RestartSpace(context.Background(), spaceName)
		if err != nil {
			return fmt.Errorf("couldn't restart space %q: %w", spaceName, err)
		}

		fmt.Println("Space restarting: ", spaceName)
		return nil
	},
}
View Source
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. Give knot's own flags before the space name; everything after the command is passed to it unchanged.",
	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,

	PassThrough: true,
	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 err
		}

		user, err := client.WhoAmI(context.Background())
		if err != nil {
			return fmt.Errorf("couldn't get the current user: %w", err)
		}

		spaces, _, err := client.GetSpaces(context.Background(), user.Id, false)
		if err != nil {
			return fmt.Errorf("couldn't list 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 %q not found", 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 {
			return cmdutil.WebSocketError(fmt.Sprintf("couldn't connect to space %q to run the command", spaceName), response, 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("couldn't send the command to space %q: %w", spaceName, err)
		}

		for {
			messageType, message, err := ws.ReadMessage()
			if err != nil {

				if websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) {
					break
				}
				return fmt.Errorf("lost the connection to space %q while the command was running: %w", spaceName, err)
			}

			if len(message) > 0 && message[0] == 0 {
				if len(message) == 1 {
					break
				}
				var status apiclient.RunCommandStatus
				if err := json.Unmarshal(message[1:], &status); err != nil {
					return fmt.Errorf("the command failed in space %q", spaceName)
				}
				code := status.ExitCode
				if code <= 0 {
					code = 1
				}
				return &cmdutil.ExitCodeError{Code: code, Err: fmt.Errorf("the command failed in space %q: %s", spaceName, status.Error)}
			}

			if messageType == websocket.BinaryMessage {
				os.Stderr.Write(message)
			} else {
				os.Stdout.Write(message)
			}
		}

		return nil
	},
}
View Source
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 err
		}
		client.SetTimeout(5 * time.Minute)

		space, err := client.GetSpaceByName(ctx, cmd.GetStringArg("space-name"))
		if err != nil {
			return fmt.Errorf("couldn't find space %q: %w", cmd.GetStringArg("space-name"), 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("couldn't run the script in space %q: %w", cmd.GetStringArg("space-name"), err)
			}
			if exitCode != 0 {
				os.Exit(exitCode)
			}
		} else {

			exitCode, err := client.ExecuteScriptStream(ctx, space.SpaceId, scriptArg, argv)
			if err != nil {
				return fmt.Errorf("couldn't run the script in space %q: %w", cmd.GetStringArg("space-name"), err)
			}
			if exitCode != 0 {
				os.Exit(exitCode)
			}
		}
		return nil
	},
}
View Source
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.

View Source
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 err
		}

		_, err = client.SetSpaceCustomField(context.Background(), spaceName, fieldName, fieldValue)
		if err != nil {
			return fmt.Errorf("couldn't set custom field %q of space %q: %w", fieldName, spaceName, err)
		}

		fmt.Printf("Custom field '%s' set to '%s' on space '%s'\n", fieldName, fieldValue, spaceName)
		return nil
	},
}
View Source
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,
	},
}
View Source
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 err
		}

		code, err := client.StartSpace(context.Background(), spaceName)
		if err != nil {
			if code == 503 {
				return fmt.Errorf("couldn't start space %q: it is outside its schedule", spaceName)
			} else if code == 507 {
				return fmt.Errorf("couldn't start space %q: it would exceed your resource quota; stop another space first", spaceName)
			} else {
				return fmt.Errorf("couldn't start space %q: %w", spaceName, err)
			}
		}

		fmt.Println("Space started: ", spaceName)
		return nil
	},
}
View Source
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 err
		}

		_, err = client.StopSpace(context.Background(), spaceName)
		if err != nil {
			return fmt.Errorf("couldn't stop space %q: %w", spaceName, err)
		}

		fmt.Println("Space stopped: ", spaceName)
		return nil
	},
}
View Source
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.

By default the tunnel is created on the server that owns the space. The
--tunnel-server/--tunnel-token or --tunnel-alias flags target any other knot
server instead.`,
	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.

View Source
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("couldn't read 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("couldn't create the API client: %w", err)
		}

		user, err := client.WhoAmI(context.Background())
		if err != nil {
			return fmt.Errorf("couldn't get the current user: %w", err)
		}

		spaces, _, err := client.GetSpaces(context.Background(), user.Id, false)
		if err != nil {
			return fmt.Errorf("couldn't list 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 %q not found", spaceName)
		}

		err = client.WriteSpaceFileMode(context.Background(), spaceId, filePath, content, cmd.GetString("mode"))
		if err != nil {
			return fmt.Errorf("couldn't write %s in space %q: %w", filePath, spaceName, err)
		}

		fmt.Printf("Successfully wrote to %s\n", filePath)
		return nil
	},
}

Functions

This section is empty.

Types

This section is empty.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL