@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.
- package/LICENSE +21 -0
- package/README.md +432 -0
- package/bin/shogo.mjs +38 -0
- package/dist/cli.mjs +64 -0
- package/package.json +62 -0
- package/src/cli.ts +199 -0
- package/src/commands/agent.ts +123 -0
- package/src/commands/config.ts +29 -0
- package/src/commands/doctor.ts +128 -0
- package/src/commands/login.ts +126 -0
- package/src/commands/logs.ts +24 -0
- 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/runtime.ts +104 -0
- package/src/commands/start.ts +290 -0
- package/src/commands/status.ts +22 -0
- package/src/commands/stop.ts +13 -0
- package/src/lib/__tests__/agent-launch.test.ts +92 -0
- package/src/lib/__tests__/cloud-login-coverage.test.ts +210 -0
- package/src/lib/__tests__/cloud-login.test.ts +218 -0
- package/src/lib/__tests__/cloud-sync-watcher.test.ts +214 -0
- package/src/lib/__tests__/config.test.ts +141 -0
- package/src/lib/__tests__/db-doctor.test.ts +204 -0
- package/src/lib/__tests__/git-cloner.test.ts +382 -0
- package/src/lib/__tests__/runtime-manager-auto-pull.test.ts +275 -0
- package/src/lib/__tests__/runtime-manager-cloud-sync-gate.test.ts +119 -0
- package/src/lib/__tests__/runtime-manager-coverage-gaps.test.ts +768 -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-idle-eviction.test.ts +196 -0
- package/src/lib/__tests__/runtime-manager-restart-cap.test.ts +310 -0
- package/src/lib/__tests__/runtime-manager-tree-sitter-env.test.ts +189 -0
- package/src/lib/__tests__/runtime-manager-wait-for-health.test.ts +269 -0
- package/src/lib/__tests__/runtime-resolver.test.ts +112 -0
- package/src/lib/__tests__/tunnel-coverage.test.ts +1094 -0
- package/src/lib/__tests__/tunnel-structured-502.test.ts +101 -0
- package/src/lib/__tests__/worker-commands-greenfield.test.ts +463 -0
- package/src/lib/__tests__/worker-libs-greenfield.test.ts +355 -0
- package/src/lib/__tests__/worker-transport-greenfield.test.ts +176 -0
- package/src/lib/api-discovery.ts +36 -0
- package/src/lib/cloud-login.ts +320 -0
- package/src/lib/cloud-sync-watcher.ts +322 -0
- package/src/lib/config.ts +68 -0
- package/src/lib/db-doctor.ts +355 -0
- package/src/lib/device-id.ts +27 -0
- package/src/lib/git-cloner.ts +354 -0
- package/src/lib/paths.ts +53 -0
- package/src/lib/preflight.ts +158 -0
- package/src/lib/process-manager.ts +123 -0
- package/src/lib/resource-limits.ts +340 -0
- package/src/lib/runtime-install.ts +371 -0
- package/src/lib/runtime-manager.ts +1873 -0
- package/src/lib/runtime-resolver.ts +136 -0
- package/src/lib/transport.ts +202 -0
- package/src/lib/tunnel.ts +710 -0
- 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
|
+
}
|
package/src/lib/paths.ts
ADDED
|
@@ -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
|
+
}
|