@akanjs/devkit 3.1.1 → 3.1.2-rc.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/akanConfig/akanConfig.test.ts +75 -0
  2. package/akanConfig/akanConfig.ts +11 -1
  3. package/akanMcpContract.test.ts +28 -1
  4. package/akanMcpContract.ts +51 -3
  5. package/applicationBuildRunner.test.ts +36 -0
  6. package/applicationBuildRunner.ts +4 -3
  7. package/artifact/implicitRootLayout.ts +4 -2
  8. package/cloud/globalConfig.test.ts +34 -0
  9. package/cloud/globalConfig.ts +2 -2
  10. package/codeAgent/agent/AkanCodeEnv.ts +26 -0
  11. package/codeAgent/agent/AkanCodeServices.ts +5 -2
  12. package/codeAgent/agent/CodeAgent.ts +7 -0
  13. package/codeAgent/agent/CodeAgentGate.ts +156 -10
  14. package/codeAgent/agent/CodeAgentProxy.ts +5 -4
  15. package/codeAgent/agent/CodeAgentRpcHost.ts +3 -0
  16. package/codeAgent/agent/CodeAgentRpcListener.ts +102 -16
  17. package/codeAgent/agent/WorkspaceTrust.ts +200 -0
  18. package/codeAgent/agent/akanCodePaths.ts +8 -1
  19. package/codeAgent/agent/akanSystemPrompt.ts +4 -0
  20. package/codeAgent/agent/codeAgentContract.test.ts +119 -0
  21. package/codeAgent/agent/codeAgentHosts.ts +30 -0
  22. package/codeAgent/agent/codeAgentProxy.test.ts +53 -1
  23. package/codeAgent/agent/codeAgentRpcListener.test.ts +115 -13
  24. package/codeAgent/agent/stringArg.ts +1 -1
  25. package/codeAgent/skills/akan-agent/SKILL.md +8 -1
  26. package/codeAgent/skills/akan-code/SKILL.md +37 -11
  27. package/codeAgent/skills/akan-debug/SKILL.md +2 -1
  28. package/codeAgent/skills/akan-err/SKILL.md +2 -1
  29. package/codeAgent/skills/akan-query/SKILL.md +3 -2
  30. package/codeAgent/skills/akan-runtime/SKILL.md +3 -0
  31. package/codeAgent/tools/McpServerConfig.ts +6 -4
  32. package/codeAgent/tools/McpSignIn.ts +7 -7
  33. package/codeAgent/tools/McpTokenStore.ts +63 -16
  34. package/codeAgent/tools/McpToolPack.ts +8 -1
  35. package/codeAgent/tools/WebToolPack.ts +50 -6
  36. package/codeAgent/tools/akanCodePlugins.test.ts +70 -2
  37. package/codeAgent/tools/mcpOAuth.test.ts +50 -14
  38. package/codeAgent/tools/mcpServerConfig.test.ts +130 -1
  39. package/commandDecorators/command.ts +4 -1
  40. package/createTunnel.test.ts +200 -2
  41. package/createTunnel.ts +80 -6
  42. package/envSeed.test.ts +90 -0
  43. package/envSeed.ts +68 -0
  44. package/executors.test.ts +19 -1
  45. package/executors.ts +41 -2
  46. package/fileSys.test.ts +30 -2
  47. package/fileSys.ts +14 -0
  48. package/formSetterScanner.test.ts +44 -1
  49. package/formSetterScanner.ts +53 -8
  50. package/frontendBuild/csrArtifactBuilder.ts +7 -0
  51. package/frontendBuild/mcpAppViewBuilder.test.ts +63 -0
  52. package/frontendBuild/mcpAppViewBuilder.ts +7 -1
  53. package/frontendBuild/optionalPeers.test.ts +132 -0
  54. package/integration/ssrMemoryProbe.ts +6 -0
  55. package/lint/no-unguarded-endpoint.grit +5 -5
  56. package/maintainabilityReport.ts +1 -1
  57. package/mobile/desktopServerStage.test.ts +1 -0
  58. package/mobile/desktopServerStage.ts +3 -1
  59. package/mobile/nativeApp.test.ts +20 -5
  60. package/mobile/nativeApp.ts +4 -1
  61. package/mobile/nativeConfig.test.ts +209 -5
  62. package/mobile/nativeConfig.ts +127 -6
  63. package/mobile/nativePublicEnv.test.ts +2 -0
  64. package/mobile/nativePublicEnv.ts +13 -0
  65. package/package.json +3 -2
  66. package/shellOnlyEnv.test.ts +65 -0
  67. package/shellOnlyEnv.ts +54 -0
  68. package/signalMutationIndex.ts +22 -81
  69. package/sshHostKeys.ts +59 -0
  70. package/storeSource.ts +9 -8
  71. package/subspace.test.ts +10 -0
  72. package/subspaceClone.ts +3 -1
  73. package/subspaceConfig.ts +6 -0
  74. package/transforms/barrelImportsPlugin.ts +10 -4
  75. package/transforms/mcpAppPageTrimmer.test.ts +8 -2
  76. package/transforms/mcpAppPageTrimmer.ts +5 -20
  77. package/transforms/pureLazyMarker.ts +1 -1
  78. package/workflow/mutationScaffold.ts +35 -21
  79. package/workflow/plan.ts +28 -0
  80. package/workflow/sourceAst.ts +10 -48
package/executors.test.ts CHANGED
@@ -376,6 +376,23 @@ describe("Workspace and app executor environment contracts", () => {
376
376
  });
377
377
  });
378
378
 
379
+ test("a workspace with no .env, as a fresh clone has, runs local unless the shell names an environment", async () => {
380
+ const root = await makeTempRoot();
381
+ delete process.env.AKAN_PUBLIC_ENV;
382
+
383
+ WorkspaceExecutor.defaultCloneToLocalEnv(root);
384
+ expect(WorkspaceExecutor.getBaseDevEnv(path.join(root, ".env")).env).toBe("local");
385
+
386
+ process.env.AKAN_PUBLIC_ENV = "main";
387
+ WorkspaceExecutor.defaultCloneToLocalEnv(root);
388
+ expect(process.env.AKAN_PUBLIC_ENV).toBe("main");
389
+
390
+ delete process.env.AKAN_PUBLIC_ENV;
391
+ await writeFile(path.join(root, ".env"), "AKAN_PUBLIC_REPO_NAME=repo\n");
392
+ WorkspaceExecutor.defaultCloneToLocalEnv(root);
393
+ expect(WorkspaceExecutor.getBaseDevEnv(path.join(root, ".env")).env).toBe("debug");
394
+ });
395
+
379
396
  test("a build or tunnel for a real environment refuses a defaulted value, and a local one takes it", () => {
380
397
  const baseDevEnv = {
381
398
  repoName: "fixture-repo",
@@ -1305,7 +1322,8 @@ const createScannableLib = async (
1305
1322
  await writeJson(path.join(libDir, "tsconfig.json"), { compilerOptions: { target: "ESNext", paths: {} } });
1306
1323
  await writeText(path.join(libDir, "akan.config.ts"), config);
1307
1324
  await mkdir(path.join(libDir, "lib", "__scalar"), { recursive: true });
1308
- for (const [file, content] of Object.entries(files)) await writeText(path.join(libDir, file), content);
1325
+ const libFiles = { "env/env.server.testing.ts": "export const env = {};\n", ...files };
1326
+ for (const [file, content] of Object.entries(libFiles)) await writeText(path.join(libDir, file), content);
1309
1327
  return { ...temp, libDir };
1310
1328
  };
1311
1329
 
package/executors.ts CHANGED
@@ -44,6 +44,7 @@ import {
44
44
  import { AkanAppConfig, AkanLibConfig, AkanWorkspaceConfig, decreaseBuildNum, increaseBuildNum } from "./akanConfig";
45
45
  import { getRootBoundarySegments, isRootBoundarySegments } from "./artifact/implicitRootLayout";
46
46
  import { CodegenLock } from "./codegenLock";
47
+ import { EnvSeed } from "./envSeed";
47
48
  import { FileSys, getDirname } from "./fileSys";
48
49
  import { JsonFormatter } from "./jsonFormatter";
49
50
  import { Linter } from "./linter";
@@ -429,11 +430,13 @@ export class Executor {
429
430
  targetPath,
430
431
  scanInfo,
431
432
  overwrite = true,
433
+ dryRun = false,
432
434
  }: {
433
435
  templatePath: string;
434
436
  targetPath: string;
435
437
  scanInfo?: AppInfo | LibInfo | null;
436
438
  overwrite?: boolean;
439
+ dryRun?: boolean;
437
440
  },
438
441
  dict: { [key: string]: string } = {},
439
442
  options: { [key: string]: unknown } = {},
@@ -456,6 +459,7 @@ export class Executor {
456
459
  const filename = typeof result === "object" ? result.filename : path.basename(targetPath).replace(".js", ".ts");
457
460
  const content = typeof result === "object" ? result.content : result;
458
461
  const convertedTargetPath = convertPath(`${path.dirname(targetPath)}/${filename}`);
462
+ if (dryRun) return { filePath: this.getPath(convertedTargetPath), content, written: false };
459
463
  this.logger.verbose(`Apply template ${templatePath} to ${convertedTargetPath}`);
460
464
  return this.writeFile(convertedTargetPath, content, { overwrite });
461
465
  } else if (targetPath.endsWith(".template")) {
@@ -465,11 +469,13 @@ export class Executor {
465
469
  (data, [key, value]) => data.replace(new RegExp(`<%= ${key} %>`, "g"), value),
466
470
  content,
467
471
  );
472
+ if (dryRun) return { filePath: this.getPath(convertedTargetPath), content: convertedContent, written: false };
468
473
  this.logger.verbose(`Apply template ${templatePath} to ${convertedTargetPath}`);
469
474
  return this.writeFile(convertedTargetPath, convertedContent, { overwrite });
470
475
  } else if (staticTemplateFileExtensions.has(path.extname(targetPath).toLowerCase())) {
471
476
  const convertedTargetPath = convertPath(targetPath);
472
477
  const writePath = this.getPath(convertedTargetPath);
478
+ if (dryRun) return { filePath: writePath, content: "", written: false };
473
479
  if (!overwrite && (await FileSys.fileExists(writePath)))
474
480
  return { filePath: writePath, content: "", written: false };
475
481
  const dirname = path.dirname(writePath);
@@ -486,6 +492,7 @@ export class Executor {
486
492
  dict = {},
487
493
  options = {},
488
494
  overwrite = true,
495
+ dryRun = false,
489
496
  }: {
490
497
  basePath: string;
491
498
  template: string;
@@ -493,6 +500,7 @@ export class Executor {
493
500
  dict?: { [key: string]: string };
494
501
  options?: { [key: string]: unknown };
495
502
  overwrite?: boolean;
503
+ dryRun?: boolean;
496
504
  }): Promise<WrittenFileContent[]> {
497
505
  const templateRoot = await this.#resolveTemplateRoot();
498
506
  const templatePath = `${templateRoot}${template ? `/${template}` : ""}`;
@@ -503,6 +511,7 @@ export class Executor {
503
511
  targetPath: path.join(basePath, path.basename(templatePath)),
504
512
  scanInfo,
505
513
  overwrite,
514
+ dryRun,
506
515
  },
507
516
  dict,
508
517
  options,
@@ -521,6 +530,7 @@ export class Executor {
521
530
  targetPath: path.join(basePath, subdir),
522
531
  scanInfo,
523
532
  overwrite,
533
+ dryRun,
524
534
  },
525
535
  dict,
526
536
  options,
@@ -533,6 +543,7 @@ export class Executor {
533
543
  scanInfo,
534
544
  dict,
535
545
  overwrite,
546
+ dryRun,
536
547
  options,
537
548
  });
538
549
  }),
@@ -558,6 +569,7 @@ export class Executor {
558
569
  dict?: { [key: string]: string };
559
570
  options?: { [key: string]: unknown };
560
571
  overwrite?: boolean;
572
+ dryRun?: boolean;
561
573
  }): Promise<FileContent[]> {
562
574
  const fileContents = await this._applyTemplate({ ...options, dict: withCapitalizedDict(options.dict) });
563
575
  await this.#formatAppliedTemplate(fileContents.filter((fileContent) => fileContent.written));
@@ -712,6 +724,10 @@ export class WorkspaceExecutor extends Executor {
712
724
  defaulted,
713
725
  };
714
726
  }
727
+ //? A clone has no `.env` (gitignored for its secrets): it runs local, as a new workspace and env.server.ts do, not debug.
728
+ static defaultCloneToLocalEnv(workspaceRoot: string) {
729
+ if (!existsSync(path.join(workspaceRoot, ".env"))) process.env.AKAN_PUBLIC_ENV ??= "local";
730
+ }
715
731
  //? A build or tunnel for a real environment carries these into an image, a bundle or a host name it dials.
716
732
  static assertDeployableEnv(baseDevEnv: BaseDevEnv, environment: string, purpose: string) {
717
733
  const defaulted = environment === "local" ? [] : (baseDevEnv.defaulted ?? []);
@@ -804,11 +820,19 @@ export class WorkspaceExecutor extends Executor {
804
820
  };
805
821
  return await getDirs(basePath);
806
822
  }
807
- async commit(message: string, { init = false, add = true }: { init?: boolean; add?: boolean } = {}) {
823
+ async commit(
824
+ message: string,
825
+ {
826
+ init = false,
827
+ add = true,
828
+ identity,
829
+ }: { init?: boolean; add?: boolean; identity?: { name: string; email: string } } = {},
830
+ ) {
808
831
  if (init) await this.spawn("git", ["init", "--quiet"]);
809
832
  if (add) await this.spawn("git", ["add", "."]);
833
+ const author = identity ? ["-c", `user.name=${identity.name}`, "-c", `user.email=${identity.email}`] : [];
810
834
  // Argument vector, not a shell string: a message carrying a double quote breaks the interpolated form.
811
- await this.spawn("git", ["commit", "--quiet", "-m", message]);
835
+ await this.spawn("git", [...author, "commit", "--quiet", "-m", message]);
812
836
  }
813
837
  /** `git commit` exits non-zero on an empty index, so a re-runnable caller has to ask first. */
814
838
  async hasChanges() {
@@ -923,8 +947,23 @@ export class SysExecutor extends Executor {
923
947
  if (!this.hasScanInfo() && !allowEmpty) throw new Error("Scan info is not available");
924
948
  return this.#scanInfo as AllowEmpty extends true ? AppInfo | LibInfo | null : AppInfo | LibInfo;
925
949
  }
950
+ async #seedEnvFiles(): Promise<FileContent[]> {
951
+ const envDir = path.join(this.cwdPath, "env");
952
+ const missing = await new EnvSeed(envDir).missing();
953
+ if (!missing.length) return [];
954
+ const written = await Promise.all(
955
+ missing.map(({ fileName, content }) =>
956
+ this.writeFile(path.join(envDir, fileName), content, { overwrite: false, silent: true }),
957
+ ),
958
+ );
959
+ this.logger.info(
960
+ `${missing.map(({ fileName }) => `env/${fileName}`).join(", ")} were missing (gitignored, so a clone has none) and now hold local defaults: put this machine's values in them, or fetch the team's with \`akan download-env\`.`,
961
+ );
962
+ return written;
963
+ }
926
964
  #getScanTemplateTasks(scanInfo: AppInfo | LibInfo): (Promise<FileContent[]> | null)[] {
927
965
  return [
966
+ this.#seedEnvFiles(),
928
967
  this._applyTemplate({ basePath: "env", template: "env", scanInfo }),
929
968
  this._applyTemplate({ basePath: "lib", template: "lib", scanInfo }),
930
969
  this._applyTemplate({ basePath: ".", template: "server.ts", scanInfo }),
package/fileSys.test.ts CHANGED
@@ -1,7 +1,9 @@
1
- import { describe, expect, test } from "bun:test";
1
+ import { describe, expect, spyOn, test } from "bun:test";
2
+ import { chmodSync, mkdirSync, mkdtempSync, rmSync, statSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
2
4
  import path from "node:path";
3
5
  import { pathToFileURL } from "node:url";
4
- import { getDirname } from "./fileSys";
6
+ import { FileSys, getDirname } from "./fileSys";
5
7
 
6
8
  describe("getDirname", () => {
7
9
  test("converts file URLs to filesystem paths", () => {
@@ -19,3 +21,29 @@ describe("getDirname", () => {
19
21
  expect(dirname).toBe("C:\\Users\\ken78\\.bun\\install\\global\\node_modules\\@akanjs\\devkit");
20
22
  });
21
23
  });
24
+
25
+ describe("FileSys.ensurePrivateDir", () => {
26
+ //? macOS refuses even the owner a chmod of a folder flagged user-immutable; Linux needs root for the same.
27
+ test.skipIf(process.platform !== "darwin")(
28
+ "warns instead of throwing when the folder's mode cannot be changed",
29
+ () => {
30
+ const root = mkdtempSync(path.join(tmpdir(), "akan-private-dir-"));
31
+ const dir = path.join(root, "locked");
32
+ mkdirSync(dir);
33
+ chmodSync(dir, 0o755);
34
+ Bun.spawnSync(["chflags", "uchg", dir]);
35
+ const warn = spyOn(FileSys.logger, "warn").mockImplementation(() => undefined);
36
+ try {
37
+ expect(FileSys.ensurePrivateDir(dir)).toBe(dir);
38
+ expect(statSync(dir).mode & 0o777).toBe(0o755);
39
+ expect(warn.mock.calls.map(([line]) => String(line))).toEqual([
40
+ expect.stringContaining(`${dir} stays at mode 755`),
41
+ ]);
42
+ } finally {
43
+ warn.mockRestore();
44
+ Bun.spawnSync(["chflags", "nouchg", dir]);
45
+ rmSync(root, { recursive: true, force: true });
46
+ }
47
+ },
48
+ );
49
+ });
package/fileSys.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { chmodSync, mkdirSync, statSync } from "node:fs";
1
2
  import { lstat, rename, rm, stat } from "node:fs/promises";
2
3
  import nodePath from "node:path";
3
4
  import { fileURLToPath } from "node:url";
@@ -70,6 +71,19 @@ export class FileSys {
70
71
  }
71
72
  }
72
73
  }
74
+ //? `mkdir`'s mode only applies to a directory it creates, so one an older build made is tightened here.
75
+ static ensurePrivateDir(dir: string) {
76
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
77
+ if (process.platform === "win32") return dir;
78
+ const { mode, uid } = statSync(dir);
79
+ if ((mode & 0o077) === 0 || uid !== process.getuid?.()) return dir;
80
+ try {
81
+ chmodSync(dir, 0o700);
82
+ } catch (error) {
83
+ FileSys.logger.warn(`${dir} stays at mode ${(mode & 0o777).toString(8)}: ${(error as Error).message}`);
84
+ }
85
+ return dir;
86
+ }
73
87
  static async writeJson(path: string, content: object) {
74
88
  return await Bun.file(path).write(`${JSON.stringify(content, null, 2)}\n`);
75
89
  }
@@ -10,8 +10,26 @@ const fileOf = (file: string, content: string): SourceFileInfo => ({
10
10
  sourceFile: ts.createSourceFile(file, content, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX),
11
11
  });
12
12
 
13
+ const taskConstant = fileOf(
14
+ "apps/demo/lib/task/task.constant.ts",
15
+ `export class TaskInput extends via((field) => ({
16
+ title: field(String),
17
+ type: field(String),
18
+ phone: field(String),
19
+ project: field(Project).optional(),
20
+ members: field([User]),
21
+ })) {}
22
+
23
+ export class TaskObject extends via(TaskInput, (field) => {
24
+ return { status: field(String) };
25
+ }) {}
26
+
27
+ export class TaskInsight extends via(Task, (field) => ({ onSale: field(Int) })) {}
28
+ `,
29
+ );
30
+
13
31
  const scan = (content: string) =>
14
- new FormSetterScanner().scan([fileOf("apps/demo/lib/task/Task.Template.tsx", content)]);
32
+ new FormSetterScanner().scan([taskConstant, fileOf("apps/demo/lib/task/Task.Template.tsx", content)]);
15
33
 
16
34
  describe("FormSetterScanner", () => {
17
35
  test("a setter passed by reference publishes, so nothing is reported", () => {
@@ -73,6 +91,31 @@ describe("FormSetterScanner", () => {
73
91
  ).toEqual([]);
74
92
  });
75
93
 
94
+ test("a slice setter is not a field setter, though its name reads set…On…", () => {
95
+ expect(
96
+ scan(`export const Filters = () => (
97
+ <Select onChange={(page) => st.do.setPageOfTaskOnSale(page)} />
98
+ );
99
+ `),
100
+ ).toEqual([]);
101
+ });
102
+
103
+ test("a field is known from its model's constant, so another model's setter is not counted", () => {
104
+ expect(
105
+ scan(`export const General = () => (
106
+ <>
107
+ <Field.Text onChange={(title) => st.do.setTitleOnProject(title)} />
108
+ <Field.Text onChange={(onSale) => st.do.setOnSaleOnTask(onSale)} />
109
+ </>
110
+ );
111
+ `),
112
+ ).toEqual([]);
113
+ expect(
114
+ scan(`export const General = () => <Field.Text onChange={(status) => st.do.setStatusOnTask(status)} />;\n`)[0]
115
+ ?.message,
116
+ ).toContain("setStatusOnTask");
117
+ });
118
+
76
119
  test("only .tsx is scanned", () => {
77
120
  const content = `export const set = (v: string) => <X onChange={(t) => st.do.setTitleOnTask(t)} />;\n`;
78
121
  expect(new FormSetterScanner().scan([fileOf("apps/demo/lib/task/task.store.ts", content)])).toEqual([]);
@@ -1,3 +1,4 @@
1
+ import { capitalize, formSetterNames } from "akanjs/common";
1
2
  import ts from "typescript";
2
3
  import type { QualityWarning, SourceFileInfo } from "./qualityScanner";
3
4
 
@@ -5,17 +6,61 @@ import type { QualityWarning, SourceFileInfo } from "./qualityScanner";
5
6
  // forwarding wrappers; this counts every field left unpublished whatever the reason. A zero-parameter handler is a
6
7
  // button setting a constant, not a form control, so it is not counted.
7
8
  export class FormSetterScanner {
8
- static #fieldSetter = /^set[A-Za-z0-9_$]*On[A-Za-z0-9_$]*$/;
9
+ static #constantPath = /(?:^|\/)lib\/([^_/][^/]*)\/\1\.constant\.ts$/;
10
+ static #baseFields = ["id", "createdAt", "updatedAt", "removedAt"];
9
11
 
10
12
  scan(sourceFiles: SourceFileInfo[]): QualityWarning[] {
11
- return sourceFiles.filter((sourceFile) => sourceFile.file.endsWith(".tsx")).flatMap((f) => this.#scanFile(f));
13
+ const fieldSetters = FormSetterScanner.#fieldSettersOf(sourceFiles);
14
+ return sourceFiles
15
+ .filter((sourceFile) => sourceFile.file.endsWith(".tsx"))
16
+ .flatMap((f) => this.#scanFile(f, fieldSetters));
12
17
  }
13
18
 
14
- #scanFile({ file, sourceFile }: SourceFileInfo): QualityWarning[] {
19
+ // Built forward from the constants: matching `set…On…` also caught slice setters such as `setPageOfProductOnSale`.
20
+ static #fieldSettersOf(sourceFiles: SourceFileInfo[]) {
21
+ const setters = new Set<string>();
22
+ for (const { file, sourceFile } of sourceFiles) {
23
+ const refName = FormSetterScanner.#constantPath.exec(file)?.[1];
24
+ if (!refName) continue;
25
+ const className = capitalize(refName);
26
+ const fullModelClasses = new Set([`${className}Input`, `${className}Object`, `Light${className}`, className]);
27
+ const keys = new Set(FormSetterScanner.#baseFields);
28
+ for (const statement of sourceFile.statements)
29
+ if (ts.isClassDeclaration(statement) && statement.name && fullModelClasses.has(statement.name.text))
30
+ for (const key of FormSetterScanner.#viaFieldKeysOf(statement)) keys.add(key);
31
+ for (const key of keys) setters.add(formSetterNames(className, key).setFieldOnModel);
32
+ }
33
+ return setters;
34
+ }
35
+
36
+ static #viaFieldKeysOf(declaration: ts.ClassDeclaration): string[] {
37
+ const via = declaration.heritageClauses?.find((clause) => clause.token === ts.SyntaxKind.ExtendsKeyword)?.types[0]
38
+ ?.expression;
39
+ if (!via || !ts.isCallExpression(via)) return [];
40
+ return via.arguments.flatMap((argument) => {
41
+ if (!ts.isArrowFunction(argument) && !ts.isFunctionExpression(argument)) return [];
42
+ const fields = FormSetterScanner.#returnedObjectOf(argument.body);
43
+ return (fields?.properties ?? []).flatMap((property) =>
44
+ property.name && (ts.isIdentifier(property.name) || ts.isStringLiteral(property.name))
45
+ ? [property.name.text]
46
+ : [],
47
+ );
48
+ });
49
+ }
50
+
51
+ static #returnedObjectOf(body: ts.ConciseBody): ts.ObjectLiteralExpression | null {
52
+ let expression: ts.Node | undefined = body;
53
+ if (ts.isBlock(body)) expression = body.statements.find(ts.isReturnStatement)?.expression;
54
+ while (expression && (ts.isParenthesizedExpression(expression) || ts.isAsExpression(expression)))
55
+ expression = expression.expression;
56
+ return expression && ts.isObjectLiteralExpression(expression) ? expression : null;
57
+ }
58
+
59
+ #scanFile({ file, sourceFile }: SourceFileInfo, fieldSetters: Set<string>): QualityWarning[] {
15
60
  const wrapped: { setter: string; line: number }[] = [];
16
61
  const visit = (node: ts.Node) => {
17
62
  if (ts.isJsxAttribute(node)) {
18
- const setter = FormSetterScanner.#wrappedSetterOf(node);
63
+ const setter = FormSetterScanner.#wrappedSetterOf(node, fieldSetters);
19
64
  if (setter) wrapped.push({ setter, line: FormSetterScanner.#lineOf(sourceFile, node) });
20
65
  }
21
66
  ts.forEachChild(node, visit);
@@ -40,17 +85,17 @@ export class FormSetterScanner {
40
85
  }
41
86
 
42
87
  /** The setter a handler prop writes behind a wrapper, or null when it is passed by reference or absent. */
43
- static #wrappedSetterOf(attribute: ts.JsxAttribute): string | null {
88
+ static #wrappedSetterOf(attribute: ts.JsxAttribute, fieldSetters: Set<string>): string | null {
44
89
  if (!ts.isIdentifier(attribute.name) || !/^on[A-Z]/.test(attribute.name.text)) return null;
45
90
  const initializer = attribute.initializer;
46
91
  if (!initializer || !ts.isJsxExpression(initializer) || !initializer.expression) return null;
47
92
  const handler = initializer.expression;
48
93
  if (!ts.isArrowFunction(handler) && !ts.isFunctionExpression(handler)) return null;
49
94
  if (!handler.parameters.length) return null;
50
- return FormSetterScanner.#setterCallIn(handler.body);
95
+ return FormSetterScanner.#setterCallIn(handler.body, fieldSetters);
51
96
  }
52
97
 
53
- static #setterCallIn(node: ts.Node): string | null {
98
+ static #setterCallIn(node: ts.Node, fieldSetters: Set<string>): string | null {
54
99
  let found: string | null = null;
55
100
  const visit = (current: ts.Node) => {
56
101
  if (found) return;
@@ -61,7 +106,7 @@ export class FormSetterScanner {
61
106
  ts.isIdentifier(expression.expression) &&
62
107
  expression.expression.text === "st" &&
63
108
  expression.name.text === "do" &&
64
- FormSetterScanner.#fieldSetter.test(name.text)
109
+ fieldSetters.has(name.text)
65
110
  ) {
66
111
  found = name.text;
67
112
  return;
@@ -3,6 +3,7 @@ import path from "node:path";
3
3
  import type { BaseBuildArtifact } from "akanjs/server";
4
4
  import { type PageEntry, resolveSsrPageEntriesForApp } from "../artifact/implicitRootLayout";
5
5
  import type { App } from "../commandDecorators";
6
+ import { type BarrelImportsPluginOptions, createBarrelImportsPlugin } from "../transforms/barrelImportsPlugin";
6
7
  import { createBrowserDirnamePlugin } from "../transforms/browserDirnamePlugin";
7
8
  import { SideEffectImportOrder } from "../transforms/sideEffectImportOrder";
8
9
  import { bundleDefine } from "./bundleDefine";
@@ -67,6 +68,7 @@ export class CsrArtifactBuilder {
67
68
  const options = CsrArtifactBuilder.bundleOptions(this.#app, this.#command, akanConfig.optimizeImports);
68
69
  const result = await Bun.build({
69
70
  ...options,
71
+ plugins: [...options.plugins, await CsrArtifactBuilder.barrelImportsPlugin(this.#app)],
70
72
  entrypoints: htmlBasePaths.map((basePath) => this.#generatedPath(CsrArtifactBuilder.htmlFilename(basePath))),
71
73
  files: generatedFiles,
72
74
  root: this.#generatedDir,
@@ -118,6 +120,11 @@ export class CsrArtifactBuilder {
118
120
  };
119
121
  }
120
122
 
123
+ //? Whole, the akanjs/ui barrel bundles every component and the optional peers (react-icons) they import.
124
+ static async barrelImportsPlugin(app: App, pipeAfter?: BarrelImportsPluginOptions["pipeAfter"]) {
125
+ return await createBarrelImportsPlugin(app, { barrels: ["akanjs/ui"], pipeAfter });
126
+ }
127
+
121
128
  /** Where a build records the `AKAN_PUBLIC_*` it inlined, which nothing in the minified bundle names. */
122
129
  static publicEnvPath(app: App) {
123
130
  return path.join(app.dist.cwdPath, ".akan", "artifact", "csr-public-env.json");
@@ -1,6 +1,7 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import path from "node:path";
3
3
  import { tempDirs, writeText as write } from "../testHelpers";
4
+ import { CsrArtifactBuilder } from "./csrArtifactBuilder";
4
5
  import { McpAppViewBuilder } from "./mcpAppViewBuilder";
5
6
 
6
7
  const makeTempRoot = tempDirs("akan-mcp-app-views-");
@@ -132,6 +133,68 @@ describe("McpAppViewBuilder.discover", () => {
132
133
  });
133
134
  });
134
135
 
136
+ describe("McpAppViewBuilder.build", () => {
137
+ test("rewrites the akanjs/ui barrel, trims the page to its apps and drops a lazy component nothing renders", async () => {
138
+ const root = await makeTempRoot();
139
+ const file = (rel: string) => path.join(root, rel);
140
+ await write(
141
+ file("node_modules/akanjs/package.json"),
142
+ JSON.stringify({
143
+ name: "akanjs",
144
+ exports: { "./ui": "./ui/index.ts", "./ui/*": "./ui/*", "./webkit": "./webkit.ts" },
145
+ }),
146
+ );
147
+ await write(
148
+ file("node_modules/akanjs/webkit.ts"),
149
+ "export const McpAppBoot = { run: (view: unknown) => console.info(view) };\n",
150
+ );
151
+ await write(
152
+ file("node_modules/akanjs/ui/index.ts"),
153
+ 'export { Link } from "./Link";\nexport { Toolbox } from "./Toolbox";\n',
154
+ );
155
+ await write(file("node_modules/akanjs/ui/Link.ts"), 'export const Link = "a link";\n');
156
+ await write(
157
+ file("node_modules/akanjs/ui/Toolbox.ts"),
158
+ 'console.info("a toolbox loaded");\nexport const Toolbox = "a toolbox";\n',
159
+ );
160
+ await write(file("apps/demo/ui/lazy.ts"), "export const lazy = (load: () => Promise<unknown>) => ({ load });\n");
161
+ await write(file("apps/demo/ui/Heavy.ts"), 'export default "a heavy chunk";\n');
162
+ await write(
163
+ file("apps/demo/ui/cards.ts"),
164
+ 'import { lazy } from "./lazy";\nexport const Heavy = lazy(() => import("./Heavy"));\nexport const Card = "a card";\n',
165
+ );
166
+ await write(
167
+ file("apps/demo/page/home.tsx"),
168
+ [
169
+ 'import { Link } from "akanjs/ui";',
170
+ 'import { Card } from "../ui/cards";',
171
+ "const page = () => ({ app: (name: string, description: string, draw: () => unknown) => ({ render: (render: () => unknown) => [name, description, draw, render] }) });",
172
+ 'export default page().app("showHome", "Show home.", () => [Link, Card]).render(() => "drawn by the render");',
173
+ ].join("\n"),
174
+ );
175
+ const app = {
176
+ cwdPath: file("apps/demo"),
177
+ dist: { cwdPath: file("dist/apps/demo") },
178
+ workspace: { workspaceRoot: root },
179
+ getTsConfig: async () => ({ compilerOptions: {} }),
180
+ getPublicEnv: () => ({}),
181
+ logger: { warn: () => undefined },
182
+ };
183
+
184
+ await new McpAppViewBuilder(app as never, "build").build(
185
+ [{ key: "./home.tsx", moduleAbsPath: file("apps/demo/page/home.tsx") }],
186
+ { basePaths: [], cssAssets: {}, options: CsrArtifactBuilder.bundleOptions(app as never, "build") },
187
+ );
188
+
189
+ const view = await Bun.file(file("dist/apps/demo/mcp-apps/showHome.html")).text();
190
+ expect(view).toContain("a link");
191
+ expect(view).toContain("a card");
192
+ expect(view).not.toContain("a toolbox");
193
+ expect(view).not.toContain("drawn by the render");
194
+ expect(view).not.toContain("a heavy chunk");
195
+ });
196
+ });
197
+
135
198
  describe("McpAppViewBuilder.entryOf", () => {
136
199
  test("boots the one app with its page, the root layout and overrides it sits under, by relative path", () => {
137
200
  const view = {
@@ -78,7 +78,13 @@ export class McpAppViewBuilder {
78
78
  const pagePaths = [...new Set(views.map((view) => view.page.moduleAbsPath))];
79
79
  const result = await Bun.build({
80
80
  ...options,
81
- plugins: [...options.plugins, McpAppPageTrimmer.plugin(pagePaths), PureLazyMarker.plugin()],
81
+ plugins: [
82
+ ...options.plugins,
83
+ //? Bun runs one onLoad per module, so the one rewriting the barrel also trims the pages and marks `lazy()`; the
84
+ //? marker plugin takes the TypeScript that one leaves to Bun, a package's own outside node_modules/akanjs.
85
+ await CsrArtifactBuilder.barrelImportsPlugin(this.#app, McpAppPageTrimmer.pipe(pagePaths)),
86
+ PureLazyMarker.plugin(),
87
+ ],
82
88
  entrypoints: views.map((view) => path.join(generatedDir, `${view.name}.html`)),
83
89
  files,
84
90
  root: generatedDir,
@@ -0,0 +1,132 @@
1
+ import { describe, expect, spyOn, test } from "bun:test";
2
+ import { mkdir, symlink } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import type { BunPlugin } from "bun";
5
+ import { resolveSsrPageEntries } from "../artifact/implicitRootLayout";
6
+ import { tempDirs, writeText as write } from "../testHelpers";
7
+ import { CsrArtifactBuilder } from "./csrArtifactBuilder";
8
+ import { VENDOR_SPECIFIERS } from "./vendorSpecifiers";
9
+
10
+ const makeTempRoot = tempDirs("akan-optional-peers-");
11
+ const akanjsRoot = path.resolve(import.meta.dir, "../../../akanjs");
12
+ const { peerDependenciesMeta } = (await Bun.file(path.join(akanjsRoot, "package.json")).json()) as {
13
+ peerDependenciesMeta: Record<string, { optional?: boolean }>;
14
+ };
15
+ //? The app installs these itself: they are peers so that it renders with one React.
16
+ const appRenderer = new Set(["react", "react-dom", "react-server-dom-webpack"]);
17
+ const optionalPeers = Object.entries(peerDependenciesMeta)
18
+ .filter(([name, meta]) => meta.optional && !appRenderer.has(name))
19
+ .map(([name]) => name);
20
+ const optionalPeerImport = new RegExp(
21
+ `^(?:${optionalPeers.map((name) => name.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&")).join("|")})(?:/|$)`,
22
+ );
23
+
24
+ const recordOptionalPeers = (reached: string[]): BunPlugin => ({
25
+ name: "record-optional-peers",
26
+ setup(build) {
27
+ build.onResolve({ filter: optionalPeerImport }, ({ path: specifier, importer }) => {
28
+ reached.push(`${path.relative(akanjsRoot, importer)} -> ${specifier}`);
29
+ return { path: specifier, external: true };
30
+ });
31
+ },
32
+ });
33
+
34
+ const linkPackage = async (root: string, name: string, dir: string) => {
35
+ await mkdir(path.join(root, "node_modules"), { recursive: true });
36
+ await symlink(dir, path.join(root, "node_modules", name));
37
+ };
38
+
39
+ const writeApp = async (indexPage: string) => {
40
+ const root = await makeTempRoot();
41
+ const appDir = path.join(root, "apps/demo");
42
+ await linkPackage(root, "akanjs", akanjsRoot);
43
+ await linkPackage(root, "react", path.dirname(Bun.resolveSync("react/package.json", akanjsRoot)));
44
+ await write(
45
+ path.join(root, "tsconfig.json"),
46
+ JSON.stringify({ compilerOptions: { paths: { "@apps/*": ["./apps/*"] } } }),
47
+ );
48
+ await write(path.join(appDir, "client.ts"), "export {};\n");
49
+ await write(path.join(appDir, "env/env.client.ts"), "export const env = {};\n");
50
+ await write(path.join(appDir, "lib/dict.ts"), "export const getAllDictionary = () => ({});\n");
51
+ await write(path.join(appDir, "page/_index.tsx"), indexPage);
52
+ await write(path.join(appDir, "page/_overrides.tsx"), "export default {};\n");
53
+ return { root, appDir, pageKeys: ["./_index.tsx", "./_overrides.tsx"] };
54
+ };
55
+
56
+ const buildCsr = async (indexPage: string) => {
57
+ const { root, appDir, pageKeys } = await writeApp(indexPage);
58
+ const app = {
59
+ name: "demo",
60
+ cwdPath: appDir,
61
+ dist: { cwdPath: path.join(root, "dist/apps/demo") },
62
+ workspace: { workspaceRoot: root },
63
+ getPageKeys: async () => pageKeys,
64
+ getConfig: async () => ({ basePaths: [], optimizeImports: [], barrelImports: [] }),
65
+ getTsConfig: async () => ({ compilerOptions: { paths: { "@apps/*": ["./apps/*"] } } }),
66
+ getPublicEnv: () => ({}),
67
+ log: () => undefined,
68
+ verbose: () => undefined,
69
+ logger: { warn: () => undefined },
70
+ };
71
+ const reached: string[] = [];
72
+ const build = Bun.build;
73
+ const recorded = spyOn(Bun, "build").mockImplementation((config) =>
74
+ build({ ...config, plugins: [recordOptionalPeers(reached), ...(config.plugins ?? [])] }),
75
+ );
76
+
77
+ try {
78
+ await new CsrArtifactBuilder(app as never, "build").build();
79
+ } finally {
80
+ recorded.mockRestore();
81
+ }
82
+ return { root, reached };
83
+ };
84
+
85
+ describe("optional peers of akanjs", () => {
86
+ test("the generated root layout, the _overrides.tsx wrapper and the runtime entries import none", async () => {
87
+ const { appDir, pageKeys } = await writeApp("export default function Page() {\n return null;\n}\n");
88
+ const generated = (await resolveSsrPageEntries({ appCwdPath: appDir, appName: "demo", pageKeys }))
89
+ .map(({ moduleAbsPath }) => moduleAbsPath)
90
+ .filter((file) => file.includes(`${path.sep}.akan${path.sep}`));
91
+ const runtime = [
92
+ path.join(akanjsRoot, "server/rscClient.tsx"),
93
+ path.join(akanjsRoot, "server/rscSegmentOutlet.tsx"),
94
+ path.join(akanjsRoot, "webkit/index.ts"),
95
+ ...VENDOR_SPECIFIERS.map((specifier) =>
96
+ path.join(akanjsRoot, "server/vendor", `${specifier.replaceAll("/", "-").replaceAll(".", "-")}.ts`),
97
+ ),
98
+ ];
99
+ expect(generated).toHaveLength(2);
100
+
101
+ //? No barrel rewrite, as a dev registry compiles every module an import names.
102
+ for (const target of ["browser", "bun"] as const) {
103
+ const reached: string[] = [];
104
+ const result = await Bun.build({
105
+ entrypoints: [...generated, ...runtime],
106
+ target,
107
+ throw: false,
108
+ plugins: [recordOptionalPeers(reached)],
109
+ });
110
+
111
+ expect(result.logs.map((log) => log.message)).toEqual([]);
112
+ expect(reached).toEqual([]);
113
+ }
114
+ });
115
+
116
+ test("the CSR shell build of a page drawing akanjs/ui components imports none", async () => {
117
+ const { reached } = await buildCsr(
118
+ 'import { Link, System } from "akanjs/ui";\n\nexport default function Page() {\n return (\n <Link href="/">\n <System.ThemeToggle />\n </Link>\n );\n}\n',
119
+ );
120
+
121
+ expect(reached).toEqual([]);
122
+ });
123
+
124
+ test("the MCP App view build of a page drawing akanjs/ui components imports none", async () => {
125
+ const { root, reached } = await buildCsr(
126
+ 'import { page } from "akanjs/client";\nimport { Link } from "akanjs/ui";\n\nexport default page()\n .app("showHome", "Show the home link.", () => <Link href="/">home</Link>)\n .render(() => null);\n',
127
+ );
128
+
129
+ expect(await Bun.file(path.join(root, "dist/apps/demo/mcp-apps/showHome.html")).exists()).toBe(true);
130
+ expect(reached).toEqual([]);
131
+ });
132
+ });