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