@akanjs/devkit 3.0.0-alpha.9 → 3.0.0-alpha.90

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 (183) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/DEV_RUNTIME_KNOBS.md +27 -0
  3. package/README.ko.md +1 -1
  4. package/README.md +1 -1
  5. package/agentsIndex.test.ts +10 -0
  6. package/agentsIndex.ts +21 -1
  7. package/aiEditor.ts +1 -1
  8. package/akanApp/BackendImportGraph.test.ts +120 -0
  9. package/akanApp/BackendImportGraph.ts +167 -0
  10. package/akanApp/akanApp.host.test.ts +16 -678
  11. package/akanApp/akanApp.host.ts +113 -578
  12. package/akanApp/devHostPolicy.test.ts +542 -0
  13. package/akanApp/devHostPolicy.ts +442 -0
  14. package/akanApp/index.ts +2 -0
  15. package/akanConfig/akanConfig.test.ts +182 -14
  16. package/akanConfig/akanConfig.ts +143 -47
  17. package/akanConfig/types.ts +9 -0
  18. package/akanContext.ts +30 -14
  19. package/akanMcpContract.ts +25 -4
  20. package/appSelectionMemory.ts +28 -0
  21. package/applicationBuildRunner.test.ts +1 -1
  22. package/applicationBuildRunner.ts +45 -21
  23. package/artifact/implicitRootLayout.test.ts +67 -0
  24. package/artifact/implicitRootLayout.ts +27 -7
  25. package/artifact/routeSeedIndex.test.ts +1 -0
  26. package/biome.base.json +86 -47
  27. package/biomeBase.ts +6 -5
  28. package/capacitorApp.ts +10 -0
  29. package/cloud/globalConfig.ts +9 -2
  30. package/codegenLock.test.ts +137 -0
  31. package/codegenLock.ts +136 -0
  32. package/commandDecorators/argMeta.ts +9 -1
  33. package/commandDecorators/command.ts +94 -7
  34. package/commandDecorators/commandBuilder.ts +20 -5
  35. package/commandDecorators/commandDecorators.test.ts +115 -3
  36. package/commandDecorators/helpFormatter.ts +4 -0
  37. package/dependencyScanner.test.ts +99 -0
  38. package/dependencyScanner.ts +27 -24
  39. package/devkitUtils.test.ts +0 -104
  40. package/executors.test.ts +129 -4
  41. package/executors.ts +105 -24
  42. package/fileEditor.ts +19 -19
  43. package/fileSys.ts +19 -1
  44. package/formSetterScanner.test.ts +80 -0
  45. package/formSetterScanner.ts +92 -0
  46. package/frontendBuild/autoImportSync.test.ts +58 -0
  47. package/frontendBuild/autoImportSync.ts +7 -2
  48. package/frontendBuild/buildRouteClient.test.ts +47 -24
  49. package/frontendBuild/clientBuildTypes.ts +4 -0
  50. package/frontendBuild/clientEntriesBundler.ts +4 -1
  51. package/frontendBuild/clientEntryDiscovery.ts +2 -2
  52. package/frontendBuild/csrArtifactBuilder.ts +116 -84
  53. package/frontendBuild/cssCompiler.ts +123 -12
  54. package/frontendBuild/cssImportResolver.ts +8 -7
  55. package/frontendBuild/devGeneratedIndexSync.ts +3 -2
  56. package/frontendBuild/fontOptimizer.ts +37 -18
  57. package/frontendBuild/fontPruner.test.ts +220 -0
  58. package/frontendBuild/fontPruner.ts +206 -0
  59. package/frontendBuild/frontendBuild.test.ts +178 -10
  60. package/frontendBuild/hmrWatcher.ts +1 -1
  61. package/frontendBuild/index.ts +1 -1
  62. package/frontendBuild/pagesBundleBuilder.ts +3 -3
  63. package/frontendBuild/pagesEntrySourceGenerator.ts +11 -88
  64. package/frontendBuild/routeClientBuilder.ts +12 -5
  65. package/frontendBuild/sourceMtimeIndex.ts +1 -1
  66. package/frontendBuild/ssrBaseArtifactBuilder.ts +21 -4
  67. package/frontendBuild/styleContract.ts +14 -20
  68. package/frontendBuild/themeValidator.ts +22 -17
  69. package/frontendBuild/vendorSpecifiers.ts +1 -0
  70. package/frontendBuild/watchRootResolver.test.ts +67 -0
  71. package/frontendBuild/watchRootResolver.ts +13 -1
  72. package/incrementalBuilder/devWatchBatch.test.ts +18 -20
  73. package/incrementalBuilder/devWatchBatch.ts +6 -2
  74. package/incrementalBuilder/incrementalBuilder.host.ts +51 -7
  75. package/incrementalBuilder/incrementalBuilder.proc.ts +17 -6
  76. package/index.ts +0 -7
  77. package/integration/devStabilityHarness.ts +2 -10
  78. package/libSource.test.ts +112 -0
  79. package/libSource.ts +126 -0
  80. package/lint/__fixtures__/README.md +40 -0
  81. package/lint/__fixtures__/no-arbitrary-color/bad.tsx +3 -0
  82. package/lint/__fixtures__/no-arbitrary-color/good.tsx +4 -0
  83. package/lint/__fixtures__/no-async-component-in-ui/bad.tsx +4 -0
  84. package/lint/__fixtures__/no-async-component-in-ui/good.tsx +4 -0
  85. package/lint/__fixtures__/no-bang-comment-in-client/bad.tsx +4 -0
  86. package/lint/__fixtures__/no-bang-comment-in-client/fixture.json +1 -0
  87. package/lint/__fixtures__/no-bang-comment-in-client/good.tsx +3 -0
  88. package/lint/__fixtures__/no-daisyui-legacy-class/bad.tsx +7 -0
  89. package/lint/__fixtures__/no-daisyui-legacy-class/good.tsx +5 -0
  90. package/lint/__fixtures__/no-deep-internal-import/bad.tsx +3 -0
  91. package/lint/__fixtures__/no-deep-internal-import/fixture.json +1 -0
  92. package/lint/__fixtures__/no-deep-internal-import/good.tsx +3 -0
  93. package/lint/__fixtures__/no-deprecated-log-level/bad.tsx +4 -0
  94. package/lint/__fixtures__/no-deprecated-log-level/good.tsx +4 -0
  95. package/lint/__fixtures__/no-import-client-functions/bad.tsx +2 -0
  96. package/lint/__fixtures__/no-import-client-functions/good.tsx +4 -0
  97. package/lint/__fixtures__/no-import-client-in-server/bad.tsx +6 -0
  98. package/lint/__fixtures__/no-import-client-in-server/good.tsx +5 -0
  99. package/lint/__fixtures__/no-import-external-library/bad.tsx +4 -0
  100. package/lint/__fixtures__/no-import-external-library/good.tsx +5 -0
  101. package/lint/__fixtures__/no-import-server-in-client/bad.tsx +6 -0
  102. package/lint/__fixtures__/no-import-server-in-client/good.tsx +4 -0
  103. package/lint/__fixtures__/no-init-fetch-in-client/store-file/bad.ts +1 -0
  104. package/lint/__fixtures__/no-init-fetch-in-client/store-file/fixture.json +1 -0
  105. package/lint/__fixtures__/no-init-fetch-in-client/store-file/good.ts +1 -0
  106. package/lint/__fixtures__/no-init-fetch-in-client/use-client/bad.tsx +4 -0
  107. package/lint/__fixtures__/no-init-fetch-in-client/use-client/good.tsx +5 -0
  108. package/lint/__fixtures__/no-inline-color/bad.tsx +3 -0
  109. package/lint/__fixtures__/no-inline-color/good.tsx +3 -0
  110. package/lint/__fixtures__/no-interpolated-arbitrary-class/bad.tsx +3 -0
  111. package/lint/__fixtures__/no-interpolated-arbitrary-class/good.tsx +3 -0
  112. package/lint/__fixtures__/no-js-private-class-method/bad.tsx +3 -0
  113. package/lint/__fixtures__/no-js-private-class-method/good.tsx +3 -0
  114. package/lint/__fixtures__/no-model-type-in-util-zone/bad.tsx +3 -0
  115. package/lint/__fixtures__/no-model-type-in-util-zone/good.tsx +6 -0
  116. package/lint/__fixtures__/no-raw-palette-class/bad.tsx +6 -0
  117. package/lint/__fixtures__/no-raw-palette-class/good.tsx +8 -0
  118. package/lint/__fixtures__/no-redeclare-predefined-endpoint/bad.ts +10 -0
  119. package/lint/__fixtures__/no-redeclare-predefined-endpoint/fixture.json +1 -0
  120. package/lint/__fixtures__/no-redeclare-predefined-endpoint/good.ts +6 -0
  121. package/lint/__fixtures__/no-return-in-store-action/bad.tsx +2 -0
  122. package/lint/__fixtures__/no-return-in-store-action/good.tsx +6 -0
  123. package/lint/__fixtures__/no-throw-raw-error/bad.tsx +4 -0
  124. package/lint/__fixtures__/no-throw-raw-error/good.tsx +4 -0
  125. package/lint/__fixtures__/no-unpublished-form-setter/bad.tsx +3 -0
  126. package/lint/__fixtures__/no-unpublished-form-setter/good.tsx +5 -0
  127. package/lint/__fixtures__/no-use-client-in-server/bad.tsx +3 -0
  128. package/lint/__fixtures__/no-use-client-in-server/good.tsx +2 -0
  129. package/lint/__fixtures__/non-scalar-props-restricted/bad.tsx +5 -0
  130. package/lint/__fixtures__/non-scalar-props-restricted/good.tsx +4 -0
  131. package/lint/gritRules.test.ts +178 -0
  132. package/lint/no-arbitrary-color.grit +1 -1
  133. package/lint/no-async-component-in-ui.grit +35 -0
  134. package/lint/no-bang-comment-in-client.grit +23 -10
  135. package/lint/no-daisyui-legacy-class.grit +26 -9
  136. package/lint/no-deprecated-log-level.grit +17 -0
  137. package/lint/no-init-fetch-in-client.grit +47 -0
  138. package/lint/no-inline-color.grit +10 -8
  139. package/lint/no-interpolated-arbitrary-class.grit +3 -3
  140. package/lint/no-model-type-in-util-zone.grit +58 -0
  141. package/lint/no-raw-palette-class.grit +3 -3
  142. package/lint/no-unpublished-form-setter.grit +41 -0
  143. package/lint/non-scalar-props-restricted.grit +16 -7
  144. package/linter.test.ts +80 -0
  145. package/linter.ts +99 -25
  146. package/package.json +5 -8
  147. package/prompter.ts +9 -4
  148. package/qualityScanner.test.ts +116 -0
  149. package/qualityScanner.ts +89 -21
  150. package/recipeScanner.ts +4 -1
  151. package/scanInfo.ts +32 -26
  152. package/semver.test.ts +26 -0
  153. package/semver.ts +31 -0
  154. package/slicePlanner.test.ts +182 -0
  155. package/slicePlanner.ts +235 -0
  156. package/ssrScanner.test.ts +301 -0
  157. package/subspace.test.ts +418 -0
  158. package/subspace.ts +723 -0
  159. package/subspaceConfig.ts +76 -0
  160. package/transforms/asyncDefaultExportDetector.ts +103 -0
  161. package/transforms/barrelImportsPlugin.ts +1 -1
  162. package/transforms/externalizeFrameworkPlugin.ts +0 -1
  163. package/transforms/transforms.test.ts +5 -5
  164. package/transforms/tsconfigPackageResolver.test.ts +230 -0
  165. package/tsconfig.json +6 -2
  166. package/typeChecker.ts +1 -1
  167. package/types.ts +1 -0
  168. package/uploadRelease.ts +2 -2
  169. package/workflow/executor.test.ts +146 -0
  170. package/workspaceLayout.test.ts +56 -4
  171. package/workspaceLayout.ts +48 -2
  172. package/builder.ts +0 -164
  173. package/extractDeps.ts +0 -86
  174. package/frontendBuild/styleGuard.test.ts +0 -165
  175. package/frontendBuild/styleGuard.ts +0 -322
  176. package/getCredentials.ts +0 -19
  177. package/getModelFileData.ts +0 -62
  178. package/src/capacitorApp.ts +0 -282
  179. package/streamAi.ts +0 -45
  180. package/ui/MultiScrollList.tsx +0 -242
  181. package/ui/ScrollList.tsx +0 -107
  182. package/ui/index.ts +0 -2
  183. package/useStdoutDimensions.ts +0 -20
package/subspace.ts ADDED
@@ -0,0 +1,723 @@
1
+ import { mkdir, readdir, rename, rm } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { AppExecutor, Executor, WorkspaceExecutor } from "./executors";
4
+ import { FileSys } from "./fileSys";
5
+ import { LibSource } from "./libSource";
6
+ import { type SlicePlan, SlicePlanner } from "./slicePlanner";
7
+ import type { SubspaceConfig, SubspaceDeclaration } from "./subspaceConfig";
8
+ import type { PackageJson } from "./types";
9
+
10
+ export interface SubspaceIncomingCommit {
11
+ sha: string;
12
+ author: string;
13
+ subject: string;
14
+ }
15
+
16
+ export interface SubspaceAnchor {
17
+ /** Subspace commit that last carried a push, located by the one file only a push writes. */
18
+ commit: string | null;
19
+ /** Commits the subspace gained since then — customer work that has never been in the workspace. */
20
+ incoming: SubspaceIncomingCommit[];
21
+ }
22
+
23
+ export interface SubspaceStatus {
24
+ name: string;
25
+ branch: string;
26
+ /** False when the subspace has no such branch: a push refuses it rather than creating it. */
27
+ hasBranch: boolean;
28
+ anchor: SubspaceAnchor;
29
+ /** Slice paths where workspace and subspace disagree — what a push would change. */
30
+ behindPaths: string[];
31
+ /** Libraries the subspace edited on its own: the drift this mechanism exists to stop. */
32
+ driftedLibs: string[];
33
+ }
34
+
35
+ export interface SubspaceDiffSection {
36
+ files: string[];
37
+ patch: string;
38
+ }
39
+
40
+ export interface SubspaceDiffResult {
41
+ name: string;
42
+ branch: string;
43
+ /** What `akan subspace push` would change in the subspace, split so library changes are impossible to miss. */
44
+ app: SubspaceDiffSection;
45
+ libs: SubspaceDiffSection;
46
+ }
47
+
48
+ export interface SubspacePushResult {
49
+ name: string;
50
+ outcome: "pushed" | "skipped" | "refused";
51
+ reason?: string;
52
+ commit?: string;
53
+ changedFiles: number;
54
+ /** Workspace-root dependencies left out of this subspace's manifest, because its apps do not use them. */
55
+ prunedDependencies?: string[];
56
+ }
57
+
58
+ export interface SubspacePullResult {
59
+ name: string;
60
+ applied: string[];
61
+ /** Library hunks, left on disk as a patch instead of applied unless `adoptLibs` was passed. */
62
+ libPatch: { path: string; files: string[] } | null;
63
+ ignored: string[];
64
+ incoming: SubspaceIncomingCommit[];
65
+ }
66
+
67
+ /**
68
+ * One customer repo, mirrored from this workspace.
69
+ *
70
+ * Push is a squashed snapshot of the workspace's own tracked files, so a subspace's history never
71
+ * carries the workspace's — which is also what keeps one customer's commit messages out of another's
72
+ * repo. Pull is the reverse and rare: it compares the subspace against the last push it received rather
73
+ * than against the workspace, so the diff is exactly the customer's own work however far the workspace
74
+ * has moved on.
75
+ */
76
+ export class Subspace {
77
+ static readonly anchorFile = "akan.subspace.json";
78
+ /** Never reaches a subspace: it names every other customer's repo. */
79
+ static readonly workspaceOnlyEntries = ["akan.subspace.ts"];
80
+ /** Workspace members, which are replaced wholesale rather than overlaid. */
81
+ static readonly memberDirs = ["apps", "libs"];
82
+ /** Subspace-owned in both directions: env values belong to the repo that deploys, not to the workspace. */
83
+ static readonly subspaceOwnedDirs = ["env"];
84
+ static readonly secretsMarkers = ["# akan:secrets (managed by akan.config.ts — do not edit)", "# akan:secrets:end"];
85
+ static readonly holdDir = ".akan-subspace-hold";
86
+
87
+ #workspace: WorkspaceExecutor;
88
+ #config: SubspaceConfig;
89
+ #declaration: SubspaceDeclaration;
90
+
91
+ constructor(workspace: WorkspaceExecutor, config: SubspaceConfig, declaration: SubspaceDeclaration) {
92
+ this.#workspace = workspace;
93
+ this.#config = config;
94
+ this.#declaration = declaration;
95
+ }
96
+
97
+ get name() {
98
+ return this.#declaration.name;
99
+ }
100
+ get apps() {
101
+ return this.#declaration.apps;
102
+ }
103
+ get #remote() {
104
+ return `subspace-${this.#declaration.name}`;
105
+ }
106
+ get #clonePath() {
107
+ return path.join(this.#workspace.workspaceRoot, ".akan/subspace", this.#declaration.name);
108
+ }
109
+
110
+ async #git(args: string[]) {
111
+ return await this.#workspace.spawn("git", args);
112
+ }
113
+
114
+ async branch() {
115
+ return (await this.#git(["rev-parse", "--abbrev-ref", "HEAD"])).trim();
116
+ }
117
+
118
+ async headSha() {
119
+ return (await this.#git(["rev-parse", "--short", "HEAD"])).trim();
120
+ }
121
+
122
+ /** Adds the subspace as a remote if absent, then fetches the branch into this workspace's object store. */
123
+ async fetch(branch: string) {
124
+ const remotes = (await this.#git(["remote"])).split("\n").map((line) => line.trim());
125
+ if (remotes.includes(this.#remote)) await this.#git(["remote", "set-url", this.#remote, this.#declaration.repo]);
126
+ else await this.#git(["remote", "add", this.#remote, this.#declaration.repo]);
127
+ try {
128
+ await this.#git(["fetch", "--quiet", this.#remote, branch]);
129
+ return true;
130
+ } catch {
131
+ //? A subspace that has never had this branch is not an error here — `push` refuses it by name.
132
+ return false;
133
+ }
134
+ }
135
+
136
+ /**
137
+ * The last push, located by the one file only a push writes. A tag or a recorded sha would have to be
138
+ * kept in step by hand; this file is already in the subspace's history and cannot drift out of it.
139
+ */
140
+ async anchor(branch: string): Promise<SubspaceAnchor> {
141
+ const ref = `${this.#remote}/${branch}`;
142
+ const commit = (await this.#git(["log", "-1", "--format=%H", ref, "--", Subspace.anchorFile])).trim();
143
+ if (!commit) return { commit: null, incoming: [] };
144
+ const log = await this.#git(["log", "--format=%H%x09%an%x09%s", `${commit}..${ref}`]);
145
+ const incoming = log
146
+ .split("\n")
147
+ .filter((line) => !!line.trim())
148
+ .map((line) => {
149
+ const [sha = "", author = "", ...subject] = line.split("\t");
150
+ return { sha: sha.slice(0, 12), author, subject: subject.join("\t") };
151
+ });
152
+ return { commit, incoming };
153
+ }
154
+
155
+ /** App and lib directories this subspace carries. Libraries come from each app's closure, never declared. */
156
+ async slice() {
157
+ const plans = await Promise.all(
158
+ this.#declaration.apps.map(
159
+ async (appName) => await new SlicePlanner(AppExecutor.from(this.#workspace, appName)).plan(),
160
+ ),
161
+ );
162
+ const libs = [...new Set(plans.flatMap((plan) => plan.libs))].sort();
163
+ const paths = [...this.#declaration.apps.map((app) => `apps/${app}`), ...libs.map((lib) => `libs/${lib}`)];
164
+ return { plans, libs, paths };
165
+ }
166
+
167
+ #isSubspaceOwned(file: string) {
168
+ const segments = file.split("/");
169
+ if (!Subspace.memberDirs.includes(segments[0] ?? "")) return false;
170
+ return Subspace.subspaceOwnedDirs.includes(segments[2] ?? "");
171
+ }
172
+
173
+ /**
174
+ * A library manifest always differs: the subspace's copy carries the `akan.source` stamp a push writes and
175
+ * the workspace's does not. Comparing it with that key removed is what keeps `status` and `diff` from
176
+ * reporting every library as changed forever.
177
+ */
178
+ #isStamped(file: string) {
179
+ return /^libs\/[^/]+\/package\.json$/.test(file);
180
+ }
181
+
182
+ async #differsBeyondStamp(ref: string, file: string) {
183
+ const [theirs, ours] = await Promise.all([
184
+ this.#git(["show", `${ref}:${file}`]).catch(() => ""),
185
+ this.#git(["show", `HEAD:${file}`]).catch(() => ""),
186
+ ]);
187
+ if (!theirs || !ours) return true;
188
+ const normalize = (content: string) => {
189
+ try {
190
+ const manifest = JSON.parse(content) as Record<string, unknown>;
191
+ delete manifest[LibSource.manifestKey];
192
+ return JSON.stringify(manifest);
193
+ } catch {
194
+ return content;
195
+ }
196
+ };
197
+ return normalize(theirs) !== normalize(ours);
198
+ }
199
+
200
+ async #meaningfulFiles(ref: string, files: string[]) {
201
+ const kept = await Promise.all(
202
+ files.map(async (file) => (this.#isStamped(file) ? await this.#differsBeyondStamp(ref, file) : true)),
203
+ );
204
+ return files.filter((_, index) => kept[index]);
205
+ }
206
+
207
+ async status(branch: string): Promise<SubspaceStatus> {
208
+ const hasBranch = await this.fetch(branch);
209
+ const [{ paths, libs }, anchor] = await Promise.all([
210
+ this.slice(),
211
+ hasBranch ? this.anchor(branch) : Promise.resolve<SubspaceAnchor>({ commit: null, incoming: [] }),
212
+ ]);
213
+ if (!hasBranch) return { name: this.name, branch, hasBranch, anchor, behindPaths: [], driftedLibs: [] };
214
+ const ref = `${this.#remote}/${branch}`;
215
+ const diff = await this.#git(["diff", "--name-only", "HEAD", ref, "--", ...paths]);
216
+ const behindPaths = await this.#meaningfulFiles(
217
+ ref,
218
+ diff
219
+ .split("\n")
220
+ .filter((file) => !!file.trim())
221
+ .filter((file) => !this.#isSubspaceOwned(file)),
222
+ );
223
+ const driftedLibs = libs.filter((lib) => behindPaths.some((file) => file.startsWith(`libs/${lib}/`)));
224
+ return { name: this.name, branch, hasBranch, anchor, behindPaths, driftedLibs };
225
+ }
226
+
227
+ async #ensureClone(branch: string) {
228
+ if (!(await FileSys.dirExists(path.join(this.#clonePath, ".git")))) {
229
+ await rm(this.#clonePath, { recursive: true, force: true });
230
+ await mkdir(path.dirname(this.#clonePath), { recursive: true });
231
+ await this.#workspace.spawn("git", ["clone", "--quiet", this.#declaration.repo, this.#clonePath]);
232
+ }
233
+ const clone = new Executor(`subspace-${this.name}`, this.#clonePath);
234
+ await clone.spawn("git", ["fetch", "--quiet", "origin", branch]);
235
+ await clone.spawn("git", ["checkout", "--quiet", "-B", branch, `origin/${branch}`]);
236
+ await clone.spawn("git", ["clean", "-qfd"]);
237
+ return clone;
238
+ }
239
+
240
+ /** Moves the subspace's env trees aside, so replacing the members cannot take them with it. */
241
+ async #holdSubspaceOwned() {
242
+ const held: { from: string; to: string }[] = [];
243
+ const holdRoot = path.join(this.#clonePath, Subspace.holdDir);
244
+ await rm(holdRoot, { recursive: true, force: true });
245
+ for (const member of Subspace.memberDirs) {
246
+ const memberRoot = path.join(this.#clonePath, member);
247
+ if (!(await FileSys.dirExists(memberRoot))) continue;
248
+ for (const name of await readdir(memberRoot)) {
249
+ for (const owned of Subspace.subspaceOwnedDirs) {
250
+ const from = path.join(memberRoot, name, owned);
251
+ if (!(await FileSys.dirExists(from))) continue;
252
+ const to = path.join(holdRoot, member, name, owned);
253
+ await mkdir(path.dirname(to), { recursive: true });
254
+ await rename(from, to);
255
+ held.push({ from, to });
256
+ }
257
+ }
258
+ }
259
+ return held;
260
+ }
261
+
262
+ /**
263
+ * Puts back only the files the workspace does not ship. The workspace still owns the tracked env switch files
264
+ * (`env.server.ts` / `env.client.ts`), which is what keeps them in step; everything else under `env/` —
265
+ * the per-environment values, which the workspace gitignores and never had — belongs to the subspace and wins.
266
+ */
267
+ async #restoreSubspaceOwned(held: { from: string; to: string }[]) {
268
+ for (const { from, to } of held) await Subspace.#copyMissing(to, from);
269
+ await rm(path.join(this.#clonePath, Subspace.holdDir), { recursive: true, force: true });
270
+ }
271
+
272
+ static async #copyMissing(from: string, to: string) {
273
+ await mkdir(to, { recursive: true });
274
+ for (const entry of await readdir(from, { withFileTypes: true })) {
275
+ const source = path.join(from, entry.name);
276
+ const target = path.join(to, entry.name);
277
+ if (entry.isDirectory()) await Subspace.#copyMissing(source, target);
278
+ else if (!(await FileSys.entryExists(target))) await rename(source, target);
279
+ }
280
+ }
281
+
282
+ /**
283
+ * `git archive` emits exactly the tracked files at HEAD, which is why the copy needs no exclude list of
284
+ * its own: generated barrels, the `(libs)`/`public/libs` symlinks, env values, secrets and the lockfile
285
+ * are all outside git and cannot enter the archive. Directory arguments only — a file list would put
286
+ * route paths like `page/(docs)/…` through a shell.
287
+ */
288
+ async #extract(paths: string[], excludes: string[] = []) {
289
+ const pathspec = paths.map((entry) => `'${entry}'`).join(" ");
290
+ const excludeArgs = excludes.map((entry) => `--exclude='${entry}'`).join(" ");
291
+ await this.#workspace.exec(
292
+ `git archive HEAD -- ${pathspec} | tar -x -C '${this.#clonePath}' ${excludeArgs}`.trim(),
293
+ );
294
+ }
295
+
296
+ /**
297
+ * Verify-only, and an allowlist rather than a filter so no secret can reach a customer's clone even by
298
+ * accident. `AKAN_PUBLIC_*` values are the ones akan embeds in every client bundle, so they are public
299
+ * by construction; the cloud workspace id is pinned to `local` rather than copied.
300
+ */
301
+ #localEnv() {
302
+ const { serveDomain } = WorkspaceExecutor.getBaseDevEnv();
303
+ return {
304
+ AKAN_WORKSPACE_ID: "local",
305
+ AKAN_PUBLIC_REPO_NAME: this.name,
306
+ AKAN_PUBLIC_SERVE_DOMAIN: serveDomain,
307
+ AKAN_PUBLIC_ENV: "local",
308
+ AKAN_PUBLIC_OPERATION_MODE: "local",
309
+ AKAN_PUBLIC_LOG_LEVEL: "warn",
310
+ };
311
+ }
312
+
313
+ /**
314
+ * The CLI identifies a workspace root by `package.json` + `tsconfig.json` + `.env`, so a clone with no
315
+ * `.env` cannot run `akan sync` at all — and one can never arrive with the slice, since every akan
316
+ * workspace gitignores it and the subspace's real values are its own. Excluded through the clone's
317
+ * `.git/info/exclude` rather than through the copied `.gitignore`: a workspace that omitted the pattern
318
+ * would otherwise commit this file into the customer's repo.
319
+ */
320
+ async #writeLocalEnv() {
321
+ const excludePath = path.join(this.#clonePath, ".git/info/exclude");
322
+ const exclude = (await FileSys.fileExists(excludePath)) ? await FileSys.readText(excludePath) : "";
323
+ if (!exclude.split("\n").includes("/.env")) {
324
+ await mkdir(path.dirname(excludePath), { recursive: true });
325
+ await FileSys.writeText(excludePath, exclude.trim() ? `${exclude.replace(/\n*$/, "\n")}/.env\n` : "/.env\n");
326
+ }
327
+ const envPath = path.join(this.#clonePath, ".env");
328
+ if (await FileSys.fileExists(envPath)) return;
329
+ const lines = Object.entries(this.#localEnv()).map(([key, value]) => `${key}=${value}`);
330
+ await FileSys.writeText(envPath, `${lines.join("\n")}\n`);
331
+ }
332
+
333
+ /**
334
+ * A workspace's root manifest is the union of every app it holds, so shipping it verbatim installs
335
+ * every other customer's dependency tree in this repo. It is rebuilt from this subspace's own slices
336
+ * instead, keeping the workspace's exact version specs.
337
+ */
338
+ async #rewriteManifest(plans: SlicePlan[]) {
339
+ const manifestPath = path.join(this.#clonePath, "package.json");
340
+ if (!(await FileSys.fileExists(manifestPath))) return [];
341
+ const rootPackageJson = (await FileSys.readJson(manifestPath)) as PackageJson;
342
+ const { packageJson, pruned, warnings } = await SlicePlanner.pruneDependencies(
343
+ this.#workspace,
344
+ rootPackageJson,
345
+ plans.flatMap((plan) => plan.requiredDependencies),
346
+ );
347
+ for (const warning of warnings) this.#workspace.logger.warn(warning);
348
+ await FileSys.writeJson(manifestPath, {
349
+ ...packageJson,
350
+ name: this.name,
351
+ description: `${this.name} workspace`,
352
+ });
353
+ return pruned;
354
+ }
355
+
356
+ async #applySlice(clone: Executor, branch: string) {
357
+ const { libs, paths, plans } = await this.slice();
358
+ const held = await this.#holdSubspaceOwned();
359
+ for (const member of Subspace.memberDirs)
360
+ await rm(path.join(this.#clonePath, member), { recursive: true, force: true });
361
+
362
+ //* The shell is overlaid, never reconciled: a subspace owns its deployment (its own CI files and
363
+ //* workflows live there), so a root entry the workspace does not have is left alone rather than deleted.
364
+ await this.#extract(["."], ["apps/*", "libs/*", "pkgs/*"]);
365
+ await this.#extract(paths);
366
+ await this.#restoreSubspaceOwned(held);
367
+
368
+ for (const entry of [...Subspace.workspaceOnlyEntries, ...this.#config.exclude])
369
+ await rm(path.join(this.#clonePath, entry), { recursive: true, force: true });
370
+
371
+ await this.#rewriteGitignore();
372
+ const pruned = await this.#rewriteManifest(plans);
373
+ await this.#rewriteWorkspaceSection(libs);
374
+ await this.#writeLocalEnv();
375
+ for (const lib of libs) await this.#stampLib(clone, lib, branch);
376
+ return { libs, pruned };
377
+ }
378
+
379
+ /**
380
+ * The `akan:secrets` block akan generates lists every app in the workspace by name, so it is filtered down to
381
+ * this subspace's own apps. `bun.lock` is un-ignored because a subspace commits its lockfile — that is what
382
+ * makes two subspaces on one branch resolve the same dependency tree rather than merely the same ranges.
383
+ */
384
+ async #rewriteGitignore() {
385
+ const gitignorePath = path.join(this.#clonePath, ".gitignore");
386
+ if (!(await FileSys.fileExists(gitignorePath))) return;
387
+ const [begin = "", end = ""] = Subspace.secretsMarkers;
388
+ const lines = (await FileSys.readText(gitignorePath)).split("\n");
389
+ const beginIdx = lines.indexOf(begin);
390
+ const endIdx = lines.indexOf(end);
391
+ const filtered =
392
+ beginIdx >= 0 && endIdx > beginIdx
393
+ ? [
394
+ ...lines.slice(0, beginIdx + 1),
395
+ ...lines
396
+ .slice(beginIdx + 1, endIdx)
397
+ .filter((line) => this.#declaration.apps.some((app) => line.startsWith(`apps/${app}/`))),
398
+ ...lines.slice(endIdx),
399
+ ]
400
+ : lines;
401
+ await FileSys.writeText(gitignorePath, filtered.filter((line) => line.trim() !== "**/bun.lock").join("\n"));
402
+ }
403
+
404
+ /** The generated `## Workspace` block names every app and library in the workspace. */
405
+ async #rewriteWorkspaceSection(libs: string[]) {
406
+ const replacements = [
407
+ [/^- Repo: .*$/m, `- Repo: ${this.name}`],
408
+ [/^- Apps: .*$/m, `- Apps: ${this.#declaration.apps.join(", ")}`],
409
+ [/^- Libraries: .*$/m, `- Libraries: ${libs.length ? libs.join(", ") : "(none)"}`],
410
+ ] as const;
411
+ for (const file of ["AGENTS.md", "CLAUDE.md"]) {
412
+ const filePath = path.join(this.#clonePath, file);
413
+ if (!(await FileSys.fileExists(filePath))) continue;
414
+ const content = await FileSys.readText(filePath);
415
+ const rewritten = replacements.reduce((text, [pattern, value]) => text.replace(pattern, value), content);
416
+ if (rewritten !== content) await FileSys.writeText(filePath, rewritten);
417
+ }
418
+ }
419
+
420
+ /**
421
+ * Written onto the clone's manifest directly rather than through `LibSource`, which addresses a library
422
+ * by its position in the *workspace's* workspace. The hash follows the same rule — the library's own git files
423
+ * with `env/` and the stamp itself left out — and is only written when it would change, so an
424
+ * up-to-date subspace stays clean and the push is skipped.
425
+ */
426
+ async #stampLib(clone: Executor, lib: string, branch: string) {
427
+ const manifestPath = path.join(this.#clonePath, "libs", lib, "package.json");
428
+ if (!(await FileSys.fileExists(manifestPath))) return;
429
+ const origin = `${this.#workspace.repoName}#${branch}`;
430
+ const sha = await this.headSha();
431
+ const [hash, previous] = await Promise.all([this.#hashLib(clone, lib), this.#committedStamp(clone, lib)]);
432
+ const manifest = (await FileSys.readJson(manifestPath)) as Record<string, unknown>;
433
+ const akan = (manifest[LibSource.manifestKey] ?? {}) as Record<string, unknown>;
434
+ //* The extraction overwrote the manifest with the workspace's, stamp and all, so the previous stamp has to
435
+ //* come from the clone's HEAD. Reusing its `syncedAt` when nothing else moved is what leaves the file
436
+ //* byte-identical to what is committed — otherwise every push is dirty and none is ever skipped.
437
+ const unchanged = previous?.origin === origin && previous.sha === sha && previous.hash === hash;
438
+ const syncedAt = unchanged ? previous.syncedAt : new Date().toISOString();
439
+ manifest[LibSource.manifestKey] = { ...akan, source: { origin, sha, hash, syncedAt } };
440
+ await FileSys.writeJson(manifestPath, manifest);
441
+ }
442
+
443
+ async #committedStamp(clone: Executor, lib: string) {
444
+ try {
445
+ const committed = await clone.spawn("git", ["show", `HEAD:libs/${lib}/package.json`]);
446
+ const manifest = JSON.parse(committed) as Record<string, unknown>;
447
+ const akan = (manifest[LibSource.manifestKey] ?? {}) as Record<string, unknown>;
448
+ return akan.source as { origin: string; sha: string; hash: string; syncedAt: string } | undefined;
449
+ } catch {
450
+ //? First push: the library is not in the subspace's history yet.
451
+ return undefined;
452
+ }
453
+ }
454
+
455
+ async #hashLib(clone: Executor, lib: string) {
456
+ const listed = await clone.spawn("git", [
457
+ "ls-files",
458
+ "-z",
459
+ "--cached",
460
+ "--others",
461
+ "--exclude-standard",
462
+ "--",
463
+ `libs/${lib}`,
464
+ ]);
465
+ const files = listed
466
+ .split("\0")
467
+ .filter((file) => !!file && !this.#isSubspaceOwned(file))
468
+ .sort();
469
+ const hasher = new Bun.CryptoHasher("sha256");
470
+ for (const file of files) {
471
+ const content = await FileSys.readText(path.join(this.#clonePath, file));
472
+ hasher.update(file);
473
+ hasher.update("\0");
474
+ if (file === `libs/${lib}/package.json`) {
475
+ const manifest = JSON.parse(content) as Record<string, unknown>;
476
+ delete manifest[LibSource.manifestKey];
477
+ hasher.update(JSON.stringify(manifest));
478
+ } else hasher.update(content);
479
+ hasher.update("\0");
480
+ }
481
+ return hasher.digest("hex").slice(0, 32);
482
+ }
483
+
484
+ /** Direction is subspace → workspace, so the patch reads as what a push would apply rather than its inverse. */
485
+ async diff(branch: string, filter?: string | null): Promise<SubspaceDiffResult> {
486
+ if (!(await this.fetch(branch))) throw new Error(`Subspace "${this.name}" has no branch "${branch}"`);
487
+ const { libs, paths } = await this.slice();
488
+ const libPaths = libs.map((lib) => `libs/${lib}`);
489
+ const appPaths = paths.filter((entry) => !libPaths.includes(entry));
490
+ const [app, libSection] = await Promise.all([
491
+ this.#diffSection(branch, appPaths, filter),
492
+ this.#diffSection(branch, libPaths, filter),
493
+ ]);
494
+ return { name: this.name, branch, app, libs: libSection };
495
+ }
496
+
497
+ async #diffSection(branch: string, paths: string[], filter?: string | null): Promise<SubspaceDiffSection> {
498
+ const scoped = filter ? paths.filter((entry) => entry.startsWith(filter) || filter.startsWith(entry)) : paths;
499
+ if (!scoped.length) return { files: [], patch: "" };
500
+ const from = `${this.#remote}/${branch}`;
501
+ const pathspec = filter ? [filter] : scoped;
502
+ const names = await this.#git(["diff", "--name-only", from, "HEAD", "--", ...pathspec]);
503
+ const files = await this.#meaningfulFiles(
504
+ from,
505
+ names
506
+ .split("\n")
507
+ .filter((file) => !!file.trim())
508
+ .filter((file) => !this.#isSubspaceOwned(file)),
509
+ );
510
+ if (!files.length) return { files: [], patch: "" };
511
+ //? argv, not a shell: a route path such as `page/(docs)/…` would need quoting through one.
512
+ return { files, patch: await this.#git(["diff", from, "HEAD", "--", ...files]) };
513
+ }
514
+
515
+ /**
516
+ * Refuses the subspace rather than throwing, so one customer repo that fails to install or sync does not
517
+ * abort the push to the rest of the subspaces. The env is passed explicitly so the child sees the values
518
+ * written into the clone instead of inheriting the workspace's own.
519
+ */
520
+ async #verify(clone: Executor) {
521
+ const env = { ...process.env, ...this.#localEnv() };
522
+ try {
523
+ await clone.spawn("bun", ["install"], { env });
524
+ for (const app of this.#declaration.apps) await clone.spawn("bunx", ["akan", "sync", app], { env });
525
+ return null;
526
+ } catch (error) {
527
+ return error instanceof Error ? error.message : String(error);
528
+ }
529
+ }
530
+
531
+ async push(branch: string, { verify = true }: { verify?: boolean } = {}): Promise<SubspacePushResult> {
532
+ this.#config.assertPushable(branch);
533
+ if (await this.#workspace.hasChanges())
534
+ return { name: this.name, outcome: "refused", reason: "workspace working tree is dirty", changedFiles: 0 };
535
+ if (!(await this.fetch(branch)))
536
+ return { name: this.name, outcome: "refused", reason: `subspace has no branch "${branch}"`, changedFiles: 0 };
537
+
538
+ const clone = await this.#ensureClone(branch);
539
+ const { pruned } = await this.#applySlice(clone, branch);
540
+
541
+ const dirty = (await clone.spawn("git", ["status", "--porcelain"])).trim();
542
+ if (!dirty)
543
+ return {
544
+ name: this.name,
545
+ outcome: "skipped",
546
+ reason: "already up to date",
547
+ changedFiles: 0,
548
+ prunedDependencies: pruned,
549
+ };
550
+
551
+ const changedFiles = dirty.split("\n").length;
552
+ if (verify) {
553
+ const failure = await this.#verify(clone);
554
+ if (failure) return { name: this.name, outcome: "refused", reason: failure, changedFiles };
555
+ }
556
+ await this.#writeAnchorFile(branch);
557
+ await clone.spawn("git", ["add", "-A"]);
558
+ const message = `chore(subspace): sync from ${this.#workspace.repoName}@${await this.headSha()}`;
559
+ await clone.spawn("git", ["commit", "--quiet", "-m", message]);
560
+ //* Never force: a diverged subspace holds customer commits, and `pull` is how those come back.
561
+ await clone.spawn("git", ["push", "--quiet", "origin", branch]);
562
+ return {
563
+ name: this.name,
564
+ outcome: "pushed",
565
+ commit: (await clone.spawn("git", ["rev-parse", "--short", "HEAD"])).trim(),
566
+ changedFiles,
567
+ prunedDependencies: pruned,
568
+ };
569
+ }
570
+
571
+ async #writeAnchorFile(branch: string) {
572
+ await FileSys.writeJson(path.join(this.#clonePath, Subspace.anchorFile), {
573
+ workspace: this.#workspace.repoName,
574
+ hubSha: await this.headSha(),
575
+ branch,
576
+ apps: this.#declaration.apps,
577
+ syncedAt: new Date().toISOString(),
578
+ });
579
+ }
580
+
581
+ async pull(branch: string, { adoptLibs = false }: { adoptLibs?: boolean } = {}): Promise<SubspacePullResult> {
582
+ if (!(await this.fetch(branch))) throw new Error(`Subspace "${this.name}" has no branch "${branch}"`);
583
+ const anchor = await this.anchor(branch);
584
+ if (!anchor.commit)
585
+ throw new Error(`Subspace "${this.name}" carries no ${Subspace.anchorFile} — it has never received a push`);
586
+ if (!anchor.incoming.length) return { name: this.name, applied: [], libPatch: null, ignored: [], incoming: [] };
587
+
588
+ const range = `${anchor.commit}..${this.#remote}/${branch}`;
589
+ const changed = (await this.#git(["diff", "--name-only", range])).split("\n").filter((file) => !!file.trim());
590
+ const appPrefixes = this.#declaration.apps.map((app) => `apps/${app}/`);
591
+ const appFiles = changed.filter(
592
+ (file) => appPrefixes.some((prefix) => file.startsWith(prefix)) && !this.#isSubspaceOwned(file),
593
+ );
594
+ const libFiles = changed.filter((file) => file.startsWith("libs/") && !this.#isSubspaceOwned(file));
595
+ const ignored = changed.filter((file) => !appFiles.includes(file) && !libFiles.includes(file));
596
+
597
+ const applied = adoptLibs ? [...appFiles, ...libFiles] : appFiles;
598
+ if (applied.length) await this.#applyPatch(range, applied, "incoming");
599
+ const saved = !adoptLibs && libFiles.length ? await this.#savePatch(range, libFiles, "libs") : null;
600
+ const libPatch = saved ? { path: saved.path, files: saved.files } : null;
601
+ return { name: this.name, applied, libPatch, ignored, incoming: anchor.incoming };
602
+ }
603
+
604
+ /** Left uncommitted in the workspace's working tree: a conflict is a normal 3-way conflict for a person. */
605
+ async #applyPatch(range: string, files: string[], label: string) {
606
+ const { absolute } = await this.#savePatch(range, files, label);
607
+ await this.#git(["apply", "--3way", absolute]);
608
+ }
609
+
610
+ /**
611
+ * A library hunk is never applied by default: the workspace is the one copy every other subspace is pushed from,
612
+ * so adopting one customer's edit silently would ship it to all of them.
613
+ */
614
+ async #savePatch(range: string, files: string[], label: string) {
615
+ const patchPath = path.join(this.#workspace.workspaceRoot, ".akan/subspace", `${this.name}-${label}.patch`);
616
+ await mkdir(path.dirname(patchPath), { recursive: true });
617
+ await FileSys.writeText(patchPath, await this.#git(["diff", range, "--", ...files]));
618
+ return { path: path.relative(this.#workspace.workspaceRoot, patchPath), absolute: patchPath, files };
619
+ }
620
+ }
621
+
622
+ export function formatSubspaceStatuses(statuses: SubspaceStatus[]) {
623
+ const sections = [
624
+ "Akan Subspace Status",
625
+ `branch: ${statuses[0]?.branch ?? "(none)"}`,
626
+ "",
627
+ ...statuses.flatMap((status) => {
628
+ if (!status.hasBranch) return [` ${status.name}: no such branch in the subspace — push is refused`];
629
+ const behind = status.behindPaths.length ? `${status.behindPaths.length} file(s) behind` : "up to date";
630
+ const incoming = status.anchor.incoming.length
631
+ ? `${status.anchor.incoming.length} customer commit(s) to pull`
632
+ : "no customer commits";
633
+ const drift = status.driftedLibs.length ? ` DRIFTED LIBS: ${status.driftedLibs.join(", ")}` : null;
634
+ return [
635
+ ` ${status.name}: ${behind}, ${incoming}`,
636
+ ...status.anchor.incoming.map((commit) => ` ${commit.sha} ${commit.author} ${commit.subject}`),
637
+ ...(drift ? [drift] : []),
638
+ ];
639
+ }),
640
+ ];
641
+ return sections.join("\n");
642
+ }
643
+
644
+ export function formatSubspacePushResults(results: SubspacePushResult[]) {
645
+ const sections = [
646
+ "Akan Subspace Push",
647
+ "",
648
+ ...results.flatMap((result) => {
649
+ const detail =
650
+ result.outcome === "pushed" ? `${result.commit} (${result.changedFiles} files)` : (result.reason ?? "");
651
+ const [first = "", ...rest] = detail.split("\n");
652
+ const pruned = result.prunedDependencies?.length
653
+ ? [` ${result.prunedDependencies.length} unused root dependenc(ies) left out of package.json`]
654
+ : [];
655
+ return [
656
+ ` ${result.outcome.padEnd(8)} ${result.name} ${first}`,
657
+ ...rest.map((line) => ` ${line}`),
658
+ ...pruned,
659
+ ];
660
+ }),
661
+ ];
662
+ return sections.join("\n");
663
+ }
664
+
665
+ export function formatSubspacePullResult(result: SubspacePullResult) {
666
+ const sections = [
667
+ `Akan Subspace Pull — ${result.name}`,
668
+ "",
669
+ `Customer commits since the last push (${result.incoming.length}):`,
670
+ "",
671
+ ...(result.incoming.length
672
+ ? result.incoming.map((commit) => ` ${commit.sha} ${commit.author} ${commit.subject}`)
673
+ : [" (none — nothing to pull)"]),
674
+ "",
675
+ `Applied to the working tree, uncommitted (${result.applied.length}):`,
676
+ "",
677
+ ...(result.applied.length ? result.applied.map((file) => ` ${file}`) : [" (none)"]),
678
+ ...(result.libPatch
679
+ ? [
680
+ "",
681
+ `Library edits NOT applied (${result.libPatch.files.length}) — review before adopting:`,
682
+ "",
683
+ ...result.libPatch.files.map((file) => ` ${file}`),
684
+ "",
685
+ ` patch: ${result.libPatch.path}`,
686
+ " adopt with: akan subspace pull <name> --adopt-libs",
687
+ ]
688
+ : []),
689
+ ...(result.ignored.length
690
+ ? ["", `Ignored (workspace-owned or subspace-owned): ${result.ignored.length} file(s)`]
691
+ : []),
692
+ ];
693
+ return sections.join("\n");
694
+ }
695
+
696
+ export function formatSubspaceDiff(result: SubspaceDiffResult) {
697
+ const total = result.app.files.length + result.libs.files.length;
698
+ const sections = [
699
+ `Akan Subspace Diff — ${result.name} (${result.branch})`,
700
+ "what `akan subspace push` would change in the subspace",
701
+ "",
702
+ ...(total ? [] : [" subspace is identical to the workspace for this slice."]),
703
+ ...(result.libs.files.length
704
+ ? [
705
+ `LIBRARY changes (${result.libs.files.length}) — the subspace edited shared code:`,
706
+ "",
707
+ ...result.libs.files.map((file) => ` ${file}`),
708
+ "",
709
+ result.libs.patch,
710
+ ]
711
+ : []),
712
+ ...(result.app.files.length
713
+ ? [
714
+ `App changes (${result.app.files.length}):`,
715
+ "",
716
+ ...result.app.files.map((file) => ` ${file}`),
717
+ "",
718
+ result.app.patch,
719
+ ]
720
+ : []),
721
+ ];
722
+ return sections.join("\n");
723
+ }