@arcote.tech/arc-cli 0.6.2 → 0.7.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.
@@ -17,6 +17,8 @@ import {
17
17
  updateCache,
18
18
  type BuildCache,
19
19
  } from "./build-cache";
20
+ import type { ChunkPlan, PackageChunk } from "./chunk-planner";
21
+ import { SHELL_EXTERNALS } from "./framework-peers";
20
22
  import {
21
23
  readInstalledVersion,
22
24
  sha256Hex,
@@ -28,8 +30,42 @@ import { pAll } from "./parallel";
28
30
 
29
31
  /** Re-export for internal CLI consumers (avoid direct platform dependency in consumers). */
30
32
  export type { BuildManifest, ModuleDescriptor };
31
- /** Re-export hash helpers for backward compatibility (deploy/remote-sync uses sha256OfFiles). */
33
+ /** Re-export hash helpers for backward compatibility (deploy code uses sha256OfFiles). */
32
34
  export { sha256Hex, sha256OfFiles };
35
+ /** Re-export externals so callers (shell builder) stay decoupled from framework-peers. */
36
+ export { SHELL_EXTERNALS };
37
+
38
+ /**
39
+ * Bun's automatic JSX transform emits `import { jsxDEV } from "react/jsx-dev-runtime"`
40
+ * regardless of NODE_ENV. React's production module exports `jsxDEV` as undefined
41
+ * (it's a debug-only symbol). At runtime in a production container this crashes
42
+ * the moment a JSX call is reached. The plugin redirects `react/jsx-dev-runtime`
43
+ * imports to a synthetic module that re-exports `jsx` as `jsxDEV` from the
44
+ * production jsx-runtime — semantically equivalent at runtime, no debug data.
45
+ *
46
+ * Applied to BOTH browser and server bundles. Server bundles may import
47
+ * JSX-typed Trans/translation components from workspace deps; without the
48
+ * shim, loadServerContext fails on any server bundle that touches them.
49
+ */
50
+ function jsxDevShimPlugin(): import("bun").BunPlugin {
51
+ return {
52
+ name: "jsx-dev-runtime-shim",
53
+ setup(build) {
54
+ build.onResolve({ filter: /^react\/jsx-dev-runtime$/ }, () => ({
55
+ path: "react-jsx-dev-runtime-shim",
56
+ namespace: "jsx-dev-shim",
57
+ }));
58
+ build.onLoad({ filter: /.*/, namespace: "jsx-dev-shim" }, () => ({
59
+ contents: `import { jsx, jsxs, Fragment } from "react/jsx-runtime";
60
+ export const jsxDEV = jsx;
61
+ export const jsxsDEV = jsxs;
62
+ export { Fragment };
63
+ `,
64
+ loader: "ts",
65
+ }));
66
+ },
67
+ };
68
+ }
33
69
 
34
70
  /** Clients that a context package is built for. */
35
71
  const CONTEXT_CLIENTS = [
@@ -37,21 +73,6 @@ const CONTEXT_CLIENTS = [
37
73
  { name: "browser", target: "browser" as const, defines: { ONLY_SERVER: "false", ONLY_BROWSER: "true", ONLY_CLIENT: "true" } },
38
74
  ];
39
75
 
40
- /** Packages that shell provides — modules import them but don't bundle them. */
41
- export const SHELL_EXTERNALS = [
42
- "react",
43
- "react-dom",
44
- "react/jsx-runtime",
45
- "react/jsx-dev-runtime",
46
- "@arcote.tech/arc",
47
- "@arcote.tech/arc-ds",
48
- "@arcote.tech/arc-react",
49
- "@arcote.tech/arc-auth",
50
- "@arcote.tech/arc-utils",
51
- "@arcote.tech/arc-workspace",
52
- "@arcote.tech/platform",
53
- ];
54
-
55
76
  export interface WorkspacePackage {
56
77
  name: string;
57
78
  path: string;
@@ -206,9 +227,16 @@ async function buildContextClient(
206
227
 
207
228
  console.log(` building: ${pkg.name} (${client.name})`);
208
229
 
230
+ // Externals: framework peers + npm dependencies. Workspace deps (value
231
+ // starts with `workspace:`) are bundled inline — the deploy image has no
232
+ // workspace symlinks, so any cross-package import not bundled here would
233
+ // fail to resolve at runtime in the container.
209
234
  const peerDeps = Object.keys(pkg.packageJson.peerDependencies ?? {});
210
- const deps = Object.keys(pkg.packageJson.dependencies ?? {});
211
- const externals = [...peerDeps, ...deps];
235
+ const allDeps = (pkg.packageJson.dependencies ?? {}) as Record<string, string>;
236
+ const npmDeps = Object.entries(allDeps)
237
+ .filter(([, spec]) => !spec.startsWith("workspace:"))
238
+ .map(([name]) => name);
239
+ const externals = [...peerDeps, ...npmDeps];
212
240
 
213
241
  const result = await Bun.build({
214
242
  entrypoints: [pkg.entrypoint],
@@ -217,6 +245,7 @@ async function buildContextClient(
217
245
  format: "esm",
218
246
  naming: "index.[ext]",
219
247
  external: externals,
248
+ plugins: [jsxDevShimPlugin()],
220
249
  define: client.defines,
221
250
  });
222
251
 
@@ -279,7 +308,7 @@ export async function buildContextPackages(
279
308
  }
280
309
 
281
310
  // ---------------------------------------------------------------------------
282
- // Modules bundlesingle Bun.build of all wrapper re-exports
311
+ // Modules bundlesone Bun.build per chunk group (public + per-token-name)
283
312
  // ---------------------------------------------------------------------------
284
313
 
285
314
  interface ModulesBundleResult {
@@ -288,130 +317,177 @@ interface ModulesBundleResult {
288
317
  }
289
318
 
290
319
  /**
291
- * Bundle all workspace packages into ESM chunks for the platform shell.
292
- * Cached as a single unit any source change in any package invalidates.
320
+ * Build each chunk group as an independent Bun.build. Chunks NEVER share
321
+ * code across groups a public chunk file can be served unauthenticated;
322
+ * per-token chunk files are signed and require the matching token to fetch.
323
+ *
324
+ * Layout: `<outDir>/<chunk>/<safeName>.js` (+ Bun's shared chunk files
325
+ * `chunk-<hash>.js` inside the same chunk dir).
326
+ *
327
+ * i18n strings extracted from all groups are merged into a single workspace
328
+ * translation catalog (extraction is build-input metadata, not bundle output).
293
329
  */
294
- export async function buildModulesBundle(
330
+ export async function buildModulesByChunks(
295
331
  rootDir: string,
296
332
  outDir: string,
297
- packages: WorkspacePackage[],
333
+ plan: ChunkPlan,
298
334
  cache: BuildCache,
299
335
  noCache: boolean,
300
336
  ): Promise<ModulesBundleResult> {
301
337
  mkdirSync(outDir, { recursive: true });
302
338
 
303
- const unitId = "modules-bundle";
339
+ const i18nCollector = new Map<string, Set<string>>();
340
+ const aggregateModules: ModuleDescriptor[] = [];
341
+ let allCached = true;
342
+
343
+ for (const chunk of plan.chunks) {
344
+ const members = plan.groups.get(chunk) ?? [];
345
+ if (members.length === 0) continue;
346
+
347
+ const chunkOutDir = join(outDir, chunk);
348
+ mkdirSync(chunkOutDir, { recursive: true });
349
+
350
+ const result = await buildChunkGroup(
351
+ rootDir,
352
+ chunkOutDir,
353
+ chunk,
354
+ members,
355
+ cache,
356
+ noCache,
357
+ i18nCollector,
358
+ );
359
+
360
+ aggregateModules.push(...result.modules);
361
+ if (!result.cached) allCached = false;
362
+ }
363
+
364
+ // Single workspace-wide i18n write — keeps msgid bookkeeping atomic.
365
+ await finalizeTranslations(rootDir, join(outDir, ".."), i18nCollector);
366
+
367
+ return { modules: aggregateModules, cached: allCached };
368
+ }
369
+
370
+ async function buildChunkGroup(
371
+ rootDir: string,
372
+ chunkOutDir: string,
373
+ chunk: string,
374
+ members: readonly PackageChunk[],
375
+ cache: BuildCache,
376
+ noCache: boolean,
377
+ i18nCollector: Map<string, Set<string>>,
378
+ ): Promise<ModulesBundleResult> {
379
+ const unitId = `modules-chunk:${chunk}`;
304
380
 
305
- // Per-package source hash gives us deterministic input identity even though
306
- // we run a single Bun.build for the whole bundle (chunk sharing wins).
307
- const pkgHashes = packages.map((p) => ({
308
- name: p.name,
309
- safeName: basename(p.path),
310
- srcHash: pkgSourceHash(p),
381
+ const pkgHashes = members.map((m) => ({
382
+ name: m.pkg.name,
383
+ safeName: m.safeName,
384
+ moduleName: m.moduleName,
385
+ srcHash: pkgSourceHash(m.pkg),
311
386
  }));
312
387
 
313
388
  const inputHash = sha256OfJson({
389
+ chunk,
314
390
  pkgHashes,
315
391
  externals: SHELL_EXTERNALS,
316
392
  define: { ONLY_SERVER: "false", ONLY_BROWSER: "true", ONLY_CLIENT: "true" },
317
393
  });
318
394
 
319
- // Cache hit → reconstruct module descriptors from existing files in outDir.
320
395
  if (!noCache && isCacheHit(cache, unitId, inputHash)) {
321
396
  const existing = cache.units[unitId]?.outputHashes ?? {};
322
397
  const modules: ModuleDescriptor[] = [];
323
- for (const { safeName, name } of pkgHashes) {
324
- const file = `${safeName}.js`;
325
- const filePath = join(outDir, file);
398
+ let missing = false;
399
+ for (const h of pkgHashes) {
400
+ const file = `${h.safeName}.js`;
401
+ const filePath = join(chunkOutDir, file);
326
402
  if (!existsSync(filePath)) {
327
- // Output missing despite hash match — fall through to rebuild.
328
- console.log(` rebuilding modules-bundle: output ${file} missing`);
329
- return await actuallyBuild();
403
+ missing = true;
404
+ break;
330
405
  }
331
- modules.push({ file, name, hash: existing[safeName] ?? sha256Hex(readFileSync(filePath)) });
406
+ modules.push({
407
+ file,
408
+ name: h.moduleName,
409
+ chunk,
410
+ hash: existing[h.safeName] ?? sha256Hex(readFileSync(filePath)),
411
+ });
412
+ }
413
+ if (!missing) {
414
+ console.log(` ✓ cached: ${unitId} (${modules.length} module(s))`);
415
+ return { modules, cached: true };
332
416
  }
333
- console.log(` cached: modules-bundle (${modules.length} module(s))`);
334
- return { modules, cached: true };
417
+ console.log(` rebuilding ${unitId}: output file missing`);
335
418
  }
336
419
 
337
- return await actuallyBuild();
338
-
339
- async function actuallyBuild(): Promise<ModulesBundleResult> {
340
- console.log(` building: modules-bundle (${packages.length} package(s))`);
420
+ console.log(` building: ${unitId} (${members.length} module(s))`);
341
421
 
342
- const tmpDir = join(outDir, "_entries");
343
- mkdirSync(tmpDir, { recursive: true });
422
+ const tmpDir = join(chunkOutDir, "_entries");
423
+ mkdirSync(tmpDir, { recursive: true });
344
424
 
345
- const entrypoints: string[] = [];
346
- const fileToName = new Map<string, string>();
425
+ const entrypoints: string[] = [];
426
+ const fileToModuleName = new Map<string, string>();
347
427
 
348
- for (const pkg of packages) {
349
- const safeName = basename(pkg.path);
350
- const moduleName = pkg.name.includes("/") ? pkg.name.split("/").pop()! : pkg.name;
351
- fileToName.set(safeName, moduleName);
428
+ for (const m of members) {
429
+ fileToModuleName.set(m.safeName, m.moduleName);
430
+ const wrapperFile = join(tmpDir, `${m.safeName}.ts`);
431
+ writeFileSync(wrapperFile, `export * from "${m.pkg.name}";\n`);
432
+ entrypoints.push(wrapperFile);
433
+ }
352
434
 
353
- const wrapperFile = join(tmpDir, `${safeName}.ts`);
354
- writeFileSync(wrapperFile, `export * from "${pkg.name}";\n`);
355
- entrypoints.push(wrapperFile);
356
- }
435
+ const arcExternalPlugin: import("bun").BunPlugin = {
436
+ name: "arc-external",
437
+ setup(build) {
438
+ build.onResolve({ filter: /^@arcote\.tech\// }, (args) => {
439
+ return { path: args.path, external: true };
440
+ });
441
+ },
442
+ };
357
443
 
358
- const i18nCollector = new Map<string, Set<string>>();
359
-
360
- const arcExternalPlugin: import("bun").BunPlugin = {
361
- name: "arc-external",
362
- setup(build) {
363
- build.onResolve({ filter: /^@arcote\.tech\// }, (args) => {
364
- return { path: args.path, external: true };
365
- });
366
- },
367
- };
368
-
369
- const result = await Bun.build({
370
- entrypoints,
371
- outdir: outDir,
372
- splitting: true,
373
- format: "esm",
374
- target: "browser",
375
- external: SHELL_EXTERNALS,
376
- plugins: [arcExternalPlugin, i18nExtractPlugin(i18nCollector, rootDir)],
377
- naming: "[name].[ext]",
378
- define: {
379
- ONLY_SERVER: "false",
380
- ONLY_BROWSER: "true",
381
- ONLY_CLIENT: "true",
382
- },
383
- });
444
+ const result = await Bun.build({
445
+ entrypoints,
446
+ outdir: chunkOutDir,
447
+ splitting: true,
448
+ format: "esm",
449
+ target: "browser",
450
+ external: [...SHELL_EXTERNALS],
451
+ plugins: [
452
+ arcExternalPlugin,
453
+ jsxDevShimPlugin(),
454
+ i18nExtractPlugin(i18nCollector, rootDir),
455
+ ],
456
+ naming: "[name].[ext]",
457
+ define: {
458
+ ONLY_SERVER: "false",
459
+ ONLY_BROWSER: "true",
460
+ ONLY_CLIENT: "true",
461
+ },
462
+ });
384
463
 
385
- if (!result.success) {
386
- console.error("Modules bundle build failed:");
387
- for (const log of result.logs) console.error(log);
388
- throw new Error("Module build failed");
389
- }
464
+ if (!result.success) {
465
+ console.error(`Chunk "${chunk}" build failed:`);
466
+ for (const log of result.logs) console.error(log);
467
+ throw new Error(`Module chunk build failed: ${chunk}`);
468
+ }
390
469
 
391
- // i18n extraction write/update .po files (compileAllCatalogs handles .json).
392
- await finalizeTranslations(rootDir, join(outDir, ".."), i18nCollector);
393
-
394
- rmSync(tmpDir, { recursive: true, force: true });
395
-
396
- const outputHashes: Record<string, string> = {};
397
- const modules: ModuleDescriptor[] = result.outputs
398
- .filter((o) => o.kind === "entry-point")
399
- .map((o) => {
400
- const file = basename(o.path);
401
- const safeName = file.replace(/\.js$/, "");
402
- const bytes = readFileSync(o.path);
403
- const hash = sha256Hex(bytes);
404
- outputHashes[safeName] = hash;
405
- return {
406
- file,
407
- name: fileToName.get(safeName) ?? safeName,
408
- hash,
409
- };
410
- });
470
+ rmSync(tmpDir, { recursive: true, force: true });
471
+
472
+ const outputHashes: Record<string, string> = {};
473
+ const modules: ModuleDescriptor[] = result.outputs
474
+ .filter((o) => o.kind === "entry-point")
475
+ .map((o) => {
476
+ const file = basename(o.path);
477
+ const safeName = file.replace(/\.js$/, "");
478
+ const bytes = readFileSync(o.path);
479
+ const hash = sha256Hex(bytes);
480
+ outputHashes[safeName] = hash;
481
+ return {
482
+ file,
483
+ name: fileToModuleName.get(safeName) ?? safeName,
484
+ chunk,
485
+ hash,
486
+ };
487
+ });
411
488
 
412
- updateCache(cache, unitId, inputHash, { outputHashes });
413
- return { modules, cached: false };
414
- }
489
+ updateCache(cache, unitId, inputHash, { outputHashes });
490
+ return { modules, cached: false };
415
491
  }
416
492
 
417
493
  // ---------------------------------------------------------------------------
@@ -1,13 +1,16 @@
1
1
  import { existsSync, readFileSync } from "fs";
2
2
  import { dirname, join } from "path";
3
+ import { fileURLToPath } from "url";
3
4
  import { bootstrap } from "../deploy/bootstrap";
4
5
  import {
5
6
  deployConfigExists,
6
7
  loadDeployConfig,
7
8
  saveDeployConfig,
8
9
  } from "../deploy/config";
10
+ import { updateEnvDeployment } from "../deploy/deploy-env";
11
+ import { buildImage, sanitizeImageName } from "../deploy/image";
9
12
  import { detectRemoteState } from "../deploy/remote-state";
10
- import { syncEnv } from "../deploy/remote-sync";
13
+ import { dockerLogin, dockerPush } from "../deploy/registry";
11
14
  import { runSurvey } from "../deploy/survey";
12
15
  import {
13
16
  buildAll,
@@ -15,6 +18,7 @@ import {
15
18
  log,
16
19
  ok,
17
20
  resolveWorkspace,
21
+ type WorkspaceInfo,
18
22
  } from "../platform/shared";
19
23
 
20
24
  interface PlatformDeployOptions {
@@ -24,17 +28,27 @@ interface PlatformDeployOptions {
24
28
  skipBuild?: boolean;
25
29
  /** Force rebuild before deploy. */
26
30
  rebuild?: boolean;
31
+ /** Build the Docker image locally, then exit. Does NOT touch the remote. */
32
+ buildOnly?: boolean;
33
+ /**
34
+ * Rollback / pin to a specific image tag. Skips build + push, only updates
35
+ * /opt/arc/.env on the host and triggers `docker compose pull/up`.
36
+ * Format: bare content hash (e.g. `abc123def456`) or full ref.
37
+ */
38
+ imageTag?: string;
27
39
  }
28
40
 
29
41
  // ---------------------------------------------------------------------------
30
- // Entry point for `arc platform deploy [env] [--skip-build] [--rebuild]`.
42
+ // Entry point for `arc platform deploy [env]`.
31
43
  //
32
- // High-level flow:
44
+ // Flow:
33
45
  // 1. resolveWorkspace
34
- // 2. load or survey deploy.arc.json
35
- // 3. ensure local build (buildAll unless --skip-build)
36
- // 4. detectRemoteState bootstrap if needed
37
- // 5. for each env (or the one passed as arg): syncEnv
46
+ // 2. Load or survey deploy.arc.json
47
+ // 3. Ensure local build (buildAll unless --skip-build)
48
+ // 4. Build Docker image (or accept --image-tag for rollback)
49
+ // 5. dockerLogin + dockerPush
50
+ // 6. Detect remote state → bootstrap if needed
51
+ // 7. For each env: updateEnvDeployment (atomic .env line + pull + up + health)
38
52
  // ---------------------------------------------------------------------------
39
53
 
40
54
  export async function platformDeploy(
@@ -63,24 +77,63 @@ export async function platformDeploy(
63
77
  })()
64
78
  : Object.keys(cfg.envs);
65
79
 
66
- // 2. Ensure local build
80
+ // 2. Ensure local build (unless --image-tag rollback skips build+push entirely)
67
81
  const manifestPath = join(ws.modulesDir, "manifest.json");
68
- const needBuild = options.rebuild || !existsSync(manifestPath);
69
- if (needBuild && !options.skipBuild) {
70
- log("Building platform...");
71
- await buildAll(ws, { noCache: options.rebuild });
72
- ok("Build complete");
73
- } else if (!existsSync(manifestPath)) {
74
- err("No build found and --skip-build was set.");
75
- process.exit(1);
82
+ if (!options.imageTag) {
83
+ const needBuild = options.rebuild || !existsSync(manifestPath);
84
+ if (needBuild && !options.skipBuild) {
85
+ log("Building platform...");
86
+ await buildAll(ws, { noCache: options.rebuild });
87
+ ok("Build complete");
88
+ } else if (!existsSync(manifestPath)) {
89
+ err("No build found and --skip-build was set.");
90
+ process.exit(1);
91
+ }
76
92
  }
77
93
 
78
- // 3. Detect remote state
94
+ // 3. Resolve the full image ref. Two paths:
95
+ // a) --image-tag <hash> — rollback / pin. No build, no push.
96
+ // b) Default — buildImage locally, push to private registry.
97
+ const imageName = sanitizeImageName(ws.rootPkg.name ?? ws.appName);
98
+ let fullRef: string;
99
+ let contentHash: string;
100
+
101
+ if (options.imageTag) {
102
+ contentHash = options.imageTag.includes(":")
103
+ ? options.imageTag.split(":").pop()!
104
+ : options.imageTag;
105
+ fullRef = `${cfg.registry.domain}/${imageName}:${contentHash}`;
106
+ log(`Pinning to existing image ${fullRef} (skipping build + push)`);
107
+ } else {
108
+ log(`Building Docker image ${imageName}...`);
109
+ const result = await buildImage(ws, {
110
+ imageName,
111
+ registryDomain: cfg.registry.domain,
112
+ });
113
+ fullRef = result.fullRef;
114
+ contentHash = result.contentHash;
115
+ ok(`Image built: ${fullRef}`);
116
+
117
+ // 3b. --build-only: produce image, log, exit before push/deploy.
118
+ if (options.buildOnly) {
119
+ log(`contentHash: ${contentHash}`);
120
+ return;
121
+ }
122
+
123
+ // 4. Push to the private registry. dockerLogin reads password from the
124
+ // env var named in cfg.registry.passwordEnv.
125
+ log(`Logging in to ${cfg.registry.domain}...`);
126
+ await dockerLogin(cfg.registry);
127
+ log(`Pushing ${fullRef}...`);
128
+ await dockerPush(fullRef);
129
+ ok("Image pushed");
130
+ }
131
+
132
+ // 5. Detect remote state, bootstrap if needed
79
133
  log("Inspecting remote server...");
80
134
  const state = await detectRemoteState(cfg);
81
135
  log(`Remote state: ${state.kind}`);
82
136
 
83
- // 4. Bootstrap if needed
84
137
  const cliVersion = readCliVersion();
85
138
  const configHash = await hashDeployConfig(ws.rootDir);
86
139
  if (state.kind !== "ready") {
@@ -93,29 +146,21 @@ export async function platformDeploy(
93
146
  });
94
147
  }
95
148
 
96
- // 5. Sync each env
149
+ // 6. Update each env — atomic /opt/arc/.env line + pull + up + health
97
150
  for (const env of targetEnvs) {
98
- log(`Syncing env "${env}"...`);
99
- const outcome = await syncEnv({
151
+ log(`Updating env "${env}"...`);
152
+ const outcome = await updateEnvDeployment({
153
+ target: cfg.target,
100
154
  cfg,
101
155
  env,
102
- ws,
103
- projectDir: ws.rootDir,
156
+ fullRef,
104
157
  });
105
- if (
106
- outcome.changedModules.length === 0 &&
107
- !outcome.shellChanged &&
108
- !outcome.stylesChanged
109
- ) {
110
- ok(`${env}: already up to date`);
158
+ if (outcome.redeployed) {
159
+ ok(`${env}: live at ${fullRef}`);
111
160
  } else {
112
- const parts: string[] = [];
113
- if (outcome.changedModules.length > 0) {
114
- parts.push(`${outcome.changedModules.length} module(s): ${outcome.changedModules.join(", ")}`);
115
- }
116
- if (outcome.shellChanged) parts.push("shell");
117
- if (outcome.stylesChanged) parts.push("styles");
118
- ok(`${env}: updated ${parts.join(", ")}`);
161
+ err(
162
+ `${env}: deployed but health check did not pass within retries — check \`docker logs arc-${env}\``,
163
+ );
119
164
  }
120
165
  }
121
166
  }
@@ -124,30 +169,33 @@ export async function platformDeploy(
124
169
  // Helpers
125
170
  // ---------------------------------------------------------------------------
126
171
 
172
+ /**
173
+ * Read the arc-cli package version by walking up from this file until we
174
+ * find a package.json with `name: "@arcote.tech/arc-cli"`. Source and
175
+ * bundled layouts have different depths (source: src/commands/, bundle:
176
+ * dist/), so a fixed `..` count doesn't work — walk until we hit the
177
+ * canonical manifest.
178
+ */
127
179
  function readCliVersion(): string {
128
- // import.meta.dir gets mangled by `bun build` — derive from process.argv[1]
129
- // (the bundled dist/index.js path) which is stable across run modes.
130
- const candidates: string[] = [];
131
- const entry = process.argv[1];
132
- if (entry) {
133
- candidates.push(join(dirname(entry), "..", "package.json"));
134
- }
135
180
  try {
136
- candidates.push(join(import.meta.dir, "..", "..", "package.json"));
137
- } catch {
138
- // import.meta.dir unavailable
139
- }
140
- for (const path of candidates) {
141
- try {
142
- const pkg = JSON.parse(readFileSync(path, "utf-8"));
143
- if (pkg.name === "@arcote.tech/arc-cli" && pkg.version) {
144
- return pkg.version as string;
181
+ let cur = dirname(fileURLToPath(import.meta.url));
182
+ const root = dirname(cur).startsWith("/") ? "/" : ".";
183
+ while (cur !== root && cur !== "") {
184
+ const candidate = join(cur, "package.json");
185
+ if (existsSync(candidate)) {
186
+ const pkg = JSON.parse(readFileSync(candidate, "utf-8"));
187
+ if (pkg.name === "@arcote.tech/arc-cli") {
188
+ return pkg.version ?? "unknown";
189
+ }
145
190
  }
146
- } catch {
147
- // Try next
191
+ const parent = dirname(cur);
192
+ if (parent === cur) break;
193
+ cur = parent;
148
194
  }
195
+ return "unknown";
196
+ } catch {
197
+ return "unknown";
149
198
  }
150
- return "unknown";
151
199
  }
152
200
 
153
201
  async function hashDeployConfig(rootDir: string): Promise<string> {