@akanjs/devkit 3.1.0-rc.2 → 3.1.0-rc.20

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 (101) hide show
  1. package/abstractDoc.ts +1 -1
  2. package/agentsIndex.test.ts +4 -1
  3. package/akanConfig/akanConfig.ts +2 -2
  4. package/akanContext.ts +4 -4
  5. package/akanMcpContract.ts +1 -1
  6. package/applicationBuildRunner.test.ts +24 -1
  7. package/applicationBuildRunner.ts +11 -1
  8. package/artifact/implicitRootLayout.test.ts +2 -0
  9. package/artifact/implicitRootLayout.ts +1 -0
  10. package/biome.base.json +1 -11
  11. package/biomeBase.ts +1 -1
  12. package/cloud/configLock.ts +2 -1
  13. package/cloud/globalConfig.ts +3 -2
  14. package/codeAgent/agent/AkanCodeCompaction.ts +49 -0
  15. package/codeAgent/agent/AkanCodeServices.ts +2 -3
  16. package/codeAgent/agent/CodeAgent.ts +46 -2
  17. package/codeAgent/agent/CodeAgentProxy.ts +2 -2
  18. package/codeAgent/agent/CodeAgentRpcHost.ts +3 -0
  19. package/codeAgent/agent/akanCodeCompaction.test.ts +36 -0
  20. package/codeAgent/agent/akanCodeModel.ts +1 -1
  21. package/codeAgent/agent/akanContextFiles.test.ts +3 -1
  22. package/codeAgent/agent/codeAgent.test.ts +61 -3
  23. package/codeAgent/agent/codeAgentContract.test.ts +1 -0
  24. package/codeAgent/index.ts +1 -0
  25. package/codeAgent/skills/akan-code/SKILL.md +4 -0
  26. package/codeAgent/skills/akan-store/SKILL.md +2 -1
  27. package/codeAgent/tools/AkanCodePlugins.ts +3 -1
  28. package/codeAgent/tools/DeepSeekWeb.ts +127 -0
  29. package/codeAgent/tools/WebToolPack.ts +128 -29
  30. package/codeAgent/tools/akanCodePlugins.test.ts +143 -16
  31. package/codeAgent/tools/deepSeekWeb.test.ts +113 -0
  32. package/commandDecorators/command.ts +5 -4
  33. package/createTunnel.test.ts +14 -0
  34. package/createTunnel.ts +3 -1
  35. package/dependencyScanner.ts +1 -1
  36. package/devkitUtils.test.ts +1 -1
  37. package/executors.test.ts +85 -3
  38. package/executors.ts +30 -13
  39. package/frontendBuild/autoImportSync.test.ts +3 -1
  40. package/frontendBuild/clientEntryDiscovery.ts +1 -1
  41. package/frontendBuild/csrArtifactBuilder.test.ts +30 -0
  42. package/frontendBuild/csrArtifactBuilder.ts +5 -3
  43. package/frontendBuild/csrDevPatcher.test.ts +4 -1
  44. package/frontendBuild/cssBlockParser.test.ts +123 -0
  45. package/frontendBuild/cssBlockParser.ts +132 -0
  46. package/frontendBuild/cssCandidateCache.test.ts +16 -0
  47. package/frontendBuild/cssCandidateCache.ts +8 -6
  48. package/frontendBuild/cssCompiler.test.ts +53 -0
  49. package/frontendBuild/cssCompiler.ts +8 -4
  50. package/frontendBuild/fontOptimizer.ts +2 -1
  51. package/frontendBuild/frontendBuild.test.ts +14 -0
  52. package/frontendBuild/hmrWatcher.ts +1 -1
  53. package/frontendBuild/pagesBundleBuilder.test.ts +98 -0
  54. package/frontendBuild/pagesBundleBuilder.ts +64 -9
  55. package/frontendBuild/routeClientBuilder.ts +1 -1
  56. package/frontendBuild/sourceMtimeIndex.test.ts +1 -1
  57. package/frontendBuild/themeValidator.test.ts +83 -2
  58. package/frontendBuild/themeValidator.ts +37 -15
  59. package/getRelatedCnsts.ts +2 -1
  60. package/integration/devStabilityHarness.ts +5 -3
  61. package/lint/__fixtures__/no-deep-internal-import/bad.tsx +1 -0
  62. package/lint/__fixtures__/no-deep-internal-import/good.tsx +1 -0
  63. package/lint/__fixtures__/no-import-client-functions/bad.tsx +1 -0
  64. package/lint/__fixtures__/no-import-client-functions/good.tsx +1 -0
  65. package/lint/__fixtures__/no-import-client-in-server/bad.tsx +1 -0
  66. package/lint/__fixtures__/no-import-client-in-server/good.tsx +1 -0
  67. package/lint/__fixtures__/no-import-external-library/bad.tsx +1 -0
  68. package/lint/__fixtures__/no-import-external-library/good.tsx +1 -0
  69. package/lint/__fixtures__/no-import-external-library-in-test/bad.tsx +1 -0
  70. package/lint/__fixtures__/no-import-external-library-in-test/good.tsx +1 -0
  71. package/lint/__fixtures__/no-import-server-in-client/bad.tsx +1 -0
  72. package/lint/__fixtures__/no-import-server-in-client/good.tsx +1 -0
  73. package/lint/__fixtures__/no-init-fetch-in-client/store-file/bad.ts +1 -1
  74. package/lint/__fixtures__/no-static-in-object-light-model/bad.ts +2 -2
  75. package/mobile/mobileTarget.ts +1 -2
  76. package/mobile/nativeApp.test.ts +1 -1
  77. package/mobile/nativeConfig.test.ts +3 -0
  78. package/mobile/nativeConfig.ts +3 -2
  79. package/mobile/nativeWebDir.ts +3 -2
  80. package/mobile/updateUploader.test.ts +1 -0
  81. package/mobile/updateUploader.ts +3 -3
  82. package/package.json +20 -20
  83. package/piDependencyPins.test.ts +18 -19
  84. package/platformTest/LinuxDockerTarget.ts +2 -1
  85. package/platformTest/PlatformTestTarget.ts +7 -5
  86. package/platformTest/WindowsSshTarget.ts +2 -1
  87. package/qualityScanner.ts +1 -1
  88. package/recipeScanner.test.ts +9 -3
  89. package/scanInfo.ts +1 -1
  90. package/subspaceClone.ts +5 -4
  91. package/testHelpers.ts +2 -0
  92. package/transforms/barrelImportsPlugin.ts +1 -1
  93. package/transforms/browserDirnamePlugin.test.ts +88 -0
  94. package/transforms/browserDirnamePlugin.ts +25 -0
  95. package/transforms/rscUseClientTransform.ts +2 -2
  96. package/transforms/transforms.test.ts +18 -2
  97. package/transforms/tsconfigPackageResolver.test.ts +9 -0
  98. package/types.ts +1 -0
  99. package/workflow/artifacts.ts +1 -1
  100. package/workflow/rolloutGate.test.ts +5 -1
  101. package/workflow/source.ts +2 -2
@@ -186,20 +186,21 @@ const assignCommandContext = (context: CommandContext, argMeta: ArgMeta | Intern
186
186
  else context.values[argMeta.type.toLowerCase()] = value;
187
187
  };
188
188
 
189
+ //? Not `.env`, which a fresh clone lacks; an app or a package folder has a package.json and tsconfig.json of its own.
189
190
  const assertCurrentDirectoryIsWorkspaceRoot = async () => {
190
191
  const cwd = process.cwd();
191
- const [hasPackageJson, hasTsConfig, hasEnv] = await Promise.all([
192
+ const [hasPackageJson, hasTsConfig, ...hasMemberDirs] = await Promise.all([
192
193
  FileSys.fileExists(`${cwd}/package.json`),
193
194
  FileSys.fileExists(`${cwd}/tsconfig.json`),
194
- FileSys.fileExists(`${cwd}/.env`),
195
+ ...["apps", "libs", "pkgs"].map((dir) => FileSys.dirExists(`${cwd}/${dir}`)),
195
196
  ]);
196
- if (hasPackageJson && hasTsConfig && hasEnv) return;
197
+ if (hasPackageJson && hasTsConfig && hasMemberDirs.some(Boolean)) return;
197
198
 
198
199
  throw new Error(
199
200
  [
200
201
  "Akan CLI commands must be run from the workspace root.",
201
202
  `Current directory: ${cwd}`,
202
- "Move to the directory that contains package.json, tsconfig.json, and .env, then run the command again.",
203
+ "Move to the directory that contains package.json, tsconfig.json, and an apps/, libs/ or pkgs/ folder, then run the command again.",
203
204
  ].join("\n"),
204
205
  );
205
206
  };
@@ -48,4 +48,18 @@ describe("createTunnel", () => {
48
48
  expect(failure.message).toContain(reason.message);
49
49
  }
50
50
  });
51
+
52
+ test("refuses to dial a cluster host built on the local default serve domain", async () => {
53
+ const { app } = track(await createTempApp("demo"));
54
+ delete process.env.AKAN_PUBLIC_SERVE_DOMAIN;
55
+ const connect = spyOn(Client.prototype, "connect");
56
+ try {
57
+ await expect(createTunnel("redis", { app, environment: "debug" })).rejects.toThrow(
58
+ 'The redis tunnel for the "debug" environment needs AKAN_PUBLIC_SERVE_DOMAIN (would be "localhost") set',
59
+ );
60
+ expect(connect).not.toHaveBeenCalled();
61
+ } finally {
62
+ connect.mockRestore();
63
+ }
64
+ });
51
65
  });
package/createTunnel.ts CHANGED
@@ -10,7 +10,9 @@ export const createTunnel = async (
10
10
  service: "redis" | "postgres",
11
11
  { app, environment, port = service === "postgres" ? 5432 : 6379 }: TunnelOption,
12
12
  ) => {
13
- const { serveDomain, repoName } = WorkspaceExecutor.getBaseDevEnv();
13
+ const baseDevEnv = WorkspaceExecutor.getBaseDevEnv();
14
+ WorkspaceExecutor.assertDeployableEnv(baseDevEnv, environment, `The ${service} tunnel`);
15
+ const { serveDomain, repoName } = baseDevEnv;
14
16
  const host = `${app.name}-${environment}.${serveDomain}`;
15
17
  const sshPort = process.env.SSH_TUNNEL_PORT ? parseInt(process.env.SSH_TUNNEL_PORT) : 32767;
16
18
  const dstHost = `${service}-0.${service}-svc.${app.name}-${environment}.svc.cluster.local`;
@@ -107,7 +107,7 @@ export class TypeScriptDependencyScanner {
107
107
  return {
108
108
  npmDeps: [...runtimeDeps].sort(),
109
109
  npmDevDeps: [...devDeps].sort(),
110
- missingDeps: missingDeps.sort(),
110
+ missingDeps: missingDeps.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)),
111
111
  };
112
112
  }
113
113
 
@@ -76,7 +76,7 @@ describe("TypeScriptDependencyScanner", () => {
76
76
  });
77
77
  expect(deps.pkgDeps).toEqual(["akanjs/tool"]);
78
78
  expect(deps.libDeps).toEqual(["shared"]);
79
- expect(deps.npmDeps.sort()).toEqual(["lodash", "react"]);
79
+ expect(deps.npmDeps.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))).toEqual(["lodash", "react"]);
80
80
  expect(deps.npmDevDeps).toEqual(["typescript"]);
81
81
 
82
82
  const graph = scanner.generateDependencyGraph();
package/executors.test.ts CHANGED
@@ -99,6 +99,29 @@ describe("Executor filesystem helpers", () => {
99
99
  expect(entries.dirs).toContain("nested");
100
100
  });
101
101
 
102
+ test("lists a folder in name order, whatever order the filesystem keeps it in", async () => {
103
+ const root = await makeTempRoot();
104
+ const exec = new Executor("fixture", root);
105
+ for (const name of ["summary", "file", "banner", "_noti", "admin"]) await exec.mkdir(`lib/${name}`);
106
+ for (const name of ["st.ts", "cnst.ts", "dict.ts"]) await exec.writeFile(`lib/${name}`, "");
107
+
108
+ expect(await exec.readdir("lib")).toEqual([
109
+ "_noti",
110
+ "admin",
111
+ "banner",
112
+ "cnst.ts",
113
+ "dict.ts",
114
+ "file",
115
+ "st.ts",
116
+ "summary",
117
+ ]);
118
+ expect(await exec.getFilesAndDirs("lib")).toEqual({
119
+ files: ["cnst.ts", "dict.ts", "st.ts"],
120
+ dirs: ["_noti", "admin", "banner", "file", "summary"],
121
+ });
122
+ expect(await exec.getAllFiles("**/*", { cwd: exec.getPath("lib") })).toEqual(["cnst.ts", "dict.ts", "st.ts"]);
123
+ });
124
+
102
125
  test("a refreshed tsconfig re-reads the config it extends", async () => {
103
126
  const root = await makeTempRoot();
104
127
  const appDir = path.join(root, "apps/demo");
@@ -312,7 +335,7 @@ describe("Executor filesystem helpers", () => {
312
335
  });
313
336
 
314
337
  describe("Workspace and app executor environment contracts", () => {
315
- test("reads base development environment and reports required missing values", () => {
338
+ test("reads base development environment", () => {
316
339
  process.env.AKAN_PUBLIC_REPO_NAME = "repo";
317
340
  process.env.AKAN_PUBLIC_SERVE_DOMAIN = "example.com";
318
341
  process.env.AKAN_PUBLIC_ENV = "local";
@@ -328,10 +351,53 @@ describe("Workspace and app executor environment contracts", () => {
328
351
  env: "local",
329
352
  portOffset: 10,
330
353
  workspaceId: undefined,
354
+ defaulted: [],
331
355
  });
356
+ });
332
357
 
333
- delete process.env.AKAN_PUBLIC_REPO_NAME;
334
- expect(() => WorkspaceExecutor.getBaseDevEnv()).toThrow("AKAN_PUBLIC_REPO_NAME is not set");
358
+ test("unset, the repo name is the origin remote's and the serve domain localhost, both marked defaulted", async () => {
359
+ const root = await makeTempRoot();
360
+ for (const args of [
361
+ ["init", "-q"],
362
+ ["remote", "add", "origin", "git@github.com:akan-team/fixture-repo.git"],
363
+ ])
364
+ expect(Bun.spawnSync(["git", ...args], { cwd: root }).exitCode).toBe(0);
365
+ for (const key of ["AKAN_PUBLIC_REPO_NAME", "AKAN_PUBLIC_SERVE_DOMAIN", "AKAN_PUBLIC_ENV", "AKAN_WORKSPACE_ROOT"])
366
+ delete process.env[key];
367
+
368
+ expect(WorkspaceExecutor.getBaseDevEnv(path.join(root, ".env"))).toMatchObject({
369
+ repoName: "fixture-repo",
370
+ serveDomain: "localhost",
371
+ env: "debug",
372
+ defaulted: ["AKAN_PUBLIC_REPO_NAME", "AKAN_PUBLIC_SERVE_DOMAIN"],
373
+ });
374
+
375
+ process.env.AKAN_PUBLIC_SERVE_DOMAIN = "example.com";
376
+ expect(WorkspaceExecutor.getBaseDevEnv(path.join(root, ".env"))).toMatchObject({
377
+ repoName: "fixture-repo",
378
+ serveDomain: "example.com",
379
+ defaulted: ["AKAN_PUBLIC_REPO_NAME"],
380
+ });
381
+ });
382
+
383
+ test("a build or tunnel for a real environment refuses a defaulted value, and a local one takes it", () => {
384
+ const baseDevEnv = {
385
+ repoName: "fixture-repo",
386
+ serveDomain: "localhost",
387
+ env: "debug" as const,
388
+ portOffset: 0,
389
+ workspaceRoot: undefined,
390
+ };
391
+ const defaulted = { ...baseDevEnv, defaulted: ["AKAN_PUBLIC_SERVE_DOMAIN" as const] };
392
+
393
+ expect(() => WorkspaceExecutor.assertDeployableEnv(defaulted, "main", "akan build")).toThrow(
394
+ 'akan build for the "main" environment needs AKAN_PUBLIC_SERVE_DOMAIN (would be "localhost") set',
395
+ );
396
+ expect(() => WorkspaceExecutor.assertDeployableEnv(defaulted, "local", "akan build")).not.toThrow();
397
+ expect(() =>
398
+ WorkspaceExecutor.assertDeployableEnv({ ...baseDevEnv, defaulted: [] }, "main", "akan build"),
399
+ ).not.toThrow();
400
+ expect(() => WorkspaceExecutor.assertDeployableEnv(baseDevEnv, "main", "akan build")).not.toThrow();
335
401
  });
336
402
 
337
403
  test("reads base development environment from an explicit env file", async () => {
@@ -359,6 +425,7 @@ describe("Workspace and app executor environment contracts", () => {
359
425
  env: "develop",
360
426
  portOffset: 7,
361
427
  workspaceId: undefined,
428
+ defaulted: [],
362
429
  });
363
430
  });
364
431
 
@@ -417,6 +484,21 @@ describe("Workspace and app executor environment contracts", () => {
417
484
  expect((await stat(path.join(root, "dist/apps/demo/public"))).isDirectory()).toBe(true);
418
485
  });
419
486
 
487
+ test("the app command env hands the dev server the local defaults that stood in for unset values", async () => {
488
+ const root = await makeTempRoot();
489
+ delete process.env.AKAN_PUBLIC_REPO_NAME;
490
+ delete process.env.AKAN_PUBLIC_SERVE_DOMAIN;
491
+ process.env.AKAN_WORKSPACE_ROOT = root;
492
+ const app = AppExecutor.from(new WorkspaceExecutor({ workspaceRoot: root, repoName: "repo" }), "defaults");
493
+
494
+ const env = app.getCommandEnv();
495
+ expect(env.AKAN_PUBLIC_SERVE_DOMAIN).toBe("localhost");
496
+ expect(env.AKAN_PUBLIC_REPO_NAME).toBe(WorkspaceExecutor.getBaseDevEnv().repoName);
497
+
498
+ process.env.AKAN_PUBLIC_SERVE_DOMAIN = "example.com";
499
+ expect(app.getCommandEnv().AKAN_PUBLIC_SERVE_DOMAIN).toBe("example.com");
500
+ });
501
+
420
502
  test("akan start clears the dev output and keeps the bin downloads and a native folder from before dist/native", async () => {
421
503
  const root = await makeTempRoot();
422
504
  process.env.AKAN_PUBLIC_REPO_NAME = "repo";
package/executors.ts CHANGED
@@ -54,7 +54,7 @@ import { Spinner } from "./spinner";
54
54
  // Type-only: `getTypeChecker` loads it on demand to keep `typescript` out of the resident module graph.
55
55
  import type { TypeChecker } from "./typeChecker";
56
56
  import { ProjectTypecheck } from "./typecheck/projectTypecheck";
57
- import type { FileContent, PackageJson, TsConfigJson } from "./types";
57
+ import type { BaseDevEnv, FileContent, PackageJson, TsConfigJson } from "./types";
58
58
  import { barrelFacetDirs } from "./workspaceLayout";
59
59
 
60
60
  export interface PageRoot {
@@ -271,20 +271,20 @@ export class Executor {
271
271
  const readPath = this.getPath(dirPath);
272
272
  try {
273
273
  const glob = new Bun.Glob("*");
274
- return Array.from(glob.scanSync({ cwd: readPath, onlyFiles: false }));
274
+ return Array.from(glob.scanSync({ cwd: readPath, onlyFiles: false })).sort();
275
275
  } catch {
276
276
  return [];
277
277
  }
278
278
  }
279
279
  async getAllFiles(pattern = "**/*", { cwd }: { cwd?: string } = {}): Promise<string[]> {
280
280
  const glob = new Bun.Glob(pattern);
281
- return Array.from(glob.scanSync({ cwd: cwd ?? this.cwdPath, onlyFiles: true }));
281
+ return Array.from(glob.scanSync({ cwd: cwd ?? this.cwdPath, onlyFiles: true })).sort();
282
282
  }
283
283
  async getFilesAndDirs(dirPath: string): Promise<{ files: string[]; dirs: string[] }> {
284
284
  const fullDirPath = this.getPath(dirPath);
285
285
  const glob = new Bun.Glob("*");
286
- const files = Array.from(glob.scanSync({ cwd: fullDirPath, onlyFiles: true }));
287
- const allEntries = Array.from(glob.scanSync({ cwd: fullDirPath, onlyFiles: false }));
286
+ const files = Array.from(glob.scanSync({ cwd: fullDirPath, onlyFiles: true })).sort();
287
+ const allEntries = Array.from(glob.scanSync({ cwd: fullDirPath, onlyFiles: false })).sort();
288
288
  const dirs = allEntries.filter((entry) => !files.includes(entry));
289
289
  return { files, dirs };
290
290
  }
@@ -662,6 +662,7 @@ export class WorkspaceExecutor extends Executor {
662
662
  }
663
663
  /** The public env a native build was told to reproduce (`--public-env`): it outranks `.env`, which outranks the rest. */
664
664
  static publicEnvPin: Record<string, string> | null = null;
665
+ static readonly localServeDomain = "localhost";
665
666
  static getBaseDevEnv(envPath?: string) {
666
667
  // Bun auto-loads .env, so we use process.env directly
667
668
  const sourceEnv = {
@@ -673,11 +674,11 @@ export class WorkspaceExecutor extends Executor {
673
674
  const workspaceRoot = sourceEnv.AKAN_WORKSPACE_ROOT;
674
675
  const workspaceId = sourceEnv.AKAN_WORKSPACE_ID;
675
676
 
676
- const repoName = sourceEnv.AKAN_PUBLIC_REPO_NAME;
677
- if (!repoName) throw new Error("AKAN_PUBLIC_REPO_NAME is not set");
678
-
679
- const serveDomain = sourceEnv.AKAN_PUBLIC_SERVE_DOMAIN;
680
- if (!serveDomain) throw new Error("AKAN_PUBLIC_SERVE_DOMAIN is not set");
677
+ const repoName =
678
+ sourceEnv.AKAN_PUBLIC_REPO_NAME ||
679
+ resolveRepoName(envPath ? path.dirname(envPath) : (workspaceRoot ?? process.cwd()));
680
+ const serveDomain = sourceEnv.AKAN_PUBLIC_SERVE_DOMAIN || WorkspaceExecutor.localServeDomain;
681
+ const defaulted = (["AKAN_PUBLIC_REPO_NAME", "AKAN_PUBLIC_SERVE_DOMAIN"] as const).filter((key) => !sourceEnv[key]);
681
682
 
682
683
  const portOffset = parseInt(sourceEnv.PORT_OFFSET ?? "0");
683
684
 
@@ -697,8 +698,20 @@ export class WorkspaceExecutor extends Executor {
697
698
  env,
698
699
  portOffset,
699
700
  workspaceId,
701
+ defaulted,
700
702
  };
701
703
  }
704
+ //? A build or tunnel for a real environment carries these into an image, a bundle or a host name it dials.
705
+ static assertDeployableEnv(baseDevEnv: BaseDevEnv, environment: string, purpose: string) {
706
+ const defaulted = environment === "local" ? [] : (baseDevEnv.defaulted ?? []);
707
+ if (!defaulted.length) return;
708
+ const values = { AKAN_PUBLIC_REPO_NAME: baseDevEnv.repoName, AKAN_PUBLIC_SERVE_DOMAIN: baseDevEnv.serveDomain };
709
+ const standIns = defaulted.map((key) => `${key} (would be "${values[key]}")`).join(" and ");
710
+ throw new Error(
711
+ `${purpose} for the "${environment}" environment needs ${standIns} set, not a local default. ` +
712
+ `Set ${defaulted.length > 1 ? "them" : "it"} in the workspace .env or the shell, or use AKAN_PUBLIC_ENV=local.`,
713
+ );
714
+ }
702
715
  getWorkspaceId<AllowEmpty extends boolean = false>({
703
716
  allowEmpty,
704
717
  }: {
@@ -1159,10 +1172,12 @@ export class AppExecutor extends SysExecutor {
1159
1172
  return basePort + appIndex + portOffset;
1160
1173
  }
1161
1174
  getCommandEnv(env: Record<string, string> = {}): Record<string, string> {
1162
- const { portOffset } = WorkspaceExecutor.getBaseDevEnv();
1175
+ const { portOffset, repoName, serveDomain } = WorkspaceExecutor.getBaseDevEnv();
1163
1176
  const PORT = (8282 + portOffset).toString();
1164
1177
  return {
1165
1178
  ...process.env,
1179
+ AKAN_PUBLIC_REPO_NAME: repoName,
1180
+ AKAN_PUBLIC_SERVE_DOMAIN: serveDomain,
1166
1181
  AKAN_PUBLIC_APP_NAME: this.name,
1167
1182
  AKAN_WORKSPACE_ROOT: this.workspace.workspaceRoot,
1168
1183
  NODE_NO_WARNINGS: "1",
@@ -1323,7 +1338,7 @@ export class AppExecutor extends SysExecutor {
1323
1338
  pageKeys.push(key);
1324
1339
  }
1325
1340
  }
1326
- pageKeys.sort();
1341
+ pageKeys.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
1327
1342
  this.#pageKeys = this.#excludeDevOnlyPages ? this.#dropDevOnlyPages(pageKeys, devOnlyKeys, devOnlyDirs) : pageKeys;
1328
1343
  return this.#pageKeys;
1329
1344
  }
@@ -1581,7 +1596,9 @@ export class PkgExecutor extends Executor {
1581
1596
  devDependencies: string[] = [],
1582
1597
  ): Promise<Pick<PackageJson, "dependencies" | "devDependencies">> {
1583
1598
  const dependencyNames = [...new Set(dependencies)].sort();
1584
- const devDependencyNames = [...new Set(devDependencies)].filter((dep) => !dependencyNames.includes(dep)).sort();
1599
+ const devDependencyNames = [...new Set(devDependencies)]
1600
+ .filter((dep) => !dependencyNames.includes(dep))
1601
+ .sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
1585
1602
  const dependencyVersions = new Map<string, string>();
1586
1603
  const missingDeps: string[] = [];
1587
1604
  for (const dep of [...dependencyNames, ...devDependencyNames]) {
@@ -253,7 +253,9 @@ describe("AutoImportSync.syncForBatch", () => {
253
253
 
254
254
  const result = await sync.syncForBatch([uiFile, pageFile, webkitFile]);
255
255
  expect(result.errors).toEqual([]);
256
- expect(result.changedFiles.sort()).toEqual([pageFile, webkitFile, uiFile].sort());
256
+ expect(result.changedFiles.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))).toEqual(
257
+ [pageFile, webkitFile, uiFile].sort(),
258
+ );
257
259
  expect(await readFile(uiFile, "utf8")).toContain(`import { st } from "@apps/akan/client";`);
258
260
  expect(await readFile(pageFile, "utf8")).toContain(`import { cnst } from "@apps/akan/client";`);
259
261
  expect(await readFile(webkitFile, "utf8")).toContain(`import { fetch } from "@libs/shared/client";`);
@@ -6,7 +6,7 @@ import { BarrelAnalyzer } from "../transforms/barrelAnalyzer";
6
6
  import { createTsconfigPackageResolver, rewriteBarrelImports } from "../transforms/barrelImportsPlugin";
7
7
  import type { AkanConfig, ClientEntryDiscovery, ScannedImport } from "./clientBuildTypes";
8
8
 
9
- const USE_CLIENT_RE = /^\s*(?:\/\*[\s\S]*?\*\/\s*|\/\/[^\n]*\n\s*)*["']use client["']/;
9
+ const USE_CLIENT_RE = /^\s*(?:\/\*[^*]*\*+(?:[^/*][^*]*\*+)*\/\s*|\/\/[^\n]*\n\s*)*["']use client["']/;
10
10
  const NODE_MODULES_RE = /[\\/]node_modules[\\/]/;
11
11
  const AKANJS_NODE_MODULE_RE = /[\\/]node_modules[\\/]akanjs[\\/]/;
12
12
  // Bun's scanImports also surfaces CSS and asset imports, none of which can be a client component.
@@ -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 } from "../testHelpers";
4
+ import { CsrArtifactBuilder } from "./csrArtifactBuilder";
4
5
 
5
6
  const makeTempRoot = tempDirs("akan-csr-artifact-");
6
7
 
@@ -31,4 +32,33 @@ console.info(await result.outputs[0].text());
31
32
  expect(stdout).toContain("envmark:debug");
32
33
  expect(stdout).not.toContain("envmark:local");
33
34
  });
35
+
36
+ //? An OTA release is keyed on the bundle's hash, so a no-op build from another checkout must not stage one.
37
+ test("bundles the same app identically from two checkout paths", async () => {
38
+ const app = { getPublicEnv: () => ({ AKAN_PUBLIC_ENV: "main" }) };
39
+ const buildFrom = async (root: string) => {
40
+ await writeText(
41
+ path.join(root, "node_modules/wasmdep/package.json"),
42
+ '{ "name": "wasmdep", "main": "index.js" }\n',
43
+ );
44
+ await writeText(
45
+ path.join(root, "node_modules/wasmdep/index.js"),
46
+ // biome-ignore lint/suspicious/noTemplateCurlyInString: the fixture module's own source holds a template literal
47
+ 'var isNode = typeof process == "object" && !!process.versions?.node;\nmodule.exports = isNode ? `${__dirname}/` : "";\n',
48
+ );
49
+ await writeText(path.join(root, "entry.ts"), 'import dir from "wasmdep";\nconsole.info(dir);\n');
50
+ const result = await Bun.build({
51
+ ...CsrArtifactBuilder.bundleOptions(app as never, "build"),
52
+ entrypoints: [path.join(root, "entry.ts")],
53
+ });
54
+ expect(result.success).toBe(true);
55
+ return await result.outputs[0]?.text();
56
+ };
57
+ const [first, second] = [await makeTempRoot(), await makeTempRoot()];
58
+
59
+ const [firstOutput, secondOutput] = [await buildFrom(first), await buildFrom(second)];
60
+
61
+ expect(firstOutput).toBe(secondOutput);
62
+ expect(firstOutput).not.toContain(path.basename(first));
63
+ });
34
64
  });
@@ -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 { createBrowserDirnamePlugin } from "../transforms/browserDirnamePlugin";
6
7
  import { bundleDefine } from "./bundleDefine";
7
8
  import { CsrEntryFiles } from "./csrEntryFiles";
8
9
  import { PagesBundleBuilder } from "./pagesBundleBuilder";
@@ -90,7 +91,7 @@ export class CsrArtifactBuilder {
90
91
  define: bundleDefine(app, command, "csr"),
91
92
  optimizeImports,
92
93
  // A raw `.css` in the route graph is Tailwind source; the base artifact's compiled sheet is the only stylesheet.
93
- plugins: [PagesBundleBuilder.createCssStubPlugin()],
94
+ plugins: [PagesBundleBuilder.createCssStubPlugin(), createBrowserDirnamePlugin()],
94
95
  };
95
96
  }
96
97
 
@@ -234,7 +235,7 @@ void bootCsr(pages);
234
235
  for await (const filePath of glob.scan({ cwd: this.#outputDir, absolute: true })) {
235
236
  if (predicate(filePath)) files.push(filePath);
236
237
  }
237
- return files.sort();
238
+ return files.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
238
239
  }
239
240
 
240
241
  // Bun hoists the module script into <head> and a React bundle contains "</head>" as text, so positions come from a
@@ -248,9 +249,10 @@ void bootCsr(pages);
248
249
  return `${html}\n${snippet}`;
249
250
  }
250
251
 
252
+ // An unclosed body runs to EOF, as the HTML tokenizer reads it; that also keeps the scan linear.
251
253
  static blankEmbeddedContent(html: string): string {
252
254
  return html.replace(
253
- /<script\b[^>]*>[\s\S]*?<\/script\s*>|<style\b[^>]*>[\s\S]*?<\/style\s*>|<!--[\s\S]*?-->/gi,
255
+ /<script\b[^>]*>[\s\S]*?(?:<\/script(?:[\s/][^<>]*)?>|$)|<style\b[^>]*>[\s\S]*?(?:<\/style(?:[\s/][^<>]*)?>|$)|<!--[\s\S]*?(?:--!?>|$)/gi,
254
256
  (match) => " ".repeat(match.length),
255
257
  );
256
258
  }
@@ -379,7 +379,10 @@ describe("CsrDevPatcher", () => {
379
379
  await expect(patcher.update([], { roots: [file("ui/E1.tsx")], onlyRoots: true })).rejects.toThrow();
380
380
  await expect(patcher.update([], { roots: [file("ui/E4.tsx")], onlyRoots: true })).rejects.toThrow();
381
381
  const graph = (await bundler.writer.readJson<CsrDevGraph>("graph.json")) as CsrDevGraph;
382
- expect(graph.pending.sort()).toEqual(["apps/demo/ui/E1.tsx", "apps/demo/ui/E4.tsx"]);
382
+ expect(graph.pending.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))).toEqual([
383
+ "apps/demo/ui/E1.tsx",
384
+ "apps/demo/ui/E4.tsx",
385
+ ]);
383
386
  });
384
387
 
385
388
  test("a route's check that adds its own root leaves another route's failed root pending", async () => {
@@ -0,0 +1,123 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { type CssBlock, CssBlockParser, type CssNode } from "./cssBlockParser";
3
+
4
+ const blocksOf = (block: CssBlock) => block.nodes.filter((node): node is CssBlock => node.kind === "block");
5
+ const declarationsOf = (block: CssBlock) =>
6
+ block.nodes.flatMap((node) => (node.kind === "declaration" ? [[node.property, node.value]] : []));
7
+
8
+ const expectLinear = (run: () => unknown) => {
9
+ const started = performance.now();
10
+ run();
11
+ expect(performance.now() - started).toBeLessThan(1000);
12
+ };
13
+
14
+ describe("CssBlockParser.parse", () => {
15
+ test("reads consecutive blocks with their preludes and declarations", () => {
16
+ const root = CssBlockParser.parse(`a { --x: 1; } b { --y: 2 } c{--z:3}`);
17
+ expect(blocksOf(root).map((block) => block.prelude)).toEqual(["a", "b", "c"]);
18
+ expect(blocksOf(root).map(declarationsOf)).toEqual([[["--x", "1"]], [["--y", "2"]], [["--z", "3"]]]);
19
+ });
20
+
21
+ test("nests at-rules and nested rules, keeping a block's own declarations apart from its children's", () => {
22
+ const root = CssBlockParser.parse(
23
+ `@media (min-width: 1px) { :root { --a: 1; } } .card { color: red; &:hover { color: blue; } --b: 2; }`,
24
+ );
25
+ const [media, card] = blocksOf(root);
26
+ expect(media.atRule).toBe("@media");
27
+ expect(media.prelude).toBe("@media (min-width: 1px)");
28
+ expect(declarationsOf(blocksOf(media)[0])).toEqual([["--a", "1"]]);
29
+ expect(card.atRule).toBeNull();
30
+ expect(declarationsOf(card)).toEqual([
31
+ ["color", "red"],
32
+ ["--b", "2"],
33
+ ]);
34
+ expect(blocksOf(card).map((block) => block.prelude)).toEqual(["&:hover"]);
35
+ });
36
+
37
+ test("treats at-rule statements as neither blocks nor declarations", () => {
38
+ const root = CssBlockParser.parse(
39
+ `@source "./**/*";\n@import "tailwindcss";\n@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));\n:root { --a: 1; }`,
40
+ );
41
+ expect(root.nodes).toEqual([
42
+ { kind: "block", prelude: ":root", atRule: null, nodes: [{ kind: "declaration", property: "--a", value: "1" }] },
43
+ ]);
44
+ });
45
+
46
+ test("ignores braces, semicolons and comment markers inside strings, comments and escapes", () => {
47
+ const root = CssBlockParser.parse(
48
+ [
49
+ `/* :root { --commented: 1; } */`,
50
+ `.a::before { content: "} ; /* {"; --q: 'x;y'; }`,
51
+ `.w-\\[10px\\]\\{ { --escaped: 1; }`,
52
+ `.b { background: url(data:image/png;base64,AAAA); --after: 2; }`,
53
+ ].join("\n"),
54
+ );
55
+ expect(blocksOf(root).map((block) => block.prelude)).toEqual([".a::before", ".w-\\[10px\\]\\{", ".b"]);
56
+ expect(blocksOf(root).map(declarationsOf)).toEqual([
57
+ [
58
+ ["content", `"} ; /* {"`],
59
+ ["--q", "'x;y'"],
60
+ ],
61
+ [["--escaped", "1"]],
62
+ [
63
+ ["background", "url(data:image/png;base64,AAAA)"],
64
+ ["--after", "2"],
65
+ ],
66
+ ]);
67
+ });
68
+
69
+ test("drops comments from preludes and values", () => {
70
+ const root = CssBlockParser.parse(`/* lead */ :root /* mid */ , [data-theme="dark"] { --a: /* x */ #fff; }`);
71
+ const [block] = blocksOf(root);
72
+ expect(block.prelude).toBe(`:root , [data-theme="dark"]`);
73
+ expect(declarationsOf(block)).toEqual([["--a", "#fff"]]);
74
+ });
75
+
76
+ test("closes what the input leaves open and tolerates a stray closing brace", () => {
77
+ const root = CssBlockParser.parse(`} :root { --a: 1; .x { --b: 2`);
78
+ const [rootBlock] = blocksOf(root);
79
+ expect(declarationsOf(rootBlock)).toEqual([["--a", "1"]]);
80
+ expect(declarationsOf(blocksOf(rootBlock)[0])).toEqual([["--b", "2"]]);
81
+ expect(CssBlockParser.parse(`.a { content: "unterminated\n--b: 2; }`).nodes).toHaveLength(1);
82
+ expect(CssBlockParser.parse(`.a { --b: 1 } /* unterminated`).nodes).toHaveLength(1);
83
+ });
84
+ });
85
+
86
+ describe("CssBlockParser.walk", () => {
87
+ test("visits in source order, passing each block's context to its children", () => {
88
+ const root = CssBlockParser.parse(
89
+ `.a { --x: 1; @media m { --y: 2; } --z: 3; } @theme { --skipped: 1; } .b { --w: 4 }`,
90
+ );
91
+ const visited: string[] = [];
92
+ CssBlockParser.walk(root, "", (node: CssNode, path: string) => {
93
+ if (node.kind === "declaration") {
94
+ visited.push(`${path}${node.property}`);
95
+ return null;
96
+ }
97
+ return node.atRule === "@theme" ? null : `${path}${node.prelude}>`;
98
+ });
99
+ expect(visited).toEqual([".a>--x", ".a>@media m>--y", ".a>--z", ".b>--w"]);
100
+ });
101
+
102
+ test("walks a deeply nested tree without recursion", () => {
103
+ const root = CssBlockParser.parse(`${"@media x{".repeat(100_000)}:root{--deep:1}`);
104
+ let found = "";
105
+ CssBlockParser.walk(root, true, (node) => {
106
+ if (node.kind === "declaration") found = node.property;
107
+ return true;
108
+ });
109
+ expect(found).toBe("--deep");
110
+ });
111
+ });
112
+
113
+ describe("CssBlockParser linear time", () => {
114
+ test("adversarial runs finish well under a second", () => {
115
+ expectLinear(() => CssBlockParser.parse(`}${" ".repeat(100_000)}`));
116
+ expectLinear(() => CssBlockParser.parse(`:root{${"--".repeat(100_000)}`));
117
+ expectLinear(() => CssBlockParser.parse("@theme".repeat(100_000)));
118
+ expectLinear(() => CssBlockParser.parse("/*".repeat(100_000)));
119
+ expectLinear(() => CssBlockParser.parse(`"${"\\".repeat(100_000)}`));
120
+ expectLinear(() => CssBlockParser.parse("(".repeat(100_000)));
121
+ expectLinear(() => CssBlockParser.parse(":root{--a:#fff}".repeat(100_000)));
122
+ });
123
+ });