|
| 1 | +package sandbox |
| 2 | + |
| 3 | +import ( |
| 4 | + "context" |
| 5 | + "fmt" |
| 6 | + "os" |
| 7 | + "os/exec" |
| 8 | + "path/filepath" |
| 9 | + "strings" |
| 10 | + |
| 11 | + "github.com/urfave/cli/v2" |
| 12 | + |
| 13 | + "github.com/NodeOps-app/createos-cli/internal/api" |
| 14 | +) |
| 15 | + |
| 16 | +// The integrations monorepo. Every host below installs some part of it, and |
| 17 | +// the two that have no installer of their own (OpenCode, DeepSeek Harness) |
| 18 | +// need a checkout on disk, which setupPluginCheckout manages. |
| 19 | +const ( |
| 20 | + setupPluginRepo = "NodeOps-app/createos-plugin" |
| 21 | + setupPluginRepoURL = "https://github.com/" + setupPluginRepo |
| 22 | + // The Claude Code marketplace declares itself under this name, so |
| 23 | + // `plugin@marketplace` ids resolve to it regardless of how the user |
| 24 | + // named the source when adding it. |
| 25 | + setupMarketplaceName = "createos" |
| 26 | +) |
| 27 | + |
| 28 | +// newSetupCommand returns `createos sandbox setup`, the harness integration |
| 29 | +// group. One subcommand per host on |
| 30 | +// https://createos.sh/docs/Sandbox/Integrations. |
| 31 | +func newSetupCommand() *cli.Command { |
| 32 | + return &cli.Command{ |
| 33 | + Name: "setup", |
| 34 | + Usage: "Connect a coding harness to CreateOS Sandbox", |
| 35 | + Description: "Each subcommand wires one harness to CreateOS Sandbox, so a\n" + |
| 36 | + "workspace runs on a disposable microVM instead of your laptop.\n\n" + |
| 37 | + "Every subcommand takes --doctor, which checks the prerequisites and\n" + |
| 38 | + "reports without changing anything.", |
| 39 | + Subcommands: []*cli.Command{ |
| 40 | + newSetupClaudeCodeCommand(), |
| 41 | + newSetupCodexCommand(), |
| 42 | + newSetupDeepSeekCommand(), |
| 43 | + newSetupHerdrCommand(), |
| 44 | + newSetupOpenCodeCommand(), |
| 45 | + newSetupOrcaCommand(), |
| 46 | + newSetupPiCommand(), |
| 47 | + }, |
| 48 | + } |
| 49 | +} |
| 50 | + |
| 51 | +// setupSignedIn confirms the session actually works before a host integration |
| 52 | +// is wired to it. A plugin installed against a dead session fails later, in |
| 53 | +// the host's UI, where the reason is much harder to see. |
| 54 | +func setupSignedIn(c *cli.Context) error { |
| 55 | + client, ok := c.App.Metadata[api.SandboxClientKey].(*api.SandboxClient) |
| 56 | + if !ok { |
| 57 | + return fmt.Errorf("you're not signed in — run 'createos login' first") |
| 58 | + } |
| 59 | + if _, _, err := client.ListSandboxes(c.Context, api.ListSandboxesOpts{}); err != nil { |
| 60 | + return fmt.Errorf("your session is not usable — run 'createos login' again: %w", err) |
| 61 | + } |
| 62 | + fmt.Println("signed in to CreateOS") |
| 63 | + return nil |
| 64 | +} |
| 65 | + |
| 66 | +// setupRequireBin resolves a host binary, turning a bare exec.LookPath miss |
| 67 | +// into the install hint the user actually needs. |
| 68 | +func setupRequireBin(name, hint string) (string, error) { |
| 69 | + bin, err := exec.LookPath(name) |
| 70 | + if err != nil { |
| 71 | + return "", fmt.Errorf("%s is not on PATH — %s", name, hint) |
| 72 | + } |
| 73 | + fmt.Printf("%s found at %s\n", name, bin) |
| 74 | + return bin, nil |
| 75 | +} |
| 76 | + |
| 77 | +// setupRun runs a host CLI and returns its combined output, which callers |
| 78 | +// attach to any error: these tools explain their own failures far better than |
| 79 | +// an exit status does. |
| 80 | +func setupRun(ctx context.Context, bin string, args ...string) (string, error) { |
| 81 | + // #nosec G204 -- bin is an exec.LookPath result and every arg is either a |
| 82 | + // literal from this package or a path the user named on the command line; |
| 83 | + // it is one argv element, never a shell string. |
| 84 | + out, err := exec.CommandContext(ctx, bin, args...).CombinedOutput() |
| 85 | + return string(out), err |
| 86 | +} |
| 87 | + |
| 88 | +// setupAlreadyDone reports whether a host CLI refused because the thing was |
| 89 | +// already installed. Those tools exit non-zero for it, so a plain error check |
| 90 | +// would make a second `setup` run fail on a box that is correctly set up. |
| 91 | +func setupAlreadyDone(out string) bool { |
| 92 | + s := strings.ToLower(out) |
| 93 | + for _, phrase := range []string{ |
| 94 | + "already exists", |
| 95 | + "already added", |
| 96 | + "already installed", |
| 97 | + "already registered", |
| 98 | + "already configured", |
| 99 | + } { |
| 100 | + if strings.Contains(s, phrase) { |
| 101 | + return true |
| 102 | + } |
| 103 | + } |
| 104 | + return false |
| 105 | +} |
| 106 | + |
| 107 | +// setupCheckoutDir is where setup keeps its own clone of the integrations |
| 108 | +// monorepo. Beside the per-sandbox keys and ssh mux sockets the CLI already |
| 109 | +// owns, so nothing of the user's is involved. |
| 110 | +func setupCheckoutDir() (string, error) { |
| 111 | + home, err := os.UserHomeDir() |
| 112 | + if err != nil { |
| 113 | + return "", fmt.Errorf("resolve $HOME: %w", err) |
| 114 | + } |
| 115 | + return filepath.Join(home, ".config", "createos", "plugins", "createos-plugin"), nil |
| 116 | +} |
| 117 | + |
| 118 | +// setupPluginCheckout returns a path to the integrations monorepo. |
| 119 | +// |
| 120 | +// A --local path wins and is used as-is, so plugin developers can point the |
| 121 | +// setup at their own working tree. Otherwise setup owns a clone under |
| 122 | +// ~/.config/createos and refreshes it on every run, because the host reads |
| 123 | +// these files directly — a stale checkout silently pins the user to whatever |
| 124 | +// the plugin looked like the day they first ran setup. |
| 125 | +func setupPluginCheckout(ctx context.Context, local string) (string, error) { |
| 126 | + if local = strings.TrimSpace(local); local != "" { |
| 127 | + dir, err := filepath.Abs(local) |
| 128 | + if err != nil { |
| 129 | + return "", fmt.Errorf("could not resolve %q: %w", local, err) |
| 130 | + } |
| 131 | + if _, err := os.Stat(filepath.Join(dir, "packages")); err != nil { |
| 132 | + return "", fmt.Errorf("%s does not look like the integrations repo: no packages/ directory", dir) |
| 133 | + } |
| 134 | + fmt.Printf("using your checkout at %s\n", dir) |
| 135 | + return dir, nil |
| 136 | + } |
| 137 | + |
| 138 | + if _, err := setupRequireBin("git", "install it from https://git-scm.com"); err != nil { |
| 139 | + return "", err |
| 140 | + } |
| 141 | + dir, err := setupCheckoutDir() |
| 142 | + if err != nil { |
| 143 | + return "", err |
| 144 | + } |
| 145 | + if _, err := os.Stat(filepath.Join(dir, ".git")); err == nil { |
| 146 | + fmt.Printf("updating %s\n", dir) |
| 147 | + if out, pullErr := setupRun(ctx, "git", "-C", dir, "pull", "--ff-only", "--quiet"); pullErr != nil { |
| 148 | + // A diverged or dirty checkout is the user's, not ours to reset. |
| 149 | + // The stale copy still works, so warn and carry on. |
| 150 | + fmt.Printf("could not update the checkout, using it as-is: %s\n", strings.TrimSpace(out)) |
| 151 | + } |
| 152 | + return dir, nil |
| 153 | + } |
| 154 | + if err := os.MkdirAll(filepath.Dir(dir), 0o750); err != nil { |
| 155 | + return "", fmt.Errorf("could not create %s: %w", filepath.Dir(dir), err) |
| 156 | + } |
| 157 | + fmt.Printf("cloning %s into %s\n", setupPluginRepoURL, dir) |
| 158 | + if out, err := setupRun(ctx, "git", "clone", "--depth", "1", setupPluginRepoURL, dir); err != nil { |
| 159 | + return "", fmt.Errorf("could not clone the integrations repo: %w\n%s", err, out) |
| 160 | + } |
| 161 | + return dir, nil |
| 162 | +} |
| 163 | + |
| 164 | +// setupPackageDir resolves one package inside the checkout and fails loudly |
| 165 | +// when it is missing, which means the checkout is not what we think it is. |
| 166 | +func setupPackageDir(checkout, pkg string) (string, error) { |
| 167 | + dir := filepath.Join(checkout, "packages", pkg) |
| 168 | + if _, err := os.Stat(dir); err != nil { |
| 169 | + return "", fmt.Errorf("%s is missing from the checkout at %s", pkg, checkout) |
| 170 | + } |
| 171 | + return dir, nil |
| 172 | +} |
| 173 | + |
| 174 | +// setupDoctorFlag is the flag every subcommand shares. |
| 175 | +func setupDoctorFlag() cli.Flag { |
| 176 | + return &cli.BoolFlag{ |
| 177 | + Name: "doctor", |
| 178 | + Usage: "Check the prerequisites and report, without changing anything", |
| 179 | + } |
| 180 | +} |
| 181 | + |
| 182 | +// setupLocalFlag is shared by the hosts that need a checkout on disk. |
| 183 | +func setupLocalFlag() cli.Flag { |
| 184 | + return &cli.StringFlag{ |
| 185 | + Name: "local", |
| 186 | + Usage: "Use this local checkout of " + setupPluginRepo + " instead of cloning it", |
| 187 | + } |
| 188 | +} |
| 189 | + |
| 190 | +// setupDoctorDone prints the line that ends a --doctor run. |
| 191 | +func setupDoctorDone() { |
| 192 | + fmt.Println("\nEverything the plugin needs is present. Run this again without --doctor to install it.") |
| 193 | +} |
0 commit comments