@arcote.tech/arc-cli 0.6.2 → 0.7.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.
Files changed (37) hide show
  1. package/dist/index.js +1696 -1663
  2. package/package.json +7 -7
  3. package/src/builder/access-extractor.ts +64 -46
  4. package/src/builder/build-cache.ts +3 -1
  5. package/src/builder/chunk-planner.ts +107 -0
  6. package/src/builder/dependency-collector.ts +83 -41
  7. package/src/builder/framework-peers.ts +81 -0
  8. package/src/builder/module-builder.ts +322 -106
  9. package/src/commands/platform-build.ts +2 -1
  10. package/src/commands/platform-deploy.ts +121 -64
  11. package/src/commands/platform-dev.ts +11 -100
  12. package/src/commands/platform-start.ts +4 -90
  13. package/src/deploy/ansible.ts +23 -3
  14. package/src/deploy/assets/ansible/site.yml +23 -7
  15. package/src/deploy/assets.ts +23 -7
  16. package/src/deploy/bootstrap.ts +270 -10
  17. package/src/deploy/caddyfile.ts +19 -23
  18. package/src/deploy/compose.ts +44 -27
  19. package/src/deploy/config.ts +67 -3
  20. package/src/deploy/deploy-env.ts +129 -0
  21. package/src/deploy/env-file.ts +103 -0
  22. package/src/deploy/htpasswd.ts +28 -0
  23. package/src/deploy/image-template.ts +74 -0
  24. package/src/deploy/image.ts +243 -0
  25. package/src/deploy/registry.ts +79 -0
  26. package/src/deploy/ssh.ts +52 -122
  27. package/src/deploy/survey.ts +64 -0
  28. package/src/index.ts +20 -13
  29. package/src/platform/server.ts +119 -94
  30. package/src/platform/shared.ts +139 -292
  31. package/src/platform/startup.ts +159 -0
  32. package/runtime/Dockerfile +0 -29
  33. package/runtime/build-and-push.sh +0 -23
  34. package/runtime/entrypoint.sh +0 -58
  35. package/src/commands/build-shell.ts +0 -152
  36. package/src/deploy/remote-sync.ts +0 -321
  37. package/src/platform/deploy-api.ts +0 -400
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arcote.tech/arc-cli",
3
- "version": "0.6.2",
3
+ "version": "0.7.1",
4
4
  "description": "CLI tool for Arc framework",
5
5
  "module": "index.ts",
6
6
  "main": "dist/index.js",
@@ -12,12 +12,12 @@
12
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 && chmod +x dist/index.js"
13
13
  },
14
14
  "dependencies": {
15
- "@arcote.tech/arc": "^0.6.2",
16
- "@arcote.tech/arc-ds": "^0.6.2",
17
- "@arcote.tech/arc-react": "^0.6.2",
18
- "@arcote.tech/arc-host": "^0.6.2",
19
- "@arcote.tech/arc-adapter-db-sqlite": "^0.6.2",
20
- "@arcote.tech/platform": "^0.6.2",
15
+ "@arcote.tech/arc": "^0.7.1",
16
+ "@arcote.tech/arc-ds": "^0.7.1",
17
+ "@arcote.tech/arc-react": "^0.7.1",
18
+ "@arcote.tech/arc-host": "^0.7.1",
19
+ "@arcote.tech/arc-adapter-db-sqlite": "^0.7.1",
20
+ "@arcote.tech/platform": "^0.7.1",
21
21
  "@clack/prompts": "^0.9.0",
22
22
  "commander": "^11.1.0",
23
23
  "chokidar": "^3.5.3",
@@ -1,21 +1,34 @@
1
1
  import { spawn } from "bun";
2
- import { mkdirSync, readFileSync, unlinkSync, writeFileSync } from "fs";
3
- import { tmpdir } from "os";
4
- import { basename, join } from "path";
5
- import type { WorkspacePackage } from "./module-builder";
6
- import { isContextPackage } from "./module-builder";
2
+ import {
3
+ existsSync,
4
+ mkdirSync,
5
+ readFileSync,
6
+ unlinkSync,
7
+ writeFileSync,
8
+ } from "fs";
9
+ import { join } from "path";
10
+ import { isContextPackage, type WorkspacePackage } from "./module-builder";
7
11
 
8
12
  // ---------------------------------------------------------------------------
9
- // access-extractor — runs platform module-access discovery in an ISOLATED
10
- // subprocess so the global registry (singleton in @arcote.tech/platform) is
11
- // not polluted between builds. The subprocess imports each context bundle,
12
- // calls getAllModuleAccess(), and serializes a plain-JSON view to
13
- // `<arcDir>/access.json`.
13
+ // access-extractor — discovers per-module access rules (`protectedBy(...)`)
14
+ // from already-built server bundles, in an ISOLATED subprocess.
14
15
  //
15
- // The `check` callback per rule is NOT serialized it's a function reference
16
- // kept inside the server bundle. We only record `{ token: { name }, hasCheck }`.
17
- // Runtime resolves the actual check via the loaded server bundle at request
18
- // time.
16
+ // Why subprocess: the global module registry in @arcote.tech/platform is a
17
+ // singleton. Importing user packages from the main CLI process would pollute
18
+ // the registry across builds.
19
+ //
20
+ // Why server bundles (not source): user package source files may import
21
+ // browser-only code at top level (React, JSX runtime, DOM globals). The
22
+ // subprocess is target=bun — those imports crash. Server bundles are built
23
+ // with ONLY_SERVER=true defines that tree-shake the browser branches, so
24
+ // they're safe to load in a bare Bun process.
25
+ //
26
+ // Constraint: only CONTEXT packages can have `protectedBy(...)` rules —
27
+ // non-context (pure-browser) packages are skipped. This is sensible: access
28
+ // checks logically belong with server state, not display components.
29
+ //
30
+ // Order requirement: buildContextPackages MUST run before extractAccessMap
31
+ // — server bundles at `packages/<pkg>/dist/server/main/index.js` must exist.
19
32
  // ---------------------------------------------------------------------------
20
33
 
21
34
  export interface SerializedAccessRule {
@@ -30,33 +43,35 @@ export interface SerializedModuleAccess {
30
43
  export type SerializedAccessMap = Record<string, SerializedModuleAccess>;
31
44
 
32
45
  export async function extractAccessMap(
33
- arcDir: string,
34
- packages: WorkspacePackage[],
46
+ rootDir: string,
47
+ packages: readonly WorkspacePackage[],
35
48
  ): Promise<SerializedAccessMap> {
36
- // Prefer the v0.6 per-module location; fall back to the legacy per-package
37
- // dist path used while the build pipeline migration is in progress. The
38
- // fallback goes away once module-builder always emits server bundles
39
- // under <arcDir>/modules/<safeName>/server.js.
49
+ // Each entry: a context-package server bundle path that the worker will
50
+ // dynamically import. Bun resolves the bundle's internal external imports
51
+ // (`@arcote.tech/platform` etc.) via node_modules walking from the bundle
52
+ // location — workspace symlinks point back to source.
40
53
  const serverBundles = packages
41
54
  .filter((p) => isContextPackage(p.packageJson))
42
- .map((p) => {
43
- const v06Path = join(arcDir, "modules", basename(p.path), "server.js");
44
- const legacyPath = join(p.path, "dist", "server", "main", "index.js");
45
- return { name: p.name, path: v06Path, fallback: legacyPath };
46
- });
47
-
48
- // Output target subprocess writes here, we read back.
49
- const outPath = join(arcDir, "access.json");
50
- mkdirSync(arcDir, { recursive: true });
51
-
52
- // Worker script as a tempfile so we can `bun run <path>` (Bun has no -e flag
53
- // for TypeScript). Cleanup in finally.
54
- const workerPath = join(tmpdir(), `arc-access-extractor-${Date.now()}.mjs`);
55
+ .map((p) => ({
56
+ name: p.name,
57
+ path: join(p.path, "dist", "server", "main", "index.js"),
58
+ }))
59
+ .filter((b) => existsSync(b.path));
60
+
61
+ // Worker must live INSIDE the workspace tree so Bun's module resolver can
62
+ // walk up to <rootDir>/node_modules and find @arcote.tech/platform via the
63
+ // bare specifier `@arcote.tech/platform`. A tmpfile in /tmp/ would fail
64
+ // bare-specifier resolution.
65
+ const workerDir = join(rootDir, ".arc", ".tmp");
66
+ mkdirSync(workerDir, { recursive: true });
67
+ const workerPath = join(workerDir, `access-extractor-${Date.now()}.mjs`);
68
+ const outPath = join(workerDir, `access-${Date.now()}.json`);
55
69
  writeFileSync(workerPath, WORKER_SOURCE);
56
70
 
57
71
  try {
58
72
  const proc = spawn({
59
73
  cmd: ["bun", "run", workerPath],
74
+ cwd: rootDir,
60
75
  env: {
61
76
  ...process.env,
62
77
  ARC_ACCESS_BUNDLES: JSON.stringify(serverBundles),
@@ -76,17 +91,22 @@ export async function extractAccessMap(
76
91
  } catch {
77
92
  // best effort
78
93
  }
94
+ try {
95
+ unlinkSync(outPath);
96
+ } catch {
97
+ // best effort
98
+ }
79
99
  }
80
100
  }
81
101
 
82
102
  // ---------------------------------------------------------------------------
83
- // Worker source (inlined — runs in fresh Bun process)
103
+ // Worker source (inlined — runs in fresh Bun process at user workspace cwd)
84
104
  // ---------------------------------------------------------------------------
85
105
 
86
106
  const WORKER_SOURCE = `
87
- import { existsSync } from "node:fs";
88
-
89
107
  globalThis.ONLY_SERVER = true;
108
+ globalThis.ONLY_BROWSER = false;
109
+ globalThis.ONLY_CLIENT = false;
90
110
 
91
111
  const bundles = JSON.parse(process.env.ARC_ACCESS_BUNDLES || "[]");
92
112
  const out = process.env.ARC_ACCESS_OUT;
@@ -95,20 +115,18 @@ if (!out) {
95
115
  process.exit(2);
96
116
  }
97
117
 
118
+ // Bare-specifier import — Bun walks up from this worker's location
119
+ // (<rootDir>/.arc/.tmp/) to <rootDir>/node_modules and finds the package.
120
+ // Single entry (./src/index.ts) — React imports on top level are benign
121
+ // (createContext, function defs); no DOM access until actual render.
98
122
  const platform = await import("@arcote.tech/platform");
99
123
 
100
- for (const { name, path, fallback } of bundles) {
101
- const target = existsSync(path) ? path : fallback;
102
- if (!target || !existsSync(target)) {
103
- // No server bundle on either path — module has no protected access rules
104
- // to discover. Skip silently rather than logging a misleading error.
105
- continue;
106
- }
124
+ for (const { name, path } of bundles) {
107
125
  try {
108
- await import(target);
126
+ await import(path);
109
127
  } catch (e) {
110
- console.error("[access-extractor-worker] failed to import", name, "from", target, e);
111
- // Continue — partial access map is better than total failure.
128
+ console.error("[access-extractor-worker] failed to import", name, ":", e.message);
129
+ // Partial map is better than total failure.
112
130
  }
113
131
  }
114
132
 
@@ -1,7 +1,9 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
2
2
  import { join } from "path";
3
3
 
4
- const CACHE_VERSION = 1;
4
+ // v2: switched from `modules-bundle` (one unit) to `modules-chunk:<name>`
5
+ // (one unit per chunk group). Old v1 entries are irrelevant.
6
+ const CACHE_VERSION = 2;
5
7
  const CACHE_FILE = ".build-cache.json";
6
8
 
7
9
  export interface CacheEntry {
@@ -0,0 +1,107 @@
1
+ import { basename } from "path";
2
+ import type { SerializedAccessMap } from "./access-extractor";
3
+ import type { WorkspacePackage } from "./module-builder";
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // chunk-planner — decides which chunk group each workspace package belongs to,
7
+ // based on its `protectedBy(...)` declarations.
8
+ //
9
+ // Rules:
10
+ // - 0 access rules → "public"
11
+ // - 1 rule → rule.token.name
12
+ // - N rules, all same token.name → that token name
13
+ // - N rules, mixed token.names → throw (multi-token modules are not
14
+ // supported; user must split or use
15
+ // a single token type)
16
+ //
17
+ // Output: a per-chunk grouping consumed by buildModulesByChunks() to emit
18
+ // independent Bun.build per chunk group. Chunks NEVER share code across
19
+ // groups (a public chunk file can be served unauthenticated; a per-token
20
+ // chunk file is signed and requires the matching token).
21
+ // ---------------------------------------------------------------------------
22
+
23
+ export const PUBLIC_CHUNK = "public" as const;
24
+
25
+ export interface PackageChunk {
26
+ /** Workspace package being assigned. */
27
+ readonly pkg: WorkspacePackage;
28
+ /** Chunk group: "public" or a token.name from `protectedBy(...)`. */
29
+ readonly chunk: string;
30
+ /** Module name as it appears in the runtime registry (last path segment of pkg.name). */
31
+ readonly moduleName: string;
32
+ /** Filesystem-safe directory name within `<arcDir>/modules/<chunk>/`. */
33
+ readonly safeName: string;
34
+ }
35
+
36
+ export interface ChunkPlan {
37
+ /** Module-name → assignment, one entry per workspace package. */
38
+ readonly assignments: ReadonlyMap<string, PackageChunk>;
39
+ /** Chunk group → packages in that group, used by builder to spawn N parallel Bun.build. */
40
+ readonly groups: ReadonlyMap<string, readonly PackageChunk[]>;
41
+ /** Sorted chunk names (always starts with "public" if present). */
42
+ readonly chunks: readonly string[];
43
+ }
44
+
45
+ export function planChunks(
46
+ packages: readonly WorkspacePackage[],
47
+ accessMap: SerializedAccessMap,
48
+ ): ChunkPlan {
49
+ const assignments = new Map<string, PackageChunk>();
50
+ const groups = new Map<string, PackageChunk[]>();
51
+
52
+ for (const pkg of packages) {
53
+ const moduleName = moduleNameOf(pkg.name);
54
+ const access = accessMap[moduleName];
55
+ const chunk = resolveChunk(moduleName, access);
56
+
57
+ const entry: PackageChunk = {
58
+ pkg,
59
+ chunk,
60
+ moduleName,
61
+ safeName: basename(pkg.path),
62
+ };
63
+ assignments.set(moduleName, entry);
64
+
65
+ const bucket = groups.get(chunk);
66
+ if (bucket) {
67
+ bucket.push(entry);
68
+ } else {
69
+ groups.set(chunk, [entry]);
70
+ }
71
+ }
72
+
73
+ const chunks = [...groups.keys()].sort((a, b) => {
74
+ if (a === PUBLIC_CHUNK) return -1;
75
+ if (b === PUBLIC_CHUNK) return 1;
76
+ return a.localeCompare(b);
77
+ });
78
+
79
+ return { assignments, groups, chunks };
80
+ }
81
+
82
+ // ---------------------------------------------------------------------------
83
+ // Helpers
84
+ // ---------------------------------------------------------------------------
85
+
86
+ /** Match arc.ts: scoped pkg "@my-app/auth" → module name "auth". */
87
+ function moduleNameOf(pkgName: string): string {
88
+ return pkgName.includes("/") ? pkgName.split("/").pop()! : pkgName;
89
+ }
90
+
91
+ function resolveChunk(
92
+ moduleName: string,
93
+ access: SerializedAccessMap[string] | undefined,
94
+ ): string {
95
+ if (!access || access.rules.length === 0) return PUBLIC_CHUNK;
96
+
97
+ const tokenNames = new Set(access.rules.map((r) => r.token.name));
98
+ if (tokenNames.size === 1) {
99
+ return [...tokenNames][0];
100
+ }
101
+
102
+ const list = [...tokenNames].sort().join(", ");
103
+ throw new Error(
104
+ `Module "${moduleName}" has access rules for multiple tokens [${list}]. ` +
105
+ `Multi-token modules are not supported — split the module or unify on a single token type.`,
106
+ );
107
+ }
@@ -1,8 +1,11 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
3
3
  import { basename, join } from "path";
4
+ import { FRAMEWORK_PEERS, FRAMEWORK_PEER_SET } from "./framework-peers";
4
5
  import type { WorkspacePackage } from "./module-builder";
5
6
 
7
+ export { FRAMEWORK_PEERS };
8
+
6
9
  // ---------------------------------------------------------------------------
7
10
  // dependency-collector — generates per-module + global framework dep manifests
8
11
  // that the runtime container uses to `bun install`.
@@ -15,20 +18,6 @@ import type { WorkspacePackage } from "./module-builder";
15
18
  // into the module's own node_modules at deploy time.
16
19
  // ---------------------------------------------------------------------------
17
20
 
18
- /**
19
- * Packages that MUST be singleton across all modules — they define classes
20
- * checked with `instanceof` cross-module. Container resolves these from the
21
- * platform-level `node_modules/`, not per-module.
22
- */
23
- export const FRAMEWORK_PEERS = [
24
- "@arcote.tech/arc",
25
- "@arcote.tech/arc-ds",
26
- "@arcote.tech/arc-react",
27
- "@arcote.tech/platform",
28
- "react",
29
- "react-dom",
30
- ] as const;
31
-
32
21
  export type FrameworkPeer = (typeof FRAMEWORK_PEERS)[number];
33
22
 
34
23
  export interface CollectedDeps {
@@ -46,10 +35,17 @@ export function collectFrameworkDeps(
46
35
  arcDir: string,
47
36
  rootDir: string,
48
37
  packages: WorkspacePackage[],
38
+ sharedDeps: ReadonlyArray<{ name: string; version: string }> = [],
49
39
  ): CollectedDeps {
50
40
  mkdirSync(arcDir, { recursive: true });
51
41
 
52
42
  const versions = resolveFrameworkVersions(rootDir, packages);
43
+ // Shared deps (discovered across ≥ 2 workspace packages) join the framework
44
+ // in the global manifest — installed once into <arcDir>/node_modules/ and
45
+ // served via the shell so each module's browser bundle leaves them external.
46
+ for (const { name, version } of sharedDeps) {
47
+ versions[name] = version;
48
+ }
53
49
  const manifest = {
54
50
  name: "arc-platform-framework",
55
51
  private: true,
@@ -59,30 +55,16 @@ export function collectFrameworkDeps(
59
55
  const manifestPath = join(arcDir, "package.json");
60
56
  writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + "\n");
61
57
 
62
- // Empty bun.lock placeholder. We CAN'T copy the workspace bun.lock — it
63
- // contains the full dep graph (workspaces, dev deps) but our framework
64
- // manifest is just the singleton peers. `bun install --frozen-lockfile`
65
- // would reject as "lockfile had changes". An empty lockfile is harmless
66
- // when server runs `bun install --production` without freeze.
67
- const targetLock = join(arcDir, "bun.lock");
68
- writeFileSync(targetLock, "");
69
-
70
- // Hash MUST match the server-side computation in deploy-api.ts
71
- // (sha256 over concat of raw bytes) — otherwise every deploy would think
72
- // framework changed and re-install in a loop.
73
- const hash = sha256ConcatHex([manifestPath, targetLock]);
58
+ // Hash over package.json bytes alone. No lockfile in the hash: newer bun
59
+ // rejects empty `{}` lockfiles, and the framework dep set is small enough
60
+ // to let bun generate its own lockfile during install in the image build.
61
+ const hash = sha256Hex(readFileSync(manifestPath));
74
62
  writeFileSync(join(arcDir, ".deps-hash"), hash + "\n");
75
63
  return { hash, manifestPath };
76
64
  }
77
65
 
78
- function sha256ConcatHex(paths: string[]): string {
79
- const hash = createHash("sha256");
80
- for (const p of paths) {
81
- if (existsSync(p)) {
82
- hash.update(readFileSync(p));
83
- }
84
- }
85
- return hash.digest("hex");
66
+ function sha256Hex(bytes: Buffer): string {
67
+ return createHash("sha256").update(bytes).digest("hex");
86
68
  }
87
69
 
88
70
  // ---------------------------------------------------------------------------
@@ -92,19 +74,37 @@ function sha256ConcatHex(paths: string[]): string {
92
74
  export function collectModuleDeps(
93
75
  arcDir: string,
94
76
  pkg: WorkspacePackage,
77
+ sharedDepNames: ReadonlySet<string> = new Set(),
95
78
  ): CollectedDeps {
96
79
  const safeName = basename(pkg.path);
97
80
  const moduleDir = join(arcDir, "modules", safeName);
98
81
  mkdirSync(moduleDir, { recursive: true });
99
82
 
100
83
  const pkgDeps = (pkg.packageJson.dependencies ?? {}) as Record<string, string>;
84
+ const peerDeps = (pkg.packageJson.peerDependencies ?? {}) as Record<
85
+ string,
86
+ string
87
+ >;
101
88
  const filtered: Record<string, string> = {};
102
- const frameworkSet = new Set<string>(FRAMEWORK_PEERS);
89
+
90
+ // Regular deps, excluding:
91
+ // - framework peers (global install via collectFrameworkDeps)
92
+ // - shared deps (also global — promoted to shell across ≥ 2 modules)
93
+ // - workspace links (bundled inline by buildModules; not on npm)
103
94
  for (const [name, spec] of Object.entries(pkgDeps)) {
104
- if (frameworkSet.has(name)) continue;
95
+ if (FRAMEWORK_PEER_SET.has(name)) continue;
96
+ if (sharedDepNames.has(name)) continue;
105
97
  if (spec.startsWith("workspace:")) continue;
106
98
  filtered[name] = spec;
107
99
  }
100
+ // Peer deps minus framework peers + shared deps — e.g. `@arcote.tech/arc-auth`
101
+ // is declared peer of `@ndt/auth` and lives in framework peers; user peers
102
+ // not in either set go per-module.
103
+ for (const [name, spec] of Object.entries(peerDeps)) {
104
+ if (FRAMEWORK_PEER_SET.has(name)) continue;
105
+ if (sharedDepNames.has(name)) continue;
106
+ filtered[name] = spec;
107
+ }
108
108
 
109
109
  const manifest = {
110
110
  name: `arc-module-${safeName}`,
@@ -128,22 +128,51 @@ function resolveFrameworkVersions(
128
128
  rootDir: string,
129
129
  packages: WorkspacePackage[],
130
130
  ): Record<string, string> {
131
- // Workspace root deps win that's where the user pins versions. Fall back
132
- // to whatever a workspace package declared, then `*` as last resort.
131
+ // The framework manifest installs into /app/node_modules in the deploy
132
+ // image. It needs to cover:
133
+ // 1. Static framework peers (FRAMEWORK_PEERS) — always present
134
+ // 2. User-extension @arcote.tech/* peers (e.g. arc-ai-openai) declared
135
+ // as peerDeps on user context packages
136
+ // 3. npm runtime deps that server bundles leave external (non-workspace
137
+ // deps not yet bundled inline)
138
+ // Resolution order for each: root package.json wins, then any workspace
139
+ // package's deps/peerDeps, then "*" as last resort.
133
140
  const rootPkg = JSON.parse(
134
141
  readFileSync(join(rootDir, "package.json"), "utf-8"),
135
142
  );
136
143
  const rootDeps = (rootPkg.dependencies ?? {}) as Record<string, string>;
144
+ const rootDevDeps = (rootPkg.devDependencies ?? {}) as Record<string, string>;
145
+
146
+ // Build the full required name set.
147
+ const required = new Set<string>(FRAMEWORK_PEERS);
148
+ for (const pkg of packages) {
149
+ const peers = pkg.packageJson.peerDependencies ?? {};
150
+ const deps = (pkg.packageJson.dependencies ?? {}) as Record<string, string>;
151
+ // Any @arcote.tech/* peer beyond the static list (e.g. arc-ai adapters).
152
+ for (const name of Object.keys(peers)) {
153
+ if (name.startsWith("@arcote.tech/")) required.add(name);
154
+ }
155
+ // npm deps (non-workspace) — server bundles leave them external, so the
156
+ // image needs them in /app/node_modules.
157
+ for (const [name, spec] of Object.entries(deps)) {
158
+ if (spec.startsWith("workspace:")) continue;
159
+ if (name.startsWith("@arcote.tech/") || !isFrameworkExternal(name)) continue;
160
+ required.add(name);
161
+ }
162
+ }
163
+
137
164
  const out: Record<string, string> = {};
138
- for (const name of FRAMEWORK_PEERS) {
139
- const rootSpec = rootDeps[name];
165
+ for (const name of required) {
166
+ const rootSpec = rootDeps[name] ?? rootDevDeps[name];
140
167
  if (rootSpec) {
141
168
  out[name] = rootSpec;
142
169
  continue;
143
170
  }
144
171
  let found: string | undefined;
145
172
  for (const pkg of packages) {
146
- const spec = (pkg.packageJson.dependencies ?? {})[name];
173
+ const spec =
174
+ (pkg.packageJson.dependencies ?? {})[name] ??
175
+ (pkg.packageJson.peerDependencies ?? {})[name];
147
176
  if (spec) {
148
177
  found = spec;
149
178
  break;
@@ -154,6 +183,19 @@ function resolveFrameworkVersions(
154
183
  return out;
155
184
  }
156
185
 
186
+ /**
187
+ * True if a non-arc npm dep is one the image must install globally for
188
+ * runtime resolution. Today we include everything that user context packages
189
+ * declare as deps but isn't `workspace:` — server bundles leave them external
190
+ * so they MUST be present in /app/node_modules.
191
+ *
192
+ * Future optimization: bundle these inline into the server bundle to avoid
193
+ * the install layer entirely. Left as a follow-up.
194
+ */
195
+ function isFrameworkExternal(_name: string): boolean {
196
+ return true;
197
+ }
198
+
157
199
  function sha256OfFiles(paths: string[]): string {
158
200
  const hash = createHash("sha256");
159
201
  for (const p of paths.sort()) {
@@ -0,0 +1,81 @@
1
+ // ---------------------------------------------------------------------------
2
+ // framework-peers — single source of truth for packages that MUST be
3
+ // singleton across all modules. Used by:
4
+ //
5
+ // - dependency-collector.ts (decides which deps go to the global
6
+ // framework manifest vs per-module install)
7
+ // - module-builder.ts (decides which imports stay external in
8
+ // browser bundles, mapped via importmap to
9
+ // /shell/<short>.js)
10
+ // - shared.ts (collectArcPeerDeps — runtime shell entry
11
+ // discovery, importmap generation)
12
+ // - commands/build-shell.ts (runtime shell builder, fallback for user-
13
+ // extension @arcote.tech/* packages)
14
+ //
15
+ // Two reasons a package needs to be singleton:
16
+ //
17
+ // 1. Core framework — `instanceof` checks for ArcAggregate / ArcCommand /
18
+ // ArcFunction base classes require a single class identity across module
19
+ // boundaries.
20
+ // 2. Browser-side React Context — AuthProvider / WorkspaceProvider /
21
+ // ChatProvider etc.; duplicate Context objects across modules cause
22
+ // `useAuth must be used within AuthProvider` even when the provider IS
23
+ // mounted (just from a different module's React.Context instance).
24
+ //
25
+ // The runtime container installs these into <arcDir>/node_modules/ (global)
26
+ // and the shell builder emits /shell/<short>.js per peer.
27
+ // ---------------------------------------------------------------------------
28
+
29
+ /** Core framework packages — `instanceof` consumers. */
30
+ export const CORE_PEERS = [
31
+ "@arcote.tech/arc",
32
+ "@arcote.tech/arc-ds",
33
+ "@arcote.tech/arc-react",
34
+ "@arcote.tech/platform",
35
+ ] as const;
36
+
37
+ /** Browser-side fragments with React Context — singleton identity required. */
38
+ export const BROWSER_FRAGMENT_PEERS = [
39
+ "@arcote.tech/arc-auth",
40
+ "@arcote.tech/arc-workspace",
41
+ "@arcote.tech/arc-utils",
42
+ "@arcote.tech/arc-chat",
43
+ ] as const;
44
+
45
+ /** All @arcote.tech/* packages that must be singleton. */
46
+ export const ARC_PEERS = [...CORE_PEERS, ...BROWSER_FRAGMENT_PEERS] as const;
47
+
48
+ /** React + ReactDOM — separate from arc peers but treated identically. */
49
+ export const REACT_PEERS = ["react", "react-dom"] as const;
50
+
51
+ /**
52
+ * Full set of npm packages that the runtime container installs globally
53
+ * (one copy in <arcDir>/node_modules/, shared across all per-module bundles).
54
+ * Filter applied by dependency-collector to keep them out of per-module deps.
55
+ */
56
+ export const FRAMEWORK_PEERS = [...ARC_PEERS, ...REACT_PEERS] as const;
57
+
58
+ /**
59
+ * Bare specifiers that browser bundles leave external — resolved via the
60
+ * importmap in the shell HTML. Superset of FRAMEWORK_PEERS because React's
61
+ * automatic JSX transform emits `react/jsx-runtime` and `react/jsx-dev-runtime`
62
+ * sub-path imports that also need to point at the shared shell bundle.
63
+ */
64
+ export const SHELL_EXTERNALS = [
65
+ ...FRAMEWORK_PEERS,
66
+ "react/jsx-runtime",
67
+ "react/jsx-dev-runtime",
68
+ ] as const;
69
+
70
+ export const FRAMEWORK_PEER_SET = new Set<string>(FRAMEWORK_PEERS);
71
+ export const SHELL_EXTERNAL_SET = new Set<string>(SHELL_EXTERNALS);
72
+
73
+ /** Short identifier used as the shell bundle filename (`/shell/<short>.js`). */
74
+ export function shortNameOf(pkg: string): string {
75
+ // `@arcote.tech/platform` is special-cased historically to `platform`
76
+ // (rather than `@arcote.tech/platform`) — keep that mapping stable so
77
+ // existing consumers don't break.
78
+ if (pkg === "@arcote.tech/platform") return "platform";
79
+ if (pkg.startsWith("@arcote.tech/")) return pkg.slice("@arcote.tech/".length);
80
+ return pkg;
81
+ }