@akanjs/devkit 3.0.0-alpha.8 → 3.0.0-alpha.80

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 (174) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.ko.md +1 -1
  3. package/README.md +1 -1
  4. package/agentsIndex.test.ts +10 -0
  5. package/agentsIndex.ts +47 -1
  6. package/aiEditor.ts +1 -1
  7. package/akanApp/BackendImportGraph.test.ts +120 -0
  8. package/akanApp/BackendImportGraph.ts +167 -0
  9. package/akanApp/akanApp.host.test.ts +16 -678
  10. package/akanApp/akanApp.host.ts +65 -562
  11. package/akanApp/devHostPolicy.test.ts +542 -0
  12. package/akanApp/devHostPolicy.ts +412 -0
  13. package/akanApp/index.ts +2 -0
  14. package/akanConfig/akanConfig.test.ts +182 -14
  15. package/akanConfig/akanConfig.ts +132 -47
  16. package/akanConfig/types.ts +8 -0
  17. package/akanContext.ts +70 -14
  18. package/akanMcpContract.ts +25 -4
  19. package/applicationBuildRunner.test.ts +1 -1
  20. package/applicationBuildRunner.ts +45 -21
  21. package/artifact/implicitRootLayout.test.ts +67 -0
  22. package/artifact/implicitRootLayout.ts +27 -7
  23. package/artifact/routeSeedIndex.test.ts +1 -0
  24. package/biome.base.json +340 -0
  25. package/biomeBase.ts +9 -0
  26. package/capacitorApp.ts +10 -0
  27. package/cloud/globalConfig.ts +9 -2
  28. package/commandDecorators/command.ts +30 -6
  29. package/commandDecorators/commandBuilder.ts +20 -5
  30. package/commandDecorators/commandDecorators.test.ts +115 -3
  31. package/dependencyScanner.test.ts +99 -0
  32. package/dependencyScanner.ts +27 -24
  33. package/devkitUtils.test.ts +0 -104
  34. package/executors.test.ts +132 -5
  35. package/executors.ts +89 -14
  36. package/fileEditor.ts +19 -19
  37. package/fleetConfig.ts +73 -0
  38. package/fleetGuard.test.ts +121 -0
  39. package/fleetGuard.ts +157 -0
  40. package/fleetSpoke.test.ts +390 -0
  41. package/fleetSpoke.ts +719 -0
  42. package/formSetterScanner.test.ts +80 -0
  43. package/formSetterScanner.ts +92 -0
  44. package/frontendBuild/autoImportSync.test.ts +58 -0
  45. package/frontendBuild/autoImportSync.ts +4 -0
  46. package/frontendBuild/buildRouteClient.test.ts +47 -24
  47. package/frontendBuild/clientBuildTypes.ts +4 -0
  48. package/frontendBuild/clientEntriesBundler.ts +4 -1
  49. package/frontendBuild/clientEntryDiscovery.ts +2 -2
  50. package/frontendBuild/csrArtifactBuilder.ts +116 -84
  51. package/frontendBuild/cssCompiler.ts +123 -12
  52. package/frontendBuild/cssImportResolver.ts +8 -7
  53. package/frontendBuild/fontOptimizer.ts +37 -18
  54. package/frontendBuild/fontPruner.test.ts +220 -0
  55. package/frontendBuild/fontPruner.ts +206 -0
  56. package/frontendBuild/frontendBuild.test.ts +178 -10
  57. package/frontendBuild/hmrWatcher.ts +1 -1
  58. package/frontendBuild/index.ts +1 -1
  59. package/frontendBuild/pagesBundleBuilder.ts +3 -3
  60. package/frontendBuild/pagesEntrySourceGenerator.ts +11 -88
  61. package/frontendBuild/routeClientBuilder.ts +12 -5
  62. package/frontendBuild/ssrBaseArtifactBuilder.ts +21 -4
  63. package/frontendBuild/styleContract.ts +14 -20
  64. package/frontendBuild/themeValidator.ts +22 -17
  65. package/frontendBuild/vendorSpecifiers.ts +1 -0
  66. package/incrementalBuilder/devWatchBatch.test.ts +18 -20
  67. package/incrementalBuilder/devWatchBatch.ts +6 -2
  68. package/incrementalBuilder/incrementalBuilder.host.ts +1 -1
  69. package/incrementalBuilder/incrementalBuilder.proc.ts +8 -4
  70. package/index.ts +0 -5
  71. package/integration/devStabilityHarness.ts +2 -10
  72. package/libSource.test.ts +109 -0
  73. package/libSource.ts +126 -0
  74. package/lint/__fixtures__/README.md +40 -0
  75. package/lint/__fixtures__/no-arbitrary-color/bad.tsx +3 -0
  76. package/lint/__fixtures__/no-arbitrary-color/good.tsx +4 -0
  77. package/lint/__fixtures__/no-async-component-in-ui/bad.tsx +4 -0
  78. package/lint/__fixtures__/no-async-component-in-ui/good.tsx +4 -0
  79. package/lint/__fixtures__/no-bang-comment-in-client/bad.tsx +4 -0
  80. package/lint/__fixtures__/no-bang-comment-in-client/fixture.json +1 -0
  81. package/lint/__fixtures__/no-bang-comment-in-client/good.tsx +3 -0
  82. package/lint/__fixtures__/no-daisyui-legacy-class/bad.tsx +7 -0
  83. package/lint/__fixtures__/no-daisyui-legacy-class/good.tsx +5 -0
  84. package/lint/__fixtures__/no-deep-internal-import/bad.tsx +3 -0
  85. package/lint/__fixtures__/no-deep-internal-import/fixture.json +1 -0
  86. package/lint/__fixtures__/no-deep-internal-import/good.tsx +3 -0
  87. package/lint/__fixtures__/no-deprecated-log-level/bad.tsx +4 -0
  88. package/lint/__fixtures__/no-deprecated-log-level/good.tsx +4 -0
  89. package/lint/__fixtures__/no-import-client-functions/bad.tsx +2 -0
  90. package/lint/__fixtures__/no-import-client-functions/good.tsx +4 -0
  91. package/lint/__fixtures__/no-import-client-in-server/bad.tsx +6 -0
  92. package/lint/__fixtures__/no-import-client-in-server/good.tsx +5 -0
  93. package/lint/__fixtures__/no-import-external-library/bad.tsx +4 -0
  94. package/lint/__fixtures__/no-import-external-library/good.tsx +5 -0
  95. package/lint/__fixtures__/no-import-server-in-client/bad.tsx +6 -0
  96. package/lint/__fixtures__/no-import-server-in-client/good.tsx +4 -0
  97. package/lint/__fixtures__/no-init-fetch-in-client/store-file/bad.ts +1 -0
  98. package/lint/__fixtures__/no-init-fetch-in-client/store-file/fixture.json +1 -0
  99. package/lint/__fixtures__/no-init-fetch-in-client/store-file/good.ts +1 -0
  100. package/lint/__fixtures__/no-init-fetch-in-client/use-client/bad.tsx +4 -0
  101. package/lint/__fixtures__/no-init-fetch-in-client/use-client/good.tsx +5 -0
  102. package/lint/__fixtures__/no-inline-color/bad.tsx +3 -0
  103. package/lint/__fixtures__/no-inline-color/good.tsx +3 -0
  104. package/lint/__fixtures__/no-interpolated-arbitrary-class/bad.tsx +3 -0
  105. package/lint/__fixtures__/no-interpolated-arbitrary-class/good.tsx +3 -0
  106. package/lint/__fixtures__/no-js-private-class-method/bad.tsx +3 -0
  107. package/lint/__fixtures__/no-js-private-class-method/good.tsx +3 -0
  108. package/lint/__fixtures__/no-model-type-in-util-zone/bad.tsx +3 -0
  109. package/lint/__fixtures__/no-model-type-in-util-zone/good.tsx +6 -0
  110. package/lint/__fixtures__/no-raw-palette-class/bad.tsx +6 -0
  111. package/lint/__fixtures__/no-raw-palette-class/good.tsx +8 -0
  112. package/lint/__fixtures__/no-redeclare-predefined-endpoint/bad.ts +10 -0
  113. package/lint/__fixtures__/no-redeclare-predefined-endpoint/fixture.json +1 -0
  114. package/lint/__fixtures__/no-redeclare-predefined-endpoint/good.ts +6 -0
  115. package/lint/__fixtures__/no-return-in-store-action/bad.tsx +2 -0
  116. package/lint/__fixtures__/no-return-in-store-action/good.tsx +6 -0
  117. package/lint/__fixtures__/no-throw-raw-error/bad.tsx +4 -0
  118. package/lint/__fixtures__/no-throw-raw-error/good.tsx +4 -0
  119. package/lint/__fixtures__/no-unpublished-form-setter/bad.tsx +3 -0
  120. package/lint/__fixtures__/no-unpublished-form-setter/good.tsx +5 -0
  121. package/lint/__fixtures__/no-use-client-in-server/bad.tsx +3 -0
  122. package/lint/__fixtures__/no-use-client-in-server/good.tsx +2 -0
  123. package/lint/__fixtures__/non-scalar-props-restricted/bad.tsx +5 -0
  124. package/lint/__fixtures__/non-scalar-props-restricted/good.tsx +4 -0
  125. package/lint/gritRules.test.ts +178 -0
  126. package/lint/no-arbitrary-color.grit +1 -1
  127. package/lint/no-async-component-in-ui.grit +35 -0
  128. package/lint/no-bang-comment-in-client.grit +23 -10
  129. package/lint/no-daisyui-legacy-class.grit +26 -9
  130. package/lint/no-deprecated-log-level.grit +17 -0
  131. package/lint/no-import-client-in-server.grit +48 -0
  132. package/lint/no-import-server-in-client.grit +45 -0
  133. package/lint/no-init-fetch-in-client.grit +47 -0
  134. package/lint/no-inline-color.grit +10 -8
  135. package/lint/no-interpolated-arbitrary-class.grit +3 -3
  136. package/lint/no-model-type-in-util-zone.grit +58 -0
  137. package/lint/no-raw-palette-class.grit +3 -3
  138. package/lint/no-unpublished-form-setter.grit +41 -0
  139. package/lint/non-scalar-props-restricted.grit +16 -7
  140. package/linter.test.ts +80 -0
  141. package/linter.ts +99 -25
  142. package/package.json +6 -6
  143. package/prompter.ts +9 -4
  144. package/qualityScanner.test.ts +116 -0
  145. package/qualityScanner.ts +89 -21
  146. package/recipeScanner.ts +4 -1
  147. package/repoIdentity.ts +42 -0
  148. package/scanInfo.ts +32 -26
  149. package/semver.test.ts +26 -0
  150. package/semver.ts +31 -0
  151. package/slicePlanner.test.ts +151 -0
  152. package/slicePlanner.ts +157 -0
  153. package/ssrScanner.test.ts +301 -0
  154. package/transforms/asyncDefaultExportDetector.ts +103 -0
  155. package/transforms/barrelImportsPlugin.ts +1 -1
  156. package/transforms/externalizeFrameworkPlugin.ts +0 -1
  157. package/transforms/transforms.test.ts +5 -5
  158. package/transforms/tsconfigPackageResolver.test.ts +230 -0
  159. package/tsconfig.json +6 -2
  160. package/typeChecker.ts +1 -1
  161. package/types.ts +1 -0
  162. package/ui/ScrollList.tsx +6 -8
  163. package/uploadRelease.ts +2 -2
  164. package/workflow/executor.test.ts +146 -0
  165. package/workspaceLayout.test.ts +56 -4
  166. package/workspaceLayout.ts +49 -4
  167. package/builder.ts +0 -164
  168. package/extractDeps.ts +0 -86
  169. package/frontendBuild/styleGuard.test.ts +0 -165
  170. package/frontendBuild/styleGuard.ts +0 -322
  171. package/getCredentials.ts +0 -19
  172. package/getModelFileData.ts +0 -62
  173. package/src/capacitorApp.ts +0 -282
  174. package/streamAi.ts +0 -45
@@ -0,0 +1,121 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { Executor, LibExecutor, WorkspaceExecutor } from "./executors";
6
+ import { FleetGuard, formatFleetGuardReport } from "./fleetGuard";
7
+ import { LibSource } from "./libSource";
8
+
9
+ const tempRoots: string[] = [];
10
+
11
+ afterEach(async () => {
12
+ await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
13
+ });
14
+
15
+ const write = async (filePath: string, content: string) => {
16
+ await mkdir(path.dirname(filePath), { recursive: true });
17
+ await writeFile(filePath, content);
18
+ };
19
+
20
+ const git = async (cwd: string, args: string[]) =>
21
+ await new Executor("fixture", cwd).spawn("git", ["-c", "user.email=t@t", "-c", "user.name=t", ...args]);
22
+
23
+ /**
24
+ * A spoke as a push leaves it. Built by hand rather than by running a push: `LibExecutor.from` memoises by
25
+ * library name across the whole process, so a fixture that also builds a hub would hand the guard the
26
+ * hub's copy of the library. In a real spoke the hub is a different process entirely.
27
+ */
28
+ const makeSpoke = async (libName: string, appName: string) => {
29
+ const root = await mkdtemp(path.join(os.tmpdir(), "akan-guard-"));
30
+ tempRoots.push(root);
31
+ await write(path.join(root, "package.json"), '{ "name": "spoke", "version": "0.0.1", "description": "spoke" }\n');
32
+ await write(path.join(root, "tsconfig.json"), "{}\n");
33
+ await write(path.join(root, ".gitignore"), "node_modules\n**/.akan\n");
34
+ await write(path.join(root, `apps/${appName}/akan.config.ts`), "export default {};\n");
35
+ await write(path.join(root, `apps/${appName}/env/env.server.local.ts`), "export const env = {};\n");
36
+ await write(path.join(root, `libs/${libName}/akan.config.ts`), "export default {};\n");
37
+ await write(path.join(root, `libs/${libName}/package.json`), `{ "name": "@${libName}", "version": "0.0.1" }\n`);
38
+ await write(path.join(root, `libs/${libName}/common/helper.ts`), "export const helper = 1;\n");
39
+ await mkdir(path.join(root, `libs/${libName}/lib`), { recursive: true });
40
+
41
+ await git(root, ["init", "--quiet", "--initial-branch=develop"]);
42
+ await git(root, ["add", "-A"]);
43
+ await git(root, ["commit", "--quiet", "-m", "initial"]);
44
+
45
+ const workspace = WorkspaceExecutor.fromRoot({ workspaceRoot: root, repoName: `spoke-${libName}` });
46
+ await new LibSource(LibExecutor.from(workspace, libName)).write({ origin: "hub#develop", sha: "abc1234" });
47
+ await write(
48
+ path.join(root, FleetGuard.anchorFile),
49
+ `${JSON.stringify({ hub: "hub", hubSha: "abc1234", branch: "develop", apps: [appName], syncedAt: "now" }, null, 2)}\n`,
50
+ );
51
+ await git(root, ["add", "-A"]);
52
+ await git(root, ["commit", "--quiet", "-m", "chore(fleet): sync from hub@abc1234"]);
53
+
54
+ return { root, workspace, guard: new FleetGuard(workspace) };
55
+ };
56
+
57
+ describe("FleetGuard", () => {
58
+ test("passes in a workspace that is not a spoke, so one hook line is safe in the hub too", async () => {
59
+ const root = await mkdtemp(path.join(os.tmpdir(), "akan-guard-"));
60
+ tempRoots.push(root);
61
+ await write(path.join(root, "package.json"), '{ "name": "hub", "version": "0.0.1", "description": "hub" }\n');
62
+ await git(root, ["init", "--quiet"]);
63
+
64
+ const report = await new FleetGuard(
65
+ WorkspaceExecutor.fromRoot({ workspaceRoot: root, repoName: "bare-hub" }),
66
+ ).check();
67
+
68
+ expect(report.spoke).toBeNull();
69
+ expect(report.violations).toEqual([]);
70
+ expect(formatFleetGuardReport(report)).toContain("not a fleet spoke");
71
+ });
72
+
73
+ test("passes a spoke that has changed nothing hub-owned", async () => {
74
+ const { root, guard } = await makeSpoke("clean-kit", "clean-app");
75
+ await write(path.join(root, "apps/clean-app/lib/task.ts"), "export const task = 1;\n");
76
+ await git(root, ["add", "-A"]);
77
+ await git(root, ["commit", "--quiet", "-m", "customer app work"]);
78
+
79
+ const report = await guard.check();
80
+
81
+ expect(report.spoke?.branch).toBe("develop");
82
+ expect(report.anchor).toMatch(/^[0-9a-f]{40}$/);
83
+ expect(report.violations).toEqual([]);
84
+ expect(report.driftedLibs).toEqual([]);
85
+ });
86
+
87
+ test("catches a library edit both by path and by stamp", async () => {
88
+ const { root, guard } = await makeSpoke("drift-kit", "drift-app");
89
+ await write(path.join(root, "libs/drift-kit/common/helper.ts"), "export const helper = 99;\n");
90
+ await git(root, ["add", "-A"]);
91
+ await git(root, ["commit", "--quiet", "-m", "quick fix in the lib"]);
92
+
93
+ const report = await guard.check();
94
+
95
+ expect(report.violations.map((violation) => violation.path)).toEqual(["libs/drift-kit/common/helper.ts"]);
96
+ expect(report.violations[0]?.reason).toContain("owned by the hub");
97
+ expect(report.driftedLibs.map((drift) => drift.lib)).toEqual(["drift-kit"]);
98
+ expect(formatFleetGuardReport(report)).toContain("akansoft");
99
+ });
100
+
101
+ test("catches a workspace manifest edit and leaves env alone", async () => {
102
+ const { root, guard } = await makeSpoke("shell-kit", "shell-app");
103
+ await write(path.join(root, "package.json"), '{ "name": "spoke", "version": "0.0.2", "description": "x" }\n');
104
+ await write(path.join(root, "apps/shell-app/env/env.server.local.ts"), "export const env = { key: 1 };\n");
105
+
106
+ const report = await guard.check();
107
+
108
+ expect(report.violations.map((violation) => violation.path)).toEqual(["package.json"]);
109
+ });
110
+
111
+ test("reads only the index in staged mode", async () => {
112
+ const { root, guard } = await makeSpoke("staged-kit", "staged-app");
113
+ await write(path.join(root, "libs/staged-kit/common/helper.ts"), "export const helper = 5;\n");
114
+
115
+ expect((await guard.check({ staged: true })).violations).toEqual([]);
116
+ await git(root, ["add", "libs/staged-kit/common/helper.ts"]);
117
+ expect((await guard.check({ staged: true })).violations.map((violation) => violation.path)).toEqual([
118
+ "libs/staged-kit/common/helper.ts",
119
+ ]);
120
+ });
121
+ });
package/fleetGuard.ts ADDED
@@ -0,0 +1,157 @@
1
+ import path from "node:path";
2
+ import { LibExecutor, type WorkspaceExecutor } from "./executors";
3
+ import { FileSys } from "./fileSys";
4
+ import { LibSource } from "./libSource";
5
+
6
+ export interface FleetAnchorFile {
7
+ hub: string;
8
+ hubSha: string;
9
+ branch: string;
10
+ apps: string[];
11
+ syncedAt: string;
12
+ }
13
+
14
+ export interface FleetViolation {
15
+ path: string;
16
+ reason: string;
17
+ }
18
+
19
+ export interface FleetLibDrift {
20
+ lib: string;
21
+ expected: string;
22
+ actual: string;
23
+ }
24
+
25
+ export interface FleetGuardReport {
26
+ /** Null when this workspace is not a spoke, which is a pass — the same hook line is safe in the hub. */
27
+ spoke: FleetAnchorFile | null;
28
+ anchor: string | null;
29
+ violations: FleetViolation[];
30
+ driftedLibs: FleetLibDrift[];
31
+ }
32
+
33
+ /**
34
+ * The spoke-side half of the fleet contract, run *inside* a customer repo by its own CI and pre-commit
35
+ * hook. It needs no hub access and no credentials: the library stamps carry the hash to compare against,
36
+ * and the last push is found by the one file only a push writes.
37
+ *
38
+ * Guards `libs/**` and the workspace-shell manifests, which the hub owns. `env/` is deliberately not
39
+ * guarded — those values belong to the repo that deploys.
40
+ */
41
+ export class FleetGuard {
42
+ static readonly anchorFile = "akan.fleet.json";
43
+ static readonly hubOwnedRootFiles = ["package.json", "tsconfig.json", "biome.json", "bunfig.toml"];
44
+ static readonly spokeOwnedDirs = ["env"];
45
+ static readonly contact = "Ask the akansoft team to make this change in the hub, then pull it back down.";
46
+
47
+ #workspace: WorkspaceExecutor;
48
+ constructor(workspace: WorkspaceExecutor) {
49
+ this.#workspace = workspace;
50
+ }
51
+
52
+ async #readAnchorFile() {
53
+ const anchorPath = path.join(this.#workspace.workspaceRoot, FleetGuard.anchorFile);
54
+ if (!(await FileSys.fileExists(anchorPath))) return null;
55
+ return (await FileSys.readJson(anchorPath)) as FleetAnchorFile;
56
+ }
57
+
58
+ /** The push commit: the newest one that touched the file only a push writes. */
59
+ async #anchorCommit() {
60
+ const commit = await this.#workspace.spawn("git", ["log", "-1", "--format=%H", "--", FleetGuard.anchorFile]);
61
+ return commit.trim() || null;
62
+ }
63
+
64
+ #isSpokeOwned(file: string) {
65
+ const segments = file.split("/");
66
+ if (segments[0] !== "apps" && segments[0] !== "libs") return false;
67
+ return FleetGuard.spokeOwnedDirs.includes(segments[2] ?? "");
68
+ }
69
+
70
+ #violationOf(file: string): FleetViolation | null {
71
+ if (this.#isSpokeOwned(file)) return null;
72
+ if (file === FleetGuard.anchorFile) return { path: file, reason: "the fleet anchor is written by a push" };
73
+ if (FleetGuard.hubOwnedRootFiles.includes(file))
74
+ return { path: file, reason: "workspace manifests are owned by the hub" };
75
+ if (file.startsWith("libs/")) return { path: file, reason: "shared libraries are owned by the hub" };
76
+ return null;
77
+ }
78
+
79
+ async #changedFiles({ staged }: { staged: boolean }) {
80
+ if (staged) {
81
+ const cached = await this.#workspace.spawn("git", ["diff", "--cached", "--name-only"]);
82
+ return cached.split("\n").filter((file) => !!file.trim());
83
+ }
84
+ const anchor = await this.#anchorCommit();
85
+ const sinceAnchor = anchor
86
+ ? (await this.#workspace.spawn("git", ["diff", "--name-only", `${anchor}..HEAD`]))
87
+ .split("\n")
88
+ .filter((file) => !!file.trim())
89
+ : [];
90
+ const working = (await this.#workspace.spawn("git", ["status", "--porcelain"]))
91
+ .split("\n")
92
+ .filter((line) => !!line.trim())
93
+ //? Porcelain v1: two status columns, a space, then the path — and a rename carries `old -> new`.
94
+ .map((line) => line.slice(3).split(" -> ").at(-1) ?? "")
95
+ .filter((file) => !!file);
96
+ return [...new Set([...sinceAnchor, ...working])];
97
+ }
98
+
99
+ async #libDrift(): Promise<FleetLibDrift[]> {
100
+ const libNames = await this.#workspace.getLibs();
101
+ const statuses = await Promise.all(
102
+ libNames.map(async (libName) => await new LibSource(LibExecutor.from(this.#workspace, libName)).status()),
103
+ );
104
+ return statuses
105
+ .filter((status) => status.drift === "drifted")
106
+ .map((status) => ({ lib: status.lib, expected: status.stamp?.hash ?? "", actual: status.hash }));
107
+ }
108
+
109
+ async check({ staged = false }: { staged?: boolean } = {}): Promise<FleetGuardReport> {
110
+ const spoke = await this.#readAnchorFile();
111
+ //* Not a spoke: a pass, so one `akan fleet check` line in a shared pre-commit hook is safe in the hub
112
+ //* too — the hub is where hub-owned files are supposed to be edited.
113
+ if (!spoke) return { spoke: null, anchor: null, violations: [], driftedLibs: [] };
114
+ const [anchor, changed, driftedLibs] = await Promise.all([
115
+ this.#anchorCommit(),
116
+ this.#changedFiles({ staged }),
117
+ staged ? Promise.resolve<FleetLibDrift[]>([]) : this.#libDrift(),
118
+ ]);
119
+ const violations = changed
120
+ .map((file) => this.#violationOf(file))
121
+ .filter((violation): violation is FleetViolation => !!violation);
122
+ return { spoke, anchor, violations, driftedLibs };
123
+ }
124
+ }
125
+
126
+ export function formatFleetGuardReport(report: FleetGuardReport, { warn = false }: { warn?: boolean } = {}) {
127
+ if (!report.spoke) return "Akan Fleet Guard: not a fleet spoke (no akan.fleet.json) — nothing to check.";
128
+ const clean = !report.violations.length && !report.driftedLibs.length;
129
+ const sections = [
130
+ "Akan Fleet Guard",
131
+ `hub: ${report.spoke.hub}#${report.spoke.branch} apps: ${report.spoke.apps.join(", ")}`,
132
+ `last push: ${report.anchor?.slice(0, 12) ?? "(none)"}`,
133
+ "",
134
+ ...(clean ? [" no hub-owned file was changed here."] : []),
135
+ ...(report.violations.length
136
+ ? [
137
+ `Hub-owned files changed in this repo (${report.violations.length}):`,
138
+ "",
139
+ ...report.violations.map((violation) => ` ${violation.path} — ${violation.reason}`),
140
+ ]
141
+ : []),
142
+ ...(report.driftedLibs.length
143
+ ? [
144
+ "",
145
+ `Libraries edited since the last push (${report.driftedLibs.length}):`,
146
+ "",
147
+ ...report.driftedLibs.map(
148
+ (drift) => ` libs/${drift.lib} stamped ${drift.expected.slice(0, 12)}, now ${drift.actual.slice(0, 12)}`,
149
+ ),
150
+ ]
151
+ : []),
152
+ ...(clean
153
+ ? []
154
+ : ["", ` ${FleetGuard.contact}`, ...(warn ? [" (warning only — this commit is not blocked)"] : [])]),
155
+ ];
156
+ return sections.join("\n");
157
+ }
@@ -0,0 +1,390 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { mkdir, mkdtemp, readdir, rm, writeFile } from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { Executor, WorkspaceExecutor } from "./executors";
6
+ import { FileSys } from "./fileSys";
7
+ import { FleetConfig } from "./fleetConfig";
8
+ import { FleetSpoke, formatFleetDiff, formatFleetPushResults, formatFleetStatuses } from "./fleetSpoke";
9
+
10
+ const tempRoots: string[] = [];
11
+ const originalEnv = { ...process.env };
12
+
13
+ beforeEach(() => {
14
+ process.env = { ...originalEnv };
15
+ process.env.AKAN_PUBLIC_REPO_NAME = "hub";
16
+ process.env.AKAN_PUBLIC_SERVE_DOMAIN = "example.com";
17
+ process.env.AKAN_PUBLIC_ENV = "local";
18
+ });
19
+
20
+ afterEach(async () => {
21
+ process.env = { ...originalEnv };
22
+ await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
23
+ });
24
+
25
+ const write = async (filePath: string, content: string) => {
26
+ await mkdir(path.dirname(filePath), { recursive: true });
27
+ await writeFile(filePath, content);
28
+ };
29
+
30
+ const git = async (cwd: string, args: string[]) =>
31
+ await new Executor("fixture", cwd).spawn("git", ["-c", "user.email=t@t", "-c", "user.name=t", ...args]);
32
+
33
+ const hubGitignore = [
34
+ "node_modules",
35
+ "**/.akan",
36
+ "**/bun.lock",
37
+ "apps/*/lib/cnst.ts",
38
+ "**/env.server.local.ts",
39
+ "**/akan.app.json",
40
+ "",
41
+ "# akan:secrets (managed by akan.config.ts — do not edit)",
42
+ "apps/served/secrets/**/*",
43
+ "apps/private/secrets/**",
44
+ "# akan:secrets:end",
45
+ ].join("\n");
46
+
47
+ const hubAgents = [
48
+ "# Hub",
49
+ "",
50
+ "## Workspace",
51
+ "",
52
+ "- Repo: hub",
53
+ "- Apps: private, served",
54
+ "- Libraries: kit",
55
+ "",
56
+ ].join("\n");
57
+
58
+ const makeSys = async (root: string, member: "apps" | "libs", name: string, extra: Record<string, string> = {}) => {
59
+ await write(path.join(root, member, name, "akan.config.ts"), "export default {};\n");
60
+ await write(path.join(root, member, name, "tsconfig.json"), "{}\n");
61
+ await write(path.join(root, member, name, "package.json"), `{ "name": "@${name}", "version": "0.0.1" }\n`);
62
+ await mkdir(path.join(root, member, name, "lib"), { recursive: true });
63
+ for (const [relative, content] of Object.entries(extra))
64
+ await write(path.join(root, member, name, relative), content);
65
+ };
66
+
67
+ /** A hub with two apps, one shared library, and a spoke that serves only the first app. */
68
+ const makeFleet = async (servedApp: string, privateApp: string, libName: string) => {
69
+ const workRoot = await mkdtemp(path.join(os.tmpdir(), "akan-fleet-"));
70
+ tempRoots.push(workRoot);
71
+ const hubRoot = path.join(workRoot, "hub");
72
+ const bareRoot = path.join(workRoot, "spoke.git");
73
+ await mkdir(hubRoot, { recursive: true });
74
+
75
+ await write(path.join(hubRoot, "package.json"), '{ "name": "hub", "version": "0.0.1", "description": "hub" }\n');
76
+ await write(path.join(hubRoot, ".gitignore"), `${hubGitignore}\n`);
77
+ await write(path.join(hubRoot, "AGENTS.md"), hubAgents);
78
+ await write(path.join(hubRoot, "biome.json"), "{}\n");
79
+ await write(path.join(hubRoot, "benchmarks/bench.ts"), "export {};\n");
80
+ await write(
81
+ path.join(hubRoot, "akan.fleet.ts"),
82
+ `export default { pushableBranches: ["develop"], exclude: ["benchmarks"], spokes: [{ name: "acme", repo: "${bareRoot}", apps: ["${servedApp}"] }] };\n`,
83
+ );
84
+
85
+ await makeSys(hubRoot, "apps", servedApp, {
86
+ "lib/task/task.constant.ts": `import { helper } from "@libs/${libName}/common";\n\nexport { helper };\n`,
87
+ "lib/cnst.ts": "export {};\n",
88
+ "env/env.server.local.ts": "export const env = { from: 'hub' };\n",
89
+ "env/env.server.ts": "export const env = {};\n",
90
+ });
91
+ await makeSys(hubRoot, "apps", privateApp, { "lib/task/task.constant.ts": "export const task = 1;\n" });
92
+ await makeSys(hubRoot, "libs", libName, { "common/helper.ts": "export const helper = 1;\n" });
93
+
94
+ await git(hubRoot, ["init", "--quiet", "--initial-branch=develop"]);
95
+ await git(hubRoot, ["add", "-A"]);
96
+ await git(hubRoot, ["commit", "--quiet", "-m", "hub"]);
97
+
98
+ await git(workRoot, ["init", "--bare", "--quiet", "--initial-branch=develop", bareRoot]);
99
+ const seedRoot = path.join(workRoot, "seed");
100
+ await git(workRoot, ["clone", "--quiet", bareRoot, seedRoot]);
101
+ await write(path.join(seedRoot, "README.md"), "spoke\n");
102
+ await git(seedRoot, ["checkout", "--quiet", "-B", "develop"]);
103
+ await git(seedRoot, ["add", "-A"]);
104
+ await git(seedRoot, ["commit", "--quiet", "-m", "seed"]);
105
+ await git(seedRoot, ["push", "--quiet", "origin", "develop"]);
106
+
107
+ const workspace = WorkspaceExecutor.fromRoot({ workspaceRoot: hubRoot, repoName: `hub-${servedApp}` });
108
+ const config = await FleetConfig.from(workspace);
109
+ if (!config) throw new Error("fixture config missing");
110
+ const declaration = config.spokes[0];
111
+ if (!declaration) throw new Error("fixture spoke missing");
112
+ const spoke = new FleetSpoke(workspace, config, declaration);
113
+ const clonePath = path.join(hubRoot, ".akan/fleet/acme");
114
+ return { workRoot, hubRoot, bareRoot, workspace, spoke, clonePath };
115
+ };
116
+
117
+ /** A working copy of the spoke, as a customer developer would have. */
118
+ const cloneSpoke = async (workRoot: string, bareRoot: string, name: string) => {
119
+ const root = path.join(workRoot, name);
120
+ await git(workRoot, ["clone", "--quiet", bareRoot, root]);
121
+ await git(root, ["checkout", "--quiet", "develop"]);
122
+ return root;
123
+ };
124
+
125
+ describe("FleetSpoke push", () => {
126
+ test("ships the slice, holds back everything hub-only, and preserves the spoke's env", async () => {
127
+ const { hubRoot, bareRoot, workRoot, spoke, clonePath } = await makeFleet("served", "private", "kit");
128
+
129
+ const result = await spoke.push("develop", { verify: false });
130
+ expect(result.outcome).toBe("pushed");
131
+
132
+ const spokeRoot = await cloneSpoke(workRoot, bareRoot, "check");
133
+ const entries = await readdir(spokeRoot);
134
+
135
+ expect(await FileSys.fileExists(path.join(spokeRoot, "apps/served/lib/task/task.constant.ts"))).toBe(true);
136
+ expect(await FileSys.fileExists(path.join(spokeRoot, "libs/kit/common/helper.ts"))).toBe(true);
137
+ expect(await FileSys.dirExists(path.join(spokeRoot, "apps/private"))).toBe(false);
138
+ expect(entries).not.toContain("benchmarks");
139
+ expect(entries).not.toContain("akan.fleet.ts");
140
+ expect(await FileSys.fileExists(path.join(spokeRoot, "apps/served/lib/cnst.ts"))).toBe(false);
141
+ expect(await FileSys.fileExists(path.join(spokeRoot, "apps/served/env/env.server.local.ts"))).toBe(false);
142
+ expect(await FileSys.fileExists(path.join(spokeRoot, "apps/served/env/env.server.ts"))).toBe(true);
143
+ expect(await FileSys.fileExists(path.join(spokeRoot, "README.md"))).toBe(true);
144
+ void hubRoot;
145
+ void clonePath;
146
+ });
147
+
148
+ test("filters the leaky generated blocks down to this spoke", async () => {
149
+ const { bareRoot, workRoot, spoke } = await makeFleet("leak-served", "leak-private", "leak-kit");
150
+ await spoke.push("develop", { verify: false });
151
+ const spokeRoot = await cloneSpoke(workRoot, bareRoot, "check");
152
+
153
+ const gitignore = await FileSys.readText(path.join(spokeRoot, ".gitignore"));
154
+ expect(gitignore).not.toContain("apps/private/secrets");
155
+ expect(gitignore).not.toContain("**/bun.lock");
156
+
157
+ const agents = await FileSys.readText(path.join(spokeRoot, "AGENTS.md"));
158
+ expect(agents).toContain("- Apps: leak-served");
159
+ expect(agents).toContain("- Libraries: leak-kit");
160
+ expect(agents).not.toContain("leak-private");
161
+ });
162
+
163
+ test("writes the anchor file and stamps the library it shipped", async () => {
164
+ const { bareRoot, workRoot, spoke } = await makeFleet("stamp-served", "stamp-private", "stamp-kit");
165
+ await spoke.push("develop", { verify: false });
166
+ const spokeRoot = await cloneSpoke(workRoot, bareRoot, "check");
167
+
168
+ const anchor = (await FileSys.readJson(path.join(spokeRoot, FleetSpoke.anchorFile))) as {
169
+ branch: string;
170
+ apps: string[];
171
+ };
172
+ expect(anchor.branch).toBe("develop");
173
+ expect(anchor.apps).toEqual(["stamp-served"]);
174
+
175
+ const manifest = (await FileSys.readJson(path.join(spokeRoot, "libs/stamp-kit/package.json"))) as {
176
+ akan: { source: { origin: string; hash: string } };
177
+ };
178
+ expect(manifest.akan.source.origin).toContain("#develop");
179
+ expect(manifest.akan.source.hash).toMatch(/^[0-9a-f]{32}$/);
180
+ });
181
+
182
+ test("is idempotent — a second push with no hub change is skipped", async () => {
183
+ const { spoke } = await makeFleet("idem-served", "idem-private", "idem-kit");
184
+ expect((await spoke.push("develop", { verify: false })).outcome).toBe("pushed");
185
+ expect((await spoke.push("develop", { verify: false })).outcome).toBe("skipped");
186
+ });
187
+
188
+ test("refuses a branch the spoke does not have, and one the config does not allow", async () => {
189
+ const { spoke } = await makeFleet("refuse-served", "refuse-private", "refuse-kit");
190
+ const missing = await spoke.push("release", { verify: false }).catch((error: Error) => error);
191
+ expect((missing as Error).message).toContain("not pushable");
192
+ });
193
+
194
+ test("installs the spoke-side guard, calling the CLI rather than shipping a script", async () => {
195
+ const { bareRoot, workRoot, spoke } = await makeFleet("guard-served", "guard-private", "guard-kit");
196
+ await spoke.push("develop", { verify: false });
197
+ const spokeRoot = await cloneSpoke(workRoot, bareRoot, "check");
198
+
199
+ const workflow = await FileSys.readText(path.join(spokeRoot, ".github/workflows/akan-fleet-guard.yml"));
200
+ expect(workflow).toContain("bunx akan fleet check");
201
+ expect(workflow).toContain("fetch-depth: 0");
202
+
203
+ const hook = await FileSys.readText(path.join(spokeRoot, ".husky/pre-commit"));
204
+ expect(hook.trim()).toBe(FleetSpoke.guardHookLine);
205
+ });
206
+
207
+ //? The fixture hub deliberately does not gitignore `.env`, so this also proves the clone-local exclude.
208
+ test("leaves the clone a runnable workspace root without shipping the file that makes it one", async () => {
209
+ const { bareRoot, workRoot, spoke, clonePath } = await makeFleet("env-served", "env-private", "env-kit");
210
+ await spoke.push("develop", { verify: false });
211
+
212
+ const localEnv = await FileSys.readText(path.join(clonePath, ".env"));
213
+ expect(localEnv).toContain("AKAN_PUBLIC_REPO_NAME=acme");
214
+ expect(localEnv).toContain("AKAN_PUBLIC_SERVE_DOMAIN=example.com");
215
+ expect(localEnv).not.toContain("hub");
216
+ expect(await FileSys.readText(path.join(clonePath, ".git/info/exclude"))).toContain("/.env");
217
+
218
+ const spokeRoot = await cloneSpoke(workRoot, bareRoot, "env-check");
219
+ expect(await FileSys.fileExists(path.join(spokeRoot, ".env"))).toBe(false);
220
+ });
221
+
222
+ test("reports a refusal's whole reason, indented, instead of one truncated line", () => {
223
+ const report = formatFleetPushResults([
224
+ { name: "acme", outcome: "refused", reason: "verification failed\nexit code: 1", changedFiles: 12 },
225
+ { name: "beta", outcome: "pushed", commit: "abc1234", changedFiles: 3 },
226
+ ]);
227
+ expect(report).toContain("refused acme verification failed");
228
+ expect(report).toContain(" exit code: 1");
229
+ expect(report).toContain("pushed beta abc1234 (3 files)");
230
+ });
231
+ });
232
+
233
+ describe("FleetSpoke status", () => {
234
+ test("reads clean right after a push, despite the stamp only the spoke carries", async () => {
235
+ const { spoke } = await makeFleet("status-served", "status-private", "status-kit");
236
+ await spoke.push("develop", { verify: false });
237
+
238
+ const status = await spoke.status("develop");
239
+
240
+ expect(status.hasBranch).toBe(true);
241
+ expect(status.behindPaths).toEqual([]);
242
+ expect(status.driftedLibs).toEqual([]);
243
+ expect(status.anchor.commit).toMatch(/^[0-9a-f]{40}$/);
244
+ expect(status.anchor.incoming).toEqual([]);
245
+ expect(formatFleetStatuses([status])).toContain("up to date, no customer commits");
246
+ });
247
+
248
+ test("names the drifted library and counts the customer commits", async () => {
249
+ const { bareRoot, workRoot, spoke } = await makeFleet("sdrift-served", "sdrift-private", "sdrift-kit");
250
+ await spoke.push("develop", { verify: false });
251
+
252
+ const spokeRoot = await cloneSpoke(workRoot, bareRoot, "customer");
253
+ await write(path.join(spokeRoot, "libs/sdrift-kit/common/helper.ts"), "export const helper = 99;\n");
254
+ await git(spokeRoot, ["add", "-A"]);
255
+ await git(spokeRoot, ["commit", "--quiet", "-m", "lib hack"]);
256
+ await git(spokeRoot, ["push", "--quiet", "origin", "develop"]);
257
+
258
+ const status = await spoke.status("develop");
259
+
260
+ expect(status.driftedLibs).toEqual(["sdrift-kit"]);
261
+ expect(status.anchor.incoming.map((commit) => commit.subject)).toEqual(["lib hack"]);
262
+ expect(formatFleetStatuses([status])).toContain("DRIFTED LIBS: sdrift-kit");
263
+ });
264
+
265
+ test("reports a spoke with no such branch instead of creating one", async () => {
266
+ const { spoke } = await makeFleet("nobranch-served", "nobranch-private", "nobranch-kit");
267
+ const status = await spoke.status("main");
268
+
269
+ expect(status.hasBranch).toBe(false);
270
+ expect(formatFleetStatuses([status])).toContain("no such branch");
271
+ });
272
+ });
273
+
274
+ describe("FleetSpoke diff", () => {
275
+ test("reads as what a push would apply, with library changes split out", async () => {
276
+ const { bareRoot, workRoot, spoke } = await makeFleet("diff-served", "diff-private", "diff-kit");
277
+ await spoke.push("develop", { verify: false });
278
+
279
+ const spokeRoot = await cloneSpoke(workRoot, bareRoot, "customer");
280
+ await write(path.join(spokeRoot, "libs/diff-kit/common/helper.ts"), "export const helper = 99;\n");
281
+ await write(path.join(spokeRoot, "apps/diff-served/lib/task/task.service.ts"), "export const fixed = true;\n");
282
+ await git(spokeRoot, ["add", "-A"]);
283
+ await git(spokeRoot, ["commit", "--quiet", "-m", "customer work"]);
284
+ await git(spokeRoot, ["push", "--quiet", "origin", "develop"]);
285
+
286
+ const result = await spoke.diff("develop");
287
+
288
+ expect(result.libs.files).toEqual(["libs/diff-kit/common/helper.ts"]);
289
+ expect(result.app.files).toEqual(["apps/diff-served/lib/task/task.service.ts"]);
290
+ //? Direction: the hub would restore `helper = 1` and delete the file the spoke added.
291
+ expect(result.libs.patch).toContain("-export const helper = 99;");
292
+ expect(result.libs.patch).toContain("+export const helper = 1;");
293
+ expect(formatFleetDiff(result)).toContain("LIBRARY changes (1)");
294
+ });
295
+
296
+ test("is empty right after a push", async () => {
297
+ const { spoke } = await makeFleet("quietdiff-served", "quietdiff-private", "quietdiff-kit");
298
+ await spoke.push("develop", { verify: false });
299
+
300
+ const result = await spoke.diff("develop");
301
+ expect(result.app.files).toEqual([]);
302
+ expect(result.libs.files).toEqual([]);
303
+ expect(formatFleetDiff(result)).toContain("identical to the hub");
304
+ });
305
+ });
306
+
307
+ describe("FleetSpoke pull", () => {
308
+ test("applies the customer's app commits and holds their library edits back", async () => {
309
+ const { hubRoot, bareRoot, workRoot, spoke } = await makeFleet("pull-served", "pull-private", "pull-kit");
310
+ await spoke.push("develop", { verify: false });
311
+
312
+ const spokeRoot = await cloneSpoke(workRoot, bareRoot, "customer");
313
+ await write(path.join(spokeRoot, "apps/pull-served/lib/task/task.service.ts"), "export const fixed = true;\n");
314
+ await write(path.join(spokeRoot, "libs/pull-kit/common/helper.ts"), "export const helper = 99;\n");
315
+ await write(path.join(spokeRoot, "apps/pull-served/env/env.server.local.ts"), "export const env = {};\n");
316
+ await git(spokeRoot, ["add", "-A"]);
317
+ await git(spokeRoot, ["commit", "--quiet", "-m", "customer hotfix"]);
318
+ await git(spokeRoot, ["push", "--quiet", "origin", "develop"]);
319
+
320
+ const result = await spoke.pull("develop");
321
+
322
+ expect(result.incoming.map((commit) => commit.subject)).toEqual(["customer hotfix"]);
323
+ expect(result.applied).toEqual(["apps/pull-served/lib/task/task.service.ts"]);
324
+ expect(await FileSys.fileExists(path.join(hubRoot, "apps/pull-served/lib/task/task.service.ts"))).toBe(true);
325
+
326
+ expect(result.libPatch?.files).toEqual(["libs/pull-kit/common/helper.ts"]);
327
+ expect(await FileSys.readText(path.join(hubRoot, "libs/pull-kit/common/helper.ts"))).toContain("helper = 1;");
328
+ expect(await FileSys.fileExists(path.join(hubRoot, result.libPatch?.path ?? "missing"))).toBe(true);
329
+ });
330
+
331
+ test("adopts library edits only when asked", async () => {
332
+ const { hubRoot, bareRoot, workRoot, spoke } = await makeFleet("adopt-served", "adopt-private", "adopt-kit");
333
+ await spoke.push("develop", { verify: false });
334
+
335
+ const spokeRoot = await cloneSpoke(workRoot, bareRoot, "customer");
336
+ await write(path.join(spokeRoot, "libs/adopt-kit/common/helper.ts"), "export const helper = 99;\n");
337
+ await git(spokeRoot, ["add", "-A"]);
338
+ await git(spokeRoot, ["commit", "--quiet", "-m", "lib tweak"]);
339
+ await git(spokeRoot, ["push", "--quiet", "origin", "develop"]);
340
+
341
+ const result = await spoke.pull("develop", { adoptLibs: true });
342
+
343
+ expect(result.applied).toEqual(["libs/adopt-kit/common/helper.ts"]);
344
+ expect(result.libPatch).toBeNull();
345
+ expect(await FileSys.readText(path.join(hubRoot, "libs/adopt-kit/common/helper.ts"))).toContain("helper = 99;");
346
+ });
347
+
348
+ test("is a no-op when the spoke has no commits of its own", async () => {
349
+ const { spoke } = await makeFleet("quiet-served", "quiet-private", "quiet-kit");
350
+ await spoke.push("develop", { verify: false });
351
+
352
+ const result = await spoke.pull("develop");
353
+ expect(result.incoming).toEqual([]);
354
+ expect(result.applied).toEqual([]);
355
+ });
356
+ });
357
+
358
+ describe("FleetConfig", () => {
359
+ test("refuses one app claimed by two spokes", () => {
360
+ expect(
361
+ () =>
362
+ new FleetConfig({
363
+ spokes: [
364
+ { name: "a", repo: "a.git", apps: ["shared"] },
365
+ { name: "b", repo: "b.git", apps: ["shared"] },
366
+ ],
367
+ }),
368
+ ).toThrow(/claimed by both/);
369
+ });
370
+
371
+ test("refuses a spoke with no apps and a duplicate name", () => {
372
+ expect(() => new FleetConfig({ spokes: [{ name: "a", repo: "a.git", apps: [] }] })).toThrow(/declares no apps/);
373
+ expect(
374
+ () =>
375
+ new FleetConfig({
376
+ spokes: [
377
+ { name: "a", repo: "a.git", apps: ["one"] },
378
+ { name: "a", repo: "b.git", apps: ["two"] },
379
+ ],
380
+ }),
381
+ ).toThrow(/duplicate spoke/);
382
+ });
383
+
384
+ test("defaults the pushable branches and rejects anything else", () => {
385
+ const config = new FleetConfig({ spokes: [{ name: "a", repo: "a.git", apps: ["one"] }] });
386
+ expect(config.pushableBranches).toEqual(FleetConfig.defaultPushableBranches);
387
+ expect(() => config.assertPushable("feature/x")).toThrow(/not pushable/);
388
+ expect(() => config.assertPushable("develop")).not.toThrow();
389
+ });
390
+ });