@shogo-ai/worker 1.0.10

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.
Files changed (57) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +432 -0
  3. package/bin/shogo.mjs +38 -0
  4. package/dist/cli.mjs +64 -0
  5. package/package.json +62 -0
  6. package/src/cli.ts +199 -0
  7. package/src/commands/agent.ts +123 -0
  8. package/src/commands/config.ts +29 -0
  9. package/src/commands/doctor.ts +128 -0
  10. package/src/commands/login.ts +126 -0
  11. package/src/commands/logs.ts +24 -0
  12. package/src/commands/project-checkout.ts +179 -0
  13. package/src/commands/project-pull.ts +133 -0
  14. package/src/commands/project-push.ts +89 -0
  15. package/src/commands/runtime.ts +104 -0
  16. package/src/commands/start.ts +290 -0
  17. package/src/commands/status.ts +22 -0
  18. package/src/commands/stop.ts +13 -0
  19. package/src/lib/__tests__/agent-launch.test.ts +92 -0
  20. package/src/lib/__tests__/cloud-login-coverage.test.ts +210 -0
  21. package/src/lib/__tests__/cloud-login.test.ts +218 -0
  22. package/src/lib/__tests__/cloud-sync-watcher.test.ts +214 -0
  23. package/src/lib/__tests__/config.test.ts +141 -0
  24. package/src/lib/__tests__/db-doctor.test.ts +204 -0
  25. package/src/lib/__tests__/git-cloner.test.ts +382 -0
  26. package/src/lib/__tests__/runtime-manager-auto-pull.test.ts +275 -0
  27. package/src/lib/__tests__/runtime-manager-cloud-sync-gate.test.ts +119 -0
  28. package/src/lib/__tests__/runtime-manager-coverage-gaps.test.ts +768 -0
  29. package/src/lib/__tests__/runtime-manager-describe-rejection.test.ts +42 -0
  30. package/src/lib/__tests__/runtime-manager-git-pull.test.ts +207 -0
  31. package/src/lib/__tests__/runtime-manager-idle-eviction.test.ts +196 -0
  32. package/src/lib/__tests__/runtime-manager-restart-cap.test.ts +310 -0
  33. package/src/lib/__tests__/runtime-manager-tree-sitter-env.test.ts +189 -0
  34. package/src/lib/__tests__/runtime-manager-wait-for-health.test.ts +269 -0
  35. package/src/lib/__tests__/runtime-resolver.test.ts +112 -0
  36. package/src/lib/__tests__/tunnel-coverage.test.ts +1094 -0
  37. package/src/lib/__tests__/tunnel-structured-502.test.ts +101 -0
  38. package/src/lib/__tests__/worker-commands-greenfield.test.ts +463 -0
  39. package/src/lib/__tests__/worker-libs-greenfield.test.ts +355 -0
  40. package/src/lib/__tests__/worker-transport-greenfield.test.ts +176 -0
  41. package/src/lib/api-discovery.ts +36 -0
  42. package/src/lib/cloud-login.ts +320 -0
  43. package/src/lib/cloud-sync-watcher.ts +322 -0
  44. package/src/lib/config.ts +68 -0
  45. package/src/lib/db-doctor.ts +355 -0
  46. package/src/lib/device-id.ts +27 -0
  47. package/src/lib/git-cloner.ts +354 -0
  48. package/src/lib/paths.ts +53 -0
  49. package/src/lib/preflight.ts +158 -0
  50. package/src/lib/process-manager.ts +123 -0
  51. package/src/lib/resource-limits.ts +340 -0
  52. package/src/lib/runtime-install.ts +371 -0
  53. package/src/lib/runtime-manager.ts +1873 -0
  54. package/src/lib/runtime-resolver.ts +136 -0
  55. package/src/lib/transport.ts +202 -0
  56. package/src/lib/tunnel.ts +710 -0
  57. package/src/lib/user-agent.ts +28 -0
@@ -0,0 +1,320 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Copyright (C) 2026 Shogo Technologies, Inc.
3
+ /**
4
+ * CLI cloud-login — pure poll-based device flow.
5
+ *
6
+ * The CLI asks the cloud to mint a one-time pending-state, opens the
7
+ * bridge page in the user's browser, then polls the cloud until the
8
+ * bridge page approves the request — at which point the cloud returns
9
+ * the minted device-tagged API key exactly once and discards the
10
+ * state. The Shogo desktop app uses the exact same handshake (driven
11
+ * from `apps/desktop/src/main.ts → runCloudSignIn`); the only thing
12
+ * that differs is the `client=cli` URL hint we send so the bridge
13
+ * page renders the right copy.
14
+ *
15
+ * Sequence:
16
+ * 1. CLI → POST /api/cli/login/start (with device metadata)
17
+ * cloud stores pending state, replies { state, userCode, authUrl,
18
+ * expiresInMs, pollIntervalMs }.
19
+ * 2. CLI prints the URL + userCode and opens it in the browser.
20
+ * 3. User signs in at <cloudUrl>/auth/cli-link?state=... and clicks
21
+ * "Approve". The bridge page POSTs /api/cli/login/approve which
22
+ * mints the key on the cloud and pins it to `state`.
23
+ * 4. CLI polls GET /api/cli/login/poll?state=... at pollIntervalMs:
24
+ * pending → keep polling
25
+ * approved → returns { key, email, workspace, deviceId } once,
26
+ * then the state is deleted.
27
+ * denied → user clicked "Cancel" on the bridge page.
28
+ * expired → 5-minute TTL elapsed.
29
+ *
30
+ * Notes:
31
+ * - The userCode (last 6 hex of state, uppercased) is shown in both
32
+ * the CLI and the browser so the user can confirm the device they
33
+ * are approving matches the terminal they typed `shogo login` in.
34
+ * - No localhost listener, no protocol handler, no deep links —
35
+ * works behind firewalls / over SSH / from a remote tmux session.
36
+ */
37
+ import { spawn } from 'node:child_process';
38
+ import { hostname, platform as osPlatform, arch } from 'node:os';
39
+ import pc from 'picocolors';
40
+ import { workerUserAgent } from './user-agent.ts';
41
+
42
+ export interface CloudLoginResult {
43
+ /** The minted shogo_sk_ key. */
44
+ key: string;
45
+ /** User email reported by cloud. */
46
+ email: string | null;
47
+ /** Workspace name the key was minted for. */
48
+ workspace: string | null;
49
+ /** Stable device id we sent up; cloud echoes it back. */
50
+ deviceId: string;
51
+ }
52
+
53
+ export interface CloudLoginOptions {
54
+ cloudUrl: string;
55
+ /** "cli" | "desktop" — hint sent to the cloud and echoed on the
56
+ * bridge page so the UI can label the approval flow correctly.
57
+ * Default: "cli". */
58
+ client?: 'cli' | 'desktop';
59
+ /** Override the device label shown in the dashboard. Defaults to hostname. */
60
+ deviceName?: string;
61
+ /** Override the platform string shown in the dashboard
62
+ * (e.g. "darwin-arm64"). Defaults to `${os.platform()}-${os.arch()}`. */
63
+ devicePlatform?: string;
64
+ /** Stable id for this machine. Caller should persist this so re-logins
65
+ * dedupe to the same device row. */
66
+ deviceId: string;
67
+ /** App version label sent to cloud. */
68
+ appVersion?: string;
69
+ /** Optional pre-select for the bridge picker. */
70
+ workspaceId?: string;
71
+ /** Cap on total wait — defaults to whatever the cloud says (usually 5min). */
72
+ timeoutMs?: number;
73
+ /** Browser-open behaviour. Three modes:
74
+ * - undefined / true: spawn the platform default opener (open /
75
+ * xdg-open / cmd start). Suitable for the CLI.
76
+ * - false: never open the browser; caller surfaces the URL.
77
+ * - function: custom opener (e.g. Electron's `shell.openExternal`).
78
+ * Errors thrown from the function are swallowed so flaky openers
79
+ * don't kill the flow — the URL is still printed via `log`. */
80
+ openBrowser?: boolean | ((url: string) => void | Promise<void>);
81
+ /** Custom logger — defaults to console.log. */
82
+ log?: (line: string) => void;
83
+ /** Override poll interval in ms. Defaults to whatever the cloud returns. */
84
+ pollIntervalMs?: number;
85
+ /** Install process-level SIGINT/SIGTERM handlers that abort the poll
86
+ * loop. Default: true (right for the CLI). Set false when embedding
87
+ * inside a long-lived host process (e.g. Electron main) where the
88
+ * caller already manages signal lifecycle and an unrelated Ctrl+C
89
+ * shouldn't unwind the host. */
90
+ installSignalHandlers?: boolean;
91
+ /** Test seam: replace the real fetch. */
92
+ fetchImpl?: typeof fetch;
93
+ /** Test seam: an AbortSignal to stop polling early. */
94
+ abortSignal?: AbortSignal;
95
+ }
96
+
97
+ export class CloudLoginError extends Error {
98
+ constructor(
99
+ message: string,
100
+ public readonly kind: 'timeout' | 'denied' | 'cancelled' | 'expired' | 'transport',
101
+ ) {
102
+ super(message);
103
+ this.name = 'CloudLoginError';
104
+ }
105
+ }
106
+
107
+ interface StartResponse {
108
+ ok: boolean;
109
+ error?: string;
110
+ state: string;
111
+ userCode: string;
112
+ authUrl: string;
113
+ expiresInMs: number;
114
+ pollIntervalMs: number;
115
+ }
116
+
117
+ interface PollResponse {
118
+ ok: boolean;
119
+ status: 'pending' | 'approved' | 'denied' | 'expired';
120
+ key?: string;
121
+ email?: string | null;
122
+ workspace?: string | null;
123
+ deviceId?: string;
124
+ error?: string;
125
+ }
126
+
127
+ export async function runCloudLogin(opts: CloudLoginOptions): Promise<CloudLoginResult> {
128
+ const cloudUrl = opts.cloudUrl.replace(/\/$/, '');
129
+ const client = opts.client ?? 'cli';
130
+ const deviceName = opts.deviceName ?? hostname();
131
+ const devicePlatform = opts.devicePlatform ?? `${osPlatform()}-${arch()}`;
132
+ const appVersion = opts.appVersion ?? readWorkerVersion();
133
+ const log = opts.log ?? ((line: string) => console.log(line));
134
+ const fetchImpl = opts.fetchImpl ?? fetch;
135
+
136
+ // 1. Start
137
+ const startRes = await fetchImpl(`${cloudUrl}/api/cli/login/start`, {
138
+ method: 'POST',
139
+ headers: { 'content-type': 'application/json', 'user-agent': workerUserAgent() },
140
+ body: JSON.stringify({
141
+ deviceId: opts.deviceId,
142
+ deviceName,
143
+ devicePlatform,
144
+ deviceAppVersion: appVersion,
145
+ workspaceId: opts.workspaceId,
146
+ client,
147
+ }),
148
+ signal: AbortSignal.timeout(10_000),
149
+ }).catch((err) => {
150
+ throw new CloudLoginError(
151
+ `Cannot reach Shogo Cloud at ${cloudUrl}: ${err?.message ?? err}`,
152
+ 'transport',
153
+ );
154
+ });
155
+ if (!startRes.ok) {
156
+ const text = await startRes.text().catch(() => '');
157
+ throw new CloudLoginError(
158
+ `Cloud rejected /api/cli/login/start (HTTP ${startRes.status}): ${text || 'no body'}`,
159
+ 'transport',
160
+ );
161
+ }
162
+ const start = (await startRes.json().catch(() => ({} as any))) as StartResponse;
163
+ if (!start?.ok || !start.state || !start.authUrl) {
164
+ throw new CloudLoginError(
165
+ `Cloud returned a malformed start response: ${start?.error ?? JSON.stringify(start)}`,
166
+ 'transport',
167
+ );
168
+ }
169
+
170
+ const pollIntervalMs = clampPollInterval(opts.pollIntervalMs ?? start.pollIntervalMs);
171
+ const timeoutMs = opts.timeoutMs ?? start.expiresInMs;
172
+ const deadline = Date.now() + timeoutMs;
173
+
174
+ // 2. Print + open browser
175
+ log('');
176
+ log(pc.bold('Sign in to Shogo Cloud'));
177
+ log(pc.dim(' cloud: ') + cloudUrl);
178
+ log(pc.dim(' device: ') + `${deviceName} (${devicePlatform})`);
179
+ log(pc.dim(' user code: ') + pc.cyan(start.userCode));
180
+ log('');
181
+ log(' Open this URL in your browser to approve:');
182
+ log(' ' + pc.cyan(start.authUrl));
183
+ log('');
184
+
185
+ if (opts.openBrowser !== false) {
186
+ if (typeof opts.openBrowser === 'function') {
187
+ // Caller-supplied opener (e.g. Electron `shell.openExternal`). We
188
+ // intentionally swallow errors here so a flaky opener doesn't
189
+ // kill the flow — the URL is already in `log` for copy/paste.
190
+ try {
191
+ await opts.openBrowser(start.authUrl);
192
+ } catch { /* user can copy/paste */ }
193
+ } else {
194
+ openInBrowser(start.authUrl).catch(() => { /* user can copy/paste */ });
195
+ }
196
+ }
197
+
198
+ log(pc.dim('Waiting for approval...'));
199
+
200
+ // 3. Poll loop
201
+ const installSignals = opts.installSignalHandlers ?? true;
202
+ const sigHandler = () => {
203
+ // We can't actually throw out of a signal handler — Node won't
204
+ // propagate it up the stack. Setting the abort signal is the right
205
+ // way to unwind the poll loop.
206
+ abortLocal.abort();
207
+ };
208
+ // Internal abort controller composes the caller's signal with our
209
+ // own signal-handler-driven cancellation, so the poll loop only
210
+ // needs to watch one signal.
211
+ const abortLocal = new AbortController();
212
+ if (opts.abortSignal) {
213
+ if (opts.abortSignal.aborted) abortLocal.abort();
214
+ else opts.abortSignal.addEventListener('abort', () => abortLocal.abort(), { once: true });
215
+ }
216
+ if (installSignals) {
217
+ process.once('SIGINT', sigHandler);
218
+ process.once('SIGTERM', sigHandler);
219
+ }
220
+
221
+ try {
222
+ while (true) {
223
+ if (abortLocal.signal.aborted) {
224
+ throw new CloudLoginError('Aborted by caller.', 'cancelled');
225
+ }
226
+ if (Date.now() >= deadline) {
227
+ throw new CloudLoginError(
228
+ `Timed out after ${Math.round(timeoutMs / 1000)}s waiting for approval.`,
229
+ 'timeout',
230
+ );
231
+ }
232
+
233
+ const pollRes = await fetchImpl(
234
+ `${cloudUrl}/api/cli/login/poll?state=${encodeURIComponent(start.state)}`,
235
+ {
236
+ method: 'GET',
237
+ headers: { accept: 'application/json', 'user-agent': workerUserAgent() },
238
+ signal: AbortSignal.timeout(10_000),
239
+ },
240
+ ).catch((err) => {
241
+ // Soft network errors during polling shouldn't kill the flow —
242
+ // user might have a flaky connection. Log and back off.
243
+ log(pc.dim(` (poll error: ${err?.message ?? err} — retrying)`));
244
+ return null;
245
+ });
246
+
247
+ if (pollRes && pollRes.ok) {
248
+ const data = (await pollRes.json().catch(() => ({} as any))) as PollResponse;
249
+ if (data?.status === 'approved' && data.key) {
250
+ return {
251
+ key: data.key,
252
+ email: data.email ?? null,
253
+ workspace: data.workspace ?? null,
254
+ deviceId: data.deviceId ?? opts.deviceId,
255
+ };
256
+ }
257
+ if (data?.status === 'denied') {
258
+ throw new CloudLoginError('Sign-in was denied in the browser.', 'denied');
259
+ }
260
+ if (data?.status === 'expired') {
261
+ throw new CloudLoginError('Sign-in request expired before it was approved.', 'expired');
262
+ }
263
+ // status === 'pending' (or unknown) → keep polling
264
+ }
265
+
266
+ await sleep(pollIntervalMs, abortLocal.signal);
267
+ }
268
+ } finally {
269
+ if (installSignals) {
270
+ process.removeListener('SIGINT', sigHandler);
271
+ process.removeListener('SIGTERM', sigHandler);
272
+ }
273
+ }
274
+ }
275
+
276
+ function clampPollInterval(ms: number): number {
277
+ if (!Number.isFinite(ms) || ms <= 0) return 2000;
278
+ return Math.min(Math.max(ms, 1000), 10_000);
279
+ }
280
+
281
+ function sleep(ms: number, signal?: AbortSignal): Promise<void> {
282
+ return new Promise((resolve, reject) => {
283
+ if (signal?.aborted) return reject(new CloudLoginError('Aborted by caller.', 'cancelled'));
284
+ const t = setTimeout(() => {
285
+ signal?.removeEventListener('abort', onAbort);
286
+ resolve();
287
+ }, ms);
288
+ const onAbort = () => {
289
+ clearTimeout(t);
290
+ reject(new CloudLoginError('Aborted by caller.', 'cancelled'));
291
+ };
292
+ signal?.addEventListener('abort', onAbort, { once: true });
293
+ });
294
+ }
295
+
296
+ // The build-time injected worker version (`shogo-cli/<version>`) is resolved
297
+ // by the shared `workerUserAgent()` helper — see `./user-agent.ts` for the
298
+ // bundler `--define` mechanism and the standalone-library fallback. It is used
299
+ // here as the `deviceAppVersion` reported to the cloud during device login.
300
+ function readWorkerVersion(): string {
301
+ return workerUserAgent();
302
+ }
303
+
304
+ function openInBrowser(url: string): Promise<void> {
305
+ return new Promise((resolve) => {
306
+ const cmd =
307
+ process.platform === 'darwin' ? 'open'
308
+ : process.platform === 'win32' ? 'cmd'
309
+ : 'xdg-open';
310
+ const args = process.platform === 'win32' ? ['/c', 'start', '""', url] : [url];
311
+ try {
312
+ const child = spawn(cmd, args, { stdio: 'ignore', detached: true });
313
+ child.on('error', () => resolve());
314
+ child.unref();
315
+ resolve();
316
+ } catch {
317
+ resolve();
318
+ }
319
+ });
320
+ }
@@ -0,0 +1,322 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Copyright (C) 2026 Shogo Technologies, Inc.
3
+ /**
4
+ * Node-only filesystem watcher that pushes local edits back to Shogo
5
+ * Cloud via {@link CloudFileTransport}. Used by:
6
+ * - `shogo project pull --watch` (foreground sync)
7
+ * - `WorkerRuntimeManager` auto-pull (background sync alongside an
8
+ * actively running agent-runtime)
9
+ *
10
+ * Design notes:
11
+ * - Uses `node:fs.watch` recursively when available (Linux >= 20.x via
12
+ * `recursive: true`, macOS, Windows) — no `chokidar` dep.
13
+ * - Debounces a window of changes into a single batch upload to amortize
14
+ * presign round trips. Default 1.5s.
15
+ * - Skips EXCLUDED_DIRS the same way the transport does.
16
+ * - Best-effort: errors are logged but never crash the watcher; the
17
+ * caller's UI surfaces them.
18
+ */
19
+
20
+ import { watch, type FSWatcher, statSync } from 'node:fs';
21
+ import { relative, sep, posix } from 'node:path';
22
+ import type { CloudFileTransport } from '@shogo-ai/sdk/cloud-file-transport';
23
+ // commitAndPush is dynamically imported so this file does NOT eagerly
24
+ // pull in git-cloner.ts at module-load. git-cloner.ts captures
25
+ // `node:child_process` via `const execFileAsync = promisify(execFile)`
26
+ // at load time; if cloud-sync-watcher.ts (and its tests) trigger that
27
+ // load before git-cloner.test.ts's `mock.module('node:child_process', ...)`
28
+ // hoist runs, the mock no longer applies to the cached `execFileAsync`.
29
+ // Lazy resolution keeps git-cloner.ts unloaded until either a tests-
30
+ // supplied `opts.commitAndPush` short-circuits the default, or the
31
+ // real `flushGit` call path explicitly opts in.
32
+
33
+ const DEBOUNCE_MS = 1500;
34
+ const EXCLUDED_DIRS = new Set(['node_modules', '.git', 'dist', 'build', '.vite', '.cache']);
35
+ /** Paths under these directories are gitignored — always route them
36
+ * through the file transport even when the watcher is in 'git' mode. */
37
+ const ALWAYS_FILE_TRANSPORT_PREFIXES = ['.shogo/'];
38
+
39
+ export type WatcherSyncMode = 'git' | 'files';
40
+
41
+ export interface WatcherGitOptions {
42
+ /** Cloud API URL. Forwarded to `commitAndPush`. */
43
+ apiUrl: string;
44
+ /** Bearer API key. */
45
+ apiKey: string;
46
+ /** Project ID. */
47
+ projectId: string;
48
+ /** Branch to push to. Default `HEAD` (current). */
49
+ branch?: string;
50
+ /** Optional author identity for the auto-commits. */
51
+ authorEmail?: string;
52
+ authorName?: string;
53
+ }
54
+
55
+ /** Signature of `commitAndPush` from `./git-cloner`. Extracted so tests
56
+ * can inject a fake without resorting to module-level mocking (which
57
+ * leaks across files in bun:test). */
58
+ export type CommitAndPushFn = (opts: {
59
+ apiUrl: string;
60
+ apiKey: string;
61
+ projectId: string;
62
+ localDir: string;
63
+ message: string;
64
+ branch?: string;
65
+ authorEmail?: string;
66
+ authorName?: string;
67
+ logger?: Pick<Console, 'log' | 'warn' | 'error'>;
68
+ }) => Promise<{ committed: boolean; commitSha?: string }>;
69
+
70
+ export interface CloudSyncWatcherOptions {
71
+ /** Local directory to watch. */
72
+ rootDir: string;
73
+ /** Transport used to push uploads. Always required — `.shogo/` writes
74
+ * flow through here even in git mode. */
75
+ transport: CloudFileTransport;
76
+ /** Optional debounce window in ms. Default 1500. */
77
+ debounceMs?: number;
78
+ /** Logger. Defaults to console. */
79
+ logger?: Pick<Console, 'log' | 'warn' | 'error'>;
80
+ /** Called whenever a batch flushes. Useful for progress UIs / metrics. */
81
+ onFlush?: (event: { uploaded: string[]; errors: number; committed?: boolean; commitSha?: string }) => void;
82
+ /**
83
+ * Sync strategy. Defaults to `'files'` to keep existing callers
84
+ * (manual `shogo project pull --watch`) working unchanged.
85
+ *
86
+ * In `'git'` mode the flush stages all tracked changes and pushes
87
+ * one commit to the cloud; `.shogo/` writes still PUT through the
88
+ * file transport because they're gitignored.
89
+ */
90
+ mode?: WatcherSyncMode;
91
+ /** Git options. Required when `mode: 'git'`. */
92
+ git?: WatcherGitOptions;
93
+ /** Override for the `commitAndPush` implementation. Tests inject a fake;
94
+ * production callers leave this unset and get the real one. */
95
+ commitAndPush?: CommitAndPushFn;
96
+ }
97
+
98
+ /** Convert an absolute or platform-native path to a forward-slash relative path. */
99
+ function toPosixRel(rootDir: string, abs: string): string {
100
+ const rel = relative(rootDir, abs);
101
+ if (!rel) return '';
102
+ return rel.split(sep).join(posix.sep);
103
+ }
104
+
105
+ function isExcluded(relPath: string): boolean {
106
+ if (!relPath) return true;
107
+ return relPath.split('/').some((part) => EXCLUDED_DIRS.has(part));
108
+ }
109
+
110
+ export class CloudSyncWatcher {
111
+ private readonly rootDir: string;
112
+ private readonly transport: CloudFileTransport;
113
+ private readonly debounceMs: number;
114
+ private readonly logger: Pick<Console, 'log' | 'warn' | 'error'>;
115
+ private readonly onFlush?: CloudSyncWatcherOptions['onFlush'];
116
+ private readonly mode: WatcherSyncMode;
117
+ private readonly git?: WatcherGitOptions;
118
+ private readonly commitAndPush: CommitAndPushFn;
119
+
120
+ private watcher: FSWatcher | null = null;
121
+ private pending = new Set<string>();
122
+ private timer: ReturnType<typeof setTimeout> | null = null;
123
+ private flushing = false;
124
+ private stopped = false;
125
+
126
+ constructor(opts: CloudSyncWatcherOptions) {
127
+ this.rootDir = opts.rootDir;
128
+ this.transport = opts.transport;
129
+ this.debounceMs = opts.debounceMs ?? DEBOUNCE_MS;
130
+ this.logger = opts.logger ?? console;
131
+ this.onFlush = opts.onFlush;
132
+ this.mode = opts.mode ?? 'files';
133
+ if (this.mode === 'git' && !opts.git) {
134
+ throw new Error('CloudSyncWatcher: mode: "git" requires the `git` option block');
135
+ }
136
+ this.git = opts.git;
137
+ this.commitAndPush = opts.commitAndPush ?? (async (args) => {
138
+ const mod = await import('./git-cloner.ts');
139
+ return mod.commitAndPush(args);
140
+ });
141
+ }
142
+
143
+ /**
144
+ * Begin watching. Throws if the directory can't be watched (e.g. it
145
+ * doesn't exist). Callers should wrap in try/catch.
146
+ */
147
+ start(): void {
148
+ if (this.watcher) return;
149
+ if (this.stopped) throw new Error('CloudSyncWatcher: already stopped, build a fresh instance');
150
+
151
+ // Some platforms / Node versions don't support `recursive: true`. Fall
152
+ // back to a non-recursive watch on the root + per-discovered-subdir
153
+ // watches in that case. For Linux >= 20 / macOS / Windows, the
154
+ // recursive variant is much cheaper and we use it by default.
155
+ try {
156
+ this.watcher = watch(this.rootDir, { recursive: true }, (eventType, filename) => {
157
+ if (!filename) return;
158
+ this.handleEvent(String(filename));
159
+ });
160
+ } catch (err: any) {
161
+ this.logger.warn(`[CloudSyncWatcher] recursive watch failed (${err?.message ?? err}); falling back to root-only watch.`);
162
+ this.watcher = watch(this.rootDir, (eventType, filename) => {
163
+ if (!filename) return;
164
+ this.handleEvent(String(filename));
165
+ });
166
+ }
167
+ }
168
+
169
+ /** Stop watching and flush any pending uploads. */
170
+ async stop(): Promise<void> {
171
+ if (this.stopped) return;
172
+ this.stopped = true;
173
+ if (this.timer) {
174
+ clearTimeout(this.timer);
175
+ this.timer = null;
176
+ }
177
+ if (this.watcher) {
178
+ try {
179
+ this.watcher.close();
180
+ } catch {
181
+ /* already closed */
182
+ }
183
+ this.watcher = null;
184
+ }
185
+ // Final flush so we don't lose the last edits made before SIGINT.
186
+ if (this.pending.size > 0) {
187
+ await this.flush();
188
+ }
189
+ }
190
+
191
+ private handleEvent(filenameLike: string): void {
192
+ if (this.stopped) return;
193
+ const rel = filenameLike.split(sep).join(posix.sep);
194
+ if (isExcluded(rel)) return;
195
+ // Ignore directory events — we only push file content. A directory rename
196
+ // shows up later as individual file events when their content is touched.
197
+ try {
198
+ const abs = `${this.rootDir}/${rel}`;
199
+ const stat = statSync(abs);
200
+ if (stat.isDirectory()) return;
201
+ } catch {
202
+ // File was deleted between the event firing and the stat — emit a
203
+ // delete intent. We track these as `null` markers in `pending` but
204
+ // since the transport doesn't have a "push pending deletes" mode
205
+ // we just drop them; the next `--delete-remote` push will clean
206
+ // them up. Documented behavior.
207
+ return;
208
+ }
209
+ this.pending.add(rel);
210
+ this.scheduleFlush();
211
+ }
212
+
213
+ private scheduleFlush(): void {
214
+ if (this.timer) return;
215
+ this.timer = setTimeout(() => {
216
+ this.timer = null;
217
+ void this.flush();
218
+ }, this.debounceMs);
219
+ }
220
+
221
+ private async flush(): Promise<void> {
222
+ if (this.flushing) {
223
+ // Re-schedule so we don't drop edits that arrived during the upload.
224
+ this.scheduleFlush();
225
+ return;
226
+ }
227
+ if (this.pending.size === 0) return;
228
+ this.flushing = true;
229
+ const batch = Array.from(this.pending);
230
+ this.pending.clear();
231
+ try {
232
+ if (this.mode === 'git') {
233
+ await this.flushGit(batch);
234
+ } else {
235
+ await this.flushFiles(batch);
236
+ }
237
+ } catch (err: any) {
238
+ this.logger.error(`[CloudSyncWatcher] flush failed: ${err?.message ?? err}`);
239
+ // Re-queue so we'll retry on the next tick.
240
+ for (const p of batch) this.pending.add(p);
241
+ this.scheduleFlush();
242
+ } finally {
243
+ this.flushing = false;
244
+ }
245
+ }
246
+
247
+ /**
248
+ * Pure file-transport flush. Used in `mode: 'files'` and as the
249
+ * always-on path for `.shogo/` writes when `mode: 'git'`.
250
+ */
251
+ private async flushFiles(batch: string[]): Promise<void> {
252
+ if (batch.length === 0) return;
253
+ const stats = await this.transport.uploadFiles(batch);
254
+ this.onFlush?.({ uploaded: batch, errors: stats.errors.length });
255
+ if (stats.errors.length > 0) {
256
+ for (const err of stats.errors) {
257
+ this.logger.warn(`[CloudSyncWatcher] upload ${err.path}: ${err.message}`);
258
+ }
259
+ }
260
+ }
261
+
262
+ /**
263
+ * Git-mode flush: route gitignored `.shogo/` writes through the file
264
+ * transport, then `git add -A && git commit && git push` everything
265
+ * else in one batch. This keeps the desktop checkpoint timeline in
266
+ * sync with what the worker's runtime is producing without having
267
+ * to push individual file PUTs per edit.
268
+ */
269
+ private async flushGit(batch: string[]): Promise<void> {
270
+ if (!this.git) throw new Error('CloudSyncWatcher: missing git options');
271
+ const fileTransportBatch: string[] = [];
272
+ const gitBatch: string[] = [];
273
+ for (const p of batch) {
274
+ if (ALWAYS_FILE_TRANSPORT_PREFIXES.some((prefix) => p === prefix.slice(0, -1) || p.startsWith(prefix))) {
275
+ fileTransportBatch.push(p);
276
+ } else {
277
+ gitBatch.push(p);
278
+ }
279
+ }
280
+
281
+ // Push .shogo/ first so the runtime's DB snapshot lands before
282
+ // any commit references that point at row IDs in it.
283
+ if (fileTransportBatch.length > 0) {
284
+ const stats = await this.transport.uploadFiles(fileTransportBatch);
285
+ if (stats.errors.length > 0) {
286
+ for (const err of stats.errors) {
287
+ this.logger.warn(`[CloudSyncWatcher] upload ${err.path}: ${err.message}`);
288
+ }
289
+ }
290
+ }
291
+
292
+ let committed = false;
293
+ let commitSha: string | undefined;
294
+ if (gitBatch.length > 0) {
295
+ const message = `auto: ${new Date().toISOString()}`;
296
+ try {
297
+ const res = await this.commitAndPush({
298
+ apiUrl: this.git.apiUrl,
299
+ apiKey: this.git.apiKey,
300
+ projectId: this.git.projectId,
301
+ localDir: this.rootDir,
302
+ message,
303
+ branch: this.git.branch,
304
+ authorEmail: this.git.authorEmail,
305
+ authorName: this.git.authorName,
306
+ logger: this.logger,
307
+ });
308
+ committed = res.committed;
309
+ commitSha = res.commitSha;
310
+ } catch (err: any) {
311
+ // commit/push failures bubble up so the caller's retry logic
312
+ // (flush() catch) re-queues the batch.
313
+ throw err;
314
+ }
315
+ }
316
+
317
+ this.onFlush?.({ uploaded: batch, errors: 0, committed, commitSha });
318
+ }
319
+ }
320
+
321
+ // Re-export for tests
322
+ export { toPosixRel as _toPosixRel, isExcluded as _isExcluded };