@akanjs/devkit 3.0.0-beta.18 → 3.0.0-beta.19

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 (63) hide show
  1. package/abstractDoc.ts +4 -1
  2. package/akanApp/akanApp.host.ts +23 -9
  3. package/akanConfig/akanConfig.test.ts +55 -30
  4. package/akanConfig/akanConfig.ts +29 -17
  5. package/akanConfig/types.ts +1 -0
  6. package/applicationBuildRunner.test.ts +5 -3
  7. package/applicationBuildRunner.ts +29 -3
  8. package/artifact/routeSeedIndex.test.ts +7 -6
  9. package/capacitorApp.test.ts +2 -2
  10. package/cloud/constants.ts +23 -0
  11. package/cloud/globalConfig.ts +13 -0
  12. package/codeAgent/agent/AkanCodeServices.ts +22 -3
  13. package/codeAgent/agent/CodeAgent.ts +33 -18
  14. package/codeAgent/agent/CodeAgentAsks.ts +3 -1
  15. package/codeAgent/agent/CodeAgentEventMapper.ts +9 -1
  16. package/codeAgent/agent/CodeAgentProxy.ts +91 -0
  17. package/codeAgent/agent/CodeAgentRpcHost.ts +29 -9
  18. package/codeAgent/agent/CodeAgentRpcListener.ts +117 -0
  19. package/codeAgent/agent/CodeAgentSuspended.ts +92 -0
  20. package/codeAgent/agent/akanCodeModel.ts +1 -1
  21. package/codeAgent/agent/akanContextFiles.test.ts +2 -1
  22. package/codeAgent/agent/codeAgentContract.test.ts +17 -0
  23. package/codeAgent/agent/codeAgentProxy.test.ts +74 -0
  24. package/codeAgent/agent/codeAgentRpcListener.test.ts +113 -0
  25. package/codeAgent/agent/codeAgentSuspended.test.ts +47 -0
  26. package/codeAgent/tools/mcpOAuth.test.ts +6 -2
  27. package/dependencyScanner.ts +4 -2
  28. package/devkitUtils.test.ts +1 -1
  29. package/executors.linkFallback.test.ts +103 -0
  30. package/executors.test.ts +8 -0
  31. package/executors.ts +31 -8
  32. package/frontendBuild/autoImportSync.test.ts +13 -0
  33. package/frontendBuild/autoImportSync.ts +19 -1
  34. package/frontendBuild/buildRouteClient.test.ts +52 -1
  35. package/frontendBuild/cssImportResolver.ts +1 -1
  36. package/frontendBuild/fontOptimizer.test.ts +1 -1
  37. package/frontendBuild/frontendBuild.test.ts +19 -17
  38. package/frontendBuild/hmrWatcher.test.ts +1 -1
  39. package/frontendBuild/routeClientBuilder.ts +27 -10
  40. package/frontendBuild/sourceMtimeIndex.test.ts +6 -5
  41. package/getDirname.test.ts +4 -2
  42. package/incrementalBuilder/devWatchBatch.test.ts +7 -6
  43. package/incrementalBuilder/incrementalBuilder.proc.ts +1 -0
  44. package/integration/devStabilityHarness.test.ts +7 -2
  45. package/lint/gritRules.test.ts +2 -1
  46. package/lint/no-deep-internal-import.grit +2 -2
  47. package/lint/no-redeclare-predefined-endpoint.grit +2 -1
  48. package/linter.ts +4 -1
  49. package/package.json +2 -2
  50. package/platformTest/BunTestReport.test.ts +60 -0
  51. package/platformTest/BunTestReport.ts +65 -0
  52. package/platformTest/IdleWatchedProcess.test.ts +64 -0
  53. package/platformTest/IdleWatchedProcess.ts +77 -0
  54. package/platformTest/LinuxDockerTarget.ts +120 -0
  55. package/platformTest/PlatformTestRun.test.ts +68 -0
  56. package/platformTest/PlatformTestRun.ts +141 -0
  57. package/platformTest/PlatformTestTarget.ts +120 -0
  58. package/platformTest/TestSnapshot.test.ts +80 -0
  59. package/platformTest/TestSnapshot.ts +107 -0
  60. package/platformTest/WindowsSshTarget.ts +167 -0
  61. package/subspace.test.ts +5 -2
  62. package/subspace.ts +19 -8
  63. package/workflow/rolloutGate.test.ts +1 -1
@@ -0,0 +1,113 @@
1
+ import { afterAll, describe, expect, test } from "bun:test";
2
+ import { mkdtempSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import path from "node:path";
5
+ import type { CodeAgentEvent } from "akanjs/common";
6
+ import type { CodeAgent } from "./CodeAgent";
7
+ import { CodeAgentRpcHost } from "./CodeAgentRpcHost";
8
+ import { CodeAgentRpcListener } from "./CodeAgentRpcListener";
9
+
10
+ const dir = mkdtempSync(path.join(tmpdir(), "akan-rpc-"));
11
+ afterAll(() => rmSync(dir, { recursive: true, force: true }));
12
+
13
+ class FakeAgent {
14
+ seq = 0;
15
+ frames: CodeAgentEvent[] = [];
16
+ disposed = false;
17
+ aborted = false;
18
+ #listener: (event: CodeAgentEvent) => void = () => {};
19
+ on(listener: (event: CodeAgentEvent) => void) {
20
+ this.#listener = listener;
21
+ return () => {};
22
+ }
23
+ announce() {
24
+ this.emit();
25
+ }
26
+ emit() {
27
+ const event = { type: "idle", seq: ++this.seq } as CodeAgentEvent;
28
+ this.frames.push(event);
29
+ this.#listener(event);
30
+ }
31
+ async prompt() {
32
+ this.emit();
33
+ }
34
+ async abort() {
35
+ this.aborted = true;
36
+ }
37
+ async state({ sinceSeq }: { sinceSeq?: number } = {}) {
38
+ return { replayFrom: 0, frames: this.frames.filter((event) => event.seq > (sinceSeq ?? Number.MAX_SAFE_INTEGER)) };
39
+ }
40
+ dispose() {
41
+ this.disposed = true;
42
+ }
43
+ }
44
+
45
+ class Client {
46
+ lines: unknown[] = [];
47
+ #buffer = "";
48
+ #socket: Awaited<ReturnType<typeof Bun.connect>> | null = null;
49
+ static async connect(unix: string) {
50
+ const client = new Client();
51
+ client.#socket = await Bun.connect({
52
+ unix,
53
+ socket: {
54
+ data: (_socket, data) => {
55
+ client.#buffer += data.toString();
56
+ const parts = client.#buffer.split("\n");
57
+ client.#buffer = parts.pop() ?? "";
58
+ for (const part of parts) if (part) client.lines.push(JSON.parse(part));
59
+ },
60
+ },
61
+ });
62
+ return client;
63
+ }
64
+ send(id: string, command: object) {
65
+ this.#socket?.write(`${JSON.stringify({ id, command })}\n`);
66
+ }
67
+ async reply(id: string) {
68
+ for (let idx = 0; idx < 200; idx++) {
69
+ const found = this.lines.find((line) => (line as { id?: string }).id === id);
70
+ if (found) return found as { ok: boolean; data: { frames?: CodeAgentEvent[] } };
71
+ await Bun.sleep(5);
72
+ }
73
+ throw new Error(`no reply ${id}`);
74
+ }
75
+ close() {
76
+ this.#socket?.end();
77
+ }
78
+ }
79
+
80
+ describe("CodeAgentRpcListener", () => {
81
+ test("parses unix and tcp addresses, defaulting tcp to loopback", () => {
82
+ expect(CodeAgentRpcListener.parse("unix:/tmp/a.sock")).toEqual({ unix: "/tmp/a.sock" });
83
+ expect(CodeAgentRpcListener.parse("tcp:7000")).toEqual({ hostname: "127.0.0.1", port: 7000 });
84
+ expect(CodeAgentRpcListener.parse("0.0.0.0:7000")).toEqual({ hostname: "0.0.0.0", port: 7000 });
85
+ expect(() => CodeAgentRpcListener.parse("tcp:nope")).toThrow("--rpc-listen");
86
+ });
87
+
88
+ test("the agent outlives a dropped client, and the next one catches up by seq", async () => {
89
+ const agent = new FakeAgent();
90
+ const unix = path.join(dir, "agent.sock");
91
+ const listener = new CodeAgentRpcListener(new CodeAgentRpcHost(agent as unknown as CodeAgent, null), { unix });
92
+ listener.listen();
93
+ const served = listener.serve();
94
+
95
+ const first = await Client.connect(unix);
96
+ first.send("1", { type: "prompt", message: "hi" });
97
+ expect((await first.reply("1")).ok).toBe(true);
98
+ first.close();
99
+ await Bun.sleep(20);
100
+ agent.emit();
101
+ expect(agent.aborted).toBe(false);
102
+
103
+ const second = await Client.connect(unix);
104
+ second.send("2", { type: "get_state", sinceSeq: 2 });
105
+ const state = await second.reply("2");
106
+ expect(state.data.frames?.map((frame) => frame.seq)).toEqual([3]);
107
+
108
+ second.send("3", { type: "shutdown" });
109
+ await second.reply("3");
110
+ await served;
111
+ expect(agent.disposed).toBe(true);
112
+ });
113
+ });
@@ -0,0 +1,47 @@
1
+ import { afterAll, describe, expect, test } from "bun:test";
2
+ import { existsSync, mkdtempSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import path from "node:path";
5
+ import { CodeAgentSuspended } from "./CodeAgentSuspended";
6
+
7
+ const dir = mkdtempSync(path.join(tmpdir(), "akan-suspended-"));
8
+ afterAll(() => rmSync(dir, { recursive: true, force: true }));
9
+
10
+ const question = {
11
+ questionId: "qx.1",
12
+ prompt: "Which app?",
13
+ kind: "select" as const,
14
+ options: [{ key: "a", label: "akan" }],
15
+ };
16
+ const approval = {
17
+ approvalId: "ax.2",
18
+ toolCallId: "c1",
19
+ name: "write",
20
+ summary: "write a.ts",
21
+ policy: "writes" as const,
22
+ };
23
+
24
+ describe("CodeAgentSuspended", () => {
25
+ test("a restarted worker reads back what it was waiting on, and each ask is taken once", () => {
26
+ const file = CodeAgentSuspended.fileOf(dir, "s1");
27
+ const before = new CodeAgentSuspended(file);
28
+ before.setQuestion(question);
29
+ before.addApproval(approval);
30
+
31
+ const after = new CodeAgentSuspended(file);
32
+ expect(after.question).toEqual(question);
33
+ expect(after.approvals).toEqual([approval]);
34
+ expect(after.takeQuestion("qx.1")).toEqual(question);
35
+ expect(after.takeQuestion("qx.1")).toBeUndefined();
36
+ expect(after.takeApproval("ax.2")).toEqual(approval);
37
+ expect(after.takeApproval("ax.2")).toBeUndefined();
38
+ expect(existsSync(file ?? "")).toBe(false);
39
+ });
40
+
41
+ test("a memory session keeps its asks without touching disk", () => {
42
+ const memory = new CodeAgentSuspended(CodeAgentSuspended.fileOf(undefined, "s2"));
43
+ memory.setQuestion(question);
44
+ expect(memory.takeQuestion("other")).toBeUndefined();
45
+ expect(memory.takeQuestion("qx.1")).toEqual(question);
46
+ });
47
+ });
@@ -303,12 +303,16 @@ describe("MCP sign-in", () => {
303
303
  });
304
304
 
305
305
  describe("the token file", () => {
306
- test("it is readable only by its owner, and lives outside the workspace", async () => {
306
+ test("it lives outside the workspace", async () => {
307
307
  await McpSignIn.run(refOf(), { open: browser });
308
308
  const file = McpTokenStore.file();
309
309
  expect(file.startsWith(home)).toBe(true);
310
310
  expect(await Bun.file(file).exists()).toBe(true);
311
- expect(statSync(file).mode & 0o777).toBe(0o600);
311
+ });
312
+
313
+ test.skipIf(process.platform === "win32")("it is readable only by its owner", async () => {
314
+ await McpSignIn.run(refOf(), { open: browser });
315
+ expect(statSync(McpTokenStore.file()).mode & 0o777).toBe(0o600);
312
316
  });
313
317
 
314
318
  test("a corrupt file costs the sign-ins and not the session", async () => {
@@ -10,8 +10,10 @@ const testFileRegex = /\.(?:test|spec)\.[cm]?[tj]sx?$/;
10
10
  // `__fixtures__` holds deliberately invalid sample sources for the lint-rule suites; tsconfig `exclude` and
11
11
  // biome's `!` override already skip them, and their imports are violations rather than real dependencies.
12
12
  const skipDirs = ["node_modules", "dist", "build", ".git", ".next", "public", "ios", "android", "__fixtures__"];
13
- const isSkippedPath = (filePath: string) =>
14
- skipDirs.some((dir) => filePath.includes(`/${dir}/`) || filePath.startsWith(`${dir}/`));
13
+ const isSkippedPath = (filePath: string) => {
14
+ const posixPath = filePath.split(path.sep).join("/");
15
+ return skipDirs.some((dir) => posixPath.includes(`/${dir}/`) || posixPath.startsWith(`${dir}/`));
16
+ };
15
17
  const builtinModuleSet = new Set([...builtinModules, ...builtinModules.map((mod) => `node:${mod}`)]);
16
18
  const stripShebang = (source: string) => source.replace(/^#!.*(?:\r?\n|$)/, "");
17
19
 
@@ -40,7 +40,7 @@ describe("resolveSignalTestPreloadPath", () => {
40
40
  await write(path.join(root, "node_modules/akanjs/test/signalTest.preload.ts"), "export {};\n");
41
41
 
42
42
  await expect(resolveSignalTestPreloadPath({ cwdPath: libDir })).resolves.toContain(
43
- "node_modules/akanjs/test/signalTest.preload.ts",
43
+ path.join("node_modules/akanjs/test/signalTest.preload.ts"),
44
44
  );
45
45
  });
46
46
  });
@@ -0,0 +1,103 @@
1
+ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
2
+ import * as fsPromises from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+
6
+ //? The Windows VM runs as an administrator, so a refused link only ever happens here — injected, on every OS.
7
+ const realSymlink = fsPromises.symlink;
8
+ let refuseLinks = false;
9
+ mock.module("node:fs/promises", () => ({
10
+ ...fsPromises,
11
+ symlink: async (...args: Parameters<typeof fsPromises.symlink>) => {
12
+ if (refuseLinks)
13
+ throw Object.assign(new Error(`EPERM: operation not permitted, symlink '${String(args[0])}'`), {
14
+ code: "EPERM",
15
+ });
16
+ return await realSymlink(...args);
17
+ },
18
+ }));
19
+ const { AppExecutor, WorkspaceExecutor } = await import("./executors");
20
+
21
+ const { lstat, mkdir, mkdtemp, readFile, rm, writeFile } = fsPromises;
22
+ const originalEnv = { ...process.env };
23
+ const originalPlatform = process.platform;
24
+ const tempRoots: string[] = [];
25
+ const PAGE_SOURCE = "export default function Page() {\n return null;\n}\n";
26
+
27
+ const setPlatform = (platform: NodeJS.Platform) =>
28
+ Object.defineProperty(process, "platform", { value: platform, configurable: true, writable: true });
29
+
30
+ // `AppExecutor.from` memoises by name, so each test needs a name no other test has used.
31
+ const makeApp = async (appName: string) => {
32
+ const root = await mkdtemp(path.join(os.tmpdir(), "akan-link-fallback-"));
33
+ tempRoots.push(root);
34
+ process.env.AKAN_PUBLIC_REPO_NAME = "repo";
35
+ process.env.AKAN_PUBLIC_SERVE_DOMAIN = "example.com";
36
+ process.env.AKAN_PUBLIC_ENV = "local";
37
+ process.env.PORT_OFFSET = "0";
38
+ await writeFile(path.join(root, "package.json"), JSON.stringify({ name: "fixture", version: "1.0.0" }));
39
+ await mkdir(path.join(root, "libs/shared/public"), { recursive: true });
40
+ await writeFile(path.join(root, "libs/shared/public/logo.png"), "logo");
41
+ await mkdir(path.join(root, "libs/shared/page/about"), { recursive: true });
42
+ await writeFile(path.join(root, "libs/shared/page/about/_index.tsx"), PAGE_SOURCE);
43
+ await mkdir(path.join(root, "apps", appName, "page"), { recursive: true });
44
+ await writeFile(path.join(root, "apps", appName, "akan.config.ts"), "export default { syncPageLibs: true };\n");
45
+ const workspace = new WorkspaceExecutor({ workspaceRoot: root, repoName: "repo" });
46
+ return { root, app: AppExecutor.from(workspace, appName), appRoot: path.join(root, "apps", appName) };
47
+ };
48
+
49
+ beforeEach(() => {
50
+ process.env = { ...originalEnv };
51
+ refuseLinks = true;
52
+ });
53
+
54
+ afterEach(async () => {
55
+ refuseLinks = false;
56
+ setPlatform(originalPlatform);
57
+ process.env = { ...originalEnv };
58
+ await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
59
+ });
60
+
61
+ describe("lib links the OS refuses to create", () => {
62
+ test("Windows gets a copy of the lib assets, and a resync replaces it without touching the lib", async () => {
63
+ setPlatform("win32");
64
+ const { root, app, appRoot } = await makeApp("fallback-assets");
65
+
66
+ await app.syncAssets(["shared"]);
67
+ const copied = path.join(appRoot, "public/libs/shared");
68
+ expect((await lstat(copied)).isSymbolicLink()).toBe(false);
69
+ expect(await readFile(path.join(copied, "logo.png"), "utf8")).toBe("logo");
70
+
71
+ await writeFile(path.join(root, "libs/shared/public/logo.png"), "logo-v2");
72
+ await app.syncAssets(["shared"]);
73
+ expect(await readFile(path.join(copied, "logo.png"), "utf8")).toBe("logo-v2");
74
+
75
+ await app.syncAssets([]);
76
+ expect(await lstat(path.join(appRoot, "public/libs")).catch(() => null)).toBeNull();
77
+ expect(await readFile(path.join(root, "libs/shared/public/logo.png"), "utf8")).toBe("logo-v2");
78
+ });
79
+
80
+ test("Windows gets a copy of the lib pages that still routes, and a resync picks up a new lib route", async () => {
81
+ setPlatform("win32");
82
+ const { root, app, appRoot } = await makeApp("fallback-pages");
83
+
84
+ expect(await app.syncPages(["shared"])).toBe(true);
85
+ expect((await lstat(path.join(appRoot, "page/(libs)/(shared)"))).isSymbolicLink()).toBe(false);
86
+ expect(await app.getPageKeys({ refresh: true })).toEqual(["./(libs)/(shared)/about/_index.tsx"]);
87
+
88
+ await mkdir(path.join(root, "libs/shared/page/terms"), { recursive: true });
89
+ await writeFile(path.join(root, "libs/shared/page/terms/_index.tsx"), PAGE_SOURCE);
90
+ expect(await app.syncPages(["shared"])).toBe(true);
91
+ expect(await app.getPageKeys({ refresh: true })).toEqual([
92
+ "./(libs)/(shared)/about/_index.tsx",
93
+ "./(libs)/(shared)/terms/_index.tsx",
94
+ ]);
95
+ });
96
+
97
+ test("elsewhere the refusal surfaces rather than being papered over with a copy", async () => {
98
+ setPlatform(originalPlatform === "win32" ? "linux" : originalPlatform);
99
+ const { app } = await makeApp("fallback-posix");
100
+
101
+ await expect(app.syncAssets(["shared"])).rejects.toThrow("EPERM");
102
+ });
103
+ });
package/executors.test.ts CHANGED
@@ -312,9 +312,17 @@ describe("Workspace and app executor environment contracts", () => {
312
312
  expect(env.AKAN_PUBLIC_SERVER_PORT).toBe("8285");
313
313
  expect(env.EXTRA).toBe("ok");
314
314
 
315
+ const shellOperationMode = process.env.AKAN_PUBLIC_OPERATION_MODE;
316
+ process.env.AKAN_PUBLIC_OPERATION_MODE = "local";
315
317
  const prepared = await app.prepareCommand("build");
318
+ const bakedOperationMode = process.env.AKAN_PUBLIC_OPERATION_MODE;
319
+ if (shellOperationMode === undefined) delete process.env.AKAN_PUBLIC_OPERATION_MODE;
320
+ else process.env.AKAN_PUBLIC_OPERATION_MODE = shellOperationMode;
321
+ expect(bakedOperationMode).toBeUndefined();
316
322
  expect(prepared.env.AKAN_COMMAND_TYPE).toBe("build");
317
323
  expect(prepared.env.AKAN_PUBLIC_BASE_PATHS).toBe("admin");
324
+ expect(prepared.env.AKAN_DATABASE_MODE).toBe("single");
325
+ expect(prepared.env.AKAN_DATABASE_MODES).toBe("single");
318
326
  // Bundling reads `process.env` through getPublicEnv and `define`s every AKAN_PUBLIC_* into a literal, so a
319
327
  // dev port published here would be baked into the artifact and outrank the PORT the container is run with.
320
328
  expect(process.env.AKAN_PUBLIC_APP_NAME).toBe("demo");
package/executors.ts CHANGED
@@ -11,6 +11,7 @@ import { readFileSync } from "node:fs";
11
11
  import {
12
12
  copyFile,
13
13
  cp as cpEntry,
14
+ lstat,
14
15
  mkdir,
15
16
  readdir as readDirEntries,
16
17
  realpath,
@@ -96,7 +97,9 @@ const staticTemplateFileExtensions = new Set([
96
97
  ".xml",
97
98
  ]);
98
99
 
99
- const formatCommandArg = (value: string) => (/^[\w@%+=:,./-]+$/.test(value) ? value : JSON.stringify(value));
100
+ //? A backslash is a path separator on Windows but an escape in a POSIX shell, so only there does it force quoting.
101
+ const plainCommandArgPattern = process.platform === "win32" ? /^[\w@%+=:,./\\-]+$/ : /^[\w@%+=:,./-]+$/;
102
+ const formatCommandArg = (value: string) => (plainCommandArgPattern.test(value) ? value : JSON.stringify(value));
100
103
 
101
104
  const formatCommandForDisplay = (command: string, args: string[] = []) =>
102
105
  [command, ...args].map(formatCommandArg).join(" ");
@@ -475,8 +478,10 @@ export class Executor {
475
478
  const isDirectory = (await stat(src)).isDirectory();
476
479
  if (!(await FileSys.exists(dest)) && isDirectory) await mkdir(dest, { recursive: true });
477
480
  //* `cp -r` keeps symlinks on GNU coreutils but follows them on macOS, so anything that must land as
478
- //* real files regardless of platform has to say so explicitly.
479
- if (dereference) await cpEntry(src, dest, { recursive: isDirectory, dereference: true, force: true });
481
+ //* real files regardless of platform has to say so explicitly. Windows has no `cp -r <dir>/.` and a
482
+ //* symlink there needs a privilege most accounts lack, so it always lands as real files.
483
+ if (dereference || process.platform === "win32")
484
+ await cpEntry(src, dest, { recursive: isDirectory, dereference: true, force: true });
480
485
  else await $`cp -r ${src}${isDirectory ? "/." : ""} ${dest}`;
481
486
  }
482
487
  log(msg: string) {
@@ -1405,14 +1410,21 @@ export class AppExecutor extends SysExecutor {
1405
1410
  ...env,
1406
1411
  };
1407
1412
  }
1413
+ /** The database mode a command runs this app in, and the modes its build carries — the same rule the server boots by. */
1414
+ async getDatabaseModeEnv() {
1415
+ const akanConfig = await this.getConfig();
1416
+ return {
1417
+ AKAN_DATABASE_MODE: akanConfig.resolveDatabaseMode(),
1418
+ AKAN_DATABASE_MODES: akanConfig.database.modes.join(","),
1419
+ };
1420
+ }
1408
1421
  async prepareCommand(type: "build" | "start") {
1409
1422
  const akanConfig = await this.getConfig();
1410
- const databaseMode = process.env.AKAN_DATABASE_MODE ?? akanConfig.defaultDatabaseMode ?? "single";
1411
1423
  const routeEnv = {
1412
1424
  AKAN_PUBLIC_BASE_PATHS: [...akanConfig.basePaths].join(","),
1413
1425
  AKAN_PUBLIC_API_PREFIX: akanConfig.api.prefix,
1414
1426
  AKAN_PUBLIC_WS_PREFIX: akanConfig.api.websocketPrefix,
1415
- AKAN_DATABASE_MODE: databaseMode,
1427
+ ...(await this.getDatabaseModeEnv()),
1416
1428
  };
1417
1429
  Object.assign(process.env, routeEnv);
1418
1430
  if (type === "build") {
@@ -1457,11 +1469,14 @@ export class AppExecutor extends SysExecutor {
1457
1469
  // this does not leak non-public env.
1458
1470
  // The port keys are this machine's dev allocation, and `define` turns an `AKAN_PUBLIC_*` into a literal the
1459
1471
  // artifact can never be run with a different value for — a baked port would outrank the `PORT` the container
1460
- // is started with and send every SSR self-call to a port nothing bound.
1472
+ // is started with and send every SSR self-call to a port nothing bound. The operation mode likewise: one image
1473
+ // serves an edge site and a cloud cluster, and a shell's `local` would pin every artifact to it.
1461
1474
  if (type === "build") {
1462
1475
  const buildEnv = { ...env };
1463
1476
  delete buildEnv.AKAN_PUBLIC_CLIENT_PORT;
1464
1477
  delete buildEnv.AKAN_PUBLIC_SERVER_PORT;
1478
+ delete buildEnv.AKAN_PUBLIC_OPERATION_MODE;
1479
+ delete process.env.AKAN_PUBLIC_OPERATION_MODE;
1465
1480
  Object.assign(process.env, buildEnv);
1466
1481
  }
1467
1482
  return { env };
@@ -1507,7 +1522,9 @@ export class AppExecutor extends SysExecutor {
1507
1522
  const owners = new Map<string, { absPath: string; fromLib: boolean }>();
1508
1523
  const devOnlyKeys = new Set<string>();
1509
1524
  const devOnlyDirs: string[] = [];
1510
- for (const root of await this.getPageRoots()) {
1525
+ const pageRoots = await this.getPageRoots();
1526
+ const libKeyPrefixes = pageRoots.map((root) => root.keyPrefix).filter(Boolean);
1527
+ for (const root of pageRoots) {
1511
1528
  if (!(await FileSys.dirExists(root.dir))) continue;
1512
1529
  for await (const rel of glob.scan({
1513
1530
  cwd: root.dir,
@@ -1517,6 +1534,8 @@ export class AppExecutor extends SysExecutor {
1517
1534
  const segments = rel.split(path.sep);
1518
1535
  if (segments.some((s) => s === "node_modules")) continue;
1519
1536
  const posix = `${root.keyPrefix}${segments.join("/")}`;
1537
+ //* A lib folder that is a copy rather than a link (the Windows fallback) is walked into from `page` too.
1538
+ if (!root.keyPrefix && libKeyPrefixes.some((prefix) => posix.startsWith(prefix))) continue;
1520
1539
  const absPath = path.join(root.dir, ...segments);
1521
1540
  validatePageSourceFile(posix, { filePath: absPath });
1522
1541
  if (!isRouteSourceFile(posix)) continue;
@@ -1619,7 +1638,11 @@ export class AppExecutor extends SysExecutor {
1619
1638
  )
1620
1639
  ).flat();
1621
1640
  const wanted = parents.flatMap((parent) => libs.map((lib) => `${parent}${AppExecutor.#pageLibsDir}/(${lib})/`));
1622
- if (linked.sort().join(",") === wanted.sort().join(",")) return false;
1641
+ //* A copy — the Windows fallback when a junction is refused — does not follow its lib, so only links are current.
1642
+ const isLink = async (key: string) =>
1643
+ (await lstat(`${this.cwdPath}/page/${key.slice(0, -1)}`).catch(() => null))?.isSymbolicLink() ?? false;
1644
+ if (linked.sort().join(",") === wanted.sort().join(",") && (await Promise.all(linked.map(isLink))).every(Boolean))
1645
+ return false;
1623
1646
  await Promise.all(
1624
1647
  parents.map((parent) => this.removeDir(`${this.cwdPath}/page/${parent}${AppExecutor.#pageLibsDir}`)),
1625
1648
  );
@@ -161,6 +161,19 @@ describe("transformSource — client role (package client entry)", () => {
161
161
  );
162
162
  });
163
163
 
164
+ test("never claims fetch from a file that calls the platform fetch", () => {
165
+ const globalOnly = `"use client";\n\nexport const toBlob = async (url: string) => await (await fetch(url)).blob();\n`;
166
+ const mixed = `"use client";\n\nexport const C = async (url: string) => (await fetch(url)).ok && fetch.me() && st.foo;\n`;
167
+ const passed = `"use client";\n\nexport const load = (get = fetch) => get("/a");\n`;
168
+
169
+ expect(transformSource(globalOnly, "CropImage.tsx", clientLibCtx)).toBeNull();
170
+ expect(transformSource(mixed, "C.Zone.tsx", clientLibCtx)).toBe(
171
+ `"use client";\nimport { st } from "@libs/shared/client";\n\nexport const C = async (url: string) => (await fetch(url)).ok && fetch.me() && st.foo;\n`,
172
+ );
173
+ expect(transformSource(passed, "load.ts", clientLibCtx)).toBeNull();
174
+ expect(transformSource(globalOnly, "x.store.ts", storeCtx)).toBeNull();
175
+ });
176
+
164
177
  test("handles webkit .ts files (no jsx) the same as tsx", () => {
165
178
  const source = `import { getEnv } from "akanjs/base";\n\nexport const load = () => fetch.me();\n`;
166
179
  const out = transformSource(source, "cookie.ts", clientLibCtx);
@@ -161,6 +161,10 @@ const LIB_BARRELS: Record<string, { barrel: string; kind: ImportKind }> = {
161
161
  cnst: { barrel: "cnst", kind: "namespace" },
162
162
  srv: { barrel: "srv", kind: "namespace" },
163
163
  };
164
+ //* Registry names that are also runtime globals. An unbound `fetch(url)` is the platform's fetch, and importing Akan's
165
+ //* over it silently rebinds the call, so such a name is claimed only when every reference has the Akan member shape
166
+ //* (`fetch.viewX()`); one bare use leaves the file alone.
167
+ const GLOBAL_NAMES = new Set(["fetch"]);
164
168
  //* ECMAScript/TS structural globals never resolved as domain symbols, so that a package model named
165
169
  //* e.g. `Map` cannot shadow the JS `Map` used in `new Map()`. Domain-y globals like `File` are allowed
166
170
  //* on purpose (they are real models here).
@@ -378,13 +382,14 @@ export const transformSource = (
378
382
  const sf = ts.createSourceFile(fileName, source, ts.ScriptTarget.Latest, true, scriptKindFor(fileName));
379
383
  const bound = collectBoundNames(sf);
380
384
  const used = collectUsedReferences(sf);
385
+ const bareGlobals = collectBareGlobalReferences(sf);
381
386
 
382
387
  // Group the needed symbols by their import target: framework registry first, then dynamic resolver.
383
388
  // Named groups map each name to whether it is type-only, so we can emit `import type`/inline `type`.
384
389
  const namedBySpecifier = new Map<string, Map<string, boolean>>();
385
390
  const namespaceImports: { name: string; specifier: string }[] = [];
386
391
  for (const name of used) {
387
- if (bound.has(name)) continue;
392
+ if (bound.has(name) || bareGlobals.has(name)) continue;
388
393
  const target = targetFor(name, ctx) ?? resolveExtra?.(name) ?? null;
389
394
  if (!target) continue;
390
395
  if (target.kind === "namespace") namespaceImports.push({ name, specifier: target.specifier });
@@ -478,6 +483,19 @@ const collectUsedReferences = (sf: ts.SourceFile): Set<string> => {
478
483
  return used;
479
484
  };
480
485
 
486
+ const collectBareGlobalReferences = (sf: ts.SourceFile): Set<string> => {
487
+ const bare = new Set<string>();
488
+ const visit = (node: ts.Node) => {
489
+ if (ts.isIdentifier(node) && GLOBAL_NAMES.has(node.text) && isReferencePosition(node)) {
490
+ const parent = node.parent;
491
+ if (!(parent && ts.isPropertyAccessExpression(parent) && parent.expression === node)) bare.add(node.text);
492
+ }
493
+ ts.forEachChild(node, visit);
494
+ };
495
+ ts.forEachChild(sf, visit);
496
+ return bare;
497
+ };
498
+
481
499
  //* True when the identifier reads a binding, as opposed to being a member name, property key, or
482
500
  //* declaration name. e.g. in `cnst.Coordinate`, `cnst` is a reference but `Coordinate` is not.
483
501
  const isReferencePosition = (node: ts.Identifier): boolean => {
@@ -27,7 +27,7 @@ afterEach(async () => {
27
27
 
28
28
  describe("route client store bootstrap", () => {
29
29
  test("wraps client entries with app client bootstrap before re-exporting components", () => {
30
- const original = "/repo/pkgs/akanjs/ui/Model/NewWrapper_Client.tsx";
30
+ const original = path.resolve("/repo/pkgs/akanjs/ui/Model/NewWrapper_Client.tsx");
31
31
  const source = RouteClientBuilder.createStoreBootstrapEntrySource({
32
32
  appName: "akan",
33
33
  originalEntry: original,
@@ -147,6 +147,57 @@ describe("route client store bootstrap", () => {
147
147
  );
148
148
  });
149
149
 
150
+ test("bundles every route's entries into one graph so a page never loads a module twice", async () => {
151
+ const root = await makeTempRoot();
152
+ const appDir = path.join(root, "apps/demo");
153
+ const pageA = path.join(appDir, "page/a.tsx");
154
+ const pageB = path.join(appDir, "page/b.tsx");
155
+ const entryA = path.join(appDir, "ui/A.tsx");
156
+ const entryB = path.join(appDir, "ui/B.tsx");
157
+ const shared = path.join(appDir, "common/shared.ts");
158
+ await write(pageA, 'import { A } from "../ui/A";\nexport default A;\n');
159
+ await write(pageB, 'import { B } from "../ui/B";\nexport default B;\n');
160
+ await write(entryA, '"use client";\nimport { mark } from "../common/shared";\nexport const A = () => mark;\n');
161
+ await write(entryB, '"use client";\nimport { mark } from "../common/shared";\nexport const B = () => mark;\n');
162
+ await write(shared, 'export const mark = "shared-module-marker";\n');
163
+ const app = {
164
+ name: "demo",
165
+ cwdPath: appDir,
166
+ dist: { cwdPath: path.join(root, "dist/apps/demo") },
167
+ workspace: { workspaceRoot: root },
168
+ getConfig: async () => ({ barrelImports: [], optimizeImports: [] }),
169
+ getTsConfig: async () => ({ compilerOptions: { paths: {} } }),
170
+ getPublicEnv: () => ({}),
171
+ } as never;
172
+ const discovery = new GraphClientEntryDiscovery({ barrelImports: [] }, async () => null);
173
+ const build = (seeds: string[], knownEntries: Set<string>) =>
174
+ new RouteClientBuilder({
175
+ app,
176
+ seeds,
177
+ graphSeeds: [pageA, pageB],
178
+ knownEntries,
179
+ discovery,
180
+ artifact: {} as never,
181
+ }).build();
182
+
183
+ const first = await build([pageA], new Set());
184
+ const second = await build([pageB], new Set(first.newEntries));
185
+
186
+ expect(first.newEntries).toEqual([entryA, entryB]);
187
+ expect(first.discoveredEntries).toEqual([entryA]);
188
+ expect(first.clientDeps).not.toContain(entryB);
189
+ expect(Object.keys(first.clientDepsByEntry ?? {}).sort()).toEqual([entryA, entryB]);
190
+ expect(second.newEntries).toEqual([]);
191
+ expect(second.discoveredEntries).toEqual([entryB]);
192
+ expect(Object.keys(second.manifestDelta)).toEqual([]);
193
+ const clientOut = path.join(appDir, ".akan/artifact/client");
194
+ const outputs = [...new Bun.Glob("**/*.js").scanSync(clientOut)];
195
+ const carriers = await Promise.all(
196
+ outputs.map(async (file) => (await Bun.file(path.join(clientOut, file)).text()).includes("shared-module-marker")),
197
+ );
198
+ expect(carriers.filter(Boolean)).toHaveLength(1);
199
+ });
200
+
150
201
  test("discovers client entries from installed akanjs package sources", async () => {
151
202
  const root = await makeTempRoot();
152
203
  const seed = path.join(root, "apps/demo/page/_index.tsx");
@@ -135,7 +135,7 @@ export class CssImportResolver {
135
135
 
136
136
  async #firstExisting(basePath: string): Promise<string | null> {
137
137
  for (const suffix of CSS_IMPORT_EXTS) {
138
- const candidate = `${basePath}${suffix}`;
138
+ const candidate = path.normalize(`${basePath}${suffix}`);
139
139
  if (await Bun.file(candidate).exists()) return candidate;
140
140
  }
141
141
  return null;
@@ -170,7 +170,7 @@ describe("FontOptimizer discovery", () => {
170
170
  expect(result.fonts).toEqual([]);
171
171
  expect(result.files).toEqual([]);
172
172
  expect(warnings).toHaveLength(1);
173
- expect(warnings[0]).toContain("page/_layout.tsx");
173
+ expect(warnings[0]).toContain(path.join("page", "_layout.tsx"));
174
174
  });
175
175
 
176
176
  test("warns about an entry it cannot read and keeps the ones it can", async () => {