@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 +218 -10
- package/package.json +1 -1
- package/src/cli.ts +36 -1
- package/src/commands/project-checkout.ts +179 -0
- package/src/commands/project-pull.ts +133 -0
- package/src/commands/project-push.ts +89 -0
- package/src/commands/start.ts +28 -3
- package/src/lib/__tests__/cloud-sync-watcher.test.ts +209 -0
- package/src/lib/__tests__/config.test.ts +5 -0
- package/src/lib/__tests__/git-cloner.test.ts +258 -0
- package/src/lib/__tests__/runtime-manager-auto-pull.test.ts +275 -0
- package/src/lib/__tests__/runtime-manager-describe-rejection.test.ts +42 -0
- package/src/lib/__tests__/runtime-manager-git-pull.test.ts +207 -0
- package/src/lib/__tests__/runtime-manager-tree-sitter-env.test.ts +124 -0
- package/src/lib/__tests__/tunnel-structured-502.test.ts +101 -0
- package/src/lib/cloud-sync-watcher.ts +311 -0
- package/src/lib/config.ts +7 -2
- package/src/lib/git-cloner.ts +354 -0
- package/src/lib/paths.ts +18 -0
- package/src/lib/runtime-manager.ts +469 -8
- package/src/lib/tunnel.ts +40 -1
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
|
-
|
|
13
|
-
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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: {
|
|
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-
|
|
387
|
+
- Source: [github.com/shogo-labs/shogo-ai](https://github.com/shogo-labs/shogo-ai)
|
package/package.json
CHANGED
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
|
+
}
|