@arcote.tech/arc-cli 0.7.25 → 0.7.27

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arcote.tech/arc-cli",
3
- "version": "0.7.25",
3
+ "version": "0.7.27",
4
4
  "description": "CLI tool for Arc framework",
5
5
  "module": "index.ts",
6
6
  "main": "dist/index.js",
@@ -9,16 +9,16 @@
9
9
  "arc": "./dist/index.js"
10
10
  },
11
11
  "scripts": {
12
- "build": "bun build --target=bun ./src/index.ts --outdir=dist --external @arcote.tech/arc --external @arcote.tech/arc-ds --external @arcote.tech/arc-react --external @arcote.tech/platform --external '@opentelemetry/*' && chmod +x dist/index.js"
12
+ "build": "bun build --target=bun ./src/index.ts --outdir=dist --external @arcote.tech/arc --external @arcote.tech/arc-ds --external @arcote.tech/arc-react --external @arcote.tech/platform --external @arcote.tech/arc-map --external '@opentelemetry/*' && chmod +x dist/index.js"
13
13
  },
14
14
  "dependencies": {
15
- "@arcote.tech/arc": "^0.7.25",
16
- "@arcote.tech/arc-ds": "^0.7.25",
17
- "@arcote.tech/arc-react": "^0.7.25",
18
- "@arcote.tech/arc-host": "^0.7.25",
19
- "@arcote.tech/arc-adapter-db-sqlite": "^0.7.25",
20
- "@arcote.tech/arc-adapter-db-postgres": "^0.7.25",
21
- "@arcote.tech/arc-otel": "^0.7.25",
15
+ "@arcote.tech/arc": "^0.7.27",
16
+ "@arcote.tech/arc-ds": "^0.7.27",
17
+ "@arcote.tech/arc-react": "^0.7.27",
18
+ "@arcote.tech/arc-host": "^0.7.27",
19
+ "@arcote.tech/arc-adapter-db-sqlite": "^0.7.27",
20
+ "@arcote.tech/arc-adapter-db-postgres": "^0.7.27",
21
+ "@arcote.tech/arc-otel": "^0.7.27",
22
22
  "@opentelemetry/api": "^1.9.0",
23
23
  "@opentelemetry/api-logs": "^0.57.0",
24
24
  "@opentelemetry/core": "^1.30.0",
@@ -31,7 +31,8 @@
31
31
  "@opentelemetry/sdk-trace-base": "^1.30.0",
32
32
  "@opentelemetry/sdk-trace-node": "^1.30.0",
33
33
  "@opentelemetry/semantic-conventions": "^1.27.0",
34
- "@arcote.tech/platform": "^0.7.25",
34
+ "@arcote.tech/platform": "^0.7.27",
35
+ "@arcote.tech/arc-map": "^0.7.27",
35
36
  "@clack/prompts": "^0.9.0",
36
37
  "commander": "^11.1.0",
37
38
  "chokidar": "^3.5.3",
@@ -3,7 +3,8 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
3
3
  import { basename, dirname, join } from "path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { FRAMEWORK_PEERS, FRAMEWORK_PEER_SET } from "./framework-peers";
6
- import type { WorkspacePackage } from "./module-builder";
6
+ import { readInstalledVersion } from "./hash";
7
+ import type { ServerExternal, WorkspacePackage } from "./module-builder";
7
8
 
8
9
  export { FRAMEWORK_PEERS };
9
10
 
@@ -37,10 +38,17 @@ export function collectFrameworkDeps(
37
38
  rootDir: string,
38
39
  packages: WorkspacePackage[],
39
40
  sharedDeps: ReadonlyArray<{ name: string; version: string }> = [],
41
+ serverExternals: ReadonlyArray<ServerExternal> = [],
40
42
  ): CollectedDeps {
41
43
  mkdirSync(arcDir, { recursive: true });
42
44
 
43
45
  const versions = resolveFrameworkVersions(rootDir, packages);
46
+ // npm packages the server bundle left external — the runtime image must
47
+ // install them into /app/node_modules. This is the ONLY source for deps
48
+ // reached through a fragment the consumer never declares (e.g.
49
+ // `@arcote.tech/arc-files` → `@aws-sdk/client-s3`): the bundle records what
50
+ // it actually externalized, so the manifest tracks real usage, no whitelist.
51
+ mergeServerExternals(versions, rootDir, packages, serverExternals);
44
52
  // Shared deps (discovered across ≥ 2 workspace packages) join the framework
45
53
  // in the global manifest — installed once into <arcDir>/node_modules/ and
46
54
  // served via the shell so each module's browser bundle leaves them external.
@@ -178,12 +186,13 @@ function resolveFrameworkVersions(
178
186
  packages: WorkspacePackage[],
179
187
  ): Record<string, string> {
180
188
  // The framework manifest installs into /app/node_modules in the deploy
181
- // image. It needs to cover:
189
+ // image. resolveFrameworkVersions covers the singleton @arcote.tech/* set:
182
190
  // 1. Static framework peers (FRAMEWORK_PEERS) — always present
183
191
  // 2. User-extension @arcote.tech/* peers (e.g. arc-ai-openai) declared
184
192
  // as peerDeps on user context packages
185
- // 3. npm runtime deps that server bundles leave external (non-workspace
186
- // deps not yet bundled inline)
193
+ // Plain npm runtime deps the server bundle leaves external are added
194
+ // separately by mergeServerExternals (driven by what the bundle actually
195
+ // externalized, not by what workspace packages happen to declare).
187
196
  // Resolution order for each: root package.json wins, then any workspace
188
197
  // package's deps/peerDeps, then "*" as last resort.
189
198
  const rootPkg = JSON.parse(
@@ -196,18 +205,10 @@ function resolveFrameworkVersions(
196
205
  const required = new Set<string>(FRAMEWORK_PEERS);
197
206
  for (const pkg of packages) {
198
207
  const peers = pkg.packageJson.peerDependencies ?? {};
199
- const deps = (pkg.packageJson.dependencies ?? {}) as Record<string, string>;
200
208
  // Any @arcote.tech/* peer beyond the static list (e.g. arc-ai adapters).
201
209
  for (const name of Object.keys(peers)) {
202
210
  if (name.startsWith("@arcote.tech/")) required.add(name);
203
211
  }
204
- // npm deps (non-workspace) — server bundles leave them external, so the
205
- // image needs them in /app/node_modules.
206
- for (const [name, spec] of Object.entries(deps)) {
207
- if (spec.startsWith("workspace:")) continue;
208
- if (name.startsWith("@arcote.tech/") || !isFrameworkExternal(name)) continue;
209
- required.add(name);
210
- }
211
212
  }
212
213
 
213
214
  const out: Record<string, string> = {};
@@ -217,32 +218,58 @@ function resolveFrameworkVersions(
217
218
  out[name] = rootSpec;
218
219
  continue;
219
220
  }
220
- let found: string | undefined;
221
- for (const pkg of packages) {
222
- const spec =
223
- (pkg.packageJson.dependencies ?? {})[name] ??
224
- (pkg.packageJson.peerDependencies ?? {})[name];
225
- if (spec) {
226
- found = spec;
227
- break;
228
- }
229
- }
230
- out[name] = found ?? "*";
221
+ out[name] = findWorkspaceSpec(packages, name) ?? "*";
231
222
  }
232
223
  return out;
233
224
  }
234
225
 
226
+ /** Version spec for `name` declared by any workspace package, if any. */
227
+ function findWorkspaceSpec(
228
+ packages: WorkspacePackage[],
229
+ name: string,
230
+ ): string | undefined {
231
+ for (const pkg of packages) {
232
+ const spec =
233
+ (pkg.packageJson.dependencies ?? {})[name] ??
234
+ (pkg.packageJson.peerDependencies ?? {})[name];
235
+ if (spec) return spec;
236
+ }
237
+ return undefined;
238
+ }
239
+
235
240
  /**
236
- * True if a non-arc npm dep is one the image must install globally for
237
- * runtime resolution. Today we include everything that user context packages
238
- * declare as deps but isn't `workspace:` — server bundles leave them external
239
- * so they MUST be present in /app/node_modules.
240
- *
241
- * Future optimization: bundle these inline into the server bundle to avoid
242
- * the install layer entirely. Left as a follow-up.
241
+ * Add server-bundle externals to the install manifest. For each, resolve a
242
+ * version spec by cascade so the image installs something compatible even when
243
+ * the consumer never declared the dep:
244
+ * 1. consumer root deps/devDeps (their explicit pin wins)
245
+ * 2. spec recorded from the importing package (e.g. arc-files peerDeps)
246
+ * 3. any workspace package's declaration
247
+ * 4. the version actually installed in node_modules (caret-ranged)
248
+ * 5. "*" as last resort
249
+ * Already-resolved names (framework peers) are left untouched.
243
250
  */
244
- function isFrameworkExternal(_name: string): boolean {
245
- return true;
251
+ function mergeServerExternals(
252
+ versions: Record<string, string>,
253
+ rootDir: string,
254
+ packages: WorkspacePackage[],
255
+ externals: ReadonlyArray<ServerExternal>,
256
+ ): void {
257
+ const rootPkg = JSON.parse(
258
+ readFileSync(join(rootDir, "package.json"), "utf-8"),
259
+ );
260
+ const rootDeps = (rootPkg.dependencies ?? {}) as Record<string, string>;
261
+ const rootDevDeps = (rootPkg.devDependencies ?? {}) as Record<string, string>;
262
+
263
+ for (const { name, version } of externals) {
264
+ if (versions[name]) continue;
265
+ const installed = readInstalledVersion(rootDir, name);
266
+ versions[name] =
267
+ rootDeps[name] ??
268
+ rootDevDeps[name] ??
269
+ version ??
270
+ findWorkspaceSpec(packages, name) ??
271
+ (installed ? `^${installed}` : "*");
272
+ }
246
273
  }
247
274
 
248
275
  function sha256OfFiles(paths: string[]): string {
@@ -8,6 +8,7 @@ import {
8
8
  writeFileSync,
9
9
  } from "fs";
10
10
  import { basename, dirname, join, relative } from "path";
11
+ import { builtinModules } from "node:module";
11
12
  import type { BuildManifest, ModuleDescriptor } from "@arcote.tech/platform";
12
13
  import { buildTypeDeclarations } from "../utils/build";
13
14
  import { i18nExtractPlugin, finalizeTranslations } from "../i18n";
@@ -18,7 +19,11 @@ import {
18
19
  type BuildCache,
19
20
  } from "./build-cache";
20
21
  import type { ChunkPlan } from "./chunk-planner";
21
- import { SHELL_EXTERNALS, FRAMEWORK_PEERS } from "./framework-peers";
22
+ import {
23
+ SHELL_EXTERNALS,
24
+ FRAMEWORK_PEERS,
25
+ FRAMEWORK_PEER_SET,
26
+ } from "./framework-peers";
22
27
  import {
23
28
  readInstalledVersion,
24
29
  sha256Hex,
@@ -97,30 +102,101 @@ function singleReactPlugin(rootDir: string): import("bun").BunPlugin {
97
102
  };
98
103
  }
99
104
 
105
+ /** One external the server bundle leaves for the deploy image to install. */
106
+ export interface ServerExternal {
107
+ /** Package name (subpath stripped): `@aws-sdk/client-s3`, `react`, … */
108
+ readonly name: string;
109
+ /** Version spec read from the importing package's package.json, if found. */
110
+ readonly version?: string;
111
+ }
112
+
113
+ const BUILTIN_SET = new Set<string>(builtinModules);
114
+
115
+ /** Package name from a bare specifier, dropping any subpath. */
116
+ function pkgNameOf(spec: string): string {
117
+ if (spec.startsWith("@")) return spec.split("/").slice(0, 2).join("/");
118
+ return spec.split("/")[0];
119
+ }
120
+
121
+ /** node:/bun: builtins — external by `target: "bun"`, never installed. */
122
+ function isBuiltinSpec(spec: string): boolean {
123
+ if (spec.startsWith("node:") || spec.startsWith("bun:") || spec === "bun")
124
+ return true;
125
+ return BUILTIN_SET.has(spec) || BUILTIN_SET.has(pkgNameOf(spec));
126
+ }
127
+
100
128
  /**
101
- * Marks heavy server-side packages as external. Bun.build's `external: string[]`
102
- * accepts only literal package names wildcard like `@aws-sdk/*` is treated
103
- * verbatim and doesn't match anything. This plugin uses `onResolve` regex to
104
- * catch any subpackage matching the prefix and marks it external.
105
- *
106
- * Removes ~10-15 MB of AWS SDK v3 from every server bundle that transitively
107
- * pulls `@arcote.tech/arc-files` (e.g. `@ndt/content` was 340 MB before).
129
+ * Best-effort version spec for `name` as declared by the package that imports
130
+ * it. Walks up from the importer to the nearest package.json that lists `name`
131
+ * in deps/peerDeps/devDeps e.g. `@aws-sdk/client-s3` resolves to the
132
+ * `^3.x` spec in `@arcote.tech/arc-files`'s peerDependencies. The collector
133
+ * falls back to root deps / installed version when this returns undefined.
134
+ */
135
+ function versionFromImporter(importer: string, name: string): string | undefined {
136
+ let dir = importer ? dirname(importer) : "";
137
+ while (dir && dir !== dirname(dir)) {
138
+ const pj = join(dir, "package.json");
139
+ if (existsSync(pj)) {
140
+ try {
141
+ const json = JSON.parse(readFileSync(pj, "utf-8"));
142
+ const spec =
143
+ json.dependencies?.[name] ??
144
+ json.peerDependencies?.[name] ??
145
+ json.devDependencies?.[name];
146
+ if (typeof spec === "string") return spec;
147
+ } catch {
148
+ // unreadable package.json — keep walking
149
+ }
150
+ }
151
+ dir = dirname(dir);
152
+ }
153
+ return undefined;
154
+ }
155
+
156
+ /**
157
+ * Structural externalization for the combined server bundle: inline only
158
+ * first-party code (workspace `@ndt/*` source — handled earlier by
159
+ * `workspaceSourcePlugin` — plus non-peer `@arcote.tech/*` fragments like
160
+ * `arc-files`), and leave EVERY third-party npm package external. The deploy
161
+ * image installs externals into `/app/node_modules` anyway, so inlining them
162
+ * only bloats the bundle. Replaces the old hardcoded `@aws-sdk|@smithy` regex
163
+ * (a band-aid from the 1.1 GB nested-dist era, since fixed by the single
164
+ * source-based build) — no package-name whitelist to keep in sync.
108
165
  *
109
- * Runtime requirement: each external package must be available in
110
- * `node_modules/` at server startup (Bun resolves bare specifiers). Consumer
111
- * apps declare them in root `package.json` dependencies.
166
+ * Records each externalized package + the version spec of the package that
167
+ * imports it, so `collectFrameworkDeps` can emit them into the install
168
+ * manifest. This is what closes the gap for deps reached through a fragment
169
+ * the consumer never declares (arc-files → aws-sdk).
112
170
  */
113
- function serverExternalsPlugin(): import("bun").BunPlugin {
171
+ function npmExternalsPlugin(
172
+ workspaceNames: ReadonlySet<string>,
173
+ recorded: Map<string, string | undefined>,
174
+ ): import("bun").BunPlugin {
114
175
  return {
115
- name: "server-externals",
176
+ name: "npm-externals",
116
177
  setup(build) {
117
- // @aws-sdk/* and @smithy/* full AWS SDK v3 + low-level signing/HTTP
118
- // primitives it depends on. Add more prefixes here if other heavy
119
- // server-only libs appear (e.g. `@google-cloud/*`).
120
- build.onResolve({ filter: /^(@aws-sdk|@smithy)\// }, (args) => ({
121
- path: args.path,
122
- external: true,
123
- }));
178
+ // Bare specifiers only. An exact workspace name (`@ndt/foo`) is resolved
179
+ // to source earlier by workspaceSourcePlugin and never reaches here; a
180
+ // workspace SUBPATH (`@ndt/foo/bar`) does, and must NOT be externalized.
181
+ build.onResolve({ filter: /^[^./]/ }, (args) => {
182
+ if (isBuiltinSpec(args.path)) {
183
+ return { path: args.path, external: true };
184
+ }
185
+ const name = pkgNameOf(args.path);
186
+ // First-party workspace package (subpath import) — let Bun resolve and
187
+ // inline it; `workspace:*` is not installable from npm.
188
+ if (workspaceNames.has(name)) return null;
189
+ // First-party non-peer fragments inline from source/dist so
190
+ // SERVER_DEFINES (ONLY_SERVER…) apply and import cycles stay safe.
191
+ if (name.startsWith("@arcote.tech/") && !FRAMEWORK_PEER_SET.has(name)) {
192
+ return null;
193
+ }
194
+ const version = versionFromImporter(args.importer, name);
195
+ // Defensive: any `workspace:` dep is first-party — never externalize it.
196
+ if (version?.startsWith("workspace:")) return null;
197
+ if (!recorded.has(name)) recorded.set(name, version);
198
+ return { path: args.path, external: true };
199
+ });
124
200
  },
125
201
  };
126
202
  }
@@ -201,6 +277,11 @@ const SERVER_DEFINES = { ONLY_SERVER: "true", ONLY_BROWSER: "false", ONLY_CLIENT
201
277
  * auto-emitted `chunk-<hash>.js` siblings are pulled in transitively. */
202
278
  export const SERVER_ENTRY_FILE = "_server.js";
203
279
 
280
+ /** Sidecar listing third-party packages the server bundle left external.
281
+ * Written next to the bundle so a cached rebuild can still feed the install
282
+ * manifest (collectFrameworkDeps) without re-running Bun.build. */
283
+ export const SERVER_EXTERNALS_FILE = "_externals.json";
284
+
204
285
  export interface WorkspacePackage {
205
286
  name: string;
206
287
  path: string;
@@ -502,6 +583,12 @@ export async function buildContextPackages(
502
583
  export interface ServerAppResult {
503
584
  readonly entryFile: string;
504
585
  readonly cached: boolean;
586
+ /**
587
+ * Third-party npm packages the bundle left external — the deploy image must
588
+ * install exactly these. Fed to `collectFrameworkDeps`. Empty on a cache hit
589
+ * (the manifest from the cached build is already on disk).
590
+ */
591
+ readonly externals: ReadonlyArray<ServerExternal>;
505
592
  }
506
593
 
507
594
  export async function buildServerApp(
@@ -548,9 +635,17 @@ export async function buildServerApp(
548
635
  });
549
636
 
550
637
  const entryFileAbs = join(serverDir, SERVER_ENTRY_FILE);
638
+ const externalsFileAbs = join(serverDir, SERVER_EXTERNALS_FILE);
551
639
  if (!noCache && isCacheHit(cache, unitId, inputHash, [entryFileAbs])) {
552
640
  console.log(` ✓ cached: ${unitId}`);
553
- return { entryFile: SERVER_ENTRY_FILE, cached: true };
641
+ // collectFrameworkDeps runs every build (uncached) and needs the external
642
+ // set to build the install manifest — reload it from the sidecar written
643
+ // by the last real build so a cache hit doesn't drop deps from the manifest.
644
+ return {
645
+ entryFile: SERVER_ENTRY_FILE,
646
+ cached: true,
647
+ externals: readServerExternals(externalsFileAbs),
648
+ };
554
649
  }
555
650
 
556
651
  console.log(` building: ${unitId} (${contexts.length} server modules)`);
@@ -571,6 +666,10 @@ export async function buildServerApp(
571
666
  contexts.map((p) => `import "${p.name}";`).join("\n") + "\n",
572
667
  );
573
668
 
669
+ // npmExternalsPlugin records every third-party package it leaves external,
670
+ // plus the version spec of whatever imports it (best-effort).
671
+ const recorded = new Map<string, string | undefined>();
672
+
574
673
  let result;
575
674
  try {
576
675
  result = await Bun.build({
@@ -586,8 +685,11 @@ export async function buildServerApp(
586
685
  external,
587
686
  plugins: [
588
687
  jsxDevShimPlugin(),
589
- serverExternalsPlugin(),
688
+ // workspaceSourcePlugin MUST precede npmExternalsPlugin: the first
689
+ // onResolve to return a result wins, so `@ndt/*` source is pinned
690
+ // before the catch-all externalizer can mark it external.
590
691
  workspaceSourcePlugin(srcByName),
692
+ npmExternalsPlugin(new Set(srcByName.keys()), recorded),
591
693
  ],
592
694
  define: SERVER_DEFINES,
593
695
  });
@@ -610,9 +712,28 @@ export async function buildServerApp(
610
712
  );
611
713
  }
612
714
 
715
+ // Third-party externals only — framework peers carry their own resolution in
716
+ // collectFrameworkDeps, so drop them here to avoid double-sourcing versions.
717
+ const externals: ServerExternal[] = [...recorded]
718
+ .filter(([name]) => !FRAMEWORK_PEER_SET.has(name))
719
+ .map(([name, version]) => ({ name, version }))
720
+ .sort((a, b) => a.name.localeCompare(b.name));
721
+ writeFileSync(externalsFileAbs, JSON.stringify(externals, null, 2) + "\n");
722
+
613
723
  const outputHash = sha256OfDir(serverDir);
614
724
  updateCache(cache, unitId, inputHash, { outputHash });
615
- return { entryFile: SERVER_ENTRY_FILE, cached: false };
725
+ return { entryFile: SERVER_ENTRY_FILE, cached: false, externals };
726
+ }
727
+
728
+ /** Read the externals sidecar written by the last real server build. */
729
+ function readServerExternals(path: string): ServerExternal[] {
730
+ if (!existsSync(path)) return [];
731
+ try {
732
+ const parsed = JSON.parse(readFileSync(path, "utf-8"));
733
+ return Array.isArray(parsed) ? (parsed as ServerExternal[]) : [];
734
+ } catch {
735
+ return [];
736
+ }
616
737
  }
617
738
 
618
739
 
@@ -3,12 +3,11 @@ import { resolveWorkspace } from "../platform/shared";
3
3
 
4
4
  /** `arc platform dev` — dev mode (watcher + SSE reload + no-cache headers). */
5
5
  export async function platformDev(
6
- opts: { noCache?: boolean } = {},
6
+ opts: { noCache?: boolean; map?: boolean } = {},
7
7
  ): Promise<void> {
8
8
  const ws = resolveWorkspace();
9
9
  // noCache is consumed by buildAll inside startPlatform when devMode=true.
10
10
  // For now the dev startup always uses the cache after the first build;
11
11
  // explicit --no-cache here only matters if we wire it through later.
12
- void opts;
13
- await startPlatform({ ws, devMode: true });
12
+ await startPlatform({ ws, devMode: true, map: opts.map });
14
13
  }
package/src/index.ts CHANGED
@@ -34,8 +34,9 @@ platform
34
34
  .command("dev")
35
35
  .description("Start platform in dev mode (Bun server + Vite HMR)")
36
36
  .option("--no-cache", "Force full rebuild on startup")
37
- .action((opts: { cache?: boolean }) =>
38
- platformDev({ noCache: opts.cache === false }),
37
+ .option("--map", "Also start the Arc Context Map dev tool on a separate port")
38
+ .action((opts: { cache?: boolean; map?: boolean }) =>
39
+ platformDev({ noCache: opts.cache === false, map: opts.map }),
39
40
  );
40
41
 
41
42
  platform
@@ -172,13 +172,8 @@ export async function buildAll(
172
172
  // into multi-hundred-MB files. The deploy image COPYs this dir wholesale;
173
173
  // loadServerContext + the access extractor import the entry, chunks ride along.
174
174
  const serverDir = join(ws.arcDir, "server");
175
- const { entryFile: serverEntry } = await buildServerApp(
176
- ws.rootDir,
177
- serverDir,
178
- ws.packages,
179
- cache,
180
- noCache,
181
- );
175
+ const { entryFile: serverEntry, externals: serverExternals } =
176
+ await buildServerApp(ws.rootDir, serverDir, ws.packages, cache, noCache);
182
177
 
183
178
  // Phase 2 — extract access metadata (token name + hasCheck per module) in
184
179
  // an isolated subprocess that imports the combined server bundle. MUST run
@@ -223,7 +218,7 @@ export async function buildAll(
223
218
  // Phase 5 — framework peer manifest at `<arcDir>/package.json`. Used by
224
219
  // the deploy image build (single `bun install` for all peers, one copy
225
220
  // shared across module bundles).
226
- collectFrameworkDeps(ws.arcDir, ws.rootDir, ws.packages);
221
+ collectFrameworkDeps(ws.arcDir, ws.rootDir, ws.packages, [], serverExternals);
227
222
 
228
223
  saveBuildCache(ws.arcDir, cache);
229
224
 
@@ -35,6 +35,8 @@ export interface StartPlatformOptions {
35
35
  dbPath?: string;
36
36
  /** Dev mode: rebuild on startup, watch for changes, SSE-notify clients on rebuild. */
37
37
  devMode: boolean;
38
+ /** Dev-only: also start the Arc Context Map tool on a separate port. */
39
+ map?: boolean;
38
40
  }
39
41
 
40
42
  export async function startPlatform(
@@ -111,9 +113,16 @@ export async function startPlatform(
111
113
  ok("Commands, queries, WebSocket — all on same port");
112
114
  }
113
115
 
116
+ // 4a. Dev-only: optional Arc Context Map tool on a separate port, starting
117
+ // just above the platform's actual port (auto-increments if busy).
118
+ let onReload: (() => void) | undefined;
119
+ if (devMode && opts.map) {
120
+ onReload = await startMapTool(ws, actualPort + 1);
121
+ }
122
+
114
123
  // 4. Dev-only: file watcher + debounced rebuild + SSE notify.
115
124
  if (devMode) {
116
- attachDevWatcher(ws, platform);
125
+ attachDevWatcher(ws, platform, onReload);
117
126
  }
118
127
 
119
128
  // 5. Graceful shutdown for both modes.
@@ -125,7 +134,42 @@ export async function startPlatform(
125
134
  process.on("SIGINT", cleanup);
126
135
  }
127
136
 
128
- function attachDevWatcher(ws: WorkspaceInfo, platform: PlatformServer): void {
137
+ /**
138
+ * Start the Arc Context Map dev tool on a separate port. Loaded lazily so the
139
+ * ts-morph dependency stays out of the normal `platform dev` path. Returns a
140
+ * reload hook to wire into the file watcher, or undefined on failure.
141
+ */
142
+ async function startMapTool(
143
+ ws: WorkspaceInfo,
144
+ port: number,
145
+ ): Promise<(() => void) | undefined> {
146
+ try {
147
+ const { startMapServer } = await import("@arcote.tech/arc-map");
148
+ const { getContext } = await import("@arcote.tech/platform");
149
+ const globs = ws.packages
150
+ .map((p) => join(p.path, "src", "**", "*.{ts,tsx}"));
151
+ const packageOf = (absFile: string): string | undefined =>
152
+ ws.packages.find((p) => absFile.startsWith(join(p.path, "src")))?.name;
153
+
154
+ const map = await startMapServer({
155
+ getContext: () => getContext(),
156
+ scan: { globs, workspaceRoot: ws.rootDir, packageOf },
157
+ useCasePackages: ws.packages.map((p) => ({ name: p.name, path: p.path })),
158
+ port,
159
+ });
160
+ ok(`Context map → ${map.url}`);
161
+ return map.notifyReload;
162
+ } catch (e) {
163
+ err(`Context map failed to start: ${(e as Error).message}`);
164
+ return undefined;
165
+ }
166
+ }
167
+
168
+ function attachDevWatcher(
169
+ ws: WorkspaceInfo,
170
+ platform: PlatformServer,
171
+ onReload?: () => void,
172
+ ): void {
129
173
  log("Watching for changes...");
130
174
  let rebuildTimer: ReturnType<typeof setTimeout> | null = null;
131
175
  let isRebuilding = false;
@@ -140,6 +184,7 @@ function attachDevWatcher(ws: WorkspaceInfo, platform: PlatformServer): void {
140
184
  const next = await buildAll(ws);
141
185
  platform.setManifest(next);
142
186
  platform.notifyReload(next);
187
+ onReload?.();
143
188
  ok(
144
189
  `Rebuilt — initial + ${Object.keys(next.groups).length} group(s)`,
145
190
  );
@@ -1,5 +1,13 @@
1
1
  import { spawn } from "child_process";
2
- import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "fs";
2
+ import {
3
+ cpSync,
4
+ existsSync,
5
+ mkdirSync,
6
+ readFileSync,
7
+ rmSync,
8
+ unlinkSync,
9
+ writeFileSync,
10
+ } from "fs";
3
11
  import { dirname, join } from "path";
4
12
  import type { ArcConfig, ContextInfo } from "./config";
5
13
  import { generateClientTypes } from "./config";
@@ -152,6 +160,37 @@ export interface DeclarationResult {
152
160
  * @param globalsContent Optional ambient declarations (ONLY_SERVER etc.) —
153
161
  * written to a temp .d.ts and included in compilation.
154
162
  */
163
+ /**
164
+ * Emituje deklaracje do katalogu TYMCZASOWEGO (siostra `outDir`), a po sukcesie
165
+ * scala je do `outDir`. Zapobiega TS5055 ("Cannot write file … would overwrite
166
+ * input file") gdy SAMO-REFERENCYJNY cykl importów pakietu (np. workspace →
167
+ * billing → workspace) wciąga własny `outDir/*.d.ts` do programu jako input —
168
+ * wtedy emisja wprost do `outDir` kolidowałaby z tym plikiem-inputem. Program ma
169
+ * `outDir` = temp, więc nie pisze po pliku czytanym jako wejście; świeże
170
+ * deklaracje przenosimy na miejsce dopiero po emisji.
171
+ */
172
+ async function runDeclEmit(
173
+ files: string[],
174
+ outDir: string,
175
+ cwd: string,
176
+ ): Promise<DeclarationResult> {
177
+ const tmpOut = `${outDir}.decltmp`;
178
+ rmSync(tmpOut, { recursive: true, force: true });
179
+ try {
180
+ let result = await runTsgo(files, tmpOut, cwd);
181
+ if (result === null) {
182
+ result = await runTsc(files, tmpOut, cwd);
183
+ }
184
+ if (result.success) {
185
+ mkdirSync(outDir, { recursive: true });
186
+ cpSync(tmpOut, outDir, { recursive: true });
187
+ }
188
+ return result;
189
+ } finally {
190
+ rmSync(tmpOut, { recursive: true, force: true });
191
+ }
192
+ }
193
+
155
194
  export async function buildTypeDeclarations(
156
195
  files: string[],
157
196
  outDir: string,
@@ -170,10 +209,7 @@ export async function buildTypeDeclarations(
170
209
  allFiles.push(globalsPath);
171
210
  }
172
211
 
173
- let result = await runTsgo(allFiles, outDir, rootDir);
174
- if (result === null) {
175
- result = await runTsc(allFiles, outDir, rootDir);
176
- }
212
+ const result = await runDeclEmit(allFiles, outDir, rootDir);
177
213
 
178
214
  // Clean up temp globals
179
215
  if (globalsPath && existsSync(globalsPath)) {
@@ -204,11 +240,7 @@ export async function buildContextDeclarations(
204
240
  ? [context.fullPath, arcTypesPath]
205
241
  : [context.fullPath];
206
242
 
207
- let result = await runTsgo(files, outDir, configDir);
208
- if (result === null) {
209
- result = await runTsc(files, outDir, configDir);
210
- }
211
- return result;
243
+ return runDeclEmit(files, outDir, configDir);
212
244
  }
213
245
 
214
246
  // ---------------------------------------------------------------------------