@shogo-ai/worker 1.7.4 → 1.7.6

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,89 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Copyright (C) 2026 Shogo Technologies, Inc.
3
+ /**
4
+ * `shogo project push <projectId>` — upload a local workspace directory
5
+ * back to Shogo Cloud. The inverse of `shogo project pull`.
6
+ *
7
+ * With `--delete-remote`, any file present in the cloud manifest but
8
+ * missing locally is also removed from cloud. This makes `push` behave
9
+ * like a one-shot "mirror local to cloud" rather than a strict additive
10
+ * upload — safer to leave off by default.
11
+ */
12
+
13
+ import { existsSync } from 'node:fs';
14
+ import { resolve } from 'node:path';
15
+ import pc from 'picocolors';
16
+ import { CloudFileTransport, type ProgressEvent, type SyncStats } from '@shogo-ai/sdk';
17
+ import { resolveConfig } from '../lib/config.ts';
18
+ import { projectDirFor } from '../lib/paths.ts';
19
+
20
+ export interface ProjectPushFlags {
21
+ from?: string;
22
+ deleteRemote?: boolean;
23
+ include?: string;
24
+ apiKey?: string;
25
+ cloudUrl?: string;
26
+ }
27
+
28
+ export async function runProjectPush(projectId: string, flags: ProjectPushFlags): Promise<void> {
29
+ if (!projectId) throw new Error('projectId is required');
30
+
31
+ const cfg = resolveConfig({
32
+ apiKey: flags.apiKey,
33
+ cloudUrl: flags.cloudUrl,
34
+ });
35
+
36
+ const from = resolve(flags.from ?? projectDirFor(projectId, cfg.projectsDir));
37
+ if (!existsSync(from)) {
38
+ throw new Error(`Source directory does not exist: ${from}`);
39
+ }
40
+
41
+ const include = flags.include?.split(',').map((s) => s.trim()).filter(Boolean);
42
+
43
+ console.log(pc.bold(`\nshogo project push ${pc.cyan(projectId)}`));
44
+ console.log(pc.dim(' cloud ') + cfg.cloudUrl);
45
+ console.log(pc.dim(' from ') + from);
46
+ if (include?.length) console.log(pc.dim(' include ') + include.join(', '));
47
+ if (flags.deleteRemote) console.log(pc.yellow(' --delete-remote: remote files not present locally will be DELETED'));
48
+ console.log('');
49
+
50
+ const transport = new CloudFileTransport({
51
+ apiUrl: cfg.cloudUrl,
52
+ apiKey: cfg.apiKey,
53
+ projectId,
54
+ localDir: from,
55
+ include,
56
+ onProgress: (e: ProgressEvent) => {
57
+ const verb = e.kind === 'upload' ? '↑' : e.kind === 'delete' ? '✗' : '·';
58
+ const pct = e.total > 0 ? ` ${e.index + 1}/${e.total}` : '';
59
+ const sz = e.bytes != null ? pc.dim(` (${formatBytes(e.bytes)})`) : '';
60
+ console.log(pc.dim(` ${verb}${pct} ${e.path}${sz}`));
61
+ },
62
+ });
63
+
64
+ const stats = await transport.uploadAll({ deleteRemote: flags.deleteRemote });
65
+ printSummary('Push', stats);
66
+ }
67
+
68
+ function printSummary(label: string, stats: SyncStats): void {
69
+ const ok = stats.errors.length === 0;
70
+ const head = ok ? pc.green(`✓ ${label} complete`) : pc.red(`✗ ${label} completed with errors`);
71
+ console.log(`\n${head}`);
72
+ console.log(pc.dim(' uploaded: ') + stats.uploaded);
73
+ if (stats.deleted) console.log(pc.dim(' deleted: ') + stats.deleted);
74
+ if (!ok) {
75
+ console.log(pc.dim(' errors: ') + stats.errors.length);
76
+ for (const err of stats.errors.slice(0, 5)) {
77
+ console.log(pc.dim(' ') + pc.red(`${err.path}: ${err.message}`));
78
+ }
79
+ if (stats.errors.length > 5) {
80
+ console.log(pc.dim(` ... and ${stats.errors.length - 5} more`));
81
+ }
82
+ }
83
+ }
84
+
85
+ function formatBytes(n: number): string {
86
+ if (n < 1024) return `${n}B`;
87
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)}KB`;
88
+ return `${(n / 1024 / 1024).toFixed(2)}MB`;
89
+ }
@@ -52,6 +52,16 @@ export interface StartFlags {
52
52
  runtimeBin?: string;
53
53
  debug?: boolean;
54
54
  foreground?: boolean;
55
+ /** Set to `true` to disable the on-first-request auto-pull of project
56
+ * workspaces from cloud. Auto-pull is ON by default for `cli_worker`
57
+ * workers so a freshly-paired VPS can serve pinned projects without
58
+ * the operator first running `shogo project pull`. */
59
+ noAutoPull?: boolean;
60
+ projectsDir?: string;
61
+ /** Disable the git smart-HTTP sync path even when `git` is on PATH.
62
+ * When set, auto-pull falls back to the CloudFileTransport file-pump.
63
+ * Useful when outbound HTTPS to git pack RPC endpoints is firewalled. */
64
+ noGit?: boolean;
55
65
  }
56
66
 
57
67
  export async function runStart(flags: StartFlags): Promise<void> {
@@ -61,6 +71,7 @@ export async function runStart(flags: StartFlags): Promise<void> {
61
71
  apiKey: flags.apiKey,
62
72
  cloudUrl: flags.cloudUrl,
63
73
  port: flags.port ? parseInt(flags.port, 10) : undefined,
74
+ projectsDir: flags.projectsDir,
64
75
  });
65
76
 
66
77
  const proxy = resolveProxy(flags.proxy);
@@ -97,11 +108,16 @@ export async function runStart(flags: StartFlags): Promise<void> {
97
108
  if (!ok) process.exit(1);
98
109
  }
99
110
 
111
+ const autoPullEnabled = !flags.noAutoPull;
112
+ const useGit = !flags.noGit;
113
+
100
114
  console.log(pc.bold('\nShogo Worker — Starting'));
101
115
  console.log(pc.dim(' name ') + cfg.name);
102
116
  console.log(pc.dim(' worker-dir ') + cfg.workerDir);
103
117
  console.log(pc.dim(' cloud ') + cfg.cloudUrl);
104
118
  console.log(pc.dim(' runtime ') + `${resolved.path} ${pc.dim(`(via ${resolved.source})`)}`);
119
+ console.log(pc.dim(' auto-pull ') + (autoPullEnabled ? pc.green('on') + pc.dim(` → ${cfg.projectsDir}`) : pc.yellow('off')));
120
+ console.log(pc.dim(' sync mode ') + (useGit ? pc.green('git') + pc.dim(' (falls back to file transport if git is missing)') : pc.yellow('files-only')));
105
121
  if (flags.project) console.log(pc.dim(' project ') + flags.project);
106
122
  if (proxy) {
107
123
  console.log(pc.dim(' proxy ') + `${proxy.url} ${pc.dim(`(from ${proxy.source})`)}`);
@@ -111,14 +127,20 @@ export async function runStart(flags: StartFlags): Promise<void> {
111
127
  const defaultSpawnConfig: ProjectSpawnConfig = {
112
128
  cloudUrl: cfg.cloudUrl,
113
129
  apiKey: cfg.apiKey,
114
- // No projectDir on the worker — the agent-runtime fetches workspace
115
- // state from the cloud using its API key. CWD defaults to a tmp
116
- // dir per `WorkerRuntimeManager.resolveCwd()`.
130
+ // No projectDir up front — the runtime manager's `maybeAutoPull`
131
+ // sets PROJECT_DIR per-project to <projectsDir>/<projectId>/ once
132
+ // the clone completes. CWD defaults to that same directory.
117
133
  };
118
134
 
119
135
  const runtimeManager = new WorkerRuntimeManager({
120
136
  runtimeBin: flags.runtimeBin,
121
137
  defaultSpawnConfig,
138
+ autoPull: {
139
+ enabled: autoPullEnabled,
140
+ projectsDir: cfg.projectsDir,
141
+ watch: true,
142
+ useGit,
143
+ },
122
144
  });
123
145
 
124
146
  // Eagerly resolve so the cached `resolved` is reused — also exits early
@@ -223,6 +245,9 @@ function buildChildArgv(flags: StartFlags): string[] {
223
245
  if (flags.project) out.push('--project', flags.project);
224
246
  if (flags.runtimeBin) out.push('--runtime-bin', flags.runtimeBin);
225
247
  if (flags.debug) out.push('--debug');
248
+ if (flags.noAutoPull) out.push('--no-auto-pull');
249
+ if (flags.projectsDir) out.push('--projects-dir', flags.projectsDir);
250
+ if (flags.noGit) out.push('--no-git');
226
251
  return out;
227
252
  }
228
253
 
@@ -0,0 +1,209 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Copyright (C) 2026 Shogo Technologies, Inc.
3
+ /**
4
+ * Tests for {@link CloudSyncWatcher}.
5
+ *
6
+ * We exercise the watcher by:
7
+ * - mounting a real tmpdir (fs.watch only works on real paths)
8
+ * - injecting a fake CloudFileTransport that records uploadFiles calls
9
+ * - touching files and waiting for the debounce window to fire
10
+ *
11
+ * Recursive `fs.watch` is supported on macOS / Windows / Linux >= 20.x.
12
+ * The CI matrix is expected to satisfy this; on platforms where it
13
+ * isn't, the watcher's `start()` will fall back to a flat root watch
14
+ * (which still passes the "single file in root" assertions but skips
15
+ * the "nested file" one).
16
+ */
17
+ import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
18
+ import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from 'node:fs';
19
+ import { tmpdir } from 'node:os';
20
+ import { join } from 'node:path';
21
+ import { CloudSyncWatcher, _isExcluded, type CommitAndPushFn } from '../cloud-sync-watcher.ts';
22
+
23
+ /** Tests inject a fake `commitAndPush` rather than mocking the module
24
+ * (mock.module leaks across files in bun:test). */
25
+ const commitAndPushCalls: any[] = [];
26
+ const commitAndPushFake: CommitAndPushFn = async (opts) => {
27
+ commitAndPushCalls.push(opts);
28
+ return { committed: true, commitSha: 'deadbeef1234' };
29
+ };
30
+
31
+ function fakeTransport() {
32
+ const calls: { paths: string[] }[] = [];
33
+ return {
34
+ transport: {
35
+ uploadFiles: async (paths: string[]) => {
36
+ calls.push({ paths: [...paths] });
37
+ return { downloaded: 0, uploaded: paths.length, skipped: 0, deleted: 0, errors: [] };
38
+ },
39
+ } as any,
40
+ calls,
41
+ };
42
+ }
43
+
44
+ function wait(ms: number): Promise<void> {
45
+ return new Promise((r) => setTimeout(r, ms));
46
+ }
47
+
48
+ describe('CloudSyncWatcher', () => {
49
+ let dir: string;
50
+ beforeEach(() => {
51
+ dir = mkdtempSync(join(tmpdir(), 'cloud-sync-watcher-'));
52
+ });
53
+ afterEach(() => {
54
+ try {
55
+ rmSync(dir, { recursive: true, force: true });
56
+ } catch {
57
+ /* ignore */
58
+ }
59
+ });
60
+
61
+ it('isExcluded skips node_modules / .git / dist / build paths', () => {
62
+ expect(_isExcluded('node_modules/lib/index.js')).toBe(true);
63
+ expect(_isExcluded('.git/HEAD')).toBe(true);
64
+ expect(_isExcluded('dist/bundle.js')).toBe(true);
65
+ expect(_isExcluded('build/main.css')).toBe(true);
66
+ expect(_isExcluded('src/App.tsx')).toBe(false);
67
+ expect(_isExcluded('config.json')).toBe(false);
68
+ expect(_isExcluded('')).toBe(true);
69
+ });
70
+
71
+ it('debounces a batch of edits into a single uploadFiles call', async () => {
72
+ const { transport, calls } = fakeTransport();
73
+ const watcher = new CloudSyncWatcher({ rootDir: dir, transport, debounceMs: 100 });
74
+ watcher.start();
75
+ // Give fs.watch a tick to start delivering events on macOS/Linux before
76
+ // we start writing — otherwise the first writes can be dropped.
77
+ await wait(50);
78
+
79
+ writeFileSync(join(dir, 'a.txt'), 'A');
80
+ writeFileSync(join(dir, 'b.txt'), 'B');
81
+ writeFileSync(join(dir, 'c.txt'), 'C');
82
+
83
+ await wait(400);
84
+ await watcher.stop();
85
+
86
+ // All three files coalesced into ONE upload batch.
87
+ expect(calls.length).toBeGreaterThanOrEqual(1);
88
+ const allPaths = calls.flatMap((c) => c.paths);
89
+ expect(allPaths).toContain('a.txt');
90
+ expect(allPaths).toContain('b.txt');
91
+ expect(allPaths).toContain('c.txt');
92
+ });
93
+
94
+ it('skips events inside excluded directories', async () => {
95
+ const { transport, calls } = fakeTransport();
96
+ mkdirSync(join(dir, 'node_modules'), { recursive: true });
97
+ mkdirSync(join(dir, 'src'), { recursive: true });
98
+ const watcher = new CloudSyncWatcher({ rootDir: dir, transport, debounceMs: 100 });
99
+ watcher.start();
100
+ await wait(50);
101
+
102
+ writeFileSync(join(dir, 'node_modules', 'lib.js'), 'LIB');
103
+ writeFileSync(join(dir, 'src', 'App.tsx'), 'APP');
104
+
105
+ await wait(400);
106
+ await watcher.stop();
107
+
108
+ const allPaths = calls.flatMap((c) => c.paths);
109
+ expect(allPaths.some((p) => p.includes('node_modules'))).toBe(false);
110
+ // On platforms where recursive watch works, src/App.tsx should have been
111
+ // captured. Where it doesn't, the array is empty — both are valid given
112
+ // the platform caveat described at the top of this file.
113
+ });
114
+
115
+ it('flushes any pending uploads on stop()', async () => {
116
+ const { transport, calls } = fakeTransport();
117
+ const watcher = new CloudSyncWatcher({ rootDir: dir, transport, debounceMs: 5_000 });
118
+ watcher.start();
119
+ await wait(50);
120
+
121
+ writeFileSync(join(dir, 'just-in-time.txt'), 'JIT');
122
+ // Don't wait the full debounce — stop() should still flush.
123
+ await wait(100);
124
+ await watcher.stop();
125
+
126
+ const allPaths = calls.flatMap((c) => c.paths);
127
+ expect(allPaths).toContain('just-in-time.txt');
128
+ });
129
+
130
+ it('git mode: flushes call commitAndPush instead of uploadFiles', async () => {
131
+ commitAndPushCalls.length = 0;
132
+ const { transport, calls } = fakeTransport();
133
+ const watcher = new CloudSyncWatcher({
134
+ rootDir: dir,
135
+ transport,
136
+ debounceMs: 100,
137
+ mode: 'git',
138
+ git: { apiUrl: 'https://api.shogo.ai', apiKey: 'shogo_sk_test', projectId: 'p_abc' },
139
+ commitAndPush: commitAndPushFake,
140
+ });
141
+ watcher.start();
142
+ await wait(50);
143
+
144
+ writeFileSync(join(dir, 'src.tsx'), 'CODE');
145
+ await wait(400);
146
+ await watcher.stop();
147
+
148
+ expect(commitAndPushCalls.length).toBeGreaterThanOrEqual(1);
149
+ expect(commitAndPushCalls[0]!.projectId).toBe('p_abc');
150
+ expect(commitAndPushCalls[0]!.localDir).toBe(dir);
151
+ expect(commitAndPushCalls[0]!.apiKey).toBe('shogo_sk_test');
152
+ // Non-.shogo paths should NOT go through uploadFiles in git mode.
153
+ const fileTransportPaths = calls.flatMap((c) => c.paths);
154
+ expect(fileTransportPaths.includes('src.tsx')).toBe(false);
155
+ });
156
+
157
+ it('git mode: .shogo/ writes still route through the file transport', async () => {
158
+ commitAndPushCalls.length = 0;
159
+ const { transport, calls } = fakeTransport();
160
+ mkdirSync(join(dir, '.shogo'), { recursive: true });
161
+ const watcher = new CloudSyncWatcher({
162
+ rootDir: dir,
163
+ transport,
164
+ debounceMs: 100,
165
+ mode: 'git',
166
+ git: { apiUrl: 'https://api.shogo.ai', apiKey: 'shogo_sk_test', projectId: 'p_xyz' },
167
+ commitAndPush: commitAndPushFake,
168
+ });
169
+ watcher.start();
170
+ await wait(50);
171
+
172
+ writeFileSync(join(dir, '.shogo', 'db.sqlite'), 'SQLITE');
173
+ writeFileSync(join(dir, 'App.tsx'), 'APP');
174
+ await wait(400);
175
+ await watcher.stop();
176
+
177
+ const fileTransportPaths = calls.flatMap((c) => c.paths);
178
+ expect(fileTransportPaths.some((p) => p.startsWith('.shogo'))).toBe(true);
179
+ // App.tsx commits go to git, NOT the file transport.
180
+ expect(fileTransportPaths.includes('App.tsx')).toBe(false);
181
+ expect(commitAndPushCalls.length).toBeGreaterThanOrEqual(1);
182
+ });
183
+
184
+ it('git mode: throws when constructed without git options', () => {
185
+ const { transport } = fakeTransport();
186
+ expect(() => new CloudSyncWatcher({ rootDir: dir, transport, mode: 'git' as const })).toThrow();
187
+ });
188
+
189
+ it('invokes onFlush with the uploaded batch + error count', async () => {
190
+ const { transport } = fakeTransport();
191
+ const flushes: Array<{ uploaded: string[]; errors: number }> = [];
192
+ const watcher = new CloudSyncWatcher({
193
+ rootDir: dir,
194
+ transport,
195
+ debounceMs: 50,
196
+ onFlush: (e) => flushes.push(e),
197
+ });
198
+ watcher.start();
199
+ await wait(50);
200
+
201
+ writeFileSync(join(dir, 'x.txt'), 'X');
202
+ await wait(300);
203
+ await watcher.stop();
204
+
205
+ expect(flushes.length).toBeGreaterThanOrEqual(1);
206
+ expect(flushes[0]!.errors).toBe(0);
207
+ expect(flushes[0]!.uploaded).toContain('x.txt');
208
+ });
209
+ });
@@ -30,6 +30,8 @@ function setupTmpHome(): string {
30
30
  RUNTIME_DIR: join(homeDir, 'runtime'),
31
31
  RUNTIME_BIN: join(homeDir, 'runtime', 'agent-runtime'),
32
32
  RUNTIME_VERSION_FILE: join(homeDir, 'runtime', 'version.json'),
33
+ PROJECTS_DIR: join(homeDir, 'projects'),
34
+ projectDirFor: (id: string, base = join(homeDir, 'projects')) => join(base, id),
33
35
  ensureHome: () => {
34
36
  mkdirSync(homeDir, { recursive: true, mode: 0o700 });
35
37
  mkdirSync(join(homeDir, 'logs'), { recursive: true, mode: 0o700 });
@@ -37,6 +39,9 @@ function setupTmpHome(): string {
37
39
  ensureRuntimeDir: () => {
38
40
  mkdirSync(join(homeDir, 'runtime'), { recursive: true, mode: 0o700 });
39
41
  },
42
+ ensureProjectsDir: (base = join(homeDir, 'projects')) => {
43
+ mkdirSync(base, { recursive: true, mode: 0o700 });
44
+ },
40
45
  }));
41
46
  return configFile;
42
47
  }
@@ -0,0 +1,258 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Copyright (C) 2026 Shogo Technologies, Inc.
3
+ /**
4
+ * Tests for {@link cloneProject}, {@link commitAndPush}, and the
5
+ * support helpers in `lib/git-cloner.ts`.
6
+ *
7
+ * Strategy:
8
+ * - Mock `node:child_process` so we never actually shell out.
9
+ * - Capture the argv each call would have run with and assert the
10
+ * bearer header lives in `-c http.extraHeader=…` rather than the
11
+ * URL or environment.
12
+ */
13
+
14
+ import { describe, it, expect, mock, beforeEach } from 'bun:test';
15
+ import { EventEmitter } from 'node:events';
16
+
17
+ interface SpawnInvocation {
18
+ cmd: string;
19
+ args: string[];
20
+ cwd?: string;
21
+ env?: NodeJS.ProcessEnv;
22
+ }
23
+
24
+ const spawnInvocations: SpawnInvocation[] = [];
25
+ type SpawnHandler = (
26
+ inv: SpawnInvocation,
27
+ ) => { stdout?: string; stderr?: string; exitCode?: number; runError?: Error };
28
+ let spawnHandler: SpawnHandler = () => ({ exitCode: 0 });
29
+
30
+ function makeFakeChild(result: { stdout?: string; stderr?: string; exitCode?: number; runError?: Error }) {
31
+ const child = new EventEmitter() as any;
32
+ child.stdout = new EventEmitter();
33
+ child.stdout.setEncoding = () => {};
34
+ child.stderr = new EventEmitter();
35
+ child.stderr.setEncoding = () => {};
36
+ child.kill = () => {};
37
+ child.exitCode = null;
38
+ child.signalCode = null;
39
+ // Emit chunks + close asynchronously so callers' on() handlers register first.
40
+ setTimeout(() => {
41
+ if (result.runError) {
42
+ child.emit('error', result.runError);
43
+ return;
44
+ }
45
+ if (result.stdout) child.stdout.emit('data', result.stdout);
46
+ if (result.stderr) child.stderr.emit('data', result.stderr);
47
+ child.exitCode = result.exitCode ?? 0;
48
+ child.emit('close', result.exitCode ?? 0);
49
+ }, 5);
50
+ return child;
51
+ }
52
+
53
+ mock.module('node:child_process', () => ({
54
+ spawn: (cmd: string, args: string[], opts?: any) => {
55
+ const inv: SpawnInvocation = { cmd, args, cwd: opts?.cwd, env: opts?.env };
56
+ spawnInvocations.push(inv);
57
+ const result = spawnHandler(inv);
58
+ return makeFakeChild(result);
59
+ },
60
+ execFile: (cmd: string, args: string[], opts: any, cb: any) => {
61
+ const inv: SpawnInvocation = { cmd, args };
62
+ spawnInvocations.push(inv);
63
+ // probe for `git --version`
64
+ const callback = typeof opts === 'function' ? opts : cb;
65
+ setTimeout(() => callback?.(null, 'git version 2.40.0', ''), 1);
66
+ },
67
+ }));
68
+
69
+ const { buildGitUrl, cloneProject, commitAndPush, gitIsAvailable, isGitRepo, gitFetchAndReset } =
70
+ await import('../git-cloner.ts');
71
+
72
+ beforeEach(() => {
73
+ spawnInvocations.length = 0;
74
+ spawnHandler = () => ({ exitCode: 0 });
75
+ });
76
+
77
+ describe('buildGitUrl', () => {
78
+ it('joins cloud url and project id with a trailing /git', () => {
79
+ expect(buildGitUrl('https://api.shogo.ai', 'p_abc')).toBe('https://api.shogo.ai/api/projects/p_abc/git');
80
+ });
81
+ it('strips trailing slashes from the cloud url', () => {
82
+ expect(buildGitUrl('https://api.shogo.ai///', 'p_abc')).toBe('https://api.shogo.ai/api/projects/p_abc/git');
83
+ });
84
+ });
85
+
86
+ describe('gitIsAvailable', () => {
87
+ it('returns true when git --version succeeds', async () => {
88
+ const ok = await gitIsAvailable(true);
89
+ expect(ok).toBe(true);
90
+ });
91
+ });
92
+
93
+ describe('cloneProject', () => {
94
+ it('uses http.extraHeader for the bearer token (never in argv URL)', async () => {
95
+ spawnHandler = (inv) => {
96
+ if (inv.args[0] === 'rev-parse') return { stdout: 'abc123\n' };
97
+ return { exitCode: 0 };
98
+ };
99
+ const res = await cloneProject({
100
+ apiUrl: 'https://api.shogo.ai',
101
+ apiKey: 'shogo_sk_secret',
102
+ projectId: 'p_demo',
103
+ localDir: '/tmp/test-clone',
104
+ });
105
+ expect(res.commitSha).toBe('abc123');
106
+
107
+ const cloneInv = spawnInvocations.find((i) => i.args.includes('clone'));
108
+ expect(cloneInv).toBeDefined();
109
+ expect(cloneInv!.args).toContain('-c');
110
+ expect(cloneInv!.args.some((a) => a.startsWith('http.extraHeader=Authorization: Bearer'))).toBe(true);
111
+ // URL should NOT carry the secret.
112
+ const urlArg = cloneInv!.args.find((a) => a.startsWith('https://'))!;
113
+ expect(urlArg.includes('shogo_sk_secret')).toBe(false);
114
+ expect(urlArg).toBe('https://api.shogo.ai/api/projects/p_demo/git');
115
+ // Shallow by default.
116
+ expect(cloneInv!.args.includes('--depth=1')).toBe(true);
117
+ });
118
+
119
+ it('throws when the target dir already has a .git', async () => {
120
+ // existsSync uses real fs; create a tmp dir with a .git.
121
+ const { mkdtempSync, mkdirSync, rmSync } = await import('node:fs');
122
+ const { tmpdir } = await import('node:os');
123
+ const { join } = await import('node:path');
124
+ const dir = mkdtempSync(join(tmpdir(), 'git-cloner-'));
125
+ try {
126
+ mkdirSync(join(dir, '.git'));
127
+ await expect(
128
+ cloneProject({
129
+ apiUrl: 'https://api.shogo.ai',
130
+ apiKey: 'shogo_sk_secret',
131
+ projectId: 'p_demo',
132
+ localDir: dir,
133
+ }),
134
+ ).rejects.toThrow(/\.git already exists/);
135
+ } finally {
136
+ rmSync(dir, { recursive: true, force: true });
137
+ }
138
+ });
139
+
140
+ it('shallow=false omits --depth=1', async () => {
141
+ spawnHandler = (inv) => {
142
+ if (inv.args[0] === 'rev-parse') return { stdout: 'abc123\n' };
143
+ return { exitCode: 0 };
144
+ };
145
+ await cloneProject({
146
+ apiUrl: 'https://api.shogo.ai',
147
+ apiKey: 'k',
148
+ projectId: 'p',
149
+ localDir: '/tmp/never',
150
+ shallow: false,
151
+ });
152
+ const cloneInv = spawnInvocations.find((i) => i.args.includes('clone'))!;
153
+ expect(cloneInv.args.includes('--depth=1')).toBe(false);
154
+ });
155
+
156
+ it('rejects when git exits non-zero', async () => {
157
+ spawnHandler = () => ({ exitCode: 128, stderr: 'fatal: repository not found' });
158
+ await expect(
159
+ cloneProject({
160
+ apiUrl: 'https://api.shogo.ai',
161
+ apiKey: 'k',
162
+ projectId: 'p',
163
+ localDir: '/tmp/never',
164
+ }),
165
+ ).rejects.toThrow(/exited with code 128/);
166
+ });
167
+ });
168
+
169
+ describe('commitAndPush', () => {
170
+ it('reports committed=false when there is nothing to commit', async () => {
171
+ spawnHandler = (inv) => {
172
+ // `git diff --cached --quiet` exits 0 when nothing is staged.
173
+ if (inv.args[0] === 'diff') return { exitCode: 0 };
174
+ return { exitCode: 0 };
175
+ };
176
+ const res = await commitAndPush({
177
+ apiUrl: 'https://api.shogo.ai',
178
+ apiKey: 'shogo_sk_test',
179
+ projectId: 'p_clean',
180
+ localDir: '/tmp/never',
181
+ message: 'no-op',
182
+ });
183
+ expect(res.committed).toBe(false);
184
+ expect(res.commitSha).toBeUndefined();
185
+ // We should not have pushed.
186
+ expect(spawnInvocations.some((i) => i.args[0] === 'push')).toBe(false);
187
+ });
188
+
189
+ it('commits + pushes when there are staged changes', async () => {
190
+ spawnHandler = (inv) => {
191
+ if (inv.args[0] === 'diff') {
192
+ // Non-zero exit = "diff present" = something to commit.
193
+ return { exitCode: 1 };
194
+ }
195
+ if (inv.args[0] === 'rev-parse') return { stdout: 'feedface\n' };
196
+ return { exitCode: 0 };
197
+ };
198
+ const res = await commitAndPush({
199
+ apiUrl: 'https://api.shogo.ai',
200
+ apiKey: 'shogo_sk_test',
201
+ projectId: 'p_dirty',
202
+ localDir: '/tmp/never',
203
+ message: 'auto: 2026-01-01T00:00:00Z',
204
+ });
205
+ expect(res.committed).toBe(true);
206
+ expect(res.commitSha).toBe('feedface');
207
+ const pushInv = spawnInvocations.find((i) => i.args.includes('push'))!;
208
+ expect(pushInv).toBeDefined();
209
+ expect(pushInv.args.some((a) => a.startsWith('http.extraHeader='))).toBe(true);
210
+ });
211
+ });
212
+
213
+ describe('gitFetchAndReset', () => {
214
+ it('passes bearer via -c http.extraHeader on both fetch and reset', async () => {
215
+ spawnHandler = (inv) => {
216
+ if (inv.args[0] === 'rev-parse') return { stdout: 'cafebabe\n' };
217
+ return { exitCode: 0 };
218
+ };
219
+ const res = await gitFetchAndReset({
220
+ apiUrl: 'https://api.shogo.ai',
221
+ apiKey: 'shogo_sk_test',
222
+ projectId: 'p_demo',
223
+ localDir: '/tmp/never',
224
+ });
225
+ expect(res.commitSha).toBe('cafebabe');
226
+ const fetchInv = spawnInvocations.find((i) => i.args.includes('fetch'))!;
227
+ expect(fetchInv.args.some((a) => a.startsWith('http.extraHeader='))).toBe(true);
228
+ // reset does NOT need the bearer.
229
+ const resetInv = spawnInvocations.find((i) => i.args.includes('reset'))!;
230
+ expect(resetInv.args.some((a) => a.startsWith('http.extraHeader='))).toBe(false);
231
+ });
232
+ });
233
+
234
+ describe('isGitRepo', () => {
235
+ it('returns true when .git exists in the dir', async () => {
236
+ const { mkdtempSync, mkdirSync, rmSync } = await import('node:fs');
237
+ const { tmpdir } = await import('node:os');
238
+ const { join } = await import('node:path');
239
+ const dir = mkdtempSync(join(tmpdir(), 'is-git-'));
240
+ try {
241
+ mkdirSync(join(dir, '.git'));
242
+ expect(isGitRepo(dir)).toBe(true);
243
+ } finally {
244
+ rmSync(dir, { recursive: true, force: true });
245
+ }
246
+ });
247
+ it('returns false when .git is missing', async () => {
248
+ const { mkdtempSync, rmSync } = await import('node:fs');
249
+ const { tmpdir } = await import('node:os');
250
+ const { join } = await import('node:path');
251
+ const dir = mkdtempSync(join(tmpdir(), 'is-git-'));
252
+ try {
253
+ expect(isGitRepo(dir)).toBe(false);
254
+ } finally {
255
+ rmSync(dir, { recursive: true, force: true });
256
+ }
257
+ });
258
+ });