@gajae-gateway/cli 0.1.8 → 0.2.0

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
@@ -127,7 +135,6 @@ const MIN_BINARY_BYTES = 1_048_576;
127
135
  const MAX_ASSET_BYTES = 512 * 1024 * 1024;
128
136
 
129
137
  const RELEASE_MARKER = ".gajaeway-release";
130
- const LOCK_FILE = ".gajaeway-update.lock";
131
138
 
132
139
  export function releaseMarkerPath(binDir: string): string {
133
140
  return join(binDir, RELEASE_MARKER);
@@ -168,6 +175,7 @@ export interface UpdateResult {
168
175
  readonly installed: readonly string[];
169
176
  readonly backupDir?: string;
170
177
  readonly restartQueued?: string;
178
+ readonly brokerNotEnabled?: boolean;
171
179
  readonly restartSkipped?: boolean;
172
180
  }
173
181
 
@@ -191,6 +199,7 @@ export function renderUpdate(result: UpdateResult): string[] {
191
199
  if (result.restartQueued !== undefined)
192
200
  lines.push(`restart: queued ${result.restartQueued} — check with: gajaeway ops restart-stack --status`);
193
201
  else if (result.restartSkipped) lines.push("restart: skipped (--no-restart)");
202
+ if (result.brokerNotEnabled) lines.push("broker unit installed, not enabled: run gajaeway setup --broker-agent");
194
203
  return lines;
195
204
  }
196
205
 
@@ -202,7 +211,13 @@ export interface RestartQueueReceipt {
202
211
  export type ReleaseFetcher = (repo: string) => Promise<ReleaseInfo>;
203
212
  export type AssetDownloader = (asset: ReleaseAsset) => Promise<Uint8Array>;
204
213
  export type TarballExtractor = (tarball: Uint8Array, intoDir: string) => Promise<void>;
205
- export type RestartQueuer = (home: string) => Promise<RestartQueueReceipt>;
214
+ export type RestartQueuer = (home: string, binDir: string, trigger: "update") => Promise<RestartQueueReceipt>;
215
+ export type UpdateCommandRunner = (command: readonly string[]) => Promise<number>;
216
+
217
+ type UpdateServiceOptions = Pick<
218
+ InstallServicesOptions,
219
+ "launchAgentsDir" | "unitDir" | "loginPathRunner" | "writeFile"
220
+ >;
206
221
 
207
222
  export interface UpdateDeps {
208
223
  readonly fetchLatestRelease?: ReleaseFetcher;
@@ -214,6 +229,9 @@ export interface UpdateDeps {
214
229
  readonly env?: NodeJS.ProcessEnv;
215
230
  readonly execPath?: string;
216
231
  readonly now?: () => number;
232
+ readonly gjcInstallRunner?: GjcInstallRunner;
233
+ readonly serviceRunner?: UpdateCommandRunner;
234
+ readonly serviceOptions?: UpdateServiceOptions;
217
235
  }
218
236
 
219
237
  export interface RunUpdateOptions {
@@ -222,6 +240,77 @@ export interface RunUpdateOptions {
222
240
  readonly deps?: UpdateDeps;
223
241
  }
224
242
 
243
+ async function readPinnedGjcVersion(path: string): Promise<string> {
244
+ let parsed: unknown;
245
+ try {
246
+ parsed = JSON.parse(await readFile(path, "utf8"));
247
+ } catch (error) {
248
+ throw new Error(
249
+ `could not read pinned gjc version from ${path}: ${error instanceof Error ? error.message : String(error)}`,
250
+ );
251
+ }
252
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
253
+ throw new Error(`invalid release package metadata at ${path}`);
254
+ const gjc = (parsed as { gjc?: unknown }).gjc;
255
+ if (typeof gjc !== "object" || gjc === null || Array.isArray(gjc))
256
+ throw new Error(`release package metadata at ${path} has no gjc.version`);
257
+ const version = (gjc as { version?: unknown }).version;
258
+ if (typeof version !== "string" || version.trim() === "")
259
+ throw new Error(`release package metadata at ${path} has no gjc.version`);
260
+ return version;
261
+ }
262
+
263
+ async function defaultUpdateCommandRunner(command: readonly string[]): Promise<number> {
264
+ const child = Bun.spawn([...command], { stdin: "ignore", stdout: "ignore", stderr: "pipe" });
265
+ const [stderr, exitCode] = await Promise.all([new Response(child.stderr).text(), child.exited]);
266
+ if (exitCode !== 0) {
267
+ const detail = stderr.trim();
268
+ throw new Error(`${command.join(" ")} exited with status ${exitCode}${detail.length === 0 ? "" : `: ${detail}`}`);
269
+ }
270
+ return exitCode;
271
+ }
272
+
273
+ async function installUpdatedServices(options: {
274
+ readonly home: string;
275
+ readonly binDir: string;
276
+ readonly platform: ServicePlatform;
277
+ readonly noRestart: boolean;
278
+ readonly deps: UpdateDeps;
279
+ }): Promise<boolean> {
280
+ const { home, binDir, platform, noRestart, deps } = options;
281
+ const brokerProvisioned = await exists(homeLayout(home).brokerProvisionManifest);
282
+ const env = { ...(deps.env ?? process.env), GAJAEWAY_HOME: home };
283
+ await installServices({
284
+ binDir,
285
+ platform,
286
+ env,
287
+ configuredAdaptersOnly: true,
288
+ compareAndWrite: true,
289
+ ...deps.serviceOptions,
290
+ });
291
+ if (platform === "linux") {
292
+ const run = deps.serviceRunner ?? defaultUpdateCommandRunner;
293
+ const execute = async (command: readonly string[]): Promise<void> => {
294
+ const exitCode = await run(command);
295
+ if (exitCode !== 0) throw new Error(`${command.join(" ")} exited with status ${exitCode}`);
296
+ };
297
+ await execute(["systemctl", "--user", "daemon-reload"]);
298
+ if (!noRestart) {
299
+ const enableUnits: string[] = [];
300
+ for (const spec of serviceSpecs()) {
301
+ if (spec.id === "broker" && !brokerProvisioned) continue;
302
+ if (
303
+ spec.id.startsWith("adapter-") &&
304
+ !(await Bun.file(join(home, `adapter-${spec.id.slice(8)}.json`)).exists())
305
+ )
306
+ continue;
307
+ enableUnits.push(systemdUnitName(spec));
308
+ }
309
+ if (enableUnits.length > 0) await execute(["systemctl", "--user", "enable", ...enableUnits]);
310
+ }
311
+ }
312
+ return !brokerProvisioned;
313
+ }
225
314
  export async function runUpdate(options: RunUpdateOptions): Promise<UpdateResult> {
226
315
  const deps = options.deps ?? {};
227
316
  const platform = deps.platform ?? process.platform;
@@ -247,45 +336,67 @@ export async function runUpdate(options: RunUpdateOptions): Promise<UpdateResult
247
336
  return { current, latest, tag: release.tag, repo, checkedOnly: true, upToDate: false, installed: [] };
248
337
  if (binDir === undefined) throw new Error("run the update from a compiled gajaeway binary, or pass --bin-dir DIR");
249
338
 
250
- const unlock = await acquireUpdateLock(binDir);
339
+ let lease: Lease;
251
340
  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));
341
+ lease = await acquireLease(options.home, "update");
342
+ console.info(`lease_acquired name=update pid=${process.pid}`);
343
+ } catch (error) {
344
+ if (error instanceof LeaseHeldError) {
345
+ console.error(error.message);
346
+ throw new Error(`another gajaeway update is running (${error.message})`, { cause: error });
265
347
  }
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));
348
+ console.error(`lease_failed name=update detail=${error instanceof Error ? error.message : String(error)}`);
349
+ throw error;
350
+ }
351
+
352
+ const installed = await (async () => {
353
+ let staged: StagedRelease | undefined;
354
+ try {
355
+ staged = await stageRelease(asset, deps);
356
+ const installedNames = await knownBinariesIn(binDir);
357
+ const toInstall = binariesToInstall(staged.names, installedNames);
358
+ const missingCore = CORE_BINARIES.filter((name) => !toInstall.includes(name));
359
+ if (missingCore.length > 0)
360
+ throw new Error(`release tarball is missing core binaries: ${missingCore.join(", ")}`);
361
+ await assertStagedBinariesUsable(staged, toInstall);
362
+
363
+ const gjcVersion = await readPinnedGjcVersion(join(staged.dir, "package.json"));
364
+ await installPinnedGjc({
365
+ home: options.home,
366
+ version: gjcVersion,
367
+ ...(deps.gjcInstallRunner === undefined ? {} : { runner: deps.gjcInstallRunner }),
368
+ });
369
+
370
+ const stamp = new Date((deps.now ?? Date.now)()).toISOString().replace(/[-:]/g, "").slice(0, 15);
371
+ const backupDir = join(binDir, `backup-${current}-${stamp}Z`);
372
+ await mkdir(backupDir, { recursive: false });
373
+ for (const name of toInstall) {
374
+ const target = join(binDir, name);
375
+ if (await exists(target)) await copyFile(target, join(backupDir, name));
376
+ }
377
+ for (const name of toInstall) {
378
+ const stagedPath = join(staged.dir, name);
379
+ const tempPath = join(binDir, `.update-${name}`);
380
+ await copyFile(stagedPath, tempPath);
381
+ await chmod(tempPath, 0o755);
382
+ await rename(tempPath, join(binDir, name));
383
+ }
384
+ await writeFile(releaseMarkerPath(binDir), `${latest}\n`, "utf8");
385
+ const brokerNotEnabled = await installUpdatedServices({
386
+ home: options.home,
387
+ binDir,
388
+ platform,
389
+ noRestart: options.args.noRestart,
390
+ deps,
391
+ });
392
+ return { toInstall, backupDir, brokerNotEnabled };
393
+ } finally {
394
+ if (staged !== undefined) await rm(staged.dir, { recursive: true, force: true });
395
+ await lease.release();
272
396
  }
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);
397
+ })();
398
+
399
+ if (options.args.noRestart)
289
400
  return {
290
401
  current,
291
402
  latest,
@@ -293,13 +404,25 @@ export async function runUpdate(options: RunUpdateOptions): Promise<UpdateResult
293
404
  repo,
294
405
  checkedOnly: false,
295
406
  upToDate: false,
296
- installed: toInstall,
297
- backupDir,
298
- restartQueued: receipt.receiptId,
407
+ installed: installed.toInstall,
408
+ backupDir: installed.backupDir,
409
+ brokerNotEnabled: installed.brokerNotEnabled,
410
+ restartSkipped: true,
299
411
  };
300
- } finally {
301
- await unlock();
302
- }
412
+ const receipt = await (deps.queueRestart ?? defaultQueueRestart)(options.home, binDir, "update");
413
+
414
+ return {
415
+ current,
416
+ latest,
417
+ tag: release.tag,
418
+ repo,
419
+ checkedOnly: false,
420
+ upToDate: false,
421
+ installed: installed.toInstall,
422
+ backupDir: installed.backupDir,
423
+ brokerNotEnabled: installed.brokerNotEnabled,
424
+ restartQueued: receipt.receiptId,
425
+ };
303
426
  }
304
427
 
305
428
  function binariesToInstall(stagedNames: readonly string[], installedNames: readonly string[]): string[] {
@@ -393,58 +516,7 @@ async function defaultExtractTarball(tarball: Uint8Array, intoDir: string): Prom
393
516
  await rm(tarballPath, { force: true });
394
517
  }
395
518
 
396
- async function defaultQueueRestart(home: string): Promise<RestartQueueReceipt> {
397
- const { receipt, supervisorPid } = await launchRestartStack({ home });
519
+ async function defaultQueueRestart(home: string, binDir: string, trigger: "update"): Promise<RestartQueueReceipt> {
520
+ const { receipt, supervisorPid } = await launchRestartStack({ home, binDir, trigger });
398
521
  return { receiptId: receipt.id, supervisorPid };
399
522
  }
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
- }