command_spaces

package
v0.31.0 Latest Latest
Warning

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

Go to latest
Published: Jul 20, 2026 License: Apache-2.0 Imports: 28 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("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
	},
}
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: %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
	},
}
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("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
	},
}
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 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
	},
}
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 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
	},
}
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 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
	},
}
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 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
	},
}
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 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.

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 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
	},
}
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 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
	},
}
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 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
	},
}
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 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
	},
}
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("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
	},
}
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 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
	},
}
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.",
	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
	},
}
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 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
	},
}
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 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
	},
}
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,
		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 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
	},
}
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 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
	},
}
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.`,
	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("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.

Jump to

Keyboard shortcuts

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