@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,354 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Copyright (C) 2026 Shogo Technologies, Inc.
3
+ /**
4
+ * Git-based project cloner for the Shogo worker.
5
+ *
6
+ * Talks to the smart-HTTP backend mounted at
7
+ * `<cloudUrl>/api/projects/:projectId/git/*`
8
+ * (see `apps/api/src/routes/git-http.ts`). We let the local `git`
9
+ * binary do all the wire-protocol work and just provide auth via the
10
+ * `http.extraHeader` config so the API key never lands in argv as a
11
+ * URL secret.
12
+ *
13
+ * Why git instead of the file transport for clones:
14
+ * - Pack-based delta sync (much smaller than enumerated PUTs).
15
+ * - First-class history: every checkpoint is a real reachable commit.
16
+ * - Atomicity: `git fetch && git reset --hard` is transactional.
17
+ *
18
+ * Falls back to the file transport in `WorkerRuntimeManager` if `git`
19
+ * isn't available — that's the only reason this module exposes the
20
+ * `gitIsAvailable()` probe.
21
+ */
22
+
23
+ import { spawn, execFile } from 'node:child_process';
24
+ import { existsSync } from 'node:fs';
25
+ import { join } from 'node:path';
26
+ import { promisify } from 'node:util';
27
+
28
+ const execFileAsync = promisify(execFile);
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // Availability probe
32
+ // ---------------------------------------------------------------------------
33
+
34
+ let gitProbeCache: boolean | null = null;
35
+
36
+ /**
37
+ * One-shot probe for whether `git` is on PATH. Cached after first call.
38
+ *
39
+ * Pass `force: true` to bust the cache (used in tests).
40
+ */
41
+ export async function gitIsAvailable(force = false): Promise<boolean> {
42
+ if (gitProbeCache !== null && !force) return gitProbeCache;
43
+ try {
44
+ await execFileAsync('git', ['--version'], { timeout: 3000 });
45
+ gitProbeCache = true;
46
+ } catch {
47
+ gitProbeCache = false;
48
+ }
49
+ return gitProbeCache;
50
+ }
51
+
52
+ // ---------------------------------------------------------------------------
53
+ // URL builder
54
+ // ---------------------------------------------------------------------------
55
+
56
+ /**
57
+ * Build the smart-HTTP URL the worker should `git clone` from.
58
+ *
59
+ * Example: `https://api.shogo.ai/api/projects/p_abc/git`
60
+ *
61
+ * `git` appends `/info/refs` etc. on its own. We strip any trailing
62
+ * slash from the input cloud URL so we don't end up with `//api/...`.
63
+ */
64
+ export function buildGitUrl(cloudApiUrl: string, projectId: string): string {
65
+ const base = cloudApiUrl.replace(/\/+$/, '');
66
+ return `${base}/api/projects/${projectId}/git`;
67
+ }
68
+
69
+ // ---------------------------------------------------------------------------
70
+ // Spawn helper
71
+ // ---------------------------------------------------------------------------
72
+
73
+ export interface RunGitOptions {
74
+ /** Working directory for the command. */
75
+ cwd?: string;
76
+ /** Extra env vars (merged into process.env). */
77
+ env?: NodeJS.ProcessEnv;
78
+ /** Hard timeout in ms. Default 5 minutes. */
79
+ timeoutMs?: number;
80
+ /** Optional logger for stdout/stderr chunks (used by --watch UIs). */
81
+ logger?: Pick<Console, 'log' | 'warn' | 'error'>;
82
+ }
83
+
84
+ export interface RunGitResult {
85
+ stdout: string;
86
+ stderr: string;
87
+ exitCode: number;
88
+ }
89
+
90
+ /**
91
+ * Run `git <args>` and resolve with captured stdout/stderr/exitCode.
92
+ *
93
+ * We use `spawn` (not `execFile`) so we can apply the bearer-token
94
+ * `http.extraHeader` via `-c` args without it ever appearing in a
95
+ * shell-quoted command string, and so we never buffer arbitrarily
96
+ * large pack data (clones can produce tens of MB on stderr alone for
97
+ * progress lines).
98
+ *
99
+ * Rejects on non-zero exit with the stderr captured into the Error.
100
+ */
101
+ export function runGit(args: string[], opts: RunGitOptions = {}): Promise<RunGitResult> {
102
+ const { cwd, env, timeoutMs = 5 * 60 * 1000, logger } = opts;
103
+ return new Promise((resolve, reject) => {
104
+ const child = spawn('git', args, {
105
+ cwd,
106
+ env: { ...process.env, ...env },
107
+ stdio: ['ignore', 'pipe', 'pipe'],
108
+ });
109
+
110
+ let stdout = '';
111
+ let stderr = '';
112
+ const stdoutChunks: string[] = [];
113
+ const stderrChunks: string[] = [];
114
+
115
+ child.stdout.setEncoding('utf-8');
116
+ child.stderr.setEncoding('utf-8');
117
+ child.stdout.on('data', (chunk: string) => {
118
+ stdoutChunks.push(chunk);
119
+ if (logger) logger.log(chunk.trimEnd());
120
+ });
121
+ child.stderr.on('data', (chunk: string) => {
122
+ stderrChunks.push(chunk);
123
+ // git emits progress on stderr — keep verbose to that channel.
124
+ if (logger) logger.warn(chunk.trimEnd());
125
+ });
126
+
127
+ const timer = setTimeout(() => {
128
+ try { child.kill('SIGKILL'); } catch { /* ignore */ }
129
+ reject(new Error(`git ${args[0]} timed out after ${timeoutMs}ms`));
130
+ }, timeoutMs);
131
+
132
+ child.on('error', (err) => {
133
+ clearTimeout(timer);
134
+ reject(err);
135
+ });
136
+
137
+ child.on('close', (code) => {
138
+ clearTimeout(timer);
139
+ stdout = stdoutChunks.join('');
140
+ stderr = stderrChunks.join('');
141
+ const exitCode = code ?? -1;
142
+ if (exitCode === 0) {
143
+ resolve({ stdout, stderr, exitCode });
144
+ } else {
145
+ const err = new Error(`git ${args[0]} exited with code ${exitCode}: ${stderr.slice(0, 500)}`) as Error & {
146
+ exitCode: number;
147
+ stdout: string;
148
+ stderr: string;
149
+ };
150
+ err.exitCode = exitCode;
151
+ err.stdout = stdout;
152
+ err.stderr = stderr;
153
+ reject(err);
154
+ }
155
+ });
156
+ });
157
+ }
158
+
159
+ // ---------------------------------------------------------------------------
160
+ // Public API
161
+ // ---------------------------------------------------------------------------
162
+
163
+ export interface CloneProjectOptions {
164
+ /** Base cloud API URL, e.g. `https://api.shogo.ai`. */
165
+ apiUrl: string;
166
+ /** Bearer API key (`shogo_sk_*`). Never appears in the URL or argv. */
167
+ apiKey: string;
168
+ /** Project to clone. */
169
+ projectId: string;
170
+ /** Destination directory. Must not already contain a git repo. */
171
+ localDir: string;
172
+ /**
173
+ * Whether to clone with `--depth=1`. Defaults to `true` — most
174
+ * worker usage doesn't need full history, and shallow clones are
175
+ * ~10x smaller.
176
+ */
177
+ shallow?: boolean;
178
+ /** Optional logger for git stdout/stderr (progress lines). */
179
+ logger?: Pick<Console, 'log' | 'warn' | 'error'>;
180
+ /** Per-call timeout. Default 5 minutes. */
181
+ timeoutMs?: number;
182
+ }
183
+
184
+ export interface CloneProjectResult {
185
+ /** SHA of the new HEAD commit. */
186
+ commitSha: string;
187
+ }
188
+
189
+ /**
190
+ * Clone (or refuse-to-clone if already cloned) the given project into
191
+ * `localDir`.
192
+ *
193
+ * If `localDir/.git` already exists, this throws — callers must
194
+ * `gitFetchAndReset` instead. This guard prevents accidental overwrites
195
+ * of in-progress worker state.
196
+ */
197
+ export async function cloneProject(opts: CloneProjectOptions): Promise<CloneProjectResult> {
198
+ const { apiUrl, apiKey, projectId, localDir, shallow = true, logger, timeoutMs } = opts;
199
+
200
+ if (existsSync(join(localDir, '.git'))) {
201
+ throw new Error(`cloneProject: ${localDir}/.git already exists; use gitFetchAndReset instead`);
202
+ }
203
+
204
+ const url = buildGitUrl(apiUrl, projectId);
205
+ const args: string[] = [
206
+ '-c', `http.extraHeader=Authorization: Bearer ${apiKey}`,
207
+ 'clone',
208
+ ];
209
+ if (shallow) args.push('--depth=1');
210
+ args.push(url, localDir);
211
+
212
+ await runGit(args, { logger, timeoutMs });
213
+
214
+ // Read the resulting HEAD sha so callers can record it (auto-pull
215
+ // path stamps this onto the project record for diagnostic UI).
216
+ const head = await runGit(['rev-parse', 'HEAD'], { cwd: localDir, logger, timeoutMs: 10_000 });
217
+ return { commitSha: head.stdout.trim() };
218
+ }
219
+
220
+ export interface GitFetchOptions {
221
+ apiUrl: string;
222
+ apiKey: string;
223
+ projectId: string;
224
+ localDir: string;
225
+ /** Branch to fetch. Defaults to the default branch ("HEAD"). */
226
+ branch?: string;
227
+ logger?: Pick<Console, 'log' | 'warn' | 'error'>;
228
+ timeoutMs?: number;
229
+ }
230
+
231
+ /**
232
+ * Fetch the latest refs from the cloud and hard-reset the working
233
+ * tree to match the remote tip.
234
+ *
235
+ * Used by the auto-pull recheck loop and by `shogo project checkout`
236
+ * for refs that fall outside the shallow window.
237
+ */
238
+ export async function gitFetchAndReset(opts: GitFetchOptions): Promise<{ commitSha: string }> {
239
+ const { apiUrl, apiKey, projectId, localDir, branch = 'HEAD', logger, timeoutMs } = opts;
240
+ const url = buildGitUrl(apiUrl, projectId);
241
+
242
+ // We re-supply the bearer header on every invocation — even though
243
+ // we set it once at clone time, `git config --local` is persistent
244
+ // and would leave the key on disk. The `-c` form keeps it ephemeral.
245
+ const cfg = ['-c', `http.extraHeader=Authorization: Bearer ${apiKey}`];
246
+
247
+ await runGit([...cfg, 'fetch', url, branch], { cwd: localDir, logger, timeoutMs });
248
+ await runGit(['reset', '--hard', 'FETCH_HEAD'], { cwd: localDir, logger, timeoutMs });
249
+
250
+ const head = await runGit(['rev-parse', 'HEAD'], { cwd: localDir, logger, timeoutMs: 10_000 });
251
+ return { commitSha: head.stdout.trim() };
252
+ }
253
+
254
+ export interface GitUnshallowOptions {
255
+ apiUrl: string;
256
+ apiKey: string;
257
+ projectId: string;
258
+ localDir: string;
259
+ logger?: Pick<Console, 'log' | 'warn' | 'error'>;
260
+ timeoutMs?: number;
261
+ }
262
+
263
+ /**
264
+ * Convert a shallow clone into a full clone so callers can `git
265
+ * checkout <old-sha>` against any historical checkpoint.
266
+ *
267
+ * Cheap to call repeatedly — git short-circuits if the repo is
268
+ * already complete.
269
+ */
270
+ export async function gitFetchUnshallow(opts: GitUnshallowOptions): Promise<void> {
271
+ const { apiUrl, apiKey, projectId, localDir, logger, timeoutMs } = opts;
272
+ const url = buildGitUrl(apiUrl, projectId);
273
+ const cfg = ['-c', `http.extraHeader=Authorization: Bearer ${apiKey}`];
274
+ // If the repo isn't shallow, git errors out — make it a no-op.
275
+ if (existsSync(join(localDir, '.git', 'shallow'))) {
276
+ await runGit([...cfg, 'fetch', '--unshallow', url], { cwd: localDir, logger, timeoutMs });
277
+ }
278
+ }
279
+
280
+ export interface CommitAndPushOptions {
281
+ apiUrl: string;
282
+ apiKey: string;
283
+ projectId: string;
284
+ localDir: string;
285
+ /** Commit message. */
286
+ message: string;
287
+ /** Branch to push to. Defaults to `HEAD` (current branch). */
288
+ branch?: string;
289
+ /** Author email used for commit metadata. Falls back to GIT_AUTHOR_EMAIL. */
290
+ authorEmail?: string;
291
+ /** Author name. Falls back to GIT_AUTHOR_NAME. */
292
+ authorName?: string;
293
+ logger?: Pick<Console, 'log' | 'warn' | 'error'>;
294
+ timeoutMs?: number;
295
+ }
296
+
297
+ export interface CommitAndPushResult {
298
+ /** True when a commit was actually created (false if nothing was staged). */
299
+ committed: boolean;
300
+ /** SHA of the new commit, when one was made. */
301
+ commitSha?: string;
302
+ }
303
+
304
+ /**
305
+ * `git add -A && git commit && git push` for the watcher's commit-mode
306
+ * flush. Returns `committed: false` when there were no changes, so the
307
+ * watcher doesn't claim a push for an empty edit batch.
308
+ */
309
+ export async function commitAndPush(opts: CommitAndPushOptions): Promise<CommitAndPushResult> {
310
+ const { apiUrl, apiKey, projectId, localDir, message, branch = 'HEAD', authorEmail, authorName, logger, timeoutMs } = opts;
311
+
312
+ const url = buildGitUrl(apiUrl, projectId);
313
+ const env: NodeJS.ProcessEnv = {};
314
+ if (authorEmail) {
315
+ env.GIT_AUTHOR_EMAIL = authorEmail;
316
+ env.GIT_COMMITTER_EMAIL = authorEmail;
317
+ }
318
+ if (authorName) {
319
+ env.GIT_AUTHOR_NAME = authorName;
320
+ env.GIT_COMMITTER_NAME = authorName;
321
+ }
322
+
323
+ await runGit(['add', '-A'], { cwd: localDir, env, logger, timeoutMs });
324
+
325
+ // `git diff --cached --quiet` exits non-zero when there's something to commit.
326
+ let hasChanges = false;
327
+ try {
328
+ await runGit(['diff', '--cached', '--quiet'], { cwd: localDir, env, logger, timeoutMs });
329
+ } catch {
330
+ hasChanges = true;
331
+ }
332
+ if (!hasChanges) return { committed: false };
333
+
334
+ await runGit(
335
+ ['commit', '-m', message, '--no-verify'],
336
+ { cwd: localDir, env, logger, timeoutMs },
337
+ );
338
+
339
+ const head = await runGit(['rev-parse', 'HEAD'], { cwd: localDir, env, logger, timeoutMs: 10_000 });
340
+ const commitSha = head.stdout.trim();
341
+
342
+ const cfg = ['-c', `http.extraHeader=Authorization: Bearer ${apiKey}`];
343
+ await runGit([...cfg, 'push', url, branch], { cwd: localDir, env, logger, timeoutMs });
344
+
345
+ return { committed: true, commitSha };
346
+ }
347
+
348
+ /**
349
+ * Check whether a directory looks like a git working tree we own.
350
+ * Used by the auto-pull path to decide between clone vs. fetch.
351
+ */
352
+ export function isGitRepo(localDir: string): boolean {
353
+ return existsSync(join(localDir, '.git'));
354
+ }
@@ -0,0 +1,53 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Copyright (C) 2026 Shogo Technologies, Inc.
3
+ /**
4
+ * Path helpers for the Shogo Worker CLI.
5
+ * Config, PID, and logs live under ~/.shogo/ on all platforms.
6
+ */
7
+ import { homedir } from 'node:os';
8
+ import { join } from 'node:path';
9
+ import { mkdirSync } from 'node:fs';
10
+
11
+ export const HOME_DIR = join(homedir(), '.shogo');
12
+ export const CONFIG_FILE = join(HOME_DIR, 'config.json');
13
+ export const CREDENTIALS_FILE = join(HOME_DIR, 'credentials.json');
14
+ /** Stable per-machine UUID. Generated on first `shogo login`, persisted
15
+ * verbatim, sent up as `deviceId` so cloud dedupes across re-logins. */
16
+ export const DEVICE_ID_FILE = join(HOME_DIR, 'device-id');
17
+ export const PID_FILE = join(HOME_DIR, 'worker.pid');
18
+ export const LOGS_DIR = join(HOME_DIR, 'logs');
19
+ export const WORKER_LOG = join(LOGS_DIR, 'worker.log');
20
+ export const WORKER_ERR = join(LOGS_DIR, 'worker.err.log');
21
+
22
+ /** Default install location for the AGPL agent-runtime binary. */
23
+ export const RUNTIME_DIR = join(HOME_DIR, 'runtime');
24
+ export const RUNTIME_BIN = join(RUNTIME_DIR, process.platform === 'win32' ? 'agent-runtime.exe' : 'agent-runtime');
25
+ export const RUNTIME_VERSION_FILE = join(RUNTIME_DIR, 'version.json');
26
+
27
+ /**
28
+ * Default root for cloned project workspaces. The worker stores
29
+ * each pulled project under `<PROJECTS_DIR>/<projectId>/`.
30
+ *
31
+ * Persistent (NOT in tmpdir) so a pinned project doesn't have to
32
+ * re-pull from cloud after a reboot.
33
+ */
34
+ export const PROJECTS_DIR = join(HOME_DIR, 'projects');
35
+
36
+ export function projectDirFor(projectId: string, baseDir: string = PROJECTS_DIR): string {
37
+ return join(baseDir, projectId);
38
+ }
39
+
40
+ export function ensureHome(): void {
41
+ mkdirSync(HOME_DIR, { recursive: true, mode: 0o700 });
42
+ mkdirSync(LOGS_DIR, { recursive: true, mode: 0o700 });
43
+ }
44
+
45
+ export function ensureRuntimeDir(): void {
46
+ ensureHome();
47
+ mkdirSync(RUNTIME_DIR, { recursive: true, mode: 0o700 });
48
+ }
49
+
50
+ export function ensureProjectsDir(baseDir: string = PROJECTS_DIR): void {
51
+ ensureHome();
52
+ mkdirSync(baseDir, { recursive: true, mode: 0o700 });
53
+ }
@@ -0,0 +1,158 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Copyright (C) 2026 Shogo Technologies, Inc.
3
+ /**
4
+ * Preflight checks for `shogo worker start --debug`.
5
+ *
6
+ * Verifies: runtime version, worker directory, proxy (when set), all three
7
+ * allowlist hosts (control/tunnel/artifacts), and API-key validity. The
8
+ * allowlist probes match what `docs/my-machines-networking.md` tells
9
+ * security teams to open, so if preflight passes we know the firewall is
10
+ * correctly configured.
11
+ *
12
+ * The `criticality` field on each host controls the overall pass/fail:
13
+ * - fatal → must reach. Preflight fails.
14
+ * - graceful → should reach, but the worker will start anyway (we only
15
+ * warn, because tunnel-direct + artifacts are optional per
16
+ * the failure-modes table).
17
+ */
18
+ import pc from 'picocolors';
19
+ import { existsSync } from 'node:fs';
20
+ import { deriveAllowlist, probeProxy, type ProxyConfig, type AllowlistHost } from './transport.ts';
21
+
22
+ export interface Check {
23
+ name: string;
24
+ /** 'fatal' ⇒ overall preflight fails; 'graceful' ⇒ prints a warning only. */
25
+ criticality?: 'fatal' | 'graceful';
26
+ run(): Promise<{ ok: boolean; detail?: string }>;
27
+ }
28
+
29
+ const DEFAULT_PROBE_TIMEOUT_MS = 5000;
30
+
31
+ async function probeHealth(url: string, timeoutMs: number): Promise<{ ok: boolean; detail: string }> {
32
+ try {
33
+ const ctl = new AbortController();
34
+ const t = setTimeout(() => ctl.abort(), timeoutMs);
35
+ const r = await fetch(`${url.replace(/\/$/, '')}/health`, { signal: ctl.signal }).catch(() => null);
36
+ clearTimeout(t);
37
+ if (!r) return { ok: false, detail: 'no response (firewall? DNS?)' };
38
+ return { ok: true, detail: `HTTP ${r.status}` };
39
+ } catch (err: any) {
40
+ return { ok: false, detail: err?.message ?? 'unknown error' };
41
+ }
42
+ }
43
+
44
+ export const makeChecks = (opts: {
45
+ cloudUrl: string;
46
+ apiKey: string;
47
+ workerDir: string;
48
+ proxy?: ProxyConfig | null;
49
+ }): Check[] => {
50
+ const checks: Check[] = [
51
+ {
52
+ name: 'Runtime (node >= 20)',
53
+ criticality: 'fatal',
54
+ async run() {
55
+ const [major] = process.versions.node.split('.').map(Number);
56
+ return (major ?? 0) >= 20
57
+ ? { ok: true, detail: `node v${process.versions.node}` }
58
+ : { ok: false, detail: `node v${process.versions.node} — need >=20` };
59
+ },
60
+ },
61
+ {
62
+ name: 'Worker directory exists',
63
+ criticality: 'fatal',
64
+ async run() {
65
+ return existsSync(opts.workerDir)
66
+ ? { ok: true, detail: opts.workerDir }
67
+ : { ok: false, detail: `${opts.workerDir} does not exist` };
68
+ },
69
+ },
70
+ ];
71
+
72
+ if (opts.proxy) {
73
+ checks.push({
74
+ name: `Proxy reachable (${safeHost(opts.proxy.url)})`,
75
+ criticality: 'fatal',
76
+ async run() {
77
+ return probeProxy(opts.proxy!);
78
+ },
79
+ });
80
+ }
81
+
82
+ const allowlist = deriveAllowlist(opts.cloudUrl);
83
+ for (const entry of allowlist) {
84
+ checks.push({
85
+ name: `Reach ${entry.host}${entry.purpose !== 'control' ? pc.dim(` (${entry.purpose})`) : ''}`,
86
+ criticality: entry.criticality,
87
+ run: () => probeHealth(entry.url, DEFAULT_PROBE_TIMEOUT_MS),
88
+ });
89
+ }
90
+
91
+ checks.push({
92
+ name: 'API key valid',
93
+ criticality: 'fatal',
94
+ async run() {
95
+ try {
96
+ const r = await fetch(`${opts.cloudUrl}/api/instances/heartbeat`, {
97
+ method: 'POST',
98
+ headers: { 'content-type': 'application/json', 'x-api-key': opts.apiKey },
99
+ body: JSON.stringify({
100
+ hostname: 'preflight',
101
+ name: 'preflight',
102
+ os: 'preflight',
103
+ arch: 'preflight',
104
+ metadata: { preflight: true },
105
+ }),
106
+ });
107
+ if (r.status === 401 || r.status === 403) {
108
+ return { ok: false, detail: `HTTP ${r.status} — key rejected` };
109
+ }
110
+ return r.ok
111
+ ? { ok: true, detail: `HTTP ${r.status}` }
112
+ : { ok: false, detail: `HTTP ${r.status}` };
113
+ } catch (err: any) {
114
+ return { ok: false, detail: err?.message ?? 'unknown error' };
115
+ }
116
+ },
117
+ });
118
+
119
+ return checks;
120
+ };
121
+
122
+ function safeHost(raw: string): string {
123
+ try { return new URL(raw).host; } catch { return raw; }
124
+ }
125
+
126
+ export async function runPreflight(checks: Check[]): Promise<boolean> {
127
+ console.log(pc.bold('\nShogo Worker — Preflight\n'));
128
+ let fatalFailed = false;
129
+ let gracefulFailed = 0;
130
+ for (const c of checks) {
131
+ process.stdout.write(` ${pc.dim('...')} ${c.name}`);
132
+ const result = await c.run();
133
+ process.stdout.write('\r');
134
+ if (result.ok) {
135
+ console.log(
136
+ ` ${pc.green('✓')} ${c.name}${result.detail ? pc.dim(` — ${result.detail}`) : ''}`,
137
+ );
138
+ } else if (c.criticality === 'graceful') {
139
+ console.log(
140
+ ` ${pc.yellow('◦')} ${c.name}${result.detail ? pc.dim(` — ${result.detail}`) : ''}${pc.yellow(' (optional — worker will still start)')}`,
141
+ );
142
+ gracefulFailed++;
143
+ } else {
144
+ console.log(
145
+ ` ${pc.red('✗')} ${c.name}${result.detail ? pc.dim(` — ${result.detail}`) : ''}`,
146
+ );
147
+ fatalFailed = true;
148
+ }
149
+ }
150
+ if (!fatalFailed && gracefulFailed === 0) {
151
+ console.log(pc.green('\nAll checks passed.\n'));
152
+ } else if (!fatalFailed) {
153
+ console.log(pc.yellow(`\nStarting with ${gracefulFailed} optional host(s) blocked. See docs/my-machines-networking.md.\n`));
154
+ } else {
155
+ console.log(pc.red('\nPreflight failed — fix blocking issues before starting.\n'));
156
+ }
157
+ return !fatalFailed;
158
+ }
@@ -0,0 +1,123 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Copyright (C) 2026 Shogo Technologies, Inc.
3
+ /**
4
+ * Manages the lifecycle of the spawned apps/api worker process.
5
+ *
6
+ * Single-instance enforcement via a PID file at ~/.shogo/worker.pid:
7
+ * one worker per machine is intentional — mirrors Cursor's `agent worker`
8
+ * behaviour so the cloud-side instance identity stays 1:1 with a machine
9
+ * rather than multiplexing across forks.
10
+ *
11
+ * Signal hygiene: callers that hold the CLI process in the foreground
12
+ * (`shogo worker start --foreground`) should invoke `installShutdownHooks()`
13
+ * so Ctrl-C / SIGTERM tears down the child and clears the PID file.
14
+ * The detached codepath doesn't need it — once `child.unref()` runs the
15
+ * CLI exits and the child owns its own PID file.
16
+ */
17
+ import { spawn, type ChildProcess } from 'node:child_process';
18
+ import { existsSync, openSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
19
+ import { PID_FILE, WORKER_LOG, WORKER_ERR, ensureHome } from './paths.ts';
20
+
21
+ export interface SpawnOpts {
22
+ entry: string;
23
+ runner: 'bun' | 'node' | 'tsx';
24
+ env: NodeJS.ProcessEnv;
25
+ cwd: string;
26
+ detach?: boolean;
27
+ inheritStdio?: boolean;
28
+ }
29
+
30
+ export function readPid(): number | null {
31
+ if (!existsSync(PID_FILE)) return null;
32
+ const raw = readFileSync(PID_FILE, 'utf-8').trim();
33
+ const n = parseInt(raw, 10);
34
+ return Number.isFinite(n) ? n : null;
35
+ }
36
+
37
+ export function isRunning(pid: number): boolean {
38
+ try {
39
+ process.kill(pid, 0);
40
+ return true;
41
+ } catch {
42
+ return false;
43
+ }
44
+ }
45
+
46
+ export function clearPid(): void {
47
+ try { unlinkSync(PID_FILE); } catch {}
48
+ }
49
+
50
+ export function spawnWorker(opts: SpawnOpts): { pid: number; child: ChildProcess } {
51
+ ensureHome();
52
+
53
+ const existingPid = readPid();
54
+ if (existingPid && isRunning(existingPid)) {
55
+ throw new Error(`Worker already running (pid=${existingPid}). Run \`shogo worker stop\` first.`);
56
+ }
57
+ if (existingPid) clearPid();
58
+
59
+ const stdio: ("ignore" | "inherit" | number)[] = opts.inheritStdio
60
+ ? ['ignore', 'inherit', 'inherit']
61
+ : ['ignore', openSync(WORKER_LOG, 'a'), openSync(WORKER_ERR, 'a')];
62
+
63
+ const child = spawn(opts.runner, [opts.entry], {
64
+ cwd: opts.cwd,
65
+ env: opts.env,
66
+ detached: !!opts.detach,
67
+ stdio: stdio as any,
68
+ });
69
+
70
+ if (!child.pid) throw new Error('Failed to spawn worker process.');
71
+ writeFileSync(PID_FILE, String(child.pid), { mode: 0o600 });
72
+
73
+ if (opts.detach) child.unref();
74
+ return { pid: child.pid, child };
75
+ }
76
+
77
+ /**
78
+ * Install SIGINT / SIGTERM / exit handlers that forward the signal to the
79
+ * foreground child and clear the PID file. Idempotent — safe to call twice.
80
+ *
81
+ * Only meaningful for foreground runs; detached workers own their own
82
+ * lifecycle after `child.unref()`.
83
+ */
84
+ export function installShutdownHooks(child: ChildProcess): void {
85
+ let shutdownStarted = false;
86
+ const shutdown = (signal: NodeJS.Signals) => {
87
+ if (shutdownStarted) return;
88
+ shutdownStarted = true;
89
+ try { child.kill(signal); } catch { /* already gone */ }
90
+ clearPid();
91
+ };
92
+
93
+ process.once('SIGINT', () => shutdown('SIGINT'));
94
+ process.once('SIGTERM', () => shutdown('SIGTERM'));
95
+ process.once('SIGHUP', () => shutdown('SIGHUP'));
96
+ process.once('exit', () => {
97
+ if (!shutdownStarted) clearPid();
98
+ });
99
+
100
+ // Propagate child's exit to this process for foreground runs.
101
+ child.on('exit', (code, signal) => {
102
+ clearPid();
103
+ if (signal) process.exit(128 + (signalToInt(signal) ?? 0));
104
+ process.exit(code ?? 0);
105
+ });
106
+ }
107
+
108
+ function signalToInt(signal: NodeJS.Signals): number | undefined {
109
+ const map: Record<string, number> = { SIGHUP: 1, SIGINT: 2, SIGQUIT: 3, SIGTERM: 15 };
110
+ return map[signal];
111
+ }
112
+
113
+ export function stopWorker(signal: NodeJS.Signals = 'SIGTERM'): { killedPid: number | null } {
114
+ const pid = readPid();
115
+ if (!pid) return { killedPid: null };
116
+ if (!isRunning(pid)) {
117
+ clearPid();
118
+ return { killedPid: null };
119
+ }
120
+ try { process.kill(pid, signal); } catch {}
121
+ clearPid();
122
+ return { killedPid: pid };
123
+ }