@akanjs/devkit 3.0.2-rc.0 → 3.1.0-rc.0

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 (76) hide show
  1. package/CHANGELOG.md +46 -7
  2. package/akanApp/akanApp.host.test.ts +62 -0
  3. package/akanApp/akanApp.host.ts +16 -4
  4. package/akanApp/devHostPolicy.test.ts +14 -0
  5. package/akanApp/devHostPolicy.ts +11 -0
  6. package/akanConfig/akanConfig.test.ts +77 -7
  7. package/akanConfig/akanConfig.ts +111 -13
  8. package/akanConfig/akanWorkspaceConfig.test.ts +79 -0
  9. package/akanConfig/akanWorkspaceConfig.ts +63 -0
  10. package/akanConfig/index.ts +1 -0
  11. package/akanConfig/types.ts +23 -1
  12. package/akanContext.ts +1 -1
  13. package/applicationBuildRunner.test.ts +1 -1
  14. package/applicationBuildRunner.ts +73 -84
  15. package/artifact/implicitRootLayout.test.ts +6 -2
  16. package/artifact/implicitRootLayout.ts +12 -7
  17. package/biome.base.json +7 -2
  18. package/cloud/cloudApi.ts +82 -0
  19. package/cloud/globalConfig.ts +4 -0
  20. package/cloud/index.ts +1 -0
  21. package/cloud/updatePackUpload.test.ts +195 -0
  22. package/cloud/updatePackUpload.ts +115 -0
  23. package/commandDecorators/command.ts +8 -2
  24. package/commandDecorators/targetMeta.ts +1 -0
  25. package/createTunnel.test.ts +51 -0
  26. package/createTunnel.ts +32 -14
  27. package/executors.test.ts +21 -0
  28. package/executors.ts +9 -2
  29. package/frontendBuild/csrArtifactBuilder.ts +10 -1
  30. package/frontendBuild/cssCandidateCache.test.ts +10 -0
  31. package/frontendBuild/cssCandidateCache.ts +4 -2
  32. package/frontendBuild/pagesBundleBuilder.ts +5 -1
  33. package/frontendBuild/ssrBaseArtifactBuilder.ts +6 -2
  34. package/incrementalBuilder/buildBatch.proc.ts +14 -2
  35. package/incrementalBuilder/buildBatchProtocol.ts +3 -2
  36. package/incrementalBuilder/incrementalBuilder.proc.test.ts +96 -0
  37. package/incrementalBuilder/incrementalBuilder.proc.ts +30 -1
  38. package/lint/__fixtures__/no-import-external-library-in-test/bad.tsx +6 -0
  39. package/lint/__fixtures__/no-import-external-library-in-test/good.tsx +6 -0
  40. package/lint/no-import-external-library-in-test.grit +24 -0
  41. package/mobile/desktopServerStage.test.ts +6 -0
  42. package/mobile/desktopServerStage.ts +25 -3
  43. package/mobile/index.ts +1 -0
  44. package/mobile/nativeApp.test.ts +1 -0
  45. package/mobile/nativeApp.ts +96 -34
  46. package/mobile/nativeConfig.test.ts +59 -1
  47. package/mobile/nativeConfig.ts +13 -3
  48. package/mobile/serverBundleSecrets.test.ts +61 -0
  49. package/mobile/serverBundleSecrets.ts +85 -0
  50. package/mobile/updateUploader.test.ts +49 -0
  51. package/mobile/updateUploader.ts +71 -0
  52. package/package.json +3 -2
  53. package/pipeline/PipelineCompiler.test.ts +631 -0
  54. package/pipeline/PipelineCompiler.ts +646 -0
  55. package/pipeline/PipelineDefinition.test.ts +89 -0
  56. package/pipeline/PipelineDefinition.ts +117 -0
  57. package/pipeline/PipelineDryRun.test.ts +93 -0
  58. package/pipeline/PipelineDryRun.ts +89 -0
  59. package/pipeline/PipelineInputReader.ts +739 -0
  60. package/pipeline/index.ts +4 -0
  61. package/qualityScanner.test.ts +9 -3
  62. package/qualityScanner.ts +5 -2
  63. package/scanInfo.ts +13 -6
  64. package/subspace.test.ts +108 -4
  65. package/subspace.ts +162 -10
  66. package/subspaceConfig.ts +11 -14
  67. package/testHelpers.ts +4 -0
  68. package/transforms/externalizeFrameworkPlugin.ts +1 -7
  69. package/transforms/serverEnvPlugin.test.ts +93 -2
  70. package/transforms/serverEnvPlugin.ts +71 -18
  71. package/transforms/serverEnvProfile.test.ts +82 -0
  72. package/transforms/serverEnvProfile.ts +114 -0
  73. package/typecheck/projectTypecheck.test.ts +32 -0
  74. package/typecheck/projectTypecheck.ts +71 -0
  75. package/workspaceLayout.test.ts +80 -3
  76. package/workspaceLayout.ts +15 -4
@@ -0,0 +1,4 @@
1
+ export * from "./PipelineCompiler";
2
+ export * from "./PipelineDefinition";
3
+ export * from "./PipelineDryRun";
4
+ export * from "./PipelineInputReader";
@@ -233,17 +233,23 @@ describe("AkanQualityScanner layout rules", () => {
233
233
  expect(warnings[0]?.fix).toContain("akan sync");
234
234
  });
235
235
 
236
- test("keeps a root signal test out of the lib facet rule", async () => {
236
+ test("flags a test at the lib root and points it at its module folder or srvkit", async () => {
237
237
  const root = await makeWorkspace({
238
238
  "libs/demo/lib/cnst.ts": "export const cnst = 1;\n",
239
239
  "libs/demo/lib/user.signal.test.ts": "export const test = 1;\n",
240
+ "libs/demo/lib/user/user.signal.test.ts": "export const test = 1;\n",
240
241
  "libs/demo/lib/helper.ts": "export const helper = 1;\n",
241
242
  });
242
243
 
243
244
  const warnings = rulesOf(await new AkanQualityScanner().scan(root), "akan.layout.lib-facet-file");
244
245
 
245
- expect(warnings).toHaveLength(1);
246
- expect(warnings[0]?.file).toBe("libs/demo/lib/helper.ts");
246
+ expect(warnings.map((warning) => warning.file).sort()).toEqual([
247
+ "libs/demo/lib/helper.ts",
248
+ "libs/demo/lib/user.signal.test.ts",
249
+ ]);
250
+ const rootTest = warnings.find((warning) => warning.file === "libs/demo/lib/user.signal.test.ts");
251
+ expect(rootTest?.message).toContain("lib/<model>/<model>.signal.test.ts");
252
+ expect(rootTest?.message).toContain("srvkit/");
247
253
  });
248
254
  });
249
255
 
package/qualityScanner.ts CHANGED
@@ -10,6 +10,7 @@ import { fileWarning, formatSsrBalance, type SsrBalanceEntry, SsrScanner } from
10
10
  import {
11
11
  facetIndexShadowOf,
12
12
  isAllowedLibFacetRootFile,
13
+ libFacetRootHintOf,
13
14
  rootAllowedDirs,
14
15
  rootAllowedFiles,
15
16
  rootEntryHintOf,
@@ -145,7 +146,7 @@ const RULE_FIXES: Record<string, string> = {
145
146
  "akan.layout.lib-root-folder":
146
147
  "Move the folder's contents into a conventional lib folder (common, env, lib, page, private, public, srvkit, ui, or webkit) and delete it.",
147
148
  "akan.layout.lib-facet-file":
148
- "Move the file into a domain module folder under lib/; keep the lib facet root limited to generated support facets.",
149
+ "Move the file into a domain module folder under lib/ (a test beside the file it covers, or into srvkit/ when it drives several modules); keep the lib facet root limited to generated support facets.",
149
150
  "akan.layout.facet-index-shadow":
150
151
  "Delete the hand-written index file and run akan sync; move a namespace it builds into <facet>/<Folder>/index.tsx.",
151
152
  "akan.layout.module-ui-file":
@@ -460,7 +461,9 @@ export class AkanQualityScanner {
460
461
  "akan.layout.lib-facet-file",
461
462
  "layout",
462
463
  sourceFile.file,
463
- `Unexpected lib facet root file "${libFacetFile}". Keep direct lib/ root files limited to generated support facets.`,
464
+ `Unexpected lib facet root file "${libFacetFile}". ${
465
+ libFacetRootHintOf(libFacetFile) ?? "Keep direct lib/ root files limited to generated support facets."
466
+ }`,
464
467
  ),
465
468
  );
466
469
 
package/scanInfo.ts CHANGED
@@ -14,6 +14,9 @@ import {
14
14
  barrelFacetDirs,
15
15
  facetIndexShadowOf,
16
16
  isAllowedLibFacetRootFile,
17
+ libFacetRootHintOf,
18
+ moduleTestHint,
19
+ moduleTestTargetOf,
17
20
  rootAllowedDirs,
18
21
  rootAllowedFiles,
19
22
  rootEntryHintOf,
@@ -93,7 +96,7 @@ async function assertScanConvention(exec: AppExecutor | LibExecutor, libRoot: {
93
96
 
94
97
  for (const filename of libRoot.files)
95
98
  if (!isAllowedLibFacetRootFile(filename))
96
- addViolation(path.join("lib", filename), "unsupported lib facet root file");
99
+ addViolation(path.join("lib", filename), libFacetRootHintOf(filename) ?? "unsupported lib facet root file");
97
100
  for (const dirname of libRoot.dirs)
98
101
  if (dirname.startsWith("__") && !internalLibDirs.has(dirname))
99
102
  addViolation(path.join("lib", dirname), "unsupported internal lib folder");
@@ -133,12 +136,14 @@ async function validateModuleFiles(
133
136
 
134
137
  for (const filename of files) {
135
138
  const scanPath = getScanPath(exec, path.join(modulePath, filename));
136
- if (filename === "index.ts" || filename === "index.tsx" || /\.(test|spec)\.(ts|tsx)$/.test(filename)) continue;
139
+ if (filename === "index.ts" || filename === "index.tsx") continue;
137
140
  if (filename === `${moduleName}.abstract.md`) continue;
138
- const uiMatch = filename.match(/^([A-Z][A-Za-z0-9]+)\.([A-Z][A-Za-z0-9]*)\.tsx$/);
139
- const match = uiMatch ?? filename.match(/^([a-z][a-zA-Z0-9]*)\.([a-z][a-z0-9]*)\.ts$/);
141
+ const testTarget = moduleTestTargetOf(filename);
142
+ const sourceName = testTarget ?? filename;
143
+ const uiMatch = sourceName.match(/^([A-Z][A-Za-z0-9]+)\.([A-Z][A-Za-z0-9]*)\.tsx$/);
144
+ const match = uiMatch ?? sourceName.match(/^([a-z][a-zA-Z0-9]*)\.([a-z][a-z0-9]*)\.ts$/);
140
145
  if (!match) {
141
- violations.push(`${scanPath}: unsupported module file`);
146
+ violations.push(`${scanPath}: ${testTarget ? moduleTestHint : "unsupported module file"}`);
142
147
  continue;
143
148
  }
144
149
  const [, fileModuleName, fileType] = match;
@@ -146,7 +151,9 @@ async function validateModuleFiles(
146
151
  if (fileModuleName !== expectedName)
147
152
  violations.push(`${scanPath}: module name mismatch: expected '${expectedName}', got '${fileModuleName}'`);
148
153
  if (!(uiMatch ? moduleUiFileTypes : moduleNonUiFileTypes)[kind].has(fileType))
149
- violations.push(`${scanPath}: unsupported ${kind}${uiMatch ? " UI" : ""} file`);
154
+ violations.push(
155
+ `${scanPath}: ${testTarget ? moduleTestHint : `unsupported ${kind}${uiMatch ? " UI" : ""} file`}`,
156
+ );
150
157
  }
151
158
  }
152
159
 
package/subspace.test.ts CHANGED
@@ -3,7 +3,13 @@ import { mkdir, readdir, rm } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { Executor, WorkspaceExecutor } from "./executors";
5
5
  import { FileSys } from "./fileSys";
6
- import { formatSubspaceDiff, formatSubspacePushResults, formatSubspaceStatuses, Subspace } from "./subspace";
6
+ import {
7
+ formatSubspaceDiff,
8
+ formatSubspacePlan,
9
+ formatSubspacePushResults,
10
+ formatSubspaceStatuses,
11
+ Subspace,
12
+ } from "./subspace";
7
13
  import { SubspaceConfig } from "./subspaceConfig";
8
14
  import { isolateEnv, tempDirs, writeText as write } from "./testHelpers";
9
15
 
@@ -88,9 +94,10 @@ const makeMirror = async (servedApp: string, privateApp: string, libName: string
88
94
  );
89
95
  await write(path.join(wsRoot, "benchmarks/bench.ts"), "export {};\n");
90
96
  await write(
91
- path.join(wsRoot, "akan.subspace.ts"),
92
- `export default { pushableBranches: ["develop"], exclude: ["benchmarks"], subspaces: [{ name: "acme", repo: ${JSON.stringify(bareRoot)}, apps: ["${servedApp}"] }] };\n`,
97
+ path.join(wsRoot, "akan.config.ts"),
98
+ `export default { subspace: { pushableBranches: ["develop"], exclude: ["benchmarks"], subspaces: [{ name: "acme", repo: ${JSON.stringify(bareRoot)}, apps: ["${servedApp}"] }] } };\n`,
93
99
  );
100
+ await write(path.join(wsRoot, "Jenkinsfile"), "pipeline { agent any }\n");
94
101
 
95
102
  await makeSys(wsRoot, "apps", servedApp, {
96
103
  "lib/task/task.constant.ts": [
@@ -152,7 +159,8 @@ describe("Subspace push", () => {
152
159
  expect(await FileSys.fileExists(path.join(subspaceRoot, "libs/kit/common/helper.ts"))).toBe(true);
153
160
  expect(await FileSys.dirExists(path.join(subspaceRoot, "apps/private"))).toBe(false);
154
161
  expect(entries).not.toContain("benchmarks");
155
- expect(entries).not.toContain("akan.subspace.ts");
162
+ expect(entries).not.toContain("akan.config.ts");
163
+ expect(entries).not.toContain("Jenkinsfile");
156
164
  expect(await FileSys.fileExists(path.join(subspaceRoot, "apps/served/lib/cnst.ts"))).toBe(false);
157
165
  expect(await FileSys.fileExists(path.join(subspaceRoot, "apps/served/env/env.server.local.ts"))).toBe(false);
158
166
  expect(await FileSys.fileExists(path.join(subspaceRoot, "apps/served/env/env.server.ts"))).toBe(true);
@@ -216,6 +224,63 @@ describe("Subspace push", () => {
216
224
  expect((await subspace.push("develop", { verify: false })).outcome).toBe("skipped");
217
225
  });
218
226
 
227
+ test("skips a push after an unrelated workspace commit, and re-stamps a library only when its content moves", async () => {
228
+ const { wsRoot, workRoot, bareRoot, subspace } = await makeMirror("calm-served", "calm-private", "calm-kit");
229
+ expect((await subspace.push("develop", { verify: false })).outcome).toBe("pushed");
230
+ const stampOf = async (root: string) =>
231
+ (
232
+ (await FileSys.readJson(path.join(root, "libs/calm-kit/package.json"))) as {
233
+ akan: { source: { sha: string; hash: string } };
234
+ }
235
+ ).akan.source;
236
+ const first = await stampOf(await cloneSubspace(workRoot, bareRoot, "calm-first"));
237
+
238
+ await write(path.join(wsRoot, "apps/calm-private/lib/task/task.constant.ts"), "export const task = 2;\n");
239
+ await git(wsRoot, ["add", "-A"]);
240
+ await git(wsRoot, ["commit", "--quiet", "-m", "outside the slice"]);
241
+ expect((await subspace.push("develop", { verify: false })).outcome).toBe("skipped");
242
+
243
+ await write(path.join(wsRoot, "libs/calm-kit/common/helper.ts"), "export const helper = 2;\n");
244
+ await git(wsRoot, ["add", "-A"]);
245
+ await git(wsRoot, ["commit", "--quiet", "-m", "inside the slice"]);
246
+ expect((await subspace.push("develop", { verify: false })).outcome).toBe("pushed");
247
+ const second = await stampOf(await cloneSubspace(workRoot, bareRoot, "calm-second"));
248
+ expect(second.hash).not.toBe(first.hash);
249
+ expect(second.sha).not.toBe(first.sha);
250
+ });
251
+
252
+ test("keeps the subspace's own root config and drops a Jenkinsfile an earlier push left there", async () => {
253
+ const { workRoot, bareRoot, subspace } = await makeMirror("own-served", "own-private", "own-kit");
254
+ const customer = await cloneSubspace(workRoot, bareRoot, "own-customer");
255
+ await write(path.join(customer, "akan.config.ts"), "export default { pipeline: {} };\n");
256
+ await git(customer, ["add", "-A"]);
257
+ await git(customer, ["commit", "--quiet", "-m", "our own global stages"]);
258
+ await write(path.join(customer, "Jenkinsfile"), "pipeline { agent none }\n");
259
+ await git(customer, ["add", "-A"]);
260
+ await git(customer, ["commit", "--quiet", "-m", `${Subspace.syncSubjectPrefix}workspace@0000000`]);
261
+ await git(customer, ["push", "--quiet", "origin", "develop"]);
262
+
263
+ expect((await subspace.push("develop", { verify: false })).outcome).toBe("pushed");
264
+ const subspaceRoot = await cloneSubspace(workRoot, bareRoot, "own-check");
265
+ expect(await FileSys.readText(path.join(subspaceRoot, "akan.config.ts"))).toContain(
266
+ "export default { pipeline: {} };",
267
+ );
268
+ expect(await FileSys.fileExists(path.join(subspaceRoot, "Jenkinsfile"))).toBe(false);
269
+ });
270
+
271
+ test("refuses a lock that resolves from a registry only this machine reaches", async () => {
272
+ const { wsRoot, subspace } = await makeMirror("lock-served", "lock-private", "lock-kit");
273
+ await write(
274
+ path.join(wsRoot, "bun.lock"),
275
+ '{ "packages": { "lodash": ["lodash@4.0.0", "http://127.0.0.1:4873/lodash/-/lodash-4.0.0.tgz"] } }\n',
276
+ );
277
+ await git(wsRoot, ["add", "-f", "bun.lock"]);
278
+ await git(wsRoot, ["commit", "--quiet", "-m", "a verdaccio lock"]);
279
+ const result = (await subspace.push("develop", { verify: false })) as { outcome: string; reason?: string };
280
+ expect(result.outcome).toBe("refused");
281
+ expect(result.reason).toContain("http://127.0.0.1:4873");
282
+ });
283
+
219
284
  test("refuses a branch the subspace does not have, and one the config does not allow", async () => {
220
285
  const { subspace } = await makeMirror("refuse-served", "refuse-private", "refuse-kit");
221
286
  const missing = await subspace.push("release", { verify: false }).catch((error: Error) => error);
@@ -445,3 +510,42 @@ describe("SubspaceConfig", () => {
445
510
  expect(() => config.assertPushable("develop")).not.toThrow();
446
511
  });
447
512
  });
513
+
514
+ describe("Subspace.plan", () => {
515
+ test("reports the slice a push ships without adding a remote or a clone", async () => {
516
+ const { wsRoot, subspace, clonePath } = await makeMirror("served", "private", "kit");
517
+ const plan = await subspace.plan("develop");
518
+ expect(plan).toMatchObject({
519
+ name: "acme",
520
+ branch: "develop",
521
+ branchRefusal: null,
522
+ apps: ["served"],
523
+ libs: [{ name: "kit", apps: ["served"] }],
524
+ excludedRootEntries: ["benchmarks"],
525
+ subspaceOwnedRootEntries: ["akan.config.ts", "Jenkinsfile"],
526
+ prunedDependencies: ["another-app-dep"],
527
+ lockProblem: null,
528
+ });
529
+ expect(plan.rootEntries).toEqual(expect.arrayContaining([".gitignore", "AGENTS.md", "biome.json", "package.json"]));
530
+ expect(plan.rootEntries).not.toContain("benchmarks");
531
+ expect(plan.rootEntries).not.toContain("Jenkinsfile");
532
+ expect(plan.files).toBeGreaterThan(0);
533
+ expect((await git(wsRoot, ["remote"])).trim()).toBe("");
534
+ expect(await FileSys.dirExists(clonePath)).toBe(false);
535
+ expect(formatSubspacePlan(plan)).toContain("Nothing here would make a push refuse.");
536
+ });
537
+
538
+ test("names what a push would refuse: a branch it may not push and a loopback-registry lock", async () => {
539
+ const { wsRoot, subspace } = await makeMirror("served", "private", "kit");
540
+ await write(
541
+ path.join(wsRoot, "bun.lock"),
542
+ '{ "packages": { "x": ["x@1.0.0", "http://127.0.0.1:4873/x/-/x-1.0.0.tgz"] } }\n',
543
+ );
544
+ await git(wsRoot, ["add", "-f", "bun.lock"]);
545
+ await git(wsRoot, ["commit", "--quiet", "-m", "lock"]);
546
+ const plan = await subspace.plan("main");
547
+ expect(plan.branchRefusal).toContain('Branch "main" is not pushable');
548
+ expect(plan.lockProblem).toContain("http://127.0.0.1:4873");
549
+ expect(formatSubspacePlan(plan)).toContain("A push would be refused:");
550
+ });
551
+ });
package/subspace.ts CHANGED
@@ -55,6 +55,28 @@ export interface SubspacePushResult {
55
55
  prunedDependencies?: string[];
56
56
  }
57
57
 
58
+ export interface SubspacePlan {
59
+ name: string;
60
+ repo: string;
61
+ branch: string;
62
+ /** Why a push from this branch would be refused, or null when it may push. */
63
+ branchRefusal: string | null;
64
+ apps: string[];
65
+ /** Each library the slice carries, with the apps whose closure pulls it in. */
66
+ libs: { name: string; apps: string[] }[];
67
+ /** Tracked files under the slice's apps/ and libs/ directories. */
68
+ files: number;
69
+ /** Workspace-root entries a push copies into the subspace. */
70
+ rootEntries: string[];
71
+ excludedRootEntries: string[];
72
+ /** Workspace-root entries never pushed: the subspace keeps its own copy, if it has one. */
73
+ subspaceOwnedRootEntries: string[];
74
+ prunedDependencies: string[];
75
+ /** Why the workspace's bun.lock would make a push refuse, or null. */
76
+ lockProblem: string | null;
77
+ warnings: string[];
78
+ }
79
+
58
80
  export interface SubspaceEnvUploadResult {
59
81
  name: string;
60
82
  /** The cloud workspace whose env archive was replaced: the subspace's own, never this workspace's. */
@@ -79,14 +101,19 @@ export interface SubspacePullResult {
79
101
  // pull diffs against the last push received, so it is exactly the customer's own work.
80
102
  export class Subspace {
81
103
  static readonly anchorFile = "akan.subspace.json";
82
- /** Never reaches a subspace: it names every other customer's repo. */
83
- static readonly workspaceOnlyEntries = ["akan.subspace.ts"];
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 ";
84
110
  /** Workspace members, which are replaced wholesale rather than overlaid. */
85
111
  static readonly memberDirs = ["apps", "libs"];
86
112
  /** Subspace-owned in both directions: env values belong to the repo that deploys, not to the workspace. */
87
113
  static readonly subspaceOwnedDirs = ["env"];
88
114
  static readonly secretsMarkers = ["# akan:secrets (managed by akan.config.ts — do not edit)", "# akan:secrets:end"];
89
115
  static readonly holdDir = ".akan-subspace-hold";
116
+ static readonly #loopbackRegistry = /https?:\/\/(?:127\.0\.0\.1|localhost|0\.0\.0\.0|\[::1\])(?::\d+)?/;
90
117
 
91
118
  #workspace: WorkspaceExecutor;
92
119
  #config: SubspaceConfig;
@@ -118,6 +145,10 @@ export class Subspace {
118
145
  return await this.#workspace.spawn("git", args);
119
146
  }
120
147
 
148
+ async #cloneGit(args: string[]) {
149
+ return await this.#workspace.spawn("git", ["-C", this.#clonePath, ...args]);
150
+ }
151
+
121
152
  async branch() {
122
153
  return (await this.#git(["rev-parse", "--abbrev-ref", "HEAD"])).trim();
123
154
  }
@@ -167,6 +198,52 @@ export class Subspace {
167
198
  return { plans, libs, paths };
168
199
  }
169
200
 
201
+ /** What a push from `branch` would ship, worked out from this checkout alone: no fetch, no remote, no clone. */
202
+ async plan(branch: string): Promise<SubspacePlan> {
203
+ const { plans, libs } = await this.slice();
204
+ const rootFiles = plans[0]?.rootFiles ?? [];
205
+ const present = new Set(rootFiles.map((file) => file.split("/")[0] ?? file));
206
+ const owned = Subspace.subspaceOwnedRootEntries;
207
+ const excluded = this.#config.exclude.filter((entry) => !owned.includes(entry));
208
+ const excludedTops = new Set(excluded.map((entry) => entry.replace(/\/+$/, "")));
209
+ const libFiles = new Map(plans.flatMap((plan) => Object.entries(plan.libFiles)));
210
+ const { pruned, warnings } = await SlicePlanner.pruneDependencies(
211
+ this.#workspace,
212
+ await this.#workspace.getPackageJson(),
213
+ plans.flatMap((plan) => plan.requiredDependencies),
214
+ );
215
+ let branchRefusal: string | null = null;
216
+ try {
217
+ this.#config.assertPushable(branch);
218
+ } catch (error) {
219
+ branchRefusal = error instanceof Error ? error.message : String(error);
220
+ }
221
+ return {
222
+ name: this.name,
223
+ repo: this.#declaration.repo,
224
+ branch,
225
+ branchRefusal,
226
+ apps: [...this.#declaration.apps],
227
+ libs: libs.map((lib) => ({
228
+ name: lib,
229
+ apps: plans.filter((plan) => plan.libs.includes(lib)).map((plan) => plan.app),
230
+ })),
231
+ files:
232
+ plans.reduce((sum, plan) => sum + plan.appFiles.length, 0) +
233
+ [...libFiles.values()].reduce((sum, files) => sum + files.length, 0),
234
+ rootEntries: [...present]
235
+ .filter((entry) => !excludedTops.has(entry) && !owned.includes(entry))
236
+ .sort((a, b) => a.localeCompare(b)),
237
+ excludedRootEntries: excluded,
238
+ subspaceOwnedRootEntries: owned.filter((entry) => present.has(entry)),
239
+ prunedDependencies: pruned,
240
+ lockProblem: rootFiles.includes("bun.lock")
241
+ ? await this.#loopbackRegistryLock(this.#workspace.workspaceRoot)
242
+ : null,
243
+ warnings: [...new Set([...plans.flatMap((plan) => plan.warnings), ...warnings])],
244
+ };
245
+ }
246
+
170
247
  #isSubspaceOwned(file: string) {
171
248
  const segments = file.split("/");
172
249
  if (!Subspace.memberDirs.includes(segments[0] ?? "")) return false;
@@ -258,6 +335,36 @@ export class Subspace {
258
335
  await rm(path.join(this.#clonePath, Subspace.holdDir), { recursive: true, force: true });
259
336
  }
260
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
+
261
368
  static async #copyMissing(from: string, to: string) {
262
369
  await mkdir(to, { recursive: true });
263
370
  for (const entry of await readdir(from, { withFileTypes: true })) {
@@ -337,16 +444,19 @@ export class Subspace {
337
444
  async #applySlice(clone: Executor, branch: string) {
338
445
  const { libs, paths, plans } = await this.slice();
339
446
  const held = await this.#holdSubspaceOwned();
447
+ const heldRootEntries = await this.#holdRootEntries();
340
448
  for (const member of Subspace.memberDirs)
341
449
  await rm(path.join(this.#clonePath, member), { recursive: true, force: true });
342
450
 
343
451
  //* Overlaid, never reconciled: a subspace owns its deployment, so a root entry the workspace lacks is left alone.
344
452
  await this.#extract(["."], ["apps/*", "libs/*", "pkgs/*"]);
345
453
  await this.#extract(paths);
454
+ await this.#restoreRootEntries(heldRootEntries);
346
455
  await this.#restoreSubspaceOwned(held);
347
456
 
348
- for (const entry of [...Subspace.workspaceOnlyEntries, ...this.#config.exclude])
349
- await rm(path.join(this.#clonePath, entry), { recursive: true, force: true });
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 });
350
460
 
351
461
  await this.#rewriteGitignore();
352
462
  const pruned = await this.#rewriteManifest(plans);
@@ -404,11 +514,12 @@ export class Subspace {
404
514
  const [hash, previous] = await Promise.all([this.#hashLib(clone, lib), this.#committedStamp(clone, lib)]);
405
515
  const manifest = (await FileSys.readJson(manifestPath)) as Record<string, unknown>;
406
516
  const akan = (manifest[LibSource.manifestKey] ?? {}) as Record<string, unknown>;
407
- //* The extraction overwrote the manifest, so the previous stamp comes from the clone's HEAD; reusing its
408
- //* `syncedAt` when nothing moved keeps the file byte-identical, or no push would ever be skipped.
409
- const unchanged = previous?.origin === origin && previous.sha === sha && previous.hash === hash;
410
- const syncedAt = unchanged ? previous.syncedAt : new Date().toISOString();
411
- manifest[LibSource.manifestKey] = { ...akan, source: { origin, sha, hash, syncedAt } };
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 };
412
523
  await FileSys.writeJson(manifestPath, manifest);
413
524
  }
414
525
 
@@ -472,6 +583,16 @@ export class Subspace {
472
583
  return { files, patch: await this.#git(["diff", from, "HEAD", "--", ...files]) };
473
584
  }
474
585
 
586
+ // 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];
591
+ return registry
592
+ ? `bun.lock resolves packages from ${registry}, a registry only this machine reaches; reinstall from a public registry and commit that lock first`
593
+ : null;
594
+ }
595
+
475
596
  // Refuses rather than throws, so one failing subspace does not abort the rest; the env is explicit so the child
476
597
  // sees the clone's values, not the workspace's.
477
598
  async #verify(clone: Executor) {
@@ -510,9 +631,11 @@ export class Subspace {
510
631
  const failure = await this.#verify(clone);
511
632
  if (failure) return { name: this.name, outcome: "refused", reason: failure, changedFiles };
512
633
  }
634
+ const lockProblem = await this.#loopbackRegistryLock();
635
+ if (lockProblem) return { name: this.name, outcome: "refused", reason: lockProblem, changedFiles };
513
636
  await this.#writeAnchorFile(branch);
514
637
  await clone.spawn("git", ["add", "-A"]);
515
- const message = `chore(subspace): sync from ${this.#workspace.repoName}@${await this.headSha()}`;
638
+ const message = `${Subspace.syncSubjectPrefix}${this.#workspace.repoName}@${await this.headSha()}`;
516
639
  await clone.spawn("git", ["commit", "--quiet", "-m", message]);
517
640
  //* Never force: a diverged subspace holds customer commits, and `pull` is how those come back.
518
641
  await clone.spawn("git", ["push", "--quiet", "origin", branch]);
@@ -694,3 +817,32 @@ export function formatSubspaceDiff(result: SubspaceDiffResult) {
694
817
  ];
695
818
  return sections.join("\n");
696
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
+ }
package/subspaceConfig.ts CHANGED
@@ -1,20 +1,17 @@
1
- import path from "node:path";
2
1
  import type { SubspaceConfigInput, SubspaceDeclaration } from "akanjs";
3
2
  import type { WorkspaceExecutor } from "./executors";
4
- import { FileSys } from "./fileSys";
5
3
 
6
4
  export type { SubspaceConfigInput, SubspaceDeclaration };
7
5
 
8
6
  // No branch field: the workspace's current branch is used, so every subspace on it holds the same akanjs and libs.
9
7
  export class SubspaceConfig {
10
- static readonly fileName = "akan.subspace.ts";
8
+ /** Where the declaration lives, as error messages name it. */
9
+ static readonly source = "subspace in the workspace root akan.config.ts";
11
10
  static readonly defaultPushableBranches = ["main", "develop", "debug"];
12
11
 
13
12
  static async from(workspace: WorkspaceExecutor): Promise<SubspaceConfig | null> {
14
- const configPath = path.join(workspace.workspaceRoot, SubspaceConfig.fileName);
15
- if (!(await FileSys.fileExists(configPath))) return null;
16
- const input = await import(configPath).then((mod: { default: SubspaceConfigInput }) => mod.default);
17
- return new SubspaceConfig(input);
13
+ const { subspace } = await workspace.getConfig();
14
+ return subspace ? new SubspaceConfig(subspace) : null;
18
15
  }
19
16
 
20
17
  readonly pushableBranches: string[];
@@ -34,18 +31,18 @@ export class SubspaceConfig {
34
31
  const idOwners = new Map<string, string>();
35
32
  for (const subspace of this.subspaces) {
36
33
  if (!subspace.name || !subspace.repo)
37
- throw new Error(`${SubspaceConfig.fileName}: every subspace needs a name and a repo`);
34
+ throw new Error(`${SubspaceConfig.source}: every subspace needs a name and a repo`);
38
35
  if (seenNames.has(subspace.name))
39
- throw new Error(`${SubspaceConfig.fileName}: duplicate subspace "${subspace.name}"`);
36
+ throw new Error(`${SubspaceConfig.source}: duplicate subspace "${subspace.name}"`);
40
37
  seenNames.add(subspace.name);
41
38
  if (!subspace.apps?.length)
42
- throw new Error(`${SubspaceConfig.fileName}: subspace "${subspace.name}" declares no apps`);
39
+ throw new Error(`${SubspaceConfig.source}: subspace "${subspace.name}" declares no apps`);
43
40
  for (const app of subspace.apps) {
44
41
  const owner = appOwners.get(app);
45
42
  //* One app never goes to two subspaces: per-subspace domains and ids are not expressible. Share a lib instead.
46
43
  if (owner)
47
44
  throw new Error(
48
- `${SubspaceConfig.fileName}: app "${app}" is claimed by both "${owner}" and "${subspace.name}"`,
45
+ `${SubspaceConfig.source}: app "${app}" is claimed by both "${owner}" and "${subspace.name}"`,
49
46
  );
50
47
  appOwners.set(app, subspace.name);
51
48
  }
@@ -54,7 +51,7 @@ export class SubspaceConfig {
54
51
  //* An env upload replaces the cloud workspace's archive whole, so two subspaces would overwrite each other.
55
52
  if (idOwner)
56
53
  throw new Error(
57
- `${SubspaceConfig.fileName}: subspaces "${idOwner}" and "${subspace.name}" declare the same workspaceId`,
54
+ `${SubspaceConfig.source}: subspaces "${idOwner}" and "${subspace.name}" declare the same workspaceId`,
58
55
  );
59
56
  idOwners.set(subspace.workspaceId, subspace.name);
60
57
  }
@@ -64,7 +61,7 @@ export class SubspaceConfig {
64
61
  if (!names.length) return this.subspaces;
65
62
  return names.map((name) => {
66
63
  const subspace = this.subspaces.find((candidate) => candidate.name === name);
67
- if (!subspace) throw new Error(`${SubspaceConfig.fileName}: unknown subspace "${name}"`);
64
+ if (!subspace) throw new Error(`${SubspaceConfig.source}: unknown subspace "${name}"`);
68
65
  return subspace;
69
66
  });
70
67
  }
@@ -72,7 +69,7 @@ export class SubspaceConfig {
72
69
  assertPushable(branch: string) {
73
70
  if (this.pushableBranches.includes(branch)) return;
74
71
  throw new Error(
75
- `Branch "${branch}" is not pushable. ${SubspaceConfig.fileName} allows: ${this.pushableBranches.join(", ")}`,
72
+ `Branch "${branch}" is not pushable. ${SubspaceConfig.source} allows: ${this.pushableBranches.join(", ")}`,
76
73
  );
77
74
  }
78
75
  }
package/testHelpers.ts CHANGED
@@ -61,6 +61,10 @@ export const createFakeExecutor = <Extra extends object = object>(
61
61
  recorder.record(`${name}.scanSync`, ...args);
62
62
  return { name };
63
63
  },
64
+ //? `PkgExecutor.from(fake, name)` reads `.workspace`; without one it falls back to `fromRoot()`, which forks git.
65
+ get workspace(): unknown {
66
+ return this;
67
+ },
64
68
  ...extra,
65
69
  }) as Extra & {
66
70
  name: string;
@@ -17,13 +17,7 @@ const DEFAULT_INCLUDE = ["akanjs/", "@apps/", "@libs/"];
17
17
  // Runtime hosts and singleton framework packages stay external; ordinary npm dependencies are bundled.
18
18
  const DEFAULT_EXCLUDE_EXACT = new Set<string>(["akanjs/webkit", "@akanjs/cli", "@akanjs/devkit"]);
19
19
  const DEFAULT_EXCLUDE_PREFIX = ["@akanjs/cli/", "@akanjs/devkit/"];
20
- const OPTIONAL_BACKEND_EXTERNAL_EXACT = new Set<string>([
21
- "@libsql/client",
22
- "bullmq",
23
- "ioredis",
24
- "postgres",
25
- "protobufjs",
26
- ]);
20
+ const OPTIONAL_BACKEND_EXTERNAL_EXACT = new Set<string>(["bullmq", "ioredis", "postgres", "protobufjs"]);
27
21
  const RUNTIME_EXTERNAL_EXACT = new Set<string>([
28
22
  "react",
29
23
  "react-dom",