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

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 (106) 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 +3 -12
  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/argMeta.ts +1 -0
  33. package/commandDecorators/command.ts +52 -14
  34. package/commandDecorators/commandBuilder.ts +6 -3
  35. package/commandDecorators/commandDecorators.test.ts +130 -6
  36. package/commandDecorators/helpFormatter.ts +2 -1
  37. package/commandDecorators/targetMeta.ts +10 -0
  38. package/createTunnel.test.ts +14 -0
  39. package/createTunnel.ts +3 -1
  40. package/dependencyScanner.ts +1 -1
  41. package/devkitUtils.test.ts +1 -1
  42. package/executors.test.ts +108 -3
  43. package/executors.ts +30 -13
  44. package/frontendBuild/autoImportSync.test.ts +3 -1
  45. package/frontendBuild/clientEntryDiscovery.ts +1 -1
  46. package/frontendBuild/csrArtifactBuilder.test.ts +30 -0
  47. package/frontendBuild/csrArtifactBuilder.ts +5 -3
  48. package/frontendBuild/csrDevPatcher.test.ts +4 -1
  49. package/frontendBuild/cssBlockParser.test.ts +123 -0
  50. package/frontendBuild/cssBlockParser.ts +132 -0
  51. package/frontendBuild/cssCandidateCache.test.ts +16 -0
  52. package/frontendBuild/cssCandidateCache.ts +8 -6
  53. package/frontendBuild/cssCompiler.test.ts +53 -0
  54. package/frontendBuild/cssCompiler.ts +8 -4
  55. package/frontendBuild/fontOptimizer.ts +2 -1
  56. package/frontendBuild/frontendBuild.test.ts +14 -0
  57. package/frontendBuild/hmrWatcher.ts +1 -1
  58. package/frontendBuild/pagesBundleBuilder.test.ts +98 -0
  59. package/frontendBuild/pagesBundleBuilder.ts +64 -9
  60. package/frontendBuild/routeClientBuilder.ts +1 -1
  61. package/frontendBuild/sourceMtimeIndex.test.ts +1 -1
  62. package/frontendBuild/themeValidator.test.ts +83 -2
  63. package/frontendBuild/themeValidator.ts +37 -15
  64. package/getRelatedCnsts.ts +2 -1
  65. package/integration/devStabilityHarness.ts +5 -3
  66. package/lint/__fixtures__/no-deep-internal-import/bad.tsx +1 -0
  67. package/lint/__fixtures__/no-deep-internal-import/good.tsx +1 -0
  68. package/lint/__fixtures__/no-import-client-functions/bad.tsx +1 -0
  69. package/lint/__fixtures__/no-import-client-functions/good.tsx +1 -0
  70. package/lint/__fixtures__/no-import-client-in-server/bad.tsx +1 -0
  71. package/lint/__fixtures__/no-import-client-in-server/good.tsx +1 -0
  72. package/lint/__fixtures__/no-import-external-library/bad.tsx +1 -0
  73. package/lint/__fixtures__/no-import-external-library/good.tsx +1 -0
  74. package/lint/__fixtures__/no-import-external-library-in-test/bad.tsx +1 -0
  75. package/lint/__fixtures__/no-import-external-library-in-test/good.tsx +1 -0
  76. package/lint/__fixtures__/no-import-server-in-client/bad.tsx +1 -0
  77. package/lint/__fixtures__/no-import-server-in-client/good.tsx +1 -0
  78. package/lint/__fixtures__/no-init-fetch-in-client/store-file/bad.ts +1 -1
  79. package/lint/__fixtures__/no-static-in-object-light-model/bad.ts +2 -2
  80. package/mobile/mobileTarget.ts +1 -2
  81. package/mobile/nativeApp.test.ts +1 -1
  82. package/mobile/nativeConfig.test.ts +3 -0
  83. package/mobile/nativeConfig.ts +3 -2
  84. package/mobile/nativeWebDir.ts +3 -2
  85. package/mobile/updateUploader.test.ts +1 -0
  86. package/mobile/updateUploader.ts +3 -3
  87. package/package.json +20 -20
  88. package/piDependencyPins.test.ts +18 -19
  89. package/platformTest/LinuxDockerTarget.ts +2 -1
  90. package/platformTest/PlatformTestTarget.ts +7 -5
  91. package/platformTest/WindowsSshTarget.ts +2 -1
  92. package/qualityScanner.ts +1 -1
  93. package/recipeScanner.test.ts +9 -3
  94. package/scanInfo.ts +37 -4
  95. package/subspaceClone.ts +5 -4
  96. package/testHelpers.ts +2 -0
  97. package/transforms/barrelImportsPlugin.ts +1 -1
  98. package/transforms/browserDirnamePlugin.test.ts +88 -0
  99. package/transforms/browserDirnamePlugin.ts +25 -0
  100. package/transforms/rscUseClientTransform.ts +2 -2
  101. package/transforms/transforms.test.ts +18 -2
  102. package/transforms/tsconfigPackageResolver.test.ts +9 -0
  103. package/types.ts +1 -0
  104. package/workflow/artifacts.ts +1 -1
  105. package/workflow/rolloutGate.test.ts +5 -1
  106. package/workflow/source.ts +2 -2
@@ -1,7 +1,7 @@
1
1
  import { readdir } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import type { UpdateEnvelope, UpdateManifest } from "@akanjs/native/api";
4
- import { isRecord } from "akanjs/common";
4
+ import { isRecord, trimTrailingSlashes } from "akanjs/common";
5
5
  import { UpdatePackFolder } from "./updatePackFolder";
6
6
 
7
7
  /** What the uploader needs of an S3 client; Bun.S3Client is one. */
@@ -32,9 +32,9 @@ export class UpdateUploader {
32
32
 
33
33
  /** `s3://bucket/prefix`: the credentials and endpoint come from S3_* (or AWS_*) in the environment. */
34
34
  static parseTarget(target: string): { bucket: string; prefix: string } {
35
- const match = /^s3:\/\/([^/]+)\/?(.*)$/.exec(target.trim());
35
+ const match = /^s3:\/\/([^/]+)(?:\/(.*))?$/.exec(target.trim());
36
36
  if (!match?.[1]) throw new Error(`--upload takes s3://<bucket>/<prefix>, not ${JSON.stringify(target)}.`);
37
- return { bucket: match[1], prefix: (match[2] ?? "").replace(/\/+$/, "") };
37
+ return { bucket: match[1], prefix: trimTrailingSlashes(match[2] ?? "") };
38
38
  }
39
39
 
40
40
  static order(files: string[]): { releases: string[]; manifests: string[] } {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akanjs/devkit",
3
- "version": "3.1.0-rc.2",
3
+ "version": "3.1.0-rc.21",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -40,31 +40,31 @@
40
40
  "./*": "./*.ts"
41
41
  },
42
42
  "dependencies": {
43
- "@earendil-works/chord": "0.86.1",
44
- "@earendil-works/pi-agent-core": "0.86.1",
45
- "@earendil-works/pi-ai": "0.86.1",
46
- "@earendil-works/pi-coding-agent": "0.86.1",
47
- "@earendil-works/pi-telemetry": "0.86.1",
48
- "@earendil-works/pi-tui": "0.86.1",
49
- "@inquirer/prompts": "^8.4.3",
50
- "@tailwindcss/node": "^4.3.0",
51
- "akanjs": "3.1.0-rc.2",
52
- "chalk": "^5.6.2",
53
- "commander": "^14.0.3",
54
- "dayjs": "^1.11.20",
55
- "fontaine": "^0.8.0",
43
+ "@earendil-works/pi-agent-core": "1.3.0",
44
+ "@earendil-works/pi-ai": "1.3.0",
45
+ "@earendil-works/pi-codemode": "1.3.0",
46
+ "@earendil-works/pi-coding-agent": "1.3.0",
47
+ "@earendil-works/pi-mcp": "1.3.0",
48
+ "@earendil-works/pi-tui": "1.3.0",
49
+ "@inquirer/prompts": "^8.7.3",
50
+ "@tailwindcss/node": "^4.3.3",
51
+ "akanjs": "3.1.0-rc.21",
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": {
@@ -2,31 +2,24 @@ import { describe, expect, test } from "bun:test";
2
2
  import path from "node:path";
3
3
  import type { PackageJson } from "@akanjs/devkit/types";
4
4
 
5
- //? A published install reads neither pi's npm-shrinkwrap.json nor our overrides, so PackageRunner re-declares pi's
6
- //? sibling pins (a floating 0.80.x once broke every global install); this keeps them equal to the shrinkwrap.
5
+ //? PackageRunner re-declares pi's sibling pins because neither our `overrides` nor pi's own ranges reach a
6
+ //? consumer's install. The source of truth is pi's `dependencies`: since 1.3.0 it pins every sibling exactly, where
7
+ //? 0.86.1 floated `^0.86.1` and only the (now removed) `npm-shrinkwrap.json` held the resolved version.
7
8
 
8
9
  const repoRoot = path.resolve(import.meta.dir, "../../..");
9
10
  const piName = "@earendil-works/pi-coding-agent";
10
11
  const piDir = `${repoRoot}/node_modules/${piName}`;
11
12
  const publishedPackages = ["pkgs/@akanjs/devkit/package.json", "pkgs/@akanjs/cli/package.json"];
12
13
 
13
- interface Shrinkwrap {
14
- packages: Record<string, { version?: string }>;
15
- }
16
-
17
14
  const readJson = async <T>(file: string) => (await Bun.file(file).json()) as T;
18
15
 
19
- const shrinkwrappedPins = async () => {
20
- const { packages } = await readJson<Shrinkwrap>(`${piDir}/npm-shrinkwrap.json`);
21
- // Top-level entries only: a nested `node_modules/` key is another package's placement, not ours to pin.
22
- const entries = Object.entries(packages)
23
- .filter(([key]) => /^node_modules\/@earendil-works\/[^/]+$/.test(key))
24
- .map(([key, entry]) => [key.replace(/^node_modules\//, ""), entry.version ?? ""] as const);
25
- return Object.fromEntries(entries);
16
+ const siblingEntries = async () => {
17
+ const { dependencies = {} } = await readJson<PackageJson>(`${piDir}/package.json`);
18
+ return Object.entries(dependencies).filter(([name]) => /^@earendil-works\/[^/]+$/.test(name));
26
19
  };
27
20
 
28
21
  describe("pi dependency pins", () => {
29
- test("the shrinkwrap read here belongs to the coding agent we publish", async () => {
22
+ test("the package read here belongs to the coding agent we publish", async () => {
30
23
  const installed = await readJson<PackageJson>(`${piDir}/package.json`);
31
24
  for (const file of publishedPackages) {
32
25
  const { dependencies = {} } = await readJson<PackageJson>(`${repoRoot}/${file}`);
@@ -34,13 +27,19 @@ describe("pi dependency pins", () => {
34
27
  }
35
28
  });
36
29
 
37
- test("every sibling pi package is published at the version the coding agent shrinkwrapped", async () => {
38
- const pins = await shrinkwrappedPins();
39
- expect(Object.keys(pins).length).toBeGreaterThan(0);
30
+ test("the coding agent pins each sibling exactly", async () => {
31
+ const siblings = await siblingEntries();
32
+ expect(siblings.length).toBeGreaterThan(0);
33
+ const floating = siblings.filter(([, version]) => !/^\d+\.\d+\.\d+/.test(version));
34
+ expect(floating).toEqual([]);
35
+ });
36
+
37
+ test("every sibling pi package is published at the version the coding agent pins", async () => {
38
+ const siblings = await siblingEntries();
40
39
  for (const file of publishedPackages) {
41
40
  const { dependencies = {} } = await readJson<PackageJson>(`${repoRoot}/${file}`);
42
- const declared = Object.fromEntries(Object.keys(pins).map((name) => [name, dependencies[name]]));
43
- expect({ file, ...declared }).toEqual({ file, ...pins });
41
+ const declared = Object.fromEntries(siblings.map(([name]) => [name, dependencies[name]]));
42
+ expect({ file, ...declared }).toEqual({ file, ...Object.fromEntries(siblings) });
44
43
  }
45
44
  });
46
45
  });
@@ -19,9 +19,10 @@ export class LinuxDockerTarget extends PlatformTestTarget {
19
19
  }
20
20
 
21
21
  //* No compiler in the image: with one, ssh2 builds its native addon and Bun panics loading it (oven-sh/bun#18546).
22
+ //* apt goes over https: a captive portal answers plain http with its splash page, which apt reports as unsigned.
22
23
  static readonly dockerfile = [
23
24
  `FROM oven/bun:${Bun.version}-slim`,
24
- "RUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates procps lsof && rm -rf /var/lib/apt/lists/*",
25
+ "RUN sed -i 's|http://|https://|g' /etc/apt/sources.list.d/debian.sources && apt-get update && apt-get install -y --no-install-recommends git ca-certificates procps lsof && rm -rf /var/lib/apt/lists/*",
25
26
  ].join("\n");
26
27
 
27
28
  get image() {
@@ -108,14 +108,16 @@ export abstract class PlatformTestTarget {
108
108
  const logPath = this.#logPath(context, "install");
109
109
  const install = await this.exec("bun install --frozen-lockfile", logPath, PlatformTestTarget.installIdleMs);
110
110
  if (install.exitCode === 0) return;
111
- const failedScripts = [...install.output.matchAll(/(?:pre|post)?install script from "([^"]+)" exited/g)].map(
112
- (match) => match[1] ?? "",
113
- );
111
+ const failedPackages = [
112
+ ...install.output.matchAll(
113
+ /(?:pre|post)?install script from "([^"]+)" exited|failed to link binaries for package: (@?[^@\s]+)@/g,
114
+ ),
115
+ ].map((match) => match[1] ?? match[2] ?? "");
114
116
  const tolerable =
115
- failedScripts.length > 0 && failedScripts.every((pkg) => this.tolerateScriptFailures.includes(pkg));
117
+ failedPackages.length > 0 && failedPackages.every((pkg) => this.tolerateScriptFailures.includes(pkg));
116
118
  if (!tolerable) throw new Error(`bun install failed — see ${logPath}`);
117
119
  //* A failed lifecycle script makes bun skip linking the root `.bin`; a second pass without scripts links it.
118
- envWarnings.push(`install script failed for ${failedScripts.join(", ")} — tolerated on ${this.platform}`);
120
+ envWarnings.push(`install failed for ${failedPackages.join(", ")} — tolerated on ${this.platform}`);
119
121
  const relink = await this.exec(
120
122
  "bun install --frozen-lockfile --ignore-scripts",
121
123
  this.#logPath(context, "install-relink"),
@@ -9,7 +9,8 @@ export class WindowsSshTarget extends PlatformTestTarget {
9
9
  static readonly utmctlPath = "/Applications/UTM.app/Contents/MacOS/utmctl";
10
10
  static readonly defaultWorkRoot = "C:\\akan-test";
11
11
  //* arm64 has neither a sharp@0.32 prebuilt nor a Chrome for puppeteer's postinstall; x64 users get both.
12
- static readonly defaultTolerateScriptFailures = ["sharp", "puppeteer"];
12
+ //* Nor a msgpackr-extract prebuilt: bun deletes it when node-gyp fails, racing the link of msgpackr's bins.
13
+ static readonly defaultTolerateScriptFailures = ["sharp", "puppeteer", "msgpackr"];
13
14
  static readonly #runDirPattern = "^\\d{8}-\\d{6}$";
14
15
  static readonly #keptRuns = 2;
15
16
 
package/qualityScanner.ts CHANGED
@@ -215,7 +215,7 @@ export class AkanQualityScanner {
215
215
  if (!(await isDirectory(absoluteTargetRoot))) continue;
216
216
  await this.#walkTargetFiles(workspaceRoot, absoluteTargetRoot, ignoreFilter, files);
217
217
  }
218
- return files.sort();
218
+ return files.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
219
219
  }
220
220
 
221
221
  async #readGitIgnore(workspaceRoot: string) {
@@ -48,7 +48,10 @@ export default function Page() {
48
48
  describe("scanRecipes", () => {
49
49
  test("detects framework recipes with full variant surface", () => {
50
50
  const recipes = scanRecipes([{ path: "recipe.ts", content: FRAMEWORK, importFrom: "akanjs/ui" }]);
51
- expect(recipes.map((r) => r.name).sort()).toEqual(["badgeRecipe", "buttonRecipe"]);
51
+ expect(recipes.map((r) => r.name).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))).toEqual([
52
+ "badgeRecipe",
53
+ "buttonRecipe",
54
+ ]);
52
55
 
53
56
  const button = byName(recipes, "buttonRecipe");
54
57
  expect(button?.importFrom).toBe("akanjs/ui");
@@ -91,7 +94,10 @@ describe("scanRecipes", () => {
91
94
  { path: "a.ts", content: src, importFrom: "@a" },
92
95
  { path: "b.ts", content: `export const other = recipe(tv({ base: "c" }));`, importFrom: "@b" },
93
96
  ]);
94
- expect(recipes.map((r) => `${r.name}@${r.importFrom}`).sort()).toEqual(["other@@b", "shown@@a"]);
97
+ expect(recipes.map((r) => `${r.name}@${r.importFrom}`).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))).toEqual([
98
+ "other@@b",
99
+ "shown@@a",
100
+ ]);
95
101
  });
96
102
  });
97
103
 
@@ -154,7 +160,7 @@ describe("collectRecipeSources", () => {
154
160
  expect(
155
161
  scanRecipes(sources)
156
162
  .map((r) => r.name)
157
- .sort(),
163
+ .sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)),
158
164
  ).toEqual(["appBox", "appCard"]);
159
165
  });
160
166
 
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,
@@ -113,7 +114,7 @@ async function assertScanConvention(exec: AppExecutor | LibExecutor, libRoot: {
113
114
  if (violations.length > 0) {
114
115
  throw new Error(
115
116
  `[scan-convention]\n${violations
116
- .sort()
117
+ .sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))
117
118
  .map((violation) => `- ${violation}`)
118
119
  .join("\n")}`,
119
120
  );
@@ -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/subspaceClone.ts CHANGED
@@ -221,19 +221,20 @@ export class SubspaceClone {
221
221
 
222
222
  // An allowlist, not a filter, so no secret can reach a customer's clone; `AKAN_PUBLIC_*` ships in every bundle anyway.
223
223
  #localEnv() {
224
- const { serveDomain } = WorkspaceExecutor.getBaseDevEnv();
224
+ const { serveDomain, defaulted = [] } = WorkspaceExecutor.getBaseDevEnv();
225
225
  return {
226
226
  AKAN_WORKSPACE_ID: "local",
227
227
  AKAN_PUBLIC_REPO_NAME: this.#declaration.name,
228
- AKAN_PUBLIC_SERVE_DOMAIN: serveDomain,
228
+ //? Written down, the local default would read as chosen and pass the clone's build check for a real environment.
229
+ ...(defaulted.includes("AKAN_PUBLIC_SERVE_DOMAIN") ? {} : { AKAN_PUBLIC_SERVE_DOMAIN: serveDomain }),
229
230
  AKAN_PUBLIC_ENV: "local",
230
231
  AKAN_PUBLIC_OPERATION_MODE: "local",
231
232
  AKAN_PUBLIC_LOG_LEVEL: "warn",
232
233
  };
233
234
  }
234
235
 
235
- // The CLI needs a `.env` to recognise a workspace root and none arrives with the slice. Excluded through
236
- // `.git/info/exclude`, not the copied `.gitignore`, so it can never be committed to the customer's repo.
236
+ // No `.env` arrives with the slice, and the clone runs under the customer's repo name in a local env. Excluded
237
+ // through `.git/info/exclude`, not the copied `.gitignore`, so it can never be committed to the customer's repo.
237
238
  async #writeLocalEnv() {
238
239
  const excludePath = path.join(this.#clonePath, ".git/info/exclude");
239
240
  const exclude = (await FileSys.fileExists(excludePath)) ? await FileSys.readText(excludePath) : "";
package/testHelpers.ts CHANGED
@@ -80,6 +80,8 @@ export const createFakeExecutor = <Extra extends object = object>(
80
80
  export const makeCliTempWorkspace = async (parentDir = os.tmpdir()) => {
81
81
  await mkdir(parentDir, { recursive: true });
82
82
  const root = await mkdtemp(path.join(parentDir, "akan-cli-"));
83
+ //? The CLI recognises a workspace root by an apps/, libs/ or pkgs/ folder beside package.json and tsconfig.json.
84
+ await mkdir(path.join(root, "apps"));
83
85
  await writeText(path.join(root, ".gitignore"), "");
84
86
  await writeText(
85
87
  path.join(root, ".env"),
@@ -195,7 +195,7 @@ const resolvePackageExport = (exportsMap: Record<string, ExportValue> | undefine
195
195
  if (!subpath.startsWith(prefix) || !subpath.endsWith(suffix)) continue;
196
196
  const wildcard = subpath.slice(prefix.length, subpath.length - suffix.length);
197
197
  const resolved = resolveExportValue(value);
198
- if (resolved) return resolved.replace("*", wildcard);
198
+ if (resolved) return resolved.split("*").join(wildcard);
199
199
  }
200
200
 
201
201
  return null;
@@ -0,0 +1,88 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { realpathSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { tempDirs, writeText } from "../testHelpers";
5
+ import { createBrowserDirnamePlugin } from "./browserDirnamePlugin";
6
+
7
+ const makeTempRoot = tempDirs("akan-devkit-browser-dirname-");
8
+
9
+ const build = async (entry: string) => {
10
+ const result = await Bun.build({
11
+ entrypoints: [entry],
12
+ target: "browser",
13
+ minify: true,
14
+ plugins: [createBrowserDirnamePlugin()],
15
+ });
16
+ expect(result.success).toBe(true);
17
+ return (await Promise.all(result.outputs.map((output) => output.text()))).join("\n");
18
+ };
19
+
20
+ describe("createBrowserDirnamePlugin", () => {
21
+ test("gives a vendor module its path from node_modules on, never the absolute one", async () => {
22
+ const root = await makeTempRoot();
23
+ await writeText(path.join(root, "node_modules/cjsdep/package.json"), '{ "name": "cjsdep", "main": "index.js" }\n');
24
+ await writeText(
25
+ path.join(root, "node_modules/cjsdep/index.js"),
26
+ "module.exports = { dir: __dirname, file: __filename };\n",
27
+ );
28
+ await writeText(
29
+ path.join(root, "node_modules/esmdep/package.json"),
30
+ '{ "name": "esmdep", "type": "module", "main": "index.js" }\n',
31
+ );
32
+ await writeText(
33
+ path.join(root, "node_modules/esmdep/index.js"),
34
+ // biome-ignore lint/suspicious/noTemplateCurlyInString: the fixture module's own source holds a template literal
35
+ 'export const dir = typeof __dirname !== "undefined" ? `${__dirname}/` : "browser";\n',
36
+ );
37
+ await writeText(path.join(root, "node_modules/esmdep/legacy.cjs"), "module.exports = __filename;\n");
38
+ const entry = path.join(root, "entry.ts");
39
+ await writeText(
40
+ entry,
41
+ 'import cjs from "cjsdep";\nimport { dir } from "esmdep";\nimport legacy from "esmdep/legacy.cjs";\nconsole.info(cjs, dir, legacy);\n',
42
+ );
43
+
44
+ const output = await build(entry);
45
+
46
+ expect(output).not.toContain(root);
47
+ expect(output).not.toContain(realpathSync(root));
48
+ expect(output).toContain('"/node_modules/cjsdep"');
49
+ expect(output).toContain('"/node_modules/cjsdep/index.js"');
50
+ expect(output).toContain('"/node_modules/esmdep/"');
51
+ expect(output).toContain('"/node_modules/esmdep/legacy.cjs"');
52
+ });
53
+
54
+ test("leaves a module's own __dirname binding alone", async () => {
55
+ const root = await makeTempRoot();
56
+ await writeText(path.join(root, "node_modules/owndep/package.json"), '{ "name": "owndep", "main": "index.js" }\n');
57
+ await writeText(
58
+ path.join(root, "node_modules/owndep/index.js"),
59
+ 'const __dirname = "own-binding";\nmodule.exports = __dirname;\n',
60
+ );
61
+ const entry = path.join(root, "entry.ts");
62
+ await writeText(entry, 'import own from "owndep";\nconsole.info(own);\n');
63
+
64
+ const output = await build(entry);
65
+
66
+ expect(output).toContain('"own-binding"');
67
+ expect(output).not.toContain("/node_modules/owndep");
68
+ });
69
+
70
+ //? `define` would leak here: Bun applies it to the server modules a macro runs, which then read `/`.
71
+ test("keeps the real __dirname in the modules a macro runs", async () => {
72
+ const root = await makeTempRoot();
73
+ await writeText(path.join(root, "node_modules/fsdep/package.json"), '{ "name": "fsdep", "main": "index.js" }\n');
74
+ await writeText(path.join(root, "node_modules/fsdep/marker.txt"), "beside-me\n");
75
+ await writeText(
76
+ path.join(root, "node_modules/fsdep/index.js"),
77
+ 'const fs = require("node:fs");\nmodule.exports = () => fs.readFileSync(__dirname + "/marker.txt", "utf8").trim();\n',
78
+ );
79
+ await writeText(
80
+ path.join(root, "macro.ts"),
81
+ 'import readMarker from "fsdep";\nexport const marker = () => readMarker();\n',
82
+ );
83
+ const entry = path.join(root, "entry.ts");
84
+ await writeText(entry, 'import { marker } from "./macro" with { type: "macro" };\nconsole.info(marker());\n');
85
+
86
+ expect(await build(entry)).toContain('"beside-me"');
87
+ });
88
+ });
@@ -0,0 +1,25 @@
1
+ import path from "node:path";
2
+ import type { BunPlugin } from "bun";
3
+
4
+ const vendorScript = /[\\/]node_modules[\\/].+\.[cm]?js$/;
5
+
6
+ //* Bun's browser build declares `__dirname` / `__filename` in each module naming them as the absolute path it read the
7
+ //* module from, so a CSR bundle carried the checkout location and its hash moved with it. A vendor module gets its path
8
+ //* from `node_modules` on instead. Not `define`: Bun applies that to the modules a macro imports and runs, where a
9
+ //* server module reading files beside itself would read `/`, while a plugin never sees the macro graph.
10
+ export const createBrowserDirnamePlugin = (): BunPlugin => ({
11
+ name: "akan-browser-dirname",
12
+ setup(build) {
13
+ build.onLoad({ filter: vendorScript }, async (args) => {
14
+ const source = await Bun.file(args.path).text();
15
+ if (!source.includes("__dirname") && !source.includes("__filename")) return undefined;
16
+ const posixPath = args.path.replaceAll("\\", "/");
17
+ const filename = posixPath.slice(posixPath.indexOf("/node_modules/"));
18
+ const define = { __dirname: JSON.stringify(path.posix.dirname(filename)), __filename: JSON.stringify(filename) };
19
+ return {
20
+ loader: "js",
21
+ contents: new Bun.Transpiler({ loader: "js", target: "browser", define }).transformSync(source),
22
+ };
23
+ });
24
+ },
25
+ });
@@ -2,9 +2,9 @@ import path from "node:path";
2
2
  import ts from "typescript";
3
3
  import { loaderFor } from "./moduleSyntax";
4
4
 
5
- const USE_CLIENT_RE = /^\s*(?:\/\*[\s\S]*?\*\/\s*|\/\/[^\n]*\n\s*)*["']use client["']/;
5
+ const USE_CLIENT_RE = /^\s*(?:\/\*[^*]*\*+(?:[^/*][^*]*\*+)*\/\s*|\/\/[^\n]*\n\s*)*["']use client["']/;
6
6
  const IMPLICIT_ROOT_LAYOUT_RE =
7
- /[/\\]\.akan[/\\]generated[/\\](?:implicit-root-layout|root-layouts[/\\].*__root_layout)\.(tsx|ts|jsx|js)$/;
7
+ /[/\\]\.akan[/\\]generated[/\\](?:implicit-root-layout|root-layouts[/\\][^/\\]*__root_layout)\.(tsx|ts|jsx|js)$/;
8
8
  const STAR_REEXPORT_HINT_RE = /\bexport\s*\*\s*from\b/;
9
9
 
10
10
  export interface UseClientTransformArgs {
@@ -4,7 +4,12 @@ import { ApplicationBuildReporter } from "../applicationBuildReporter";
4
4
  import { tempDirs, writeText as write } from "../testHelpers";
5
5
  import { BarrelAnalyzer, type BarrelExportTarget } from "./barrelAnalyzer";
6
6
  import { rewriteBarrelImports } from "./barrelImportsPlugin";
7
- import { scanUseClientExports, toClientReferencePath, transformUseClient } from "./rscUseClientTransform";
7
+ import {
8
+ hasUseClientDirective,
9
+ scanUseClientExports,
10
+ toClientReferencePath,
11
+ transformUseClient,
12
+ } from "./rscUseClientTransform";
8
13
  import { createUseClientBundlePlugin } from "./useClientBundlePlugin";
9
14
 
10
15
  const makeTempRoot = tempDirs("akan-devkit-transform-");
@@ -27,6 +32,12 @@ describe("transformUseClient", () => {
27
32
  ).toBeNull();
28
33
  });
29
34
 
35
+ test("reads the directive through the leading comments only, in linear time", () => {
36
+ expect(hasUseClientDirective('/* a */\n// b /* c\n/** d **/\n"use client";')).toBe(true);
37
+ expect(hasUseClientDirective('/* a */ x /* b */ "use client";')).toBe(false);
38
+ expect(hasUseClientDirective(`${"/* a */\n".repeat(40)}export const value = 1;`)).toBe(false);
39
+ });
40
+
30
41
  test("a client module with no exports stays server code and is not reported as a client module", () => {
31
42
  const reported: string[] = [];
32
43
  const onClientModule = (file: string) => reported.push(file);
@@ -93,7 +104,12 @@ describe('export * in a "use client" module', () => {
93
104
  "export const text = \"export * from './a string'\";",
94
105
  "",
95
106
  ].join("\n");
96
- expect(scanUseClientExports(shapes, file).sort()).toEqual(["LeaveInfo", "Voc", "ns", "text"]);
107
+ expect(scanUseClientExports(shapes, file).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))).toEqual([
108
+ "LeaveInfo",
109
+ "Voc",
110
+ "ns",
111
+ "text",
112
+ ]);
97
113
  expect(scanUseClientExports('export * from "./a";\nexport const X = 1;\n', file)).toEqual(["X"]);
98
114
  const stub = transformUseClient(shapes, { path: file, workspaceRoot: "/repo" });
99
115
  expect(stub).toContain("export const LeaveInfo = registerClientReference");
@@ -167,6 +167,15 @@ describe("createTsconfigPackageResolver — node_modules fallback", () => {
167
167
  expect((await resolve("akanjs/signal"))?.entryFile).toBe(entryFile);
168
168
  });
169
169
 
170
+ test("substitutes every * of a wildcard target, as Node does", async () => {
171
+ const root = await makeTempRoot();
172
+ await writePackageJson(root, "akanjs", { exports: { "./*": "./*/*.js" } });
173
+ const entryFile = await write(root, "node_modules/akanjs/signal/signal.js");
174
+ const resolve = await resolverFor(root, {});
175
+
176
+ expect((await resolve("akanjs/signal"))?.entryFile).toBe(entryFile);
177
+ });
178
+
170
179
  test("resolves a scoped package subpath", async () => {
171
180
  // Vendor-neutral on purpose: `packageExports.test.ts` reads every `"@akanjs/devkit/<x>"` literal here as an import.
172
181
  const root = await makeTempRoot();
package/types.ts CHANGED
@@ -44,4 +44,5 @@ export interface BaseDevEnv {
44
44
  env: "testing" | "debug" | "develop" | "main" | "local";
45
45
  portOffset: number;
46
46
  appName?: string | undefined;
47
+ defaulted?: ("AKAN_PUBLIC_REPO_NAME" | "AKAN_PUBLIC_SERVE_DOMAIN")[];
47
48
  }
@@ -205,7 +205,7 @@ const syncStateSlug = (target: string) =>
205
205
  target
206
206
  .trim()
207
207
  .replace(/[^a-zA-Z0-9]+/g, "-")
208
- .replace(/^-+|-+$/g, "")
208
+ .replace(/^-|-$/g, "")
209
209
  .toLowerCase();
210
210
 
211
211
  export const workflowSyncStatePath = (target: string) => `${workflowSyncDir}/${syncStateSlug(target) || "target"}.json`;
@@ -56,7 +56,11 @@ describe("tooling rollout gate", () => {
56
56
  }),
57
57
  );
58
58
 
59
- expect(violations.map((violation) => `${violation.packageName}:${violation.status}`).sort()).toEqual([
59
+ expect(
60
+ violations
61
+ .map((violation) => `${violation.packageName}:${violation.status}`)
62
+ .sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)),
63
+ ).toEqual([
60
64
  "@ttsc/graph:reference-only",
61
65
  "ast-grep:experiment-only",
62
66
  "recast:experiment-only",
@@ -32,7 +32,7 @@ export const sourceFile = (sys: Sys, path: string, action: PrimitiveChangedFile[
32
32
  export const moduleComponentName = (moduleName: string) =>
33
33
  moduleName
34
34
  .replace(/[-_]+/g, " ")
35
- .replace(/(?:^|\s+)([a-zA-Z0-9])/g, (_, char: string) => char.toUpperCase())
35
+ .replace(/(?<=^|\s)[a-zA-Z0-9]/g, (char) => char.toUpperCase())
36
36
  .replace(/\s+/g, "");
37
37
 
38
38
  export const moduleSourcePaths = (moduleName: string) => {
@@ -768,7 +768,7 @@ export const insertTemplateField = ({
768
768
 
769
769
  export const ensureEnumImport = (content: string) => {
770
770
  if (content.includes("enumOf")) return content;
771
- const baseImport = /import \{ ([^}]+) \} from "akanjs\/base";/.exec(content);
771
+ const baseImport = /import \{ ([^{}]+) \} from "akanjs\/base";/.exec(content);
772
772
  if (baseImport) {
773
773
  const names = baseImport[1]?.split(",").map((name) => name.trim()) ?? [];
774
774
  return content.replace(baseImport[0], `import { ${[...names, "enumOf"].sort().join(", ")} } from "akanjs/base";`);