@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.
@@ -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 CHANGED
@@ -24,6 +24,19 @@ export const RUNTIME_DIR = join(HOME_DIR, 'runtime');
24
24
  export const RUNTIME_BIN = join(RUNTIME_DIR, process.platform === 'win32' ? 'agent-runtime.exe' : 'agent-runtime');
25
25
  export const RUNTIME_VERSION_FILE = join(RUNTIME_DIR, 'version.json');
26
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
+
27
40
  export function ensureHome(): void {
28
41
  mkdirSync(HOME_DIR, { recursive: true, mode: 0o700 });
29
42
  mkdirSync(LOGS_DIR, { recursive: true, mode: 0o700 });
@@ -33,3 +46,8 @@ export function ensureRuntimeDir(): void {
33
46
  ensureHome();
34
47
  mkdirSync(RUNTIME_DIR, { recursive: true, mode: 0o700 });
35
48
  }
49
+
50
+ export function ensureProjectsDir(baseDir: string = PROJECTS_DIR): void {
51
+ ensureHome();
52
+ mkdirSync(baseDir, { recursive: true, mode: 0o700 });
53
+ }