@akanjs/devkit 3.0.0-beta.2 → 3.0.0-beta.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 (289) hide show
  1. package/README.ko.md +2 -2
  2. package/README.md +3 -2
  3. package/abstractDoc.test.ts +6 -15
  4. package/abstractDoc.ts +6 -8
  5. package/agentsIndex.test.ts +4 -7
  6. package/agentsIndex.ts +6 -22
  7. package/akanApp/BackendImportGraph.test.ts +8 -22
  8. package/akanApp/BackendImportGraph.ts +1 -20
  9. package/akanApp/akanApp.host.test.ts +1 -3
  10. package/akanApp/akanApp.host.ts +82 -254
  11. package/akanApp/devHostPolicy.test.ts +10 -34
  12. package/akanApp/devHostPolicy.ts +12 -98
  13. package/akanConfig/akanConfig.test.ts +142 -106
  14. package/akanConfig/akanConfig.ts +147 -191
  15. package/akanConfig/types.ts +4 -3
  16. package/akanContext.ts +110 -160
  17. package/akanMcpContract.ts +28 -73
  18. package/appSelectionMemory.ts +1 -4
  19. package/applicationBuildReporter.ts +37 -5
  20. package/applicationBuildRunner.test.ts +5 -3
  21. package/applicationBuildRunner.ts +54 -61
  22. package/applicationTestPreload.ts +3 -12
  23. package/artifact/implicitRootLayout.test.ts +32 -30
  24. package/artifact/implicitRootLayout.ts +58 -106
  25. package/artifact/routeSeedIndex.test.ts +7 -6
  26. package/artifact/routeSeedIndex.ts +11 -44
  27. package/biome.base.json +32 -1
  28. package/biomeBase.ts +2 -5
  29. package/cloud/cloudApi.test.ts +202 -0
  30. package/cloud/cloudApi.ts +77 -39
  31. package/cloud/configLock.test.ts +64 -0
  32. package/cloud/configLock.ts +96 -0
  33. package/cloud/constants.ts +26 -5
  34. package/cloud/globalConfig.ts +70 -32
  35. package/codeAgent/agent/AkanCodeServices.ts +97 -0
  36. package/codeAgent/agent/AkanContextFiles.ts +94 -0
  37. package/codeAgent/agent/CodeAgent.ts +551 -0
  38. package/codeAgent/agent/CodeAgentAsks.ts +66 -0
  39. package/codeAgent/agent/CodeAgentEventMapper.ts +225 -0
  40. package/codeAgent/agent/CodeAgentGate.ts +93 -0
  41. package/codeAgent/agent/CodeAgentProxy.ts +82 -0
  42. package/codeAgent/agent/CodeAgentRpcHost.ts +103 -0
  43. package/codeAgent/agent/CodeAgentRpcListener.ts +109 -0
  44. package/codeAgent/agent/CodeAgentStreamPrinter.ts +86 -0
  45. package/codeAgent/agent/CodeAgentSuspended.ts +85 -0
  46. package/codeAgent/agent/CodeAgentUi.ts +47 -0
  47. package/codeAgent/agent/CodeMailbox.ts +148 -0
  48. package/codeAgent/agent/CodeSessionFork.ts +56 -0
  49. package/codeAgent/agent/CodeSessionIndex.ts +78 -0
  50. package/codeAgent/agent/SubagentPool.ts +214 -0
  51. package/codeAgent/agent/akanCodeModel.ts +47 -0
  52. package/codeAgent/agent/akanCodePaths.ts +31 -0
  53. package/codeAgent/agent/akanContextFiles.test.ts +114 -0
  54. package/codeAgent/agent/akanSystemPrompt.ts +46 -0
  55. package/codeAgent/agent/codeAgent.test.ts +178 -0
  56. package/codeAgent/agent/codeAgentContract.test.ts +523 -0
  57. package/codeAgent/agent/codeAgentProxy.test.ts +74 -0
  58. package/codeAgent/agent/codeAgentRpcListener.test.ts +113 -0
  59. package/codeAgent/agent/codeAgentSuspended.test.ts +47 -0
  60. package/codeAgent/agent/codeMailbox.test.ts +76 -0
  61. package/codeAgent/agent/codeSessionFork.test.ts +58 -0
  62. package/codeAgent/agent/codeSessionIndex.test.ts +77 -0
  63. package/codeAgent/agent/consoleToStderr.ts +8 -0
  64. package/codeAgent/agent/jsonLines.ts +29 -0
  65. package/codeAgent/agent/stringArg.ts +5 -0
  66. package/codeAgent/feedback/DevLogFeedback.ts +82 -0
  67. package/codeAgent/feedback/PreviewChrome.ts +82 -0
  68. package/codeAgent/feedback/PreviewFeedback.ts +46 -0
  69. package/codeAgent/feedback/PreviewView.ts +95 -0
  70. package/codeAgent/feedback/TurnFeedback.ts +98 -0
  71. package/codeAgent/feedback/VerifyFeedback.ts +40 -0
  72. package/codeAgent/feedback/previewChrome.test.ts +73 -0
  73. package/codeAgent/index.ts +34 -0
  74. package/codeAgent/skills/akan-code/SKILL.md +130 -0
  75. package/codeAgent/skills/akan-debug/SKILL.md +42 -0
  76. package/codeAgent/skills/akan-deps/SKILL.md +50 -0
  77. package/codeAgent/skills/akan-err/SKILL.md +39 -0
  78. package/codeAgent/skills/akan-module/SKILL.md +45 -0
  79. package/codeAgent/skills/akan-scaffold/SKILL.md +76 -0
  80. package/codeAgent/skills/akan-scalar/SKILL.md +52 -0
  81. package/codeAgent/skills/akan-service/SKILL.md +34 -0
  82. package/codeAgent/skills/akan-store/SKILL.md +67 -0
  83. package/codeAgent/skills/akan-ui/SKILL.md +63 -0
  84. package/codeAgent/skills/akan-validate/SKILL.md +44 -0
  85. package/codeAgent/tools/AkanCli.ts +50 -0
  86. package/codeAgent/tools/AkanCodePlugins.ts +136 -0
  87. package/codeAgent/tools/AkanEditScope.ts +48 -0
  88. package/codeAgent/tools/AkanEnvKeys.ts +50 -0
  89. package/codeAgent/tools/AkanToolPack.ts +92 -0
  90. package/codeAgent/tools/AkanVerifier.ts +48 -0
  91. package/codeAgent/tools/AskToolPack.ts +99 -0
  92. package/codeAgent/tools/MailToolPack.ts +76 -0
  93. package/codeAgent/tools/McpClient.ts +187 -0
  94. package/codeAgent/tools/McpOAuth.ts +233 -0
  95. package/codeAgent/tools/McpOAuthLoopback.ts +64 -0
  96. package/codeAgent/tools/McpServerConfig.ts +129 -0
  97. package/codeAgent/tools/McpSignIn.ts +158 -0
  98. package/codeAgent/tools/McpTokenStore.ts +81 -0
  99. package/codeAgent/tools/McpToolPack.ts +199 -0
  100. package/codeAgent/tools/SessionToolPack.ts +91 -0
  101. package/codeAgent/tools/WebToolPack.ts +147 -0
  102. package/codeAgent/tools/akanCodePlugins.test.ts +677 -0
  103. package/codeAgent/tools/askToolPack.test.ts +116 -0
  104. package/codeAgent/tools/mcpOAuth.test.ts +612 -0
  105. package/codeAgent/tools/mcpServerConfig.test.ts +212 -0
  106. package/codegenLock.test.ts +4 -13
  107. package/codegenLock.ts +3 -17
  108. package/commandDecorators/argMeta.ts +1 -5
  109. package/commandDecorators/camelToKebabCase.ts +1 -0
  110. package/commandDecorators/command.ts +46 -102
  111. package/commandDecorators/commandBuilder.ts +30 -61
  112. package/commandDecorators/commandDecorators.test.ts +2 -10
  113. package/commandDecorators/dependencyBuilder.ts +10 -1
  114. package/commandDecorators/helpFormatter.ts +68 -145
  115. package/commandDecorators/index.ts +0 -2
  116. package/commandDecorators/targetMeta.ts +9 -7
  117. package/createTunnel.ts +7 -14
  118. package/dependencyScanner.test.ts +4 -16
  119. package/dependencyScanner.ts +31 -80
  120. package/devkitUtils.test.ts +48 -20
  121. package/executors.linkFallback.test.ts +103 -0
  122. package/executors.test.ts +132 -106
  123. package/executors.ts +195 -496
  124. package/fileEditor.ts +9 -37
  125. package/{getDirname.test.ts → fileSys.test.ts} +5 -3
  126. package/fileSys.ts +6 -8
  127. package/formSetterScanner.ts +3 -16
  128. package/frontendBuild/allRoutesBuilder.ts +7 -21
  129. package/frontendBuild/autoImportSync.test.ts +27 -38
  130. package/frontendBuild/autoImportSync.ts +31 -65
  131. package/frontendBuild/buildRouteClient.test.ts +62 -37
  132. package/frontendBuild/bundleDefine.ts +12 -0
  133. package/frontendBuild/clientBuildTypes.ts +5 -36
  134. package/frontendBuild/clientEntriesBundler.ts +9 -40
  135. package/frontendBuild/clientEntryDiscovery.ts +6 -41
  136. package/frontendBuild/csrArtifactBuilder.ts +6 -25
  137. package/frontendBuild/cssCandidateCache.test.ts +0 -6
  138. package/frontendBuild/cssCandidateCache.ts +6 -41
  139. package/frontendBuild/cssCompiler.ts +29 -78
  140. package/frontendBuild/cssImportResolver.ts +6 -19
  141. package/frontendBuild/devChangePlanner.ts +7 -10
  142. package/frontendBuild/devGeneratedIndexSync.test.ts +12 -27
  143. package/frontendBuild/devGeneratedIndexSync.ts +1 -4
  144. package/frontendBuild/fontOptimizer.test.ts +6 -14
  145. package/frontendBuild/fontOptimizer.ts +15 -72
  146. package/frontendBuild/fontPruner.test.ts +5 -10
  147. package/frontendBuild/fontPruner.ts +5 -20
  148. package/frontendBuild/frontendBuild.test.ts +46 -69
  149. package/frontendBuild/hmrChangeClassifier.ts +2 -2
  150. package/frontendBuild/hmrWatcher.test.ts +8 -32
  151. package/frontendBuild/hmrWatcher.ts +14 -71
  152. package/frontendBuild/pagesBundleBuilder.ts +4 -29
  153. package/frontendBuild/precompressArtifacts.ts +5 -18
  154. package/frontendBuild/routeClientBuilder.ts +23 -46
  155. package/frontendBuild/routesManifestArtifactSerializer.ts +5 -36
  156. package/frontendBuild/sourceMtimeIndex.test.ts +41 -97
  157. package/frontendBuild/sourceMtimeIndex.ts +19 -130
  158. package/frontendBuild/ssrBaseArtifactBuilder.ts +2 -8
  159. package/frontendBuild/styleContract.ts +1 -8
  160. package/frontendBuild/themeValidator.ts +4 -26
  161. package/frontendBuild/watchRootResolver.test.ts +23 -42
  162. package/frontendBuild/watchRootResolver.ts +5 -27
  163. package/getRelatedCnsts.ts +12 -92
  164. package/incrementalBuilder/buildBatch.proc.ts +29 -58
  165. package/incrementalBuilder/buildBatchProtocol.ts +8 -36
  166. package/incrementalBuilder/buildBatchRunner.test.ts +34 -0
  167. package/incrementalBuilder/buildBatchRunner.ts +10 -31
  168. package/incrementalBuilder/builderChannel.test.ts +24 -49
  169. package/incrementalBuilder/builderChannel.ts +6 -37
  170. package/incrementalBuilder/builderRequestRouter.test.ts +0 -8
  171. package/incrementalBuilder/builderRequestRouter.ts +5 -24
  172. package/incrementalBuilder/devWatchBatch.test.ts +21 -24
  173. package/incrementalBuilder/incrementalBuilder.host.test.ts +18 -55
  174. package/incrementalBuilder/incrementalBuilder.host.ts +18 -76
  175. package/incrementalBuilder/incrementalBuilder.proc.test.ts +81 -0
  176. package/incrementalBuilder/incrementalBuilder.proc.ts +49 -161
  177. package/index.ts +5 -19
  178. package/integration/devResourceProbe.ts +50 -56
  179. package/integration/devStability.integration.test.ts +47 -194
  180. package/integration/devStabilityHarness.test.ts +13 -28
  181. package/integration/devStabilityHarness.ts +65 -330
  182. package/integration/ssrMemoryProbe.ts +41 -167
  183. package/libSource.test.ts +4 -15
  184. package/libSource.ts +19 -31
  185. package/lint/__fixtures__/no-document-cookie/bad.tsx +4 -0
  186. package/lint/__fixtures__/no-document-cookie/good.tsx +7 -0
  187. package/lint/__fixtures__/no-web-only-api-outside-webkit/bad.tsx +7 -0
  188. package/lint/__fixtures__/no-web-only-api-outside-webkit/good.tsx +11 -0
  189. package/lint/__fixtures__/no-web-storage/bad.tsx +6 -0
  190. package/lint/__fixtures__/no-web-storage/good.tsx +7 -0
  191. package/lint/gritRules.test.ts +8 -24
  192. package/lint/no-deep-internal-import.grit +2 -2
  193. package/lint/no-document-cookie.grit +29 -0
  194. package/lint/no-redeclare-predefined-endpoint.grit +2 -1
  195. package/lint/no-web-only-api-outside-webkit.grit +44 -0
  196. package/lint/no-web-storage.grit +30 -0
  197. package/linter.test.ts +1 -2
  198. package/linter.ts +26 -116
  199. package/mobile/index.ts +6 -0
  200. package/mobile/mobileTarget.ts +16 -13
  201. package/mobile/nativeApi.test.ts +30 -0
  202. package/mobile/nativeApi.ts +40 -0
  203. package/mobile/nativeApp.test.ts +85 -0
  204. package/mobile/nativeApp.ts +210 -0
  205. package/mobile/nativeConfig.test.ts +199 -0
  206. package/mobile/nativeConfig.ts +184 -0
  207. package/mobile/nativeWebDir.test.ts +80 -0
  208. package/mobile/nativeWebDir.ts +62 -0
  209. package/mobile/placeholderAppId.test.ts +13 -0
  210. package/mobile/placeholderAppId.ts +16 -0
  211. package/mobile/usageDescriptions.test.ts +27 -0
  212. package/mobile/usageDescriptions.ts +24 -0
  213. package/package.json +10 -10
  214. package/packageExports.test.ts +3 -18
  215. package/packageExportsMap.ts +3 -19
  216. package/piDependencyPins.test.ts +46 -0
  217. package/platformTest/BunTestReport.test.ts +60 -0
  218. package/platformTest/BunTestReport.ts +65 -0
  219. package/platformTest/IdleWatchedProcess.test.ts +56 -0
  220. package/platformTest/IdleWatchedProcess.ts +73 -0
  221. package/platformTest/LinuxDockerTarget.ts +105 -0
  222. package/platformTest/PlatformTestRun.test.ts +64 -0
  223. package/platformTest/PlatformTestRun.ts +141 -0
  224. package/platformTest/PlatformTestTarget.ts +142 -0
  225. package/platformTest/TestSnapshot.test.ts +72 -0
  226. package/platformTest/TestSnapshot.ts +103 -0
  227. package/platformTest/WindowsSshTarget.ts +151 -0
  228. package/prompter.ts +3 -13
  229. package/qualityScanner.test.ts +9 -26
  230. package/qualityScanner.ts +200 -286
  231. package/recipeScanner.test.ts +7 -12
  232. package/recipeScanner.ts +9 -38
  233. package/repoIdentity.ts +2 -9
  234. package/routeSourceValidator.test.ts +0 -1
  235. package/routeSourceValidator.ts +7 -42
  236. package/scanInfo.ts +91 -225
  237. package/selectModel.ts +1 -2
  238. package/semver.ts +3 -5
  239. package/slicePlanner.test.ts +6 -24
  240. package/slicePlanner.ts +11 -32
  241. package/spinner.test.ts +3 -8
  242. package/spinner.ts +4 -16
  243. package/sshTunnel.ts +1 -6
  244. package/ssrScanner.test.ts +1 -6
  245. package/ssrScanner.ts +103 -113
  246. package/stripAnsi.ts +3 -0
  247. package/subspace.test.ts +30 -25
  248. package/subspace.ts +81 -115
  249. package/subspaceConfig.ts +11 -9
  250. package/testHelpers.ts +194 -0
  251. package/transforms/asyncDefaultExportDetector.ts +3 -11
  252. package/transforms/barrelAnalyzer.ts +43 -135
  253. package/transforms/barrelImportsPlugin.ts +38 -175
  254. package/transforms/externalizeFrameworkPlugin.test.ts +39 -118
  255. package/transforms/externalizeFrameworkPlugin.ts +13 -87
  256. package/transforms/moduleSyntax.ts +32 -0
  257. package/transforms/rscUseClientTransform.ts +35 -20
  258. package/transforms/transforms.test.ts +153 -44
  259. package/transforms/tsconfigPackageResolver.test.ts +6 -24
  260. package/transforms/useClientBundlePlugin.ts +2 -22
  261. package/typeChecker.ts +7 -63
  262. package/types.ts +1 -0
  263. package/uploadRelease.ts +10 -25
  264. package/workflow/artifacts.ts +18 -0
  265. package/workflow/executor.test.ts +1 -12
  266. package/workflow/executor.ts +101 -110
  267. package/workflow/index.ts +0 -1
  268. package/workflow/moduleIndex.test.ts +4 -15
  269. package/workflow/moduleIndex.ts +9 -49
  270. package/workflow/plan.ts +18 -25
  271. package/workflow/render.ts +80 -96
  272. package/workflow/rolloutGate.test.ts +1 -1
  273. package/workflow/source.ts +52 -69
  274. package/workflow/sourceAst.ts +50 -0
  275. package/workspaceLayout.test.ts +13 -3
  276. package/workspaceLayout.ts +20 -23
  277. package/abstractCompactor.ts +0 -112
  278. package/aiEditor.test.ts +0 -60
  279. package/aiEditor.ts +0 -387
  280. package/applicationReleasePackager.ts +0 -209
  281. package/capacitorApp.test.ts +0 -372
  282. package/capacitorApp.ts +0 -1648
  283. package/commandDecorators/commandMeta.ts +0 -7
  284. package/commandDecorators/types.ts +0 -10
  285. package/getDirname.ts +0 -4
  286. package/guideline.ts +0 -19
  287. package/local/optimize-resource/ipcprobe/child.ts +0 -1
  288. package/local/optimize-resource/ipcprobe/parent.ts +0 -11
  289. package/workflow/primitive.ts +0 -59
@@ -0,0 +1,105 @@
1
+ import os from "node:os";
2
+ import path from "node:path";
3
+ import type { LinuxTestTargetConfig, PlatformGatePolicy } from "../cloud/constants";
4
+ import { IdleWatchedProcess } from "./IdleWatchedProcess";
5
+ import { type PlatformRunContext, PlatformTestTarget } from "./PlatformTestTarget";
6
+
7
+ export class LinuxDockerTarget extends PlatformTestTarget {
8
+ static readonly cacheVolume = "akan-test-bun-cache";
9
+
10
+ readonly platform = "linux";
11
+ readonly policy: PlatformGatePolicy;
12
+ readonly #config: LinuxTestTargetConfig;
13
+ #container: string | null = null;
14
+
15
+ constructor(config: LinuxTestTargetConfig = {}) {
16
+ super();
17
+ this.#config = config;
18
+ this.policy = config.policy ?? "gate";
19
+ }
20
+
21
+ //* No compiler in the image: with one, ssh2 builds its native addon and Bun panics loading it (oven-sh/bun#18546).
22
+ static readonly dockerfile = [
23
+ `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
+ ].join("\n");
26
+
27
+ get image() {
28
+ return `akan-test-linux:bun-${Bun.version}-${Bun.hash(LinuxDockerTarget.dockerfile).toString(16).slice(0, 8)}`;
29
+ }
30
+
31
+ protected async prepare(context: PlatformRunContext) {
32
+ if ((await LinuxDockerTarget.spawnText(["docker", "info"])).exitCode !== 0)
33
+ throw new Error("Docker is not running — start Docker Desktop, or leave linux out of --platforms");
34
+ await this.#ensureImage();
35
+ const container = `akan-test-${context.runId}`;
36
+ const cpus = this.#config.cpus ?? Math.max(2, Math.floor(os.availableParallelism() / 2));
37
+ const started = await LinuxDockerTarget.spawnText([
38
+ "docker",
39
+ "run",
40
+ "-d",
41
+ "--rm",
42
+ "--name",
43
+ container,
44
+ "--cpus",
45
+ String(cpus),
46
+ ...(this.#config.memory ? ["--memory", this.#config.memory] : []),
47
+ "-v",
48
+ `${LinuxDockerTarget.cacheVolume}:/root/.bun/install/cache`,
49
+ "-v",
50
+ `${path.dirname(context.snapshot.tarPath)}:/io:ro`,
51
+ "-v",
52
+ `${context.envPath}:/io-env:ro`,
53
+ "-w",
54
+ "/w",
55
+ this.image,
56
+ "sleep",
57
+ "infinity",
58
+ ]);
59
+ if (started.exitCode !== 0) throw new Error(`docker run failed: ${started.stderr.trim()}`);
60
+ this.#container = container;
61
+ const extract = await this.exec(
62
+ `tar -xf /io/${path.basename(context.snapshot.tarPath)} -C /w && cp /io-env /w/.env && git -C /w init -q`,
63
+ path.join(context.logDir, this.platform, "extract.log"),
64
+ PlatformTestTarget.installIdleMs,
65
+ );
66
+ if (extract.exitCode !== 0) throw new Error("extracting the snapshot into the container failed");
67
+ }
68
+
69
+ protected async exec(command: string, logPath: string, idleMs: number) {
70
+ const container = this.#container;
71
+ if (!container) throw new Error("the linux container is not running");
72
+ return await new IdleWatchedProcess(
73
+ [
74
+ "docker",
75
+ "exec",
76
+ ...Object.entries(PlatformTestTarget.gitIdentityEnv).flatMap(([key, value]) => ["-e", `${key}=${value}`]),
77
+ container,
78
+ "sh",
79
+ "-c",
80
+ command,
81
+ ],
82
+ {
83
+ logPath,
84
+ idleMs,
85
+ onIdle: async () => {
86
+ await LinuxDockerTarget.spawnText(["docker", "exec", container, "pkill", "-9", "bun"]);
87
+ },
88
+ },
89
+ ).run();
90
+ }
91
+
92
+ protected async cleanup() {
93
+ if (this.#container) await LinuxDockerTarget.spawnText(["docker", "rm", "-f", this.#container]);
94
+ this.#container = null;
95
+ }
96
+
97
+ async #ensureImage() {
98
+ if ((await LinuxDockerTarget.spawnText(["docker", "image", "inspect", this.image])).exitCode === 0) return;
99
+ const built = await LinuxDockerTarget.spawnText(
100
+ ["docker", "build", "-t", this.image, "-"],
101
+ LinuxDockerTarget.dockerfile,
102
+ );
103
+ if (built.exitCode !== 0) throw new Error(`building ${this.image} failed: ${built.stderr.trim().slice(-400)}`);
104
+ }
105
+ }
@@ -0,0 +1,64 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { writeFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { tempDirs } from "../testHelpers";
5
+ import { BunTestReport } from "./BunTestReport";
6
+ import { PlatformTestRun } from "./PlatformTestRun";
7
+ import type { PackageTestResult, PlatformTestResult } from "./PlatformTestTarget";
8
+
9
+ const makeRoot = tempDirs("akan-platform-env-");
10
+
11
+ const pkg = (status: PackageTestResult["status"]): PackageTestResult => ({
12
+ pkg: "akanjs",
13
+ status,
14
+ report: new BunTestReport(""),
15
+ ms: 1,
16
+ logPath: "x.log",
17
+ });
18
+ const result = (
19
+ platform: PlatformTestResult["platform"],
20
+ policy: PlatformTestResult["policy"],
21
+ packages: PackageTestResult[],
22
+ infraError: string | null = null,
23
+ ): PlatformTestResult => ({ platform, policy, packages, envWarnings: [], infraError, ms: 1 });
24
+
25
+ describe("PlatformTestRun", () => {
26
+ test("a failing gate blocks, a failing warn only warns, and a setup failure counts as a failure", () => {
27
+ const verdict = PlatformTestRun.verdict([
28
+ result("linux", "gate", [pkg("pass"), pkg("fail")]),
29
+ result("windows", "warn", [pkg("pass")], "cannot reach user@host over ssh"),
30
+ ]);
31
+
32
+ expect(verdict.blocking.map((one) => one.platform)).toEqual(["linux"]);
33
+ expect(verdict.warning.map((one) => one.platform)).toEqual(["windows"]);
34
+ });
35
+
36
+ test("a hung package fails its platform", () => {
37
+ expect(PlatformTestRun.failed(result("windows", "gate", [pkg("pass"), pkg("hung")]))).toBe(true);
38
+ expect(PlatformTestRun.failed(result("windows", "gate", [pkg("pass")]))).toBe(false);
39
+ });
40
+
41
+ test("hands a test host only the env keys the CLI needs, never the secrets beside them", async () => {
42
+ const root = await makeRoot();
43
+ await writeFile(
44
+ path.join(root, ".env"),
45
+ ["USE_AKANJS_PKGS=true", "OPENAI_API_KEY=sk-secret", 'AKAN_PUBLIC_SERVE_DOMAIN="akanjs.com"', "JEV_KEY=x"].join(
46
+ "\n",
47
+ ),
48
+ );
49
+
50
+ expect(await PlatformTestRun.allowlistedEnv(root)).toBe(
51
+ 'USE_AKANJS_PKGS=true\nAKAN_PUBLIC_SERVE_DOMAIN="akanjs.com"\n',
52
+ );
53
+ });
54
+
55
+ test("reads a comma-separated platform list, where none means no remote platform", () => {
56
+ expect(PlatformTestRun.parsePlatforms("linux, Windows,linux")).toEqual(["linux", "windows"]);
57
+ expect(PlatformTestRun.parsePlatforms("none")).toEqual([]);
58
+ expect(() => PlatformTestRun.parsePlatforms("linux,mac")).toThrow("mac");
59
+ });
60
+
61
+ test("run ids sort by time", () => {
62
+ expect(PlatformTestRun.newRunId(new Date(2026, 8, 26, 9, 5, 7))).toBe("20260926-090507");
63
+ });
64
+ });
@@ -0,0 +1,141 @@
1
+ import path from "node:path";
2
+ import chalk from "chalk";
3
+ import type { TestTargetsConfig } from "../cloud/constants";
4
+ import { GlobalConfig } from "../cloud/globalConfig";
5
+ import { LinuxDockerTarget } from "./LinuxDockerTarget";
6
+ import { type PlatformTestResult, PlatformTestTarget, type RemoteTestPlatform } from "./PlatformTestTarget";
7
+ import { TestSnapshot } from "./TestSnapshot";
8
+ import { WindowsSshTarget } from "./WindowsSshTarget";
9
+
10
+ interface PlatformTestRunOptions {
11
+ workspaceRoot: string;
12
+ platforms: RemoteTestPlatform[];
13
+ pkgs: string[];
14
+ targets: TestTargetsConfig;
15
+ onProgress: (message: string) => void;
16
+ }
17
+
18
+ export interface PlatformVerdict {
19
+ blocking: PlatformTestResult[];
20
+ warning: PlatformTestResult[];
21
+ }
22
+
23
+ export class PlatformTestRun {
24
+ //? The CLI refuses a root without these, and the cli suite builds temp workspaces from them; none is a secret.
25
+ static readonly envKeys = [
26
+ "USE_AKANJS_PKGS",
27
+ "AKAN_PUBLIC_REPO_NAME",
28
+ "AKAN_PUBLIC_SERVE_DOMAIN",
29
+ "AKAN_PUBLIC_ENV",
30
+ "AKAN_PUBLIC_OPERATION_MODE",
31
+ "APP_OPERATION_MODE",
32
+ "SERVER_MODE",
33
+ ] as const;
34
+ static readonly promoteAfterGreenRuns = 3;
35
+
36
+ readonly runId: string;
37
+ readonly logDir: string;
38
+ readonly snapshot: TestSnapshot;
39
+ readonly #results: Promise<PlatformTestResult[]>;
40
+
41
+ constructor(runId: string, logDir: string, snapshot: TestSnapshot, results: Promise<PlatformTestResult[]>) {
42
+ this.runId = runId;
43
+ this.logDir = logDir;
44
+ this.snapshot = snapshot;
45
+ this.#results = results;
46
+ }
47
+
48
+ static async start({ workspaceRoot, platforms, pkgs, targets, onProgress }: PlatformTestRunOptions) {
49
+ const runId = PlatformTestRun.newRunId();
50
+ const logDir = path.join(workspaceRoot, "local", "deploy-test", runId);
51
+ const snapshot = await TestSnapshot.create(workspaceRoot, logDir);
52
+ const envPath = path.join(logDir, "env");
53
+ await Bun.write(envPath, await PlatformTestRun.allowlistedEnv(workspaceRoot));
54
+ const instances = platforms.map((platform) => {
55
+ if (platform === "linux") return new LinuxDockerTarget(targets.linux);
56
+ if (!targets.windows) throw new Error("the windows test target is not configured");
57
+ return new WindowsSshTarget(targets.windows);
58
+ });
59
+ const results = Promise.all(
60
+ instances.map((target) => target.run({ snapshot, envPath, runId, logDir, pkgs, onProgress })),
61
+ );
62
+ return new PlatformTestRun(runId, logDir, snapshot, results);
63
+ }
64
+
65
+ async results() {
66
+ return await this.#results;
67
+ }
68
+
69
+ static parsePlatforms(value: string): RemoteTestPlatform[] {
70
+ const names = value
71
+ .split(",")
72
+ .map((name) => name.trim().toLowerCase())
73
+ .filter((name) => name && name !== "none");
74
+ const unknown = names.filter((name) => name !== "linux" && name !== "windows");
75
+ if (unknown.length)
76
+ throw new Error(`Unknown test platform: ${unknown.join(", ")} (expected linux, windows, or none)`);
77
+ return [...new Set(names as RemoteTestPlatform[])];
78
+ }
79
+
80
+ static newRunId(now = new Date()) {
81
+ const pad = (value: number) => String(value).padStart(2, "0");
82
+ return `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
83
+ }
84
+
85
+ static async allowlistedEnv(workspaceRoot: string) {
86
+ const envFile = Bun.file(path.join(workspaceRoot, ".env"));
87
+ const lines = (await envFile.exists()) ? (await envFile.text()).split(/\r?\n/) : [];
88
+ const allowed = new Set<string>(PlatformTestRun.envKeys);
89
+ const kept = lines.filter((line) => allowed.has(line.split("=", 1)[0]?.trim() ?? ""));
90
+ return `${kept.join("\n")}\n`;
91
+ }
92
+
93
+ static failed(result: PlatformTestResult) {
94
+ return !!result.infraError || result.packages.some((pkg) => pkg.status !== "pass");
95
+ }
96
+
97
+ static verdict(results: PlatformTestResult[]): PlatformVerdict {
98
+ const failed = results.filter((result) => PlatformTestRun.failed(result));
99
+ return {
100
+ blocking: failed.filter((result) => result.policy === "gate"),
101
+ warning: failed.filter((result) => result.policy === "warn"),
102
+ };
103
+ }
104
+
105
+ format(results: PlatformTestResult[], { maxFailures = 15 }: { maxFailures?: number } = {}) {
106
+ const lines = [
107
+ chalk.bold(
108
+ `Platform tests · snapshot ${this.snapshot.digest} (${this.snapshot.fileCount} files) · ${path.relative(this.snapshot.root, this.logDir)}`,
109
+ ),
110
+ ];
111
+ for (const result of results) {
112
+ const mark = PlatformTestRun.failed(result) ? chalk.red("✖") : chalk.green("✔");
113
+ lines.push(`${mark} ${result.platform} (${result.policy}, ${Math.round(result.ms / 1000)}s)`);
114
+ if (result.infraError) lines.push(chalk.red(` setup: ${result.infraError}`));
115
+ for (const warning of result.envWarnings) lines.push(chalk.yellow(` env: ${warning}`));
116
+ for (const pkg of result.packages) {
117
+ const line = ` ${PlatformTestTarget.describe(pkg)}`;
118
+ lines.push(pkg.status === "pass" ? line : chalk.red(line));
119
+ for (const failure of pkg.report.failures.slice(0, maxFailures))
120
+ lines.push(chalk.dim(` ${failure.file ? `${failure.file} › ` : ""}${failure.name}`));
121
+ if (pkg.report.failures.length > maxFailures)
122
+ lines.push(chalk.dim(` … ${pkg.report.failures.length - maxFailures} more in ${pkg.logPath}`));
123
+ }
124
+ }
125
+ return lines.join("\n");
126
+ }
127
+
128
+ /** A `warn` platform becomes a gate once it has been green for enough deploys in a row. */
129
+ static async recordStreaks(results: PlatformTestResult[]) {
130
+ const targets = await GlobalConfig.getTestTargets();
131
+ const windows = targets.windows;
132
+ const result = results.find((one) => one.platform === "windows");
133
+ if (!windows || !result || (windows.policy ?? "warn") !== "warn") return null;
134
+ const greenStreak = PlatformTestRun.failed(result) ? 0 : (windows.greenStreak ?? 0) + 1;
135
+ const promoted = greenStreak >= PlatformTestRun.promoteAfterGreenRuns;
136
+ await GlobalConfig.setTestTargets({
137
+ windows: { ...windows, greenStreak, ...(promoted ? { policy: "gate" } : {}) },
138
+ });
139
+ return { platform: "windows" as const, greenStreak, promoted };
140
+ }
141
+ }
@@ -0,0 +1,142 @@
1
+ import path from "node:path";
2
+ import type { PlatformGatePolicy } from "../cloud/constants";
3
+ import { BunTestReport } from "./BunTestReport";
4
+ import type { IdleWatchedResult } from "./IdleWatchedProcess";
5
+ import type { TestSnapshot } from "./TestSnapshot";
6
+
7
+ export type RemoteTestPlatform = "linux" | "windows";
8
+ export type PackageTestStatus = "pass" | "fail" | "hung";
9
+
10
+ export interface PackageTestResult {
11
+ pkg: string;
12
+ status: PackageTestStatus;
13
+ report: BunTestReport;
14
+ ms: number;
15
+ logPath: string;
16
+ }
17
+
18
+ export interface PlatformTestResult {
19
+ platform: RemoteTestPlatform;
20
+ policy: PlatformGatePolicy;
21
+ packages: PackageTestResult[];
22
+ envWarnings: string[];
23
+ infraError: string | null;
24
+ ms: number;
25
+ }
26
+
27
+ export interface PlatformRunContext {
28
+ snapshot: TestSnapshot;
29
+ envPath: string;
30
+ runId: string;
31
+ logDir: string;
32
+ pkgs: string[];
33
+ onProgress: (message: string) => void;
34
+ }
35
+
36
+ export abstract class PlatformTestTarget {
37
+ static readonly installIdleMs = 5 * 60_000;
38
+ static readonly testIdleMs = 2 * 60_000;
39
+ protected static readonly gitIdentityEnv = {
40
+ GIT_AUTHOR_NAME: "akan-test",
41
+ GIT_AUTHOR_EMAIL: "akan-test@localhost",
42
+ GIT_COMMITTER_NAME: "akan-test",
43
+ GIT_COMMITTER_EMAIL: "akan-test@localhost",
44
+ GIT_CONFIG_COUNT: "1",
45
+ GIT_CONFIG_KEY_0: "init.defaultBranch",
46
+ GIT_CONFIG_VALUE_0: "main",
47
+ };
48
+
49
+ abstract readonly platform: RemoteTestPlatform;
50
+ abstract readonly policy: PlatformGatePolicy;
51
+ protected readonly tolerateScriptFailures: string[] = [];
52
+
53
+ protected abstract prepare(context: PlatformRunContext): Promise<void>;
54
+ protected abstract exec(command: string, logPath: string, idleMs: number): Promise<IdleWatchedResult>;
55
+ protected abstract cleanup(context: PlatformRunContext): Promise<void>;
56
+
57
+ async run(context: PlatformRunContext): Promise<PlatformTestResult> {
58
+ const startedAt = performance.now();
59
+ const envWarnings: string[] = [];
60
+ const packages: PackageTestResult[] = [];
61
+ const result = (infraError: string | null): PlatformTestResult => ({
62
+ platform: this.platform,
63
+ policy: this.policy,
64
+ packages,
65
+ envWarnings,
66
+ infraError,
67
+ ms: performance.now() - startedAt,
68
+ });
69
+ try {
70
+ await this.prepare(context);
71
+ await this.#install(context, envWarnings);
72
+ const build = await this.exec(
73
+ "bun run buildAkan",
74
+ this.#logPath(context, "build"),
75
+ PlatformTestTarget.installIdleMs,
76
+ );
77
+ if (build.exitCode !== 0) throw new Error(`buildAkan failed — see ${this.#logPath(context, "build")}`);
78
+ for (const pkg of context.pkgs) {
79
+ const logPath = this.#logPath(context, `test-${pkg.replaceAll("/", "__")}`);
80
+ const run = await this.exec(
81
+ `bun dist/pkgs/@akanjs/cli/index.js test ${pkg}`,
82
+ logPath,
83
+ PlatformTestTarget.testIdleMs,
84
+ );
85
+ const report = new BunTestReport(run.output);
86
+ const status: PackageTestStatus =
87
+ run.hung || run.timedOut ? "hung" : report.passed && run.exitCode === 0 ? "pass" : "fail";
88
+ packages.push({ pkg, status, report, ms: run.ms, logPath });
89
+ context.onProgress(`[${this.platform}] ${PlatformTestTarget.describe(packages.at(-1) as PackageTestResult)}`);
90
+ }
91
+ return result(null);
92
+ } catch (error) {
93
+ return result(error instanceof Error ? error.message : String(error));
94
+ } finally {
95
+ await this.cleanup(context).catch(() => undefined);
96
+ }
97
+ }
98
+
99
+ static describe({ pkg, status, report, ms }: PackageTestResult) {
100
+ const seconds = `${Math.round(ms / 1000)}s`;
101
+ if (status === "hung") return `${pkg}: hung${report.lastFile ? ` in ${report.lastFile}` : ""} (${seconds})`;
102
+ return `${pkg}: ${report.pass} pass / ${report.fail} fail${report.errors ? ` / ${report.errors} errors` : ""} (${seconds})`;
103
+ }
104
+
105
+ async #install(context: PlatformRunContext, envWarnings: string[]) {
106
+ const logPath = this.#logPath(context, "install");
107
+ const install = await this.exec("bun install --frozen-lockfile", logPath, PlatformTestTarget.installIdleMs);
108
+ if (install.exitCode === 0) return;
109
+ const failedScripts = [...install.output.matchAll(/(?:pre|post)?install script from "([^"]+)" exited/g)].map(
110
+ (match) => match[1] ?? "",
111
+ );
112
+ const tolerable =
113
+ failedScripts.length > 0 && failedScripts.every((pkg) => this.tolerateScriptFailures.includes(pkg));
114
+ if (!tolerable) throw new Error(`bun install failed — see ${logPath}`);
115
+ //* A failed lifecycle script makes bun skip linking the root `.bin`; a second pass without scripts links it.
116
+ envWarnings.push(`install script failed for ${failedScripts.join(", ")} — tolerated on ${this.platform}`);
117
+ const relink = await this.exec(
118
+ "bun install --frozen-lockfile --ignore-scripts",
119
+ this.#logPath(context, "install-relink"),
120
+ PlatformTestTarget.installIdleMs,
121
+ );
122
+ if (relink.exitCode !== 0) throw new Error(`bun install --ignore-scripts failed — see ${logPath}`);
123
+ }
124
+
125
+ #logPath(context: PlatformRunContext, name: string) {
126
+ return path.join(context.logDir, this.platform, `${name}.log`);
127
+ }
128
+
129
+ protected static async spawnText(command: string[], stdin?: string) {
130
+ const proc = Bun.spawn(command, {
131
+ stdin: stdin === undefined ? "ignore" : new Response(stdin),
132
+ stdout: "pipe",
133
+ stderr: "pipe",
134
+ });
135
+ const [stdout, stderr, exitCode] = await Promise.all([
136
+ new Response(proc.stdout).text(),
137
+ new Response(proc.stderr).text(),
138
+ proc.exited,
139
+ ]);
140
+ return { stdout, stderr, exitCode };
141
+ }
142
+ }
@@ -0,0 +1,72 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { unlink } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { tempDirs, writeText } from "../testHelpers";
5
+ import { TestSnapshot } from "./TestSnapshot";
6
+
7
+ const makeRoot = tempDirs("akan-snapshot-");
8
+ const git = async (cwd: string, ...args: string[]) => {
9
+ const proc = Bun.spawn(["git", ...args], {
10
+ cwd,
11
+ stdout: "ignore",
12
+ stderr: "ignore",
13
+ env: {
14
+ ...process.env,
15
+ GIT_AUTHOR_NAME: "t",
16
+ GIT_AUTHOR_EMAIL: "t@t",
17
+ GIT_COMMITTER_NAME: "t",
18
+ GIT_COMMITTER_EMAIL: "t@t",
19
+ },
20
+ });
21
+ expect(await proc.exited).toBe(0);
22
+ };
23
+ const write = async (root: string, file: string, content: string) => await writeText(path.join(root, file), content);
24
+ const makeRepo = async () => {
25
+ const root = await makeRoot();
26
+ await git(root, "init", "-q");
27
+ await write(root, ".gitignore", "ignored/\nlocal/\n.env\n");
28
+ await write(root, "tracked.ts", "export {};\n");
29
+ await write(root, "gone.ts", "export {};\n");
30
+ await git(root, "add", ".");
31
+ await git(root, "commit", "-q", "-m", "init");
32
+ await unlink(path.join(root, "gone.ts"));
33
+ await write(root, "untracked.ts", "export const a = 1;\n");
34
+ await write(root, "ignored/secret.ts", "export {};\n");
35
+ await write(root, ".env", "OPENAI_API_KEY=nope\n");
36
+ return root;
37
+ };
38
+ const tarEntries = async (tarPath: string) => {
39
+ const proc = Bun.spawn(["tar", "-tf", tarPath], { stdout: "pipe" });
40
+ return (await new Response(proc.stdout).text()).split(/\r?\n/).filter(Boolean).sort();
41
+ };
42
+
43
+ describe("TestSnapshot", () => {
44
+ test("packs what git would show — tracked and untracked, never ignored or deleted", async () => {
45
+ const root = await makeRepo();
46
+ const snapshot = await TestSnapshot.create(root, path.join(root, "local", "run"));
47
+
48
+ expect(await tarEntries(snapshot.tarPath)).toEqual([".gitignore", "tracked.ts", "untracked.ts"]);
49
+ expect(snapshot.fileCount).toBe(3);
50
+ });
51
+
52
+ test("reports what changed underneath it", async () => {
53
+ const root = await makeRepo();
54
+ const snapshot = await TestSnapshot.create(root, path.join(root, "local", "run"));
55
+ await write(root, "tracked.ts", "export const changed = true;\n");
56
+ await write(root, "added.ts", "export {};\n");
57
+ await unlink(path.join(root, "untracked.ts"));
58
+
59
+ expect(await snapshot.drift()).toEqual({ changed: ["tracked.ts"], added: ["added.ts"], removed: ["untracked.ts"] });
60
+ });
61
+
62
+ test("refuses an env file git does not ignore instead of shipping it to a test host", async () => {
63
+ const root = await makeRepo();
64
+ await write(root, "apps/demo/env/env.server.local.ts", "export default {};\n");
65
+ await write(root, "apps/demo/env/env.server.example.ts", "export default {};\n");
66
+ await write(root, "templates/.env.template", "KEY=\n");
67
+
68
+ await expect(TestSnapshot.create(root, path.join(root, "local", "run"))).rejects.toThrow(
69
+ "apps/demo/env/env.server.local.ts",
70
+ );
71
+ });
72
+ });
@@ -0,0 +1,103 @@
1
+ import { mkdir, stat, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ export interface SnapshotDrift {
5
+ changed: string[];
6
+ added: string[];
7
+ removed: string[];
8
+ }
9
+
10
+ //? Per-file hashes are kept so the tree is re-checked before publishing: a run gates only if nothing changed under it.
11
+ export class TestSnapshot {
12
+ static readonly #secretPatterns = [
13
+ /(^|\/)\.env(\.(?!(template|example|sample)$)[^/]+)?$/,
14
+ /(^|\/)env\/env\.(client|server)\.(?!(type|example)\.ts$)[^/]+\.ts$/,
15
+ ];
16
+
17
+ readonly root: string;
18
+ readonly tarPath: string;
19
+ readonly digest: string;
20
+ readonly #hashes: Map<string, string>;
21
+
22
+ constructor(root: string, tarPath: string, hashes: Map<string, string>) {
23
+ this.root = root;
24
+ this.tarPath = tarPath;
25
+ this.#hashes = hashes;
26
+ this.digest = TestSnapshot.#digestOf(hashes);
27
+ }
28
+
29
+ get fileCount() {
30
+ return this.#hashes.size;
31
+ }
32
+
33
+ static async create(root: string, outDir: string) {
34
+ const hashes = await TestSnapshot.#hashTree(root);
35
+ //? A tracked file is already in the repository; only an untracked one nobody ignored can leak by being copied.
36
+ const untracked = await TestSnapshot.#gitList(root, ["--others", "--exclude-standard"]);
37
+ const secrets = untracked.filter((file) => TestSnapshot.#secretPatterns.some((re) => re.test(file)));
38
+ if (secrets.length)
39
+ throw new Error(
40
+ `Refusing to snapshot env files git does not ignore — they would be copied to every test target: ${secrets.join(", ")}`,
41
+ );
42
+ await mkdir(outDir, { recursive: true });
43
+ const listPath = path.join(outDir, "files.txt");
44
+ const tarPath = path.join(outDir, "src.tar");
45
+ await writeFile(listPath, `${[...hashes.keys()].join("\n")}\n`);
46
+ const tar = Bun.spawn(
47
+ ["tar", ...(process.platform === "darwin" ? ["--no-xattrs"] : []), "-cf", tarPath, "-T", listPath],
48
+ { cwd: root, env: { ...process.env, COPYFILE_DISABLE: "1" }, stdout: "ignore", stderr: "pipe" },
49
+ );
50
+ if ((await tar.exited) !== 0) throw new Error(`tar failed: ${await new Response(tar.stderr).text()}`);
51
+ return new TestSnapshot(root, tarPath, hashes);
52
+ }
53
+
54
+ async drift(): Promise<SnapshotDrift> {
55
+ const current = await TestSnapshot.#hashTree(this.root);
56
+ const changed = [...current].filter(([file, hash]) => this.#hashes.has(file) && this.#hashes.get(file) !== hash);
57
+ return {
58
+ changed: changed.map(([file]) => file),
59
+ added: [...current.keys()].filter((file) => !this.#hashes.has(file)),
60
+ removed: [...this.#hashes.keys()].filter((file) => !current.has(file)),
61
+ };
62
+ }
63
+
64
+ static async #gitList(root: string, args: string[]) {
65
+ const proc = Bun.spawn(["git", "ls-files", ...args, "-z"], { cwd: root, stdout: "pipe", stderr: "pipe" });
66
+ const [stdout, stderr, exitCode] = await Promise.all([
67
+ new Response(proc.stdout).text(),
68
+ new Response(proc.stderr).text(),
69
+ proc.exited,
70
+ ]);
71
+ if (exitCode !== 0) throw new Error(`git ls-files failed in ${root}: ${stderr.trim()}`);
72
+ return [...new Set(stdout.split("\0").filter(Boolean))].sort();
73
+ }
74
+
75
+ static async #listFiles(root: string) {
76
+ const listed = await TestSnapshot.#gitList(root, ["--cached", "--others", "--exclude-standard"]);
77
+ //? `--cached` still lists a tracked file deleted from the working tree, which tar would then fail on.
78
+ const present = await Promise.all(
79
+ listed.map(async (file) => ((await stat(path.join(root, file)).catch(() => null))?.isFile() ? file : null)),
80
+ );
81
+ return present.filter((file): file is string => file !== null);
82
+ }
83
+
84
+ static async #hashTree(root: string) {
85
+ const files = await TestSnapshot.#listFiles(root);
86
+ const hashes = new Map<string, string>();
87
+ for (let start = 0; start < files.length; start += 64) {
88
+ const batch = files.slice(start, start + 64);
89
+ const batchHashes = await Promise.all(
90
+ batch.map(async (file) => Bun.hash(await Bun.file(path.join(root, file)).bytes()).toString(16)),
91
+ );
92
+ for (const [idx, file] of batch.entries()) hashes.set(file, batchHashes[idx] ?? "");
93
+ }
94
+ return hashes;
95
+ }
96
+
97
+ static #digestOf(hashes: Map<string, string>) {
98
+ return Bun.hash([...hashes].map(([file, hash]) => `${file}:${hash}`).join("\n"))
99
+ .toString(16)
100
+ .padStart(16, "0")
101
+ .slice(0, 12);
102
+ }
103
+ }