@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.
- package/README.md +218 -10
- package/package.json +1 -1
- package/src/cli.ts +36 -1
- 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/start.ts +28 -3
- package/src/lib/__tests__/cloud-sync-watcher.test.ts +209 -0
- package/src/lib/__tests__/config.test.ts +5 -0
- package/src/lib/__tests__/git-cloner.test.ts +258 -0
- package/src/lib/__tests__/runtime-manager-auto-pull.test.ts +275 -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-tree-sitter-env.test.ts +124 -0
- package/src/lib/__tests__/tunnel-structured-502.test.ts +101 -0
- package/src/lib/cloud-sync-watcher.ts +311 -0
- package/src/lib/config.ts +7 -2
- package/src/lib/git-cloner.ts +354 -0
- package/src/lib/paths.ts +18 -0
- package/src/lib/runtime-manager.ts +469 -8
- package/src/lib/tunnel.ts +40 -1
|
@@ -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
|
+
}
|
package/src/commands/start.ts
CHANGED
|
@@ -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
|
|
115
|
-
//
|
|
116
|
-
//
|
|
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
|
+
});
|