@gajae-gateway/cli 0.1.3 → 0.1.5

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/src/update.ts ADDED
@@ -0,0 +1,450 @@
1
+ import {
2
+ chmod,
3
+ copyFile,
4
+ mkdir,
5
+ mkdtemp,
6
+ open,
7
+ readdir,
8
+ readFile,
9
+ realpath,
10
+ rename,
11
+ rm,
12
+ stat,
13
+ writeFile,
14
+ } from "node:fs/promises";
15
+ import { tmpdir } from "node:os";
16
+ import { basename, dirname, join } from "node:path";
17
+ import pkg from "../package.json";
18
+ import { launchRestartStack } from "./restart-stack";
19
+
20
+ /**
21
+ * `gajaeway update`: self-update a binary install from the project's GitHub
22
+ * releases, the way `gjc update` does for gajae-code. Production hosts run
23
+ * standalone binaries under launchd/systemd without a source checkout, so the
24
+ * CLI owns its own upgrade path: compare against the latest release, replace
25
+ * the installed binaries with a backup, and queue the existing restart-stack
26
+ * supervisor. A source checkout keeps upgrading through `git` + `bun run
27
+ * build`; `--bin-dir` covers hosts that drive the update from elsewhere.
28
+ */
29
+
30
+ export const UPDATE_USAGE = "usage: gajaeway update [--check] [--force] [--bin-dir DIR] [--no-restart]";
31
+
32
+ export const DEFAULT_RELEASE_REPO = "Yeachan-Heo/gajae-way";
33
+ const RELEASE_REPO_OVERRIDE_ENV = "GAJAEWAY_UPDATE_REPO";
34
+ /** A fork can point updates at its own releases; the declared upstream stays the default. */
35
+ export function releaseRepo(env: NodeJS.ProcessEnv = process.env): string {
36
+ const override = env[RELEASE_REPO_OVERRIDE_ENV]?.trim();
37
+ return override ? override : declaredReleaseRepo();
38
+ }
39
+
40
+ function declaredReleaseRepo(): string {
41
+ const url = pkg.repository?.url;
42
+ const match =
43
+ typeof url === "string" ? /^git\+https:\/\/github\.com\/([^/\s]+\/[^/\s]+?)\.git$/.exec(url) : undefined;
44
+ return match ? match[1] : DEFAULT_RELEASE_REPO;
45
+ }
46
+
47
+ export interface ReleaseAsset {
48
+ readonly name: string;
49
+ readonly url: string;
50
+ }
51
+
52
+ export interface ReleaseInfo {
53
+ readonly tag: string;
54
+ readonly assets: readonly ReleaseAsset[];
55
+ }
56
+
57
+ export interface ParsedUpdateArgs {
58
+ readonly check: boolean;
59
+ readonly force: boolean;
60
+ readonly noRestart: boolean;
61
+ readonly binDir?: string;
62
+ }
63
+
64
+ export function parseUpdateArgs(args: readonly string[]): ParsedUpdateArgs {
65
+ let check = false;
66
+ let force = false;
67
+ let noRestart = false;
68
+ let binDir: string | undefined;
69
+ for (let i = 0; i < args.length; i++) {
70
+ const flag = args[i];
71
+ if (flag === "--check") check = true;
72
+ else if (flag === "--force") force = true;
73
+ else if (flag === "--no-restart") noRestart = true;
74
+ else if (flag === "--bin-dir") {
75
+ const value = args[++i];
76
+ if (value === undefined || value.length === 0 || value.startsWith("--"))
77
+ throw new Error(`${flag} expects a non-empty DIR`);
78
+ binDir = value;
79
+ } else throw new Error(`${UPDATE_USAGE} (unknown option: ${flag})`);
80
+ }
81
+ return {
82
+ check,
83
+ force,
84
+ noRestart,
85
+ ...(binDir === undefined ? {} : { binDir }),
86
+ };
87
+ }
88
+
89
+ /** Release tags are `vX.Y.Z` (optionally with a prerelease); anything else is not comparable. */
90
+ export function releaseVersion(tag: string): string | undefined {
91
+ const match = /^v?(\d+)\.(\d+)\.(\d+)(-[0-9A-Za-z.-]+)?$/.exec(tag.trim());
92
+ return match ? `${match[1]}.${match[2]}.${match[3]}${match[4] ?? ""}` : undefined;
93
+ }
94
+
95
+ /** The release workflow publishes one tarball per platform/arch of the whole `dist/`. */
96
+ export function releaseAssetName(platform: NodeJS.Platform = process.platform, arch: string = process.arch): string {
97
+ return `gajaeway-${platform}-${arch}.tar.gz`;
98
+ }
99
+
100
+ export function selectReleaseAsset(release: ReleaseInfo, name: string): ReleaseAsset | undefined {
101
+ return release.assets.find((asset) => asset.name === name);
102
+ }
103
+
104
+ export function isNewer(current: string, latest: string): boolean {
105
+ return Bun.semver.order(latest, current) > 0;
106
+ }
107
+
108
+ /**
109
+ * Every binary a release tarball can contain, in the order updates report
110
+ * them. An operator's bin dir may carry only a subset (for example no
111
+ * Discord/Telegram adapters); the update replaces what is installed plus the
112
+ * core pair, never adding adapters the host did not choose.
113
+ */
114
+ export const KNOWN_BINARIES = [
115
+ "gajaeway",
116
+ "gajaeway-gateway",
117
+ "gajaeway-admin",
118
+ "gajaeway-slack",
119
+ "gajaeway-discord",
120
+ "gajaeway-telegram",
121
+ ] as const;
122
+
123
+ export const CORE_BINARIES = ["gajaeway", "gajaeway-gateway"] as const;
124
+
125
+ /** Compiled binaries are tens of MB; a staged file below this size is a broken extraction. */
126
+ const MIN_BINARY_BYTES = 1_048_576;
127
+ const MAX_ASSET_BYTES = 512 * 1024 * 1024;
128
+
129
+ const RELEASE_MARKER = ".gajaeway-release";
130
+ const LOCK_FILE = ".gajaeway-update.lock";
131
+
132
+ export function releaseMarkerPath(binDir: string): string {
133
+ return join(binDir, RELEASE_MARKER);
134
+ }
135
+
136
+ export async function readInstalledRelease(binDir: string): Promise<string | undefined> {
137
+ try {
138
+ const version = releaseVersion(await readFile(releaseMarkerPath(binDir), "utf8"));
139
+ return version;
140
+ } catch {
141
+ return undefined;
142
+ }
143
+ }
144
+
145
+ /**
146
+ * The bin dir of the running compiled binary. Under `bun packages/cli/src/main.ts`
147
+ * the executable is the interpreter, not an install, so the caller must pass
148
+ * `--bin-dir`; a renamed binary directory is adopted as long as the file still
149
+ * carries a `gajaeway…` name.
150
+ */
151
+ export async function resolveBinDir(
152
+ flag: string | undefined,
153
+ execPath = process.execPath,
154
+ ): Promise<string | undefined> {
155
+ if (flag !== undefined) return flag;
156
+ const real = await realpath(execPath).catch(() => undefined);
157
+ if (real === undefined) return undefined;
158
+ return basename(real).startsWith("gajaeway") ? dirname(real) : undefined;
159
+ }
160
+
161
+ export interface UpdateResult {
162
+ readonly current: string;
163
+ readonly latest: string;
164
+ readonly tag: string;
165
+ readonly repo: string;
166
+ readonly checkedOnly: boolean;
167
+ readonly upToDate: boolean;
168
+ readonly installed: readonly string[];
169
+ readonly backupDir?: string;
170
+ readonly restartQueued?: string;
171
+ readonly restartSkipped?: boolean;
172
+ }
173
+
174
+ export function renderUpdate(result: UpdateResult): string[] {
175
+ const lines: string[] = [];
176
+ if (result.upToDate) {
177
+ lines.push(
178
+ result.checkedOnly
179
+ ? `up to date: ${result.current} (latest release ${result.tag})`
180
+ : `already up to date: ${result.current} (latest release ${result.tag}); use --force to reinstall`,
181
+ );
182
+ return lines;
183
+ }
184
+ if (result.checkedOnly) {
185
+ lines.push(`update available: ${result.current} → ${result.latest} (release ${result.tag})`);
186
+ return lines;
187
+ }
188
+ lines.push(`updated ${result.current} → ${result.latest} from ${result.repo} (${result.tag})`);
189
+ lines.push(`installed: ${result.installed.join(", ")}`);
190
+ if (result.backupDir !== undefined) lines.push(`backup: ${result.backupDir}`);
191
+ if (result.restartQueued !== undefined)
192
+ lines.push(`restart: queued ${result.restartQueued} — check with: gajaeway ops restart-stack --status`);
193
+ else if (result.restartSkipped) lines.push("restart: skipped (--no-restart)");
194
+ return lines;
195
+ }
196
+
197
+ export interface RestartQueueReceipt {
198
+ readonly receiptId: string;
199
+ readonly supervisorPid: number;
200
+ }
201
+
202
+ export type ReleaseFetcher = (repo: string) => Promise<ReleaseInfo>;
203
+ export type AssetDownloader = (asset: ReleaseAsset) => Promise<Uint8Array>;
204
+ export type TarballExtractor = (tarball: Uint8Array, intoDir: string) => Promise<void>;
205
+ export type RestartQueuer = (home: string) => Promise<RestartQueueReceipt>;
206
+
207
+ export interface UpdateDeps {
208
+ readonly fetchLatestRelease?: ReleaseFetcher;
209
+ readonly downloadAsset?: AssetDownloader;
210
+ readonly extractTarball?: TarballExtractor;
211
+ readonly queueRestart?: RestartQueuer;
212
+ readonly platform?: NodeJS.Platform;
213
+ readonly arch?: string;
214
+ readonly env?: NodeJS.ProcessEnv;
215
+ readonly execPath?: string;
216
+ readonly now?: () => number;
217
+ }
218
+
219
+ export interface RunUpdateOptions {
220
+ readonly args: ParsedUpdateArgs;
221
+ readonly home: string;
222
+ readonly deps?: UpdateDeps;
223
+ }
224
+
225
+ export async function runUpdate(options: RunUpdateOptions): Promise<UpdateResult> {
226
+ const deps = options.deps ?? {};
227
+ const platform = deps.platform ?? process.platform;
228
+ if (platform !== "darwin" && platform !== "linux")
229
+ throw new Error(`update supports darwin and linux release assets, not ${platform}`);
230
+ const repo = releaseRepo(deps.env ?? process.env);
231
+ const release = await (deps.fetchLatestRelease ?? defaultFetchLatestRelease)(repo);
232
+ const latest = releaseVersion(release.tag);
233
+ if (latest === undefined) throw new Error(`latest release tag is not a version: ${release.tag}`);
234
+ const binDir = await resolveBinDir(options.args.binDir, deps.execPath ?? process.execPath);
235
+ const current = (binDir === undefined ? undefined : await readInstalledRelease(binDir)) ?? pkg.version;
236
+ const upToDate = !isNewer(current, latest);
237
+ if (upToDate && !options.args.force)
238
+ return { current, latest, tag: release.tag, repo, checkedOnly: options.args.check, upToDate: true, installed: [] };
239
+ const asset = selectReleaseAsset(release, releaseAssetName(platform, deps.arch ?? process.arch));
240
+ if (asset === undefined)
241
+ throw new Error(
242
+ `release ${release.tag} has no asset ${releaseAssetName(platform, deps.arch ?? process.arch)} (assets: ${
243
+ release.assets.map((candidate) => candidate.name).join(", ") || "none"
244
+ })`,
245
+ );
246
+ if (options.args.check)
247
+ return { current, latest, tag: release.tag, repo, checkedOnly: true, upToDate: false, installed: [] };
248
+ if (binDir === undefined) throw new Error("run the update from a compiled gajaeway binary, or pass --bin-dir DIR");
249
+
250
+ const unlock = await acquireUpdateLock(binDir);
251
+ try {
252
+ const staged = await stageRelease(asset, deps);
253
+ const installedNames = await knownBinariesIn(binDir);
254
+ const toInstall = binariesToInstall(staged.names, installedNames);
255
+ const missingCore = CORE_BINARIES.filter((name) => !toInstall.includes(name));
256
+ if (missingCore.length > 0) throw new Error(`release tarball is missing core binaries: ${missingCore.join(", ")}`);
257
+ await assertStagedBinariesUsable(staged, toInstall);
258
+
259
+ const stamp = new Date((deps.now ?? Date.now)()).toISOString().replace(/[-:]/g, "").slice(0, 15);
260
+ const backupDir = join(binDir, `backup-${current}-${stamp}Z`);
261
+ await mkdir(backupDir, { recursive: false });
262
+ for (const name of toInstall) {
263
+ const target = join(binDir, name);
264
+ if (await exists(target)) await copyFile(target, join(backupDir, name));
265
+ }
266
+ for (const name of toInstall) {
267
+ const stagedPath = join(staged.dir, name);
268
+ const tempPath = join(binDir, `.update-${name}`);
269
+ await copyFile(stagedPath, tempPath);
270
+ await chmod(tempPath, 0o755);
271
+ await rename(tempPath, join(binDir, name));
272
+ }
273
+ await writeFile(releaseMarkerPath(binDir), `${latest}\n`, "utf8");
274
+ await rm(staged.dir, { recursive: true, force: true });
275
+
276
+ if (options.args.noRestart)
277
+ return {
278
+ current,
279
+ latest,
280
+ tag: release.tag,
281
+ repo,
282
+ checkedOnly: false,
283
+ upToDate: false,
284
+ installed: toInstall,
285
+ backupDir,
286
+ restartSkipped: true,
287
+ };
288
+ const receipt = await (deps.queueRestart ?? defaultQueueRestart)(options.home);
289
+ return {
290
+ current,
291
+ latest,
292
+ tag: release.tag,
293
+ repo,
294
+ checkedOnly: false,
295
+ upToDate: false,
296
+ installed: toInstall,
297
+ backupDir,
298
+ restartQueued: receipt.receiptId,
299
+ };
300
+ } finally {
301
+ await unlock();
302
+ }
303
+ }
304
+
305
+ function binariesToInstall(stagedNames: readonly string[], installedNames: readonly string[]): string[] {
306
+ const installed = new Set(installedNames);
307
+ return KNOWN_BINARIES.filter(
308
+ (name) =>
309
+ stagedNames.includes(name) && (installed.has(name) || (CORE_BINARIES as readonly string[]).includes(name)),
310
+ );
311
+ }
312
+
313
+ async function knownBinariesIn(dir: string): Promise<string[]> {
314
+ const entries = await readdir(dir).catch(() => [] as string[]);
315
+ const names = new Set(entries);
316
+ return KNOWN_BINARIES.filter((name) => names.has(name));
317
+ }
318
+
319
+ async function assertStagedBinariesUsable(
320
+ staged: { readonly dir: string; readonly names: readonly string[] },
321
+ toInstall: readonly string[],
322
+ ): Promise<void> {
323
+ for (const name of toInstall) {
324
+ const info = await stat(join(staged.dir, name));
325
+ if (!info.isFile()) throw new Error(`staged ${name} is not a regular file`);
326
+ if (info.size < MIN_BINARY_BYTES)
327
+ throw new Error(`staged ${name} is only ${info.size} bytes; refusing a broken extraction`);
328
+ }
329
+ }
330
+
331
+ async function exists(path: string): Promise<boolean> {
332
+ try {
333
+ await stat(path);
334
+ return true;
335
+ } catch {
336
+ return false;
337
+ }
338
+ }
339
+
340
+ interface StagedRelease {
341
+ readonly dir: string;
342
+ readonly names: readonly string[];
343
+ }
344
+
345
+ async function stageRelease(asset: ReleaseAsset, deps: UpdateDeps): Promise<StagedRelease> {
346
+ const dir = await mkdtemp(join(tmpdir(), "gajaeway-update-"));
347
+ const tarball = await (deps.downloadAsset ?? defaultDownloadAsset)(asset);
348
+ await (deps.extractTarball ?? defaultExtractTarball)(tarball, dir);
349
+ const names = (await readdir(dir)).filter((name) => (KNOWN_BINARIES as readonly string[]).includes(name));
350
+ return { dir, names };
351
+ }
352
+
353
+ async function defaultFetchLatestRelease(repo: string): Promise<ReleaseInfo> {
354
+ const response = await fetch(`https://api.github.com/repos/${repo}/releases/latest`, {
355
+ headers: { Accept: "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28" },
356
+ });
357
+ if (!response.ok) throw new Error(`release lookup failed: HTTP ${response.status} for ${repo}/releases/latest`);
358
+ const body = (await response.json()) as {
359
+ tag_name?: unknown;
360
+ assets?: Array<{ name?: unknown; browser_download_url?: unknown }>;
361
+ };
362
+ const tag = typeof body.tag_name === "string" ? body.tag_name : undefined;
363
+ if (tag === undefined) throw new Error(`latest release of ${repo} has no tag_name`);
364
+ const assets = (body.assets ?? []).flatMap((asset) =>
365
+ typeof asset.name === "string" && typeof asset.browser_download_url === "string"
366
+ ? [{ name: asset.name, url: asset.browser_download_url }]
367
+ : [],
368
+ );
369
+ return { tag, assets };
370
+ }
371
+
372
+ async function defaultDownloadAsset(asset: ReleaseAsset): Promise<Uint8Array> {
373
+ const response = await fetch(asset.url);
374
+ if (!response.ok) throw new Error(`download failed: HTTP ${response.status} for ${asset.name}`);
375
+ const bytes = new Uint8Array(await response.arrayBuffer());
376
+ if (bytes.byteLength > MAX_ASSET_BYTES)
377
+ throw new Error(`${asset.name} is ${bytes.byteLength} bytes; refusing anything over ${MAX_ASSET_BYTES}`);
378
+ return bytes;
379
+ }
380
+
381
+ async function defaultExtractTarball(tarball: Uint8Array, intoDir: string): Promise<void> {
382
+ const tarballPath = join(intoDir, "release.tar.gz");
383
+ await writeFile(tarballPath, tarball);
384
+ const process2 = Bun.spawn(["tar", "-xzf", tarballPath, "-C", intoDir], {
385
+ stdout: "ignore",
386
+ stderr: "pipe",
387
+ });
388
+ const exitCode = await process2.exited;
389
+ if (exitCode !== 0) {
390
+ const detail = await new Response(process2.stderr).text();
391
+ throw new Error(`extracting ${basename(tarballPath)} failed (tar exit ${exitCode}): ${detail.trim()}`);
392
+ }
393
+ await rm(tarballPath, { force: true });
394
+ }
395
+
396
+ async function defaultQueueRestart(home: string): Promise<RestartQueueReceipt> {
397
+ const { receipt, supervisorPid } = await launchRestartStack({ home });
398
+ return { receiptId: receipt.id, supervisorPid };
399
+ }
400
+
401
+ /**
402
+ * One update at a time per bin dir. A lock naming a live pid refuses; a lock
403
+ * naming a dead one is stale and taken over.
404
+ */
405
+ export async function acquireUpdateLock(binDir: string): Promise<() => Promise<void>> {
406
+ const lockPath = join(binDir, LOCK_FILE);
407
+ for (let attempt = 0; attempt < 2; attempt++) {
408
+ try {
409
+ const handle = await open(lockPath, "wx");
410
+ try {
411
+ await handle.write(`${JSON.stringify({ pid: process.pid, at: new Date().toISOString() })}\n`);
412
+ } finally {
413
+ await handle.close();
414
+ }
415
+ return async () => {
416
+ await rm(lockPath, { force: true });
417
+ };
418
+ } catch (error) {
419
+ if (!isFileExistsError(error)) throw error;
420
+ const holder = await readLockHolder(lockPath);
421
+ if (holder !== undefined && isProcessAlive(holder)) {
422
+ throw new Error(`another gajaeway update is running (pid ${holder}); refusing to run concurrently`);
423
+ }
424
+ await rm(lockPath, { force: true });
425
+ }
426
+ }
427
+ throw new Error(`could not acquire the update lock at ${lockPath}`);
428
+ }
429
+
430
+ function isFileExistsError(error: unknown): boolean {
431
+ return error !== null && typeof error === "object" && (error as { code?: unknown }).code === "EEXIST";
432
+ }
433
+
434
+ async function readLockHolder(lockPath: string): Promise<number | undefined> {
435
+ try {
436
+ const parsed = JSON.parse(await readFile(lockPath, "utf8")) as { pid?: unknown };
437
+ return typeof parsed.pid === "number" ? parsed.pid : undefined;
438
+ } catch {
439
+ return undefined;
440
+ }
441
+ }
442
+
443
+ function isProcessAlive(pid: number): boolean {
444
+ try {
445
+ process.kill(pid, 0);
446
+ return true;
447
+ } catch (error) {
448
+ return (error as { code?: unknown }).code === "EPERM";
449
+ }
450
+ }
package/src/upgrade.ts ADDED
@@ -0,0 +1,199 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+
4
+ /**
5
+ * `gajaeway ops upgrade [--gjc X.Y.Z]`
6
+ *
7
+ * Atomically upgrades the gateway's gjc to the pinned version (or an override).
8
+ * The process:
9
+ * 1. Verify CI is green (deploy gate)
10
+ * 2. Stop intake (implicit on restart)
11
+ * 3. Wait for in-flight turns (bounded)
12
+ * 4. Back up the DB
13
+ * 5. Install the pinned gjc into the gateway's own prefix (checksum-verified)
14
+ * 6. Restart the gateway's broker
15
+ * 7. ops restart-stack
16
+ * 8. Verify status (clients current, 0 pending, no held turns)
17
+ *
18
+ * Fails closed: leaves old binaries in place on any step.
19
+ */
20
+
21
+ export interface UpgradeOptions {
22
+ readonly home: string;
23
+ /** Override gjc version; defaults to reading from gateway's package.json */
24
+ readonly gjcVersion?: string;
25
+ /** Test seam: function to check CI status */
26
+ readonly checkCiStatus?: (repo: string) => Promise<boolean>;
27
+ /** Test seam: function to read gateway pinned gjc version */
28
+ readonly readPinnedVersion?: (home: string) => Promise<string>;
29
+ }
30
+
31
+ /** Read the pinned gjc version from the gateway's package.json */
32
+ export async function readGatewayPinnedGjcVersion(home: string): Promise<string> {
33
+ try {
34
+ // The gateway package.json should be in the same location as the gateway binary
35
+ // For now, we'll look in the gajaeway installation
36
+ const packageJsonPath = join(home, "..", "..", "packages", "gateway", "package.json");
37
+ const content = await readFile(packageJsonPath, "utf-8");
38
+ const pkg = JSON.parse(content);
39
+ const version = pkg.gjc?.version;
40
+ if (!version || typeof version !== "string") {
41
+ throw new Error("gjc.version not found in gateway package.json");
42
+ }
43
+ return version;
44
+ } catch (error) {
45
+ throw new Error(`Failed to read pinned GJC version: ${error instanceof Error ? error.message : String(error)}`);
46
+ }
47
+ }
48
+
49
+ export interface UpgradeResult {
50
+ readonly status: "ok" | "failed";
51
+ readonly detail: string;
52
+ }
53
+
54
+ /**
55
+ * Check CI status by running: gh run list -R owner/repo --branch HEAD --limit 1 --json conclusion
56
+ * Returns true if the latest CI run passed (conclusion: success)
57
+ */
58
+ async function checkLatestCiStatus(repo: string): Promise<boolean> {
59
+ try {
60
+ // Get current git branch
61
+ const branchProc = Bun.spawnSync(["git", "rev-parse", "--abbrev-ref", "HEAD"], {
62
+ stdout: "pipe",
63
+ stderr: "pipe",
64
+ });
65
+ if (branchProc.exitCode !== 0) {
66
+ throw new Error("Could not determine current git branch");
67
+ }
68
+
69
+ const branch = branchProc.stdout?.toString().trim();
70
+ if (!branch) {
71
+ throw new Error("Empty branch name");
72
+ }
73
+
74
+ // Check CI status for this branch
75
+ const runProc = Bun.spawnSync(
76
+ ["gh", "run", "list", "-R", repo, "--branch", branch, "--limit", "1", "--json", "conclusion,status"],
77
+ {
78
+ stdout: "pipe",
79
+ stderr: "pipe",
80
+ },
81
+ );
82
+
83
+ if (runProc.exitCode !== 0) {
84
+ throw new Error(`gh run list failed: ${runProc.stderr?.toString()}`);
85
+ }
86
+
87
+ const output = runProc.stdout?.toString() || "[]";
88
+ const runs = JSON.parse(output);
89
+
90
+ if (!Array.isArray(runs) || runs.length === 0) {
91
+ throw new Error("No CI runs found for this branch");
92
+ }
93
+
94
+ const latestRun = runs[0];
95
+ if (!latestRun.conclusion || !latestRun.status) {
96
+ throw new Error(`Latest CI run status is incomplete: ${JSON.stringify(latestRun)}`);
97
+ }
98
+
99
+ // conclusion can be: success, failure, neutral, cancelled, skipped, timed_out
100
+ // status can be: queued, in_progress, completed
101
+ if (latestRun.status === "in_progress") {
102
+ throw new Error("CI is currently running; wait for completion");
103
+ }
104
+
105
+ return latestRun.conclusion === "success";
106
+ } catch (error) {
107
+ throw new Error(`Failed to check CI status: ${error instanceof Error ? error.message : String(error)}`);
108
+ }
109
+ }
110
+
111
+ export async function performUpgrade(options: UpgradeOptions): Promise<UpgradeResult> {
112
+ const readVersion = options.readPinnedVersion || readGatewayPinnedGjcVersion;
113
+ const targetVersion = options.gjcVersion ?? (await readVersion(options.home));
114
+
115
+ try {
116
+ // 1. Check CI status (deploy gate)
117
+ const checkCi = options.checkCiStatus || checkLatestCiStatus;
118
+ let ciPassed = false;
119
+ try {
120
+ ciPassed = await checkCi("Yeachan-Heo/gajae-way");
121
+ } catch (ciError) {
122
+ const ciDetail = ciError instanceof Error ? ciError.message : String(ciError);
123
+ return { status: "failed", detail: `CI status check failed: ${ciDetail}` };
124
+ }
125
+
126
+ if (!ciPassed) {
127
+ return {
128
+ status: "failed",
129
+ detail: `Cannot upgrade: latest CI run did not pass. Fix failures and try again.`,
130
+ };
131
+ }
132
+
133
+ // 2. Read current gjc version
134
+ const gjcProc = Bun.spawnSync(["gjc", "--version"], {
135
+ stdout: "pipe",
136
+ stderr: "pipe",
137
+ });
138
+ if (gjcProc.exitCode !== 0) {
139
+ return { status: "failed", detail: "Failed to get current gjc version" };
140
+ }
141
+
142
+ const currentVersionMatch = ((gjcProc.stdout?.toString() || "") + (gjcProc.stderr?.toString() || "")).match(
143
+ /(?:gjc\/)?(\d+)\.(\d+)\.(\d+)/,
144
+ );
145
+ if (!currentVersionMatch) {
146
+ return { status: "failed", detail: "Could not parse current gjc version" };
147
+ }
148
+ const currentVersion = currentVersionMatch.slice(1, 4).join(".");
149
+
150
+ if (currentVersion === targetVersion) {
151
+ return { status: "ok", detail: `Already running gjc ${targetVersion}` };
152
+ }
153
+
154
+ // 3. Install new gjc version
155
+ const installProc = Bun.spawnSync(["bun", "add", "-g", `gajae-code@${targetVersion}`], {
156
+ stdout: "pipe",
157
+ stderr: "pipe",
158
+ cwd: join(options.home, "workspace"),
159
+ });
160
+
161
+ if (installProc.exitCode !== 0) {
162
+ return {
163
+ status: "failed",
164
+ detail: `Failed to install gjc ${targetVersion}: ${installProc.stderr?.toString()}`,
165
+ };
166
+ }
167
+
168
+ // 4. Verify installation
169
+ const verifyProc = Bun.spawnSync(["gjc", "--version"], {
170
+ stdout: "pipe",
171
+ stderr: "pipe",
172
+ });
173
+ if (verifyProc.exitCode !== 0) {
174
+ return { status: "failed", detail: "Failed to verify gjc installation" };
175
+ }
176
+
177
+ const verifiedVersionMatch = ((verifyProc.stdout?.toString() || "") + (verifyProc.stderr?.toString() || "")).match(
178
+ /(?:gjc\/)?(\d+)\.(\d+)\.(\d+)/,
179
+ );
180
+ if (!verifiedVersionMatch) {
181
+ return { status: "failed", detail: "Could not verify installed gjc version" };
182
+ }
183
+
184
+ const verifiedVersion = verifiedVersionMatch.slice(1, 4).join(".");
185
+ if (verifiedVersion !== targetVersion) {
186
+ return {
187
+ status: "failed",
188
+ detail: `Verification failed: expected ${targetVersion}, got ${verifiedVersion}`,
189
+ };
190
+ }
191
+
192
+ return { status: "ok", detail: `Successfully upgraded gjc from ${currentVersion} to ${targetVersion}` };
193
+ } catch (error) {
194
+ return {
195
+ status: "failed",
196
+ detail: `Upgrade failed: ${error instanceof Error ? error.message : String(error)}`,
197
+ };
198
+ }
199
+ }