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

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.
@@ -2,106 +2,93 @@ import { mkdir, readdir, rename, rm } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { AppExecutor, Executor, WorkspaceExecutor } from "./executors";
4
4
  import { FileSys } from "./fileSys";
5
- import type { FleetConfig, FleetSpokeDeclaration } from "./fleetConfig";
6
5
  import { LibSource } from "./libSource";
7
- import { SlicePlanner } from "./slicePlanner";
6
+ import { type SlicePlan, SlicePlanner } from "./slicePlanner";
7
+ import type { SubspaceConfig, SubspaceDeclaration } from "./subspaceConfig";
8
+ import type { PackageJson } from "./types";
8
9
 
9
- export interface FleetIncomingCommit {
10
+ export interface SubspaceIncomingCommit {
10
11
  sha: string;
11
12
  author: string;
12
13
  subject: string;
13
14
  }
14
15
 
15
- export interface FleetAnchor {
16
- /** Spoke commit that last carried a push, located by the one file only a push writes. */
16
+ export interface SubspaceAnchor {
17
+ /** Subspace commit that last carried a push, located by the one file only a push writes. */
17
18
  commit: string | null;
18
- /** Commits the spoke gained since then — customer work that has never been in the hub. */
19
- incoming: FleetIncomingCommit[];
19
+ /** Commits the subspace gained since then — customer work that has never been in the workspace. */
20
+ incoming: SubspaceIncomingCommit[];
20
21
  }
21
22
 
22
- export interface FleetSpokeStatus {
23
+ export interface SubspaceStatus {
23
24
  name: string;
24
25
  branch: string;
25
- /** False when the spoke has no such branch: a push refuses it rather than creating it. */
26
+ /** False when the subspace has no such branch: a push refuses it rather than creating it. */
26
27
  hasBranch: boolean;
27
- anchor: FleetAnchor;
28
- /** Slice paths where hub and spoke disagree — what a push would change. */
28
+ anchor: SubspaceAnchor;
29
+ /** Slice paths where workspace and subspace disagree — what a push would change. */
29
30
  behindPaths: string[];
30
- /** Libraries the spoke edited on its own: the drift this mechanism exists to stop. */
31
+ /** Libraries the subspace edited on its own: the drift this mechanism exists to stop. */
31
32
  driftedLibs: string[];
32
33
  }
33
34
 
34
- export interface FleetDiffSection {
35
+ export interface SubspaceDiffSection {
35
36
  files: string[];
36
37
  patch: string;
37
38
  }
38
39
 
39
- export interface FleetDiffResult {
40
+ export interface SubspaceDiffResult {
40
41
  name: string;
41
42
  branch: string;
42
- /** What `akan fleet push` would change in the spoke, split so library changes are impossible to miss. */
43
- app: FleetDiffSection;
44
- libs: FleetDiffSection;
43
+ /** What `akan subspace push` would change in the subspace, split so library changes are impossible to miss. */
44
+ app: SubspaceDiffSection;
45
+ libs: SubspaceDiffSection;
45
46
  }
46
47
 
47
- export interface FleetPushResult {
48
+ export interface SubspacePushResult {
48
49
  name: string;
49
50
  outcome: "pushed" | "skipped" | "refused";
50
51
  reason?: string;
51
52
  commit?: string;
52
53
  changedFiles: number;
54
+ /** Workspace-root dependencies left out of this subspace's manifest, because its apps do not use them. */
55
+ prunedDependencies?: string[];
53
56
  }
54
57
 
55
- export interface FleetPullResult {
58
+ export interface SubspacePullResult {
56
59
  name: string;
57
60
  applied: string[];
58
61
  /** Library hunks, left on disk as a patch instead of applied unless `adoptLibs` was passed. */
59
62
  libPatch: { path: string; files: string[] } | null;
60
63
  ignored: string[];
61
- incoming: FleetIncomingCommit[];
64
+ incoming: SubspaceIncomingCommit[];
62
65
  }
63
66
 
64
67
  /**
65
- * One customer repo, mirrored from this hub.
68
+ * One customer repo, mirrored from this workspace.
66
69
  *
67
- * Push is a squashed snapshot of the hub's own tracked files, so a spoke's history never carries the
68
- * hub's — which is also what keeps one customer's commit messages out of another's repo. Pull is the
69
- * reverse and rare: it compares the spoke against the last push it received rather than against the hub,
70
- * so the diff is exactly the customer's own work however far the hub has moved on.
70
+ * Push is a squashed snapshot of the workspace's own tracked files, so a subspace's history never
71
+ * carries the workspace's — which is also what keeps one customer's commit messages out of another's
72
+ * repo. Pull is the reverse and rare: it compares the subspace against the last push it received rather
73
+ * than against the workspace, so the diff is exactly the customer's own work however far the workspace
74
+ * has moved on.
71
75
  */
72
- export class FleetSpoke {
73
- static readonly anchorFile = "akan.fleet.json";
74
- /** Never reaches a spoke: it names every other customer's repo. */
75
- static readonly hubOnlyEntries = ["akan.fleet.ts"];
76
+ export class Subspace {
77
+ static readonly anchorFile = "akan.subspace.json";
78
+ /** Never reaches a subspace: it names every other customer's repo. */
79
+ static readonly workspaceOnlyEntries = ["akan.subspace.ts"];
76
80
  /** Workspace members, which are replaced wholesale rather than overlaid. */
77
81
  static readonly memberDirs = ["apps", "libs"];
78
- /** Spoke-owned in both directions: env values belong to the repo that deploys, not to the hub. */
79
- static readonly spokeOwnedDirs = ["env"];
82
+ /** Subspace-owned in both directions: env values belong to the repo that deploys, not to the workspace. */
83
+ static readonly subspaceOwnedDirs = ["env"];
80
84
  static readonly secretsMarkers = ["# akan:secrets (managed by akan.config.ts — do not edit)", "# akan:secrets:end"];
81
- static readonly holdDir = ".akan-fleet-hold";
82
- static readonly guardHookLine = "bunx akan fleet check --staged --warn";
83
- //? `fetch-depth: 0`: the guard locates the last push through history, which a shallow checkout lacks.
84
- static readonly guardWorkflow = [
85
- "name: akan fleet guard",
86
- "on: [push, pull_request]",
87
- "jobs:",
88
- " guard:",
89
- " runs-on: ubuntu-latest",
90
- " steps:",
91
- " - uses: actions/checkout@v4",
92
- " with:",
93
- " fetch-depth: 0",
94
- " - uses: oven-sh/setup-bun@v2",
95
- " - run: bun install",
96
- " - run: bunx akan fleet check",
97
- "",
98
- ].join("\n");
85
+ static readonly holdDir = ".akan-subspace-hold";
99
86
 
100
87
  #workspace: WorkspaceExecutor;
101
- #config: FleetConfig;
102
- #declaration: FleetSpokeDeclaration;
88
+ #config: SubspaceConfig;
89
+ #declaration: SubspaceDeclaration;
103
90
 
104
- constructor(workspace: WorkspaceExecutor, config: FleetConfig, declaration: FleetSpokeDeclaration) {
91
+ constructor(workspace: WorkspaceExecutor, config: SubspaceConfig, declaration: SubspaceDeclaration) {
105
92
  this.#workspace = workspace;
106
93
  this.#config = config;
107
94
  this.#declaration = declaration;
@@ -114,10 +101,10 @@ export class FleetSpoke {
114
101
  return this.#declaration.apps;
115
102
  }
116
103
  get #remote() {
117
- return `spoke-${this.#declaration.name}`;
104
+ return `subspace-${this.#declaration.name}`;
118
105
  }
119
106
  get #clonePath() {
120
- return path.join(this.#workspace.workspaceRoot, ".akan/fleet", this.#declaration.name);
107
+ return path.join(this.#workspace.workspaceRoot, ".akan/subspace", this.#declaration.name);
121
108
  }
122
109
 
123
110
  async #git(args: string[]) {
@@ -132,7 +119,7 @@ export class FleetSpoke {
132
119
  return (await this.#git(["rev-parse", "--short", "HEAD"])).trim();
133
120
  }
134
121
 
135
- /** Adds the spoke as a remote if absent, then fetches the branch into this hub's object store. */
122
+ /** Adds the subspace as a remote if absent, then fetches the branch into this workspace's object store. */
136
123
  async fetch(branch: string) {
137
124
  const remotes = (await this.#git(["remote"])).split("\n").map((line) => line.trim());
138
125
  if (remotes.includes(this.#remote)) await this.#git(["remote", "set-url", this.#remote, this.#declaration.repo]);
@@ -141,18 +128,18 @@ export class FleetSpoke {
141
128
  await this.#git(["fetch", "--quiet", this.#remote, branch]);
142
129
  return true;
143
130
  } catch {
144
- //? A spoke that has never had this branch is not an error here — `push` refuses it by name.
131
+ //? A subspace that has never had this branch is not an error here — `push` refuses it by name.
145
132
  return false;
146
133
  }
147
134
  }
148
135
 
149
136
  /**
150
137
  * The last push, located by the one file only a push writes. A tag or a recorded sha would have to be
151
- * kept in step by hand; this file is already in the spoke's history and cannot drift out of it.
138
+ * kept in step by hand; this file is already in the subspace's history and cannot drift out of it.
152
139
  */
153
- async anchor(branch: string): Promise<FleetAnchor> {
140
+ async anchor(branch: string): Promise<SubspaceAnchor> {
154
141
  const ref = `${this.#remote}/${branch}`;
155
- const commit = (await this.#git(["log", "-1", "--format=%H", ref, "--", FleetSpoke.anchorFile])).trim();
142
+ const commit = (await this.#git(["log", "-1", "--format=%H", ref, "--", Subspace.anchorFile])).trim();
156
143
  if (!commit) return { commit: null, incoming: [] };
157
144
  const log = await this.#git(["log", "--format=%H%x09%an%x09%s", `${commit}..${ref}`]);
158
145
  const incoming = log
@@ -165,7 +152,7 @@ export class FleetSpoke {
165
152
  return { commit, incoming };
166
153
  }
167
154
 
168
- /** App and lib directories this spoke carries. Libraries come from each app's closure, never declared. */
155
+ /** App and lib directories this subspace carries. Libraries come from each app's closure, never declared. */
169
156
  async slice() {
170
157
  const plans = await Promise.all(
171
158
  this.#declaration.apps.map(
@@ -177,15 +164,15 @@ export class FleetSpoke {
177
164
  return { plans, libs, paths };
178
165
  }
179
166
 
180
- #isSpokeOwned(file: string) {
167
+ #isSubspaceOwned(file: string) {
181
168
  const segments = file.split("/");
182
- if (!FleetSpoke.memberDirs.includes(segments[0] ?? "")) return false;
183
- return FleetSpoke.spokeOwnedDirs.includes(segments[2] ?? "");
169
+ if (!Subspace.memberDirs.includes(segments[0] ?? "")) return false;
170
+ return Subspace.subspaceOwnedDirs.includes(segments[2] ?? "");
184
171
  }
185
172
 
186
173
  /**
187
- * A library manifest always differs: the spoke's copy carries the `akan.source` stamp a push writes and
188
- * the hub's does not. Comparing it with that key removed is what keeps `status` and `diff` from
174
+ * A library manifest always differs: the subspace's copy carries the `akan.source` stamp a push writes and
175
+ * the workspace's does not. Comparing it with that key removed is what keeps `status` and `diff` from
189
176
  * reporting every library as changed forever.
190
177
  */
191
178
  #isStamped(file: string) {
@@ -217,11 +204,11 @@ export class FleetSpoke {
217
204
  return files.filter((_, index) => kept[index]);
218
205
  }
219
206
 
220
- async status(branch: string): Promise<FleetSpokeStatus> {
207
+ async status(branch: string): Promise<SubspaceStatus> {
221
208
  const hasBranch = await this.fetch(branch);
222
209
  const [{ paths, libs }, anchor] = await Promise.all([
223
210
  this.slice(),
224
- hasBranch ? this.anchor(branch) : Promise.resolve<FleetAnchor>({ commit: null, incoming: [] }),
211
+ hasBranch ? this.anchor(branch) : Promise.resolve<SubspaceAnchor>({ commit: null, incoming: [] }),
225
212
  ]);
226
213
  if (!hasBranch) return { name: this.name, branch, hasBranch, anchor, behindPaths: [], driftedLibs: [] };
227
214
  const ref = `${this.#remote}/${branch}`;
@@ -231,7 +218,7 @@ export class FleetSpoke {
231
218
  diff
232
219
  .split("\n")
233
220
  .filter((file) => !!file.trim())
234
- .filter((file) => !this.#isSpokeOwned(file)),
221
+ .filter((file) => !this.#isSubspaceOwned(file)),
235
222
  );
236
223
  const driftedLibs = libs.filter((lib) => behindPaths.some((file) => file.startsWith(`libs/${lib}/`)));
237
224
  return { name: this.name, branch, hasBranch, anchor, behindPaths, driftedLibs };
@@ -243,23 +230,23 @@ export class FleetSpoke {
243
230
  await mkdir(path.dirname(this.#clonePath), { recursive: true });
244
231
  await this.#workspace.spawn("git", ["clone", "--quiet", this.#declaration.repo, this.#clonePath]);
245
232
  }
246
- const clone = new Executor(`spoke-${this.name}`, this.#clonePath);
233
+ const clone = new Executor(`subspace-${this.name}`, this.#clonePath);
247
234
  await clone.spawn("git", ["fetch", "--quiet", "origin", branch]);
248
235
  await clone.spawn("git", ["checkout", "--quiet", "-B", branch, `origin/${branch}`]);
249
236
  await clone.spawn("git", ["clean", "-qfd"]);
250
237
  return clone;
251
238
  }
252
239
 
253
- /** Moves the spoke's env trees aside, so replacing the members cannot take them with it. */
254
- async #holdSpokeOwned() {
240
+ /** Moves the subspace's env trees aside, so replacing the members cannot take them with it. */
241
+ async #holdSubspaceOwned() {
255
242
  const held: { from: string; to: string }[] = [];
256
- const holdRoot = path.join(this.#clonePath, FleetSpoke.holdDir);
243
+ const holdRoot = path.join(this.#clonePath, Subspace.holdDir);
257
244
  await rm(holdRoot, { recursive: true, force: true });
258
- for (const member of FleetSpoke.memberDirs) {
245
+ for (const member of Subspace.memberDirs) {
259
246
  const memberRoot = path.join(this.#clonePath, member);
260
247
  if (!(await FileSys.dirExists(memberRoot))) continue;
261
248
  for (const name of await readdir(memberRoot)) {
262
- for (const owned of FleetSpoke.spokeOwnedDirs) {
249
+ for (const owned of Subspace.subspaceOwnedDirs) {
263
250
  const from = path.join(memberRoot, name, owned);
264
251
  if (!(await FileSys.dirExists(from))) continue;
265
252
  const to = path.join(holdRoot, member, name, owned);
@@ -273,13 +260,13 @@ export class FleetSpoke {
273
260
  }
274
261
 
275
262
  /**
276
- * Puts back only the files the hub does not ship. The hub still owns the tracked env switch files
263
+ * Puts back only the files the workspace does not ship. The workspace still owns the tracked env switch files
277
264
  * (`env.server.ts` / `env.client.ts`), which is what keeps them in step; everything else under `env/` —
278
- * the per-environment values, which the hub gitignores and never had — belongs to the spoke and wins.
265
+ * the per-environment values, which the workspace gitignores and never had — belongs to the subspace and wins.
279
266
  */
280
- async #restoreSpokeOwned(held: { from: string; to: string }[]) {
281
- for (const { from, to } of held) await FleetSpoke.#copyMissing(to, from);
282
- await rm(path.join(this.#clonePath, FleetSpoke.holdDir), { recursive: true, force: true });
267
+ async #restoreSubspaceOwned(held: { from: string; to: string }[]) {
268
+ for (const { from, to } of held) await Subspace.#copyMissing(to, from);
269
+ await rm(path.join(this.#clonePath, Subspace.holdDir), { recursive: true, force: true });
283
270
  }
284
271
 
285
272
  static async #copyMissing(from: string, to: string) {
@@ -287,7 +274,7 @@ export class FleetSpoke {
287
274
  for (const entry of await readdir(from, { withFileTypes: true })) {
288
275
  const source = path.join(from, entry.name);
289
276
  const target = path.join(to, entry.name);
290
- if (entry.isDirectory()) await FleetSpoke.#copyMissing(source, target);
277
+ if (entry.isDirectory()) await Subspace.#copyMissing(source, target);
291
278
  else if (!(await FileSys.entryExists(target))) await rename(source, target);
292
279
  }
293
280
  }
@@ -307,9 +294,9 @@ export class FleetSpoke {
307
294
  }
308
295
 
309
296
  /**
310
- * Verify-only, and an allowlist rather than a filter so no hub secret can reach a customer's clone even
311
- * by accident. `AKAN_PUBLIC_*` values are the ones akan embeds in every client bundle, so they are public
312
- * by construction; the workspace id is pinned to `local` so the hub's cloud workspace never travels.
297
+ * Verify-only, and an allowlist rather than a filter so no secret can reach a customer's clone even by
298
+ * accident. `AKAN_PUBLIC_*` values are the ones akan embeds in every client bundle, so they are public
299
+ * by construction; the cloud workspace id is pinned to `local` rather than copied.
313
300
  */
314
301
  #localEnv() {
315
302
  const { serveDomain } = WorkspaceExecutor.getBaseDevEnv();
@@ -326,9 +313,9 @@ export class FleetSpoke {
326
313
  /**
327
314
  * The CLI identifies a workspace root by `package.json` + `tsconfig.json` + `.env`, so a clone with no
328
315
  * `.env` cannot run `akan sync` at all — and one can never arrive with the slice, since every akan
329
- * workspace gitignores it and the spoke's real values are spoke-owned. Excluded through the clone's own
330
- * `.git/info/exclude` rather than trusting the hub's `.gitignore`: a hub that omitted the pattern would
331
- * otherwise commit this file into the customer's repo.
316
+ * workspace gitignores it and the subspace's real values are its own. Excluded through the clone's
317
+ * `.git/info/exclude` rather than through the copied `.gitignore`: a workspace that omitted the pattern
318
+ * would otherwise commit this file into the customer's repo.
332
319
  */
333
320
  async #writeLocalEnv() {
334
321
  const excludePath = path.join(this.#clonePath, ".git/info/exclude");
@@ -343,38 +330,61 @@ export class FleetSpoke {
343
330
  await FileSys.writeText(envPath, `${lines.join("\n")}\n`);
344
331
  }
345
332
 
333
+ /**
334
+ * A workspace's root manifest is the union of every app it holds, so shipping it verbatim installs
335
+ * every other customer's dependency tree in this repo. It is rebuilt from this subspace's own slices
336
+ * instead, keeping the workspace's exact version specs.
337
+ */
338
+ async #rewriteManifest(plans: SlicePlan[]) {
339
+ const manifestPath = path.join(this.#clonePath, "package.json");
340
+ if (!(await FileSys.fileExists(manifestPath))) return [];
341
+ const rootPackageJson = (await FileSys.readJson(manifestPath)) as PackageJson;
342
+ const { packageJson, pruned, warnings } = await SlicePlanner.pruneDependencies(
343
+ this.#workspace,
344
+ rootPackageJson,
345
+ plans.flatMap((plan) => plan.requiredDependencies),
346
+ );
347
+ for (const warning of warnings) this.#workspace.logger.warn(warning);
348
+ await FileSys.writeJson(manifestPath, {
349
+ ...packageJson,
350
+ name: this.name,
351
+ description: `${this.name} workspace`,
352
+ });
353
+ return pruned;
354
+ }
355
+
346
356
  async #applySlice(clone: Executor, branch: string) {
347
- const { libs, paths } = await this.slice();
348
- const held = await this.#holdSpokeOwned();
349
- for (const member of FleetSpoke.memberDirs)
357
+ const { libs, paths, plans } = await this.slice();
358
+ const held = await this.#holdSubspaceOwned();
359
+ for (const member of Subspace.memberDirs)
350
360
  await rm(path.join(this.#clonePath, member), { recursive: true, force: true });
351
361
 
352
- //* The shell is overlaid, never reconciled: a spoke owns its deployment (its own CI files and
353
- //* workflows live there), so a root entry the hub does not have is left alone rather than deleted.
362
+ //* The shell is overlaid, never reconciled: a subspace owns its deployment (its own CI files and
363
+ //* workflows live there), so a root entry the workspace does not have is left alone rather than deleted.
354
364
  await this.#extract(["."], ["apps/*", "libs/*", "pkgs/*"]);
355
365
  await this.#extract(paths);
356
- await this.#restoreSpokeOwned(held);
366
+ await this.#restoreSubspaceOwned(held);
357
367
 
358
- for (const entry of [...FleetSpoke.hubOnlyEntries, ...this.#config.exclude])
368
+ for (const entry of [...Subspace.workspaceOnlyEntries, ...this.#config.exclude])
359
369
  await rm(path.join(this.#clonePath, entry), { recursive: true, force: true });
360
370
 
361
371
  await this.#rewriteGitignore();
372
+ const pruned = await this.#rewriteManifest(plans);
362
373
  await this.#rewriteWorkspaceSection(libs);
363
- await this.#writeGuards();
364
374
  await this.#writeLocalEnv();
365
375
  for (const lib of libs) await this.#stampLib(clone, lib, branch);
366
- return { libs };
376
+ return { libs, pruned };
367
377
  }
368
378
 
369
379
  /**
370
- * The `akan:secrets` block akan generates lists every app in the hub by name, so it is filtered down to
371
- * this spoke's own apps. `bun.lock` is un-ignored because a spoke commits its lockfile — that is what
372
- * makes two spokes on one branch resolve the same dependency tree rather than merely the same ranges.
380
+ * The `akan:secrets` block akan generates lists every app in the workspace by name, so it is filtered down to
381
+ * this subspace's own apps. `bun.lock` is un-ignored because a subspace commits its lockfile — that is what
382
+ * makes two subspaces on one branch resolve the same dependency tree rather than merely the same ranges.
373
383
  */
374
384
  async #rewriteGitignore() {
375
385
  const gitignorePath = path.join(this.#clonePath, ".gitignore");
376
386
  if (!(await FileSys.fileExists(gitignorePath))) return;
377
- const [begin = "", end = ""] = FleetSpoke.secretsMarkers;
387
+ const [begin = "", end = ""] = Subspace.secretsMarkers;
378
388
  const lines = (await FileSys.readText(gitignorePath)).split("\n");
379
389
  const beginIdx = lines.indexOf(begin);
380
390
  const endIdx = lines.indexOf(end);
@@ -391,7 +401,7 @@ export class FleetSpoke {
391
401
  await FileSys.writeText(gitignorePath, filtered.filter((line) => line.trim() !== "**/bun.lock").join("\n"));
392
402
  }
393
403
 
394
- /** The generated `## Workspace` block names every app and library in the hub. */
404
+ /** The generated `## Workspace` block names every app and library in the workspace. */
395
405
  async #rewriteWorkspaceSection(libs: string[]) {
396
406
  const replacements = [
397
407
  [/^- Repo: .*$/m, `- Repo: ${this.name}`],
@@ -409,9 +419,9 @@ export class FleetSpoke {
409
419
 
410
420
  /**
411
421
  * Written onto the clone's manifest directly rather than through `LibSource`, which addresses a library
412
- * by its position in the *hub's* workspace. The hash follows the same rule — the library's own git files
422
+ * by its position in the *workspace's* workspace. The hash follows the same rule — the library's own git files
413
423
  * with `env/` and the stamp itself left out — and is only written when it would change, so an
414
- * up-to-date spoke stays clean and the push is skipped.
424
+ * up-to-date subspace stays clean and the push is skipped.
415
425
  */
416
426
  async #stampLib(clone: Executor, lib: string, branch: string) {
417
427
  const manifestPath = path.join(this.#clonePath, "libs", lib, "package.json");
@@ -421,7 +431,7 @@ export class FleetSpoke {
421
431
  const [hash, previous] = await Promise.all([this.#hashLib(clone, lib), this.#committedStamp(clone, lib)]);
422
432
  const manifest = (await FileSys.readJson(manifestPath)) as Record<string, unknown>;
423
433
  const akan = (manifest[LibSource.manifestKey] ?? {}) as Record<string, unknown>;
424
- //* The extraction overwrote the manifest with the hub's, stamp and all, so the previous stamp has to
434
+ //* The extraction overwrote the manifest with the workspace's, stamp and all, so the previous stamp has to
425
435
  //* come from the clone's HEAD. Reusing its `syncedAt` when nothing else moved is what leaves the file
426
436
  //* byte-identical to what is committed — otherwise every push is dirty and none is ever skipped.
427
437
  const unchanged = previous?.origin === origin && previous.sha === sha && previous.hash === hash;
@@ -437,7 +447,7 @@ export class FleetSpoke {
437
447
  const akan = (manifest[LibSource.manifestKey] ?? {}) as Record<string, unknown>;
438
448
  return akan.source as { origin: string; sha: string; hash: string; syncedAt: string } | undefined;
439
449
  } catch {
440
- //? First push: the library is not in the spoke's history yet.
450
+ //? First push: the library is not in the subspace's history yet.
441
451
  return undefined;
442
452
  }
443
453
  }
@@ -454,7 +464,7 @@ export class FleetSpoke {
454
464
  ]);
455
465
  const files = listed
456
466
  .split("\0")
457
- .filter((file) => !!file && !this.#isSpokeOwned(file))
467
+ .filter((file) => !!file && !this.#isSubspaceOwned(file))
458
468
  .sort();
459
469
  const hasher = new Bun.CryptoHasher("sha256");
460
470
  for (const file of files) {
@@ -471,9 +481,9 @@ export class FleetSpoke {
471
481
  return hasher.digest("hex").slice(0, 32);
472
482
  }
473
483
 
474
- /** Direction is spokehub, so the patch reads as what a push would apply rather than its inverse. */
475
- async diff(branch: string, filter?: string | null): Promise<FleetDiffResult> {
476
- if (!(await this.fetch(branch))) throw new Error(`Spoke "${this.name}" has no branch "${branch}"`);
484
+ /** Direction is subspaceworkspace, so the patch reads as what a push would apply rather than its inverse. */
485
+ async diff(branch: string, filter?: string | null): Promise<SubspaceDiffResult> {
486
+ if (!(await this.fetch(branch))) throw new Error(`Subspace "${this.name}" has no branch "${branch}"`);
477
487
  const { libs, paths } = await this.slice();
478
488
  const libPaths = libs.map((lib) => `libs/${lib}`);
479
489
  const appPaths = paths.filter((entry) => !libPaths.includes(entry));
@@ -484,7 +494,7 @@ export class FleetSpoke {
484
494
  return { name: this.name, branch, app, libs: libSection };
485
495
  }
486
496
 
487
- async #diffSection(branch: string, paths: string[], filter?: string | null): Promise<FleetDiffSection> {
497
+ async #diffSection(branch: string, paths: string[], filter?: string | null): Promise<SubspaceDiffSection> {
488
498
  const scoped = filter ? paths.filter((entry) => entry.startsWith(filter) || filter.startsWith(entry)) : paths;
489
499
  if (!scoped.length) return { files: [], patch: "" };
490
500
  const from = `${this.#remote}/${branch}`;
@@ -495,7 +505,7 @@ export class FleetSpoke {
495
505
  names
496
506
  .split("\n")
497
507
  .filter((file) => !!file.trim())
498
- .filter((file) => !this.#isSpokeOwned(file)),
508
+ .filter((file) => !this.#isSubspaceOwned(file)),
499
509
  );
500
510
  if (!files.length) return { files: [], patch: "" };
501
511
  //? argv, not a shell: a route path such as `page/(docs)/…` would need quoting through one.
@@ -503,32 +513,9 @@ export class FleetSpoke {
503
513
  }
504
514
 
505
515
  /**
506
- * The spoke-side guard, which is one line calling `akan fleet check` rather than a script of its own —
507
- * the logic lives in the CLI, so a spoke never carries a copy that can rot. Written after the shell
508
- * overlay, since the hub's own `.husky/pre-commit` travels with it and would otherwise win.
509
- */
510
- async #writeGuards() {
511
- const { ci, preCommit } = this.#config.guard;
512
- if (ci === "github") {
513
- const workflowPath = path.join(this.#clonePath, ".github/workflows/akan-fleet-guard.yml");
514
- await mkdir(path.dirname(workflowPath), { recursive: true });
515
- await FileSys.writeText(workflowPath, FleetSpoke.guardWorkflow);
516
- }
517
- if (!preCommit) return;
518
- const hookPath = path.join(this.#clonePath, ".husky/pre-commit");
519
- const existing = (await FileSys.fileExists(hookPath)) ? await FileSys.readText(hookPath) : "";
520
- if (existing.includes(FleetSpoke.guardHookLine)) return;
521
- await mkdir(path.dirname(hookPath), { recursive: true });
522
- const content = existing.trim()
523
- ? `${existing.replace(/\n*$/, "\n")}${FleetSpoke.guardHookLine}\n`
524
- : `${FleetSpoke.guardHookLine}\n`;
525
- await FileSys.writeText(hookPath, content);
526
- }
527
-
528
- /**
529
- * Refuses the spoke rather than throwing, so one customer repo that fails to install or sync does not
530
- * abort the push to the rest of the fleet. The env is passed explicitly so the child sees the values
531
- * written into the clone instead of inheriting the hub's own.
516
+ * Refuses the subspace rather than throwing, so one customer repo that fails to install or sync does not
517
+ * abort the push to the rest of the subspaces. The env is passed explicitly so the child sees the values
518
+ * written into the clone instead of inheriting the workspace's own.
532
519
  */
533
520
  async #verify(clone: Executor) {
534
521
  const env = { ...process.env, ...this.#localEnv() };
@@ -541,18 +528,25 @@ export class FleetSpoke {
541
528
  }
542
529
  }
543
530
 
544
- async push(branch: string, { verify = true }: { verify?: boolean } = {}): Promise<FleetPushResult> {
531
+ async push(branch: string, { verify = true }: { verify?: boolean } = {}): Promise<SubspacePushResult> {
545
532
  this.#config.assertPushable(branch);
546
533
  if (await this.#workspace.hasChanges())
547
- return { name: this.name, outcome: "refused", reason: "hub working tree is dirty", changedFiles: 0 };
534
+ return { name: this.name, outcome: "refused", reason: "workspace working tree is dirty", changedFiles: 0 };
548
535
  if (!(await this.fetch(branch)))
549
- return { name: this.name, outcome: "refused", reason: `spoke has no branch "${branch}"`, changedFiles: 0 };
536
+ return { name: this.name, outcome: "refused", reason: `subspace has no branch "${branch}"`, changedFiles: 0 };
550
537
 
551
538
  const clone = await this.#ensureClone(branch);
552
- await this.#applySlice(clone, branch);
539
+ const { pruned } = await this.#applySlice(clone, branch);
553
540
 
554
541
  const dirty = (await clone.spawn("git", ["status", "--porcelain"])).trim();
555
- if (!dirty) return { name: this.name, outcome: "skipped", reason: "already up to date", changedFiles: 0 };
542
+ if (!dirty)
543
+ return {
544
+ name: this.name,
545
+ outcome: "skipped",
546
+ reason: "already up to date",
547
+ changedFiles: 0,
548
+ prunedDependencies: pruned,
549
+ };
556
550
 
557
551
  const changedFiles = dirty.split("\n").length;
558
552
  if (verify) {
@@ -561,21 +555,22 @@ export class FleetSpoke {
561
555
  }
562
556
  await this.#writeAnchorFile(branch);
563
557
  await clone.spawn("git", ["add", "-A"]);
564
- const message = `chore(fleet): sync from ${this.#workspace.repoName}@${await this.headSha()}`;
558
+ const message = `chore(subspace): sync from ${this.#workspace.repoName}@${await this.headSha()}`;
565
559
  await clone.spawn("git", ["commit", "--quiet", "-m", message]);
566
- //* Never force: a diverged spoke holds customer commits, and `pull` is how those come back.
560
+ //* Never force: a diverged subspace holds customer commits, and `pull` is how those come back.
567
561
  await clone.spawn("git", ["push", "--quiet", "origin", branch]);
568
562
  return {
569
563
  name: this.name,
570
564
  outcome: "pushed",
571
565
  commit: (await clone.spawn("git", ["rev-parse", "--short", "HEAD"])).trim(),
572
566
  changedFiles,
567
+ prunedDependencies: pruned,
573
568
  };
574
569
  }
575
570
 
576
571
  async #writeAnchorFile(branch: string) {
577
- await FileSys.writeJson(path.join(this.#clonePath, FleetSpoke.anchorFile), {
578
- hub: this.#workspace.repoName,
572
+ await FileSys.writeJson(path.join(this.#clonePath, Subspace.anchorFile), {
573
+ workspace: this.#workspace.repoName,
579
574
  hubSha: await this.headSha(),
580
575
  branch,
581
576
  apps: this.#declaration.apps,
@@ -583,20 +578,20 @@ export class FleetSpoke {
583
578
  });
584
579
  }
585
580
 
586
- async pull(branch: string, { adoptLibs = false }: { adoptLibs?: boolean } = {}): Promise<FleetPullResult> {
587
- if (!(await this.fetch(branch))) throw new Error(`Spoke "${this.name}" has no branch "${branch}"`);
581
+ async pull(branch: string, { adoptLibs = false }: { adoptLibs?: boolean } = {}): Promise<SubspacePullResult> {
582
+ if (!(await this.fetch(branch))) throw new Error(`Subspace "${this.name}" has no branch "${branch}"`);
588
583
  const anchor = await this.anchor(branch);
589
584
  if (!anchor.commit)
590
- throw new Error(`Spoke "${this.name}" carries no ${FleetSpoke.anchorFile} — it has never received a push`);
585
+ throw new Error(`Subspace "${this.name}" carries no ${Subspace.anchorFile} — it has never received a push`);
591
586
  if (!anchor.incoming.length) return { name: this.name, applied: [], libPatch: null, ignored: [], incoming: [] };
592
587
 
593
588
  const range = `${anchor.commit}..${this.#remote}/${branch}`;
594
589
  const changed = (await this.#git(["diff", "--name-only", range])).split("\n").filter((file) => !!file.trim());
595
590
  const appPrefixes = this.#declaration.apps.map((app) => `apps/${app}/`);
596
591
  const appFiles = changed.filter(
597
- (file) => appPrefixes.some((prefix) => file.startsWith(prefix)) && !this.#isSpokeOwned(file),
592
+ (file) => appPrefixes.some((prefix) => file.startsWith(prefix)) && !this.#isSubspaceOwned(file),
598
593
  );
599
- const libFiles = changed.filter((file) => file.startsWith("libs/") && !this.#isSpokeOwned(file));
594
+ const libFiles = changed.filter((file) => file.startsWith("libs/") && !this.#isSubspaceOwned(file));
600
595
  const ignored = changed.filter((file) => !appFiles.includes(file) && !libFiles.includes(file));
601
596
 
602
597
  const applied = adoptLibs ? [...appFiles, ...libFiles] : appFiles;
@@ -606,31 +601,31 @@ export class FleetSpoke {
606
601
  return { name: this.name, applied, libPatch, ignored, incoming: anchor.incoming };
607
602
  }
608
603
 
609
- /** Left uncommitted in the hub's working tree: a conflict is a normal 3-way conflict for a person. */
604
+ /** Left uncommitted in the workspace's working tree: a conflict is a normal 3-way conflict for a person. */
610
605
  async #applyPatch(range: string, files: string[], label: string) {
611
606
  const { absolute } = await this.#savePatch(range, files, label);
612
607
  await this.#git(["apply", "--3way", absolute]);
613
608
  }
614
609
 
615
610
  /**
616
- * A library hunk is never applied by default: the hub is the one copy every other spoke is pushed from,
611
+ * A library hunk is never applied by default: the workspace is the one copy every other subspace is pushed from,
617
612
  * so adopting one customer's edit silently would ship it to all of them.
618
613
  */
619
614
  async #savePatch(range: string, files: string[], label: string) {
620
- const patchPath = path.join(this.#workspace.workspaceRoot, ".akan/fleet", `${this.name}-${label}.patch`);
615
+ const patchPath = path.join(this.#workspace.workspaceRoot, ".akan/subspace", `${this.name}-${label}.patch`);
621
616
  await mkdir(path.dirname(patchPath), { recursive: true });
622
617
  await FileSys.writeText(patchPath, await this.#git(["diff", range, "--", ...files]));
623
618
  return { path: path.relative(this.#workspace.workspaceRoot, patchPath), absolute: patchPath, files };
624
619
  }
625
620
  }
626
621
 
627
- export function formatFleetStatuses(statuses: FleetSpokeStatus[]) {
622
+ export function formatSubspaceStatuses(statuses: SubspaceStatus[]) {
628
623
  const sections = [
629
- "Akan Fleet Status",
624
+ "Akan Subspace Status",
630
625
  `branch: ${statuses[0]?.branch ?? "(none)"}`,
631
626
  "",
632
627
  ...statuses.flatMap((status) => {
633
- if (!status.hasBranch) return [` ${status.name}: no such branch in the spoke — push is refused`];
628
+ if (!status.hasBranch) return [` ${status.name}: no such branch in the subspace — push is refused`];
634
629
  const behind = status.behindPaths.length ? `${status.behindPaths.length} file(s) behind` : "up to date";
635
630
  const incoming = status.anchor.incoming.length
636
631
  ? `${status.anchor.incoming.length} customer commit(s) to pull`
@@ -646,23 +641,30 @@ export function formatFleetStatuses(statuses: FleetSpokeStatus[]) {
646
641
  return sections.join("\n");
647
642
  }
648
643
 
649
- export function formatFleetPushResults(results: FleetPushResult[]) {
644
+ export function formatSubspacePushResults(results: SubspacePushResult[]) {
650
645
  const sections = [
651
- "Akan Fleet Push",
646
+ "Akan Subspace Push",
652
647
  "",
653
648
  ...results.flatMap((result) => {
654
649
  const detail =
655
650
  result.outcome === "pushed" ? `${result.commit} (${result.changedFiles} files)` : (result.reason ?? "");
656
651
  const [first = "", ...rest] = detail.split("\n");
657
- return [` ${result.outcome.padEnd(8)} ${result.name} ${first}`, ...rest.map((line) => ` ${line}`)];
652
+ const pruned = result.prunedDependencies?.length
653
+ ? [` ${result.prunedDependencies.length} unused root dependenc(ies) left out of package.json`]
654
+ : [];
655
+ return [
656
+ ` ${result.outcome.padEnd(8)} ${result.name} ${first}`,
657
+ ...rest.map((line) => ` ${line}`),
658
+ ...pruned,
659
+ ];
658
660
  }),
659
661
  ];
660
662
  return sections.join("\n");
661
663
  }
662
664
 
663
- export function formatFleetPullResult(result: FleetPullResult) {
665
+ export function formatSubspacePullResult(result: SubspacePullResult) {
664
666
  const sections = [
665
- `Akan Fleet Pull — ${result.name}`,
667
+ `Akan Subspace Pull — ${result.name}`,
666
668
  "",
667
669
  `Customer commits since the last push (${result.incoming.length}):`,
668
670
  "",
@@ -681,24 +683,26 @@ export function formatFleetPullResult(result: FleetPullResult) {
681
683
  ...result.libPatch.files.map((file) => ` ${file}`),
682
684
  "",
683
685
  ` patch: ${result.libPatch.path}`,
684
- " adopt with: akan fleet pull <name> --adopt-libs",
686
+ " adopt with: akan subspace pull <name> --adopt-libs",
685
687
  ]
686
688
  : []),
687
- ...(result.ignored.length ? ["", `Ignored (hub-owned or spoke-owned): ${result.ignored.length} file(s)`] : []),
689
+ ...(result.ignored.length
690
+ ? ["", `Ignored (workspace-owned or subspace-owned): ${result.ignored.length} file(s)`]
691
+ : []),
688
692
  ];
689
693
  return sections.join("\n");
690
694
  }
691
695
 
692
- export function formatFleetDiff(result: FleetDiffResult) {
696
+ export function formatSubspaceDiff(result: SubspaceDiffResult) {
693
697
  const total = result.app.files.length + result.libs.files.length;
694
698
  const sections = [
695
- `Akan Fleet Diff — ${result.name} (${result.branch})`,
696
- "what `akan fleet push` would change in the spoke",
699
+ `Akan Subspace Diff — ${result.name} (${result.branch})`,
700
+ "what `akan subspace push` would change in the subspace",
697
701
  "",
698
- ...(total ? [] : [" spoke is identical to the hub for this slice."]),
702
+ ...(total ? [] : [" subspace is identical to the workspace for this slice."]),
699
703
  ...(result.libs.files.length
700
704
  ? [
701
- `LIBRARY changes (${result.libs.files.length}) — the spoke edited shared code:`,
705
+ `LIBRARY changes (${result.libs.files.length}) — the subspace edited shared code:`,
702
706
  "",
703
707
  ...result.libs.files.map((file) => ` ${file}`),
704
708
  "",