@akanjs/devkit 3.1.0-rc.9 → 3.1.1-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 (73) hide show
  1. package/CHANGELOG.md +382 -0
  2. package/README.ko.md +1 -1
  3. package/README.md +1 -1
  4. package/akanConfig/akanConfig.test.ts +8 -4
  5. package/akanConfig/akanConfig.ts +3 -2
  6. package/applicationBuildRunner.test.ts +77 -2
  7. package/applicationBuildRunner.ts +28 -5
  8. package/artifact/implicitRootLayout.test.ts +11 -1
  9. package/artifact/implicitRootLayout.ts +33 -8
  10. package/biome.base.json +8 -3
  11. package/biomeBase.test.ts +78 -0
  12. package/biomeBase.ts +1 -1
  13. package/codeAgent/agent/AkanCodeCompaction.ts +49 -0
  14. package/codeAgent/agent/AkanCodeServices.ts +2 -3
  15. package/codeAgent/agent/CodeAgent.ts +46 -2
  16. package/codeAgent/agent/CodeAgentRpcHost.ts +3 -0
  17. package/codeAgent/agent/akanCodeCompaction.test.ts +36 -0
  18. package/codeAgent/agent/codeAgent.test.ts +61 -3
  19. package/codeAgent/agent/codeAgentContract.test.ts +1 -0
  20. package/codeAgent/index.ts +1 -0
  21. package/codeAgent/skills/akan-code/SKILL.md +4 -0
  22. package/codeAgent/skills/akan-store/SKILL.md +4 -2
  23. package/codeAgent/tools/AkanCodePlugins.ts +3 -1
  24. package/codeAgent/tools/DeepSeekWeb.ts +127 -0
  25. package/codeAgent/tools/WebToolPack.ts +149 -25
  26. package/codeAgent/tools/akanCodePlugins.test.ts +191 -0
  27. package/codeAgent/tools/deepSeekWeb.test.ts +113 -0
  28. package/commandDecorators/argMeta.ts +1 -0
  29. package/commandDecorators/command.ts +49 -10
  30. package/commandDecorators/commandBuilder.ts +6 -3
  31. package/commandDecorators/commandDecorators.test.ts +169 -6
  32. package/commandDecorators/helpFormatter.ts +2 -1
  33. package/commandDecorators/targetMeta.ts +10 -0
  34. package/executors.test.ts +53 -1
  35. package/executors.ts +10 -6
  36. package/frontendBuild/csrArtifactBuilder.test.ts +40 -0
  37. package/frontendBuild/csrArtifactBuilder.ts +24 -1
  38. package/frontendBuild/cssCandidateCache.test.ts +16 -0
  39. package/frontendBuild/cssCandidateCache.ts +8 -6
  40. package/frontendBuild/dictionaryAssetBuilder.test.ts +77 -0
  41. package/frontendBuild/dictionaryAssetBuilder.ts +49 -0
  42. package/frontendBuild/fontPruner.ts +1 -0
  43. package/frontendBuild/mcpAppViewBuilder.test.ts +170 -0
  44. package/frontendBuild/mcpAppViewBuilder.ts +245 -0
  45. package/frontendBuild/precompressArtifacts.test.ts +42 -0
  46. package/frontendBuild/precompressArtifacts.ts +21 -10
  47. package/frontendBuild/routeClientBuilder.ts +1 -0
  48. package/frontendBuild/ssrBaseArtifactBuilder.ts +3 -0
  49. package/frontendBuild/vendorSpecifiers.ts +1 -0
  50. package/incrementalBuilder/buildBatch.proc.ts +3 -3
  51. package/incrementalBuilder/buildBatchProtocol.ts +2 -2
  52. package/lint/__fixtures__/no-conditional-st-hook/bad.tsx +45 -0
  53. package/lint/__fixtures__/no-conditional-st-hook/good.tsx +33 -0
  54. package/lint/no-conditional-st-hook.grit +146 -0
  55. package/mobile/desktopServerStage.ts +2 -1
  56. package/mobile/nativeApi.ts +1 -1
  57. package/mobile/nativeApp.ts +1 -1
  58. package/mobile/nativeConfig.ts +1 -1
  59. package/mobile/updatePackFolder.ts +1 -1
  60. package/mobile/updateUploader.ts +1 -1
  61. package/package.json +15 -15
  62. package/routeSourceValidator.test.ts +19 -4
  63. package/routeSourceValidator.ts +62 -14
  64. package/scanInfo.ts +36 -3
  65. package/subspace.test.ts +3 -2
  66. package/subspaceClone.ts +1 -12
  67. package/transforms/barrelImportsPlugin.ts +4 -2
  68. package/transforms/mcpAppPageTrimmer.test.ts +89 -0
  69. package/transforms/mcpAppPageTrimmer.ts +70 -0
  70. package/transforms/pureLazyMarker.test.ts +58 -0
  71. package/transforms/pureLazyMarker.ts +42 -0
  72. package/transforms/sideEffectImportOrder.test.ts +183 -0
  73. package/transforms/sideEffectImportOrder.ts +109 -0
@@ -1,7 +1,7 @@
1
1
  import { cp, mkdir, readdir, rm, writeFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
- import type { ServerPolicy } from "@akanjs/native/api";
4
3
  import { toError } from "akanjs/common";
4
+ import type { ServerPolicy } from "akanjs/native/api";
5
5
  import type { AkanAppConfig, NativeEnv } from "../akanConfig";
6
6
  import type { App } from "../commandDecorators";
7
7
  import type { PackageJson } from "../types";
@@ -33,6 +33,7 @@ export class DesktopServerStage {
33
33
  "console.js",
34
34
  "console-runtime.js",
35
35
  "csr",
36
+ "mcp-apps",
36
37
  "public",
37
38
  ".akan",
38
39
  ]);
@@ -1,5 +1,5 @@
1
1
  import path from "node:path";
2
- import type * as NativeBuildApi from "@akanjs/native/api";
2
+ import type * as NativeBuildApi from "akanjs/native/api";
3
3
 
4
4
  export type NativeBuildApiModule = typeof NativeBuildApi;
5
5
 
@@ -1,7 +1,7 @@
1
1
  import path from "node:path";
2
- import type { AkanNativeConfig } from "@akanjs/native/config";
3
2
  import type { NativeEnv } from "akanjs";
4
3
  import { toError } from "akanjs/common";
4
+ import type { AkanNativeConfig } from "akanjs/native/config";
5
5
  import type { App } from "../commandDecorators";
6
6
  import { Executor } from "../executors";
7
7
  import { type DesktopArch, DesktopBin } from "./desktopBin";
@@ -1,6 +1,5 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import path from "node:path";
3
- import type { AkanNativeConfig } from "@akanjs/native/config";
4
3
  import type {
5
4
  AkanNativeDmgConfig,
6
5
  AkanNativeTarget,
@@ -10,6 +9,7 @@ import type {
10
9
  NativePermission,
11
10
  } from "akanjs";
12
11
  import { trimSlashes } from "akanjs/common";
12
+ import type { AkanNativeConfig } from "akanjs/native/config";
13
13
  import type { DesktopServerBundle } from "./desktopServerStage";
14
14
  import { type NativePlatform, resolveAppId } from "./mobileTarget";
15
15
  import type { NativePluginFolder } from "./nativePluginFolders";
@@ -1,8 +1,8 @@
1
1
  import { mkdtemp, rm } from "node:fs/promises";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
- import type { BundleInfo, UpdateManifest } from "@akanjs/native/api";
5
4
  import type { NativeEnv } from "akanjs";
5
+ import type { BundleInfo, UpdateManifest } from "akanjs/native/api";
6
6
  import { ExitCodeError } from "../commandDecorators/exitCodeError";
7
7
  import type { MobilePlatform } from "./mobileTarget";
8
8
 
@@ -1,7 +1,7 @@
1
1
  import { readdir } from "node:fs/promises";
2
2
  import path from "node:path";
3
- import type { UpdateEnvelope, UpdateManifest } from "@akanjs/native/api";
4
3
  import { isRecord, trimTrailingSlashes } from "akanjs/common";
4
+ import type { UpdateEnvelope, UpdateManifest } from "akanjs/native/api";
5
5
  import { UpdatePackFolder } from "./updatePackFolder";
6
6
 
7
7
  /** What the uploader needs of an S3 client; Bun.S3Client is one. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akanjs/devkit",
3
- "version": "3.1.0-rc.9",
3
+ "version": "3.1.1-rc.0",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -14,7 +14,7 @@
14
14
  "directory": "pkgs/@akanjs/devkit"
15
15
  },
16
16
  "engines": {
17
- "bun": ">=1.4.0"
17
+ "bun": ">=1.4.4"
18
18
  },
19
19
  "exports": {
20
20
  ".": {
@@ -46,25 +46,25 @@
46
46
  "@earendil-works/pi-coding-agent": "1.3.0",
47
47
  "@earendil-works/pi-mcp": "1.3.0",
48
48
  "@earendil-works/pi-tui": "1.3.0",
49
- "@inquirer/prompts": "^8.4.3",
50
- "@tailwindcss/node": "^4.3.0",
51
- "akanjs": "3.1.0-rc.9",
52
- "chalk": "^5.6.2",
53
- "commander": "^14.0.3",
54
- "dayjs": "^1.11.20",
55
- "fontaine": "^0.8.0",
49
+ "@inquirer/prompts": "^8.7.3",
50
+ "@tailwindcss/node": "^4.3.3",
51
+ "akanjs": "3.1.1-rc.0",
52
+ "chalk": "^6.0.1",
53
+ "commander": "^15.0.0",
54
+ "dayjs": "^1.11.23",
55
+ "fontaine": "^1.0.1",
56
56
  "fonteditor-core": "^2.6.3",
57
- "ignore": "^7.0.5",
58
- "ora": "^9.4.0",
57
+ "ignore": "^7.0.12",
58
+ "ora": "^9.4.1",
59
59
  "ssh2": "^1.17.0",
60
- "subset-font": "^2.5.0",
60
+ "subset-font": "^2.9.0",
61
61
  "tailwind-scrollbar": "^4.0.2",
62
- "tailwindcss": "^4.3.0",
63
- "typebox": "1.1.38",
62
+ "tailwindcss": "^4.3.3",
63
+ "typebox": "1.3.37",
64
64
  "typescript": "^6.0.3"
65
65
  },
66
66
  "peerDependencies": {
67
- "react": "19.3.0"
67
+ "react": "19.3.1"
68
68
  },
69
69
  "peerDependenciesMeta": {
70
70
  "react": {
@@ -43,7 +43,7 @@ describe("RouteSourceValidator route chains", () => {
43
43
  const chain = (source: string, kind: "page" | "layout", pattern: string) =>
44
44
  RouteSourceValidator.validateRouteSourceExports(source, `page/${kind}.tsx`, kind, { pattern });
45
45
 
46
- test("reads params, prompt and devOnly off a page() chain without evaluating it", () => {
46
+ test("reads params, prompt, apps and devOnly off a page() chain without evaluating it", () => {
47
47
  const source = [
48
48
  'import { page } from "akanjs/client";',
49
49
  "export default page()",
@@ -51,11 +51,13 @@ describe("RouteSourceValidator route chains", () => {
51
51
  ' .search("status", String)',
52
52
  " .config({ transition: 'stack', devOnly: true })",
53
53
  ' .prompt("briefProject", "Brief one project.")',
54
- " .render(async ({ projectId }) => null);",
54
+ ' .app("showProject", "Show one project.", ({ projectId }) => null)',
55
+ ' .app("showBoard", "Show its board.", ({ projectId }) => null)',
56
+ " .render(async ({ projectId, showProject }) => showProject);",
55
57
  ].join("\n");
56
58
  expect(chain(source, "page", "/:lang/project/:projectId")).toEqual({
57
59
  devOnly: true,
58
- chain: { kind: "page", params: ["projectId"], prompt: "briefProject" },
60
+ chain: { kind: "page", params: ["projectId"], prompt: "briefProject", apps: ["showProject", "showBoard"] },
59
61
  });
60
62
  });
61
63
 
@@ -88,16 +90,29 @@ describe("RouteSourceValidator route chains", () => {
88
90
  expect(() => chain("export default page().param(name, ID).render(() => null);", "page", "/:lang/:x")).toThrow(
89
91
  ".param() takes a string literal first",
90
92
  );
93
+ expect(() => chain('export default page().app(name, "x", () => null).render(() => null);', "page", "/")).toThrow(
94
+ ".app() takes a string literal first",
95
+ );
91
96
  expect(() => chain("export default page().config({ devOnly: flag }).render(() => null);", "page", "/")).toThrow(
92
97
  "devOnly must be a literal true or false",
93
98
  );
99
+ const indirect = [
100
+ 'const memo = page().app("showMemo", "x", () => null).render(() => null);',
101
+ "export default memo;",
102
+ ];
103
+ expect(() => chain(indirect.join("\n"), "page", "/")).toThrow('exports its chain through "memo"');
104
+ const renamed = [indirect[0], "export { memo as default };"];
105
+ expect(() => chain(renamed.join("\n"), "page", "/")).toThrow('exports its chain through "memo"');
94
106
  });
95
107
 
96
- test("still reads the legacy shape as before", () => {
108
+ test("still reads the legacy shape as before, which never takes a guard", () => {
97
109
  const source = [
98
110
  "export default function Page() { return null; }",
99
111
  "export const pageConfig = { devOnly: false };",
100
112
  ].join("\n");
101
113
  expect(chain(source, "page", "/")).toEqual({ devOnly: false });
114
+ expect(() => chain(`${source}\nexport const guard = async () => undefined;`, "page", "/")).toThrow(
115
+ "exports guard beside a legacy default",
116
+ );
102
117
  });
103
118
  });
@@ -15,6 +15,8 @@ export interface RouteSourceChain {
15
15
  params: string[];
16
16
  /** The `.prompt()` name, when the page publishes itself as an MCP prompt. */
17
17
  prompt?: string;
18
+ /** The `.app()` names, each one MCP App the build gives a view of its own. */
19
+ apps: string[];
18
20
  }
19
21
 
20
22
  interface ChainStage {
@@ -80,6 +82,10 @@ export class RouteSourceValidator {
80
82
  throw new Error(`[route-convention] head and generateHead cannot both be exported in ${filePath}`);
81
83
  }
82
84
  const chain = RouteSourceValidator.#readChain(sourceFile, filePath);
85
+ if (!chain && exported.has("guard"))
86
+ throw new Error(
87
+ `[route-convention] ${filePath} exports guard beside a legacy default; declare it with .guard() on its chain`,
88
+ );
83
89
  if (!chain) return { devOnly: RouteSourceValidator.#readDevOnly(sourceFile, filePath) };
84
90
  const named = [...exported].filter((name) => name !== "default");
85
91
  if (named.length)
@@ -93,34 +99,75 @@ export class RouteSourceValidator {
93
99
  if (options.pattern) RouteSourceValidator.#assertChainParams(chain, options.pattern, filePath);
94
100
  return {
95
101
  devOnly: chain.devOnly,
96
- chain: { kind: chain.kind, params: chain.params, ...(chain.prompt ? { prompt: chain.prompt } : {}) },
102
+ chain: {
103
+ kind: chain.kind,
104
+ params: chain.params,
105
+ ...(chain.prompt ? { prompt: chain.prompt } : {}),
106
+ apps: chain.apps,
107
+ },
97
108
  };
98
109
  }
99
110
 
100
111
  // Null for the legacy shape. Read off the source because the build enumerates routes without importing them.
101
112
  static #readChain(sourceFile: ts.SourceFile, filePath: string) {
102
113
  for (const statement of sourceFile.statements) {
114
+ const local = RouteSourceValidator.#defaultLocalOf(statement);
115
+ if (local && RouteSourceValidator.#bindsChain(sourceFile, local))
116
+ throw new Error(
117
+ `[route-convention] ${filePath} exports its chain through "${local}"; write \`export default page()…\` — the build reads the chain without evaluating the module`,
118
+ );
103
119
  if (!ts.isExportAssignment(statement) || statement.isExportEquals) continue;
104
- let node = RouteSourceValidator.#unwrapExpression(statement.expression);
105
- const stages: ChainStage[] = [];
106
- while (node && ts.isCallExpression(node)) {
107
- const callee = node.expression;
108
- if (ts.isPropertyAccessExpression(callee)) {
109
- stages.unshift({ name: callee.name.text, args: node.arguments });
110
- node = callee.expression;
111
- continue;
112
- }
113
- if (ts.isIdentifier(callee) && chainRoots.has(callee.text))
114
- return RouteSourceValidator.#chainOf(callee.text as RouteSourceChain["kind"], stages, filePath);
115
- return null;
120
+ const walked = RouteSourceValidator.#walkChain(RouteSourceValidator.#unwrapExpression(statement.expression));
121
+ return walked ? RouteSourceValidator.#chainOf(walked.kind, walked.stages, filePath) : null;
122
+ }
123
+ return null;
124
+ }
125
+
126
+ static #defaultLocalOf(statement: ts.Statement): string | undefined {
127
+ if (ts.isExportAssignment(statement) && !statement.isExportEquals) {
128
+ const node = RouteSourceValidator.#unwrapExpression(statement.expression);
129
+ return node && ts.isIdentifier(node) ? node.text : undefined;
130
+ }
131
+ if (!ts.isExportDeclaration(statement) || statement.moduleSpecifier || statement.isTypeOnly) return undefined;
132
+ const clause = statement.exportClause;
133
+ if (!clause || !ts.isNamedExports(clause)) return undefined;
134
+ const element = clause.elements.find((element) => !element.isTypeOnly && element.name.text === "default");
135
+ return element?.propertyName && ts.isIdentifier(element.propertyName) ? element.propertyName.text : undefined;
136
+ }
137
+
138
+ static #walkChain(start: ts.Expression | undefined) {
139
+ let node = start;
140
+ const stages: ChainStage[] = [];
141
+ while (node && ts.isCallExpression(node)) {
142
+ const callee = node.expression;
143
+ if (ts.isPropertyAccessExpression(callee)) {
144
+ stages.unshift({ name: callee.name.text, args: node.arguments });
145
+ node = callee.expression;
146
+ continue;
116
147
  }
148
+ if (ts.isIdentifier(callee) && chainRoots.has(callee.text))
149
+ return { kind: callee.text as RouteSourceChain["kind"], stages };
117
150
  return null;
118
151
  }
119
152
  return null;
120
153
  }
121
154
 
155
+ static #bindsChain(sourceFile: ts.SourceFile, name: string) {
156
+ return sourceFile.statements.some(
157
+ (statement) =>
158
+ ts.isVariableStatement(statement) &&
159
+ statement.declarationList.declarations.some(
160
+ (declaration) =>
161
+ ts.isIdentifier(declaration.name) &&
162
+ declaration.name.text === name &&
163
+ !!RouteSourceValidator.#walkChain(RouteSourceValidator.#unwrapExpression(declaration.initializer)),
164
+ ),
165
+ );
166
+ }
167
+
122
168
  static #chainOf(kind: RouteSourceChain["kind"], stages: ChainStage[], filePath: string) {
123
169
  const params: string[] = [];
170
+ const apps: string[] = [];
124
171
  let prompt: string | undefined;
125
172
  let devOnly = false;
126
173
  const literal = (stage: ChainStage) => {
@@ -134,10 +181,11 @@ export class RouteSourceValidator {
134
181
  for (const stage of stages) {
135
182
  if (stage.name === "param") params.push(literal(stage));
136
183
  else if (stage.name === "prompt") prompt = literal(stage);
184
+ else if (stage.name === "app") apps.push(literal(stage));
137
185
  else if (stage.name === "config")
138
186
  devOnly = RouteSourceValidator.#devOnlyOf(RouteSourceValidator.#unwrapExpression(stage.args[0]), filePath);
139
187
  }
140
- return { kind, params, prompt, devOnly };
188
+ return { kind, params, prompt, apps, devOnly };
141
189
  }
142
190
 
143
191
  /** The same rule the loader applies: a page names every `[x]` it sits under, a layout may name a subset. */
package/scanInfo.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { createHash } from "node:crypto";
1
2
  import path from "node:path";
2
3
  import type {
3
4
  AppConfigResult,
@@ -295,11 +296,19 @@ class ScanInfo {
295
296
  }
296
297
 
297
298
  export const isAkanFrameworkDependency = (dep: string) => dep === "akanjs" || dep.startsWith("akanjs/");
299
+ export interface MigrationScanEntry {
300
+ id: string;
301
+ checksum: string;
302
+ }
303
+
298
304
  export class AppInfo extends ScanInfo {
305
+ static readonly migrationDir = "script/migration";
306
+ static readonly #migrationIdRe = /^\d{12}_[A-Za-z][A-Za-z0-9]*$/;
299
307
  readonly type = "app";
300
308
  readonly exec: AppExecutor;
301
309
  readonly akanConfig: AppConfigResult;
302
310
  readonly libDeps: string[];
311
+ readonly migrations: MigrationScanEntry[];
303
312
 
304
313
  static appInfos = new Map<string, AppInfo>();
305
314
  static async fromExecutor(exec: AppExecutor, options: { refresh?: boolean } = {}) {
@@ -314,17 +323,21 @@ export class AppInfo extends ScanInfo {
314
323
  LibInfo.libInfos.set(libName, await LibInfo.fromExecutor(libExecutor));
315
324
  }),
316
325
  );
317
- const libDeps = await AppInfo.#getAllLibDeps(exec, scanResult.libDeps);
318
- const appInfo = new AppInfo(exec, scanResult as AppScanResult, libDeps);
326
+ const [libDeps, migrations] = await Promise.all([
327
+ AppInfo.#getAllLibDeps(exec, scanResult.libDeps),
328
+ AppInfo.scanMigrations(exec),
329
+ ]);
330
+ const appInfo = new AppInfo(exec, scanResult as AppScanResult, libDeps, migrations);
319
331
  AppInfo.appInfos.set(exec.name, appInfo);
320
332
  return appInfo;
321
333
  }
322
334
 
323
- constructor(exec: AppExecutor, scanResult: AppScanResult, libDeps: string[]) {
335
+ constructor(exec: AppExecutor, scanResult: AppScanResult, libDeps: string[], migrations: MigrationScanEntry[] = []) {
324
336
  super(scanResult);
325
337
  this.exec = exec;
326
338
  this.akanConfig = scanResult.akanConfig;
327
339
  this.libDeps = libDeps;
340
+ this.migrations = migrations;
328
341
  }
329
342
  override getScanResult(): AppScanResult {
330
343
  return this.scanResult as AppScanResult;
@@ -334,6 +347,26 @@ export class AppInfo extends ScanInfo {
334
347
  (this.scanResult as AppScanResult).routes = routes;
335
348
  }
336
349
 
350
+ //* Line endings and a BOM dropped: an editor or autocrlf would otherwise record another checksum for the same script.
351
+ static async scanMigrations(exec: AppExecutor) {
352
+ const files = (await exec.readdir(AppInfo.migrationDir)).filter(
353
+ (file) => file.endsWith(".ts") && !/\.(test|spec|d)\.ts$/.test(file),
354
+ );
355
+ return await Promise.all(
356
+ files.map(async (file) => {
357
+ const id = file.slice(0, -".ts".length);
358
+ if (!AppInfo.#migrationIdRe.test(id))
359
+ throw new Error(
360
+ `${AppInfo.migrationDir}/${file}: a migration is named <yyyyMMddHHmm>_<camelName>.ts; create one with \`akan create-migration\``,
361
+ );
362
+ const source = (await exec.readFile(`${AppInfo.migrationDir}/${file}`))
363
+ .replace(/^\uFEFF/, "")
364
+ .replaceAll("\r\n", "\n");
365
+ return { id, checksum: createHash("sha256").update(source).digest("hex") };
366
+ }),
367
+ );
368
+ }
369
+
337
370
  static async #getAllLibDeps(exec: AppExecutor, libDeps: string[], libSet = new Set<string>()) {
338
371
  await Promise.all(
339
372
  libDeps.map(async (libName) => {
package/subspace.test.ts CHANGED
@@ -245,7 +245,7 @@ describe("Subspace push", () => {
245
245
  expect(second.sha).not.toBe(first.sha);
246
246
  });
247
247
 
248
- test("keeps the subspace's own root config and drops a Jenkinsfile an earlier push left there", async () => {
248
+ test("keeps the subspace's own root config and a Jenkinsfile an earlier push left there, never the workspace's", async () => {
249
249
  const { workRoot, bareRoot, subspace } = await makeMirror("own-served", "own-private", "own-kit");
250
250
  const customer = await cloneSubspace(workRoot, bareRoot, "own-customer");
251
251
  await write(path.join(customer, "akan.config.ts"), "export default { pipeline: {} };\n");
@@ -261,7 +261,8 @@ describe("Subspace push", () => {
261
261
  expect(await FileSys.readText(path.join(subspaceRoot, "akan.config.ts"))).toContain(
262
262
  "export default { pipeline: {} };",
263
263
  );
264
- expect(await FileSys.fileExists(path.join(subspaceRoot, "Jenkinsfile"))).toBe(false);
264
+ expect(await FileSys.fileExists(path.join(subspaceRoot, "Jenkinsfile"))).toBe(true);
265
+ expect(await FileSys.readText(path.join(subspaceRoot, "Jenkinsfile"))).toContain("pipeline { agent none }");
265
266
  });
266
267
 
267
268
  test("refuses a lock that resolves from a registry only this machine reaches, before it clones", async () => {
package/subspaceClone.ts CHANGED
@@ -47,10 +47,6 @@ export class SubspaceClone {
47
47
  this.#clone = new Executor(`subspace-${declaration.name}`, this.#clonePath);
48
48
  }
49
49
 
50
- async #cloneGit(args: string[]) {
51
- return await this.#workspace.spawn("git", ["-C", this.#clonePath, ...args]);
52
- }
53
-
54
50
  async #ensure(branch: string) {
55
51
  if (!(await FileSys.dirExists(path.join(this.#clonePath, ".git")))) {
56
52
  await rm(this.#clonePath, { recursive: true, force: true });
@@ -160,15 +156,13 @@ export class SubspaceClone {
160
156
  await rm(path.join(this.#clonePath, SubspaceClone.holdDir), { recursive: true, force: true });
161
157
  }
162
158
 
163
- //* A root entry last written by a sync commit is a copy an earlier push left there, the parent's Jenkinsfile among them:
164
- //* it goes. Any other is the subspace's own and is held through the extraction.
159
+ //* Held even when an earlier push left it, the workspace's Jenkinsfile among them: it is the subspace's only copy.
165
160
  async #holdRootEntries() {
166
161
  const holdRoot = path.join(this.#clonePath, SubspaceClone.holdDir, "root");
167
162
  const held: string[] = [];
168
163
  for (const entry of SubspaceClone.subspaceOwnedRootEntries) {
169
164
  const from = path.join(this.#clonePath, entry);
170
165
  if (!(await FileSys.entryExists(from))) continue;
171
- if (await this.#isPushedCopy(entry)) continue;
172
166
  await mkdir(holdRoot, { recursive: true });
173
167
  await rename(from, path.join(holdRoot, entry));
174
168
  held.push(entry);
@@ -185,11 +179,6 @@ export class SubspaceClone {
185
179
  }
186
180
  }
187
181
 
188
- async #isPushedCopy(entry: string) {
189
- const subject = await this.#cloneGit(["log", "-1", "--format=%s", "--", entry]).catch(() => "");
190
- return subject.trim().startsWith(SubspaceClone.syncSubjectPrefix);
191
- }
192
-
193
182
  static async #copyMissing(from: string, to: string) {
194
183
  await mkdir(to, { recursive: true });
195
184
  for (const entry of await readdir(from, { withFileTypes: true })) {
@@ -10,14 +10,16 @@ export interface BarrelImportsPluginOptions {
10
10
  skipPath?: (absPath: string) => boolean;
11
11
  /** Runs after the rewrite in the same onLoad (Bun's onLoad cannot fall through); null means no change. */
12
12
  pipeAfter?: (source: string, args: { path: string }) => string | Promise<string | null> | null;
13
+ /** Barrels the bundle leaves external: a rewritten import would inline the modules the import map already serves. */
14
+ external?: readonly string[];
13
15
  }
14
16
 
15
17
  export const createBarrelImportsPlugin = async (
16
18
  app: App,
17
- { skipPath = defaultSkipPath, pipeAfter }: BarrelImportsPluginOptions = {},
19
+ { skipPath = defaultSkipPath, pipeAfter, external = [] }: BarrelImportsPluginOptions = {},
18
20
  ): Promise<BunPlugin> => {
19
21
  const akanConfig = await app.getConfig();
20
- const barrels = [...new Set(akanConfig.barrelImports)].filter(Boolean);
22
+ const barrels = [...new Set(akanConfig.barrelImports)].filter((barrel) => barrel && !external.includes(barrel));
21
23
  const analyzer = new BarrelAnalyzer({
22
24
  resolvePackage: await createTsconfigPackageResolver(app),
23
25
  });
@@ -0,0 +1,89 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { symlink } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { tempDirs, writeText } from "../testHelpers";
5
+ import { McpAppPageTrimmer } from "./mcpAppPageTrimmer";
6
+
7
+ const makeTempRoot = tempDirs("akan-mcp-app-trim-");
8
+
9
+ describe("McpAppPageTrimmer", () => {
10
+ test("hands every stage but the apps a function that draws nothing, and keeps the rest of the chain as written", () => {
11
+ const source = [
12
+ 'import { fetch, Memo } from "@apps/minimal/client";',
13
+ 'import { Screen } from "@apps/minimal/ui";',
14
+ "export default page()",
15
+ ' .param("memoId", ID)',
16
+ " .config({ topInset: 48 })",
17
+ " .guard(async ({ memoId }) => { await fetch.memo(memoId); })",
18
+ " .head(<title>Memo</title>)",
19
+ " .loading(() => <Screen>loading</Screen>)",
20
+ ' .app("showMemo", "Show one memo.", ({ memoId }) => <Memo.Zone.View view={fetch.viewMemo(memoId).memoView} />)',
21
+ " .render(({ memoId, showMemo }) => (",
22
+ ' <Screen className="pb-10">{showMemo}</Screen>',
23
+ " ));",
24
+ ].join("\n");
25
+ expect(McpAppPageTrimmer.trim(source, "memo.tsx")).toBe(
26
+ [
27
+ 'import { fetch, Memo } from "@apps/minimal/client";',
28
+ 'import { Screen } from "@apps/minimal/ui";',
29
+ "export default page()",
30
+ ' .param("memoId", ID)',
31
+ " .config({ topInset: 48 })",
32
+ " .guard(() => null)",
33
+ " .head(() => null)",
34
+ " .loading(() => null)",
35
+ ' .app("showMemo", "Show one memo.", ({ memoId }) => <Memo.Zone.View view={fetch.viewMemo(memoId).memoView} />)',
36
+ " .render(() => null);",
37
+ ].join("\n"),
38
+ );
39
+ });
40
+
41
+ test("trims a page reached through a link, as a synced lib page is", async () => {
42
+ const root = await makeTempRoot();
43
+ await writeText(
44
+ path.join(root, "libs/shared/page/login.tsx"),
45
+ [
46
+ "const page = () => ({ render: (draw: () => string) => draw });",
47
+ 'export default page().render(() => "drawn by the render");',
48
+ ].join("\n"),
49
+ );
50
+ await symlink(path.join(root, "libs/shared/page"), path.join(root, "linked"));
51
+ await writeText(path.join(root, "entry.ts"), 'import login from "./linked/login.tsx";\nexport { login };\n');
52
+ const result = await Bun.build({
53
+ entrypoints: [path.join(root, "entry.ts")],
54
+ plugins: [McpAppPageTrimmer.plugin([path.join(root, "linked/login.tsx")])],
55
+ });
56
+ const [output] = result.outputs;
57
+ expect(result.success).toBe(true);
58
+ expect(await output?.text()).not.toContain("drawn by the render");
59
+ });
60
+
61
+ test("drops a lazy component only the page's render drew, with what it imports", async () => {
62
+ const root = await makeTempRoot();
63
+ await writeText(path.join(root, "Globe.ts"), 'export default "a heavy globe";\n');
64
+ await writeText(
65
+ path.join(root, "trip.tsx"),
66
+ [
67
+ "const lazy = (load: () => Promise<unknown>) => ({ load });",
68
+ 'const Globe = lazy(() => import("./Globe"));',
69
+ "const page = () => ({ app: (name: string, draw: () => unknown) => ({ render: (render: () => unknown) => [name, draw, render] }) });",
70
+ 'export default page().app("showTrip", () => "a trip card").render(() => Globe);',
71
+ ].join("\n"),
72
+ );
73
+ await writeText(path.join(root, "entry.ts"), 'import trip from "./trip.tsx";\nconsole.info(trip);\n');
74
+ const result = await Bun.build({
75
+ entrypoints: [path.join(root, "entry.ts")],
76
+ plugins: [McpAppPageTrimmer.plugin([path.join(root, "trip.tsx")])],
77
+ splitting: false,
78
+ });
79
+ const text = (await result.outputs[0]?.text()) ?? "";
80
+ expect(result.success).toBe(true);
81
+ expect(text).toContain("a trip card");
82
+ expect(text).not.toContain("a heavy globe");
83
+ });
84
+
85
+ test("leaves a module with no chain as it is", () => {
86
+ const legacy = "export default function Page() { return <main />; }\nexport const pageConfig = {};";
87
+ expect(McpAppPageTrimmer.trim(legacy, "legacy.tsx")).toBe(legacy);
88
+ });
89
+ });
@@ -0,0 +1,70 @@
1
+ import { realpathSync } from "node:fs";
2
+ import path from "node:path";
3
+ import type { BunPlugin } from "bun";
4
+ import ts from "typescript";
5
+ import { PureLazyMarker } from "./pureLazyMarker";
6
+
7
+ type ChainStage = ts.CallExpression & { expression: ts.PropertyAccessExpression };
8
+
9
+ //* An MCP App's view runs its page's `.app()` loads and no other stage, so the view build hands each such stage a
10
+ //* function that draws nothing, and the bundler drops what only the page's own markup reached.
11
+ export class McpAppPageTrimmer {
12
+ static readonly #unused = new Set(["render", "head", "loading", "guard"]);
13
+
14
+ /** Trims exactly `pagePaths`: the rest of the graph loads as it does for the CSR shell. */
15
+ static plugin(pagePaths: string[]): BunPlugin {
16
+ return {
17
+ name: "akan-mcp-app-page",
18
+ setup(build) {
19
+ if (!pagePaths.length) return;
20
+ //? The bundler loads a module by the path a link points at: a synced lib page is one under `page/(libs)`.
21
+ const loadedPaths = new Set(
22
+ pagePaths.flatMap((pagePath) => [pagePath, McpAppPageTrimmer.#realPathOf(pagePath)]),
23
+ );
24
+ const filter = new RegExp(`^(?:${[...loadedPaths].map((pagePath) => RegExp.escape(pagePath)).join("|")})$`);
25
+ //? The bundler runs one onLoad per module, so this one also marks the page's own `lazy()` components.
26
+ build.onLoad({ filter }, async (args) => ({
27
+ contents: PureLazyMarker.mark(McpAppPageTrimmer.trim(await Bun.file(args.path).text(), args.path), args.path),
28
+ loader: path.extname(args.path).slice(1) as "tsx" | "ts" | "jsx" | "js",
29
+ }));
30
+ },
31
+ };
32
+ }
33
+
34
+ static trim(source: string, filePath: string): string {
35
+ const sourceFile = ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
36
+ const spans = McpAppPageTrimmer.#stagesOf(sourceFile)
37
+ .flatMap((stage) => {
38
+ const [first] = stage.arguments;
39
+ if (!first || !McpAppPageTrimmer.#unused.has(stage.expression.name.text)) return [];
40
+ return [{ start: first.getStart(sourceFile), end: first.getEnd() }];
41
+ })
42
+ .sort((a, b) => b.start - a.start);
43
+ let trimmed = source;
44
+ for (const { start, end } of spans) trimmed = `${trimmed.slice(0, start)}() => null${trimmed.slice(end)}`;
45
+ return trimmed;
46
+ }
47
+
48
+ static #realPathOf(filePath: string) {
49
+ try {
50
+ return realpathSync(filePath);
51
+ } catch {
52
+ return filePath;
53
+ }
54
+ }
55
+
56
+ static #stagesOf(sourceFile: ts.SourceFile): ChainStage[] {
57
+ const stages: ChainStage[] = [];
58
+ for (const statement of sourceFile.statements) {
59
+ if (!ts.isExportAssignment(statement) || statement.isExportEquals) continue;
60
+ let node: ts.Expression = statement.expression;
61
+ while (ts.isParenthesizedExpression(node) || ts.isAsExpression(node) || ts.isSatisfiesExpression(node))
62
+ node = node.expression;
63
+ while (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression)) {
64
+ stages.push(node as ChainStage);
65
+ node = node.expression.expression;
66
+ }
67
+ }
68
+ return stages;
69
+ }
70
+ }