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

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.
@@ -0,0 +1,76 @@
1
+ import path from "node:path";
2
+ import type { SubspaceConfigInput, SubspaceDeclaration } from "akanjs";
3
+ import type { WorkspaceExecutor } from "./executors";
4
+ import { FileSys } from "./fileSys";
5
+
6
+ export type { SubspaceConfigInput, SubspaceDeclaration };
7
+
8
+ /**
9
+ * `akan.subspace.ts` at the workspace root: which customer repo each app is mirrored to.
10
+ *
11
+ * There is no branch field. The branch is whichever one the workspace is on, so the same declaration
12
+ * serves `develop` and `main`, and every subspace on one branch holds the same akanjs version and the
13
+ * same library source.
14
+ */
15
+ export class SubspaceConfig {
16
+ static readonly fileName = "akan.subspace.ts";
17
+ static readonly defaultPushableBranches = ["main", "develop", "debug"];
18
+
19
+ static async from(workspace: WorkspaceExecutor): Promise<SubspaceConfig | null> {
20
+ const configPath = path.join(workspace.workspaceRoot, SubspaceConfig.fileName);
21
+ if (!(await FileSys.fileExists(configPath))) return null;
22
+ const input = await import(configPath).then((mod: { default: SubspaceConfigInput }) => mod.default);
23
+ return new SubspaceConfig(input);
24
+ }
25
+
26
+ readonly pushableBranches: string[];
27
+ readonly exclude: string[];
28
+ readonly subspaces: SubspaceDeclaration[];
29
+
30
+ constructor(input: SubspaceConfigInput) {
31
+ this.pushableBranches = input.pushableBranches ?? SubspaceConfig.defaultPushableBranches;
32
+ this.exclude = input.exclude ?? [];
33
+ this.subspaces = input.subspaces ?? [];
34
+ this.#assertValid();
35
+ }
36
+
37
+ #assertValid() {
38
+ const seenNames = new Set<string>();
39
+ const appOwners = new Map<string, string>();
40
+ for (const subspace of this.subspaces) {
41
+ if (!subspace.name || !subspace.repo)
42
+ throw new Error(`${SubspaceConfig.fileName}: every subspace needs a name and a repo`);
43
+ if (seenNames.has(subspace.name))
44
+ throw new Error(`${SubspaceConfig.fileName}: duplicate subspace "${subspace.name}"`);
45
+ seenNames.add(subspace.name);
46
+ if (!subspace.apps?.length)
47
+ throw new Error(`${SubspaceConfig.fileName}: subspace "${subspace.name}" declares no apps`);
48
+ for (const app of subspace.apps) {
49
+ const owner = appOwners.get(app);
50
+ //* One app never goes to two subspaces: the two would need per-subspace domains and ids, which
51
+ //* this declaration cannot express. Share code through a lib instead.
52
+ if (owner)
53
+ throw new Error(
54
+ `${SubspaceConfig.fileName}: app "${app}" is claimed by both "${owner}" and "${subspace.name}"`,
55
+ );
56
+ appOwners.set(app, subspace.name);
57
+ }
58
+ }
59
+ }
60
+
61
+ select(names: string[]) {
62
+ if (!names.length) return this.subspaces;
63
+ return names.map((name) => {
64
+ const subspace = this.subspaces.find((candidate) => candidate.name === name);
65
+ if (!subspace) throw new Error(`${SubspaceConfig.fileName}: unknown subspace "${name}"`);
66
+ return subspace;
67
+ });
68
+ }
69
+
70
+ assertPushable(branch: string) {
71
+ if (this.pushableBranches.includes(branch)) return;
72
+ throw new Error(
73
+ `Branch "${branch}" is not pushable. ${SubspaceConfig.fileName} allows: ${this.pushableBranches.join(", ")}`,
74
+ );
75
+ }
76
+ }
package/fleetConfig.ts DELETED
@@ -1,73 +0,0 @@
1
- import path from "node:path";
2
- import type { FleetConfigInput, FleetGuardConfig, FleetSpokeDeclaration } from "akanjs";
3
- import type { WorkspaceExecutor } from "./executors";
4
- import { FileSys } from "./fileSys";
5
-
6
- export type { FleetConfigInput, FleetGuardConfig, FleetSpokeDeclaration };
7
-
8
- /**
9
- * `akan.fleet.ts` at the workspace root: which customer repo each app is mirrored to.
10
- *
11
- * There is no branch field. The branch is whichever one the hub is on, so the same declaration serves
12
- * `develop` and `main` and every spoke on one branch holds the same akanjs version and library source.
13
- */
14
- export class FleetConfig {
15
- static readonly fileName = "akan.fleet.ts";
16
- static readonly defaultPushableBranches = ["main", "develop", "debug"];
17
- static readonly defaultGuard: FleetGuardConfig = { ci: "github", preCommit: true };
18
-
19
- static async from(workspace: WorkspaceExecutor): Promise<FleetConfig | null> {
20
- const configPath = path.join(workspace.workspaceRoot, FleetConfig.fileName);
21
- if (!(await FileSys.fileExists(configPath))) return null;
22
- const input = await import(configPath).then((mod: { default: FleetConfigInput }) => mod.default);
23
- return new FleetConfig(input);
24
- }
25
-
26
- readonly pushableBranches: string[];
27
- readonly exclude: string[];
28
- readonly guard: FleetGuardConfig;
29
- readonly spokes: FleetSpokeDeclaration[];
30
-
31
- constructor(input: FleetConfigInput) {
32
- this.pushableBranches = input.pushableBranches ?? FleetConfig.defaultPushableBranches;
33
- this.exclude = input.exclude ?? [];
34
- this.guard = { ...FleetConfig.defaultGuard, ...input.guard };
35
- this.spokes = input.spokes ?? [];
36
- this.#assertValid();
37
- }
38
-
39
- #assertValid() {
40
- const seenNames = new Set<string>();
41
- const appOwners = new Map<string, string>();
42
- for (const spoke of this.spokes) {
43
- if (!spoke.name || !spoke.repo) throw new Error(`${FleetConfig.fileName}: every spoke needs a name and a repo`);
44
- if (seenNames.has(spoke.name)) throw new Error(`${FleetConfig.fileName}: duplicate spoke "${spoke.name}"`);
45
- seenNames.add(spoke.name);
46
- if (!spoke.apps?.length) throw new Error(`${FleetConfig.fileName}: spoke "${spoke.name}" declares no apps`);
47
- for (const app of spoke.apps) {
48
- const owner = appOwners.get(app);
49
- //* One app never goes to two spokes: the two would need per-spoke domains and ids, which this
50
- //* declaration cannot express. Share code through a lib instead.
51
- if (owner)
52
- throw new Error(`${FleetConfig.fileName}: app "${app}" is claimed by both "${owner}" and "${spoke.name}"`);
53
- appOwners.set(app, spoke.name);
54
- }
55
- }
56
- }
57
-
58
- select(names: string[]) {
59
- if (!names.length) return this.spokes;
60
- return names.map((name) => {
61
- const spoke = this.spokes.find((candidate) => candidate.name === name);
62
- if (!spoke) throw new Error(`${FleetConfig.fileName}: unknown spoke "${name}"`);
63
- return spoke;
64
- });
65
- }
66
-
67
- assertPushable(branch: string) {
68
- if (this.pushableBranches.includes(branch)) return;
69
- throw new Error(
70
- `Branch "${branch}" is not pushable. ${FleetConfig.fileName} allows: ${this.pushableBranches.join(", ")}`,
71
- );
72
- }
73
- }
@@ -1,121 +0,0 @@
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 DELETED
@@ -1,157 +0,0 @@
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
- }