@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,214 @@
|
|
|
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
|
+
// App.tsx fires first so its watch event is queued before debounce starts;
|
|
173
|
+
// .shogo/db.sqlite arrives within the debounce window. The increased
|
|
174
|
+
// post-write wait absorbs cross-file timing variance (see
|
|
175
|
+
// gateway-mock side effect from git-cloner.test.ts's hoisted
|
|
176
|
+
// mock.module('node:child_process', ...) bloating bun's startup).
|
|
177
|
+
writeFileSync(join(dir, 'App.tsx'), 'APP');
|
|
178
|
+
writeFileSync(join(dir, '.shogo', 'db.sqlite'), 'SQLITE');
|
|
179
|
+
await wait(700);
|
|
180
|
+
await watcher.stop();
|
|
181
|
+
|
|
182
|
+
const fileTransportPaths = calls.flatMap((c) => c.paths);
|
|
183
|
+
expect(fileTransportPaths.some((p) => p.startsWith('.shogo'))).toBe(true);
|
|
184
|
+
// App.tsx commits go to git, NOT the file transport.
|
|
185
|
+
expect(fileTransportPaths.includes('App.tsx')).toBe(false);
|
|
186
|
+
expect(commitAndPushCalls.length).toBeGreaterThanOrEqual(1);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('git mode: throws when constructed without git options', () => {
|
|
190
|
+
const { transport } = fakeTransport();
|
|
191
|
+
expect(() => new CloudSyncWatcher({ rootDir: dir, transport, mode: 'git' as const })).toThrow();
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('invokes onFlush with the uploaded batch + error count', async () => {
|
|
195
|
+
const { transport } = fakeTransport();
|
|
196
|
+
const flushes: Array<{ uploaded: string[]; errors: number }> = [];
|
|
197
|
+
const watcher = new CloudSyncWatcher({
|
|
198
|
+
rootDir: dir,
|
|
199
|
+
transport,
|
|
200
|
+
debounceMs: 50,
|
|
201
|
+
onFlush: (e) => flushes.push(e),
|
|
202
|
+
});
|
|
203
|
+
watcher.start();
|
|
204
|
+
await wait(50);
|
|
205
|
+
|
|
206
|
+
writeFileSync(join(dir, 'x.txt'), 'X');
|
|
207
|
+
await wait(300);
|
|
208
|
+
await watcher.stop();
|
|
209
|
+
|
|
210
|
+
expect(flushes.length).toBeGreaterThanOrEqual(1);
|
|
211
|
+
expect(flushes[0]!.errors).toBe(0);
|
|
212
|
+
expect(flushes[0]!.uploaded).toContain('x.txt');
|
|
213
|
+
});
|
|
214
|
+
});
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (C) 2026 Shogo Technologies, Inc.
|
|
3
|
+
import { describe, it, expect, beforeEach, afterEach, mock } from 'bun:test';
|
|
4
|
+
import { mkdtempSync, rmSync, writeFileSync, statSync, existsSync, readFileSync, mkdirSync } from 'node:fs';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The config/credentials store reads HOME at module-load time. To test
|
|
10
|
+
* different home dirs we mock `paths.ts` per-test so config.ts picks up
|
|
11
|
+
* a tmpdir-rooted CONFIG_FILE / HOME_DIR pair without us having to
|
|
12
|
+
* mutate process.env.HOME (which is racy across the rest of the suite).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
let tmp: string;
|
|
16
|
+
|
|
17
|
+
function setupTmpHome(): string {
|
|
18
|
+
tmp = mkdtempSync(join(tmpdir(), 'shogo-config-'));
|
|
19
|
+
const homeDir = join(tmp, '.shogo');
|
|
20
|
+
const configFile = join(homeDir, 'config.json');
|
|
21
|
+
mock.module('../paths.ts', () => ({
|
|
22
|
+
HOME_DIR: homeDir,
|
|
23
|
+
CONFIG_FILE: configFile,
|
|
24
|
+
CREDENTIALS_FILE: join(homeDir, 'credentials.json'),
|
|
25
|
+
DEVICE_ID_FILE: join(homeDir, 'device-id'),
|
|
26
|
+
PID_FILE: join(homeDir, 'worker.pid'),
|
|
27
|
+
LOGS_DIR: join(homeDir, 'logs'),
|
|
28
|
+
WORKER_LOG: join(homeDir, 'logs', 'worker.log'),
|
|
29
|
+
WORKER_ERR: join(homeDir, 'logs', 'worker.err.log'),
|
|
30
|
+
RUNTIME_DIR: join(homeDir, 'runtime'),
|
|
31
|
+
RUNTIME_BIN: join(homeDir, 'runtime', 'agent-runtime'),
|
|
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),
|
|
35
|
+
ensureHome: () => {
|
|
36
|
+
mkdirSync(homeDir, { recursive: true, mode: 0o700 });
|
|
37
|
+
mkdirSync(join(homeDir, 'logs'), { recursive: true, mode: 0o700 });
|
|
38
|
+
},
|
|
39
|
+
ensureRuntimeDir: () => {
|
|
40
|
+
mkdirSync(join(homeDir, 'runtime'), { recursive: true, mode: 0o700 });
|
|
41
|
+
},
|
|
42
|
+
ensureProjectsDir: (base = join(homeDir, 'projects')) => {
|
|
43
|
+
mkdirSync(base, { recursive: true, mode: 0o700 });
|
|
44
|
+
},
|
|
45
|
+
}));
|
|
46
|
+
return configFile;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
describe('config: load + save round-trip', () => {
|
|
50
|
+
let configFile: string;
|
|
51
|
+
beforeEach(() => {
|
|
52
|
+
configFile = setupTmpHome();
|
|
53
|
+
});
|
|
54
|
+
afterEach(() => {
|
|
55
|
+
mock.restore();
|
|
56
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('loadConfig returns {} when no file exists', async () => {
|
|
60
|
+
const { loadConfig } = await import('../config.ts');
|
|
61
|
+
expect(loadConfig()).toEqual({});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('saveConfig writes JSON with mode 0600', async () => {
|
|
65
|
+
const { saveConfig, loadConfig } = await import('../config.ts');
|
|
66
|
+
saveConfig({ apiKey: 'shogo_sk_test', cloudUrl: 'https://example.com' });
|
|
67
|
+
|
|
68
|
+
expect(existsSync(configFile)).toBe(true);
|
|
69
|
+
const parsed = JSON.parse(readFileSync(configFile, 'utf-8'));
|
|
70
|
+
expect(parsed.apiKey).toBe('shogo_sk_test');
|
|
71
|
+
expect(parsed.cloudUrl).toBe('https://example.com');
|
|
72
|
+
if (process.platform !== 'win32') {
|
|
73
|
+
const st = statSync(configFile);
|
|
74
|
+
expect(st.mode & 0o077).toBe(0);
|
|
75
|
+
}
|
|
76
|
+
expect(loadConfig().apiKey).toBe('shogo_sk_test');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('loadConfig throws on corrupt JSON', async () => {
|
|
80
|
+
mkdirSync(join(tmp, '.shogo'), { recursive: true });
|
|
81
|
+
writeFileSync(configFile, '{not json');
|
|
82
|
+
const { loadConfig } = await import('../config.ts');
|
|
83
|
+
expect(() => loadConfig()).toThrow(/Corrupt config/);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('mergeConfig prefers override over base, drops undefined', async () => {
|
|
87
|
+
const { mergeConfig } = await import('../config.ts');
|
|
88
|
+
expect(
|
|
89
|
+
mergeConfig({ apiKey: 'a', cloudUrl: 'old' }, { cloudUrl: 'new', name: undefined }),
|
|
90
|
+
).toEqual({ apiKey: 'a', cloudUrl: 'new' });
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe('config: resolveConfig precedence', () => {
|
|
95
|
+
let prevEnvKey: string | undefined;
|
|
96
|
+
|
|
97
|
+
beforeEach(() => {
|
|
98
|
+
setupTmpHome();
|
|
99
|
+
prevEnvKey = process.env.SHOGO_API_KEY;
|
|
100
|
+
delete process.env.SHOGO_API_KEY;
|
|
101
|
+
});
|
|
102
|
+
afterEach(() => {
|
|
103
|
+
mock.restore();
|
|
104
|
+
if (prevEnvKey === undefined) delete process.env.SHOGO_API_KEY;
|
|
105
|
+
else process.env.SHOGO_API_KEY = prevEnvKey;
|
|
106
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('throws when no API key resolves anywhere', async () => {
|
|
110
|
+
const { resolveConfig } = await import('../config.ts');
|
|
111
|
+
expect(() => resolveConfig()).toThrow(/No API key/);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('reads API key from explicit override (highest precedence)', async () => {
|
|
115
|
+
const { resolveConfig, saveConfig } = await import('../config.ts');
|
|
116
|
+
saveConfig({ apiKey: 'shogo_sk_file' });
|
|
117
|
+
process.env.SHOGO_API_KEY = 'shogo_sk_env';
|
|
118
|
+
expect(resolveConfig({ apiKey: 'shogo_sk_override' }).apiKey).toBe('shogo_sk_override');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('falls back to env var when no override is given', async () => {
|
|
122
|
+
const { resolveConfig, saveConfig } = await import('../config.ts');
|
|
123
|
+
saveConfig({ apiKey: 'shogo_sk_file' });
|
|
124
|
+
process.env.SHOGO_API_KEY = 'shogo_sk_env';
|
|
125
|
+
expect(resolveConfig().apiKey).toBe('shogo_sk_env');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('falls back to file when no override or env is set', async () => {
|
|
129
|
+
const { resolveConfig, saveConfig } = await import('../config.ts');
|
|
130
|
+
saveConfig({ apiKey: 'shogo_sk_file' });
|
|
131
|
+
expect(resolveConfig().apiKey).toBe('shogo_sk_file');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('fills defaults for cloudUrl + port', async () => {
|
|
135
|
+
const { resolveConfig, saveConfig } = await import('../config.ts');
|
|
136
|
+
saveConfig({ apiKey: 'shogo_sk_file' });
|
|
137
|
+
const cfg = resolveConfig();
|
|
138
|
+
expect(cfg.cloudUrl).toBe('https://studio.shogo.ai');
|
|
139
|
+
expect(cfg.port).toBe(8002);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (C) 2026 Shogo Technologies, Inc.
|
|
3
|
+
//
|
|
4
|
+
// Tests for the local SQLite migration doctor.
|
|
5
|
+
//
|
|
6
|
+
// The doctor shells out to a `bun` binary to run `bun:sqlite` scripts.
|
|
7
|
+
// Under `bun test` the test process IS bun, so `process.execPath` is a
|
|
8
|
+
// valid bun binary to drive the real shell-out path against synthesized
|
|
9
|
+
// databases (mirrors apps/desktop/test-db-recovery.ts).
|
|
10
|
+
|
|
11
|
+
import { describe, it, expect, afterAll } from 'bun:test';
|
|
12
|
+
import { Database } from 'bun:sqlite';
|
|
13
|
+
import { mkdtempSync, rmSync, existsSync, statSync } from 'node:fs';
|
|
14
|
+
import { tmpdir, homedir } from 'node:os';
|
|
15
|
+
import { join, dirname } from 'node:path';
|
|
16
|
+
import { randomUUID } from 'node:crypto';
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
detectFailedMigrations,
|
|
20
|
+
backupDatabase,
|
|
21
|
+
repairFailedMigrations,
|
|
22
|
+
runDatabaseDoctor,
|
|
23
|
+
resolveDesktopDataDir,
|
|
24
|
+
resolveDesktopDbPath,
|
|
25
|
+
resolveBunBinary,
|
|
26
|
+
} from '../db-doctor.ts';
|
|
27
|
+
|
|
28
|
+
const BUN_PATH = process.execPath;
|
|
29
|
+
|
|
30
|
+
const tempDirs: string[] = [];
|
|
31
|
+
afterAll(() => {
|
|
32
|
+
for (const dir of tempDirs) {
|
|
33
|
+
try {
|
|
34
|
+
rmSync(dir, { recursive: true, force: true });
|
|
35
|
+
} catch {
|
|
36
|
+
// best-effort
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
interface SeedRow {
|
|
42
|
+
name: string;
|
|
43
|
+
startedAt: number;
|
|
44
|
+
finishedAt: number | null;
|
|
45
|
+
rolledBackAt?: number | null;
|
|
46
|
+
logs?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function makeDbPath(): string {
|
|
50
|
+
const dir = mkdtempSync(join(tmpdir(), 'shogo-doctor-test-'));
|
|
51
|
+
tempDirs.push(dir);
|
|
52
|
+
return join(dir, 'shogo.db');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function seed(dbPath: string, rows: SeedRow[]): void {
|
|
56
|
+
const db = new Database(dbPath, { create: true });
|
|
57
|
+
db.exec(`
|
|
58
|
+
CREATE TABLE _prisma_migrations (
|
|
59
|
+
id TEXT PRIMARY KEY,
|
|
60
|
+
migration_name TEXT NOT NULL,
|
|
61
|
+
checksum TEXT NOT NULL,
|
|
62
|
+
finished_at INTEGER,
|
|
63
|
+
started_at INTEGER NOT NULL,
|
|
64
|
+
rolled_back_at INTEGER,
|
|
65
|
+
logs TEXT,
|
|
66
|
+
applied_steps_count INTEGER NOT NULL DEFAULT 0
|
|
67
|
+
);
|
|
68
|
+
`);
|
|
69
|
+
const stmt = db.prepare(
|
|
70
|
+
'INSERT INTO _prisma_migrations (id, migration_name, checksum, started_at, finished_at, rolled_back_at, logs) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
|
71
|
+
);
|
|
72
|
+
for (const r of rows) {
|
|
73
|
+
stmt.run(randomUUID(), r.name, 'fake-checksum', r.startedAt, r.finishedAt, r.rolledBackAt ?? null, r.logs ?? null);
|
|
74
|
+
}
|
|
75
|
+
db.close();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
describe('detectFailedMigrations', () => {
|
|
79
|
+
it('finds the stuck row on a broken DB', () => {
|
|
80
|
+
const dbPath = makeDbPath();
|
|
81
|
+
seed(dbPath, [
|
|
82
|
+
{ name: '0000_baseline', startedAt: 1000, finishedAt: 1100 },
|
|
83
|
+
{ name: '0001_bad', startedAt: 2000, finishedAt: null, logs: 'no such table: widgets' },
|
|
84
|
+
]);
|
|
85
|
+
const failures = detectFailedMigrations(BUN_PATH, dbPath);
|
|
86
|
+
expect(failures).toHaveLength(1);
|
|
87
|
+
expect(failures[0]?.name).toBe('0001_bad');
|
|
88
|
+
expect(failures[0]?.startedAt).toBe(2000);
|
|
89
|
+
expect(failures[0]?.errorExcerpt).toContain('no such table: widgets');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('returns [] for a healthy DB', () => {
|
|
93
|
+
const dbPath = makeDbPath();
|
|
94
|
+
seed(dbPath, [{ name: '0000_baseline', startedAt: 1000, finishedAt: 1100 }]);
|
|
95
|
+
expect(detectFailedMigrations(BUN_PATH, dbPath)).toHaveLength(0);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('returns [] when the DB file does not exist', () => {
|
|
99
|
+
expect(detectFailedMigrations(BUN_PATH, join(tmpdir(), `nope-${Date.now()}.db`))).toHaveLength(0);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe('runDatabaseDoctor', () => {
|
|
104
|
+
it('reports no-database for a missing file', () => {
|
|
105
|
+
const result = runDatabaseDoctor({
|
|
106
|
+
bunPath: BUN_PATH,
|
|
107
|
+
dbPath: join(tmpdir(), `missing-${Date.now()}.db`),
|
|
108
|
+
});
|
|
109
|
+
expect(result.status).toBe('no-database');
|
|
110
|
+
expect(result.detected).toHaveLength(0);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('is a no-op on a healthy DB', () => {
|
|
114
|
+
const dbPath = makeDbPath();
|
|
115
|
+
seed(dbPath, [{ name: '0000_baseline', startedAt: 1000, finishedAt: 1100 }]);
|
|
116
|
+
const result = runDatabaseDoctor({ bunPath: BUN_PATH, dbPath });
|
|
117
|
+
expect(result.status).toBe('healthy');
|
|
118
|
+
expect(result.backupPath).toBeUndefined();
|
|
119
|
+
expect(result.cleared).toHaveLength(0);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('backs up, clears the stuck row, and reports repaired', () => {
|
|
123
|
+
const dbPath = makeDbPath();
|
|
124
|
+
seed(dbPath, [
|
|
125
|
+
{ name: 'good', startedAt: 1000, finishedAt: 1100 },
|
|
126
|
+
{ name: 'bad', startedAt: 2000, finishedAt: null, logs: 'oops' },
|
|
127
|
+
]);
|
|
128
|
+
|
|
129
|
+
const result = runDatabaseDoctor({ bunPath: BUN_PATH, dbPath });
|
|
130
|
+
|
|
131
|
+
expect(result.status).toBe('repaired');
|
|
132
|
+
expect(result.detected.map((m) => m.name)).toEqual(['bad']);
|
|
133
|
+
expect(result.cleared).toEqual(['bad']);
|
|
134
|
+
expect(result.remaining).toHaveLength(0);
|
|
135
|
+
expect(result.backupPath).toBeDefined();
|
|
136
|
+
expect(existsSync(result.backupPath!)).toBe(true);
|
|
137
|
+
expect(statSync(result.backupPath!).size).toBeGreaterThan(0);
|
|
138
|
+
|
|
139
|
+
// The good row survives; the bad row is gone.
|
|
140
|
+
const db = new Database(dbPath, { readonly: true });
|
|
141
|
+
const names = (db.query('SELECT migration_name FROM _prisma_migrations').all() as Array<{ migration_name: string }>)
|
|
142
|
+
.map((r) => r.migration_name);
|
|
143
|
+
db.close();
|
|
144
|
+
expect(names).toEqual(['good']);
|
|
145
|
+
|
|
146
|
+
// Re-running is idempotent: now healthy.
|
|
147
|
+
expect(runDatabaseDoctor({ bunPath: BUN_PATH, dbPath }).status).toBe('healthy');
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('honors skipBackup', () => {
|
|
151
|
+
const dbPath = makeDbPath();
|
|
152
|
+
seed(dbPath, [{ name: 'bad', startedAt: 2000, finishedAt: null, logs: 'oops' }]);
|
|
153
|
+
const result = runDatabaseDoctor({ bunPath: BUN_PATH, dbPath, skipBackup: true });
|
|
154
|
+
expect(result.status).toBe('repaired');
|
|
155
|
+
expect(result.backupPath).toBeUndefined();
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
describe('backupDatabase / repairFailedMigrations', () => {
|
|
160
|
+
it('produces a byte-faithful sibling backup', () => {
|
|
161
|
+
const dbPath = makeDbPath();
|
|
162
|
+
seed(dbPath, [{ name: 'm1', startedAt: 1, finishedAt: 2 }]);
|
|
163
|
+
const backupPath = backupDatabase(dbPath);
|
|
164
|
+
expect(existsSync(backupPath)).toBe(true);
|
|
165
|
+
expect(dirname(backupPath)).toBe(dirname(dbPath));
|
|
166
|
+
expect(backupPath).toMatch(/\.bak-/);
|
|
167
|
+
expect(statSync(backupPath).size).toBe(statSync(dbPath).size);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('leaves already rolled-back rows alone', () => {
|
|
171
|
+
const dbPath = makeDbPath();
|
|
172
|
+
seed(dbPath, [{ name: 'rb', startedAt: 1000, finishedAt: null, rolledBackAt: 1200 }]);
|
|
173
|
+
const deleted = repairFailedMigrations(BUN_PATH, dbPath, ['rb']);
|
|
174
|
+
expect(deleted).toBe(0);
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
describe('path / bun resolution', () => {
|
|
179
|
+
it('resolveDesktopDbPath is shogo.db under the Shogo data dir', () => {
|
|
180
|
+
expect(resolveDesktopDbPath()).toBe(join(resolveDesktopDataDir(), 'shogo.db'));
|
|
181
|
+
expect(resolveDesktopDataDir()).toContain('Shogo');
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('resolveDesktopDataDir uses the platform app-data root', () => {
|
|
185
|
+
const dir = resolveDesktopDataDir();
|
|
186
|
+
if (process.platform === 'darwin') {
|
|
187
|
+
expect(dir).toContain(join('Library', 'Application Support'));
|
|
188
|
+
} else if (process.platform === 'win32') {
|
|
189
|
+
// %APPDATA% (Roaming) — just assert it ends with the expected tail.
|
|
190
|
+
expect(dir.endsWith(join('Shogo', 'data'))).toBe(true);
|
|
191
|
+
} else {
|
|
192
|
+
expect(dir).toContain(join('.config'));
|
|
193
|
+
}
|
|
194
|
+
expect(dir.startsWith(homedir()) || process.platform === 'win32').toBe(true);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('resolveBunBinary returns the running bun and honors an explicit override', () => {
|
|
198
|
+
// Under `bun test`, process.execPath is a usable bun.
|
|
199
|
+
expect(resolveBunBinary()).toBe(process.execPath);
|
|
200
|
+
expect(resolveBunBinary(process.execPath)).toBe(process.execPath);
|
|
201
|
+
// A bogus override is rejected (null) rather than returned blindly.
|
|
202
|
+
expect(resolveBunBinary(join(tmpdir(), 'definitely-not-bun'))).toBeNull();
|
|
203
|
+
});
|
|
204
|
+
});
|