@shogo-ai/worker 1.7.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,371 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Copyright (C) 2026 Shogo Technologies, Inc.
3
+ /**
4
+ * Download + verify + install the AGPL agent-runtime binary into
5
+ * ~/.shogo/runtime/. Used by `shogo runtime install` and
6
+ * `shogo runtime update`.
7
+ *
8
+ * License boundary: this module fetches a *separate* AGPL binary from
9
+ * a release server and lays it on disk. The MIT worker never imports,
10
+ * links, or embeds the runtime — it spawns this on-disk binary as a
11
+ * separate OS process. See packages/shogo-worker/README.md.
12
+ *
13
+ * Source layout the workflow `.github/workflows/publish-agent-runtime.yml`
14
+ * produces. Runtime tarballs ride the same `v*` tag as the rest of the
15
+ * app (desktop, worker, sdk all share one version):
16
+ * <baseUrl>/v<version>/shogo-agent-runtime-<target>.tar.gz
17
+ * <baseUrl>/v<version>/shogo-agent-runtime-<target>.tar.gz.sha256
18
+ *
19
+ * Each tarball contains:
20
+ * ./agent-runtime (executable, single self-contained `bun build --compile`)
21
+ * ./VERSION (key=value: version, target, built_at, source, license)
22
+ *
23
+ * We extract via the system `tar` binary so we don't add a streaming
24
+ * tar dep just for one-shot installs. macOS, Linux, and Windows
25
+ * (since 1803) all ship a usable `tar` on PATH.
26
+ */
27
+ import { spawn } from 'node:child_process';
28
+ import { createHash } from 'node:crypto';
29
+ import {
30
+ chmodSync,
31
+ createWriteStream,
32
+ existsSync,
33
+ mkdirSync,
34
+ readFileSync,
35
+ renameSync,
36
+ rmSync,
37
+ writeFileSync,
38
+ } from 'node:fs';
39
+ import { tmpdir } from 'node:os';
40
+ import { dirname, join } from 'node:path';
41
+ import { Readable } from 'node:stream';
42
+ import { pipeline } from 'node:stream/promises';
43
+ import {
44
+ RUNTIME_BIN,
45
+ RUNTIME_DIR,
46
+ RUNTIME_VERSION_FILE,
47
+ ensureRuntimeDir,
48
+ } from './paths.ts';
49
+
50
+ export type Channel = 'stable' | 'beta' | 'nightly';
51
+
52
+ /**
53
+ * Default release base URL. The publish workflow appends agent-runtime
54
+ * tarballs to GitHub Releases attached to the app's `v*` tags on the
55
+ * canonical repo (same release that ships the desktop installers).
56
+ * Users with a self-hosted CDN (e.g. releases.shogo.ai) can override
57
+ * via `--base-url` on `shogo runtime install` or
58
+ * `SHOGO_RUNTIME_RELEASES_URL`.
59
+ *
60
+ * Layout assumed by `buildAssetUrls()`:
61
+ * ${baseUrl}/v${version}/${assetName}
62
+ */
63
+ export const DEFAULT_RELEASES_BASE_URL = 'https://github.com/shogo-ai/shogo/releases/download';
64
+
65
+ export interface InstallOptions {
66
+ /** Specific version to install (e.g. "0.1.0"). Default: latest in channel. */
67
+ version?: string;
68
+ /** Channel to read latest from. Default: 'stable'. */
69
+ channel?: Channel;
70
+ /** Override release base URL. */
71
+ baseUrl?: string;
72
+ /** Override target slug (e.g. for testing). Default: detected from process. */
73
+ target?: string;
74
+ /** Reinstall even if the same version is already on disk. */
75
+ force?: boolean;
76
+ /** Logger. Defaults to console. */
77
+ logger?: Pick<Console, 'log' | 'warn' | 'error'>;
78
+ }
79
+
80
+ export interface InstallResult {
81
+ version: string;
82
+ target: string;
83
+ binPath: string;
84
+ source: string; // resolved tarball URL
85
+ sha256: string;
86
+ channel: Channel;
87
+ }
88
+
89
+ export interface InstalledVersion {
90
+ version: string;
91
+ target: string;
92
+ installedAt: string;
93
+ channel: Channel;
94
+ source: string;
95
+ sha256: string;
96
+ }
97
+
98
+ /**
99
+ * Detect the install target slug for the current host.
100
+ * Mirrors `bun build --target=bun-${target}` slugs used in CI.
101
+ */
102
+ export function detectTarget(): string {
103
+ const platform = process.platform;
104
+ const arch = process.arch;
105
+ let os: string;
106
+ if (platform === 'darwin') os = 'darwin';
107
+ else if (platform === 'linux') os = 'linux';
108
+ else if (platform === 'win32') os = 'windows';
109
+ else throw new Error(`Unsupported platform: ${platform}`);
110
+
111
+ let cpu: string;
112
+ if (arch === 'arm64') cpu = 'arm64';
113
+ else if (arch === 'x64') cpu = 'x64';
114
+ else throw new Error(`Unsupported arch: ${arch} (need arm64 or x64)`);
115
+
116
+ return `${os}-${cpu}`;
117
+ }
118
+
119
+ /**
120
+ * Read the metadata file for the currently installed runtime, if any.
121
+ */
122
+ export function readInstalledVersion(): InstalledVersion | null {
123
+ if (!existsSync(RUNTIME_VERSION_FILE)) return null;
124
+ try {
125
+ const raw = readFileSync(RUNTIME_VERSION_FILE, 'utf-8');
126
+ return JSON.parse(raw) as InstalledVersion;
127
+ } catch {
128
+ return null;
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Resolve the latest version for a channel.
134
+ *
135
+ * Strategy: query GitHub's `/releases/latest` redirect via a HEAD
136
+ * request and parse the tag from the `Location` header. This avoids
137
+ * needing a GitHub token for unauthenticated rate-limited use.
138
+ *
139
+ * For non-stable channels we walk `/releases` (v1 API) and pick the
140
+ * newest matching prerelease tag. Channel mapping:
141
+ * stable → latest non-prerelease
142
+ * beta → latest prerelease where tag matches `*-beta.*`
143
+ * nightly → latest prerelease where tag matches `*-nightly.*`
144
+ */
145
+ export async function resolveLatestVersion(
146
+ channel: Channel,
147
+ baseUrl: string,
148
+ ): Promise<string> {
149
+ // baseUrl is `https://github.com/<owner>/<repo>/releases/download`.
150
+ // Derive the owner/repo from it for the API calls.
151
+ const m = baseUrl.match(/^https:\/\/github\.com\/([^/]+)\/([^/]+)\/releases\/download/);
152
+ if (!m) {
153
+ throw new Error(
154
+ `Cannot auto-resolve latest version from non-GitHub baseUrl '${baseUrl}'. ` +
155
+ `Pass --version explicitly.`,
156
+ );
157
+ }
158
+ const owner = m[1];
159
+ const repo = m[2];
160
+
161
+ if (channel === 'stable') {
162
+ const url = `https://api.github.com/repos/${owner}/${repo}/releases/latest`;
163
+ const resp = await fetch(url, { headers: { Accept: 'application/vnd.github+json' } });
164
+ if (!resp.ok) {
165
+ throw new Error(`GitHub API ${resp.status} for ${url}`);
166
+ }
167
+ const data = (await resp.json()) as { tag_name?: string };
168
+ if (!data.tag_name) throw new Error('GitHub API did not return tag_name');
169
+ return tagToVersion(data.tag_name);
170
+ }
171
+
172
+ // For prerelease channels, walk page 1 of /releases. The runtime now
173
+ // ships on the same `v*` tag as the rest of the app, so we filter by
174
+ // a strict `vX.Y.Z-` prefix to avoid accidentally matching legacy
175
+ // `runtime-v*` tags or unrelated prerelease tag schemes.
176
+ const url = `https://api.github.com/repos/${owner}/${repo}/releases?per_page=30`;
177
+ const resp = await fetch(url, { headers: { Accept: 'application/vnd.github+json' } });
178
+ if (!resp.ok) throw new Error(`GitHub API ${resp.status} for ${url}`);
179
+ const releases = (await resp.json()) as { tag_name: string; prerelease: boolean }[];
180
+ const wanted = channel === 'beta' ? '-beta' : '-nightly';
181
+ const match = releases.find(
182
+ (r) => r.prerelease && /^v\d+\.\d+\.\d+-/.test(r.tag_name) && r.tag_name.includes(wanted),
183
+ );
184
+ if (!match) throw new Error(`No ${channel} runtime release found`);
185
+ return tagToVersion(match.tag_name);
186
+ }
187
+
188
+ function tagToVersion(tag: string): string {
189
+ if (!/^v\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/.test(tag)) {
190
+ throw new Error(`Unexpected app tag '${tag}' (expected vX.Y.Z[-prerelease])`);
191
+ }
192
+ return tag.slice(1);
193
+ }
194
+
195
+ interface AssetUrls {
196
+ tarball: string;
197
+ sha256: string;
198
+ assetName: string;
199
+ }
200
+
201
+ function buildAssetUrls(version: string, target: string, baseUrl: string): AssetUrls {
202
+ const assetName = `shogo-agent-runtime-${target}.tar.gz`;
203
+ const tag = `v${version}`;
204
+ const tarball = `${baseUrl.replace(/\/$/, '')}/${tag}/${assetName}`;
205
+ return {
206
+ tarball,
207
+ sha256: `${tarball}.sha256`,
208
+ assetName,
209
+ };
210
+ }
211
+
212
+ /**
213
+ * Stream a URL into a local file. Throws on non-2xx.
214
+ */
215
+ async function downloadToFile(url: string, destPath: string): Promise<void> {
216
+ const resp = await fetch(url, { redirect: 'follow' });
217
+ if (!resp.ok) {
218
+ throw new Error(`Download failed: HTTP ${resp.status} for ${url}`);
219
+ }
220
+ if (!resp.body) throw new Error(`Download failed: empty body for ${url}`);
221
+ await pipeline(Readable.fromWeb(resp.body as any), createWriteStream(destPath));
222
+ }
223
+
224
+ async function fetchSha256(url: string): Promise<string> {
225
+ const resp = await fetch(url, { redirect: 'follow' });
226
+ if (!resp.ok) throw new Error(`SHA256 sidecar fetch failed: HTTP ${resp.status} for ${url}`);
227
+ const text = await resp.text();
228
+ // sha256sum format: `<hex> <filename>` — take the first whitespace-separated token.
229
+ const hex = text.trim().split(/\s+/)[0];
230
+ if (!/^[0-9a-f]{64}$/i.test(hex)) {
231
+ throw new Error(`SHA256 sidecar at ${url} did not contain a 64-char hex digest`);
232
+ }
233
+ return hex.toLowerCase();
234
+ }
235
+
236
+ function sha256OfFile(path: string): string {
237
+ const buf = readFileSync(path);
238
+ return createHash('sha256').update(buf).digest('hex');
239
+ }
240
+
241
+ async function extractTarGz(tarballPath: string, destDir: string): Promise<void> {
242
+ await new Promise<void>((resolve, reject) => {
243
+ const proc = spawn('tar', ['-xzf', tarballPath, '-C', destDir], {
244
+ stdio: ['ignore', 'pipe', 'pipe'],
245
+ });
246
+ let stderr = '';
247
+ proc.stderr.on('data', (d) => { stderr += d.toString(); });
248
+ proc.on('error', reject);
249
+ proc.on('exit', (code) => {
250
+ if (code === 0) resolve();
251
+ else reject(new Error(`tar exited ${code}: ${stderr.trim()}`));
252
+ });
253
+ });
254
+ }
255
+
256
+ /**
257
+ * Atomically swap an extracted runtime into place.
258
+ *
259
+ * Strategy: extract into a sibling staging dir, then `rename` the
260
+ * binary on top of the live one. POSIX `rename` is atomic on the same
261
+ * filesystem; if the binary is currently being executed by a running
262
+ * worker the kernel keeps the in-flight inode alive until it exits.
263
+ */
264
+ function installFromStaging(stagingBin: string, finalBin: string): void {
265
+ ensureRuntimeDir();
266
+ const dir = dirname(finalBin);
267
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
268
+ // rename across a tempfile suffix to keep the swap atomic even on
269
+ // filesystems where write-in-place is preferred.
270
+ const tmpDest = `${finalBin}.next`;
271
+ if (existsSync(tmpDest)) rmSync(tmpDest, { force: true });
272
+ renameSync(stagingBin, tmpDest);
273
+ // chmod here so the +x bit survives the rename even if the source
274
+ // tarball was created without it.
275
+ try {
276
+ if (process.platform !== 'win32') {
277
+ const { chmodSync } = require('node:fs') as typeof import('node:fs');
278
+ chmodSync(tmpDest, 0o755);
279
+ }
280
+ } catch { /* permissions best-effort */ }
281
+ if (existsSync(finalBin)) rmSync(finalBin, { force: true });
282
+ renameSync(tmpDest, finalBin);
283
+ }
284
+
285
+ export async function installRuntime(opts: InstallOptions = {}): Promise<InstallResult> {
286
+ const log = opts.logger ?? console;
287
+ const channel: Channel = opts.channel ?? 'stable';
288
+ const baseUrl = opts.baseUrl ?? process.env.SHOGO_RUNTIME_RELEASES_URL ?? DEFAULT_RELEASES_BASE_URL;
289
+ const target = opts.target ?? detectTarget();
290
+
291
+ let version = opts.version;
292
+ if (!version) {
293
+ log.log(`[runtime install] Resolving latest ${channel} version...`);
294
+ version = await resolveLatestVersion(channel, baseUrl);
295
+ log.log(`[runtime install] Latest ${channel} = ${version}`);
296
+ }
297
+
298
+ const installed = readInstalledVersion();
299
+ if (installed && installed.version === version && installed.target === target && !opts.force) {
300
+ log.log(`[runtime install] ${version} (${target}) already installed at ${RUNTIME_BIN} — pass --force to reinstall`);
301
+ return {
302
+ version,
303
+ target,
304
+ binPath: RUNTIME_BIN,
305
+ source: installed.source,
306
+ sha256: installed.sha256,
307
+ channel,
308
+ };
309
+ }
310
+
311
+ const urls = buildAssetUrls(version, target, baseUrl);
312
+ log.log(`[runtime install] Downloading ${urls.tarball}`);
313
+
314
+ const stagingRoot = join(tmpdir(), `shogo-runtime-install-${process.pid}-${Date.now()}`);
315
+ mkdirSync(stagingRoot, { recursive: true });
316
+ try {
317
+ const tarballPath = join(stagingRoot, urls.assetName);
318
+ await downloadToFile(urls.tarball, tarballPath);
319
+
320
+ log.log(`[runtime install] Verifying SHA-256...`);
321
+ const expected = await fetchSha256(urls.sha256);
322
+ const actual = sha256OfFile(tarballPath);
323
+ if (actual !== expected) {
324
+ throw new Error(
325
+ `SHA-256 mismatch for ${urls.assetName}\n expected: ${expected}\n actual: ${actual}`,
326
+ );
327
+ }
328
+
329
+ const extractDir = join(stagingRoot, 'extract');
330
+ mkdirSync(extractDir, { recursive: true });
331
+ await extractTarGz(tarballPath, extractDir);
332
+
333
+ const stagingBin = join(extractDir, 'agent-runtime');
334
+ if (!existsSync(stagingBin)) {
335
+ throw new Error(`Tarball ${urls.assetName} did not contain ./agent-runtime`);
336
+ }
337
+
338
+ installFromStaging(stagingBin, RUNTIME_BIN);
339
+
340
+ const versionRecord: InstalledVersion = {
341
+ version,
342
+ target,
343
+ installedAt: new Date().toISOString(),
344
+ channel,
345
+ source: urls.tarball,
346
+ sha256: actual,
347
+ };
348
+ writeFileSync(RUNTIME_VERSION_FILE, JSON.stringify(versionRecord, null, 2) + '\n', { mode: 0o600 });
349
+
350
+ log.log(`[runtime install] Installed agent-runtime ${version} (${target}) to ${RUNTIME_BIN}`);
351
+ return {
352
+ version,
353
+ target,
354
+ binPath: RUNTIME_BIN,
355
+ source: urls.tarball,
356
+ sha256: actual,
357
+ channel,
358
+ };
359
+ } finally {
360
+ try { rmSync(stagingRoot, { recursive: true, force: true }); } catch { /* best-effort */ }
361
+ }
362
+ }
363
+
364
+ /** Public for `shogo runtime where`. */
365
+ export function getRuntimePaths() {
366
+ return {
367
+ runtimeDir: RUNTIME_DIR,
368
+ runtimeBin: RUNTIME_BIN,
369
+ versionFile: RUNTIME_VERSION_FILE,
370
+ };
371
+ }