@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.
- 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,101 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (C) 2026 Shogo Technologies, Inc.
|
|
3
|
+
/**
|
|
4
|
+
* Pin the wire shape of the structured 502 response WorkerTunnel sends
|
|
5
|
+
* when a resolver declines to forward a tunneled request. Studio reads
|
|
6
|
+
* `code`, `message`, and `path` from the body so a future debugger can
|
|
7
|
+
* tell what was attempted without log archaeology.
|
|
8
|
+
*/
|
|
9
|
+
import { describe, expect, it } from 'bun:test';
|
|
10
|
+
import { WorkerTunnel, type ResolveRejection, type RuntimeResolver } from '../tunnel.ts';
|
|
11
|
+
|
|
12
|
+
class FakeWebSocket {
|
|
13
|
+
readyState = 1; // WebSocket.OPEN
|
|
14
|
+
sent: string[] = [];
|
|
15
|
+
send(msg: string): void {
|
|
16
|
+
this.sent.push(msg);
|
|
17
|
+
}
|
|
18
|
+
close(): void {
|
|
19
|
+
this.readyState = 3; // WebSocket.CLOSED
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function makeResolver(overrides: Partial<RuntimeResolver> = {}): RuntimeResolver {
|
|
24
|
+
return {
|
|
25
|
+
resolveLocalUrl: async () => null,
|
|
26
|
+
deriveRuntimeToken: () => null,
|
|
27
|
+
getActiveProjects: () => [],
|
|
28
|
+
status: () => null,
|
|
29
|
+
...overrides,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const silentLogger = { log: () => {}, warn: () => {}, error: () => {} };
|
|
34
|
+
|
|
35
|
+
describe('WorkerTunnel structured 502', () => {
|
|
36
|
+
it('echoes resolver-provided code+message and the original path', async () => {
|
|
37
|
+
const rejection: ResolveRejection = {
|
|
38
|
+
code: 'CLI_WORKER_HAS_NO_DATA_API',
|
|
39
|
+
message: 'cli-worker only serves /agent/* paths; tried: /api/projects',
|
|
40
|
+
};
|
|
41
|
+
const resolver = makeResolver({
|
|
42
|
+
describeRejection: () => rejection,
|
|
43
|
+
});
|
|
44
|
+
const tunnel = new WorkerTunnel({
|
|
45
|
+
apiKey: 'shogo_sk_x',
|
|
46
|
+
cloudUrl: 'https://api.test',
|
|
47
|
+
resolver,
|
|
48
|
+
logger: silentLogger,
|
|
49
|
+
});
|
|
50
|
+
const fake = new FakeWebSocket();
|
|
51
|
+
tunnel._testing().installFakeWs(fake as unknown as WebSocket);
|
|
52
|
+
|
|
53
|
+
await tunnel._testing().handleRequest({
|
|
54
|
+
type: 'request',
|
|
55
|
+
requestId: 'r-1',
|
|
56
|
+
method: 'GET',
|
|
57
|
+
path: '/api/projects?workspaceId=ws-1',
|
|
58
|
+
stream: false,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
expect(fake.sent.length).toBe(1);
|
|
62
|
+
const frame = JSON.parse(fake.sent[0]!);
|
|
63
|
+
expect(frame.type).toBe('response');
|
|
64
|
+
expect(frame.requestId).toBe('r-1');
|
|
65
|
+
expect(frame.status).toBe(502);
|
|
66
|
+
expect(frame.headers?.['content-type']).toBe('application/json');
|
|
67
|
+
|
|
68
|
+
const body = JSON.parse(frame.body);
|
|
69
|
+
expect(body.code).toBe('CLI_WORKER_HAS_NO_DATA_API');
|
|
70
|
+
expect(body.message).toBe(rejection.message);
|
|
71
|
+
// Tunnel always echoes back the ORIGINAL path (with query) so a
|
|
72
|
+
// browser request's URL survives the round-trip into the 502 body.
|
|
73
|
+
expect(body.path).toBe('/api/projects?workspaceId=ws-1');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('falls back to a generic NO_LOCAL_RUNTIME body when resolver omits describeRejection', async () => {
|
|
77
|
+
const resolver = makeResolver();
|
|
78
|
+
const tunnel = new WorkerTunnel({
|
|
79
|
+
apiKey: 'shogo_sk_x',
|
|
80
|
+
cloudUrl: 'https://api.test',
|
|
81
|
+
resolver,
|
|
82
|
+
logger: silentLogger,
|
|
83
|
+
});
|
|
84
|
+
const fake = new FakeWebSocket();
|
|
85
|
+
tunnel._testing().installFakeWs(fake as unknown as WebSocket);
|
|
86
|
+
|
|
87
|
+
await tunnel._testing().handleRequest({
|
|
88
|
+
type: 'request',
|
|
89
|
+
requestId: 'r-2',
|
|
90
|
+
method: 'GET',
|
|
91
|
+
path: '/whatever',
|
|
92
|
+
stream: false,
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
expect(fake.sent.length).toBe(1);
|
|
96
|
+
const body = JSON.parse(JSON.parse(fake.sent[0]!).body);
|
|
97
|
+
expect(body.code).toBe('NO_LOCAL_RUNTIME');
|
|
98
|
+
expect(body.path).toBe('/whatever');
|
|
99
|
+
expect(body.message).toContain('/whatever');
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (C) 2026 Shogo Technologies, Inc.
|
|
3
|
+
/**
|
|
4
|
+
* Node-only filesystem watcher that pushes local edits back to Shogo
|
|
5
|
+
* Cloud via {@link CloudFileTransport}. Used by:
|
|
6
|
+
* - `shogo project pull --watch` (foreground sync)
|
|
7
|
+
* - `WorkerRuntimeManager` auto-pull (background sync alongside an
|
|
8
|
+
* actively running agent-runtime)
|
|
9
|
+
*
|
|
10
|
+
* Design notes:
|
|
11
|
+
* - Uses `node:fs.watch` recursively when available (Linux >= 20.x via
|
|
12
|
+
* `recursive: true`, macOS, Windows) — no `chokidar` dep.
|
|
13
|
+
* - Debounces a window of changes into a single batch upload to amortize
|
|
14
|
+
* presign round trips. Default 1.5s.
|
|
15
|
+
* - Skips EXCLUDED_DIRS the same way the transport does.
|
|
16
|
+
* - Best-effort: errors are logged but never crash the watcher; the
|
|
17
|
+
* caller's UI surfaces them.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { watch, type FSWatcher, statSync } from 'node:fs';
|
|
21
|
+
import { relative, sep, posix } from 'node:path';
|
|
22
|
+
import type { CloudFileTransport } from '@shogo-ai/sdk';
|
|
23
|
+
import { commitAndPush } from './git-cloner.ts';
|
|
24
|
+
|
|
25
|
+
const DEBOUNCE_MS = 1500;
|
|
26
|
+
const EXCLUDED_DIRS = new Set(['node_modules', '.git', 'dist', 'build', '.vite', '.cache']);
|
|
27
|
+
/** Paths under these directories are gitignored — always route them
|
|
28
|
+
* through the file transport even when the watcher is in 'git' mode. */
|
|
29
|
+
const ALWAYS_FILE_TRANSPORT_PREFIXES = ['.shogo/'];
|
|
30
|
+
|
|
31
|
+
export type WatcherSyncMode = 'git' | 'files';
|
|
32
|
+
|
|
33
|
+
export interface WatcherGitOptions {
|
|
34
|
+
/** Cloud API URL. Forwarded to `commitAndPush`. */
|
|
35
|
+
apiUrl: string;
|
|
36
|
+
/** Bearer API key. */
|
|
37
|
+
apiKey: string;
|
|
38
|
+
/** Project ID. */
|
|
39
|
+
projectId: string;
|
|
40
|
+
/** Branch to push to. Default `HEAD` (current). */
|
|
41
|
+
branch?: string;
|
|
42
|
+
/** Optional author identity for the auto-commits. */
|
|
43
|
+
authorEmail?: string;
|
|
44
|
+
authorName?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Signature of `commitAndPush` from `./git-cloner`. Extracted so tests
|
|
48
|
+
* can inject a fake without resorting to module-level mocking (which
|
|
49
|
+
* leaks across files in bun:test). */
|
|
50
|
+
export type CommitAndPushFn = (opts: {
|
|
51
|
+
apiUrl: string;
|
|
52
|
+
apiKey: string;
|
|
53
|
+
projectId: string;
|
|
54
|
+
localDir: string;
|
|
55
|
+
message: string;
|
|
56
|
+
branch?: string;
|
|
57
|
+
authorEmail?: string;
|
|
58
|
+
authorName?: string;
|
|
59
|
+
logger?: Pick<Console, 'log' | 'warn' | 'error'>;
|
|
60
|
+
}) => Promise<{ committed: boolean; commitSha?: string }>;
|
|
61
|
+
|
|
62
|
+
export interface CloudSyncWatcherOptions {
|
|
63
|
+
/** Local directory to watch. */
|
|
64
|
+
rootDir: string;
|
|
65
|
+
/** Transport used to push uploads. Always required — `.shogo/` writes
|
|
66
|
+
* flow through here even in git mode. */
|
|
67
|
+
transport: CloudFileTransport;
|
|
68
|
+
/** Optional debounce window in ms. Default 1500. */
|
|
69
|
+
debounceMs?: number;
|
|
70
|
+
/** Logger. Defaults to console. */
|
|
71
|
+
logger?: Pick<Console, 'log' | 'warn' | 'error'>;
|
|
72
|
+
/** Called whenever a batch flushes. Useful for progress UIs / metrics. */
|
|
73
|
+
onFlush?: (event: { uploaded: string[]; errors: number; committed?: boolean; commitSha?: string }) => void;
|
|
74
|
+
/**
|
|
75
|
+
* Sync strategy. Defaults to `'files'` to keep existing callers
|
|
76
|
+
* (manual `shogo project pull --watch`) working unchanged.
|
|
77
|
+
*
|
|
78
|
+
* In `'git'` mode the flush stages all tracked changes and pushes
|
|
79
|
+
* one commit to the cloud; `.shogo/` writes still PUT through the
|
|
80
|
+
* file transport because they're gitignored.
|
|
81
|
+
*/
|
|
82
|
+
mode?: WatcherSyncMode;
|
|
83
|
+
/** Git options. Required when `mode: 'git'`. */
|
|
84
|
+
git?: WatcherGitOptions;
|
|
85
|
+
/** Override for the `commitAndPush` implementation. Tests inject a fake;
|
|
86
|
+
* production callers leave this unset and get the real one. */
|
|
87
|
+
commitAndPush?: CommitAndPushFn;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Convert an absolute or platform-native path to a forward-slash relative path. */
|
|
91
|
+
function toPosixRel(rootDir: string, abs: string): string {
|
|
92
|
+
const rel = relative(rootDir, abs);
|
|
93
|
+
if (!rel) return '';
|
|
94
|
+
return rel.split(sep).join(posix.sep);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function isExcluded(relPath: string): boolean {
|
|
98
|
+
if (!relPath) return true;
|
|
99
|
+
return relPath.split('/').some((part) => EXCLUDED_DIRS.has(part));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export class CloudSyncWatcher {
|
|
103
|
+
private readonly rootDir: string;
|
|
104
|
+
private readonly transport: CloudFileTransport;
|
|
105
|
+
private readonly debounceMs: number;
|
|
106
|
+
private readonly logger: Pick<Console, 'log' | 'warn' | 'error'>;
|
|
107
|
+
private readonly onFlush?: CloudSyncWatcherOptions['onFlush'];
|
|
108
|
+
private readonly mode: WatcherSyncMode;
|
|
109
|
+
private readonly git?: WatcherGitOptions;
|
|
110
|
+
private readonly commitAndPush: CommitAndPushFn;
|
|
111
|
+
|
|
112
|
+
private watcher: FSWatcher | null = null;
|
|
113
|
+
private pending = new Set<string>();
|
|
114
|
+
private timer: ReturnType<typeof setTimeout> | null = null;
|
|
115
|
+
private flushing = false;
|
|
116
|
+
private stopped = false;
|
|
117
|
+
|
|
118
|
+
constructor(opts: CloudSyncWatcherOptions) {
|
|
119
|
+
this.rootDir = opts.rootDir;
|
|
120
|
+
this.transport = opts.transport;
|
|
121
|
+
this.debounceMs = opts.debounceMs ?? DEBOUNCE_MS;
|
|
122
|
+
this.logger = opts.logger ?? console;
|
|
123
|
+
this.onFlush = opts.onFlush;
|
|
124
|
+
this.mode = opts.mode ?? 'files';
|
|
125
|
+
if (this.mode === 'git' && !opts.git) {
|
|
126
|
+
throw new Error('CloudSyncWatcher: mode: "git" requires the `git` option block');
|
|
127
|
+
}
|
|
128
|
+
this.git = opts.git;
|
|
129
|
+
this.commitAndPush = opts.commitAndPush ?? commitAndPush;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Begin watching. Throws if the directory can't be watched (e.g. it
|
|
134
|
+
* doesn't exist). Callers should wrap in try/catch.
|
|
135
|
+
*/
|
|
136
|
+
start(): void {
|
|
137
|
+
if (this.watcher) return;
|
|
138
|
+
if (this.stopped) throw new Error('CloudSyncWatcher: already stopped, build a fresh instance');
|
|
139
|
+
|
|
140
|
+
// Some platforms / Node versions don't support `recursive: true`. Fall
|
|
141
|
+
// back to a non-recursive watch on the root + per-discovered-subdir
|
|
142
|
+
// watches in that case. For Linux >= 20 / macOS / Windows, the
|
|
143
|
+
// recursive variant is much cheaper and we use it by default.
|
|
144
|
+
try {
|
|
145
|
+
this.watcher = watch(this.rootDir, { recursive: true }, (eventType, filename) => {
|
|
146
|
+
if (!filename) return;
|
|
147
|
+
this.handleEvent(String(filename));
|
|
148
|
+
});
|
|
149
|
+
} catch (err: any) {
|
|
150
|
+
this.logger.warn(`[CloudSyncWatcher] recursive watch failed (${err?.message ?? err}); falling back to root-only watch.`);
|
|
151
|
+
this.watcher = watch(this.rootDir, (eventType, filename) => {
|
|
152
|
+
if (!filename) return;
|
|
153
|
+
this.handleEvent(String(filename));
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Stop watching and flush any pending uploads. */
|
|
159
|
+
async stop(): Promise<void> {
|
|
160
|
+
if (this.stopped) return;
|
|
161
|
+
this.stopped = true;
|
|
162
|
+
if (this.timer) {
|
|
163
|
+
clearTimeout(this.timer);
|
|
164
|
+
this.timer = null;
|
|
165
|
+
}
|
|
166
|
+
if (this.watcher) {
|
|
167
|
+
try {
|
|
168
|
+
this.watcher.close();
|
|
169
|
+
} catch {
|
|
170
|
+
/* already closed */
|
|
171
|
+
}
|
|
172
|
+
this.watcher = null;
|
|
173
|
+
}
|
|
174
|
+
// Final flush so we don't lose the last edits made before SIGINT.
|
|
175
|
+
if (this.pending.size > 0) {
|
|
176
|
+
await this.flush();
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
private handleEvent(filenameLike: string): void {
|
|
181
|
+
if (this.stopped) return;
|
|
182
|
+
const rel = filenameLike.split(sep).join(posix.sep);
|
|
183
|
+
if (isExcluded(rel)) return;
|
|
184
|
+
// Ignore directory events — we only push file content. A directory rename
|
|
185
|
+
// shows up later as individual file events when their content is touched.
|
|
186
|
+
try {
|
|
187
|
+
const abs = `${this.rootDir}/${rel}`;
|
|
188
|
+
const stat = statSync(abs);
|
|
189
|
+
if (stat.isDirectory()) return;
|
|
190
|
+
} catch {
|
|
191
|
+
// File was deleted between the event firing and the stat — emit a
|
|
192
|
+
// delete intent. We track these as `null` markers in `pending` but
|
|
193
|
+
// since the transport doesn't have a "push pending deletes" mode
|
|
194
|
+
// we just drop them; the next `--delete-remote` push will clean
|
|
195
|
+
// them up. Documented behavior.
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
this.pending.add(rel);
|
|
199
|
+
this.scheduleFlush();
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
private scheduleFlush(): void {
|
|
203
|
+
if (this.timer) return;
|
|
204
|
+
this.timer = setTimeout(() => {
|
|
205
|
+
this.timer = null;
|
|
206
|
+
void this.flush();
|
|
207
|
+
}, this.debounceMs);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
private async flush(): Promise<void> {
|
|
211
|
+
if (this.flushing) {
|
|
212
|
+
// Re-schedule so we don't drop edits that arrived during the upload.
|
|
213
|
+
this.scheduleFlush();
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
if (this.pending.size === 0) return;
|
|
217
|
+
this.flushing = true;
|
|
218
|
+
const batch = Array.from(this.pending);
|
|
219
|
+
this.pending.clear();
|
|
220
|
+
try {
|
|
221
|
+
if (this.mode === 'git') {
|
|
222
|
+
await this.flushGit(batch);
|
|
223
|
+
} else {
|
|
224
|
+
await this.flushFiles(batch);
|
|
225
|
+
}
|
|
226
|
+
} catch (err: any) {
|
|
227
|
+
this.logger.error(`[CloudSyncWatcher] flush failed: ${err?.message ?? err}`);
|
|
228
|
+
// Re-queue so we'll retry on the next tick.
|
|
229
|
+
for (const p of batch) this.pending.add(p);
|
|
230
|
+
this.scheduleFlush();
|
|
231
|
+
} finally {
|
|
232
|
+
this.flushing = false;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Pure file-transport flush. Used in `mode: 'files'` and as the
|
|
238
|
+
* always-on path for `.shogo/` writes when `mode: 'git'`.
|
|
239
|
+
*/
|
|
240
|
+
private async flushFiles(batch: string[]): Promise<void> {
|
|
241
|
+
if (batch.length === 0) return;
|
|
242
|
+
const stats = await this.transport.uploadFiles(batch);
|
|
243
|
+
this.onFlush?.({ uploaded: batch, errors: stats.errors.length });
|
|
244
|
+
if (stats.errors.length > 0) {
|
|
245
|
+
for (const err of stats.errors) {
|
|
246
|
+
this.logger.warn(`[CloudSyncWatcher] upload ${err.path}: ${err.message}`);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Git-mode flush: route gitignored `.shogo/` writes through the file
|
|
253
|
+
* transport, then `git add -A && git commit && git push` everything
|
|
254
|
+
* else in one batch. This keeps the desktop checkpoint timeline in
|
|
255
|
+
* sync with what the worker's runtime is producing without having
|
|
256
|
+
* to push individual file PUTs per edit.
|
|
257
|
+
*/
|
|
258
|
+
private async flushGit(batch: string[]): Promise<void> {
|
|
259
|
+
if (!this.git) throw new Error('CloudSyncWatcher: missing git options');
|
|
260
|
+
const fileTransportBatch: string[] = [];
|
|
261
|
+
const gitBatch: string[] = [];
|
|
262
|
+
for (const p of batch) {
|
|
263
|
+
if (ALWAYS_FILE_TRANSPORT_PREFIXES.some((prefix) => p === prefix.slice(0, -1) || p.startsWith(prefix))) {
|
|
264
|
+
fileTransportBatch.push(p);
|
|
265
|
+
} else {
|
|
266
|
+
gitBatch.push(p);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Push .shogo/ first so the runtime's DB snapshot lands before
|
|
271
|
+
// any commit references that point at row IDs in it.
|
|
272
|
+
if (fileTransportBatch.length > 0) {
|
|
273
|
+
const stats = await this.transport.uploadFiles(fileTransportBatch);
|
|
274
|
+
if (stats.errors.length > 0) {
|
|
275
|
+
for (const err of stats.errors) {
|
|
276
|
+
this.logger.warn(`[CloudSyncWatcher] upload ${err.path}: ${err.message}`);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
let committed = false;
|
|
282
|
+
let commitSha: string | undefined;
|
|
283
|
+
if (gitBatch.length > 0) {
|
|
284
|
+
const message = `auto: ${new Date().toISOString()}`;
|
|
285
|
+
try {
|
|
286
|
+
const res = await this.commitAndPush({
|
|
287
|
+
apiUrl: this.git.apiUrl,
|
|
288
|
+
apiKey: this.git.apiKey,
|
|
289
|
+
projectId: this.git.projectId,
|
|
290
|
+
localDir: this.rootDir,
|
|
291
|
+
message,
|
|
292
|
+
branch: this.git.branch,
|
|
293
|
+
authorEmail: this.git.authorEmail,
|
|
294
|
+
authorName: this.git.authorName,
|
|
295
|
+
logger: this.logger,
|
|
296
|
+
});
|
|
297
|
+
committed = res.committed;
|
|
298
|
+
commitSha = res.commitSha;
|
|
299
|
+
} catch (err: any) {
|
|
300
|
+
// commit/push failures bubble up so the caller's retry logic
|
|
301
|
+
// (flush() catch) re-queues the batch.
|
|
302
|
+
throw err;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
this.onFlush?.({ uploaded: batch, errors: 0, committed, commitSha });
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Re-export for tests
|
|
311
|
+
export { toPosixRel as _toPosixRel, isExcluded as _isExcluded };
|
package/src/lib/config.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { readFileSync, writeFileSync, existsSync, chmodSync } from 'node:fs';
|
|
8
8
|
import { hostname } from 'node:os';
|
|
9
|
-
import { CONFIG_FILE, ensureHome } from './paths.ts';
|
|
9
|
+
import { CONFIG_FILE, PROJECTS_DIR, ensureHome } from './paths.ts';
|
|
10
10
|
|
|
11
11
|
export interface WorkerConfig {
|
|
12
12
|
apiKey?: string;
|
|
@@ -14,11 +14,14 @@ export interface WorkerConfig {
|
|
|
14
14
|
name?: string;
|
|
15
15
|
workerDir?: string;
|
|
16
16
|
port?: number;
|
|
17
|
+
/** Root directory under which pulled project workspaces are stored. */
|
|
18
|
+
projectsDir?: string;
|
|
17
19
|
}
|
|
18
20
|
|
|
19
|
-
const DEFAULTS:
|
|
21
|
+
const DEFAULTS: { cloudUrl: string; port: number; projectsDir: string } = {
|
|
20
22
|
cloudUrl: 'https://studio.shogo.ai',
|
|
21
23
|
port: 8002,
|
|
24
|
+
projectsDir: PROJECTS_DIR,
|
|
22
25
|
};
|
|
23
26
|
|
|
24
27
|
export function loadConfig(): WorkerConfig {
|
|
@@ -48,6 +51,7 @@ export function resolveConfig(override: WorkerConfig = {}): Required<WorkerConfi
|
|
|
48
51
|
name: process.env.SHOGO_INSTANCE_NAME,
|
|
49
52
|
workerDir: process.env.SHOGO_WORKER_DIR,
|
|
50
53
|
port: process.env.PORT ? parseInt(process.env.PORT, 10) : undefined,
|
|
54
|
+
projectsDir: process.env.SHOGO_PROJECTS_DIR,
|
|
51
55
|
};
|
|
52
56
|
const merged = mergeConfig(mergeConfig(fileCfg, env), override);
|
|
53
57
|
if (!merged.apiKey) {
|
|
@@ -59,5 +63,6 @@ export function resolveConfig(override: WorkerConfig = {}): Required<WorkerConfi
|
|
|
59
63
|
name: merged.name ?? hostname(),
|
|
60
64
|
workerDir: merged.workerDir ?? process.cwd(),
|
|
61
65
|
port: merged.port ?? DEFAULTS.port,
|
|
66
|
+
projectsDir: merged.projectsDir ?? DEFAULTS.projectsDir,
|
|
62
67
|
};
|
|
63
68
|
}
|