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

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 +69 -0
  24. package/executors.ts +30 -27
  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
@@ -0,0 +1,361 @@
1
+ import { mkdir, readdir, rename, rm } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { toError } from "akanjs/common";
4
+ import { Executor, WorkspaceExecutor } from "./executors";
5
+ import { FileSys } from "./fileSys";
6
+ import { LibSource } from "./libSource";
7
+ import { type SlicePlan, SlicePlanner } from "./slicePlanner";
8
+ import type { SubspaceDeclaration } from "./subspaceConfig";
9
+ import type { PackageJson } from "./types";
10
+
11
+ export class SubspaceClone {
12
+ static readonly anchorFile = "akan.subspace.json";
13
+ /**
14
+ * The workspace's own never reach a subspace — its root config names every other customer's repo, its Jenkinsfile
15
+ * deploys the workspace — and a subspace's own survive every push.
16
+ */
17
+ static readonly subspaceOwnedRootEntries = ["akan.config.ts", "Jenkinsfile"];
18
+ static readonly syncSubjectPrefix = "chore(subspace): sync from ";
19
+ /** Workspace members, which are replaced wholesale rather than overlaid. */
20
+ static readonly memberDirs = ["apps", "libs"];
21
+ /** Subspace-owned in both directions: env values belong to the repo that deploys, not to the workspace. */
22
+ static readonly subspaceOwnedDirs = ["env"];
23
+ static readonly secretsMarkers = ["# akan:secrets (managed by akan.config.ts — do not edit)", "# akan:secrets:end"];
24
+ static readonly holdDir = ".akan-subspace-hold";
25
+
26
+ static isSubspaceOwned(file: string) {
27
+ const segments = file.split("/");
28
+ if (!SubspaceClone.memberDirs.includes(segments[0] ?? "")) return false;
29
+ return SubspaceClone.subspaceOwnedDirs.includes(segments[2] ?? "");
30
+ }
31
+
32
+ static async open(workspace: WorkspaceExecutor, declaration: SubspaceDeclaration, branch: string) {
33
+ const clone = new SubspaceClone(workspace, declaration);
34
+ await clone.#ensure(branch);
35
+ return clone;
36
+ }
37
+
38
+ readonly #workspace: WorkspaceExecutor;
39
+ readonly #declaration: SubspaceDeclaration;
40
+ readonly #clonePath: string;
41
+ readonly #clone: Executor;
42
+
43
+ constructor(workspace: WorkspaceExecutor, declaration: SubspaceDeclaration) {
44
+ this.#workspace = workspace;
45
+ this.#declaration = declaration;
46
+ this.#clonePath = path.join(workspace.workspaceRoot, ".akan/subspace", declaration.name);
47
+ this.#clone = new Executor(`subspace-${declaration.name}`, this.#clonePath);
48
+ }
49
+
50
+ async #cloneGit(args: string[]) {
51
+ return await this.#workspace.spawn("git", ["-C", this.#clonePath, ...args]);
52
+ }
53
+
54
+ async #ensure(branch: string) {
55
+ if (!(await FileSys.dirExists(path.join(this.#clonePath, ".git")))) {
56
+ await rm(this.#clonePath, { recursive: true, force: true });
57
+ await mkdir(path.dirname(this.#clonePath), { recursive: true });
58
+ await this.#workspace.spawn("git", ["clone", "--quiet", this.#declaration.repo, this.#clonePath]);
59
+ }
60
+ await this.#clone.spawn("git", ["fetch", "--quiet", "origin", branch]);
61
+ await this.#clone.spawn("git", ["checkout", "--quiet", "-f", "-B", branch, `origin/${branch}`]);
62
+ await this.#clone.spawn("git", ["clean", "-qfd"]);
63
+ }
64
+
65
+ async applySlice(
66
+ { libs, paths, plans }: { libs: string[]; paths: string[]; plans: SlicePlan[] },
67
+ { branch, sha, remove }: { branch: string; sha: string; remove: string[] },
68
+ ) {
69
+ const held = await this.#holdSubspaceOwned();
70
+ const heldRootEntries = await this.#holdRootEntries();
71
+ for (const member of SubspaceClone.memberDirs)
72
+ await rm(path.join(this.#clonePath, member), { recursive: true, force: true });
73
+
74
+ //* Overlaid, never reconciled: a subspace owns its deployment, so a root entry the workspace lacks is left alone.
75
+ await this.#extract(["."], ["apps/*", "libs/*", "pkgs/*"]);
76
+ await this.#extract(paths);
77
+ await this.#restoreRootEntries(heldRootEntries);
78
+ await this.#restoreSubspaceOwned(held);
79
+
80
+ for (const entry of remove)
81
+ if (!SubspaceClone.subspaceOwnedRootEntries.includes(entry))
82
+ await rm(path.join(this.#clonePath, entry), { recursive: true, force: true });
83
+
84
+ await this.#rewriteGitignore();
85
+ const pruned = await this.#rewriteManifest(plans);
86
+ await this.#rewriteWorkspaceSection(libs);
87
+ await this.#writeLocalEnv();
88
+ for (const lib of libs) await this.#stampLib(lib, branch, sha);
89
+ return { libs, pruned };
90
+ }
91
+
92
+ async changedFiles() {
93
+ const dirty = (await this.#clone.spawn("git", ["status", "--porcelain"])).trim();
94
+ return dirty ? dirty.split("\n").length : 0;
95
+ }
96
+
97
+ // Refuses rather than throws, so one failing subspace does not abort the rest; the env is explicit so the child
98
+ // sees the clone's values, not the workspace's.
99
+ async verify() {
100
+ const env = { ...process.env, ...this.#localEnv() };
101
+ try {
102
+ await this.#clone.spawn("bun", ["install"], { env });
103
+ for (const app of this.#declaration.apps) await this.#clone.spawn("bunx", ["akan", "sync", app], { env });
104
+ return null;
105
+ } catch (error) {
106
+ return toError(error).message;
107
+ }
108
+ }
109
+
110
+ async lock() {
111
+ const lockPath = path.join(this.#clonePath, "bun.lock");
112
+ return (await FileSys.fileExists(lockPath)) ? await FileSys.readText(lockPath) : null;
113
+ }
114
+
115
+ async commit(branch: string, sha: string) {
116
+ await this.#writeAnchorFile(branch, sha);
117
+ await this.#clone.spawn("git", ["add", "-A"]);
118
+ const message = `${SubspaceClone.syncSubjectPrefix}${this.#workspace.repoName}@${sha}`;
119
+ await this.#clone.spawn("git", ["commit", "--quiet", "-m", message]);
120
+ //* Never force: a diverged subspace holds customer commits, and `pull` is how those come back.
121
+ await this.#clone.spawn("git", ["push", "--quiet", "origin", branch]);
122
+ return (await this.#clone.spawn("git", ["rev-parse", "--short", "HEAD"])).trim();
123
+ }
124
+
125
+ async #writeAnchorFile(branch: string, sha: string) {
126
+ await FileSys.writeJson(path.join(this.#clonePath, SubspaceClone.anchorFile), {
127
+ workspace: this.#workspace.repoName,
128
+ hubSha: sha,
129
+ branch,
130
+ apps: this.#declaration.apps,
131
+ syncedAt: new Date().toISOString(),
132
+ });
133
+ }
134
+
135
+ /** Moves the subspace's env trees aside, so replacing the members cannot take them with it. */
136
+ async #holdSubspaceOwned() {
137
+ const held: { from: string; to: string }[] = [];
138
+ const holdRoot = path.join(this.#clonePath, SubspaceClone.holdDir);
139
+ await rm(holdRoot, { recursive: true, force: true });
140
+ for (const member of SubspaceClone.memberDirs) {
141
+ const memberRoot = path.join(this.#clonePath, member);
142
+ if (!(await FileSys.dirExists(memberRoot))) continue;
143
+ for (const name of await readdir(memberRoot)) {
144
+ for (const owned of SubspaceClone.subspaceOwnedDirs) {
145
+ const from = path.join(memberRoot, name, owned);
146
+ if (!(await FileSys.dirExists(from))) continue;
147
+ const to = path.join(holdRoot, member, name, owned);
148
+ await mkdir(path.dirname(to), { recursive: true });
149
+ await rename(from, to);
150
+ held.push({ from, to });
151
+ }
152
+ }
153
+ }
154
+ return held;
155
+ }
156
+
157
+ // The workspace owns the tracked env switch files; the per-environment values it gitignores belong to the subspace.
158
+ async #restoreSubspaceOwned(held: { from: string; to: string }[]) {
159
+ for (const { from, to } of held) await SubspaceClone.#copyMissing(to, from);
160
+ await rm(path.join(this.#clonePath, SubspaceClone.holdDir), { recursive: true, force: true });
161
+ }
162
+
163
+ //* A root entry last written by a sync commit is a copy an earlier push left there, the parent's Jenkinsfile among them:
164
+ //* it goes. Any other is the subspace's own and is held through the extraction.
165
+ async #holdRootEntries() {
166
+ const holdRoot = path.join(this.#clonePath, SubspaceClone.holdDir, "root");
167
+ const held: string[] = [];
168
+ for (const entry of SubspaceClone.subspaceOwnedRootEntries) {
169
+ const from = path.join(this.#clonePath, entry);
170
+ if (!(await FileSys.entryExists(from))) continue;
171
+ if (await this.#isPushedCopy(entry)) continue;
172
+ await mkdir(holdRoot, { recursive: true });
173
+ await rename(from, path.join(holdRoot, entry));
174
+ held.push(entry);
175
+ }
176
+ return held;
177
+ }
178
+
179
+ async #restoreRootEntries(held: string[]) {
180
+ const holdRoot = path.join(this.#clonePath, SubspaceClone.holdDir, "root");
181
+ for (const entry of SubspaceClone.subspaceOwnedRootEntries) {
182
+ const target = path.join(this.#clonePath, entry);
183
+ await rm(target, { recursive: true, force: true });
184
+ if (held.includes(entry)) await rename(path.join(holdRoot, entry), target);
185
+ }
186
+ }
187
+
188
+ async #isPushedCopy(entry: string) {
189
+ const subject = await this.#cloneGit(["log", "-1", "--format=%s", "--", entry]).catch(() => "");
190
+ return subject.trim().startsWith(SubspaceClone.syncSubjectPrefix);
191
+ }
192
+
193
+ static async #copyMissing(from: string, to: string) {
194
+ await mkdir(to, { recursive: true });
195
+ for (const entry of await readdir(from, { withFileTypes: true })) {
196
+ const source = path.join(from, entry.name);
197
+ const target = path.join(to, entry.name);
198
+ if (entry.isDirectory()) await SubspaceClone.#copyMissing(source, target);
199
+ else if (!(await FileSys.entryExists(target))) await rename(source, target);
200
+ }
201
+ }
202
+
203
+ // `git archive` emits only tracked files, so generated barrels, lib symlinks, env values and secrets never enter.
204
+ async #extract(paths: string[], excludes: string[] = []) {
205
+ // Relative, because GNU tar reads a drive-letter archive name (`C:\…`) as `host:path` on a remote host.
206
+ const archivePath = path.relative(this.#workspace.workspaceRoot, `${this.#clonePath}.tar`);
207
+ await this.#workspace.spawn("git", ["archive", "-o", archivePath, "HEAD", "--", ...paths]);
208
+ try {
209
+ await this.#workspace.spawn("tar", [
210
+ "-x",
211
+ "-f",
212
+ archivePath,
213
+ "-C",
214
+ this.#clonePath,
215
+ ...excludes.map((entry) => `--exclude=${entry}`),
216
+ ]);
217
+ } finally {
218
+ await rm(path.join(this.#workspace.workspaceRoot, archivePath), { force: true });
219
+ }
220
+ }
221
+
222
+ // An allowlist, not a filter, so no secret can reach a customer's clone; `AKAN_PUBLIC_*` ships in every bundle anyway.
223
+ #localEnv() {
224
+ const { serveDomain } = WorkspaceExecutor.getBaseDevEnv();
225
+ return {
226
+ AKAN_WORKSPACE_ID: "local",
227
+ AKAN_PUBLIC_REPO_NAME: this.#declaration.name,
228
+ AKAN_PUBLIC_SERVE_DOMAIN: serveDomain,
229
+ AKAN_PUBLIC_ENV: "local",
230
+ AKAN_PUBLIC_OPERATION_MODE: "local",
231
+ AKAN_PUBLIC_LOG_LEVEL: "warn",
232
+ };
233
+ }
234
+
235
+ // The CLI needs a `.env` to recognise a workspace root and none arrives with the slice. Excluded through
236
+ // `.git/info/exclude`, not the copied `.gitignore`, so it can never be committed to the customer's repo.
237
+ async #writeLocalEnv() {
238
+ const excludePath = path.join(this.#clonePath, ".git/info/exclude");
239
+ const exclude = (await FileSys.fileExists(excludePath)) ? await FileSys.readText(excludePath) : "";
240
+ if (!exclude.split("\n").includes("/.env")) {
241
+ await mkdir(path.dirname(excludePath), { recursive: true });
242
+ await FileSys.writeText(excludePath, exclude.trim() ? `${exclude.replace(/\n*$/, "\n")}/.env\n` : "/.env\n");
243
+ }
244
+ const envPath = path.join(this.#clonePath, ".env");
245
+ if (await FileSys.fileExists(envPath)) return;
246
+ const lines = Object.entries(this.#localEnv()).map(([key, value]) => `${key}=${value}`);
247
+ await FileSys.writeText(envPath, `${lines.join("\n")}\n`);
248
+ }
249
+
250
+ // The root manifest is the union of every app's dependencies, so it is rebuilt from this subspace's slices.
251
+ async #rewriteManifest(plans: SlicePlan[]) {
252
+ const manifestPath = path.join(this.#clonePath, "package.json");
253
+ if (!(await FileSys.fileExists(manifestPath))) return [];
254
+ const rootPackageJson = (await FileSys.readJson(manifestPath)) as PackageJson;
255
+ const { packageJson, pruned, warnings } = await SlicePlanner.pruneDependencies(
256
+ this.#workspace,
257
+ rootPackageJson,
258
+ plans.flatMap((plan) => plan.requiredDependencies),
259
+ );
260
+ for (const warning of warnings) this.#workspace.logger.warn(warning);
261
+ await FileSys.writeJson(manifestPath, {
262
+ ...packageJson,
263
+ name: this.#declaration.name,
264
+ description: `${this.#declaration.name} workspace`,
265
+ });
266
+ return pruned;
267
+ }
268
+
269
+ // `akan:secrets` names every workspace app, so it is filtered; `bun.lock` is un-ignored because a subspace commits it.
270
+ async #rewriteGitignore() {
271
+ const gitignorePath = path.join(this.#clonePath, ".gitignore");
272
+ if (!(await FileSys.fileExists(gitignorePath))) return;
273
+ const [begin = "", end = ""] = SubspaceClone.secretsMarkers;
274
+ // `git archive` checks text out as CRLF under Windows Git's default `core.autocrlf=true`.
275
+ const lines = (await FileSys.readText(gitignorePath)).split(/\r?\n/);
276
+ const beginIdx = lines.indexOf(begin);
277
+ const endIdx = lines.indexOf(end);
278
+ const filtered =
279
+ beginIdx >= 0 && endIdx > beginIdx
280
+ ? [
281
+ ...lines.slice(0, beginIdx + 1),
282
+ ...lines
283
+ .slice(beginIdx + 1, endIdx)
284
+ .filter((line) => this.#declaration.apps.some((app) => line.startsWith(`apps/${app}/`))),
285
+ ...lines.slice(endIdx),
286
+ ]
287
+ : lines;
288
+ await FileSys.writeText(gitignorePath, filtered.filter((line) => line.trim() !== "**/bun.lock").join("\n"));
289
+ }
290
+
291
+ /** The generated `## Workspace` block names every app and library in the workspace. */
292
+ async #rewriteWorkspaceSection(libs: string[]) {
293
+ const replacements = [
294
+ [/^- Repo: .*$/m, `- Repo: ${this.#declaration.name}`],
295
+ [/^- Apps: .*$/m, `- Apps: ${this.#declaration.apps.join(", ")}`],
296
+ [/^- Libraries: .*$/m, `- Libraries: ${libs.length ? libs.join(", ") : "(none)"}`],
297
+ ] as const;
298
+ for (const file of ["AGENTS.md", "CLAUDE.md"]) {
299
+ const filePath = path.join(this.#clonePath, file);
300
+ if (!(await FileSys.fileExists(filePath))) continue;
301
+ const content = await FileSys.readText(filePath);
302
+ const rewritten = replacements.reduce((text, [pattern, value]) => text.replace(pattern, value), content);
303
+ if (rewritten !== content) await FileSys.writeText(filePath, rewritten);
304
+ }
305
+ }
306
+
307
+ // Not through `LibSource`, which addresses a library by its position in the workspace. The hash skips `env/` and
308
+ // the stamp itself.
309
+ async #stampLib(lib: string, branch: string, sha: string) {
310
+ const manifestPath = path.join(this.#clonePath, "libs", lib, "package.json");
311
+ if (!(await FileSys.fileExists(manifestPath))) return;
312
+ const origin = `${this.#workspace.repoName}#${branch}`;
313
+ const [hash, previous] = await Promise.all([this.#hashLib(lib), this.#committedStamp(lib)]);
314
+ const manifest = (await FileSys.readJson(manifestPath)) as Record<string, unknown>;
315
+ const akan = (manifest[LibSource.manifestKey] ?? {}) as Record<string, unknown>;
316
+ //* The extraction overwrote the manifest, so the previous stamp comes from the clone's HEAD. It is kept whole while
317
+ //* the library's content is unchanged — matching the workspace sha too would rewrite every manifest, and push,
318
+ //* after any unrelated workspace commit.
319
+ const unchanged = previous?.origin === origin && previous.hash === hash;
320
+ const source = unchanged ? previous : { origin, sha, hash, syncedAt: new Date().toISOString() };
321
+ manifest[LibSource.manifestKey] = { ...akan, source };
322
+ await FileSys.writeJson(manifestPath, manifest);
323
+ }
324
+
325
+ async #committedStamp(lib: string) {
326
+ try {
327
+ const committed = await this.#clone.spawn("git", ["show", `HEAD:libs/${lib}/package.json`]);
328
+ const manifest = JSON.parse(committed) as Record<string, unknown>;
329
+ const akan = (manifest[LibSource.manifestKey] ?? {}) as Record<string, unknown>;
330
+ return akan.source as { origin: string; sha: string; hash: string; syncedAt: string } | undefined;
331
+ } catch {
332
+ //? First push: the library is not in the subspace's history yet.
333
+ return undefined;
334
+ }
335
+ }
336
+
337
+ async #hashLib(lib: string) {
338
+ const listed = await this.#clone.spawn("git", [
339
+ "ls-files",
340
+ "-z",
341
+ "--cached",
342
+ "--others",
343
+ "--exclude-standard",
344
+ "--",
345
+ `libs/${lib}`,
346
+ ]);
347
+ const candidates = listed
348
+ .split("\0")
349
+ .filter((file) => !!file && !SubspaceClone.isSubspaceOwned(file))
350
+ .sort();
351
+ //* The index still lists files the workspace deleted since the last push (only the push's `git add -A`
352
+ //* reconciles it), and opening one throws.
353
+ const present = await Promise.all(
354
+ candidates.map(async (file) => await FileSys.entryExists(path.join(this.#clonePath, file))),
355
+ );
356
+ const files = candidates.filter((_, index) => present[index]);
357
+ return await LibSource.hashFiles(files, `libs/${lib}/package.json`, (file) =>
358
+ FileSys.readText(path.join(this.#clonePath, file)),
359
+ );
360
+ }
361
+ }
package/subspaceConfig.ts CHANGED
@@ -5,7 +5,6 @@ export type { SubspaceConfigInput, SubspaceDeclaration };
5
5
 
6
6
  // No branch field: the workspace's current branch is used, so every subspace on it holds the same akanjs and libs.
7
7
  export class SubspaceConfig {
8
- /** Where the declaration lives, as error messages name it. */
9
8
  static readonly source = "subspace in the workspace root akan.config.ts";
10
9
  static readonly defaultPushableBranches = ["main", "develop", "debug"];
11
10
 
@@ -69,7 +68,7 @@ export class SubspaceConfig {
69
68
  assertPushable(branch: string) {
70
69
  if (this.pushableBranches.includes(branch)) return;
71
70
  throw new Error(
72
- `Branch "${branch}" is not pushable. ${SubspaceConfig.source} allows: ${this.pushableBranches.join(", ")}`,
71
+ `Branch "${branch}" is not pushable. \`subspace.pushableBranches\` in the workspace root akan.config.ts allows: ${this.pushableBranches.join(", ")}`,
73
72
  );
74
73
  }
75
74
  }
@@ -0,0 +1,162 @@
1
+ import {
2
+ Subspace,
3
+ type SubspaceDiffResult,
4
+ type SubspaceEnvUploadResult,
5
+ type SubspacePlan,
6
+ type SubspacePullResult,
7
+ type SubspacePushResult,
8
+ type SubspaceStatus,
9
+ } from "./subspace";
10
+
11
+ export class SubspaceReport {
12
+ static statuses(statuses: SubspaceStatus[]) {
13
+ const sections = [
14
+ "Akan Subspace Status",
15
+ `branch: ${statuses[0]?.branch ?? "(none)"}`,
16
+ "",
17
+ ...statuses.flatMap((status) => {
18
+ if (!status.hasBranch) return [` ${status.name}: no such branch in the subspace — push is refused`];
19
+ const behind = status.behindPaths.length ? `${status.behindPaths.length} file(s) behind` : "up to date";
20
+ const incoming = status.anchor.incoming.length
21
+ ? `${status.anchor.incoming.length} customer commit(s) to pull`
22
+ : "no customer commits";
23
+ const drift = status.driftedLibs.length ? ` DRIFTED LIBS: ${status.driftedLibs.join(", ")}` : null;
24
+ return [
25
+ ` ${status.name}: ${behind}, ${incoming}`,
26
+ ...status.anchor.incoming.map((commit) => ` ${commit.sha} ${commit.author} ${commit.subject}`),
27
+ ...(drift ? [drift] : []),
28
+ ];
29
+ }),
30
+ ];
31
+ return sections.join("\n");
32
+ }
33
+
34
+ static pushResults(results: SubspacePushResult[]) {
35
+ const sections = [
36
+ "Akan Subspace Push",
37
+ "",
38
+ ...results.flatMap((result) => {
39
+ const detail =
40
+ result.outcome === "pushed" ? `${result.commit} (${result.changedFiles} files)` : (result.reason ?? "");
41
+ const [first = "", ...rest] = detail.split("\n");
42
+ const pruned = result.prunedDependencies?.length
43
+ ? [` ${result.prunedDependencies.length} unused root dependenc(ies) left out of package.json`]
44
+ : [];
45
+ return [
46
+ ` ${result.outcome.padEnd(8)} ${result.name} ${first}`,
47
+ ...rest.map((line) => ` ${line}`),
48
+ ...pruned,
49
+ ];
50
+ }),
51
+ ];
52
+ return sections.join("\n");
53
+ }
54
+
55
+ static pullResult(result: SubspacePullResult) {
56
+ const sections = [
57
+ `Akan Subspace Pull — ${result.name}`,
58
+ "",
59
+ `Customer commits since the last push (${result.incoming.length}):`,
60
+ "",
61
+ ...(result.incoming.length
62
+ ? result.incoming.map((commit) => ` ${commit.sha} ${commit.author} ${commit.subject}`)
63
+ : [" (none — nothing to pull)"]),
64
+ "",
65
+ `Applied to the working tree, uncommitted (${result.applied.length}):`,
66
+ "",
67
+ ...(result.applied.length ? result.applied.map((file) => ` ${file}`) : [" (none)"]),
68
+ ...(result.libPatch
69
+ ? [
70
+ "",
71
+ `Library edits NOT applied (${result.libPatch.files.length}) — review before adopting:`,
72
+ "",
73
+ ...result.libPatch.files.map((file) => ` ${file}`),
74
+ "",
75
+ ` patch: ${result.libPatch.path}`,
76
+ " adopt with: akan subspace pull <name> --adopt-libs",
77
+ ]
78
+ : []),
79
+ ...(result.ignored.length
80
+ ? ["", `Ignored (workspace-owned or subspace-owned): ${result.ignored.length} file(s)`]
81
+ : []),
82
+ ];
83
+ return sections.join("\n");
84
+ }
85
+
86
+ static envUpload(result: SubspaceEnvUploadResult) {
87
+ const sections = [
88
+ `Akan Subspace Env Upload — ${result.name}`,
89
+ `cloud workspace: ${result.workspaceId} · host: ${result.host}`,
90
+ "",
91
+ ...(result.outcome === "cancelled"
92
+ ? [" cancelled — the subspace's env archive is untouched."]
93
+ : [
94
+ `Uploaded (${result.files.length}), replacing that workspace's whole archive:`,
95
+ "",
96
+ ...result.files.map((file) => ` ${file}`),
97
+ "",
98
+ ` apps: ${result.apps.join(", ")}`,
99
+ ` libs: ${result.libs.join(", ") || "(none)"}`,
100
+ ]),
101
+ ];
102
+ return sections.join("\n");
103
+ }
104
+
105
+ static diff(result: SubspaceDiffResult) {
106
+ const total = result.app.files.length + result.libs.files.length;
107
+ const sections = [
108
+ `Akan Subspace Diff — ${result.name} (${result.branch})`,
109
+ "what `akan subspace push` would change in the subspace",
110
+ "",
111
+ ...(total ? [] : [" subspace is identical to the workspace for this slice."]),
112
+ ...(result.libs.files.length
113
+ ? [
114
+ `LIBRARY changes (${result.libs.files.length}) — the subspace edited shared code:`,
115
+ "",
116
+ ...result.libs.files.map((file) => ` ${file}`),
117
+ "",
118
+ result.libs.patch,
119
+ ]
120
+ : []),
121
+ ...(result.app.files.length
122
+ ? [
123
+ `App changes (${result.app.files.length}):`,
124
+ "",
125
+ ...result.app.files.map((file) => ` ${file}`),
126
+ "",
127
+ result.app.patch,
128
+ ]
129
+ : []),
130
+ ];
131
+ return sections.join("\n");
132
+ }
133
+
134
+ static plan(plan: SubspacePlan) {
135
+ const list = (entries: string[]) => (entries.length ? entries.join(", ") : "(none)");
136
+ const blockers = Subspace.planBlockers(plan);
137
+ const sections = [
138
+ `Akan Subspace Plan — ${plan.name} (${plan.branch})`,
139
+ `repo: ${plan.repo}`,
140
+ "what `akan subspace push` would ship, worked out from this checkout alone",
141
+ "",
142
+ `apps: ${list(plan.apps)}`,
143
+ `libs: ${plan.libs.length ? "" : "(none)"}`.trimEnd(),
144
+ ...plan.libs.map((lib) => ` - ${lib.name} (from ${lib.apps.join(", ")})`),
145
+ `files under apps/ and libs/: ${plan.files}`,
146
+ "",
147
+ `root entries pushed: ${list(plan.rootEntries)}`,
148
+ `root entries excluded: ${list(plan.excludedRootEntries)}`,
149
+ `root entries the subspace keeps its own: ${list(plan.subspaceOwnedRootEntries)}`,
150
+ "",
151
+ `Root dependencies left out of package.json (${plan.prunedDependencies.length}):`,
152
+ ...(plan.prunedDependencies.length ? plan.prunedDependencies.map((dep) => ` - ${dep}`) : [" (none)"]),
153
+ "",
154
+ blockers.length ? "A push would be refused:" : "Nothing here would make a push refuse.",
155
+ ...blockers.map((reason) => ` - ${reason}`),
156
+ ...(plan.warnings.length
157
+ ? ["", `Warnings (${plan.warnings.length}):`, ...plan.warnings.map((w) => ` - ${w}`)]
158
+ : []),
159
+ ];
160
+ return sections.join("\n");
161
+ }
162
+ }
@@ -178,6 +178,82 @@ describe("createServerEnvPlugin", () => {
178
178
  expect(refused.logs.map((log) => log.message).join("\n")).toContain("mailer.pass");
179
179
  });
180
180
 
181
+ test("refuses a profile, the app's or a lib's, that imports the file it is bundled in place of", async () => {
182
+ const root = await makeTempRoot();
183
+ const { envDir, entry } = await writeApp(root);
184
+ const profileFile = path.join(envDir, "env.server.main.edge.ts");
185
+ await write(
186
+ profileFile,
187
+ 'import { env as main } from "./env.server.main";\nexport const env = { hostname: main.secret };\n',
188
+ );
189
+ const plugin = () => createServerEnvPlugin({ envDir, environment: "main", environments, profile: "edge" });
190
+ const refused = await Bun.build({ entrypoints: [entry], target: "bun", plugins: [plugin()], throw: false });
191
+ expect(refused.success).toBe(false);
192
+ expect(refused.logs.map((log) => log.message).join("\n")).toContain(
193
+ 'env.server.main.edge.ts imports ./env.server.main, the file a "edge" build bundles it in place of, so it would import itself.',
194
+ );
195
+
196
+ await write(profileFile, 'export const env = { origin: "edge" };\n');
197
+ const libEnv = path.join(root, "libs/util/env/env.server.testing.ts");
198
+ await write(libEnv, 'export const env = { hostname: "lib-host" };\n');
199
+ await write(
200
+ path.join(root, "libs/util/env/env.server.testing.edge.ts"),
201
+ 'import { env as base } from "./env.server.testing";\nexport const env = { hostname: base.hostname };\n',
202
+ );
203
+ await write(entry, `import { env } from ${JSON.stringify(libEnv)};\nconsole.info(JSON.stringify(env));\n`);
204
+ const lib = await Bun.build({ entrypoints: [entry], target: "bun", plugins: [plugin()], throw: false });
205
+ expect(lib.success).toBe(false);
206
+ expect(lib.logs.map((log) => log.message).join("\n")).toContain(
207
+ "env.server.testing.edge.ts imports ./env.server.testing, the file",
208
+ );
209
+ });
210
+
211
+ test("refuses to carry as JSON a lib's env defaults that JSON would change, naming them and the file to give", async () => {
212
+ const root = await makeTempRoot();
213
+ const libEnv = path.join(root, "libs/util/env/env.server.testing.ts");
214
+ await write(
215
+ libEnv,
216
+ 'export const env = { hostname: "lib-host", startedAt: new Date(0), hosts: new Set(["a"]), onCleanup: async () => undefined, nested: { at: new Map() } };\n',
217
+ );
218
+ const { envDir, entry } = await writeApp(root);
219
+ await write(path.join(envDir, "env.server.main.edge.ts"), "export const env = {};\n");
220
+ await write(entry, `import { env } from ${JSON.stringify(libEnv)};\nconsole.info(JSON.stringify(env));\n`);
221
+ const result = await Bun.build({
222
+ entrypoints: [entry],
223
+ target: "bun",
224
+ plugins: [createServerEnvPlugin({ envDir, environment: "main", environments, profile: "edge" })],
225
+ throw: false,
226
+ });
227
+ expect(result.success).toBe(false);
228
+ const message = result.logs.map((log) => log.message).join("\n");
229
+ expect(message).toContain(
230
+ `${libEnv} holds startedAt, hosts, onCleanup, nested.at, which a "edge" build cannot carry`,
231
+ );
232
+ expect(message).toContain("Give the lib env.server.testing.edge.ts.");
233
+ });
234
+
235
+ test("a lib env that cannot be evaluated stops a profile build, which prints no line of it", async () => {
236
+ const root = await makeTempRoot();
237
+ const libEnv = path.join(root, "libs/util/env/env.server.testing.ts");
238
+ await write(
239
+ libEnv,
240
+ 'export const env = {\n security: { jwtSecret: "lib-jwt-dummy-0123" },\n x: undefinedVar.y,\n};\n',
241
+ );
242
+ const { envDir, entry } = await writeApp(root);
243
+ await write(path.join(envDir, "env.server.main.edge.ts"), "export const env = {};\n");
244
+ await write(entry, `import { env } from ${JSON.stringify(libEnv)};\nconsole.info(JSON.stringify(env));\n`);
245
+ const result = await Bun.build({
246
+ entrypoints: [entry],
247
+ target: "bun",
248
+ plugins: [createServerEnvPlugin({ envDir, environment: "main", environments, profile: "edge" })],
249
+ throw: false,
250
+ });
251
+ expect(result.success).toBe(false);
252
+ const printed = result.logs.map((log) => `${log.message}\n${log.position?.lineText ?? ""}`).join("\n");
253
+ expect(printed).toContain("ReferenceError: undefinedVar is not defined");
254
+ expect(printed).not.toContain("lib-jwt-dummy-0123");
255
+ });
256
+
181
257
  test("leaves env files outside the app's env directory alone", async () => {
182
258
  const root = await makeTempRoot();
183
259
  const other = path.join(root, "libs/shared/env/env.server.testing.ts");