@gajae-gateway/cli 0.1.8 → 0.2.1

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 CHANGED
@@ -3,7 +3,6 @@ import {
3
3
  copyFile,
4
4
  mkdir,
5
5
  mkdtemp,
6
- open,
7
6
  readdir,
8
7
  readFile,
9
8
  realpath,
@@ -14,8 +13,17 @@ import {
14
13
  } from "node:fs/promises";
15
14
  import { tmpdir } from "node:os";
16
15
  import { basename, dirname, join } from "node:path";
16
+ import { acquireLease, homeLayout, type Lease, LeaseHeldError } from "@gajae-gateway/home";
17
17
  import pkg from "../package.json";
18
+ import { type GjcInstallRunner, installPinnedGjc } from "./gjc-install";
18
19
  import { launchRestartStack } from "./restart-stack";
20
+ import {
21
+ type InstallServicesOptions,
22
+ installServices,
23
+ type ServicePlatform,
24
+ serviceSpecs,
25
+ systemdUnitName,
26
+ } from "./services";
19
27
 
20
28
  /**
21
29
  * `gajaeway update`: self-update a binary install from the project's GitHub
@@ -31,6 +39,8 @@ export const UPDATE_USAGE = "usage: gajaeway update [--check] [--force] [--bin-d
31
39
 
32
40
  export const DEFAULT_RELEASE_REPO = "Yeachan-Heo/gajae-way";
33
41
  const RELEASE_REPO_OVERRIDE_ENV = "GAJAEWAY_UPDATE_REPO";
42
+ /** The release manifest carrying the gjc pin; installed beside the binaries. */
43
+ const PIN_MANIFEST = "package.json";
34
44
  /** A fork can point updates at its own releases; the declared upstream stays the default. */
35
45
  export function releaseRepo(env: NodeJS.ProcessEnv = process.env): string {
36
46
  const override = env[RELEASE_REPO_OVERRIDE_ENV]?.trim();
@@ -127,7 +137,6 @@ const MIN_BINARY_BYTES = 1_048_576;
127
137
  const MAX_ASSET_BYTES = 512 * 1024 * 1024;
128
138
 
129
139
  const RELEASE_MARKER = ".gajaeway-release";
130
- const LOCK_FILE = ".gajaeway-update.lock";
131
140
 
132
141
  export function releaseMarkerPath(binDir: string): string {
133
142
  return join(binDir, RELEASE_MARKER);
@@ -168,6 +177,7 @@ export interface UpdateResult {
168
177
  readonly installed: readonly string[];
169
178
  readonly backupDir?: string;
170
179
  readonly restartQueued?: string;
180
+ readonly brokerNotEnabled?: boolean;
171
181
  readonly restartSkipped?: boolean;
172
182
  }
173
183
 
@@ -191,6 +201,7 @@ export function renderUpdate(result: UpdateResult): string[] {
191
201
  if (result.restartQueued !== undefined)
192
202
  lines.push(`restart: queued ${result.restartQueued} — check with: gajaeway ops restart-stack --status`);
193
203
  else if (result.restartSkipped) lines.push("restart: skipped (--no-restart)");
204
+ if (result.brokerNotEnabled) lines.push("broker unit installed, not enabled: run gajaeway setup --broker-agent");
194
205
  return lines;
195
206
  }
196
207
 
@@ -202,7 +213,13 @@ export interface RestartQueueReceipt {
202
213
  export type ReleaseFetcher = (repo: string) => Promise<ReleaseInfo>;
203
214
  export type AssetDownloader = (asset: ReleaseAsset) => Promise<Uint8Array>;
204
215
  export type TarballExtractor = (tarball: Uint8Array, intoDir: string) => Promise<void>;
205
- export type RestartQueuer = (home: string) => Promise<RestartQueueReceipt>;
216
+ export type RestartQueuer = (home: string, binDir: string, trigger: "update") => Promise<RestartQueueReceipt>;
217
+ export type UpdateCommandRunner = (command: readonly string[]) => Promise<number>;
218
+
219
+ type UpdateServiceOptions = Pick<
220
+ InstallServicesOptions,
221
+ "launchAgentsDir" | "unitDir" | "loginPathRunner" | "writeFile"
222
+ >;
206
223
 
207
224
  export interface UpdateDeps {
208
225
  readonly fetchLatestRelease?: ReleaseFetcher;
@@ -214,6 +231,9 @@ export interface UpdateDeps {
214
231
  readonly env?: NodeJS.ProcessEnv;
215
232
  readonly execPath?: string;
216
233
  readonly now?: () => number;
234
+ readonly gjcInstallRunner?: GjcInstallRunner;
235
+ readonly serviceRunner?: UpdateCommandRunner;
236
+ readonly serviceOptions?: UpdateServiceOptions;
217
237
  }
218
238
 
219
239
  export interface RunUpdateOptions {
@@ -222,6 +242,77 @@ export interface RunUpdateOptions {
222
242
  readonly deps?: UpdateDeps;
223
243
  }
224
244
 
245
+ async function readPinnedGjcVersion(path: string): Promise<string> {
246
+ let parsed: unknown;
247
+ try {
248
+ parsed = JSON.parse(await readFile(path, "utf8"));
249
+ } catch (error) {
250
+ throw new Error(
251
+ `could not read pinned gjc version from ${path}: ${error instanceof Error ? error.message : String(error)}`,
252
+ );
253
+ }
254
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
255
+ throw new Error(`invalid release package metadata at ${path}`);
256
+ const gjc = (parsed as { gjc?: unknown }).gjc;
257
+ if (typeof gjc !== "object" || gjc === null || Array.isArray(gjc))
258
+ throw new Error(`release package metadata at ${path} has no gjc.version`);
259
+ const version = (gjc as { version?: unknown }).version;
260
+ if (typeof version !== "string" || version.trim() === "")
261
+ throw new Error(`release package metadata at ${path} has no gjc.version`);
262
+ return version;
263
+ }
264
+
265
+ async function defaultUpdateCommandRunner(command: readonly string[]): Promise<number> {
266
+ const child = Bun.spawn([...command], { stdin: "ignore", stdout: "ignore", stderr: "pipe" });
267
+ const [stderr, exitCode] = await Promise.all([new Response(child.stderr).text(), child.exited]);
268
+ if (exitCode !== 0) {
269
+ const detail = stderr.trim();
270
+ throw new Error(`${command.join(" ")} exited with status ${exitCode}${detail.length === 0 ? "" : `: ${detail}`}`);
271
+ }
272
+ return exitCode;
273
+ }
274
+
275
+ async function installUpdatedServices(options: {
276
+ readonly home: string;
277
+ readonly binDir: string;
278
+ readonly platform: ServicePlatform;
279
+ readonly noRestart: boolean;
280
+ readonly deps: UpdateDeps;
281
+ }): Promise<boolean> {
282
+ const { home, binDir, platform, noRestart, deps } = options;
283
+ const brokerProvisioned = await exists(homeLayout(home).brokerProvisionManifest);
284
+ const env = { ...(deps.env ?? process.env), GAJAEWAY_HOME: home };
285
+ await installServices({
286
+ binDir,
287
+ platform,
288
+ env,
289
+ configuredAdaptersOnly: true,
290
+ compareAndWrite: true,
291
+ ...deps.serviceOptions,
292
+ });
293
+ if (platform === "linux") {
294
+ const run = deps.serviceRunner ?? defaultUpdateCommandRunner;
295
+ const execute = async (command: readonly string[]): Promise<void> => {
296
+ const exitCode = await run(command);
297
+ if (exitCode !== 0) throw new Error(`${command.join(" ")} exited with status ${exitCode}`);
298
+ };
299
+ await execute(["systemctl", "--user", "daemon-reload"]);
300
+ if (!noRestart) {
301
+ const enableUnits: string[] = [];
302
+ for (const spec of serviceSpecs()) {
303
+ if (spec.id === "broker" && !brokerProvisioned) continue;
304
+ if (
305
+ spec.id.startsWith("adapter-") &&
306
+ !(await Bun.file(join(home, `adapter-${spec.id.slice(8)}.json`)).exists())
307
+ )
308
+ continue;
309
+ enableUnits.push(systemdUnitName(spec));
310
+ }
311
+ if (enableUnits.length > 0) await execute(["systemctl", "--user", "enable", ...enableUnits]);
312
+ }
313
+ }
314
+ return !brokerProvisioned;
315
+ }
225
316
  export async function runUpdate(options: RunUpdateOptions): Promise<UpdateResult> {
226
317
  const deps = options.deps ?? {};
227
318
  const platform = deps.platform ?? process.platform;
@@ -247,45 +338,70 @@ export async function runUpdate(options: RunUpdateOptions): Promise<UpdateResult
247
338
  return { current, latest, tag: release.tag, repo, checkedOnly: true, upToDate: false, installed: [] };
248
339
  if (binDir === undefined) throw new Error("run the update from a compiled gajaeway binary, or pass --bin-dir DIR");
249
340
 
250
- const unlock = await acquireUpdateLock(binDir);
341
+ let lease: Lease;
251
342
  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));
343
+ lease = await acquireLease(options.home, "update");
344
+ console.info(`lease_acquired name=update pid=${process.pid}`);
345
+ } catch (error) {
346
+ if (error instanceof LeaseHeldError) {
347
+ console.error(error.message);
348
+ throw new Error(`another gajaeway update is running (${error.message})`, { cause: error });
265
349
  }
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));
350
+ console.error(`lease_failed name=update detail=${error instanceof Error ? error.message : String(error)}`);
351
+ throw error;
352
+ }
353
+
354
+ const installed = await (async () => {
355
+ let staged: StagedRelease | undefined;
356
+ try {
357
+ staged = await stageRelease(asset, deps);
358
+ const installedNames = await knownBinariesIn(binDir);
359
+ const toInstall = binariesToInstall(staged.names, installedNames);
360
+ const missingCore = CORE_BINARIES.filter((name) => !toInstall.includes(name));
361
+ if (missingCore.length > 0)
362
+ throw new Error(`release tarball is missing core binaries: ${missingCore.join(", ")}`);
363
+ await assertStagedBinariesUsable(staged, toInstall);
364
+
365
+ const gjcVersion = await readPinnedGjcVersion(join(staged.dir, PIN_MANIFEST));
366
+ await installPinnedGjc({
367
+ home: options.home,
368
+ version: gjcVersion,
369
+ ...(deps.gjcInstallRunner === undefined ? {} : { runner: deps.gjcInstallRunner }),
370
+ });
371
+
372
+ const stamp = new Date((deps.now ?? Date.now)()).toISOString().replace(/[-:]/g, "").slice(0, 15);
373
+ const backupDir = join(binDir, `backup-${current}-${stamp}Z`);
374
+ await mkdir(backupDir, { recursive: false });
375
+ // The compiled gateway and broker host read the gjc pin from the package.json
376
+ // beside them; a stale one makes the new binaries refuse the gjc just installed.
377
+ const replaced = [...toInstall, PIN_MANIFEST];
378
+ for (const name of replaced) {
379
+ const target = join(binDir, name);
380
+ if (await exists(target)) await copyFile(target, join(backupDir, name));
381
+ }
382
+ for (const name of replaced) {
383
+ const stagedPath = join(staged.dir, name);
384
+ const tempPath = join(binDir, `.update-${name}`);
385
+ await copyFile(stagedPath, tempPath);
386
+ await chmod(tempPath, name === PIN_MANIFEST ? 0o644 : 0o755);
387
+ await rename(tempPath, join(binDir, name));
388
+ }
389
+ await writeFile(releaseMarkerPath(binDir), `${latest}\n`, "utf8");
390
+ const brokerNotEnabled = await installUpdatedServices({
391
+ home: options.home,
392
+ binDir,
393
+ platform,
394
+ noRestart: options.args.noRestart,
395
+ deps,
396
+ });
397
+ return { toInstall, backupDir, brokerNotEnabled };
398
+ } finally {
399
+ if (staged !== undefined) await rm(staged.dir, { recursive: true, force: true });
400
+ await lease.release();
272
401
  }
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);
402
+ })();
403
+
404
+ if (options.args.noRestart)
289
405
  return {
290
406
  current,
291
407
  latest,
@@ -293,13 +409,25 @@ export async function runUpdate(options: RunUpdateOptions): Promise<UpdateResult
293
409
  repo,
294
410
  checkedOnly: false,
295
411
  upToDate: false,
296
- installed: toInstall,
297
- backupDir,
298
- restartQueued: receipt.receiptId,
412
+ installed: installed.toInstall,
413
+ backupDir: installed.backupDir,
414
+ brokerNotEnabled: installed.brokerNotEnabled,
415
+ restartSkipped: true,
299
416
  };
300
- } finally {
301
- await unlock();
302
- }
417
+ const receipt = await (deps.queueRestart ?? defaultQueueRestart)(options.home, binDir, "update");
418
+
419
+ return {
420
+ current,
421
+ latest,
422
+ tag: release.tag,
423
+ repo,
424
+ checkedOnly: false,
425
+ upToDate: false,
426
+ installed: installed.toInstall,
427
+ backupDir: installed.backupDir,
428
+ brokerNotEnabled: installed.brokerNotEnabled,
429
+ restartQueued: receipt.receiptId,
430
+ };
303
431
  }
304
432
 
305
433
  function binariesToInstall(stagedNames: readonly string[], installedNames: readonly string[]): string[] {
@@ -393,58 +521,7 @@ async function defaultExtractTarball(tarball: Uint8Array, intoDir: string): Prom
393
521
  await rm(tarballPath, { force: true });
394
522
  }
395
523
 
396
- async function defaultQueueRestart(home: string): Promise<RestartQueueReceipt> {
397
- const { receipt, supervisorPid } = await launchRestartStack({ home });
524
+ async function defaultQueueRestart(home: string, binDir: string, trigger: "update"): Promise<RestartQueueReceipt> {
525
+ const { receipt, supervisorPid } = await launchRestartStack({ home, binDir, trigger });
398
526
  return { receiptId: receipt.id, supervisorPid };
399
527
  }
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/dist/upgrade.d.ts DELETED
@@ -1,40 +0,0 @@
1
- /**
2
- * `gajaeway ops upgrade [--gjc X.Y.Z]`
3
- *
4
- * Atomically upgrades the gateway's gjc to the pinned version (or an override).
5
- * The process:
6
- * 1. Verify CI is green (deploy gate)
7
- * 2. Stop intake (implicit on restart)
8
- * 3. Wait for in-flight turns (bounded)
9
- * 4. Back up the DB
10
- * 5. Install the pinned gjc into the gateway's own prefix (checksum-verified)
11
- * 6. Restart the gateway's broker
12
- * 7. ops restart-stack
13
- * 8. Verify status (clients current, 0 pending, no held turns)
14
- *
15
- * Fails closed: leaves old binaries in place on any step.
16
- */
17
- export interface UpgradeOptions {
18
- readonly home: string;
19
- /** Override gjc version; defaults to reading from gateway's package.json */
20
- readonly gjcVersion?: string;
21
- /** Test seam: function to check CI status */
22
- readonly checkCiStatus?: (repo: string) => Promise<boolean>;
23
- /** Test seam: function to read gateway pinned gjc version */
24
- readonly readPinnedVersion?: (home: string) => Promise<string>;
25
- }
26
- /**
27
- * Read the pinned gjc version the installed gateway runs against.
28
- *
29
- * The checkout layout is tried first (a source deployment keeps the pin in
30
- * `packages/gateway/package.json`); an installed binary carries the same file
31
- * beside itself, which is the only candidate that exists on a host that never
32
- * had the checkout.
33
- */
34
- export declare function readGatewayPinnedGjcVersion(home: string): Promise<string>;
35
- export interface UpgradeResult {
36
- readonly status: "ok" | "failed";
37
- readonly detail: string;
38
- }
39
- export declare function performUpgrade(options: UpgradeOptions): Promise<UpgradeResult>;
40
- //# sourceMappingURL=upgrade.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"upgrade.d.ts","sourceRoot":"","sources":["../src/upgrade.ts"],"names":[],"mappings":"AAGA;;;;;;;;;;;;;;;GAeG;AAEH,MAAM,WAAW,cAAc;IAC9B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,4EAA4E;IAC5E,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;IAC7B,6CAA6C;IAC7C,QAAQ,CAAC,aAAa,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;IAC5D,6DAA6D;IAC7D,QAAQ,CAAC,iBAAiB,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,CAAC;CAC/D;AAED;;;;;;;GAOG;AACH,wBAAsB,2BAA2B,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAc/E;AAED,MAAM,WAAW,aAAa;IAC7B,QAAQ,CAAC,MAAM,EAAE,IAAI,GAAG,QAAQ,CAAC;IACjC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;CACxB;AA2DD,wBAAsB,cAAc,CAAC,OAAO,EAAE,cAAc,GAAG,OAAO,CAAC,aAAa,CAAC,CAwFpF"}
package/src/upgrade.ts DELETED
@@ -1,205 +0,0 @@
1
- import { readFile } from "node:fs/promises";
2
- import { dirname, 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
- /**
32
- * Read the pinned gjc version the installed gateway runs against.
33
- *
34
- * The checkout layout is tried first (a source deployment keeps the pin in
35
- * `packages/gateway/package.json`); an installed binary carries the same file
36
- * beside itself, which is the only candidate that exists on a host that never
37
- * had the checkout.
38
- */
39
- export async function readGatewayPinnedGjcVersion(home: string): Promise<string> {
40
- const candidates = [
41
- join(home, "..", "..", "packages", "gateway", "package.json"),
42
- join(dirname(process.execPath), "package.json"),
43
- ];
44
- for (const path of candidates) {
45
- try {
46
- const version = (JSON.parse(await readFile(path, "utf-8")) as { gjc?: { version?: unknown } }).gjc?.version;
47
- if (typeof version === "string" && version.length > 0) return version;
48
- } catch {
49
- // A missing or malformed candidate is not fatal: a later one may carry the pin.
50
- }
51
- }
52
- throw new Error(`Failed to read pinned GJC version from any of ${candidates.join(", ")}`);
53
- }
54
-
55
- export interface UpgradeResult {
56
- readonly status: "ok" | "failed";
57
- readonly detail: string;
58
- }
59
-
60
- /**
61
- * Check CI status by running: gh run list -R owner/repo --branch HEAD --limit 1 --json conclusion
62
- * Returns true if the latest CI run passed (conclusion: success)
63
- */
64
- async function checkLatestCiStatus(repo: string): Promise<boolean> {
65
- try {
66
- // Get current git branch
67
- const branchProc = Bun.spawnSync(["git", "rev-parse", "--abbrev-ref", "HEAD"], {
68
- stdout: "pipe",
69
- stderr: "pipe",
70
- });
71
- if (branchProc.exitCode !== 0) {
72
- throw new Error("Could not determine current git branch");
73
- }
74
-
75
- const branch = branchProc.stdout?.toString().trim();
76
- if (!branch) {
77
- throw new Error("Empty branch name");
78
- }
79
-
80
- // Check CI status for this branch
81
- const runProc = Bun.spawnSync(
82
- ["gh", "run", "list", "-R", repo, "--branch", branch, "--limit", "1", "--json", "conclusion,status"],
83
- {
84
- stdout: "pipe",
85
- stderr: "pipe",
86
- },
87
- );
88
-
89
- if (runProc.exitCode !== 0) {
90
- throw new Error(`gh run list failed: ${runProc.stderr?.toString()}`);
91
- }
92
-
93
- const output = runProc.stdout?.toString() || "[]";
94
- const runs = JSON.parse(output);
95
-
96
- if (!Array.isArray(runs) || runs.length === 0) {
97
- throw new Error("No CI runs found for this branch");
98
- }
99
-
100
- const latestRun = runs[0];
101
- if (!latestRun.conclusion || !latestRun.status) {
102
- throw new Error(`Latest CI run status is incomplete: ${JSON.stringify(latestRun)}`);
103
- }
104
-
105
- // conclusion can be: success, failure, neutral, cancelled, skipped, timed_out
106
- // status can be: queued, in_progress, completed
107
- if (latestRun.status === "in_progress") {
108
- throw new Error("CI is currently running; wait for completion");
109
- }
110
-
111
- return latestRun.conclusion === "success";
112
- } catch (error) {
113
- throw new Error(`Failed to check CI status: ${error instanceof Error ? error.message : String(error)}`);
114
- }
115
- }
116
-
117
- export async function performUpgrade(options: UpgradeOptions): Promise<UpgradeResult> {
118
- const readVersion = options.readPinnedVersion || readGatewayPinnedGjcVersion;
119
- const targetVersion = options.gjcVersion ?? (await readVersion(options.home));
120
-
121
- try {
122
- // 1. Check CI status (deploy gate)
123
- const checkCi = options.checkCiStatus || checkLatestCiStatus;
124
- let ciPassed = false;
125
- try {
126
- ciPassed = await checkCi("Yeachan-Heo/gajae-way");
127
- } catch (ciError) {
128
- const ciDetail = ciError instanceof Error ? ciError.message : String(ciError);
129
- return { status: "failed", detail: `CI status check failed: ${ciDetail}` };
130
- }
131
-
132
- if (!ciPassed) {
133
- return {
134
- status: "failed",
135
- detail: `Cannot upgrade: latest CI run did not pass. Fix failures and try again.`,
136
- };
137
- }
138
-
139
- // 2. Read current gjc version
140
- const gjcProc = Bun.spawnSync(["gjc", "--version"], {
141
- stdout: "pipe",
142
- stderr: "pipe",
143
- });
144
- if (gjcProc.exitCode !== 0) {
145
- return { status: "failed", detail: "Failed to get current gjc version" };
146
- }
147
-
148
- const currentVersionMatch = ((gjcProc.stdout?.toString() || "") + (gjcProc.stderr?.toString() || "")).match(
149
- /(?:gjc\/)?(\d+)\.(\d+)\.(\d+)/,
150
- );
151
- if (!currentVersionMatch) {
152
- return { status: "failed", detail: "Could not parse current gjc version" };
153
- }
154
- const currentVersion = currentVersionMatch.slice(1, 4).join(".");
155
-
156
- if (currentVersion === targetVersion) {
157
- return { status: "ok", detail: `Already running gjc ${targetVersion}` };
158
- }
159
-
160
- // 3. Install new gjc version
161
- const installProc = Bun.spawnSync(["bun", "add", "-g", `gajae-code@${targetVersion}`], {
162
- stdout: "pipe",
163
- stderr: "pipe",
164
- cwd: join(options.home, "workspace"),
165
- });
166
-
167
- if (installProc.exitCode !== 0) {
168
- return {
169
- status: "failed",
170
- detail: `Failed to install gjc ${targetVersion}: ${installProc.stderr?.toString()}`,
171
- };
172
- }
173
-
174
- // 4. Verify installation
175
- const verifyProc = Bun.spawnSync(["gjc", "--version"], {
176
- stdout: "pipe",
177
- stderr: "pipe",
178
- });
179
- if (verifyProc.exitCode !== 0) {
180
- return { status: "failed", detail: "Failed to verify gjc installation" };
181
- }
182
-
183
- const verifiedVersionMatch = ((verifyProc.stdout?.toString() || "") + (verifyProc.stderr?.toString() || "")).match(
184
- /(?:gjc\/)?(\d+)\.(\d+)\.(\d+)/,
185
- );
186
- if (!verifiedVersionMatch) {
187
- return { status: "failed", detail: "Could not verify installed gjc version" };
188
- }
189
-
190
- const verifiedVersion = verifiedVersionMatch.slice(1, 4).join(".");
191
- if (verifiedVersion !== targetVersion) {
192
- return {
193
- status: "failed",
194
- detail: `Verification failed: expected ${targetVersion}, got ${verifiedVersion}`,
195
- };
196
- }
197
-
198
- return { status: "ok", detail: `Successfully upgraded gjc from ${currentVersion} to ${targetVersion}` };
199
- } catch (error) {
200
- return {
201
- status: "failed",
202
- detail: `Upgrade failed: ${error instanceof Error ? error.message : String(error)}`,
203
- };
204
- }
205
- }