@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,133 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (C) 2026 Shogo Technologies, Inc.
|
|
3
|
+
/**
|
|
4
|
+
* `shogo project pull <projectId>` — clone a project's workspace from
|
|
5
|
+
* Shogo Cloud to a local directory. Optionally `--watch` starts a
|
|
6
|
+
* bidirectional sync: pull initially, then push any local edits back via
|
|
7
|
+
* the {@link CloudSyncWatcher}.
|
|
8
|
+
*
|
|
9
|
+
* This is the easy-button companion to pinning a project to a paired
|
|
10
|
+
* machine:
|
|
11
|
+
*
|
|
12
|
+
* shogo login # one-time pairing
|
|
13
|
+
* shogo project pull <projectId> # clones the staging snapshot locally
|
|
14
|
+
* shogo worker start # auto-routes pinned traffic here
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { existsSync, mkdirSync } from 'node:fs';
|
|
18
|
+
import { resolve } from 'node:path';
|
|
19
|
+
import pc from 'picocolors';
|
|
20
|
+
import { CloudFileTransport, type ProgressEvent, type SyncStats } from '@shogo-ai/sdk/cloud-file-transport';
|
|
21
|
+
import { resolveConfig } from '../lib/config.ts';
|
|
22
|
+
import { ensureProjectsDir, projectDirFor } from '../lib/paths.ts';
|
|
23
|
+
import { CloudSyncWatcher } from '../lib/cloud-sync-watcher.ts';
|
|
24
|
+
|
|
25
|
+
export interface ProjectPullFlags {
|
|
26
|
+
into?: string;
|
|
27
|
+
watch?: boolean;
|
|
28
|
+
include?: string;
|
|
29
|
+
apiKey?: string;
|
|
30
|
+
cloudUrl?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function runProjectPull(projectId: string, flags: ProjectPullFlags): Promise<void> {
|
|
34
|
+
if (!projectId) throw new Error('projectId is required');
|
|
35
|
+
|
|
36
|
+
const cfg = resolveConfig({
|
|
37
|
+
apiKey: flags.apiKey,
|
|
38
|
+
cloudUrl: flags.cloudUrl,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
ensureProjectsDir(cfg.projectsDir);
|
|
42
|
+
const into = resolve(flags.into ?? projectDirFor(projectId, cfg.projectsDir));
|
|
43
|
+
if (!existsSync(into)) {
|
|
44
|
+
mkdirSync(into, { recursive: true });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const include = flags.include?.split(',').map((s) => s.trim()).filter(Boolean);
|
|
48
|
+
|
|
49
|
+
console.log(pc.bold(`\nshogo project pull ${pc.cyan(projectId)}`));
|
|
50
|
+
console.log(pc.dim(' cloud ') + cfg.cloudUrl);
|
|
51
|
+
console.log(pc.dim(' into ') + into);
|
|
52
|
+
if (include?.length) console.log(pc.dim(' include ') + include.join(', '));
|
|
53
|
+
console.log('');
|
|
54
|
+
|
|
55
|
+
const transport = new CloudFileTransport({
|
|
56
|
+
apiUrl: cfg.cloudUrl,
|
|
57
|
+
apiKey: cfg.apiKey,
|
|
58
|
+
projectId,
|
|
59
|
+
localDir: into,
|
|
60
|
+
include,
|
|
61
|
+
onProgress: makeProgressReporter(),
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const stats = await transport.downloadAll();
|
|
65
|
+
printSummary('Pull', stats);
|
|
66
|
+
|
|
67
|
+
if (flags.watch) {
|
|
68
|
+
console.log(pc.bold('\nWatching for local changes...'));
|
|
69
|
+
const watcher = new CloudSyncWatcher({
|
|
70
|
+
rootDir: into,
|
|
71
|
+
transport,
|
|
72
|
+
onFlush: ({ uploaded, errors }) => {
|
|
73
|
+
const list = uploaded.length === 1 ? uploaded[0] : `${uploaded.length} files`;
|
|
74
|
+
const errSuffix = errors > 0 ? pc.red(` (${errors} errors)`) : '';
|
|
75
|
+
console.log(pc.dim(` ↑ ${list}${errSuffix}`));
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
watcher.start();
|
|
79
|
+
|
|
80
|
+
let shuttingDown = false;
|
|
81
|
+
const shutdown = async (signal: NodeJS.Signals) => {
|
|
82
|
+
if (shuttingDown) return;
|
|
83
|
+
shuttingDown = true;
|
|
84
|
+
console.log(pc.dim(`\nReceived ${signal} — flushing pending uploads...`));
|
|
85
|
+
try {
|
|
86
|
+
await watcher.stop();
|
|
87
|
+
} catch (err: any) {
|
|
88
|
+
console.warn(pc.yellow(`watcher.stop: ${err?.message ?? err}`));
|
|
89
|
+
}
|
|
90
|
+
process.exit(0);
|
|
91
|
+
};
|
|
92
|
+
process.once('SIGINT', () => void shutdown('SIGINT'));
|
|
93
|
+
process.once('SIGTERM', () => void shutdown('SIGTERM'));
|
|
94
|
+
process.once('SIGHUP', () => void shutdown('SIGHUP'));
|
|
95
|
+
|
|
96
|
+
// Park the foreground process. Shutdown handler exits when the user terminates.
|
|
97
|
+
await new Promise<void>(() => { /* never resolves */ });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function makeProgressReporter(): (e: ProgressEvent) => void {
|
|
102
|
+
return (e) => {
|
|
103
|
+
const verb = e.kind === 'download' ? '↓' : e.kind === 'upload' ? '↑' : e.kind === 'delete' ? '✗' : '·';
|
|
104
|
+
const pct = e.total > 0 ? ` ${e.index + 1}/${e.total}` : '';
|
|
105
|
+
const sizeNote = e.bytes != null ? pc.dim(` (${formatBytes(e.bytes)})`) : '';
|
|
106
|
+
console.log(pc.dim(` ${verb}${pct} ${e.path}${sizeNote}`));
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function printSummary(label: string, stats: SyncStats): void {
|
|
111
|
+
const ok = stats.errors.length === 0;
|
|
112
|
+
const head = ok ? pc.green(`✓ ${label} complete`) : pc.red(`✗ ${label} completed with errors`);
|
|
113
|
+
console.log(`\n${head}`);
|
|
114
|
+
console.log(pc.dim(' downloaded: ') + stats.downloaded);
|
|
115
|
+
if (stats.uploaded) console.log(pc.dim(' uploaded: ') + stats.uploaded);
|
|
116
|
+
if (stats.deleted) console.log(pc.dim(' deleted: ') + stats.deleted);
|
|
117
|
+
if (stats.skipped) console.log(pc.dim(' skipped: ') + stats.skipped);
|
|
118
|
+
if (!ok) {
|
|
119
|
+
console.log(pc.dim(' errors: ') + stats.errors.length);
|
|
120
|
+
for (const err of stats.errors.slice(0, 5)) {
|
|
121
|
+
console.log(pc.dim(' ') + pc.red(`${err.path}: ${err.message}`));
|
|
122
|
+
}
|
|
123
|
+
if (stats.errors.length > 5) {
|
|
124
|
+
console.log(pc.dim(` ... and ${stats.errors.length - 5} more`));
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function formatBytes(n: number): string {
|
|
130
|
+
if (n < 1024) return `${n}B`;
|
|
131
|
+
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)}KB`;
|
|
132
|
+
return `${(n / 1024 / 1024).toFixed(2)}MB`;
|
|
133
|
+
}
|
|
@@ -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/cloud-file-transport';
|
|
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
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (C) 2026 Shogo Technologies, Inc.
|
|
3
|
+
/**
|
|
4
|
+
* `shogo runtime` subcommands — manage the locally-installed
|
|
5
|
+
* agent-runtime binary that the worker spawns per-project.
|
|
6
|
+
*
|
|
7
|
+
* shogo runtime install [--channel <stable|beta|nightly>] [--version <x>] [--force] [--base-url <url>]
|
|
8
|
+
* shogo runtime version
|
|
9
|
+
* shogo runtime where
|
|
10
|
+
* shogo runtime update [--channel <...>]
|
|
11
|
+
*
|
|
12
|
+
* The runtime is AGPL-3.0-or-later (see packages/agent-runtime). The
|
|
13
|
+
* worker (MIT) installs it as a separate on-disk binary and spawns it
|
|
14
|
+
* as a child process — no library link.
|
|
15
|
+
*/
|
|
16
|
+
import pc from 'picocolors';
|
|
17
|
+
import {
|
|
18
|
+
type Channel,
|
|
19
|
+
detectTarget,
|
|
20
|
+
getRuntimePaths,
|
|
21
|
+
installRuntime,
|
|
22
|
+
readInstalledVersion,
|
|
23
|
+
} from '../lib/runtime-install.ts';
|
|
24
|
+
import { resolveRuntime, formatMissingRuntimeError } from '../lib/runtime-resolver.ts';
|
|
25
|
+
|
|
26
|
+
interface InstallFlags {
|
|
27
|
+
channel?: Channel;
|
|
28
|
+
version?: string;
|
|
29
|
+
baseUrl?: string;
|
|
30
|
+
force?: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function runRuntimeInstall(flags: InstallFlags = {}): Promise<void> {
|
|
34
|
+
const result = await installRuntime({
|
|
35
|
+
channel: flags.channel,
|
|
36
|
+
version: flags.version,
|
|
37
|
+
baseUrl: flags.baseUrl,
|
|
38
|
+
force: flags.force,
|
|
39
|
+
});
|
|
40
|
+
console.log();
|
|
41
|
+
console.log(pc.green('✓'), `agent-runtime ${pc.bold(result.version)} installed (${result.target})`);
|
|
42
|
+
console.log(` ${pc.dim('path: ')} ${result.binPath}`);
|
|
43
|
+
console.log(` ${pc.dim('source: ')} ${result.source}`);
|
|
44
|
+
console.log(` ${pc.dim('sha256: ')} ${result.sha256}`);
|
|
45
|
+
console.log(` ${pc.dim('channel: ')} ${result.channel}`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function runRuntimeVersion(): void {
|
|
49
|
+
const installed = readInstalledVersion();
|
|
50
|
+
if (!installed) {
|
|
51
|
+
console.log(pc.yellow('No agent-runtime installed.'));
|
|
52
|
+
console.log(`Run ${pc.cyan('shogo runtime install')} to download the latest.`);
|
|
53
|
+
process.exitCode = 1;
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
console.log(`${pc.bold('agent-runtime')} ${installed.version}`);
|
|
57
|
+
console.log(` ${pc.dim('target: ')} ${installed.target}`);
|
|
58
|
+
console.log(` ${pc.dim('channel: ')} ${installed.channel}`);
|
|
59
|
+
console.log(` ${pc.dim('installed at:')} ${installed.installedAt}`);
|
|
60
|
+
console.log(` ${pc.dim('source: ')} ${installed.source}`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function runRuntimeWhere(): void {
|
|
64
|
+
const resolved = resolveRuntime();
|
|
65
|
+
const paths = getRuntimePaths();
|
|
66
|
+
if (!resolved) {
|
|
67
|
+
console.log(pc.yellow('agent-runtime binary not found on this machine.'));
|
|
68
|
+
console.log();
|
|
69
|
+
console.log(pc.dim('Default install path:'), paths.runtimeBin);
|
|
70
|
+
console.log();
|
|
71
|
+
console.log(formatMissingRuntimeError());
|
|
72
|
+
process.exitCode = 1;
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
console.log(resolved.path);
|
|
76
|
+
if (process.env.SHOGO_DEBUG || process.env.VERBOSE) {
|
|
77
|
+
console.log(pc.dim(` (resolved via: ${resolved.source})`));
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
interface UpdateFlags {
|
|
82
|
+
channel?: Channel;
|
|
83
|
+
baseUrl?: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function runRuntimeUpdate(flags: UpdateFlags = {}): Promise<void> {
|
|
87
|
+
const installed = readInstalledVersion();
|
|
88
|
+
const targetChannel = flags.channel ?? installed?.channel ?? 'stable';
|
|
89
|
+
if (installed) {
|
|
90
|
+
console.log(
|
|
91
|
+
`${pc.dim('current:')} ${installed.version} (${installed.target}, ${installed.channel})`,
|
|
92
|
+
);
|
|
93
|
+
} else {
|
|
94
|
+
console.log(pc.dim('No existing install — installing fresh...'));
|
|
95
|
+
}
|
|
96
|
+
// installRuntime resolves the latest in-channel version on its own;
|
|
97
|
+
// pass force:true so we always reinstall when the user explicitly
|
|
98
|
+
// ran `update` (matches `npm update` / `brew upgrade` muscle memory).
|
|
99
|
+
await runRuntimeInstall({ channel: targetChannel, baseUrl: flags.baseUrl, force: true });
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function getDetectedTarget(): string {
|
|
103
|
+
return detectTarget();
|
|
104
|
+
}
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (C) 2026 Shogo Technologies, Inc.
|
|
3
|
+
/**
|
|
4
|
+
* `shogo worker start` — pair this machine with Shogo Cloud.
|
|
5
|
+
*
|
|
6
|
+
* Two execution modes:
|
|
7
|
+
*
|
|
8
|
+
* --foreground Run the tunnel + runtime-manager in this process,
|
|
9
|
+
* log to stdout, exit on SIGINT/SIGTERM. This is the
|
|
10
|
+
* shape used inside the spawned detached child below
|
|
11
|
+
* (so we never duplicate the wire-up code) and the
|
|
12
|
+
* shape `shogo runtime install && shogo worker start --foreground`
|
|
13
|
+
* uses for CI / `systemd --user` setups.
|
|
14
|
+
*
|
|
15
|
+
* (default) Detach: re-spawn `shogo worker start --foreground`
|
|
16
|
+
* as a background process via `spawnWorker`, write
|
|
17
|
+
* the pid file, return immediately. The PID file is
|
|
18
|
+
* what `shogo worker stop / status / logs` poll.
|
|
19
|
+
*
|
|
20
|
+
* Foreground path responsibilities (in order):
|
|
21
|
+
* 1. Resolve config (api key, cloud url, name, worker dir).
|
|
22
|
+
* 2. Apply HTTPS_PROXY into the process env so outbound fetch picks
|
|
23
|
+
* it up via Node/Bun's automatic dispatcher.
|
|
24
|
+
* 3. Locate the AGPL agent-runtime binary on disk; abort with a
|
|
25
|
+
* friendly install hint if missing.
|
|
26
|
+
* 4. Optional --debug preflight (proxy reachability + cloud ping).
|
|
27
|
+
* 5. Construct WorkerRuntimeManager with cloud-routed default spawn
|
|
28
|
+
* config (every per-project runtime gets cloudUrl + apiKey).
|
|
29
|
+
* 6. Construct WorkerTunnel with the runtime manager as its resolver.
|
|
30
|
+
* 7. Install signal handlers (SIGINT / SIGTERM / SIGHUP) that stop
|
|
31
|
+
* the tunnel, stop all per-project runtimes, then exit.
|
|
32
|
+
* 8. Wait forever; the cloud drives traffic in via the tunnel WS.
|
|
33
|
+
*/
|
|
34
|
+
import pc from 'picocolors';
|
|
35
|
+
import { existsSync } from 'node:fs';
|
|
36
|
+
import { resolveConfig } from '../lib/config.ts';
|
|
37
|
+
import { spawnWorker } from '../lib/process-manager.ts';
|
|
38
|
+
import { makeChecks, runPreflight } from '../lib/preflight.ts';
|
|
39
|
+
import { resolveProxy, applyProxyToEnv } from '../lib/transport.ts';
|
|
40
|
+
import { resolveRuntime, formatMissingRuntimeError } from '../lib/runtime-resolver.ts';
|
|
41
|
+
import { WorkerRuntimeManager, type ProjectSpawnConfig } from '../lib/runtime-manager.ts';
|
|
42
|
+
import { WorkerTunnel } from '../lib/tunnel.ts';
|
|
43
|
+
|
|
44
|
+
export interface StartFlags {
|
|
45
|
+
name?: string;
|
|
46
|
+
workerDir?: string;
|
|
47
|
+
apiKey?: string;
|
|
48
|
+
cloudUrl?: string;
|
|
49
|
+
port?: string;
|
|
50
|
+
proxy?: string;
|
|
51
|
+
project?: string;
|
|
52
|
+
runtimeBin?: string;
|
|
53
|
+
debug?: boolean;
|
|
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;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function runStart(flags: StartFlags): Promise<void> {
|
|
68
|
+
const cfg = resolveConfig({
|
|
69
|
+
name: flags.name,
|
|
70
|
+
workerDir: flags.workerDir,
|
|
71
|
+
apiKey: flags.apiKey,
|
|
72
|
+
cloudUrl: flags.cloudUrl,
|
|
73
|
+
port: flags.port ? parseInt(flags.port, 10) : undefined,
|
|
74
|
+
projectsDir: flags.projectsDir,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const proxy = resolveProxy(flags.proxy);
|
|
78
|
+
|
|
79
|
+
if (!flags.foreground) {
|
|
80
|
+
// Detached default — re-launch this same CLI with --foreground in
|
|
81
|
+
// a child process so the user gets their shell back. The actual
|
|
82
|
+
// tunnel + runtime-manager work happens in `runStartForeground()`
|
|
83
|
+
// below in the spawned process.
|
|
84
|
+
return runDetached({ cfg, proxy, flags });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Foreground path: surface a friendly missing-binary error BEFORE
|
|
88
|
+
// we open the tunnel — the binary is always required and it's a
|
|
89
|
+
// much better failure mode to tell the user up front than to fail
|
|
90
|
+
// on the first inbound /agent/* request.
|
|
91
|
+
const resolved = resolveRuntime({ flag: flags.runtimeBin });
|
|
92
|
+
if (!resolved) {
|
|
93
|
+
console.error(pc.red(formatMissingRuntimeError({ flag: flags.runtimeBin })));
|
|
94
|
+
process.exit(1);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
applyProxyToEnv(process.env, proxy);
|
|
98
|
+
|
|
99
|
+
if (flags.debug) {
|
|
100
|
+
const ok = await runPreflight(
|
|
101
|
+
makeChecks({
|
|
102
|
+
cloudUrl: cfg.cloudUrl,
|
|
103
|
+
apiKey: cfg.apiKey,
|
|
104
|
+
workerDir: cfg.workerDir,
|
|
105
|
+
proxy,
|
|
106
|
+
}),
|
|
107
|
+
);
|
|
108
|
+
if (!ok) process.exit(1);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const autoPullEnabled = !flags.noAutoPull;
|
|
112
|
+
const useGit = !flags.noGit;
|
|
113
|
+
|
|
114
|
+
console.log(pc.bold('\nShogo Worker — Starting'));
|
|
115
|
+
console.log(pc.dim(' name ') + cfg.name);
|
|
116
|
+
console.log(pc.dim(' worker-dir ') + cfg.workerDir);
|
|
117
|
+
console.log(pc.dim(' cloud ') + cfg.cloudUrl);
|
|
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')));
|
|
121
|
+
if (flags.project) console.log(pc.dim(' project ') + flags.project);
|
|
122
|
+
if (proxy) {
|
|
123
|
+
console.log(pc.dim(' proxy ') + `${proxy.url} ${pc.dim(`(from ${proxy.source})`)}`);
|
|
124
|
+
}
|
|
125
|
+
console.log('');
|
|
126
|
+
|
|
127
|
+
// Derive the AI proxy URL from the cloud URL so the agent-runtime
|
|
128
|
+
// routes LLM calls through Shogo Cloud's proxy instead of requiring
|
|
129
|
+
// direct API keys on the machine. The proxy endpoint lives at
|
|
130
|
+
// <cloudUrl>/api/ai/v1 — the same base the desktop app uses.
|
|
131
|
+
const aiProxyUrl = `${cfg.cloudUrl.replace(/\/+$/, '')}/api/ai/v1`;
|
|
132
|
+
|
|
133
|
+
const defaultSpawnConfig: ProjectSpawnConfig = {
|
|
134
|
+
cloudUrl: cfg.cloudUrl,
|
|
135
|
+
apiKey: cfg.apiKey,
|
|
136
|
+
aiProxyUrl,
|
|
137
|
+
aiProxyToken: cfg.apiKey,
|
|
138
|
+
// No projectDir up front — the runtime manager's `maybeAutoPull`
|
|
139
|
+
// sets PROJECT_DIR per-project to <projectsDir>/<projectId>/ once
|
|
140
|
+
// the clone completes. CWD defaults to that same directory.
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const runtimeManager = new WorkerRuntimeManager({
|
|
144
|
+
runtimeBin: flags.runtimeBin,
|
|
145
|
+
defaultSpawnConfig,
|
|
146
|
+
autoPull: {
|
|
147
|
+
enabled: autoPullEnabled,
|
|
148
|
+
projectsDir: cfg.projectsDir,
|
|
149
|
+
watch: true,
|
|
150
|
+
useGit,
|
|
151
|
+
},
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// Eagerly resolve so the cached `resolved` is reused — also exits early
|
|
155
|
+
// if a race deleted the binary between the check above and now.
|
|
156
|
+
if (!runtimeManager.resolveBinary()) {
|
|
157
|
+
console.error(pc.red(formatMissingRuntimeError({ flag: flags.runtimeBin })));
|
|
158
|
+
process.exit(1);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const tunnel = new WorkerTunnel({
|
|
162
|
+
apiKey: cfg.apiKey,
|
|
163
|
+
cloudUrl: cfg.cloudUrl,
|
|
164
|
+
name: cfg.name,
|
|
165
|
+
kind: 'cli-worker',
|
|
166
|
+
resolver: runtimeManager,
|
|
167
|
+
onAuthRevoked: (reason) => {
|
|
168
|
+
console.error(pc.red(`✗ Cloud auth revoked: ${reason}`));
|
|
169
|
+
console.error(pc.dim(` Run \`shogo login\` to re-authenticate; this worker will keep polling at the auth-failure backoff until then.`));
|
|
170
|
+
},
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
let shuttingDown = false;
|
|
174
|
+
const shutdown = async (signal: NodeJS.Signals) => {
|
|
175
|
+
if (shuttingDown) return;
|
|
176
|
+
shuttingDown = true;
|
|
177
|
+
console.log(pc.dim(`\nReceived ${signal} — shutting down...`));
|
|
178
|
+
try { tunnel.stop(); } catch { /* already stopped */ }
|
|
179
|
+
try { await runtimeManager.stopAll(); } catch (err: any) {
|
|
180
|
+
console.warn(pc.yellow(`stopAll: ${err?.message ?? err}`));
|
|
181
|
+
}
|
|
182
|
+
process.exit(0);
|
|
183
|
+
};
|
|
184
|
+
process.once('SIGINT', () => void shutdown('SIGINT'));
|
|
185
|
+
process.once('SIGTERM', () => void shutdown('SIGTERM'));
|
|
186
|
+
process.once('SIGHUP', () => void shutdown('SIGHUP'));
|
|
187
|
+
|
|
188
|
+
tunnel.start();
|
|
189
|
+
console.log(pc.green('✓ Worker running. Ctrl-C to stop.'));
|
|
190
|
+
|
|
191
|
+
// Pin the foreground process. The shutdown handler above will
|
|
192
|
+
// process.exit() when the user terminates.
|
|
193
|
+
await new Promise<void>(() => { /* never resolves */ });
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
interface DetachedOpts {
|
|
197
|
+
cfg: { apiKey: string; cloudUrl: string; name: string; workerDir: string; port: number };
|
|
198
|
+
proxy: ReturnType<typeof resolveProxy>;
|
|
199
|
+
flags: StartFlags;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Detach implementation: figure out the right argv to re-invoke this
|
|
204
|
+
* CLI with `worker start --foreground`, spawn it via `spawnWorker`
|
|
205
|
+
* (which writes the PID file + redirects stdio to ~/.shogo/logs/),
|
|
206
|
+
* then exit. The child becomes the long-running worker.
|
|
207
|
+
*/
|
|
208
|
+
function runDetached({ cfg, proxy, flags }: DetachedOpts): void {
|
|
209
|
+
const { entry, runner } = resolveSelfEntry();
|
|
210
|
+
|
|
211
|
+
const env: NodeJS.ProcessEnv = applyProxyToEnv(
|
|
212
|
+
{
|
|
213
|
+
...process.env,
|
|
214
|
+
SHOGO_API_KEY: cfg.apiKey,
|
|
215
|
+
SHOGO_CLOUD_URL: cfg.cloudUrl,
|
|
216
|
+
SHOGO_INSTANCE_NAME: cfg.name,
|
|
217
|
+
SHOGO_WORKER_DIR: cfg.workerDir,
|
|
218
|
+
SHOGO_LOCAL_MODE: 'true',
|
|
219
|
+
PORT: String(cfg.port),
|
|
220
|
+
},
|
|
221
|
+
proxy,
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
// We pass `--foreground` to the spawned child so it takes the
|
|
225
|
+
// foreground branch above. Anything else the user passed is also
|
|
226
|
+
// forwarded so e.g. `--project <id>` survives detachment.
|
|
227
|
+
const argv = buildChildArgv(flags);
|
|
228
|
+
|
|
229
|
+
const { pid } = spawnWorker({
|
|
230
|
+
entry,
|
|
231
|
+
runner,
|
|
232
|
+
env: { ...env, SHOGO_DETACHED_ARGS: argv.join(' ') },
|
|
233
|
+
cwd: cfg.workerDir,
|
|
234
|
+
detach: true,
|
|
235
|
+
inheritStdio: false,
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
console.log(pc.bold('\nShogo Worker — Started'));
|
|
239
|
+
console.log(pc.dim(' pid: ') + pid);
|
|
240
|
+
console.log(pc.dim(' name: ') + cfg.name);
|
|
241
|
+
console.log(pc.dim(' logs: ') + '~/.shogo/logs/worker.log');
|
|
242
|
+
console.log(pc.dim(' stop: ') + 'shogo worker stop');
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function buildChildArgv(flags: StartFlags): string[] {
|
|
246
|
+
const out: string[] = ['worker', 'start', '--foreground'];
|
|
247
|
+
if (flags.name) out.push('--name', flags.name);
|
|
248
|
+
if (flags.workerDir) out.push('--worker-dir', flags.workerDir);
|
|
249
|
+
if (flags.apiKey) out.push('--api-key', flags.apiKey);
|
|
250
|
+
if (flags.cloudUrl) out.push('--cloud-url', flags.cloudUrl);
|
|
251
|
+
if (flags.port) out.push('--port', flags.port);
|
|
252
|
+
if (flags.proxy) out.push('--proxy', flags.proxy);
|
|
253
|
+
if (flags.project) out.push('--project', flags.project);
|
|
254
|
+
if (flags.runtimeBin) out.push('--runtime-bin', flags.runtimeBin);
|
|
255
|
+
if (flags.debug) out.push('--debug');
|
|
256
|
+
if (flags.noAutoPull) out.push('--no-auto-pull');
|
|
257
|
+
if (flags.projectsDir) out.push('--projects-dir', flags.projectsDir);
|
|
258
|
+
if (flags.noGit) out.push('--no-git');
|
|
259
|
+
return out;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Find the entry point + runner the detached child should use.
|
|
264
|
+
*
|
|
265
|
+
* Priority:
|
|
266
|
+
* 1. The currently-executing argv[1] if it's an existing file (eg.
|
|
267
|
+
* a globally installed `shogo` bin or `bun src/cli.ts` in the
|
|
268
|
+
* monorepo).
|
|
269
|
+
* 2. The compiled binary at /usr/local/bin/shogo on PATH (best-effort).
|
|
270
|
+
* 3. The bin shim shipped with this package.
|
|
271
|
+
*/
|
|
272
|
+
function resolveSelfEntry(): { entry: string; runner: 'bun' | 'node' | 'tsx' } {
|
|
273
|
+
// process.execPath is the bun/node binary; argv[1] is the script.
|
|
274
|
+
const execPath = process.execPath;
|
|
275
|
+
const isBun = /\bbun(?:-[^/\\]*)?$/.test(execPath) || typeof (globalThis as any).Bun !== 'undefined';
|
|
276
|
+
const argvScript = process.argv[1];
|
|
277
|
+
if (argvScript && existsSync(argvScript)) {
|
|
278
|
+
// When spawned via tsx (from the bin shim), process.execPath is still
|
|
279
|
+
// `node` because tsx runs on top of Node. Detect a .ts entry and route
|
|
280
|
+
// through tsx so the detached child can handle TypeScript natively.
|
|
281
|
+
const isTs = /\.ts$/.test(argvScript);
|
|
282
|
+
const runner = isBun ? 'bun' : (isTs ? 'tsx' : 'node');
|
|
283
|
+
return { entry: argvScript, runner };
|
|
284
|
+
}
|
|
285
|
+
// Fallback: the compiled bin shim shipped with the package.
|
|
286
|
+
// Resolved relative to this file via import.meta.url so it works
|
|
287
|
+
// both in monorepo and when published.
|
|
288
|
+
const shim = new URL('../../bin/shogo.mjs', import.meta.url).pathname;
|
|
289
|
+
return { entry: shim, runner: isBun ? 'bun' : 'node' };
|
|
290
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (C) 2026 Shogo Technologies, Inc.
|
|
3
|
+
import pc from 'picocolors';
|
|
4
|
+
import { readPid, isRunning } from '../lib/process-manager.ts';
|
|
5
|
+
import { loadConfig } from '../lib/config.ts';
|
|
6
|
+
|
|
7
|
+
export async function runStatus(): Promise<void> {
|
|
8
|
+
const pid = readPid();
|
|
9
|
+
if (!pid) {
|
|
10
|
+
console.log(pc.yellow('● stopped') + pc.dim(' (no pid file)'));
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
if (!isRunning(pid)) {
|
|
14
|
+
console.log(pc.red('● dead') + pc.dim(` (stale pid ${pid})`));
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
const cfg = loadConfig();
|
|
18
|
+
console.log(pc.green('● running') + pc.dim(` (pid ${pid})`));
|
|
19
|
+
if (cfg.name) console.log(pc.dim(` name: `) + cfg.name);
|
|
20
|
+
if (cfg.cloudUrl) console.log(pc.dim(` cloud: `) + cfg.cloudUrl);
|
|
21
|
+
if (cfg.workerDir) console.log(pc.dim(` dir: `) + cfg.workerDir);
|
|
22
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (C) 2026 Shogo Technologies, Inc.
|
|
3
|
+
import pc from 'picocolors';
|
|
4
|
+
import { stopWorker } from '../lib/process-manager.ts';
|
|
5
|
+
|
|
6
|
+
export async function runStop(): Promise<void> {
|
|
7
|
+
const { killedPid } = stopWorker('SIGTERM');
|
|
8
|
+
if (killedPid === null) {
|
|
9
|
+
console.log(pc.dim('No worker running.'));
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
console.log(pc.green(`✓ Worker stopped (pid=${killedPid}).`));
|
|
13
|
+
}
|