@shogo-ai/worker 1.7.4 → 1.7.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -8,14 +8,45 @@ Outbound-only: the worker dials Shogo Cloud over HTTPS, never the other way arou
8
8
 
9
9
  ## Install
10
10
 
11
+ **macOS / Linux**
12
+
11
13
  ```bash
12
- # Requires node >= 20 (or bun >= 1.3)
13
- npm i -g @shogo-ai/worker
14
+ curl -fsSL https://install.shogo.ai | bash
15
+ ```
16
+
17
+ **Windows (PowerShell)**
18
+
19
+ ```powershell
20
+ irm https://install.shogo.ai/ps | iex
14
21
  ```
15
22
 
16
- > Prefer a single self-contained binary? Grab the prebuilt tarball for your
17
- > OS / arch from the [latest worker release](https://github.com/shogo-ai/shogo-ai/releases?q=worker-v)
18
- > it has no Node / Bun dependency.
23
+ The installer drops a single self-contained binary at `~/.shogo/bin/shogo`
24
+ (`%USERPROFILE%\.shogo\bin\shogo.exe` on Windows), verifies its SHA-256 against
25
+ the published sidecar, and adds the bin dir to `PATH`. No Node or Bun on the
26
+ target machine required.
27
+
28
+ ### Installer flags
29
+
30
+ ```bash
31
+ curl -fsSL https://install.shogo.ai | bash -s -- [flags]
32
+
33
+ --channel <stable|beta> release channel (default: stable)
34
+ --prefix <dir> install dir (default: $HOME/.shogo/bin)
35
+ --force overwrite existing install
36
+ --no-binary force npm install even if a prebuilt binary exists
37
+ ```
38
+
39
+ ### Alternate paths
40
+
41
+ | Method | When to use |
42
+ |--------|-------------|
43
+ | `npm i -g @shogo-ai/worker` | Node ≥ 20 already on the machine; you want lockstep with `package.json` in a project repo. |
44
+ | `gh release download v<X.Y.Z> -p 'shogo-<target>.tar.gz'` from [github.com/shogo-labs/shogo-ai/releases](https://github.com/shogo-labs/shogo-ai/releases) | Air-gapped / proxy-locked environments where `install.shogo.ai` is blocked. |
45
+ | `bash packages/shogo-worker/install.sh` from a repo checkout | Self-mirror; pass `SHOGO_RELEASE_HOST=...` to pull tarballs from your own CDN. |
46
+
47
+ The single binary, the npm package, and the GitHub Release tarballs all ship
48
+ from the same `v*` tag, so the version you get is identical regardless of
49
+ install method.
19
50
 
20
51
  ## Quick start
21
52
 
@@ -74,11 +105,52 @@ SHOGO_API_KEY=shogo_sk_... shogo worker start --foreground
74
105
  ├── logs/
75
106
  │ ├── worker.log
76
107
  │ └── worker.err.log
77
- └── runtime/
78
- ├── agent-runtime # AGPL binary, downloaded by `shogo runtime install`
79
- └── version.json
108
+ ├── runtime/
109
+ ├── agent-runtime # AGPL binary, downloaded by `shogo runtime install`
110
+ │ ├── runtime-template/ # Vite/React/Tailwind scaffolding the runtime seeds
111
+ │ │ # into new project workspaces. MUST live next to the
112
+ │ │ # binary — `getRuntimeTemplatePath()` looks here
113
+ │ │ # second (after the `RUNTIME_TEMPLATE_DIR` env
114
+ │ │ # override). The agent-runtime release tarball
115
+ │ │ # ships binary + this directory together.
116
+ │ ├── tree-sitter-wasm/ # Tree-sitter parser core + per-language grammars
117
+ │ │ # (`tree-sitter.wasm` + `tree-sitter-${lang}.wasm`).
118
+ │ │ # `bun build --compile` bakes the build-machine path
119
+ │ │ # for these into the binary; we ship the WASMs
120
+ │ │ # next to the binary so it can dlopen them at
121
+ │ │ # runtime regardless of where the operator put it.
122
+ │ │ # The worker also exports
123
+ │ │ # `TREE_SITTER_WASM_DIR=<this dir>` to the spawned
124
+ │ │ # runtime so the resolved location is observable
125
+ │ │ # via `env | grep TREE_SITTER`.
126
+ │ └── version.json
127
+ └── projects/<projectId>/ # cloned project workspaces (auto-pulled on first
128
+ # request, override with `--projects-dir` /
129
+ # `SHOGO_PROJECTS_DIR`)
80
130
  ```
81
131
 
132
+ ### Workspace seeding (cli-worker)
133
+
134
+ When the worker spawns the agent-runtime for a project, it MUST point that
135
+ runtime at a real on-disk workspace via `WORKSPACE_DIR` / `PROJECT_DIR`.
136
+ Three knobs control where that workspace comes from, in priority order:
137
+
138
+ 1. **Auto-pull (default).** On the first inbound request for a project,
139
+ `WorkerRuntimeManager` clones the cloud snapshot into
140
+ `<projectsDir>/<projectId>/`, watches it via `CloudSyncWatcher`, and
141
+ pushes local edits back. No operator action required.
142
+ 2. **`--projects-dir <path>` / `SHOGO_PROJECTS_DIR=<path>`.** Override the
143
+ root directory under which workspaces live. Useful when you want
144
+ workspaces on a faster disk or backed-up volume.
145
+ 3. **`shogo project pull <projectId>` (manual pre-pull).** When you've
146
+ passed `--no-auto-pull` (e.g. slow or metered connection), the worker
147
+ refuses to spawn until the canonical workspace exists at
148
+ `<projectsDir>/<projectId>/`. Pre-pulling is how you get there.
149
+
150
+ If you disable auto-pull and **don't** pre-pull, the worker fails loudly
151
+ with a multi-line error pointing you at all three options instead of
152
+ silently falling back to an empty workspace.
153
+
82
154
  ## Networking
83
155
 
84
156
  The worker needs outbound HTTPS (TCP 443) to 3 hosts. **No inbound ports required.**
@@ -137,16 +209,54 @@ port allocation, env injection, restart-with-backoff, idle eviction, and health
137
209
  checks. The same code path is consumed by Shogo Desktop (`apps/api`) for its own
138
210
  agent runtimes — so any improvement made here ships to both.
139
211
 
212
+ ### What the cli-worker tunnel does — and doesn't — handle
213
+
214
+ A cli-worker is an **execution target**: it forwards `/agent/*` paths to
215
+ the per-project agent-runtime and nothing else. Stateful data
216
+ (`/api/projects`, `/api/chat-sessions`, etc.) lives in Shogo Cloud and
217
+ is served by the cloud backend, not by the worker. Studio's
218
+ `SDKDomainProvider` checks `instance.kind` and only tunnels stateful
219
+ APIs through the desktop adapter; for cli-workers it reads from cloud
220
+ directly.
221
+
222
+ If a request for a non-`/agent/*` path does reach the cli-worker tunnel
223
+ (e.g. an out-of-date Studio client), the worker replies with a
224
+ structured 502 body so future debuggers can read the rejection without
225
+ log access:
226
+
227
+ ```json
228
+ {
229
+ "code": "CLI_WORKER_HAS_NO_DATA_API",
230
+ "message": "cli-worker only serves /agent/* paths; tried: /api/projects",
231
+ "path": "/api/projects?workspaceId=ws-1"
232
+ }
233
+ ```
234
+
140
235
  ## Programmatic use
141
236
 
142
237
  Both core classes are exposed for direct embedding (e.g. building your own
143
238
  desktop wrapper):
144
239
 
145
240
  ```ts
241
+ import { homedir } from 'node:os'
242
+ import { join } from 'node:path'
146
243
  import { WorkerTunnel, WorkerRuntimeManager } from '@shogo-ai/worker'
147
244
 
245
+ // `WorkerRuntimeManager` refuses to spawn a runtime unless it knows
246
+ // where the workspace lives on disk. For embedders that just want
247
+ // the cli-worker default behaviour, set `autoPull` and the manager
248
+ // will clone each project's workspace from cloud on first request.
249
+ // Embedders that manage workspaces themselves should set `projectDir`
250
+ // inside `defaultSpawnConfig` (or via `enrichSpawnConfig`) instead.
148
251
  const runtimeManager = new WorkerRuntimeManager({
149
- defaultSpawnConfig: { cloudUrl: 'https://studio.shogo.ai', apiKey: process.env.SHOGO_API_KEY! },
252
+ defaultSpawnConfig: {
253
+ cloudUrl: 'https://studio.shogo.ai',
254
+ apiKey: process.env.SHOGO_API_KEY!,
255
+ },
256
+ autoPull: {
257
+ enabled: true,
258
+ projectsDir: join(homedir(), '.shogo', 'projects'),
259
+ },
150
260
  })
151
261
 
152
262
  const tunnel = new WorkerTunnel({
@@ -159,6 +269,104 @@ const tunnel = new WorkerTunnel({
159
269
  tunnel.start()
160
270
  ```
161
271
 
272
+ ## External triggers
273
+
274
+ The worker lets external services (Jira, Linear, Zapier, n8n, your own
275
+ HTTP clients) send messages to a Shogo agent **running on this machine**,
276
+ without exposing any inbound port. Combine `shogo worker start` with a
277
+ project pin in Studio (or via the SDK) and the cloud-side
278
+ `/api/projects/:id/agent-proxy/*` becomes a stable public URL routed
279
+ through the worker's outbound tunnel:
280
+
281
+ ```
282
+ External caller ──HTTPS──▶ Shogo Cloud ──tunnel──▶ shogo worker ──▶ agent-runtime
283
+ (this machine)
284
+ ```
285
+
286
+ ### Pin a project to this machine
287
+
288
+ From Studio: open the project → **Channels → Run on** → pick this machine.
289
+ From a script (uses `@shogo-ai/sdk`):
290
+
291
+ ```ts
292
+ import { createClient } from '@shogo-ai/sdk'
293
+
294
+ const client = createClient({
295
+ apiUrl: 'https://api.shogo.ai',
296
+ shogoApiKey: process.env.SHOGO_API_KEY!,
297
+ })
298
+
299
+ const machines = await client.machines.list({ workspaceId })
300
+ const me = machines.find((m) => m.kind === 'cli_worker' && m.name === 'my-devbox')!
301
+
302
+ await client.machines.pinProject(projectId, {
303
+ instanceId: me.id,
304
+ policy: 'pinned', // 503 instance_offline if this machine goes down
305
+ // (use 'prefer' to fall back to a cloud pod instead)
306
+ })
307
+ ```
308
+
309
+ ### Trigger the agent
310
+
311
+ ```bash
312
+ curl -X POST \
313
+ "https://api.shogo.ai/api/projects/$PROJECT_ID/agent-proxy/agent/channels/webhook/incoming" \
314
+ -H "Authorization: Bearer $SHOGO_API_KEY" \
315
+ -H "X-Webhook-Secret: $CHANNEL_SECRET" \
316
+ -H "Content-Type: application/json" \
317
+ -d '{"message": "Triage Jira ticket ABC-123"}'
318
+ ```
319
+
320
+ The cloud verifies the bearer key, looks up the project pin, and relays
321
+ the request through this worker's existing outbound WebSocket into the
322
+ agent-runtime that the worker spawned on demand. Tool calls (shell, file
323
+ I/O, MCP servers) execute on **this machine**.
324
+
325
+ See [Webhook channel reference](https://docs.shogo.ai/docs/features/external-triggers/webhook-channel)
326
+ for the request/response shape, secret handling, and async callback mode.
327
+
328
+ ## Cloning a staging project (`shogo project pull`)
329
+
330
+ When you pin a staging project to this machine, the worker needs the
331
+ project's workspace files on disk to spawn the agent against. By default
332
+ the worker **auto-clones** the project on first request:
333
+
334
+ ```bash
335
+ shogo worker start # auto-pull is ON, git transport by default
336
+ shogo worker start --no-auto-pull # opt out (e.g. for git-backed projects)
337
+ shogo worker start --no-git # force the Files API path even when git is on PATH
338
+ shogo worker start --projects-dir /mnt/big-disk/shogo # override default ~/.shogo/projects
339
+ ```
340
+
341
+ You can also clone manually ahead of time:
342
+
343
+ ```bash
344
+ # Clones to ~/.shogo/projects/<projectId>/ by default
345
+ shogo project pull <projectId>
346
+
347
+ # Pull-then-watch: keeps a local editor and cloud in sync
348
+ shogo project pull <projectId> --watch
349
+
350
+ # Push local edits back
351
+ shogo project push <projectId>
352
+ shogo project push <projectId> --delete-remote # mirror local deletions
353
+
354
+ # Roll the local workspace back to a specific git checkpoint
355
+ shogo project checkout <projectId> # fast-forward to remote HEAD
356
+ shogo project checkout <projectId> --at "before refactor" # resolve by checkpoint name
357
+ shogo project checkout <projectId> --at <sha> --unshallow # full history
358
+ ```
359
+
360
+ **Two transports.** Auto-pull uses git's smart-HTTP protocol by default
361
+ (`git clone --depth=1` against `https://api.shogo.ai/api/projects/<id>/git`)
362
+ so the worker gets a full checkpoint history and delta-sized pushes for free.
363
+ If `git` isn't available, the worker falls back to the Files API. Manual
364
+ `shogo project pull/push` always uses the Files API so `--include` filters
365
+ and `.shogo/` SQLite state work the same way. See
366
+ [Cloning projects to a paired machine](https://docs.shogo.ai/docs/features/my-machines/project-pull)
367
+ and [Checkpoints on the VPS](https://docs.shogo.ai/docs/features/my-machines/checkpoints-on-the-vps)
368
+ for the end-to-end walkthrough.
369
+
162
370
  ## Troubleshooting
163
371
 
164
372
  ```bash
@@ -176,4 +384,4 @@ shogo runtime install # (re)download the latest stable binary
176
384
  - [Cloud Agent: My Machines guide](../../docs/cloud-agent-my-machines.md) — full
177
385
  walk-through, security model, deploy patterns
178
386
  - [Networking & firewall guide](../../docs/my-machines-networking.md)
179
- - Source: [github.com/shogo-ai/shogo-ai](https://github.com/shogo-ai/shogo-ai)
387
+ - Source: [github.com/shogo-labs/shogo-ai](https://github.com/shogo-labs/shogo-ai)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shogo-ai/worker",
3
- "version": "1.7.4",
3
+ "version": "1.7.7",
4
4
  "description": "Shogo Cloud Agent Worker — run Shogo agents on your own machine (laptop, devbox, CI).",
5
5
  "license": "MIT",
6
6
  "author": "Shogo Technologies, Inc.",
package/src/cli.ts CHANGED
@@ -24,6 +24,9 @@ import {
24
24
  runRuntimeVersion,
25
25
  runRuntimeWhere,
26
26
  } from './commands/runtime.ts';
27
+ import { runProjectPull } from './commands/project-pull.ts';
28
+ import { runProjectPush } from './commands/project-push.ts';
29
+ import { runProjectCheckout } from './commands/project-checkout.ts';
27
30
 
28
31
  const VERSION = '0.1.0';
29
32
 
@@ -58,6 +61,9 @@ worker
58
61
  .option('--runtime-bin <path>', 'Override the agent-runtime binary path')
59
62
  .option('--debug', 'Run preflight checks before starting')
60
63
  .option('--foreground', 'Run in foreground (don\'t detach)')
64
+ .option('--no-auto-pull', 'Disable auto-clone of project workspaces on first request')
65
+ .option('--projects-dir <path>', 'Root directory for cloned project workspaces (default: ~/.shogo/projects)')
66
+ .option('--no-git', 'Force the file-transport sync path even when git is available')
61
67
  .action((flags) => handle(() => runStart(flags)));
62
68
 
63
69
  worker
@@ -110,9 +116,38 @@ config
110
116
  .action(() => handle(runConfigShow));
111
117
  config
112
118
  .command('set <key> <value>')
113
- .description('Set apiKey / cloudUrl / name / workerDir / port')
119
+ .description('Set apiKey / cloudUrl / name / workerDir / port / projectsDir')
114
120
  .action((k: string, v: string) => handle(() => runConfigSet(k, v)));
115
121
 
122
+ const project = program.command('project').description('Clone/sync project workspaces between Shogo Cloud and this machine');
123
+ project
124
+ .command('pull <projectId>')
125
+ .description('Clone a project from Shogo Cloud into ~/.shogo/projects/<projectId>/')
126
+ .option('--into <dir>', 'Destination directory (default: ~/.shogo/projects/<projectId>)')
127
+ .option('--watch', 'After pull, watch the local dir and push edits back to cloud')
128
+ .option('--include <patterns>', 'Comma-separated glob patterns (e.g. "src/**,*.md")')
129
+ .option('--api-key <key>', 'Override API key for this run')
130
+ .option('--cloud-url <url>', 'Override Shogo Cloud URL for this run')
131
+ .action((id: string, flags) => handle(() => runProjectPull(id, flags)));
132
+ project
133
+ .command('push <projectId>')
134
+ .description('Upload local workspace edits back to Shogo Cloud')
135
+ .option('--from <dir>', 'Source directory (default: ~/.shogo/projects/<projectId>)')
136
+ .option('--delete-remote', 'Mirror local deletions to cloud (DESTRUCTIVE)')
137
+ .option('--include <patterns>', 'Comma-separated glob patterns')
138
+ .option('--api-key <key>', 'Override API key for this run')
139
+ .option('--cloud-url <url>', 'Override Shogo Cloud URL for this run')
140
+ .action((id: string, flags) => handle(() => runProjectPush(id, flags)));
141
+ project
142
+ .command('checkout <projectId>')
143
+ .description('Roll the local workspace to a specific git checkpoint (SHA or named checkpoint)')
144
+ .option('--at <ref>', 'Target SHA or checkpoint name (default: remote HEAD)')
145
+ .option('--unshallow', 'Fetch full history before checking out (needed for old SHAs)')
146
+ .option('--into <dir>', 'Local dir override (default: ~/.shogo/projects/<projectId>)')
147
+ .option('--api-key <key>', 'Override API key for this run')
148
+ .option('--cloud-url <url>', 'Override Shogo Cloud URL for this run')
149
+ .action((id: string, flags) => handle(() => runProjectCheckout(id, flags)));
150
+
116
151
  program.showHelpAfterError(pc.dim('\n(use --help for usage)'));
117
152
  program.parseAsync(process.argv);
118
153
 
@@ -0,0 +1,179 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Copyright (C) 2026 Shogo Technologies, Inc.
3
+ /**
4
+ * `shogo project checkout <projectId> [--at <sha|name>] [--unshallow]`
5
+ *
6
+ * Rolls the worker's local copy of a project back (or forward) to a
7
+ * specific git checkpoint. By default this targets the project's
8
+ * current cloud HEAD — i.e. it's the worker-side equivalent of
9
+ * `git pull --rebase`.
10
+ *
11
+ * `--at` accepts:
12
+ * - A full or short SHA (anything `git rev-parse` can resolve).
13
+ * - A checkpoint name (resolved against
14
+ * `GET /api/projects/:projectId/checkpoints?limit=…`).
15
+ *
16
+ * `--unshallow` first converts the local repo from a shallow clone to
17
+ * a full clone — necessary when checking out a historical SHA that
18
+ * lives beyond the original `--depth=1` window.
19
+ */
20
+
21
+ import { existsSync } from 'node:fs';
22
+ import { resolve } from 'node:path';
23
+ import pc from 'picocolors';
24
+ import { resolveConfig } from '../lib/config.ts';
25
+ import { projectDirFor } from '../lib/paths.ts';
26
+ import {
27
+ gitFetchAndReset,
28
+ gitFetchUnshallow,
29
+ isGitRepo,
30
+ runGit,
31
+ } from '../lib/git-cloner.ts';
32
+
33
+ export interface ProjectCheckoutFlags {
34
+ /** SHA or checkpoint name. Defaults to remote HEAD. */
35
+ at?: string;
36
+ /** Convert a shallow clone to a full one before checking out. */
37
+ unshallow?: boolean;
38
+ /** Local dir override. */
39
+ into?: string;
40
+ /** API key override. */
41
+ apiKey?: string;
42
+ /** Cloud URL override. */
43
+ cloudUrl?: string;
44
+ }
45
+
46
+ export async function runProjectCheckout(projectId: string, flags: ProjectCheckoutFlags): Promise<void> {
47
+ if (!projectId) throw new Error('projectId is required');
48
+
49
+ const cfg = resolveConfig({
50
+ apiKey: flags.apiKey,
51
+ cloudUrl: flags.cloudUrl,
52
+ });
53
+
54
+ const localDir = resolve(flags.into ?? projectDirFor(projectId, cfg.projectsDir));
55
+ if (!existsSync(localDir)) {
56
+ throw new Error(`Local project dir does not exist: ${localDir} — run \`shogo project pull\` first`);
57
+ }
58
+ if (!isGitRepo(localDir)) {
59
+ throw new Error(
60
+ `${localDir} is not a git repo. \`shogo project checkout\` requires the git sync path; ` +
61
+ `run \`shogo project pull\` after installing git, or use \`shogo project pull --include\` for file-only restore.`,
62
+ );
63
+ }
64
+
65
+ console.log(pc.bold(`\nshogo project checkout ${pc.cyan(projectId)}`));
66
+ console.log(pc.dim(' cloud ') + cfg.cloudUrl);
67
+ console.log(pc.dim(' local ') + localDir);
68
+ if (flags.at) console.log(pc.dim(' at ') + flags.at);
69
+ console.log('');
70
+
71
+ if (flags.unshallow) {
72
+ console.log(pc.dim('Unshallowing repo (this may take a moment)...'));
73
+ await gitFetchUnshallow({
74
+ apiUrl: cfg.cloudUrl,
75
+ apiKey: cfg.apiKey,
76
+ projectId,
77
+ localDir,
78
+ });
79
+ }
80
+
81
+ if (!flags.at) {
82
+ // Default: fast-forward to remote HEAD.
83
+ const res = await gitFetchAndReset({
84
+ apiUrl: cfg.cloudUrl,
85
+ apiKey: cfg.apiKey,
86
+ projectId,
87
+ localDir,
88
+ });
89
+ console.log(pc.green(`✓ Reset to ${res.commitSha.slice(0, 8)} (remote HEAD)`));
90
+ return;
91
+ }
92
+
93
+ // Resolve --at: try as SHA first (cheapest), fall back to checkpoint
94
+ // name lookup against the cloud listing.
95
+ let targetSha: string;
96
+ try {
97
+ const head = await runGit(['rev-parse', '--verify', `${flags.at}^{commit}`], { cwd: localDir });
98
+ targetSha = head.stdout.trim();
99
+ } catch {
100
+ targetSha = await resolveCheckpointByName(cfg.cloudUrl, cfg.apiKey, projectId, flags.at);
101
+ console.log(pc.dim(` resolved checkpoint "${flags.at}" → ${targetSha.slice(0, 8)}`));
102
+ }
103
+
104
+ // Fetch up to that SHA (covers the shallow-window case for users who
105
+ // didn't pass --unshallow but are reaching for a slightly older sha).
106
+ try {
107
+ await gitFetchAndReset({
108
+ apiUrl: cfg.cloudUrl,
109
+ apiKey: cfg.apiKey,
110
+ projectId,
111
+ localDir,
112
+ branch: targetSha,
113
+ });
114
+ } catch {
115
+ // If we can't fetch directly to that sha (it's outside the shallow
116
+ // window), do a full unshallow and retry.
117
+ if (!flags.unshallow) {
118
+ console.log(pc.yellow(' fetch failed; unshallowing and retrying...'));
119
+ await gitFetchUnshallow({
120
+ apiUrl: cfg.cloudUrl,
121
+ apiKey: cfg.apiKey,
122
+ projectId,
123
+ localDir,
124
+ });
125
+ await gitFetchAndReset({
126
+ apiUrl: cfg.cloudUrl,
127
+ apiKey: cfg.apiKey,
128
+ projectId,
129
+ localDir,
130
+ branch: targetSha,
131
+ });
132
+ } else {
133
+ throw new Error(`Cannot reach commit ${targetSha} in local clone`);
134
+ }
135
+ }
136
+
137
+ // Final reset to the requested sha (FETCH_HEAD may differ if we
138
+ // fetched a branch and the user asked for a specific commit).
139
+ await runGit(['reset', '--hard', targetSha], { cwd: localDir });
140
+ console.log(pc.green(`✓ Checked out ${targetSha.slice(0, 8)}`));
141
+ }
142
+
143
+ interface CheckpointListResponse {
144
+ ok: true;
145
+ checkpoints: Array<{ id: string; commitSha: string; name: string | null; commitMessage: string | null; createdAt: string }>;
146
+ hasMore: boolean;
147
+ }
148
+
149
+ /**
150
+ * Resolve a checkpoint by user-facing name (case-insensitive).
151
+ * Strategy: ask the cloud for the most recent N checkpoints, pick the
152
+ * one whose `name` or first 8 chars of `commitMessage` matches.
153
+ */
154
+ async function resolveCheckpointByName(
155
+ cloudUrl: string,
156
+ apiKey: string,
157
+ projectId: string,
158
+ needle: string,
159
+ ): Promise<string> {
160
+ const url = `${cloudUrl.replace(/\/+$/, '')}/api/projects/${encodeURIComponent(projectId)}/checkpoints?limit=100`;
161
+ const res = await fetch(url, {
162
+ headers: { Authorization: `Bearer ${apiKey}` },
163
+ });
164
+ if (!res.ok) {
165
+ throw new Error(`Failed to list checkpoints: HTTP ${res.status}`);
166
+ }
167
+ const body = (await res.json()) as CheckpointListResponse;
168
+ const norm = needle.toLowerCase();
169
+ const match = body.checkpoints.find((cp) => {
170
+ if (cp.name && cp.name.toLowerCase() === norm) return true;
171
+ if (cp.commitSha.startsWith(needle)) return true;
172
+ if (cp.commitMessage && cp.commitMessage.toLowerCase().includes(norm)) return true;
173
+ return false;
174
+ });
175
+ if (!match) {
176
+ throw new Error(`No checkpoint matches "${needle}" (searched ${body.checkpoints.length} most recent)`);
177
+ }
178
+ return match.commitSha;
179
+ }
@@ -0,0 +1,133 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Copyright (C) 2026 Shogo Technologies, Inc.
3
+ /**
4
+ * `shogo project pull <projectId>` — clone a project's workspace from
5
+ * Shogo Cloud to a local directory. Optionally `--watch` starts a
6
+ * bidirectional sync: pull initially, then push any local edits back via
7
+ * the {@link CloudSyncWatcher}.
8
+ *
9
+ * This is the easy-button companion to pinning a project to a paired
10
+ * machine:
11
+ *
12
+ * shogo login # one-time pairing
13
+ * shogo project pull <projectId> # clones the staging snapshot locally
14
+ * shogo worker start # auto-routes pinned traffic here
15
+ */
16
+
17
+ import { existsSync, mkdirSync } from 'node:fs';
18
+ import { resolve } from 'node:path';
19
+ import pc from 'picocolors';
20
+ import { CloudFileTransport, type ProgressEvent, type SyncStats } from '@shogo-ai/sdk';
21
+ import { resolveConfig } from '../lib/config.ts';
22
+ import { ensureProjectsDir, projectDirFor } from '../lib/paths.ts';
23
+ import { CloudSyncWatcher } from '../lib/cloud-sync-watcher.ts';
24
+
25
+ export interface ProjectPullFlags {
26
+ into?: string;
27
+ watch?: boolean;
28
+ include?: string;
29
+ apiKey?: string;
30
+ cloudUrl?: string;
31
+ }
32
+
33
+ export async function runProjectPull(projectId: string, flags: ProjectPullFlags): Promise<void> {
34
+ if (!projectId) throw new Error('projectId is required');
35
+
36
+ const cfg = resolveConfig({
37
+ apiKey: flags.apiKey,
38
+ cloudUrl: flags.cloudUrl,
39
+ });
40
+
41
+ ensureProjectsDir(cfg.projectsDir);
42
+ const into = resolve(flags.into ?? projectDirFor(projectId, cfg.projectsDir));
43
+ if (!existsSync(into)) {
44
+ mkdirSync(into, { recursive: true });
45
+ }
46
+
47
+ const include = flags.include?.split(',').map((s) => s.trim()).filter(Boolean);
48
+
49
+ console.log(pc.bold(`\nshogo project pull ${pc.cyan(projectId)}`));
50
+ console.log(pc.dim(' cloud ') + cfg.cloudUrl);
51
+ console.log(pc.dim(' into ') + into);
52
+ if (include?.length) console.log(pc.dim(' include ') + include.join(', '));
53
+ console.log('');
54
+
55
+ const transport = new CloudFileTransport({
56
+ apiUrl: cfg.cloudUrl,
57
+ apiKey: cfg.apiKey,
58
+ projectId,
59
+ localDir: into,
60
+ include,
61
+ onProgress: makeProgressReporter(),
62
+ });
63
+
64
+ const stats = await transport.downloadAll();
65
+ printSummary('Pull', stats);
66
+
67
+ if (flags.watch) {
68
+ console.log(pc.bold('\nWatching for local changes...'));
69
+ const watcher = new CloudSyncWatcher({
70
+ rootDir: into,
71
+ transport,
72
+ onFlush: ({ uploaded, errors }) => {
73
+ const list = uploaded.length === 1 ? uploaded[0] : `${uploaded.length} files`;
74
+ const errSuffix = errors > 0 ? pc.red(` (${errors} errors)`) : '';
75
+ console.log(pc.dim(` ↑ ${list}${errSuffix}`));
76
+ },
77
+ });
78
+ watcher.start();
79
+
80
+ let shuttingDown = false;
81
+ const shutdown = async (signal: NodeJS.Signals) => {
82
+ if (shuttingDown) return;
83
+ shuttingDown = true;
84
+ console.log(pc.dim(`\nReceived ${signal} — flushing pending uploads...`));
85
+ try {
86
+ await watcher.stop();
87
+ } catch (err: any) {
88
+ console.warn(pc.yellow(`watcher.stop: ${err?.message ?? err}`));
89
+ }
90
+ process.exit(0);
91
+ };
92
+ process.once('SIGINT', () => void shutdown('SIGINT'));
93
+ process.once('SIGTERM', () => void shutdown('SIGTERM'));
94
+ process.once('SIGHUP', () => void shutdown('SIGHUP'));
95
+
96
+ // Park the foreground process. Shutdown handler exits when the user terminates.
97
+ await new Promise<void>(() => { /* never resolves */ });
98
+ }
99
+ }
100
+
101
+ function makeProgressReporter(): (e: ProgressEvent) => void {
102
+ return (e) => {
103
+ const verb = e.kind === 'download' ? '↓' : e.kind === 'upload' ? '↑' : e.kind === 'delete' ? '✗' : '·';
104
+ const pct = e.total > 0 ? ` ${e.index + 1}/${e.total}` : '';
105
+ const sizeNote = e.bytes != null ? pc.dim(` (${formatBytes(e.bytes)})`) : '';
106
+ console.log(pc.dim(` ${verb}${pct} ${e.path}${sizeNote}`));
107
+ };
108
+ }
109
+
110
+ function printSummary(label: string, stats: SyncStats): void {
111
+ const ok = stats.errors.length === 0;
112
+ const head = ok ? pc.green(`✓ ${label} complete`) : pc.red(`✗ ${label} completed with errors`);
113
+ console.log(`\n${head}`);
114
+ console.log(pc.dim(' downloaded: ') + stats.downloaded);
115
+ if (stats.uploaded) console.log(pc.dim(' uploaded: ') + stats.uploaded);
116
+ if (stats.deleted) console.log(pc.dim(' deleted: ') + stats.deleted);
117
+ if (stats.skipped) console.log(pc.dim(' skipped: ') + stats.skipped);
118
+ if (!ok) {
119
+ console.log(pc.dim(' errors: ') + stats.errors.length);
120
+ for (const err of stats.errors.slice(0, 5)) {
121
+ console.log(pc.dim(' ') + pc.red(`${err.path}: ${err.message}`));
122
+ }
123
+ if (stats.errors.length > 5) {
124
+ console.log(pc.dim(` ... and ${stats.errors.length - 5} more`));
125
+ }
126
+ }
127
+ }
128
+
129
+ function formatBytes(n: number): string {
130
+ if (n < 1024) return `${n}B`;
131
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)}KB`;
132
+ return `${(n / 1024 / 1024).toFixed(2)}MB`;
133
+ }