diff --git a/cmd/wsh/cmd/wshcmd-ai.go b/cmd/wsh/cmd/wshcmd-ai.go index de1cca0d6..2953a9681 100644 --- a/cmd/wsh/cmd/wshcmd-ai.go +++ b/cmd/wsh/cmd/wshcmd-ai.go @@ -79,7 +79,7 @@ func aiRun(cmd *cobra.Command, args []string) (rtnErr error) { } // Default to "waveai" block - isDefaultBlock := blockArg == "" || blockArg == "this" + isDefaultBlock := blockArg == "" if isDefaultBlock { blockArg = "view@waveai" } diff --git a/cmd/wsh/cmd/wshcmd-conn.go b/cmd/wsh/cmd/wshcmd-conn.go index 73d3ee565..bdfebcb55 100644 --- a/cmd/wsh/cmd/wshcmd-conn.go +++ b/cmd/wsh/cmd/wshcmd-conn.go @@ -14,18 +14,80 @@ import ( ) var connCmd = &cobra.Command{ - Use: "conn [status|reinstall|disconnect|connect|ensure] [connection-name]", - Short: "implements connection commands", - Args: cobra.RangeArgs(1, 2), - RunE: connRun, + Use: "conn", + Short: "manage Wave Terminal connections", + Long: "Commands to manage Wave Terminal SSH and WSL connections", +} + +var connStatusCmd = &cobra.Command{ + Use: "status", + Short: "show status of all connections", + Args: cobra.NoArgs, + RunE: connStatusRun, + PreRunE: preRunSetupRpcClient, +} + +var connReinstallCmd = &cobra.Command{ + Use: "reinstall CONNECTION", + Short: "reinstall wsh on a connection", + Args: cobra.ExactArgs(1), + RunE: connReinstallRun, + PreRunE: preRunSetupRpcClient, +} + +var connDisconnectCmd = &cobra.Command{ + Use: "disconnect CONNECTION", + Short: "disconnect a connection", + Args: cobra.ExactArgs(1), + RunE: connDisconnectRun, + PreRunE: preRunSetupRpcClient, +} + +var connDisconnectAllCmd = &cobra.Command{ + Use: "disconnectall", + Short: "disconnect all connections", + Args: cobra.NoArgs, + RunE: connDisconnectAllRun, + PreRunE: preRunSetupRpcClient, +} + +var connConnectCmd = &cobra.Command{ + Use: "connect CONNECTION", + Short: "connect to a connection", + Args: cobra.ExactArgs(1), + RunE: connConnectRun, + PreRunE: preRunSetupRpcClient, +} + +var connEnsureCmd = &cobra.Command{ + Use: "ensure CONNECTION", + Short: "ensure wsh is installed on a connection", + Args: cobra.ExactArgs(1), + RunE: connEnsureRun, PreRunE: preRunSetupRpcClient, } func init() { rootCmd.AddCommand(connCmd) + connCmd.AddCommand(connStatusCmd) + connCmd.AddCommand(connReinstallCmd) + connCmd.AddCommand(connDisconnectCmd) + connCmd.AddCommand(connDisconnectAllCmd) + connCmd.AddCommand(connConnectCmd) + connCmd.AddCommand(connEnsureCmd) } -func connStatus() error { +func validateConnectionName(name string) error { + if !strings.HasPrefix(name, "wsl://") { + _, err := remote.ParseOpts(name) + if err != nil { + return fmt.Errorf("cannot parse connection name: %w", err) + } + } + return nil +} + +func connStatusRun(cmd *cobra.Command, args []string) error { var allResp []wshrpc.ConnStatus sshResp, err := wshclient.ConnStatusCommand(RpcClient, nil) if err != nil { @@ -48,13 +110,38 @@ func connStatus() error { if conn.Error != "" { str += fmt.Sprintf(" (%s)", conn.Error) } - str += "\n" WriteStdout("%s\n", str) } return nil } -func connDisconnectAll() error { +func connReinstallRun(cmd *cobra.Command, args []string) error { + connName := args[0] + if err := validateConnectionName(connName); err != nil { + return err + } + err := wshclient.ConnReinstallWshCommand(RpcClient, connName, &wshrpc.RpcOpts{Timeout: 60000}) + if err != nil { + return fmt.Errorf("reinstalling connection: %w", err) + } + WriteStdout("wsh reinstalled on connection %q\n", connName) + return nil +} + +func connDisconnectRun(cmd *cobra.Command, args []string) error { + connName := args[0] + if err := validateConnectionName(connName); err != nil { + return err + } + err := wshclient.ConnDisconnectCommand(RpcClient, connName, &wshrpc.RpcOpts{Timeout: 10000}) + if err != nil { + return fmt.Errorf("disconnecting %q error: %w", connName, err) + } + WriteStdout("disconnected %q\n", connName) + return nil +} + +func connDisconnectAllRun(cmd *cobra.Command, args []string) error { resp, err := wshclient.ConnStatusCommand(RpcClient, nil) if err != nil { return fmt.Errorf("getting connection status: %w", err) @@ -64,43 +151,22 @@ func connDisconnectAll() error { } for _, conn := range resp { if conn.Status == "connected" { - err := connDisconnect(conn.Connection) + err := wshclient.ConnDisconnectCommand(RpcClient, conn.Connection, &wshrpc.RpcOpts{Timeout: 10000}) if err != nil { WriteStdout("error disconnecting %q: %v\n", conn.Connection, err) + } else { + WriteStdout("disconnected %q\n", conn.Connection) } } } return nil } -func connEnsure(connName string) error { - err := wshclient.ConnEnsureCommand(RpcClient, connName, &wshrpc.RpcOpts{Timeout: 60000}) - if err != nil { - return fmt.Errorf("ensuring connection: %w", err) +func connConnectRun(cmd *cobra.Command, args []string) error { + connName := args[0] + if err := validateConnectionName(connName); err != nil { + return err } - WriteStdout("wsh ensured on connection %q\n", connName) - return nil -} - -func connReinstall(connName string) error { - err := wshclient.ConnReinstallWshCommand(RpcClient, connName, &wshrpc.RpcOpts{Timeout: 60000}) - if err != nil { - return fmt.Errorf("reinstalling connection: %w", err) - } - WriteStdout("wsh reinstalled on connection %q\n", connName) - return nil -} - -func connDisconnect(connName string) error { - err := wshclient.ConnDisconnectCommand(RpcClient, connName, &wshrpc.RpcOpts{Timeout: 10000}) - if err != nil { - return fmt.Errorf("disconnecting %q error: %w", connName, err) - } - WriteStdout("disconnected %q\n", connName) - return nil -} - -func connConnect(connName string) error { err := wshclient.ConnConnectCommand(RpcClient, connName, &wshrpc.RpcOpts{Timeout: 60000}) if err != nil { return fmt.Errorf("connecting connection: %w", err) @@ -109,32 +175,15 @@ func connConnect(connName string) error { return nil } -func connRun(cmd *cobra.Command, args []string) error { - connCmd := args[0] - var connName string - if connCmd != "status" && connCmd != "disconnectall" { - if len(args) < 2 { - return fmt.Errorf("connection name is required %q", connCmd) - } - connName = args[1] - _, err := remote.ParseOpts(connName) - if err != nil && !strings.HasPrefix(connName, "wsl://") { - return fmt.Errorf("cannot parse connection name: %w", err) - } +func connEnsureRun(cmd *cobra.Command, args []string) error { + connName := args[0] + if err := validateConnectionName(connName); err != nil { + return err } - if connCmd == "status" { - return connStatus() - } else if connCmd == "ensure" { - return connEnsure(connName) - } else if connCmd == "reinstall" { - return connReinstall(connName) - } else if connCmd == "disconnect" { - return connDisconnect(connName) - } else if connCmd == "disconnectall" { - return connDisconnectAll() - } else if connCmd == "connect" { - return connConnect(connName) - } else { - return fmt.Errorf("unknown command %q", connCmd) + err := wshclient.ConnEnsureCommand(RpcClient, connName, &wshrpc.RpcOpts{Timeout: 60000}) + if err != nil { + return fmt.Errorf("ensuring connection: %w", err) } + WriteStdout("wsh ensured on connection %q\n", connName) + return nil } diff --git a/cmd/wsh/cmd/wshcmd-deleteblock.go b/cmd/wsh/cmd/wshcmd-deleteblock.go index 40afc61f2..ccb4cf7f6 100644 --- a/cmd/wsh/cmd/wshcmd-deleteblock.go +++ b/cmd/wsh/cmd/wshcmd-deleteblock.go @@ -25,8 +25,7 @@ func deleteBlockRun(cmd *cobra.Command, args []string) (rtnErr error) { defer func() { sendActivity("deleteblock", rtnErr == nil) }() - oref := blockArg - fullORef, err := resolveSimpleId(oref) + fullORef, err := resolveBlockArg() if err != nil { return err } diff --git a/cmd/wsh/cmd/wshcmd-file-util.go b/cmd/wsh/cmd/wshcmd-file-util.go new file mode 100644 index 000000000..4979c15fd --- /dev/null +++ b/cmd/wsh/cmd/wshcmd-file-util.go @@ -0,0 +1,212 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "encoding/base64" + "fmt" + "io" + "io/fs" + "strings" + + "github.com/wavetermdev/waveterm/pkg/wshrpc" + "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" +) + +func convertNotFoundErr(err error) error { + if err == nil { + return nil + } + if strings.HasPrefix(err.Error(), "NOTFOUND:") { + return fs.ErrNotExist + } + return err +} + +func ensureWaveFile(origName string, fileData wshrpc.CommandFileData) (*wshrpc.WaveFileInfo, error) { + info, err := wshclient.FileInfoCommand(RpcClient, fileData, &wshrpc.RpcOpts{Timeout: DefaultFileTimeout}) + err = convertNotFoundErr(err) + if err == fs.ErrNotExist { + createData := wshrpc.CommandFileCreateData{ + ZoneId: fileData.ZoneId, + FileName: fileData.FileName, + } + err = wshclient.FileCreateCommand(RpcClient, createData, &wshrpc.RpcOpts{Timeout: DefaultFileTimeout}) + if err != nil { + return nil, fmt.Errorf("creating file: %w", err) + } + info, err = wshclient.FileInfoCommand(RpcClient, fileData, &wshrpc.RpcOpts{Timeout: DefaultFileTimeout}) + if err != nil { + return nil, fmt.Errorf("getting file info: %w", err) + } + return info, err + } + if err != nil { + return nil, fmt.Errorf("getting file info: %w", err) + } + return info, nil +} + +func streamWriteToWaveFile(fileData wshrpc.CommandFileData, reader io.Reader) error { + // First truncate the file with an empty write + emptyWrite := fileData + emptyWrite.Data64 = "" + err := wshclient.FileWriteCommand(RpcClient, emptyWrite, &wshrpc.RpcOpts{Timeout: DefaultFileTimeout}) + if err != nil { + return fmt.Errorf("initializing file with empty write: %w", err) + } + + const chunkSize = 32 * 1024 // 32KB chunks + buf := make([]byte, chunkSize) + totalWritten := int64(0) + + for { + n, err := reader.Read(buf) + if err == io.EOF { + break + } + if err != nil { + return fmt.Errorf("reading input: %w", err) + } + + // Check total size + totalWritten += int64(n) + if totalWritten > MaxFileSize { + return fmt.Errorf("input exceeds maximum file size of %d bytes", MaxFileSize) + } + + // Prepare and send chunk + chunk := buf[:n] + appendData := fileData + appendData.Data64 = base64.StdEncoding.EncodeToString(chunk) + + err = wshclient.FileAppendCommand(RpcClient, appendData, &wshrpc.RpcOpts{Timeout: fileTimeout}) + if err != nil { + return fmt.Errorf("appending chunk to file: %w", err) + } + } + + return nil +} + +func streamReadFromWaveFile(fileData wshrpc.CommandFileData, size int64, writer io.Writer) error { + const chunkSize = 32 * 1024 // 32KB chunks + for offset := int64(0); offset < size; offset += chunkSize { + // Calculate the length of this chunk + length := chunkSize + if offset+int64(length) > size { + length = int(size - offset) + } + + // Set up the ReadAt request + fileData.At = &wshrpc.CommandFileDataAt{ + Offset: offset, + Size: int64(length), + } + + // Read the chunk + content64, err := wshclient.FileReadCommand(RpcClient, fileData, &wshrpc.RpcOpts{Timeout: fileTimeout}) + if err != nil { + return fmt.Errorf("reading chunk at offset %d: %w", offset, err) + } + + // Decode and write the chunk + chunk, err := base64.StdEncoding.DecodeString(content64) + if err != nil { + return fmt.Errorf("decoding chunk at offset %d: %w", offset, err) + } + + _, err = writer.Write(chunk) + if err != nil { + return fmt.Errorf("writing chunk at offset %d: %w", offset, err) + } + } + + return nil +} + +type fileListResult struct { + info *wshrpc.WaveFileInfo + err error +} + +func streamFileList(zoneId string, path string, recursive bool, filesOnly bool) (<-chan fileListResult, error) { + resultChan := make(chan fileListResult) + + // If path doesn't end in /, do a single file lookup + if path != "" && !strings.HasSuffix(path, "/") { + go func() { + defer close(resultChan) + + fileData := wshrpc.CommandFileData{ + ZoneId: zoneId, + FileName: path, + } + + info, err := wshclient.FileInfoCommand(RpcClient, fileData, &wshrpc.RpcOpts{Timeout: 2000}) + err = convertNotFoundErr(err) + if err == fs.ErrNotExist { + resultChan <- fileListResult{err: fmt.Errorf("%s: No such file or directory", path)} + return + } + if err != nil { + resultChan <- fileListResult{err: err} + return + } + resultChan <- fileListResult{info: info} + }() + return resultChan, nil + } + + // Directory listing case + go func() { + defer close(resultChan) + + prefix := path + prefixLen := len(prefix) + offset := 0 + foundAny := false + + for { + listData := wshrpc.CommandFileListData{ + ZoneId: zoneId, + Prefix: prefix, + All: recursive, + Offset: offset, + Limit: 100, + } + + files, err := wshclient.FileListCommand(RpcClient, listData, &wshrpc.RpcOpts{Timeout: 2000}) + if err != nil { + resultChan <- fileListResult{err: err} + return + } + + if len(files) == 0 { + if !foundAny { + resultChan <- fileListResult{err: fmt.Errorf("%s: No such file or directory", path)} + } + return + } + + for _, f := range files { + if filesOnly && f.IsDir { + continue + } + foundAny = true + if prefixLen > 0 { + f.Name = f.Name[prefixLen:] + } + resultChan <- fileListResult{info: f} + } + + if len(files) < 100 { + return + } + offset += len(files) + } + }() + + return resultChan, nil +} diff --git a/cmd/wsh/cmd/wshcmd-file.go b/cmd/wsh/cmd/wshcmd-file.go new file mode 100644 index 000000000..1e74b462b --- /dev/null +++ b/cmd/wsh/cmd/wshcmd-file.go @@ -0,0 +1,632 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "bufio" + "bytes" + "encoding/base64" + "fmt" + "io" + "io/fs" + "net/url" + "os" + "path" + "path/filepath" + "strings" + "time" + + "github.com/spf13/cobra" + "github.com/wavetermdev/waveterm/pkg/util/colprint" + "github.com/wavetermdev/waveterm/pkg/util/utilfn" + "github.com/wavetermdev/waveterm/pkg/waveobj" + "github.com/wavetermdev/waveterm/pkg/wshrpc" + "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" + "golang.org/x/term" +) + +const ( + MaxFileSize = 10 * 1024 * 1024 // 10MB + WaveFileScheme = "wavefile" + WaveFilePrefix = "wavefile://" + + DefaultFileTimeout = 5000 +) + +var fileCmd = &cobra.Command{ + Use: "file", + Short: "manage Wave Terminal files", + Long: "Commands to manage Wave Terminal files stored in blocks", +} + +var fileTimeout int + +func init() { + rootCmd.AddCommand(fileCmd) + + fileCmd.PersistentFlags().IntVarP(&fileTimeout, "timeout", "t", 15000, "timeout in milliseconds for long operations") + + fileListCmd.Flags().BoolP("recursive", "r", false, "list subdirectories recursively") + fileListCmd.Flags().BoolP("long", "l", false, "use long listing format") + fileListCmd.Flags().BoolP("one", "1", false, "list one file per line") + fileListCmd.Flags().BoolP("files", "f", false, "list files only") + + fileCmd.AddCommand(fileListCmd) + fileCmd.AddCommand(fileCatCmd) + fileCmd.AddCommand(fileWriteCmd) + fileCmd.AddCommand(fileRmCmd) + fileCmd.AddCommand(fileInfoCmd) + fileCmd.AddCommand(fileAppendCmd) + fileCmd.AddCommand(fileCpCmd) +} + +type waveFileRef struct { + zoneId string + fileName string +} + +func parseWaveFileURL(fileURL string) (*waveFileRef, error) { + if !strings.HasPrefix(fileURL, WaveFilePrefix) { + return nil, fmt.Errorf("invalid file reference %q: must use wavefile:// URL format", fileURL) + } + + u, err := url.Parse(fileURL) + if err != nil { + return nil, fmt.Errorf("invalid wavefile URL: %w", err) + } + + if u.Scheme != WaveFileScheme { + return nil, fmt.Errorf("invalid URL scheme %q: must be wavefile://", u.Scheme) + } + + // Path must start with / + if !strings.HasPrefix(u.Path, "/") { + return nil, fmt.Errorf("invalid wavefile URL: path must start with /") + } + + // Must have a host (zone) + if u.Host == "" { + return nil, fmt.Errorf("invalid wavefile URL: must specify zone (e.g., wavefile://block/file.txt)") + } + + return &waveFileRef{ + zoneId: u.Host, + fileName: strings.TrimPrefix(u.Path, "/"), + }, nil +} + +func resolveWaveFile(ref *waveFileRef) (*waveobj.ORef, error) { + return resolveSimpleId(ref.zoneId) +} + +var fileListCmd = &cobra.Command{ + Use: "ls [wavefile://zone[/path]]", + Short: "list wave files", + Example: " wsh file ls wavefile://block/\n wsh file ls wavefile://client/configs/", + RunE: fileListRun, + PreRunE: preRunSetupRpcClient, +} + +var fileCatCmd = &cobra.Command{ + Use: "cat wavefile://zone/file", + Short: "display contents of a wave file", + Example: " wsh file cat wavefile://block/config.txt\n wsh file cat wavefile://client/settings.json", + Args: cobra.ExactArgs(1), + RunE: fileCatRun, + PreRunE: preRunSetupRpcClient, +} + +var fileInfoCmd = &cobra.Command{ + Use: "info wavefile://zone/file", + Short: "show wave file information", + Example: " wsh file info wavefile://block/config.txt", + Args: cobra.ExactArgs(1), + RunE: fileInfoRun, + PreRunE: preRunSetupRpcClient, +} + +var fileRmCmd = &cobra.Command{ + Use: "rm wavefile://zone/file", + Short: "remove a wave file", + Example: " wsh file rm wavefile://block/config.txt", + Args: cobra.ExactArgs(1), + RunE: fileRmRun, + PreRunE: preRunSetupRpcClient, +} + +var fileWriteCmd = &cobra.Command{ + Use: "write wavefile://zone/file", + Short: "write stdin into a wave file (up to 10MB)", + Example: " echo 'hello' | wsh file write wavefile://block/greeting.txt", + Args: cobra.ExactArgs(1), + RunE: fileWriteRun, + PreRunE: preRunSetupRpcClient, +} + +var fileAppendCmd = &cobra.Command{ + Use: "append wavefile://zone/file", + Short: "append stdin to a wave file", + Long: "append stdin to a wave file, buffering input and respecting 10MB total file size limit", + Example: " tail -f log.txt | wsh file append wavefile://block/app.log", + Args: cobra.ExactArgs(1), + RunE: fileAppendRun, + PreRunE: preRunSetupRpcClient, +} + +var fileCpCmd = &cobra.Command{ + Use: "cp source destination", + Short: "copy between wave files and local files", + Long: `Copy files between wave storage and local filesystem. +Exactly one of source or destination must be a wavefile:// URL.`, + Example: " wsh file cp wavefile://block/config.txt ./local-config.txt\n wsh file cp ./local-config.txt wavefile://block/config.txt", + Args: cobra.ExactArgs(2), + RunE: fileCpRun, + PreRunE: preRunSetupRpcClient, +} + +func fileCatRun(cmd *cobra.Command, args []string) error { + ref, err := parseWaveFileURL(args[0]) + if err != nil { + return err + } + + fullORef, err := resolveWaveFile(ref) + if err != nil { + return err + } + + fileData := wshrpc.CommandFileData{ + ZoneId: fullORef.OID, + FileName: ref.fileName, + } + + // Get file info first to check existence and get size + info, err := wshclient.FileInfoCommand(RpcClient, fileData, &wshrpc.RpcOpts{Timeout: 2000}) + err = convertNotFoundErr(err) + if err == fs.ErrNotExist { + return fmt.Errorf("%s: no such file", args[0]) + } + if err != nil { + return fmt.Errorf("getting file info: %w", err) + } + + err = streamReadFromWaveFile(fileData, info.Size, os.Stdout) + if err != nil { + return fmt.Errorf("reading file: %w", err) + } + + return nil +} + +func fileInfoRun(cmd *cobra.Command, args []string) error { + ref, err := parseWaveFileURL(args[0]) + if err != nil { + return err + } + + fullORef, err := resolveWaveFile(ref) + if err != nil { + return err + } + + fileData := wshrpc.CommandFileData{ + ZoneId: fullORef.OID, + FileName: ref.fileName, + } + + info, err := wshclient.FileInfoCommand(RpcClient, fileData, &wshrpc.RpcOpts{Timeout: DefaultFileTimeout}) + err = convertNotFoundErr(err) + if err == fs.ErrNotExist { + return fmt.Errorf("%s: no such file", args[0]) + } + if err != nil { + return fmt.Errorf("getting file info: %w", err) + } + + WriteStdout("filename: %s\n", info.Name) + WriteStdout("size: %d\n", info.Size) + WriteStdout("ctime: %s\n", time.Unix(info.CreatedTs/1000, 0).Format(time.DateTime)) + WriteStdout("mtime: %s\n", time.Unix(info.ModTs/1000, 0).Format(time.DateTime)) + if len(info.Meta) > 0 { + WriteStdout("metadata:\n") + for k, v := range info.Meta { + WriteStdout(" %s: %v\n", k, v) + } + } + return nil +} + +func fileRmRun(cmd *cobra.Command, args []string) error { + ref, err := parseWaveFileURL(args[0]) + if err != nil { + return err + } + + fullORef, err := resolveWaveFile(ref) + if err != nil { + return err + } + + fileData := wshrpc.CommandFileData{ + ZoneId: fullORef.OID, + FileName: ref.fileName, + } + + _, err = wshclient.FileInfoCommand(RpcClient, fileData, &wshrpc.RpcOpts{Timeout: DefaultFileTimeout}) + err = convertNotFoundErr(err) + if err == fs.ErrNotExist { + return fmt.Errorf("%s: no such file", args[0]) + } + if err != nil { + return fmt.Errorf("getting file info: %w", err) + } + + err = wshclient.FileDeleteCommand(RpcClient, fileData, &wshrpc.RpcOpts{Timeout: DefaultFileTimeout}) + if err != nil { + return fmt.Errorf("removing file: %w", err) + } + + return nil +} + +func fileWriteRun(cmd *cobra.Command, args []string) error { + ref, err := parseWaveFileURL(args[0]) + if err != nil { + return err + } + + fullORef, err := resolveWaveFile(ref) + if err != nil { + return err + } + + fileData := wshrpc.CommandFileData{ + ZoneId: fullORef.OID, + FileName: ref.fileName, + } + + _, err = ensureWaveFile(args[0], fileData) + if err != nil { + return err + } + + err = streamWriteToWaveFile(fileData, WrappedStdin) + if err != nil { + return fmt.Errorf("writing file: %w", err) + } + + return nil +} + +func fileAppendRun(cmd *cobra.Command, args []string) error { + ref, err := parseWaveFileURL(args[0]) + if err != nil { + return err + } + + fullORef, err := resolveWaveFile(ref) + if err != nil { + return err + } + + fileData := wshrpc.CommandFileData{ + ZoneId: fullORef.OID, + FileName: ref.fileName, + } + + info, err := ensureWaveFile(args[0], fileData) + if err != nil { + return err + } + if info.Size >= MaxFileSize { + return fmt.Errorf("file already at maximum size (%d bytes)", MaxFileSize) + } + + reader := bufio.NewReader(WrappedStdin) + var buf bytes.Buffer + remainingSpace := MaxFileSize - info.Size + for { + chunk := make([]byte, 8192) + n, err := reader.Read(chunk) + if err == io.EOF { + break + } + if err != nil { + return fmt.Errorf("reading input: %w", err) + } + + if int64(buf.Len()+n) > remainingSpace { + return fmt.Errorf("append would exceed maximum file size of %d bytes", MaxFileSize) + } + + buf.Write(chunk[:n]) + + if buf.Len() >= 8192 { // 8KB batch size + fileData.Data64 = base64.StdEncoding.EncodeToString(buf.Bytes()) + err = wshclient.FileAppendCommand(RpcClient, fileData, &wshrpc.RpcOpts{Timeout: fileTimeout}) + if err != nil { + return fmt.Errorf("appending to file: %w", err) + } + remainingSpace -= int64(buf.Len()) + buf.Reset() + } + } + + if buf.Len() > 0 { + fileData.Data64 = base64.StdEncoding.EncodeToString(buf.Bytes()) + err = wshclient.FileAppendCommand(RpcClient, fileData, &wshrpc.RpcOpts{Timeout: fileTimeout}) + if err != nil { + return fmt.Errorf("appending to file: %w", err) + } + } + + return nil +} + +func getTargetPath(src, dst string) (string, error) { + var srcBase string + if strings.HasPrefix(src, WaveFilePrefix) { + srcBase = path.Base(src) + } else { + srcBase = filepath.Base(src) + } + + if strings.HasPrefix(dst, WaveFilePrefix) { + // For wavefile URLs + if strings.HasSuffix(dst, "/") { + return dst + srcBase, nil + } + return dst, nil + } + + // For local paths + dstInfo, err := os.Stat(dst) + if err == nil && dstInfo.IsDir() { + // If it's an existing directory, use the source filename + return filepath.Join(dst, srcBase), nil + } + if err != nil && !os.IsNotExist(err) { + // Return error if it's something other than not exists + return "", fmt.Errorf("checking destination path: %w", err) + } + + return dst, nil +} + +func fileCpRun(cmd *cobra.Command, args []string) error { + src, origDst := args[0], args[1] + dst, err := getTargetPath(src, origDst) + if err != nil { + return err + } + srcIsWave := strings.HasPrefix(src, WaveFilePrefix) + dstIsWave := strings.HasPrefix(dst, WaveFilePrefix) + + if srcIsWave == dstIsWave { + return fmt.Errorf("exactly one file must be a wavefile:// URL") + } + + if srcIsWave { + return copyFromWaveToLocal(src, dst) + } else { + return copyFromLocalToWave(src, dst) + } +} + +func copyFromWaveToLocal(src, dst string) error { + ref, err := parseWaveFileURL(src) + if err != nil { + return err + } + + fullORef, err := resolveWaveFile(ref) + if err != nil { + return err + } + + fileData := wshrpc.CommandFileData{ + ZoneId: fullORef.OID, + FileName: ref.fileName, + } + + // Get file info first to check existence and get size + info, err := wshclient.FileInfoCommand(RpcClient, fileData, &wshrpc.RpcOpts{Timeout: 2000}) + err = convertNotFoundErr(err) + if err == fs.ErrNotExist { + return fmt.Errorf("%s: no such file", src) + } + if err != nil { + return fmt.Errorf("getting file info: %w", err) + } + + // Create the destination file + f, err := os.Create(dst) + if err != nil { + return fmt.Errorf("creating local file: %w", err) + } + defer f.Close() + + err = streamReadFromWaveFile(fileData, info.Size, f) + if err != nil { + return fmt.Errorf("reading wave file: %w", err) + } + + return nil +} + +func copyFromLocalToWave(src, dst string) error { + ref, err := parseWaveFileURL(dst) + if err != nil { + return err + } + + fullORef, err := resolveWaveFile(ref) + if err != nil { + return err + } + + // stat local file + stat, err := os.Stat(src) + if err == fs.ErrNotExist { + return fmt.Errorf("%s: no such file", src) + } + if err != nil { + return fmt.Errorf("stat local file: %w", err) + } + if stat.IsDir() { + return fmt.Errorf("%s: is a directory", src) + } + + fileData := wshrpc.CommandFileData{ + ZoneId: fullORef.OID, + FileName: ref.fileName, + } + + _, err = ensureWaveFile(dst, fileData) + if err != nil { + return err + } + + file, err := os.Open(src) + if err != nil { + return fmt.Errorf("opening local file: %w", err) + } + defer file.Close() + + err = streamWriteToWaveFile(fileData, file) + if err != nil { + return fmt.Errorf("writing wave file: %w", err) + } + + return nil +} + +func filePrintColumns(filesChan <-chan fileListResult) error { + width := 80 // default if we can't get terminal + if w, _, err := term.GetSize(int(os.Stdout.Fd())); err == nil { + width = w + } + + numCols := width / 10 + if numCols < 1 { + numCols = 1 + } + + return colprint.PrintColumns( + filesChan, + numCols, + 100, // sample size + func(f fileListResult) (string, error) { + if f.err != nil { + return "", f.err + } + return f.info.Name, nil + }, + os.Stdout, + ) +} + +func filePrintLong(filesChan <-chan fileListResult) error { + // Sample first 100 files to determine name width + maxNameLen := 0 + var samples []*wshrpc.WaveFileInfo + + for f := range filesChan { + if f.err != nil { + return f.err + } + samples = append(samples, f.info) + if len(f.info.Name) > maxNameLen { + maxNameLen = len(f.info.Name) + } + + if len(samples) >= 100 { + break + } + } + + // Use sampled width, but cap it at 60 chars to prevent excessive width + nameWidth := maxNameLen + 2 + if nameWidth > 60 { + nameWidth = 60 + } + + // Print samples + for _, f := range samples { + name := f.Name + t := time.Unix(f.ModTs/1000, 0) + timestamp := utilfn.FormatLsTime(t) + if f.Size == 0 && strings.HasSuffix(name, "/") { + fmt.Fprintf(os.Stdout, "%-*s %8s %s\n", nameWidth, name, "-", timestamp) + } else { + fmt.Fprintf(os.Stdout, "%-*s %8d %s\n", nameWidth, name, f.Size, timestamp) + } + } + + // Continue with remaining files + for f := range filesChan { + if f.err != nil { + return f.err + } + name := f.info.Name + timestamp := time.Unix(f.info.ModTs/1000, 0).Format("Jan 02 15:04") + if f.info.Size == 0 && strings.HasSuffix(name, "/") { + fmt.Fprintf(os.Stdout, "%-*s %8s %s\n", nameWidth, name, "-", timestamp) + } else { + fmt.Fprintf(os.Stdout, "%-*s %8d %s\n", nameWidth, name, f.info.Size, timestamp) + } + } + + return nil +} + +func fileListRun(cmd *cobra.Command, args []string) error { + recursive, _ := cmd.Flags().GetBool("recursive") + longForm, _ := cmd.Flags().GetBool("long") + onePerLine, _ := cmd.Flags().GetBool("one") + filesOnly, _ := cmd.Flags().GetBool("files") + + // Check if we're in a pipe + stat, _ := os.Stdout.Stat() + isPipe := (stat.Mode() & os.ModeCharDevice) == 0 + if isPipe { + onePerLine = true + } + + // Default to listing everything if no path specified + if len(args) == 0 { + args = append(args, "wavefile://client/") + } + + ref, err := parseWaveFileURL(args[0]) + if err != nil { + return err + } + + fullORef, err := resolveWaveFile(ref) + if err != nil { + return err + } + + filesChan, err := streamFileList(fullORef.OID, ref.fileName, recursive, filesOnly) + if err != nil { + return err + } + + if longForm { + return filePrintLong(filesChan) + } + + if onePerLine { + for f := range filesChan { + if f.err != nil { + return f.err + } + fmt.Fprintln(os.Stdout, f.info.Name) + } + return nil + } + + return filePrintColumns(filesChan) +} diff --git a/cmd/wsh/cmd/wshcmd-getmeta.go b/cmd/wsh/cmd/wshcmd-getmeta.go index 221423021..a70786844 100644 --- a/cmd/wsh/cmd/wshcmd-getmeta.go +++ b/cmd/wsh/cmd/wshcmd-getmeta.go @@ -76,12 +76,7 @@ func getMetaRun(cmd *cobra.Command, args []string) (rtnErr error) { defer func() { sendActivity("getmeta", rtnErr == nil) }() - - oref := blockArg - if oref == "" { - return fmt.Errorf("blockid is required") - } - fullORef, err := resolveSimpleId(oref) + fullORef, err := resolveBlockArg() if err != nil { return err } diff --git a/cmd/wsh/cmd/wshcmd-getvar.go b/cmd/wsh/cmd/wshcmd-getvar.go new file mode 100644 index 000000000..36740704c --- /dev/null +++ b/cmd/wsh/cmd/wshcmd-getvar.go @@ -0,0 +1,132 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "encoding/base64" + "fmt" + "io/fs" + "sort" + + "github.com/spf13/cobra" + "github.com/wavetermdev/waveterm/pkg/util/envutil" + "github.com/wavetermdev/waveterm/pkg/wshrpc" + "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" +) + +var getVarCmd = &cobra.Command{ + Use: "getvar [flags] [key]", + Short: "get variable(s) from a block", + Long: `Get variable(s) from a block. Without --all, requires a key argument. +With --all, prints all variables. Use -0 for null-terminated output.`, + Example: " wsh getvar FOO\n wsh getvar --all\n wsh getvar --all -0", + RunE: getVarRun, + PreRunE: preRunSetupRpcClient, +} + +var ( + getVarFileName string + getVarAllVars bool + getVarNullTerminate bool + getVarLocal bool +) + +func init() { + rootCmd.AddCommand(getVarCmd) + getVarCmd.Flags().StringVar(&getVarFileName, "varfile", DefaultVarFileName, "var file name") + getVarCmd.Flags().BoolVar(&getVarAllVars, "all", false, "get all variables") + getVarCmd.Flags().BoolVarP(&getVarNullTerminate, "null", "0", false, "use null terminators in output") + getVarCmd.Flags().BoolVarP(&getVarLocal, "local", "l", false, "get variables local to block") +} + +func getVarRun(cmd *cobra.Command, args []string) error { + defer func() { + sendActivity("getvar", WshExitCode == 0) + }() + + // Resolve block to get zoneId + if blockArg == "" { + if getVarLocal { + blockArg = "this" + } else { + blockArg = "client" + } + } + fullORef, err := resolveBlockArg() + if err != nil { + return err + } + + if getVarAllVars { + if len(args) > 0 { + return fmt.Errorf("cannot specify key with --all") + } + return getAllVariables(fullORef.OID) + } + + // Single variable case - existing logic + if len(args) != 1 { + return fmt.Errorf("requires a key argument") + } + + key := args[0] + commandData := wshrpc.CommandVarData{ + Key: key, + ZoneId: fullORef.OID, + FileName: getVarFileName, + } + + resp, err := wshclient.GetVarCommand(RpcClient, commandData, &wshrpc.RpcOpts{Timeout: 2000}) + if err != nil { + return fmt.Errorf("getting variable: %w", err) + } + + if !resp.Exists { + WshExitCode = 1 + return nil + } + + WriteStdout("%s\n", resp.Val) + return nil +} + +func getAllVariables(zoneId string) error { + fileData := wshrpc.CommandFileData{ + ZoneId: zoneId, + FileName: getVarFileName, + } + + envStr64, err := wshclient.FileReadCommand(RpcClient, fileData, &wshrpc.RpcOpts{Timeout: 2000}) + err = convertNotFoundErr(err) + if err == fs.ErrNotExist { + return nil + } + if err != nil { + return fmt.Errorf("reading variables: %w", err) + } + envBytes, err := base64.StdEncoding.DecodeString(envStr64) + if err != nil { + return fmt.Errorf("decoding variables: %w", err) + } + + envMap := envutil.EnvToMap(string(envBytes)) + + terminator := "\n" + if getVarNullTerminate { + terminator = "\x00" + } + + // Sort keys for consistent output + keys := make([]string, 0, len(envMap)) + for k := range envMap { + keys = append(keys, k) + } + sort.Strings(keys) + + for _, k := range keys { + WriteStdout("%s=%s%s", k, envMap[k], terminator) + } + + return nil +} diff --git a/cmd/wsh/cmd/wshcmd-readfile.go b/cmd/wsh/cmd/wshcmd-readfile.go index 2f2ecbd50..0c338fe7f 100644 --- a/cmd/wsh/cmd/wshcmd-readfile.go +++ b/cmd/wsh/cmd/wshcmd-readfile.go @@ -12,7 +12,7 @@ import ( ) var readFileCmd = &cobra.Command{ - Use: "readfile", + Use: "readfile [filename]", Short: "read a blockfile", Args: cobra.ExactArgs(1), Run: runReadFile, @@ -24,17 +24,12 @@ func init() { } func runReadFile(cmd *cobra.Command, args []string) { - oref := args[0] - if oref == "" { - WriteStderr("[error] oref is required\n") - return - } - fullORef, err := resolveSimpleId(oref) + fullORef, err := resolveBlockArg() if err != nil { WriteStderr("[error] %v\n", err) return } - resp64, err := wshclient.FileReadCommand(RpcClient, wshrpc.CommandFileData{ZoneId: fullORef.OID, FileName: args[1]}, &wshrpc.RpcOpts{Timeout: 5000}) + resp64, err := wshclient.FileReadCommand(RpcClient, wshrpc.CommandFileData{ZoneId: fullORef.OID, FileName: args[0]}, &wshrpc.RpcOpts{Timeout: 5000}) if err != nil { WriteStderr("[error] reading file: %v\n", err) return diff --git a/cmd/wsh/cmd/wshcmd-root.go b/cmd/wsh/cmd/wshcmd-root.go index 928d6d78a..f7bf2bb3b 100644 --- a/cmd/wsh/cmd/wshcmd-root.go +++ b/cmd/wsh/cmd/wshcmd-root.go @@ -31,6 +31,7 @@ var RpcClient *wshutil.WshRpc var RpcContext wshrpc.RpcContext var UsingTermWshMode bool var blockArg string +var WshExitCode int func WriteStderr(fmtStr string, args ...interface{}) { output := fmt.Sprintf(fmtStr, args...) @@ -59,7 +60,7 @@ func preRunSetupRpcClient(cmd *cobra.Command, args []string) error { func resolveBlockArg() (*waveobj.ORef, error) { oref := blockArg if oref == "" { - return nil, fmt.Errorf("blockid is required") + oref = "this" } fullORef, err := resolveSimpleId(oref) if err != nil { @@ -145,10 +146,10 @@ func Execute() { debug.PrintStack() wshutil.DoShutdown("", 1, true) } else { - wshutil.DoShutdown("", 0, false) + wshutil.DoShutdown("", WshExitCode, false) } }() - rootCmd.PersistentFlags().StringVarP(&blockArg, "block", "b", "this", "for commands which require a block id") + rootCmd.PersistentFlags().StringVarP(&blockArg, "block", "b", "", "for commands which require a block id") err := rootCmd.Execute() if err != nil { wshutil.DoShutdown("", 1, true) diff --git a/cmd/wsh/cmd/wshcmd-setmeta.go b/cmd/wsh/cmd/wshcmd-setmeta.go index 556d498dd..ae0b9b4a8 100644 --- a/cmd/wsh/cmd/wshcmd-setmeta.go +++ b/cmd/wsh/cmd/wshcmd-setmeta.go @@ -111,10 +111,6 @@ func setMetaRun(cmd *cobra.Command, args []string) (rtnErr error) { defer func() { sendActivity("setmeta", rtnErr == nil) }() - - if blockArg == "" { - return fmt.Errorf("block (oref) is required") - } var jsonMeta map[string]interface{} if setMetaJsonFilePath != "" { var err error @@ -139,7 +135,7 @@ func setMetaRun(cmd *cobra.Command, args []string) (rtnErr error) { if len(fullMeta) == 0 { return fmt.Errorf("no metadata keys specified") } - fullORef, err := resolveSimpleId(blockArg) + fullORef, err := resolveBlockArg() if err != nil { return err } diff --git a/cmd/wsh/cmd/wshcmd-setvar.go b/cmd/wsh/cmd/wshcmd-setvar.go new file mode 100644 index 000000000..a9f31d5f8 --- /dev/null +++ b/cmd/wsh/cmd/wshcmd-setvar.go @@ -0,0 +1,101 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + "github.com/wavetermdev/waveterm/pkg/wshrpc" + "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" +) + +const DefaultVarFileName = "var" + +var setVarCmd = &cobra.Command{ + Use: "setvar [flags] KEY=VALUE...", + Short: "set variable(s) for a block", + Long: `Set one or more variables for a block. +Use --remove/-r to remove variables instead of setting them. +When setting, each argument must be in KEY=VALUE format. +When removing, each argument is treated as a key to remove.`, + Example: " wsh setvar FOO=bar BAZ=123\n wsh setvar -r FOO BAZ", + Args: cobra.MinimumNArgs(1), + RunE: setVarRun, + PreRunE: preRunSetupRpcClient, +} + +var ( + setVarFileName string + setVarRemoveVar bool + setVarLocal bool +) + +func init() { + rootCmd.AddCommand(setVarCmd) + setVarCmd.Flags().StringVar(&setVarFileName, "varfile", DefaultVarFileName, "var file name") + setVarCmd.Flags().BoolVarP(&setVarLocal, "local", "l", false, "set variables local to block") + setVarCmd.Flags().BoolVarP(&setVarRemoveVar, "remove", "r", false, "remove the variable(s) instead of setting") +} + +func parseKeyValue(arg string) (key, value string, err error) { + if setVarRemoveVar { + return arg, "", nil + } + + parts := strings.SplitN(arg, "=", 2) + if len(parts) != 2 { + return "", "", fmt.Errorf("invalid KEY=VALUE format %q (= sign required)", arg) + } + key = parts[0] + if key == "" { + return "", "", fmt.Errorf("empty key not allowed") + } + return key, parts[1], nil +} + +func setVarRun(cmd *cobra.Command, args []string) (rtnErr error) { + defer func() { + sendActivity("setvar", rtnErr == nil) + }() + + // Resolve block to get zoneId + if blockArg == "" { + if getVarLocal { + blockArg = "this" + } else { + blockArg = "client" + } + } + fullORef, err := resolveBlockArg() + if err != nil { + return err + } + + // Process all variables + for _, arg := range args { + key, value, err := parseKeyValue(arg) + if err != nil { + return err + } + + commandData := wshrpc.CommandVarData{ + Key: key, + ZoneId: fullORef.OID, + FileName: setVarFileName, + Remove: setVarRemoveVar, + } + + if !setVarRemoveVar { + commandData.Val = value + } + + err = wshclient.SetVarCommand(RpcClient, commandData, &wshrpc.RpcOpts{Timeout: 2000}) + if err != nil { + return fmt.Errorf("setting variable %s: %w", key, err) + } + } + return nil +} diff --git a/cmd/wsh/cmd/wshcmd-web.go b/cmd/wsh/cmd/wshcmd-web.go index 8af5b4c26..a825a0d46 100644 --- a/cmd/wsh/cmd/wshcmd-web.go +++ b/cmd/wsh/cmd/wshcmd-web.go @@ -51,11 +51,7 @@ func init() { } func webGetRun(cmd *cobra.Command, args []string) error { - oref := blockArg - if oref == "" { - return fmt.Errorf("blockid not specified") - } - fullORef, err := resolveSimpleId(oref) + fullORef, err := resolveBlockArg() if err != nil { return fmt.Errorf("resolving blockid: %w", err) } diff --git a/frontend/app/store/wshclientapi.ts b/frontend/app/store/wshclientapi.ts index 9d091593f..7c8b693fc 100644 --- a/frontend/app/store/wshclientapi.ts +++ b/frontend/app/store/wshclientapi.ts @@ -137,6 +137,26 @@ class RpcApiType { return client.wshRpcCall("fileappendijson", data, opts); } + // command "filecreate" [call] + FileCreateCommand(client: WshClient, data: CommandFileCreateData, opts?: RpcOpts): Promise { + return client.wshRpcCall("filecreate", data, opts); + } + + // command "filedelete" [call] + FileDeleteCommand(client: WshClient, data: CommandFileData, opts?: RpcOpts): Promise { + return client.wshRpcCall("filedelete", data, opts); + } + + // command "fileinfo" [call] + FileInfoCommand(client: WshClient, data: CommandFileData, opts?: RpcOpts): Promise { + return client.wshRpcCall("fileinfo", data, opts); + } + + // command "filelist" [call] + FileListCommand(client: WshClient, data: CommandFileListData, opts?: RpcOpts): Promise { + return client.wshRpcCall("filelist", data, opts); + } + // command "fileread" [call] FileReadCommand(client: WshClient, data: CommandFileData, opts?: RpcOpts): Promise { return client.wshRpcCall("fileread", data, opts); @@ -157,6 +177,11 @@ class RpcApiType { return client.wshRpcCall("getupdatechannel", null, opts); } + // command "getvar" [call] + GetVarCommand(client: WshClient, data: CommandVarData, opts?: RpcOpts): Promise { + return client.wshRpcCall("getvar", data, opts); + } + // command "message" [call] MessageCommand(client: WshClient, data: CommandMessageData, opts?: RpcOpts): Promise { return client.wshRpcCall("message", data, opts); @@ -222,6 +247,11 @@ class RpcApiType { return client.wshRpcCall("setmeta", data, opts); } + // command "setvar" [call] + SetVarCommand(client: WshClient, data: CommandVarData, opts?: RpcOpts): Promise { + return client.wshRpcCall("setvar", data, opts); + } + // command "setview" [call] SetViewCommand(client: WshClient, data: CommandBlockSetViewData, opts?: RpcOpts): Promise { return client.wshRpcCall("setview", data, opts); diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index d18bca397..8153127c6 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -148,11 +148,35 @@ declare global { maxitems: number; }; + // wshrpc.CommandFileCreateData + type CommandFileCreateData = { + zoneid: string; + filename: string; + meta?: {[key: string]: any}; + opts?: FileOptsType; + }; + // wshrpc.CommandFileData type CommandFileData = { zoneid: string; filename: string; data64?: string; + at?: CommandFileDataAt; + }; + + // wshrpc.CommandFileDataAt + type CommandFileDataAt = { + offset: number; + size?: number; + }; + + // wshrpc.CommandFileListData + type CommandFileListData = { + zoneid: string; + prefix?: string; + all?: boolean; + offset?: number; + limit?: number; }; // wshrpc.CommandGetMetaData @@ -202,6 +226,22 @@ declare global { meta: MetaType; }; + // wshrpc.CommandVarData + type CommandVarData = { + key: string; + val?: string; + remove?: boolean; + zoneid: string; + filename: string; + }; + + // wshrpc.CommandVarResponseData + type CommandVarResponseData = { + key: string; + val: string; + exists: boolean; + }; + // wshrpc.CommandWaitForRouteData type CommandWaitForRouteData = { routeid: string; @@ -904,6 +944,18 @@ declare global { meta: {[key: string]: any}; }; + // wshrpc.WaveFileInfo + type WaveFileInfo = { + zoneid: string; + name: string; + opts?: FileOptsType; + size?: number; + createdts?: number; + modts?: number; + meta?: {[key: string]: any}; + isdir?: boolean; + }; + // wshrpc.WaveInfoData type WaveInfoData = { version: string; diff --git a/pkg/util/colprint/colprint.go b/pkg/util/colprint/colprint.go new file mode 100644 index 000000000..1bba5eabd --- /dev/null +++ b/pkg/util/colprint/colprint.go @@ -0,0 +1,88 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package colprint + +import ( + "fmt" + "io" +) + +// formatFn is a function that converts a value of type T to its string representation +type formatFn[T any] func(T) (string, error) + +// PrintColumns prints values in columns, adapting to long values by letting them span multiple columns +func PrintColumns[T any](values <-chan T, numCols int, sampleSize int, format formatFn[T], w io.Writer) error { + // Get first batch and determine column width + maxLen := 0 + var samples []T + + for v := range values { + samples = append(samples, v) + str, err := format(v) + if err != nil { + return err + } + if len(str) > maxLen { + maxLen = len(str) + } + if len(samples) >= sampleSize { + break + } + } + + colWidth := maxLen + 2 // Add minimum padding + if colWidth < 1 { + colWidth = 1 + } + + // Print in columns using our determined width + col := 0 + for _, v := range samples { + str, err := format(v) + if err != nil { + return err + } + if err := printColHelper(str, colWidth, &col, numCols, w); err != nil { + return err + } + } + + // Continue with any remaining values + for v := range values { + str, err := format(v) + if err != nil { + return err + } + if err := printColHelper(str, colWidth, &col, numCols, w); err != nil { + return err + } + } + + if col > 0 { + if _, err := fmt.Fprint(w, "\n"); err != nil { + return err + } + } + return nil +} + +func printColHelper(str string, colWidth int, col *int, numCols int, w io.Writer) error { + nameColSpan := (len(str) + 1) / colWidth + if (len(str)+1)%colWidth != 0 { + nameColSpan++ + } + + if *col+nameColSpan > numCols { + if _, err := fmt.Fprint(w, "\n"); err != nil { + return err + } + *col = 0 + } + + if _, err := fmt.Fprintf(w, "%-*s", nameColSpan*colWidth, str); err != nil { + return err + } + *col += nameColSpan + return nil +} diff --git a/pkg/util/envutil/envutil.go b/pkg/util/envutil/envutil.go new file mode 100644 index 000000000..3915b856a --- /dev/null +++ b/pkg/util/envutil/envutil.go @@ -0,0 +1,69 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package envutil + +import ( + "fmt" + "strings" +) + +const MaxEnvSize = 1024 * 1024 + +// env format: +// KEY=VALUE\0 +// keys cannot have '=' or '\0' in them +// values can have '=' but not '\0' + +func EnvToMap(envStr string) map[string]string { + rtn := make(map[string]string) + envLines := strings.Split(envStr, "\x00") + for _, line := range envLines { + if len(line) == 0 { + continue + } + parts := strings.SplitN(line, "=", 2) + if len(parts) == 2 { + rtn[parts[0]] = parts[1] + } + } + return rtn +} + +func MapToEnv(envMap map[string]string) string { + var sb strings.Builder + for key, val := range envMap { + sb.WriteString(key) + sb.WriteByte('=') + sb.WriteString(val) + sb.WriteByte('\x00') + } + return sb.String() +} + +func GetEnv(envStr string, key string) string { + envMap := EnvToMap(envStr) + return envMap[key] +} + +func SetEnv(envStr string, key string, val string) (string, error) { + if strings.ContainsAny(key, "=\x00") { + return "", fmt.Errorf("key cannot contain '=' or '\\x00'") + } + if strings.Contains(val, "\x00") { + return "", fmt.Errorf("value cannot contain '\\x00'") + } + if len(key)+len(val)+2+len(envStr) > MaxEnvSize { + return "", fmt.Errorf("env string too large (max %d bytes)", MaxEnvSize) + } + envMap := EnvToMap(envStr) + envMap[key] = val + rtnStr := MapToEnv(envMap) + return rtnStr, nil +} + +func RmEnv(envStr string, key string) string { + envMap := EnvToMap(envStr) + delete(envMap, key) + return MapToEnv(envMap) +} diff --git a/pkg/util/utilfn/utilfn.go b/pkg/util/utilfn/utilfn.go index 2e3acdfeb..b9539ea6f 100644 --- a/pkg/util/utilfn/utilfn.go +++ b/pkg/util/utilfn/utilfn.go @@ -28,6 +28,7 @@ import ( "strings" "syscall" "text/template" + "time" "unicode/utf8" ) @@ -923,3 +924,16 @@ func GetLineColFromOffset(barr []byte, offset int) (int, int) { } return line, col } + +func FormatLsTime(t time.Time) string { + now := time.Now() + sixMonthsAgo := now.AddDate(0, -6, 0) + + if t.After(sixMonthsAgo) { + // Recent files: "Nov 18 18:40" + return t.Format("Jan _2 15:04") + } else { + // Older files: "Apr 12 2024" + return t.Format("Jan _2 2006") + } +} diff --git a/pkg/wps/wpstypes.go b/pkg/wps/wpstypes.go index d0e6d4202..e3bcdd45f 100644 --- a/pkg/wps/wpstypes.go +++ b/pkg/wps/wpstypes.go @@ -33,6 +33,8 @@ type SubscriptionRequest struct { } const ( + FileOp_Create = "create" + FileOp_Delete = "delete" FileOp_Append = "append" FileOp_Truncate = "truncate" FileOp_Invalidate = "invalidate" diff --git a/pkg/wshrpc/wshclient/wshclient.go b/pkg/wshrpc/wshclient/wshclient.go index 81d11e360..cf943b436 100644 --- a/pkg/wshrpc/wshclient/wshclient.go +++ b/pkg/wshrpc/wshclient/wshclient.go @@ -171,6 +171,30 @@ func FileAppendIJsonCommand(w *wshutil.WshRpc, data wshrpc.CommandAppendIJsonDat return err } +// command "filecreate", wshserver.FileCreateCommand +func FileCreateCommand(w *wshutil.WshRpc, data wshrpc.CommandFileCreateData, opts *wshrpc.RpcOpts) error { + _, err := sendRpcRequestCallHelper[any](w, "filecreate", data, opts) + return err +} + +// command "filedelete", wshserver.FileDeleteCommand +func FileDeleteCommand(w *wshutil.WshRpc, data wshrpc.CommandFileData, opts *wshrpc.RpcOpts) error { + _, err := sendRpcRequestCallHelper[any](w, "filedelete", data, opts) + return err +} + +// command "fileinfo", wshserver.FileInfoCommand +func FileInfoCommand(w *wshutil.WshRpc, data wshrpc.CommandFileData, opts *wshrpc.RpcOpts) (*wshrpc.WaveFileInfo, error) { + resp, err := sendRpcRequestCallHelper[*wshrpc.WaveFileInfo](w, "fileinfo", data, opts) + return resp, err +} + +// command "filelist", wshserver.FileListCommand +func FileListCommand(w *wshutil.WshRpc, data wshrpc.CommandFileListData, opts *wshrpc.RpcOpts) ([]*wshrpc.WaveFileInfo, error) { + resp, err := sendRpcRequestCallHelper[[]*wshrpc.WaveFileInfo](w, "filelist", data, opts) + return resp, err +} + // command "fileread", wshserver.FileReadCommand func FileReadCommand(w *wshutil.WshRpc, data wshrpc.CommandFileData, opts *wshrpc.RpcOpts) (string, error) { resp, err := sendRpcRequestCallHelper[string](w, "fileread", data, opts) @@ -195,6 +219,12 @@ func GetUpdateChannelCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) (string, e return resp, err } +// command "getvar", wshserver.GetVarCommand +func GetVarCommand(w *wshutil.WshRpc, data wshrpc.CommandVarData, opts *wshrpc.RpcOpts) (*wshrpc.CommandVarResponseData, error) { + resp, err := sendRpcRequestCallHelper[*wshrpc.CommandVarResponseData](w, "getvar", data, opts) + return resp, err +} + // command "message", wshserver.MessageCommand func MessageCommand(w *wshutil.WshRpc, data wshrpc.CommandMessageData, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "message", data, opts) @@ -271,6 +301,12 @@ func SetMetaCommand(w *wshutil.WshRpc, data wshrpc.CommandSetMetaData, opts *wsh return err } +// command "setvar", wshserver.SetVarCommand +func SetVarCommand(w *wshutil.WshRpc, data wshrpc.CommandVarData, opts *wshrpc.RpcOpts) error { + _, err := sendRpcRequestCallHelper[any](w, "setvar", data, opts) + return err +} + // command "setview", wshserver.SetViewCommand func SetViewCommand(w *wshutil.WshRpc, data wshrpc.CommandBlockSetViewData, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "setview", data, opts) diff --git a/pkg/wshrpc/wshrpctypes.go b/pkg/wshrpc/wshrpctypes.go index 3d9752b6f..949b11c46 100644 --- a/pkg/wshrpc/wshrpctypes.go +++ b/pkg/wshrpc/wshrpctypes.go @@ -10,6 +10,7 @@ import ( "os" "reflect" + "github.com/wavetermdev/waveterm/pkg/filestore" "github.com/wavetermdev/waveterm/pkg/ijson" "github.com/wavetermdev/waveterm/pkg/telemetry" "github.com/wavetermdev/waveterm/pkg/vdom" @@ -66,6 +67,8 @@ const ( Command_WaveInfo = "waveinfo" Command_WshActivity = "wshactivity" Command_Activity = "activity" + Command_GetVar = "getvar" + Command_SetVar = "setvar" Command_ConnStatus = "connstatus" Command_WslStatus = "wslstatus" @@ -107,16 +110,20 @@ type WshRpcInterface interface { ControllerInputCommand(ctx context.Context, data CommandBlockInputData) error ControllerStopCommand(ctx context.Context, blockId string) error ControllerResyncCommand(ctx context.Context, data CommandControllerResyncData) error - FileAppendCommand(ctx context.Context, data CommandFileData) error - FileAppendIJsonCommand(ctx context.Context, data CommandAppendIJsonData) error ResolveIdsCommand(ctx context.Context, data CommandResolveIdsData) (CommandResolveIdsRtnData, error) CreateBlockCommand(ctx context.Context, data CommandCreateBlockData) (waveobj.ORef, error) CreateSubBlockCommand(ctx context.Context, data CommandCreateSubBlockData) (waveobj.ORef, error) DeleteBlockCommand(ctx context.Context, data CommandDeleteBlockData) error DeleteSubBlockCommand(ctx context.Context, data CommandDeleteBlockData) error WaitForRouteCommand(ctx context.Context, data CommandWaitForRouteData) (bool, error) + FileCreateCommand(ctx context.Context, data CommandFileCreateData) error + FileDeleteCommand(ctx context.Context, data CommandFileData) error + FileAppendCommand(ctx context.Context, data CommandFileData) error + FileAppendIJsonCommand(ctx context.Context, data CommandAppendIJsonData) error FileWriteCommand(ctx context.Context, data CommandFileData) error FileReadCommand(ctx context.Context, data CommandFileData) (string, error) + FileInfoCommand(ctx context.Context, data CommandFileData) (*WaveFileInfo, error) + FileListCommand(ctx context.Context, data CommandFileListData) ([]*WaveFileInfo, error) EventPublishCommand(ctx context.Context, data wps.WaveEvent) error EventSubCommand(ctx context.Context, data wps.SubscriptionRequest) error EventUnsubCommand(ctx context.Context, data string) error @@ -131,6 +138,8 @@ type WshRpcInterface interface { WaveInfoCommand(ctx context.Context) (*WaveInfoData, error) WshActivityCommand(ct context.Context, data map[string]int) error ActivityCommand(ctx context.Context, data telemetry.ActivityUpdate) error + GetVarCommand(ctx context.Context, data CommandVarData) (*CommandVarResponseData, error) + SetVarCommand(ctx context.Context, data CommandVarData) error // connection functions ConnStatusCommand(ctx context.Context) ([]ConnStatus, error) @@ -291,10 +300,42 @@ type CommandBlockInputData struct { TermSize *waveobj.TermSize `json:"termsize,omitempty"` } +type CommandFileDataAt struct { + Offset int64 `json:"offset"` + Size int64 `json:"size,omitempty"` +} + type CommandFileData struct { - ZoneId string `json:"zoneid" wshcontext:"BlockId"` - FileName string `json:"filename"` - Data64 string `json:"data64,omitempty"` + ZoneId string `json:"zoneid" wshcontext:"BlockId"` + FileName string `json:"filename"` + Data64 string `json:"data64,omitempty"` + At *CommandFileDataAt `json:"at,omitempty"` // if set, this turns read/write ops to ReadAt/WriteAt ops (len is only used for ReadAt) +} + +type WaveFileInfo struct { + ZoneId string `json:"zoneid"` + Name string `json:"name"` + Opts filestore.FileOptsType `json:"opts,omitempty"` + Size int64 `json:"size,omitempty"` + CreatedTs int64 `json:"createdts,omitempty"` + ModTs int64 `json:"modts,omitempty"` + Meta map[string]any `json:"meta,omitempty"` + IsDir bool `json:"isdir,omitempty"` +} + +type CommandFileListData struct { + ZoneId string `json:"zoneid"` + Prefix string `json:"prefix,omitempty"` + All bool `json:"all,omitempty"` + Offset int `json:"offset,omitempty"` + Limit int `json:"limit,omitempty"` +} + +type CommandFileCreateData struct { + ZoneId string `json:"zoneid"` + FileName string `json:"filename"` + Meta map[string]any `json:"meta,omitempty"` + Opts *filestore.FileOptsType `json:"opts,omitempty"` } type CommandAppendIJsonData struct { @@ -467,3 +508,17 @@ type WaveInfoData struct { type AiMessageData struct { Message string `json:"message,omitempty"` } + +type CommandVarData struct { + Key string `json:"key"` + Val string `json:"val,omitempty"` + Remove bool `json:"remove,omitempty"` + ZoneId string `json:"zoneid"` + FileName string `json:"filename"` +} + +type CommandVarResponseData struct { + Key string `json:"key"` + Val string `json:"val"` + Exists bool `json:"exists"` +} diff --git a/pkg/wshrpc/wshserver/resolvers.go b/pkg/wshrpc/wshserver/resolvers.go index 441b2d053..4de74bcdb 100644 --- a/pkg/wshrpc/wshserver/resolvers.go +++ b/pkg/wshrpc/wshserver/resolvers.go @@ -16,10 +16,15 @@ import ( "github.com/wavetermdev/waveterm/pkg/wstore" ) -const SimpleId_This = "this" -const SimpleId_Tab = "tab" -const SimpleId_Ws = "ws" -const SimpleId_Client = "client" +const ( + SimpleId_This = "this" + SimpleId_Block = "block" + SimpleId_Tab = "tab" + SimpleId_Ws = "ws" + SimpleId_Workspace = "workspace" + SimpleId_Client = "client" + SimpleId_Global = "global" +) var ( simpleTabNumRe = regexp.MustCompile(`^tab:(\d{1,3})$`) @@ -35,7 +40,8 @@ func parseSimpleId(simpleId string) (discriminator string, value string, err err } // Handle special keywords - if simpleId == SimpleId_This || simpleId == SimpleId_Tab || simpleId == SimpleId_Ws || simpleId == SimpleId_Client { + if simpleId == SimpleId_This || simpleId == SimpleId_Block || simpleId == SimpleId_Tab || + simpleId == SimpleId_Ws || simpleId == SimpleId_Workspace || simpleId == SimpleId_Client || simpleId == SimpleId_Global { return "this", simpleId, nil } @@ -76,7 +82,7 @@ func resolveThis(ctx context.Context, data wshrpc.CommandResolveIdsData, value s return nil, fmt.Errorf("no blockid in request") } - if value == SimpleId_This { + if value == SimpleId_This || value == SimpleId_Block { return &waveobj.ORef{OType: waveobj.OType_Block, OID: data.BlockId}, nil } if value == SimpleId_Tab { @@ -86,7 +92,7 @@ func resolveThis(ctx context.Context, data wshrpc.CommandResolveIdsData, value s } return &waveobj.ORef{OType: waveobj.OType_Tab, OID: tabId}, nil } - if value == SimpleId_Ws { + if value == SimpleId_Ws || value == SimpleId_Workspace { tabId, err := wstore.DBFindTabForBlockId(ctx, data.BlockId) if err != nil { return nil, fmt.Errorf("error finding tab: %v", err) @@ -97,7 +103,7 @@ func resolveThis(ctx context.Context, data wshrpc.CommandResolveIdsData, value s } return &waveobj.ORef{OType: waveobj.OType_Workspace, OID: wsId}, nil } - if value == SimpleId_Client { + if value == SimpleId_Client || value == SimpleId_Global { client, err := wstore.DBGetSingleton[*waveobj.Client](ctx) if err != nil { return nil, fmt.Errorf("error getting client: %v", err) diff --git a/pkg/wshrpc/wshserver/wshserver.go b/pkg/wshrpc/wshserver/wshserver.go index a96c9aa8e..93905f815 100644 --- a/pkg/wshrpc/wshserver/wshserver.go +++ b/pkg/wshrpc/wshserver/wshserver.go @@ -21,6 +21,7 @@ import ( "github.com/wavetermdev/waveterm/pkg/remote" "github.com/wavetermdev/waveterm/pkg/remote/conncontroller" "github.com/wavetermdev/waveterm/pkg/telemetry" + "github.com/wavetermdev/waveterm/pkg/util/envutil" "github.com/wavetermdev/waveterm/pkg/util/utilfn" "github.com/wavetermdev/waveterm/pkg/waveai" "github.com/wavetermdev/waveterm/pkg/wavebase" @@ -265,14 +266,152 @@ func (ws *WshServer) ControllerInputCommand(ctx context.Context, data wshrpc.Com return bc.SendInput(inputUnion) } +func (ws *WshServer) FileCreateCommand(ctx context.Context, data wshrpc.CommandFileCreateData) error { + var fileOpts filestore.FileOptsType + if data.Opts != nil { + fileOpts = *data.Opts + } + err := filestore.WFS.MakeFile(ctx, data.ZoneId, data.FileName, data.Meta, fileOpts) + if err != nil { + return fmt.Errorf("error creating blockfile: %w", err) + } + wps.Broker.Publish(wps.WaveEvent{ + Event: wps.Event_BlockFile, + Scopes: []string{waveobj.MakeORef(waveobj.OType_Block, data.ZoneId).String()}, + Data: &wps.WSFileEventData{ + ZoneId: data.ZoneId, + FileName: data.FileName, + FileOp: wps.FileOp_Create, + }, + }) + return nil +} + +func (ws *WshServer) FileDeleteCommand(ctx context.Context, data wshrpc.CommandFileData) error { + err := filestore.WFS.DeleteFile(ctx, data.ZoneId, data.FileName) + if err != nil { + return fmt.Errorf("error deleting blockfile: %w", err) + } + wps.Broker.Publish(wps.WaveEvent{ + Event: wps.Event_BlockFile, + Scopes: []string{waveobj.MakeORef(waveobj.OType_Block, data.ZoneId).String()}, + Data: &wps.WSFileEventData{ + ZoneId: data.ZoneId, + FileName: data.FileName, + FileOp: wps.FileOp_Delete, + }, + }) + return nil +} + +func waveFileToWaveFileInfo(wf *filestore.WaveFile) *wshrpc.WaveFileInfo { + return &wshrpc.WaveFileInfo{ + ZoneId: wf.ZoneId, + Name: wf.Name, + Opts: wf.Opts, + Size: wf.Size, + CreatedTs: wf.CreatedTs, + ModTs: wf.ModTs, + Meta: wf.Meta, + } +} + +func (ws *WshServer) FileInfoCommand(ctx context.Context, data wshrpc.CommandFileData) (*wshrpc.WaveFileInfo, error) { + fileInfo, err := filestore.WFS.Stat(ctx, data.ZoneId, data.FileName) + if err != nil { + if err == fs.ErrNotExist { + return nil, fmt.Errorf("NOTFOUND: %w", err) + } + return nil, fmt.Errorf("error getting file info: %w", err) + } + return waveFileToWaveFileInfo(fileInfo), nil +} + +func (ws *WshServer) FileListCommand(ctx context.Context, data wshrpc.CommandFileListData) ([]*wshrpc.WaveFileInfo, error) { + fileListOrig, err := filestore.WFS.ListFiles(ctx, data.ZoneId) + if err != nil { + return nil, fmt.Errorf("error listing blockfiles: %w", err) + } + var fileList []*wshrpc.WaveFileInfo + for _, wf := range fileListOrig { + fileList = append(fileList, waveFileToWaveFileInfo(wf)) + } + if data.Prefix != "" { + var filteredList []*wshrpc.WaveFileInfo + for _, file := range fileList { + if strings.HasPrefix(file.Name, data.Prefix) { + filteredList = append(filteredList, file) + } + } + fileList = filteredList + } + if !data.All { + var filteredList []*wshrpc.WaveFileInfo + dirMap := make(map[string]int64) // the value is max modtime + for _, file := range fileList { + // if there is an extra "/" after the prefix, don't include it + // first strip the prefix + relPath := strings.TrimPrefix(file.Name, data.Prefix) + // then check if there is a "/" after the prefix + if strings.Contains(relPath, "/") { + dirPath := strings.Split(relPath, "/")[0] + modTime := dirMap[dirPath] + if file.ModTs > modTime { + dirMap[dirPath] = file.ModTs + } + continue + } + filteredList = append(filteredList, file) + } + for dir := range dirMap { + filteredList = append(filteredList, &wshrpc.WaveFileInfo{ + ZoneId: data.ZoneId, + Name: data.Prefix + dir + "/", + Size: 0, + Meta: nil, + ModTs: dirMap[dir], + CreatedTs: dirMap[dir], + IsDir: true, + }) + } + fileList = filteredList + } + if data.Offset > 0 { + if data.Offset >= len(fileList) { + fileList = nil + } else { + fileList = fileList[data.Offset:] + } + } + if data.Limit > 0 { + if data.Limit < len(fileList) { + fileList = fileList[:data.Limit] + } + } + return fileList, nil +} + func (ws *WshServer) FileWriteCommand(ctx context.Context, data wshrpc.CommandFileData) error { dataBuf, err := base64.StdEncoding.DecodeString(data.Data64) if err != nil { return fmt.Errorf("error decoding data64: %w", err) } - err = filestore.WFS.WriteFile(ctx, data.ZoneId, data.FileName, dataBuf) - if err != nil { - return fmt.Errorf("error writing to blockfile: %w", err) + if data.At != nil { + err = filestore.WFS.WriteAt(ctx, data.ZoneId, data.FileName, data.At.Offset, dataBuf) + if err == fs.ErrNotExist { + return fmt.Errorf("NOTFOUND: %w", err) + } + if err != nil { + return fmt.Errorf("error writing to blockfile: %w", err) + } + } else { + err = filestore.WFS.WriteFile(ctx, data.ZoneId, data.FileName, dataBuf) + if err == fs.ErrNotExist { + return fmt.Errorf("NOTFOUND: %w", err) + } + if err != nil { + return fmt.Errorf("error writing to blockfile: %w", err) + } } wps.Broker.Publish(wps.WaveEvent{ Event: wps.Event_BlockFile, @@ -287,11 +426,25 @@ func (ws *WshServer) FileWriteCommand(ctx context.Context, data wshrpc.CommandFi } func (ws *WshServer) FileReadCommand(ctx context.Context, data wshrpc.CommandFileData) (string, error) { - _, dataBuf, err := filestore.WFS.ReadFile(ctx, data.ZoneId, data.FileName) - if err != nil { - return "", fmt.Errorf("error reading blockfile: %w", err) + if data.At != nil { + _, dataBuf, err := filestore.WFS.ReadAt(ctx, data.ZoneId, data.FileName, data.At.Offset, data.At.Size) + if err == fs.ErrNotExist { + return "", fmt.Errorf("NOTFOUND: %w", err) + } + if err != nil { + return "", fmt.Errorf("error reading blockfile: %w", err) + } + return base64.StdEncoding.EncodeToString(dataBuf), nil + } else { + _, dataBuf, err := filestore.WFS.ReadFile(ctx, data.ZoneId, data.FileName) + if err == fs.ErrNotExist { + return "", fmt.Errorf("NOTFOUND: %w", err) + } + if err != nil { + return "", fmt.Errorf("error reading blockfile: %w", err) + } + return base64.StdEncoding.EncodeToString(dataBuf), nil } - return base64.StdEncoding.EncodeToString(dataBuf), nil } func (ws *WshServer) FileAppendCommand(ctx context.Context, data wshrpc.CommandFileData) error { @@ -300,6 +453,9 @@ func (ws *WshServer) FileAppendCommand(ctx context.Context, data wshrpc.CommandF return fmt.Errorf("error decoding data64: %w", err) } err = filestore.WFS.AppendData(ctx, data.ZoneId, data.FileName, dataBuf) + if err == fs.ErrNotExist { + return fmt.Errorf("NOTFOUND: %w", err) + } if err != nil { return fmt.Errorf("error appending to blockfile: %w", err) } @@ -610,3 +766,37 @@ func (ws *WshServer) ActivityCommand(ctx context.Context, activity telemetry.Act telemetry.GoUpdateActivityWrap(activity, "wshrpc-activity") return nil } + +func (ws *WshServer) GetVarCommand(ctx context.Context, data wshrpc.CommandVarData) (*wshrpc.CommandVarResponseData, error) { + _, fileData, err := filestore.WFS.ReadFile(ctx, data.ZoneId, data.FileName) + if err == fs.ErrNotExist { + return &wshrpc.CommandVarResponseData{Key: data.Key, Exists: false}, nil + } + if err != nil { + return nil, fmt.Errorf("error reading blockfile: %w", err) + } + envMap := envutil.EnvToMap(string(fileData)) + value, ok := envMap[data.Key] + return &wshrpc.CommandVarResponseData{Key: data.Key, Exists: ok, Val: value}, nil +} + +func (ws *WshServer) SetVarCommand(ctx context.Context, data wshrpc.CommandVarData) error { + _, fileData, err := filestore.WFS.ReadFile(ctx, data.ZoneId, data.FileName) + if err == fs.ErrNotExist { + fileData = []byte{} + err = filestore.WFS.MakeFile(ctx, data.ZoneId, data.FileName, nil, filestore.FileOptsType{}) + if err != nil { + return fmt.Errorf("error creating blockfile: %w", err) + } + } else if err != nil { + return fmt.Errorf("error reading blockfile: %w", err) + } + envMap := envutil.EnvToMap(string(fileData)) + if data.Remove { + delete(envMap, data.Key) + } else { + envMap[data.Key] = data.Val + } + envStr := envutil.MapToEnv(envMap) + return filestore.WFS.WriteFile(ctx, data.ZoneId, data.FileName, []byte(envStr)) +}