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

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 (66) hide show
  1. package/akanConfig/akanConfig.test.ts +51 -5
  2. package/akanConfig/akanConfig.ts +31 -12
  3. package/akanConfig/akanWorkspaceConfig.test.ts +6 -6
  4. package/akanConfig/akanWorkspaceConfig.ts +1 -1
  5. package/applicationBuildRunner.test.ts +33 -1
  6. package/applicationBuildRunner.ts +4 -5
  7. package/cloud/cloudApi.ts +22 -15
  8. package/cloud/cloudHttpError.test.ts +32 -0
  9. package/cloud/cloudHttpError.ts +54 -0
  10. package/cloud/cloudUpdates.ts +138 -0
  11. package/cloud/globalConfig.test.ts +17 -0
  12. package/cloud/globalConfig.ts +8 -2
  13. package/cloud/index.ts +3 -0
  14. package/cloud/pipelineSecrets.ts +114 -0
  15. package/cloud/updatePackUpload.test.ts +100 -45
  16. package/cloud/updatePackUpload.ts +38 -18
  17. package/commandDecorators/argMeta.ts +11 -2
  18. package/commandDecorators/command.ts +40 -6
  19. package/commandDecorators/commandBuilder.ts +6 -1
  20. package/commandDecorators/commandDecorators.test.ts +205 -4
  21. package/commandDecorators/exitCodeError.ts +15 -0
  22. package/commandDecorators/index.ts +1 -0
  23. package/executors.test.ts +46 -0
  24. package/executors.ts +26 -23
  25. package/frontendBuild/csrArtifactBuilder.ts +18 -1
  26. package/incrementalBuilder/buildBatch.proc.ts +12 -7
  27. package/incrementalBuilder/incrementalBuilder.proc.test.ts +63 -0
  28. package/incrementalBuilder/incrementalBuilder.proc.ts +7 -5
  29. package/mobile/desktopServerStage.test.ts +13 -0
  30. package/mobile/desktopServerStage.ts +5 -7
  31. package/mobile/index.ts +2 -0
  32. package/mobile/nativeApi.test.ts +26 -0
  33. package/mobile/nativeApi.ts +5 -2
  34. package/mobile/nativeApp.test.ts +135 -33
  35. package/mobile/nativeApp.ts +109 -73
  36. package/mobile/nativeConfig.ts +2 -1
  37. package/mobile/nativePublicEnv.test.ts +131 -0
  38. package/mobile/nativePublicEnv.ts +116 -0
  39. package/mobile/serverBundleSecrets.test.ts +79 -0
  40. package/mobile/serverBundleSecrets.ts +60 -16
  41. package/mobile/updatePackFolder.test.ts +98 -0
  42. package/mobile/updatePackFolder.ts +131 -0
  43. package/mobile/updateUploader.test.ts +114 -22
  44. package/mobile/updateUploader.ts +75 -8
  45. package/package.json +2 -2
  46. package/pipeline/PipelineCompiler.test.ts +157 -1
  47. package/pipeline/PipelineCompiler.ts +80 -46
  48. package/pipeline/PipelineDefinition.test.ts +9 -0
  49. package/pipeline/PipelineDefinition.ts +4 -3
  50. package/pipeline/PipelineDryRun.test.ts +27 -0
  51. package/pipeline/PipelineDryRun.ts +2 -2
  52. package/pipeline/PipelineInputReader.ts +157 -253
  53. package/pipeline/PipelineRules.ts +92 -0
  54. package/pipeline/index.ts +1 -0
  55. package/qualityScanner.test.ts +5 -3
  56. package/slicePlanner.ts +5 -3
  57. package/subspace.test.ts +90 -19
  58. package/subspace.ts +52 -500
  59. package/subspaceClone.ts +361 -0
  60. package/subspaceConfig.ts +1 -2
  61. package/subspaceReport.ts +162 -0
  62. package/transforms/serverEnvPlugin.test.ts +76 -0
  63. package/transforms/serverEnvPlugin.ts +9 -25
  64. package/transforms/serverEnvProfile.test.ts +189 -0
  65. package/transforms/serverEnvProfile.ts +324 -60
  66. package/typecheck/projectTypecheck.ts +6 -5
package/subspace.ts CHANGED
@@ -1,11 +1,12 @@
1
- import { mkdir, readdir, rename, rm } from "node:fs/promises";
1
+ import { mkdir } from "node:fs/promises";
2
2
  import path from "node:path";
3
- import { AppExecutor, Executor, WorkspaceExecutor } from "./executors";
3
+ import { toError } from "akanjs/common";
4
+ import { AppExecutor, type WorkspaceExecutor } from "./executors";
4
5
  import { FileSys } from "./fileSys";
5
6
  import { LibSource } from "./libSource";
6
- import { type SlicePlan, SlicePlanner } from "./slicePlanner";
7
+ import { SlicePlanner } from "./slicePlanner";
8
+ import { SubspaceClone } from "./subspaceClone";
7
9
  import type { SubspaceConfig, SubspaceDeclaration } from "./subspaceConfig";
8
- import type { PackageJson } from "./types";
9
10
 
10
11
  export interface SubspaceIncomingCommit {
11
12
  sha: string;
@@ -59,8 +60,8 @@ export interface SubspacePlan {
59
60
  name: string;
60
61
  repo: string;
61
62
  branch: string;
62
- /** Why a push from this branch would be refused, or null when it may push. */
63
63
  branchRefusal: string | null;
64
+ workspaceRefusal: string | null;
64
65
  apps: string[];
65
66
  /** Each library the slice carries, with the apps whose closure pulls it in. */
66
67
  libs: { name: string; apps: string[] }[];
@@ -72,7 +73,6 @@ export interface SubspacePlan {
72
73
  /** Workspace-root entries never pushed: the subspace keeps its own copy, if it has one. */
73
74
  subspaceOwnedRootEntries: string[];
74
75
  prunedDependencies: string[];
75
- /** Why the workspace's bun.lock would make a push refuse, or null. */
76
76
  lockProblem: string | null;
77
77
  warnings: string[];
78
78
  }
@@ -100,19 +100,10 @@ export interface SubspacePullResult {
100
100
  // Push is a squashed snapshot, so no workspace history (or other customer's commit message) reaches a subspace;
101
101
  // pull diffs against the last push received, so it is exactly the customer's own work.
102
102
  export class Subspace {
103
- static readonly anchorFile = "akan.subspace.json";
104
- /**
105
- * The workspace's own never reach a subspace — its root config names every other customer's repo, its Jenkinsfile
106
- * deploys the workspace — and a subspace's own survive every push.
107
- */
108
- static readonly subspaceOwnedRootEntries = ["akan.config.ts", "Jenkinsfile"];
109
- static readonly syncSubjectPrefix = "chore(subspace): sync from ";
110
- /** Workspace members, which are replaced wholesale rather than overlaid. */
111
- static readonly memberDirs = ["apps", "libs"];
112
- /** Subspace-owned in both directions: env values belong to the repo that deploys, not to the workspace. */
113
- static readonly subspaceOwnedDirs = ["env"];
114
- static readonly secretsMarkers = ["# akan:secrets (managed by akan.config.ts — do not edit)", "# akan:secrets:end"];
115
- static readonly holdDir = ".akan-subspace-hold";
103
+ //* The declaration file before `akan.config.ts`: it names every customer's repo, so a push removes any copy of it.
104
+ static readonly retiredConfigFile = "akan.subspace.ts";
105
+ static readonly #retiredConfigRefusal =
106
+ `${Subspace.retiredConfigFile} is no longer read: move it under \`subspace\` in akan.config.ts and delete it`;
116
107
  static readonly #loopbackRegistry = /https?:\/\/(?:127\.0\.0\.1|localhost|0\.0\.0\.0|\[::1\])(?::\d+)?/;
117
108
 
118
109
  #workspace: WorkspaceExecutor;
@@ -137,18 +128,11 @@ export class Subspace {
137
128
  get #remote() {
138
129
  return `subspace-${this.#declaration.name}`;
139
130
  }
140
- get #clonePath() {
141
- return path.join(this.#workspace.workspaceRoot, ".akan/subspace", this.#declaration.name);
142
- }
143
131
 
144
132
  async #git(args: string[]) {
145
133
  return await this.#workspace.spawn("git", args);
146
134
  }
147
135
 
148
- async #cloneGit(args: string[]) {
149
- return await this.#workspace.spawn("git", ["-C", this.#clonePath, ...args]);
150
- }
151
-
152
136
  async branch() {
153
137
  return (await this.#git(["rev-parse", "--abbrev-ref", "HEAD"])).trim();
154
138
  }
@@ -173,7 +157,7 @@ export class Subspace {
173
157
  //* Located by the one file only a push writes: unlike a tag or a recorded sha, it cannot drift out of the history.
174
158
  async anchor(branch: string): Promise<SubspaceAnchor> {
175
159
  const ref = `${this.#remote}/${branch}`;
176
- const commit = (await this.#git(["log", "-1", "--format=%H", ref, "--", Subspace.anchorFile])).trim();
160
+ const commit = (await this.#git(["log", "-1", "--format=%H", ref, "--", SubspaceClone.anchorFile])).trim();
177
161
  if (!commit) return { commit: null, incoming: [] };
178
162
  const log = await this.#git(["log", "--format=%H%x09%an%x09%s", `${commit}..${ref}`]);
179
163
  const incoming = log
@@ -200,10 +184,11 @@ export class Subspace {
200
184
 
201
185
  /** What a push from `branch` would ship, worked out from this checkout alone: no fetch, no remote, no clone. */
202
186
  async plan(branch: string): Promise<SubspacePlan> {
187
+ const workspaceRefusal = await this.#workspaceRefusal();
203
188
  const { plans, libs } = await this.slice();
204
189
  const rootFiles = plans[0]?.rootFiles ?? [];
205
- const present = new Set(rootFiles.map((file) => file.split("/")[0] ?? file));
206
- const owned = Subspace.subspaceOwnedRootEntries;
190
+ const present = new Set(SlicePlanner.rootEntriesOf(rootFiles));
191
+ const owned = SubspaceClone.subspaceOwnedRootEntries;
207
192
  const excluded = this.#config.exclude.filter((entry) => !owned.includes(entry));
208
193
  const excludedTops = new Set(excluded.map((entry) => entry.replace(/\/+$/, "")));
209
194
  const libFiles = new Map(plans.flatMap((plan) => Object.entries(plan.libFiles)));
@@ -216,13 +201,14 @@ export class Subspace {
216
201
  try {
217
202
  this.#config.assertPushable(branch);
218
203
  } catch (error) {
219
- branchRefusal = error instanceof Error ? error.message : String(error);
204
+ branchRefusal = toError(error).message;
220
205
  }
221
206
  return {
222
207
  name: this.name,
223
208
  repo: this.#declaration.repo,
224
209
  branch,
225
210
  branchRefusal,
211
+ workspaceRefusal,
226
212
  apps: [...this.#declaration.apps],
227
213
  libs: libs.map((lib) => ({
228
214
  name: lib,
@@ -232,22 +218,27 @@ export class Subspace {
232
218
  plans.reduce((sum, plan) => sum + plan.appFiles.length, 0) +
233
219
  [...libFiles.values()].reduce((sum, files) => sum + files.length, 0),
234
220
  rootEntries: [...present]
235
- .filter((entry) => !excludedTops.has(entry) && !owned.includes(entry))
221
+ .filter((entry) => !excludedTops.has(entry) && !owned.includes(entry) && entry !== Subspace.retiredConfigFile)
236
222
  .sort((a, b) => a.localeCompare(b)),
237
223
  excludedRootEntries: excluded,
238
224
  subspaceOwnedRootEntries: owned.filter((entry) => present.has(entry)),
239
225
  prunedDependencies: pruned,
240
- lockProblem: rootFiles.includes("bun.lock")
241
- ? await this.#loopbackRegistryLock(this.#workspace.workspaceRoot)
242
- : null,
226
+ lockProblem: await this.#headLockProblem(),
243
227
  warnings: [...new Set([...plans.flatMap((plan) => plan.warnings), ...warnings])],
244
228
  };
245
229
  }
246
230
 
247
- #isSubspaceOwned(file: string) {
248
- const segments = file.split("/");
249
- if (!Subspace.memberDirs.includes(segments[0] ?? "")) return false;
250
- return Subspace.subspaceOwnedDirs.includes(segments[2] ?? "");
231
+ static planBlockers(plan: SubspacePlan) {
232
+ return [plan.branchRefusal, plan.workspaceRefusal, plan.lockProblem].filter((reason): reason is string => !!reason);
233
+ }
234
+
235
+ async #retiredConfigLeft() {
236
+ return await FileSys.fileExists(path.join(this.#workspace.workspaceRoot, Subspace.retiredConfigFile));
237
+ }
238
+
239
+ async #workspaceRefusal() {
240
+ if (await this.#retiredConfigLeft()) return Subspace.#retiredConfigRefusal;
241
+ return (await this.#workspace.hasChanges()) ? "workspace working tree is dirty" : null;
251
242
  }
252
243
 
253
244
  // A library manifest always differs by the `akan.source` stamp a push writes, so it is compared without that key.
@@ -274,7 +265,7 @@ export class Subspace {
274
265
  async #changedSliceFiles(ref: string, diffArgs: string[]) {
275
266
  const files = (await this.#git(["diff", "--name-only", ...diffArgs]))
276
267
  .split("\n")
277
- .filter((file) => !!file.trim() && !this.#isSubspaceOwned(file));
268
+ .filter((file) => !!file.trim() && !SubspaceClone.isSubspaceOwned(file));
278
269
  const kept = await Promise.all(
279
270
  files.map(async (file) => (this.#isStamped(file) ? await this.#differsBeyondStamp(ref, file) : true)),
280
271
  );
@@ -294,272 +285,6 @@ export class Subspace {
294
285
  return { name: this.name, branch, hasBranch, anchor, behindPaths, driftedLibs };
295
286
  }
296
287
 
297
- async #ensureClone(branch: string) {
298
- if (!(await FileSys.dirExists(path.join(this.#clonePath, ".git")))) {
299
- await rm(this.#clonePath, { recursive: true, force: true });
300
- await mkdir(path.dirname(this.#clonePath), { recursive: true });
301
- await this.#workspace.spawn("git", ["clone", "--quiet", this.#declaration.repo, this.#clonePath]);
302
- }
303
- const clone = new Executor(`subspace-${this.name}`, this.#clonePath);
304
- await clone.spawn("git", ["fetch", "--quiet", "origin", branch]);
305
- await clone.spawn("git", ["checkout", "--quiet", "-f", "-B", branch, `origin/${branch}`]);
306
- await clone.spawn("git", ["clean", "-qfd"]);
307
- return clone;
308
- }
309
-
310
- /** Moves the subspace's env trees aside, so replacing the members cannot take them with it. */
311
- async #holdSubspaceOwned() {
312
- const held: { from: string; to: string }[] = [];
313
- const holdRoot = path.join(this.#clonePath, Subspace.holdDir);
314
- await rm(holdRoot, { recursive: true, force: true });
315
- for (const member of Subspace.memberDirs) {
316
- const memberRoot = path.join(this.#clonePath, member);
317
- if (!(await FileSys.dirExists(memberRoot))) continue;
318
- for (const name of await readdir(memberRoot)) {
319
- for (const owned of Subspace.subspaceOwnedDirs) {
320
- const from = path.join(memberRoot, name, owned);
321
- if (!(await FileSys.dirExists(from))) continue;
322
- const to = path.join(holdRoot, member, name, owned);
323
- await mkdir(path.dirname(to), { recursive: true });
324
- await rename(from, to);
325
- held.push({ from, to });
326
- }
327
- }
328
- }
329
- return held;
330
- }
331
-
332
- // The workspace owns the tracked env switch files; the per-environment values it gitignores belong to the subspace.
333
- async #restoreSubspaceOwned(held: { from: string; to: string }[]) {
334
- for (const { from, to } of held) await Subspace.#copyMissing(to, from);
335
- await rm(path.join(this.#clonePath, Subspace.holdDir), { recursive: true, force: true });
336
- }
337
-
338
- //* A root entry last written by a sync commit is a copy an earlier push left there, the parent's Jenkinsfile among them:
339
- //* it goes. Any other is the subspace's own and is held through the extraction.
340
- async #holdRootEntries() {
341
- const holdRoot = path.join(this.#clonePath, Subspace.holdDir, "root");
342
- const held: string[] = [];
343
- for (const entry of Subspace.subspaceOwnedRootEntries) {
344
- const from = path.join(this.#clonePath, entry);
345
- if (!(await FileSys.entryExists(from))) continue;
346
- if (await this.#isPushedCopy(entry)) continue;
347
- await mkdir(holdRoot, { recursive: true });
348
- await rename(from, path.join(holdRoot, entry));
349
- held.push(entry);
350
- }
351
- return held;
352
- }
353
-
354
- async #restoreRootEntries(held: string[]) {
355
- const holdRoot = path.join(this.#clonePath, Subspace.holdDir, "root");
356
- for (const entry of Subspace.subspaceOwnedRootEntries) {
357
- const target = path.join(this.#clonePath, entry);
358
- await rm(target, { recursive: true, force: true });
359
- if (held.includes(entry)) await rename(path.join(holdRoot, entry), target);
360
- }
361
- }
362
-
363
- async #isPushedCopy(entry: string) {
364
- const subject = await this.#cloneGit(["log", "-1", "--format=%s", "--", entry]).catch(() => "");
365
- return subject.trim().startsWith(Subspace.syncSubjectPrefix);
366
- }
367
-
368
- static async #copyMissing(from: string, to: string) {
369
- await mkdir(to, { recursive: true });
370
- for (const entry of await readdir(from, { withFileTypes: true })) {
371
- const source = path.join(from, entry.name);
372
- const target = path.join(to, entry.name);
373
- if (entry.isDirectory()) await Subspace.#copyMissing(source, target);
374
- else if (!(await FileSys.entryExists(target))) await rename(source, target);
375
- }
376
- }
377
-
378
- // `git archive` emits only tracked files, so generated barrels, lib symlinks, env values and secrets never enter.
379
- async #extract(paths: string[], excludes: string[] = []) {
380
- // Relative, because GNU tar reads a drive-letter archive name (`C:\…`) as `host:path` on a remote host.
381
- const archivePath = path.relative(this.#workspace.workspaceRoot, `${this.#clonePath}.tar`);
382
- await this.#workspace.spawn("git", ["archive", "-o", archivePath, "HEAD", "--", ...paths]);
383
- try {
384
- await this.#workspace.spawn("tar", [
385
- "-x",
386
- "-f",
387
- archivePath,
388
- "-C",
389
- this.#clonePath,
390
- ...excludes.map((entry) => `--exclude=${entry}`),
391
- ]);
392
- } finally {
393
- await rm(path.join(this.#workspace.workspaceRoot, archivePath), { force: true });
394
- }
395
- }
396
-
397
- // An allowlist, not a filter, so no secret can reach a customer's clone; `AKAN_PUBLIC_*` ships in every bundle anyway.
398
- #localEnv() {
399
- const { serveDomain } = WorkspaceExecutor.getBaseDevEnv();
400
- return {
401
- AKAN_WORKSPACE_ID: "local",
402
- AKAN_PUBLIC_REPO_NAME: this.name,
403
- AKAN_PUBLIC_SERVE_DOMAIN: serveDomain,
404
- AKAN_PUBLIC_ENV: "local",
405
- AKAN_PUBLIC_OPERATION_MODE: "local",
406
- AKAN_PUBLIC_LOG_LEVEL: "warn",
407
- };
408
- }
409
-
410
- // The CLI needs a `.env` to recognise a workspace root and none arrives with the slice. Excluded through
411
- // `.git/info/exclude`, not the copied `.gitignore`, so it can never be committed to the customer's repo.
412
- async #writeLocalEnv() {
413
- const excludePath = path.join(this.#clonePath, ".git/info/exclude");
414
- const exclude = (await FileSys.fileExists(excludePath)) ? await FileSys.readText(excludePath) : "";
415
- if (!exclude.split("\n").includes("/.env")) {
416
- await mkdir(path.dirname(excludePath), { recursive: true });
417
- await FileSys.writeText(excludePath, exclude.trim() ? `${exclude.replace(/\n*$/, "\n")}/.env\n` : "/.env\n");
418
- }
419
- const envPath = path.join(this.#clonePath, ".env");
420
- if (await FileSys.fileExists(envPath)) return;
421
- const lines = Object.entries(this.#localEnv()).map(([key, value]) => `${key}=${value}`);
422
- await FileSys.writeText(envPath, `${lines.join("\n")}\n`);
423
- }
424
-
425
- // The root manifest is the union of every app's dependencies, so it is rebuilt from this subspace's slices.
426
- async #rewriteManifest(plans: SlicePlan[]) {
427
- const manifestPath = path.join(this.#clonePath, "package.json");
428
- if (!(await FileSys.fileExists(manifestPath))) return [];
429
- const rootPackageJson = (await FileSys.readJson(manifestPath)) as PackageJson;
430
- const { packageJson, pruned, warnings } = await SlicePlanner.pruneDependencies(
431
- this.#workspace,
432
- rootPackageJson,
433
- plans.flatMap((plan) => plan.requiredDependencies),
434
- );
435
- for (const warning of warnings) this.#workspace.logger.warn(warning);
436
- await FileSys.writeJson(manifestPath, {
437
- ...packageJson,
438
- name: this.name,
439
- description: `${this.name} workspace`,
440
- });
441
- return pruned;
442
- }
443
-
444
- async #applySlice(clone: Executor, branch: string) {
445
- const { libs, paths, plans } = await this.slice();
446
- const held = await this.#holdSubspaceOwned();
447
- const heldRootEntries = await this.#holdRootEntries();
448
- for (const member of Subspace.memberDirs)
449
- await rm(path.join(this.#clonePath, member), { recursive: true, force: true });
450
-
451
- //* Overlaid, never reconciled: a subspace owns its deployment, so a root entry the workspace lacks is left alone.
452
- await this.#extract(["."], ["apps/*", "libs/*", "pkgs/*"]);
453
- await this.#extract(paths);
454
- await this.#restoreRootEntries(heldRootEntries);
455
- await this.#restoreSubspaceOwned(held);
456
-
457
- for (const entry of this.#config.exclude)
458
- if (!Subspace.subspaceOwnedRootEntries.includes(entry))
459
- await rm(path.join(this.#clonePath, entry), { recursive: true, force: true });
460
-
461
- await this.#rewriteGitignore();
462
- const pruned = await this.#rewriteManifest(plans);
463
- await this.#rewriteWorkspaceSection(libs);
464
- await this.#writeLocalEnv();
465
- for (const lib of libs) await this.#stampLib(clone, lib, branch);
466
- return { libs, pruned };
467
- }
468
-
469
- // `akan:secrets` names every workspace app, so it is filtered; `bun.lock` is un-ignored because a subspace commits it.
470
- async #rewriteGitignore() {
471
- const gitignorePath = path.join(this.#clonePath, ".gitignore");
472
- if (!(await FileSys.fileExists(gitignorePath))) return;
473
- const [begin = "", end = ""] = Subspace.secretsMarkers;
474
- // `git archive` checks text out as CRLF under Windows Git's default `core.autocrlf=true`.
475
- const lines = (await FileSys.readText(gitignorePath)).split(/\r?\n/);
476
- const beginIdx = lines.indexOf(begin);
477
- const endIdx = lines.indexOf(end);
478
- const filtered =
479
- beginIdx >= 0 && endIdx > beginIdx
480
- ? [
481
- ...lines.slice(0, beginIdx + 1),
482
- ...lines
483
- .slice(beginIdx + 1, endIdx)
484
- .filter((line) => this.#declaration.apps.some((app) => line.startsWith(`apps/${app}/`))),
485
- ...lines.slice(endIdx),
486
- ]
487
- : lines;
488
- await FileSys.writeText(gitignorePath, filtered.filter((line) => line.trim() !== "**/bun.lock").join("\n"));
489
- }
490
-
491
- /** The generated `## Workspace` block names every app and library in the workspace. */
492
- async #rewriteWorkspaceSection(libs: string[]) {
493
- const replacements = [
494
- [/^- Repo: .*$/m, `- Repo: ${this.name}`],
495
- [/^- Apps: .*$/m, `- Apps: ${this.#declaration.apps.join(", ")}`],
496
- [/^- Libraries: .*$/m, `- Libraries: ${libs.length ? libs.join(", ") : "(none)"}`],
497
- ] as const;
498
- for (const file of ["AGENTS.md", "CLAUDE.md"]) {
499
- const filePath = path.join(this.#clonePath, file);
500
- if (!(await FileSys.fileExists(filePath))) continue;
501
- const content = await FileSys.readText(filePath);
502
- const rewritten = replacements.reduce((text, [pattern, value]) => text.replace(pattern, value), content);
503
- if (rewritten !== content) await FileSys.writeText(filePath, rewritten);
504
- }
505
- }
506
-
507
- // Not through `LibSource`, which addresses a library by its position in the workspace. The hash skips `env/` and
508
- // the stamp itself.
509
- async #stampLib(clone: Executor, lib: string, branch: string) {
510
- const manifestPath = path.join(this.#clonePath, "libs", lib, "package.json");
511
- if (!(await FileSys.fileExists(manifestPath))) return;
512
- const origin = `${this.#workspace.repoName}#${branch}`;
513
- const sha = await this.headSha();
514
- const [hash, previous] = await Promise.all([this.#hashLib(clone, lib), this.#committedStamp(clone, lib)]);
515
- const manifest = (await FileSys.readJson(manifestPath)) as Record<string, unknown>;
516
- const akan = (manifest[LibSource.manifestKey] ?? {}) as Record<string, unknown>;
517
- //* The extraction overwrote the manifest, so the previous stamp comes from the clone's HEAD. It is kept whole while
518
- //* the library's content is unchanged — matching the workspace sha too would rewrite every manifest, and push,
519
- //* after any unrelated workspace commit.
520
- const unchanged = previous?.origin === origin && previous.hash === hash;
521
- const source = unchanged ? previous : { origin, sha, hash, syncedAt: new Date().toISOString() };
522
- manifest[LibSource.manifestKey] = { ...akan, source };
523
- await FileSys.writeJson(manifestPath, manifest);
524
- }
525
-
526
- async #committedStamp(clone: Executor, lib: string) {
527
- try {
528
- const committed = await clone.spawn("git", ["show", `HEAD:libs/${lib}/package.json`]);
529
- const manifest = JSON.parse(committed) as Record<string, unknown>;
530
- const akan = (manifest[LibSource.manifestKey] ?? {}) as Record<string, unknown>;
531
- return akan.source as { origin: string; sha: string; hash: string; syncedAt: string } | undefined;
532
- } catch {
533
- //? First push: the library is not in the subspace's history yet.
534
- return undefined;
535
- }
536
- }
537
-
538
- async #hashLib(clone: Executor, lib: string) {
539
- const listed = await clone.spawn("git", [
540
- "ls-files",
541
- "-z",
542
- "--cached",
543
- "--others",
544
- "--exclude-standard",
545
- "--",
546
- `libs/${lib}`,
547
- ]);
548
- const candidates = listed
549
- .split("\0")
550
- .filter((file) => !!file && !this.#isSubspaceOwned(file))
551
- .sort();
552
- //* The index still lists files the workspace deleted since the last push (only the push's `git add -A`
553
- //* reconciles it), and opening one throws.
554
- const present = await Promise.all(
555
- candidates.map(async (file) => await FileSys.entryExists(path.join(this.#clonePath, file))),
556
- );
557
- const files = candidates.filter((_, index) => present[index]);
558
- return await LibSource.hashFiles(files, `libs/${lib}/package.json`, (file) =>
559
- FileSys.readText(path.join(this.#clonePath, file)),
560
- );
561
- }
562
-
563
288
  /** Direction is subspace → workspace, so the patch reads as what a push would apply rather than its inverse. */
564
289
  async diff(branch: string, filter?: string | null): Promise<SubspaceDiffResult> {
565
290
  if (!(await this.fetch(branch))) throw new Error(`Subspace "${this.name}" has no branch "${branch}"`);
@@ -584,40 +309,33 @@ export class Subspace {
584
309
  }
585
310
 
586
311
  // A lock written against a local registry (verdaccio) resolves on this machine alone: every install of it fails.
587
- async #loopbackRegistryLock(root = this.#clonePath) {
588
- const lockPath = path.join(root, "bun.lock");
589
- if (!(await FileSys.fileExists(lockPath))) return null;
590
- const registry = (await FileSys.readText(lockPath)).match(Subspace.#loopbackRegistry)?.[0];
312
+ static #loopbackRegistryProblem(lock: string | null) {
313
+ const registry = lock?.match(Subspace.#loopbackRegistry)?.[0];
591
314
  return registry
592
315
  ? `bun.lock resolves packages from ${registry}, a registry only this machine reaches; reinstall from a public registry and commit that lock first`
593
316
  : null;
594
317
  }
595
318
 
596
- // Refuses rather than throws, so one failing subspace does not abort the rest; the env is explicit so the child
597
- // sees the clone's values, not the workspace's.
598
- async #verify(clone: Executor) {
599
- const env = { ...process.env, ...this.#localEnv() };
600
- try {
601
- await clone.spawn("bun", ["install"], { env });
602
- for (const app of this.#declaration.apps) await clone.spawn("bunx", ["akan", "sync", app], { env });
603
- return null;
604
- } catch (error) {
605
- return error instanceof Error ? error.message : String(error);
606
- }
319
+ async #headLockProblem() {
320
+ return Subspace.#loopbackRegistryProblem(await this.#git(["show", "HEAD:bun.lock"]).catch(() => null));
607
321
  }
608
322
 
609
323
  async push(branch: string, { verify = true }: { verify?: boolean } = {}): Promise<SubspacePushResult> {
610
324
  this.#config.assertPushable(branch);
611
- if (await this.#workspace.hasChanges())
612
- return { name: this.name, outcome: "refused", reason: "workspace working tree is dirty", changedFiles: 0 };
325
+ const refusal = (await this.#workspaceRefusal()) ?? (await this.#headLockProblem());
326
+ if (refusal) return { name: this.name, outcome: "refused", reason: refusal, changedFiles: 0 };
613
327
  if (!(await this.fetch(branch)))
614
328
  return { name: this.name, outcome: "refused", reason: `subspace has no branch "${branch}"`, changedFiles: 0 };
615
329
 
616
- const clone = await this.#ensureClone(branch);
617
- const { pruned } = await this.#applySlice(clone, branch);
330
+ const clone = await SubspaceClone.open(this.#workspace, this.#declaration, branch);
331
+ const { pruned } = await clone.applySlice(await this.slice(), {
332
+ branch,
333
+ sha: await this.headSha(),
334
+ remove: [Subspace.retiredConfigFile, ...this.#config.exclude],
335
+ });
618
336
 
619
- const dirty = (await clone.spawn("git", ["status", "--porcelain"])).trim();
620
- if (!dirty)
337
+ const changedFiles = await clone.changedFiles();
338
+ if (!changedFiles)
621
339
  return {
622
340
  name: this.name,
623
341
  outcome: "skipped",
@@ -626,52 +344,37 @@ export class Subspace {
626
344
  prunedDependencies: pruned,
627
345
  };
628
346
 
629
- const changedFiles = dirty.split("\n").length;
630
347
  if (verify) {
631
- const failure = await this.#verify(clone);
348
+ const failure = await clone.verify();
632
349
  if (failure) return { name: this.name, outcome: "refused", reason: failure, changedFiles };
633
350
  }
634
- const lockProblem = await this.#loopbackRegistryLock();
351
+ //? Rechecked on the clone: the verifying `bun install` may resolve packages from this machine's registry into it.
352
+ const lockProblem = Subspace.#loopbackRegistryProblem(await clone.lock());
635
353
  if (lockProblem) return { name: this.name, outcome: "refused", reason: lockProblem, changedFiles };
636
- await this.#writeAnchorFile(branch);
637
- await clone.spawn("git", ["add", "-A"]);
638
- const message = `${Subspace.syncSubjectPrefix}${this.#workspace.repoName}@${await this.headSha()}`;
639
- await clone.spawn("git", ["commit", "--quiet", "-m", message]);
640
- //* Never force: a diverged subspace holds customer commits, and `pull` is how those come back.
641
- await clone.spawn("git", ["push", "--quiet", "origin", branch]);
642
354
  return {
643
355
  name: this.name,
644
356
  outcome: "pushed",
645
- commit: (await clone.spawn("git", ["rev-parse", "--short", "HEAD"])).trim(),
357
+ commit: await clone.commit(branch, await this.headSha()),
646
358
  changedFiles,
647
359
  prunedDependencies: pruned,
648
360
  };
649
361
  }
650
362
 
651
- async #writeAnchorFile(branch: string) {
652
- await FileSys.writeJson(path.join(this.#clonePath, Subspace.anchorFile), {
653
- workspace: this.#workspace.repoName,
654
- hubSha: await this.headSha(),
655
- branch,
656
- apps: this.#declaration.apps,
657
- syncedAt: new Date().toISOString(),
658
- });
659
- }
660
-
661
363
  async pull(branch: string, { adoptLibs = false }: { adoptLibs?: boolean } = {}): Promise<SubspacePullResult> {
364
+ if (await this.#retiredConfigLeft()) throw new Error(Subspace.#retiredConfigRefusal);
662
365
  if (!(await this.fetch(branch))) throw new Error(`Subspace "${this.name}" has no branch "${branch}"`);
663
366
  const anchor = await this.anchor(branch);
664
367
  if (!anchor.commit)
665
- throw new Error(`Subspace "${this.name}" carries no ${Subspace.anchorFile} — it has never received a push`);
368
+ throw new Error(`Subspace "${this.name}" carries no ${SubspaceClone.anchorFile} — it has never received a push`);
666
369
  if (!anchor.incoming.length) return { name: this.name, applied: [], libPatch: null, ignored: [], incoming: [] };
667
370
 
668
371
  const range = `${anchor.commit}..${this.#remote}/${branch}`;
669
372
  const changed = (await this.#git(["diff", "--name-only", range])).split("\n").filter((file) => !!file.trim());
670
373
  const appPrefixes = this.#declaration.apps.map((app) => `apps/${app}/`);
671
374
  const appFiles = changed.filter(
672
- (file) => appPrefixes.some((prefix) => file.startsWith(prefix)) && !this.#isSubspaceOwned(file),
375
+ (file) => appPrefixes.some((prefix) => file.startsWith(prefix)) && !SubspaceClone.isSubspaceOwned(file),
673
376
  );
674
- const libFiles = changed.filter((file) => file.startsWith("libs/") && !this.#isSubspaceOwned(file));
377
+ const libFiles = changed.filter((file) => file.startsWith("libs/") && !SubspaceClone.isSubspaceOwned(file));
675
378
  const ignored = changed.filter((file) => !appFiles.includes(file) && !libFiles.includes(file));
676
379
 
677
380
  const applied = adoptLibs ? [...appFiles, ...libFiles] : appFiles;
@@ -695,154 +398,3 @@ export class Subspace {
695
398
  return { path: path.relative(this.#workspace.workspaceRoot, patchPath), absolute: patchPath, files };
696
399
  }
697
400
  }
698
-
699
- export function formatSubspaceStatuses(statuses: SubspaceStatus[]) {
700
- const sections = [
701
- "Akan Subspace Status",
702
- `branch: ${statuses[0]?.branch ?? "(none)"}`,
703
- "",
704
- ...statuses.flatMap((status) => {
705
- if (!status.hasBranch) return [` ${status.name}: no such branch in the subspace — push is refused`];
706
- const behind = status.behindPaths.length ? `${status.behindPaths.length} file(s) behind` : "up to date";
707
- const incoming = status.anchor.incoming.length
708
- ? `${status.anchor.incoming.length} customer commit(s) to pull`
709
- : "no customer commits";
710
- const drift = status.driftedLibs.length ? ` DRIFTED LIBS: ${status.driftedLibs.join(", ")}` : null;
711
- return [
712
- ` ${status.name}: ${behind}, ${incoming}`,
713
- ...status.anchor.incoming.map((commit) => ` ${commit.sha} ${commit.author} ${commit.subject}`),
714
- ...(drift ? [drift] : []),
715
- ];
716
- }),
717
- ];
718
- return sections.join("\n");
719
- }
720
-
721
- export function formatSubspacePushResults(results: SubspacePushResult[]) {
722
- const sections = [
723
- "Akan Subspace Push",
724
- "",
725
- ...results.flatMap((result) => {
726
- const detail =
727
- result.outcome === "pushed" ? `${result.commit} (${result.changedFiles} files)` : (result.reason ?? "");
728
- const [first = "", ...rest] = detail.split("\n");
729
- const pruned = result.prunedDependencies?.length
730
- ? [` ${result.prunedDependencies.length} unused root dependenc(ies) left out of package.json`]
731
- : [];
732
- return [
733
- ` ${result.outcome.padEnd(8)} ${result.name} ${first}`,
734
- ...rest.map((line) => ` ${line}`),
735
- ...pruned,
736
- ];
737
- }),
738
- ];
739
- return sections.join("\n");
740
- }
741
-
742
- export function formatSubspacePullResult(result: SubspacePullResult) {
743
- const sections = [
744
- `Akan Subspace Pull — ${result.name}`,
745
- "",
746
- `Customer commits since the last push (${result.incoming.length}):`,
747
- "",
748
- ...(result.incoming.length
749
- ? result.incoming.map((commit) => ` ${commit.sha} ${commit.author} ${commit.subject}`)
750
- : [" (none — nothing to pull)"]),
751
- "",
752
- `Applied to the working tree, uncommitted (${result.applied.length}):`,
753
- "",
754
- ...(result.applied.length ? result.applied.map((file) => ` ${file}`) : [" (none)"]),
755
- ...(result.libPatch
756
- ? [
757
- "",
758
- `Library edits NOT applied (${result.libPatch.files.length}) — review before adopting:`,
759
- "",
760
- ...result.libPatch.files.map((file) => ` ${file}`),
761
- "",
762
- ` patch: ${result.libPatch.path}`,
763
- " adopt with: akan subspace pull <name> --adopt-libs",
764
- ]
765
- : []),
766
- ...(result.ignored.length
767
- ? ["", `Ignored (workspace-owned or subspace-owned): ${result.ignored.length} file(s)`]
768
- : []),
769
- ];
770
- return sections.join("\n");
771
- }
772
-
773
- export function formatSubspaceEnvUpload(result: SubspaceEnvUploadResult) {
774
- const sections = [
775
- `Akan Subspace Env Upload — ${result.name}`,
776
- `cloud workspace: ${result.workspaceId} · host: ${result.host}`,
777
- "",
778
- ...(result.outcome === "cancelled"
779
- ? [" cancelled — the subspace's env archive is untouched."]
780
- : [
781
- `Uploaded (${result.files.length}), replacing that workspace's whole archive:`,
782
- "",
783
- ...result.files.map((file) => ` ${file}`),
784
- "",
785
- ` apps: ${result.apps.join(", ")}`,
786
- ` libs: ${result.libs.join(", ") || "(none)"}`,
787
- ]),
788
- ];
789
- return sections.join("\n");
790
- }
791
-
792
- export function formatSubspaceDiff(result: SubspaceDiffResult) {
793
- const total = result.app.files.length + result.libs.files.length;
794
- const sections = [
795
- `Akan Subspace Diff — ${result.name} (${result.branch})`,
796
- "what `akan subspace push` would change in the subspace",
797
- "",
798
- ...(total ? [] : [" subspace is identical to the workspace for this slice."]),
799
- ...(result.libs.files.length
800
- ? [
801
- `LIBRARY changes (${result.libs.files.length}) — the subspace edited shared code:`,
802
- "",
803
- ...result.libs.files.map((file) => ` ${file}`),
804
- "",
805
- result.libs.patch,
806
- ]
807
- : []),
808
- ...(result.app.files.length
809
- ? [
810
- `App changes (${result.app.files.length}):`,
811
- "",
812
- ...result.app.files.map((file) => ` ${file}`),
813
- "",
814
- result.app.patch,
815
- ]
816
- : []),
817
- ];
818
- return sections.join("\n");
819
- }
820
-
821
- export function formatSubspacePlan(plan: SubspacePlan) {
822
- const list = (entries: string[]) => (entries.length ? entries.join(", ") : "(none)");
823
- const blockers = [plan.branchRefusal, plan.lockProblem].filter((reason): reason is string => !!reason);
824
- const sections = [
825
- `Akan Subspace Plan — ${plan.name} (${plan.branch})`,
826
- `repo: ${plan.repo}`,
827
- "what `akan subspace push` would ship, worked out from this checkout alone",
828
- "",
829
- `apps: ${list(plan.apps)}`,
830
- `libs: ${plan.libs.length ? "" : "(none)"}`.trimEnd(),
831
- ...plan.libs.map((lib) => ` - ${lib.name} (from ${lib.apps.join(", ")})`),
832
- `files under apps/ and libs/: ${plan.files}`,
833
- "",
834
- `root entries pushed: ${list(plan.rootEntries)}`,
835
- `root entries excluded: ${list(plan.excludedRootEntries)}`,
836
- `root entries the subspace keeps its own: ${list(plan.subspaceOwnedRootEntries)}`,
837
- "",
838
- `Root dependencies left out of package.json (${plan.prunedDependencies.length}):`,
839
- ...(plan.prunedDependencies.length ? plan.prunedDependencies.map((dep) => ` - ${dep}`) : [" (none)"]),
840
- "",
841
- blockers.length ? "A push would be refused:" : "Nothing here would make a push refuse.",
842
- ...blockers.map((reason) => ` - ${reason}`),
843
- ...(plan.warnings.length
844
- ? ["", `Warnings (${plan.warnings.length}):`, ...plan.warnings.map((w) => ` - ${w}`)]
845
- : []),
846
- ];
847
- return sections.join("\n");
848
- }