@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.
- package/abstractDoc.ts +4 -1
- package/akanApp/akanApp.host.ts +23 -9
- package/akanConfig/akanConfig.test.ts +55 -30
- package/akanConfig/akanConfig.ts +29 -17
- package/akanConfig/types.ts +1 -0
- package/applicationBuildRunner.test.ts +5 -3
- package/applicationBuildRunner.ts +29 -3
- package/artifact/routeSeedIndex.test.ts +7 -6
- package/capacitorApp.test.ts +2 -2
- package/cloud/constants.ts +23 -0
- package/cloud/globalConfig.ts +13 -0
- package/codeAgent/agent/AkanCodeServices.ts +22 -3
- package/codeAgent/agent/CodeAgent.ts +33 -18
- package/codeAgent/agent/CodeAgentAsks.ts +3 -1
- package/codeAgent/agent/CodeAgentEventMapper.ts +9 -1
- package/codeAgent/agent/CodeAgentProxy.ts +91 -0
- package/codeAgent/agent/CodeAgentRpcHost.ts +29 -9
- package/codeAgent/agent/CodeAgentRpcListener.ts +117 -0
- package/codeAgent/agent/CodeAgentSuspended.ts +92 -0
- package/codeAgent/agent/akanCodeModel.ts +1 -1
- package/codeAgent/agent/akanContextFiles.test.ts +2 -1
- package/codeAgent/agent/codeAgentContract.test.ts +17 -0
- package/codeAgent/agent/codeAgentProxy.test.ts +74 -0
- package/codeAgent/agent/codeAgentRpcListener.test.ts +113 -0
- package/codeAgent/agent/codeAgentSuspended.test.ts +47 -0
- package/codeAgent/tools/mcpOAuth.test.ts +6 -2
- package/dependencyScanner.ts +4 -2
- package/devkitUtils.test.ts +1 -1
- package/executors.linkFallback.test.ts +103 -0
- package/executors.test.ts +8 -0
- package/executors.ts +31 -8
- package/frontendBuild/autoImportSync.test.ts +13 -0
- package/frontendBuild/autoImportSync.ts +19 -1
- package/frontendBuild/buildRouteClient.test.ts +52 -1
- package/frontendBuild/cssImportResolver.ts +1 -1
- package/frontendBuild/fontOptimizer.test.ts +1 -1
- package/frontendBuild/frontendBuild.test.ts +19 -17
- package/frontendBuild/hmrWatcher.test.ts +1 -1
- package/frontendBuild/routeClientBuilder.ts +27 -10
- package/frontendBuild/sourceMtimeIndex.test.ts +6 -5
- package/getDirname.test.ts +4 -2
- package/incrementalBuilder/devWatchBatch.test.ts +7 -6
- package/incrementalBuilder/incrementalBuilder.proc.ts +1 -0
- package/integration/devStabilityHarness.test.ts +7 -2
- package/lint/gritRules.test.ts +2 -1
- package/lint/no-deep-internal-import.grit +2 -2
- package/lint/no-redeclare-predefined-endpoint.grit +2 -1
- package/linter.ts +4 -1
- package/package.json +2 -2
- package/platformTest/BunTestReport.test.ts +60 -0
- package/platformTest/BunTestReport.ts +65 -0
- package/platformTest/IdleWatchedProcess.test.ts +64 -0
- package/platformTest/IdleWatchedProcess.ts +77 -0
- package/platformTest/LinuxDockerTarget.ts +120 -0
- package/platformTest/PlatformTestRun.test.ts +68 -0
- package/platformTest/PlatformTestRun.ts +141 -0
- package/platformTest/PlatformTestTarget.ts +120 -0
- package/platformTest/TestSnapshot.test.ts +80 -0
- package/platformTest/TestSnapshot.ts +107 -0
- package/platformTest/WindowsSshTarget.ts +167 -0
- package/subspace.test.ts +5 -2
- package/subspace.ts +19 -8
- 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
|
|
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
|
-
|
|
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 () => {
|
package/dependencyScanner.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
package/devkitUtils.test.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 () => {
|