@akanjs/devkit 3.0.0-alpha.77 → 3.0.0-alpha.79
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/fleetConfig.ts +73 -0
- package/fleetGuard.test.ts +121 -0
- package/fleetGuard.ts +157 -0
- package/fleetSpoke.test.ts +365 -0
- package/fleetSpoke.ts +663 -0
- package/libSource.ts +12 -0
- package/package.json +2 -2
package/fleetSpoke.ts
ADDED
|
@@ -0,0 +1,663 @@
|
|
|
1
|
+
import { mkdir, readdir, rename, rm } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { AppExecutor, Executor, type WorkspaceExecutor } from "./executors";
|
|
4
|
+
import { FileSys } from "./fileSys";
|
|
5
|
+
import type { FleetConfig, FleetSpokeDeclaration } from "./fleetConfig";
|
|
6
|
+
import { LibSource } from "./libSource";
|
|
7
|
+
import { SlicePlanner } from "./slicePlanner";
|
|
8
|
+
|
|
9
|
+
export interface FleetIncomingCommit {
|
|
10
|
+
sha: string;
|
|
11
|
+
author: string;
|
|
12
|
+
subject: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface FleetAnchor {
|
|
16
|
+
/** Spoke commit that last carried a push, located by the one file only a push writes. */
|
|
17
|
+
commit: string | null;
|
|
18
|
+
/** Commits the spoke gained since then — customer work that has never been in the hub. */
|
|
19
|
+
incoming: FleetIncomingCommit[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface FleetSpokeStatus {
|
|
23
|
+
name: string;
|
|
24
|
+
branch: string;
|
|
25
|
+
/** False when the spoke has no such branch: a push refuses it rather than creating it. */
|
|
26
|
+
hasBranch: boolean;
|
|
27
|
+
anchor: FleetAnchor;
|
|
28
|
+
/** Slice paths where hub and spoke disagree — what a push would change. */
|
|
29
|
+
behindPaths: string[];
|
|
30
|
+
/** Libraries the spoke edited on its own: the drift this mechanism exists to stop. */
|
|
31
|
+
driftedLibs: string[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface FleetDiffSection {
|
|
35
|
+
files: string[];
|
|
36
|
+
patch: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface FleetDiffResult {
|
|
40
|
+
name: string;
|
|
41
|
+
branch: string;
|
|
42
|
+
/** What `akan fleet push` would change in the spoke, split so library changes are impossible to miss. */
|
|
43
|
+
app: FleetDiffSection;
|
|
44
|
+
libs: FleetDiffSection;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface FleetPushResult {
|
|
48
|
+
name: string;
|
|
49
|
+
outcome: "pushed" | "skipped" | "refused";
|
|
50
|
+
reason?: string;
|
|
51
|
+
commit?: string;
|
|
52
|
+
changedFiles: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface FleetPullResult {
|
|
56
|
+
name: string;
|
|
57
|
+
applied: string[];
|
|
58
|
+
/** Library hunks, left on disk as a patch instead of applied unless `adoptLibs` was passed. */
|
|
59
|
+
libPatch: { path: string; files: string[] } | null;
|
|
60
|
+
ignored: string[];
|
|
61
|
+
incoming: FleetIncomingCommit[];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* One customer repo, mirrored from this hub.
|
|
66
|
+
*
|
|
67
|
+
* Push is a squashed snapshot of the hub's own tracked files, so a spoke's history never carries the
|
|
68
|
+
* hub's — which is also what keeps one customer's commit messages out of another's repo. Pull is the
|
|
69
|
+
* reverse and rare: it compares the spoke against the last push it received rather than against the hub,
|
|
70
|
+
* so the diff is exactly the customer's own work however far the hub has moved on.
|
|
71
|
+
*/
|
|
72
|
+
export class FleetSpoke {
|
|
73
|
+
static readonly anchorFile = "akan.fleet.json";
|
|
74
|
+
/** Never reaches a spoke: it names every other customer's repo. */
|
|
75
|
+
static readonly hubOnlyEntries = ["akan.fleet.ts"];
|
|
76
|
+
/** Workspace members, which are replaced wholesale rather than overlaid. */
|
|
77
|
+
static readonly memberDirs = ["apps", "libs"];
|
|
78
|
+
/** Spoke-owned in both directions: env values belong to the repo that deploys, not to the hub. */
|
|
79
|
+
static readonly spokeOwnedDirs = ["env"];
|
|
80
|
+
static readonly secretsMarkers = ["# akan:secrets (managed by akan.config.ts — do not edit)", "# akan:secrets:end"];
|
|
81
|
+
static readonly holdDir = ".akan-fleet-hold";
|
|
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");
|
|
99
|
+
|
|
100
|
+
#workspace: WorkspaceExecutor;
|
|
101
|
+
#config: FleetConfig;
|
|
102
|
+
#declaration: FleetSpokeDeclaration;
|
|
103
|
+
|
|
104
|
+
constructor(workspace: WorkspaceExecutor, config: FleetConfig, declaration: FleetSpokeDeclaration) {
|
|
105
|
+
this.#workspace = workspace;
|
|
106
|
+
this.#config = config;
|
|
107
|
+
this.#declaration = declaration;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
get name() {
|
|
111
|
+
return this.#declaration.name;
|
|
112
|
+
}
|
|
113
|
+
get apps() {
|
|
114
|
+
return this.#declaration.apps;
|
|
115
|
+
}
|
|
116
|
+
get #remote() {
|
|
117
|
+
return `spoke-${this.#declaration.name}`;
|
|
118
|
+
}
|
|
119
|
+
get #clonePath() {
|
|
120
|
+
return path.join(this.#workspace.workspaceRoot, ".akan/fleet", this.#declaration.name);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async #git(args: string[]) {
|
|
124
|
+
return await this.#workspace.spawn("git", args);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async branch() {
|
|
128
|
+
return (await this.#git(["rev-parse", "--abbrev-ref", "HEAD"])).trim();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async headSha() {
|
|
132
|
+
return (await this.#git(["rev-parse", "--short", "HEAD"])).trim();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Adds the spoke as a remote if absent, then fetches the branch into this hub's object store. */
|
|
136
|
+
async fetch(branch: string) {
|
|
137
|
+
const remotes = (await this.#git(["remote"])).split("\n").map((line) => line.trim());
|
|
138
|
+
if (remotes.includes(this.#remote)) await this.#git(["remote", "set-url", this.#remote, this.#declaration.repo]);
|
|
139
|
+
else await this.#git(["remote", "add", this.#remote, this.#declaration.repo]);
|
|
140
|
+
try {
|
|
141
|
+
await this.#git(["fetch", "--quiet", this.#remote, branch]);
|
|
142
|
+
return true;
|
|
143
|
+
} catch {
|
|
144
|
+
//? A spoke that has never had this branch is not an error here — `push` refuses it by name.
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* 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 spoke's history and cannot drift out of it.
|
|
152
|
+
*/
|
|
153
|
+
async anchor(branch: string): Promise<FleetAnchor> {
|
|
154
|
+
const ref = `${this.#remote}/${branch}`;
|
|
155
|
+
const commit = (await this.#git(["log", "-1", "--format=%H", ref, "--", FleetSpoke.anchorFile])).trim();
|
|
156
|
+
if (!commit) return { commit: null, incoming: [] };
|
|
157
|
+
const log = await this.#git(["log", "--format=%H%x09%an%x09%s", `${commit}..${ref}`]);
|
|
158
|
+
const incoming = log
|
|
159
|
+
.split("\n")
|
|
160
|
+
.filter((line) => !!line.trim())
|
|
161
|
+
.map((line) => {
|
|
162
|
+
const [sha = "", author = "", ...subject] = line.split("\t");
|
|
163
|
+
return { sha: sha.slice(0, 12), author, subject: subject.join("\t") };
|
|
164
|
+
});
|
|
165
|
+
return { commit, incoming };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** App and lib directories this spoke carries. Libraries come from each app's closure, never declared. */
|
|
169
|
+
async slice() {
|
|
170
|
+
const plans = await Promise.all(
|
|
171
|
+
this.#declaration.apps.map(
|
|
172
|
+
async (appName) => await new SlicePlanner(AppExecutor.from(this.#workspace, appName)).plan(),
|
|
173
|
+
),
|
|
174
|
+
);
|
|
175
|
+
const libs = [...new Set(plans.flatMap((plan) => plan.libs))].sort();
|
|
176
|
+
const paths = [...this.#declaration.apps.map((app) => `apps/${app}`), ...libs.map((lib) => `libs/${lib}`)];
|
|
177
|
+
return { plans, libs, paths };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
#isSpokeOwned(file: string) {
|
|
181
|
+
const segments = file.split("/");
|
|
182
|
+
if (!FleetSpoke.memberDirs.includes(segments[0] ?? "")) return false;
|
|
183
|
+
return FleetSpoke.spokeOwnedDirs.includes(segments[2] ?? "");
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* A library manifest always differs: the spoke's copy carries the `akan.source` stamp a push writes and
|
|
188
|
+
* the hub's does not. Comparing it with that key removed is what keeps `status` and `diff` from
|
|
189
|
+
* reporting every library as changed forever.
|
|
190
|
+
*/
|
|
191
|
+
#isStamped(file: string) {
|
|
192
|
+
return /^libs\/[^/]+\/package\.json$/.test(file);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async #differsBeyondStamp(ref: string, file: string) {
|
|
196
|
+
const [theirs, ours] = await Promise.all([
|
|
197
|
+
this.#git(["show", `${ref}:${file}`]).catch(() => ""),
|
|
198
|
+
this.#git(["show", `HEAD:${file}`]).catch(() => ""),
|
|
199
|
+
]);
|
|
200
|
+
if (!theirs || !ours) return true;
|
|
201
|
+
const normalize = (content: string) => {
|
|
202
|
+
try {
|
|
203
|
+
const manifest = JSON.parse(content) as Record<string, unknown>;
|
|
204
|
+
delete manifest[LibSource.manifestKey];
|
|
205
|
+
return JSON.stringify(manifest);
|
|
206
|
+
} catch {
|
|
207
|
+
return content;
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
return normalize(theirs) !== normalize(ours);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async #meaningfulFiles(ref: string, files: string[]) {
|
|
214
|
+
const kept = await Promise.all(
|
|
215
|
+
files.map(async (file) => (this.#isStamped(file) ? await this.#differsBeyondStamp(ref, file) : true)),
|
|
216
|
+
);
|
|
217
|
+
return files.filter((_, index) => kept[index]);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async status(branch: string): Promise<FleetSpokeStatus> {
|
|
221
|
+
const hasBranch = await this.fetch(branch);
|
|
222
|
+
const [{ paths, libs }, anchor] = await Promise.all([
|
|
223
|
+
this.slice(),
|
|
224
|
+
hasBranch ? this.anchor(branch) : Promise.resolve<FleetAnchor>({ commit: null, incoming: [] }),
|
|
225
|
+
]);
|
|
226
|
+
if (!hasBranch) return { name: this.name, branch, hasBranch, anchor, behindPaths: [], driftedLibs: [] };
|
|
227
|
+
const ref = `${this.#remote}/${branch}`;
|
|
228
|
+
const diff = await this.#git(["diff", "--name-only", "HEAD", ref, "--", ...paths]);
|
|
229
|
+
const behindPaths = await this.#meaningfulFiles(
|
|
230
|
+
ref,
|
|
231
|
+
diff
|
|
232
|
+
.split("\n")
|
|
233
|
+
.filter((file) => !!file.trim())
|
|
234
|
+
.filter((file) => !this.#isSpokeOwned(file)),
|
|
235
|
+
);
|
|
236
|
+
const driftedLibs = libs.filter((lib) => behindPaths.some((file) => file.startsWith(`libs/${lib}/`)));
|
|
237
|
+
return { name: this.name, branch, hasBranch, anchor, behindPaths, driftedLibs };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async #ensureClone(branch: string) {
|
|
241
|
+
if (!(await FileSys.dirExists(path.join(this.#clonePath, ".git")))) {
|
|
242
|
+
await rm(this.#clonePath, { recursive: true, force: true });
|
|
243
|
+
await mkdir(path.dirname(this.#clonePath), { recursive: true });
|
|
244
|
+
await this.#workspace.spawn("git", ["clone", "--quiet", this.#declaration.repo, this.#clonePath]);
|
|
245
|
+
}
|
|
246
|
+
const clone = new Executor(`spoke-${this.name}`, this.#clonePath);
|
|
247
|
+
await clone.spawn("git", ["fetch", "--quiet", "origin", branch]);
|
|
248
|
+
await clone.spawn("git", ["checkout", "--quiet", "-B", branch, `origin/${branch}`]);
|
|
249
|
+
await clone.spawn("git", ["clean", "-qfd"]);
|
|
250
|
+
return clone;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Moves the spoke's env trees aside, so replacing the members cannot take them with it. */
|
|
254
|
+
async #holdSpokeOwned() {
|
|
255
|
+
const held: { from: string; to: string }[] = [];
|
|
256
|
+
const holdRoot = path.join(this.#clonePath, FleetSpoke.holdDir);
|
|
257
|
+
await rm(holdRoot, { recursive: true, force: true });
|
|
258
|
+
for (const member of FleetSpoke.memberDirs) {
|
|
259
|
+
const memberRoot = path.join(this.#clonePath, member);
|
|
260
|
+
if (!(await FileSys.dirExists(memberRoot))) continue;
|
|
261
|
+
for (const name of await readdir(memberRoot)) {
|
|
262
|
+
for (const owned of FleetSpoke.spokeOwnedDirs) {
|
|
263
|
+
const from = path.join(memberRoot, name, owned);
|
|
264
|
+
if (!(await FileSys.dirExists(from))) continue;
|
|
265
|
+
const to = path.join(holdRoot, member, name, owned);
|
|
266
|
+
await mkdir(path.dirname(to), { recursive: true });
|
|
267
|
+
await rename(from, to);
|
|
268
|
+
held.push({ from, to });
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
return held;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Puts back only the files the hub does not ship. The hub still owns the tracked env switch files
|
|
277
|
+
* (`env.server.ts` / `env.client.ts`), which is what keeps them in step; everything else under `env/` —
|
|
278
|
+
* the per-environment values, which the hub gitignores and never had — belongs to the spoke and wins.
|
|
279
|
+
*/
|
|
280
|
+
async #restoreSpokeOwned(held: { from: string; to: string }[]) {
|
|
281
|
+
for (const { from, to } of held) await FleetSpoke.#copyMissing(to, from);
|
|
282
|
+
await rm(path.join(this.#clonePath, FleetSpoke.holdDir), { recursive: true, force: true });
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
static async #copyMissing(from: string, to: string) {
|
|
286
|
+
await mkdir(to, { recursive: true });
|
|
287
|
+
for (const entry of await readdir(from, { withFileTypes: true })) {
|
|
288
|
+
const source = path.join(from, entry.name);
|
|
289
|
+
const target = path.join(to, entry.name);
|
|
290
|
+
if (entry.isDirectory()) await FleetSpoke.#copyMissing(source, target);
|
|
291
|
+
else if (!(await FileSys.entryExists(target))) await rename(source, target);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* `git archive` emits exactly the tracked files at HEAD, which is why the copy needs no exclude list of
|
|
297
|
+
* its own: generated barrels, the `(libs)`/`public/libs` symlinks, env values, secrets and the lockfile
|
|
298
|
+
* are all outside git and cannot enter the archive. Directory arguments only — a file list would put
|
|
299
|
+
* route paths like `page/(docs)/…` through a shell.
|
|
300
|
+
*/
|
|
301
|
+
async #extract(paths: string[], excludes: string[] = []) {
|
|
302
|
+
const pathspec = paths.map((entry) => `'${entry}'`).join(" ");
|
|
303
|
+
const excludeArgs = excludes.map((entry) => `--exclude='${entry}'`).join(" ");
|
|
304
|
+
await this.#workspace.exec(
|
|
305
|
+
`git archive HEAD -- ${pathspec} | tar -x -C '${this.#clonePath}' ${excludeArgs}`.trim(),
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
async #applySlice(clone: Executor, branch: string) {
|
|
310
|
+
const { libs, paths } = await this.slice();
|
|
311
|
+
const held = await this.#holdSpokeOwned();
|
|
312
|
+
for (const member of FleetSpoke.memberDirs)
|
|
313
|
+
await rm(path.join(this.#clonePath, member), { recursive: true, force: true });
|
|
314
|
+
|
|
315
|
+
//* The shell is overlaid, never reconciled: a spoke owns its deployment (its own CI files and
|
|
316
|
+
//* workflows live there), so a root entry the hub does not have is left alone rather than deleted.
|
|
317
|
+
await this.#extract(["."], ["apps/*", "libs/*", "pkgs/*"]);
|
|
318
|
+
await this.#extract(paths);
|
|
319
|
+
await this.#restoreSpokeOwned(held);
|
|
320
|
+
|
|
321
|
+
for (const entry of [...FleetSpoke.hubOnlyEntries, ...this.#config.exclude])
|
|
322
|
+
await rm(path.join(this.#clonePath, entry), { recursive: true, force: true });
|
|
323
|
+
|
|
324
|
+
await this.#rewriteGitignore();
|
|
325
|
+
await this.#rewriteWorkspaceSection(libs);
|
|
326
|
+
await this.#writeGuards();
|
|
327
|
+
for (const lib of libs) await this.#stampLib(clone, lib, branch);
|
|
328
|
+
return { libs };
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* The `akan:secrets` block akan generates lists every app in the hub by name, so it is filtered down to
|
|
333
|
+
* this spoke's own apps. `bun.lock` is un-ignored because a spoke commits its lockfile — that is what
|
|
334
|
+
* makes two spokes on one branch resolve the same dependency tree rather than merely the same ranges.
|
|
335
|
+
*/
|
|
336
|
+
async #rewriteGitignore() {
|
|
337
|
+
const gitignorePath = path.join(this.#clonePath, ".gitignore");
|
|
338
|
+
if (!(await FileSys.fileExists(gitignorePath))) return;
|
|
339
|
+
const [begin = "", end = ""] = FleetSpoke.secretsMarkers;
|
|
340
|
+
const lines = (await FileSys.readText(gitignorePath)).split("\n");
|
|
341
|
+
const beginIdx = lines.indexOf(begin);
|
|
342
|
+
const endIdx = lines.indexOf(end);
|
|
343
|
+
const filtered =
|
|
344
|
+
beginIdx >= 0 && endIdx > beginIdx
|
|
345
|
+
? [
|
|
346
|
+
...lines.slice(0, beginIdx + 1),
|
|
347
|
+
...lines
|
|
348
|
+
.slice(beginIdx + 1, endIdx)
|
|
349
|
+
.filter((line) => this.#declaration.apps.some((app) => line.startsWith(`apps/${app}/`))),
|
|
350
|
+
...lines.slice(endIdx),
|
|
351
|
+
]
|
|
352
|
+
: lines;
|
|
353
|
+
await FileSys.writeText(gitignorePath, filtered.filter((line) => line.trim() !== "**/bun.lock").join("\n"));
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** The generated `## Workspace` block names every app and library in the hub. */
|
|
357
|
+
async #rewriteWorkspaceSection(libs: string[]) {
|
|
358
|
+
const replacements = [
|
|
359
|
+
[/^- Repo: .*$/m, `- Repo: ${this.name}`],
|
|
360
|
+
[/^- Apps: .*$/m, `- Apps: ${this.#declaration.apps.join(", ")}`],
|
|
361
|
+
[/^- Libraries: .*$/m, `- Libraries: ${libs.length ? libs.join(", ") : "(none)"}`],
|
|
362
|
+
] as const;
|
|
363
|
+
for (const file of ["AGENTS.md", "CLAUDE.md"]) {
|
|
364
|
+
const filePath = path.join(this.#clonePath, file);
|
|
365
|
+
if (!(await FileSys.fileExists(filePath))) continue;
|
|
366
|
+
const content = await FileSys.readText(filePath);
|
|
367
|
+
const rewritten = replacements.reduce((text, [pattern, value]) => text.replace(pattern, value), content);
|
|
368
|
+
if (rewritten !== content) await FileSys.writeText(filePath, rewritten);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Written onto the clone's manifest directly rather than through `LibSource`, which addresses a library
|
|
374
|
+
* by its position in the *hub's* workspace. The hash follows the same rule — the library's own git files
|
|
375
|
+
* with `env/` and the stamp itself left out — and is only written when it would change, so an
|
|
376
|
+
* up-to-date spoke stays clean and the push is skipped.
|
|
377
|
+
*/
|
|
378
|
+
async #stampLib(clone: Executor, lib: string, branch: string) {
|
|
379
|
+
const manifestPath = path.join(this.#clonePath, "libs", lib, "package.json");
|
|
380
|
+
if (!(await FileSys.fileExists(manifestPath))) return;
|
|
381
|
+
const origin = `${this.#workspace.repoName}#${branch}`;
|
|
382
|
+
const sha = await this.headSha();
|
|
383
|
+
const [hash, previous] = await Promise.all([this.#hashLib(clone, lib), this.#committedStamp(clone, lib)]);
|
|
384
|
+
const manifest = (await FileSys.readJson(manifestPath)) as Record<string, unknown>;
|
|
385
|
+
const akan = (manifest[LibSource.manifestKey] ?? {}) as Record<string, unknown>;
|
|
386
|
+
//* The extraction overwrote the manifest with the hub's, stamp and all, so the previous stamp has to
|
|
387
|
+
//* come from the clone's HEAD. Reusing its `syncedAt` when nothing else moved is what leaves the file
|
|
388
|
+
//* byte-identical to what is committed — otherwise every push is dirty and none is ever skipped.
|
|
389
|
+
const unchanged = previous?.origin === origin && previous.sha === sha && previous.hash === hash;
|
|
390
|
+
const syncedAt = unchanged ? previous.syncedAt : new Date().toISOString();
|
|
391
|
+
manifest[LibSource.manifestKey] = { ...akan, source: { origin, sha, hash, syncedAt } };
|
|
392
|
+
await FileSys.writeJson(manifestPath, manifest);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
async #committedStamp(clone: Executor, lib: string) {
|
|
396
|
+
try {
|
|
397
|
+
const committed = await clone.spawn("git", ["show", `HEAD:libs/${lib}/package.json`]);
|
|
398
|
+
const manifest = JSON.parse(committed) as Record<string, unknown>;
|
|
399
|
+
const akan = (manifest[LibSource.manifestKey] ?? {}) as Record<string, unknown>;
|
|
400
|
+
return akan.source as { origin: string; sha: string; hash: string; syncedAt: string } | undefined;
|
|
401
|
+
} catch {
|
|
402
|
+
//? First push: the library is not in the spoke's history yet.
|
|
403
|
+
return undefined;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
async #hashLib(clone: Executor, lib: string) {
|
|
408
|
+
const listed = await clone.spawn("git", [
|
|
409
|
+
"ls-files",
|
|
410
|
+
"-z",
|
|
411
|
+
"--cached",
|
|
412
|
+
"--others",
|
|
413
|
+
"--exclude-standard",
|
|
414
|
+
"--",
|
|
415
|
+
`libs/${lib}`,
|
|
416
|
+
]);
|
|
417
|
+
const files = listed
|
|
418
|
+
.split("\0")
|
|
419
|
+
.filter((file) => !!file && !this.#isSpokeOwned(file))
|
|
420
|
+
.sort();
|
|
421
|
+
const hasher = new Bun.CryptoHasher("sha256");
|
|
422
|
+
for (const file of files) {
|
|
423
|
+
const content = await FileSys.readText(path.join(this.#clonePath, file));
|
|
424
|
+
hasher.update(file);
|
|
425
|
+
hasher.update("\0");
|
|
426
|
+
if (file === `libs/${lib}/package.json`) {
|
|
427
|
+
const manifest = JSON.parse(content) as Record<string, unknown>;
|
|
428
|
+
delete manifest[LibSource.manifestKey];
|
|
429
|
+
hasher.update(JSON.stringify(manifest));
|
|
430
|
+
} else hasher.update(content);
|
|
431
|
+
hasher.update("\0");
|
|
432
|
+
}
|
|
433
|
+
return hasher.digest("hex").slice(0, 32);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** Direction is spoke → hub, so the patch reads as what a push would apply rather than its inverse. */
|
|
437
|
+
async diff(branch: string, filter?: string | null): Promise<FleetDiffResult> {
|
|
438
|
+
if (!(await this.fetch(branch))) throw new Error(`Spoke "${this.name}" has no branch "${branch}"`);
|
|
439
|
+
const { libs, paths } = await this.slice();
|
|
440
|
+
const libPaths = libs.map((lib) => `libs/${lib}`);
|
|
441
|
+
const appPaths = paths.filter((entry) => !libPaths.includes(entry));
|
|
442
|
+
const [app, libSection] = await Promise.all([
|
|
443
|
+
this.#diffSection(branch, appPaths, filter),
|
|
444
|
+
this.#diffSection(branch, libPaths, filter),
|
|
445
|
+
]);
|
|
446
|
+
return { name: this.name, branch, app, libs: libSection };
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
async #diffSection(branch: string, paths: string[], filter?: string | null): Promise<FleetDiffSection> {
|
|
450
|
+
const scoped = filter ? paths.filter((entry) => entry.startsWith(filter) || filter.startsWith(entry)) : paths;
|
|
451
|
+
if (!scoped.length) return { files: [], patch: "" };
|
|
452
|
+
const from = `${this.#remote}/${branch}`;
|
|
453
|
+
const pathspec = filter ? [filter] : scoped;
|
|
454
|
+
const names = await this.#git(["diff", "--name-only", from, "HEAD", "--", ...pathspec]);
|
|
455
|
+
const files = await this.#meaningfulFiles(
|
|
456
|
+
from,
|
|
457
|
+
names
|
|
458
|
+
.split("\n")
|
|
459
|
+
.filter((file) => !!file.trim())
|
|
460
|
+
.filter((file) => !this.#isSpokeOwned(file)),
|
|
461
|
+
);
|
|
462
|
+
if (!files.length) return { files: [], patch: "" };
|
|
463
|
+
//? argv, not a shell: a route path such as `page/(docs)/…` would need quoting through one.
|
|
464
|
+
return { files, patch: await this.#git(["diff", from, "HEAD", "--", ...files]) };
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* The spoke-side guard, which is one line calling `akan fleet check` rather than a script of its own —
|
|
469
|
+
* the logic lives in the CLI, so a spoke never carries a copy that can rot. Written after the shell
|
|
470
|
+
* overlay, since the hub's own `.husky/pre-commit` travels with it and would otherwise win.
|
|
471
|
+
*/
|
|
472
|
+
async #writeGuards() {
|
|
473
|
+
const { ci, preCommit } = this.#config.guard;
|
|
474
|
+
if (ci === "github") {
|
|
475
|
+
const workflowPath = path.join(this.#clonePath, ".github/workflows/akan-fleet-guard.yml");
|
|
476
|
+
await mkdir(path.dirname(workflowPath), { recursive: true });
|
|
477
|
+
await FileSys.writeText(workflowPath, FleetSpoke.guardWorkflow);
|
|
478
|
+
}
|
|
479
|
+
if (!preCommit) return;
|
|
480
|
+
const hookPath = path.join(this.#clonePath, ".husky/pre-commit");
|
|
481
|
+
const existing = (await FileSys.fileExists(hookPath)) ? await FileSys.readText(hookPath) : "";
|
|
482
|
+
if (existing.includes(FleetSpoke.guardHookLine)) return;
|
|
483
|
+
await mkdir(path.dirname(hookPath), { recursive: true });
|
|
484
|
+
const content = existing.trim()
|
|
485
|
+
? `${existing.replace(/\n*$/, "\n")}${FleetSpoke.guardHookLine}\n`
|
|
486
|
+
: `${FleetSpoke.guardHookLine}\n`;
|
|
487
|
+
await FileSys.writeText(hookPath, content);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
async push(branch: string, { verify = true }: { verify?: boolean } = {}): Promise<FleetPushResult> {
|
|
491
|
+
this.#config.assertPushable(branch);
|
|
492
|
+
if (await this.#workspace.hasChanges())
|
|
493
|
+
return { name: this.name, outcome: "refused", reason: "hub working tree is dirty", changedFiles: 0 };
|
|
494
|
+
if (!(await this.fetch(branch)))
|
|
495
|
+
return { name: this.name, outcome: "refused", reason: `spoke has no branch "${branch}"`, changedFiles: 0 };
|
|
496
|
+
|
|
497
|
+
const clone = await this.#ensureClone(branch);
|
|
498
|
+
await this.#applySlice(clone, branch);
|
|
499
|
+
|
|
500
|
+
const dirty = (await clone.spawn("git", ["status", "--porcelain"])).trim();
|
|
501
|
+
if (!dirty) return { name: this.name, outcome: "skipped", reason: "already up to date", changedFiles: 0 };
|
|
502
|
+
|
|
503
|
+
await this.#writeAnchorFile(branch);
|
|
504
|
+
if (verify) {
|
|
505
|
+
await clone.spawn("bun", ["install"]);
|
|
506
|
+
for (const app of this.#declaration.apps) await clone.spawn("bunx", ["akan", "sync", app]);
|
|
507
|
+
}
|
|
508
|
+
await clone.spawn("git", ["add", "-A"]);
|
|
509
|
+
const message = `chore(fleet): sync from ${this.#workspace.repoName}@${await this.headSha()}`;
|
|
510
|
+
await clone.spawn("git", ["commit", "--quiet", "-m", message]);
|
|
511
|
+
//* Never force: a diverged spoke holds customer commits, and `pull` is how those come back.
|
|
512
|
+
await clone.spawn("git", ["push", "--quiet", "origin", branch]);
|
|
513
|
+
return {
|
|
514
|
+
name: this.name,
|
|
515
|
+
outcome: "pushed",
|
|
516
|
+
commit: (await clone.spawn("git", ["rev-parse", "--short", "HEAD"])).trim(),
|
|
517
|
+
changedFiles: dirty.split("\n").length,
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
async #writeAnchorFile(branch: string) {
|
|
522
|
+
await FileSys.writeJson(path.join(this.#clonePath, FleetSpoke.anchorFile), {
|
|
523
|
+
hub: this.#workspace.repoName,
|
|
524
|
+
hubSha: await this.headSha(),
|
|
525
|
+
branch,
|
|
526
|
+
apps: this.#declaration.apps,
|
|
527
|
+
syncedAt: new Date().toISOString(),
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
async pull(branch: string, { adoptLibs = false }: { adoptLibs?: boolean } = {}): Promise<FleetPullResult> {
|
|
532
|
+
if (!(await this.fetch(branch))) throw new Error(`Spoke "${this.name}" has no branch "${branch}"`);
|
|
533
|
+
const anchor = await this.anchor(branch);
|
|
534
|
+
if (!anchor.commit)
|
|
535
|
+
throw new Error(`Spoke "${this.name}" carries no ${FleetSpoke.anchorFile} — it has never received a push`);
|
|
536
|
+
if (!anchor.incoming.length) return { name: this.name, applied: [], libPatch: null, ignored: [], incoming: [] };
|
|
537
|
+
|
|
538
|
+
const range = `${anchor.commit}..${this.#remote}/${branch}`;
|
|
539
|
+
const changed = (await this.#git(["diff", "--name-only", range])).split("\n").filter((file) => !!file.trim());
|
|
540
|
+
const appPrefixes = this.#declaration.apps.map((app) => `apps/${app}/`);
|
|
541
|
+
const appFiles = changed.filter(
|
|
542
|
+
(file) => appPrefixes.some((prefix) => file.startsWith(prefix)) && !this.#isSpokeOwned(file),
|
|
543
|
+
);
|
|
544
|
+
const libFiles = changed.filter((file) => file.startsWith("libs/") && !this.#isSpokeOwned(file));
|
|
545
|
+
const ignored = changed.filter((file) => !appFiles.includes(file) && !libFiles.includes(file));
|
|
546
|
+
|
|
547
|
+
const applied = adoptLibs ? [...appFiles, ...libFiles] : appFiles;
|
|
548
|
+
if (applied.length) await this.#applyPatch(range, applied, "incoming");
|
|
549
|
+
const saved = !adoptLibs && libFiles.length ? await this.#savePatch(range, libFiles, "libs") : null;
|
|
550
|
+
const libPatch = saved ? { path: saved.path, files: saved.files } : null;
|
|
551
|
+
return { name: this.name, applied, libPatch, ignored, incoming: anchor.incoming };
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/** Left uncommitted in the hub's working tree: a conflict is a normal 3-way conflict for a person. */
|
|
555
|
+
async #applyPatch(range: string, files: string[], label: string) {
|
|
556
|
+
const { absolute } = await this.#savePatch(range, files, label);
|
|
557
|
+
await this.#git(["apply", "--3way", absolute]);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* A library hunk is never applied by default: the hub is the one copy every other spoke is pushed from,
|
|
562
|
+
* so adopting one customer's edit silently would ship it to all of them.
|
|
563
|
+
*/
|
|
564
|
+
async #savePatch(range: string, files: string[], label: string) {
|
|
565
|
+
const patchPath = path.join(this.#workspace.workspaceRoot, ".akan/fleet", `${this.name}-${label}.patch`);
|
|
566
|
+
await mkdir(path.dirname(patchPath), { recursive: true });
|
|
567
|
+
await FileSys.writeText(patchPath, await this.#git(["diff", range, "--", ...files]));
|
|
568
|
+
return { path: path.relative(this.#workspace.workspaceRoot, patchPath), absolute: patchPath, files };
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
export function formatFleetStatuses(statuses: FleetSpokeStatus[]) {
|
|
573
|
+
const sections = [
|
|
574
|
+
"Akan Fleet Status",
|
|
575
|
+
`branch: ${statuses[0]?.branch ?? "(none)"}`,
|
|
576
|
+
"",
|
|
577
|
+
...statuses.flatMap((status) => {
|
|
578
|
+
if (!status.hasBranch) return [` ${status.name}: no such branch in the spoke — push is refused`];
|
|
579
|
+
const behind = status.behindPaths.length ? `${status.behindPaths.length} file(s) behind` : "up to date";
|
|
580
|
+
const incoming = status.anchor.incoming.length
|
|
581
|
+
? `${status.anchor.incoming.length} customer commit(s) to pull`
|
|
582
|
+
: "no customer commits";
|
|
583
|
+
const drift = status.driftedLibs.length ? ` DRIFTED LIBS: ${status.driftedLibs.join(", ")}` : null;
|
|
584
|
+
return [
|
|
585
|
+
` ${status.name}: ${behind}, ${incoming}`,
|
|
586
|
+
...status.anchor.incoming.map((commit) => ` ${commit.sha} ${commit.author} ${commit.subject}`),
|
|
587
|
+
...(drift ? [drift] : []),
|
|
588
|
+
];
|
|
589
|
+
}),
|
|
590
|
+
];
|
|
591
|
+
return sections.join("\n");
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
export function formatFleetPushResults(results: FleetPushResult[]) {
|
|
595
|
+
const sections = [
|
|
596
|
+
"Akan Fleet Push",
|
|
597
|
+
"",
|
|
598
|
+
...results.map((result) => {
|
|
599
|
+
const detail =
|
|
600
|
+
result.outcome === "pushed" ? `${result.commit} (${result.changedFiles} files)` : (result.reason ?? "");
|
|
601
|
+
return ` ${result.outcome.padEnd(8)} ${result.name} ${detail}`;
|
|
602
|
+
}),
|
|
603
|
+
];
|
|
604
|
+
return sections.join("\n");
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
export function formatFleetPullResult(result: FleetPullResult) {
|
|
608
|
+
const sections = [
|
|
609
|
+
`Akan Fleet Pull — ${result.name}`,
|
|
610
|
+
"",
|
|
611
|
+
`Customer commits since the last push (${result.incoming.length}):`,
|
|
612
|
+
"",
|
|
613
|
+
...(result.incoming.length
|
|
614
|
+
? result.incoming.map((commit) => ` ${commit.sha} ${commit.author} ${commit.subject}`)
|
|
615
|
+
: [" (none — nothing to pull)"]),
|
|
616
|
+
"",
|
|
617
|
+
`Applied to the working tree, uncommitted (${result.applied.length}):`,
|
|
618
|
+
"",
|
|
619
|
+
...(result.applied.length ? result.applied.map((file) => ` ${file}`) : [" (none)"]),
|
|
620
|
+
...(result.libPatch
|
|
621
|
+
? [
|
|
622
|
+
"",
|
|
623
|
+
`Library edits NOT applied (${result.libPatch.files.length}) — review before adopting:`,
|
|
624
|
+
"",
|
|
625
|
+
...result.libPatch.files.map((file) => ` ${file}`),
|
|
626
|
+
"",
|
|
627
|
+
` patch: ${result.libPatch.path}`,
|
|
628
|
+
" adopt with: akan fleet pull <name> --adopt-libs",
|
|
629
|
+
]
|
|
630
|
+
: []),
|
|
631
|
+
...(result.ignored.length ? ["", `Ignored (hub-owned or spoke-owned): ${result.ignored.length} file(s)`] : []),
|
|
632
|
+
];
|
|
633
|
+
return sections.join("\n");
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
export function formatFleetDiff(result: FleetDiffResult) {
|
|
637
|
+
const total = result.app.files.length + result.libs.files.length;
|
|
638
|
+
const sections = [
|
|
639
|
+
`Akan Fleet Diff — ${result.name} (${result.branch})`,
|
|
640
|
+
"what `akan fleet push` would change in the spoke",
|
|
641
|
+
"",
|
|
642
|
+
...(total ? [] : [" spoke is identical to the hub for this slice."]),
|
|
643
|
+
...(result.libs.files.length
|
|
644
|
+
? [
|
|
645
|
+
`LIBRARY changes (${result.libs.files.length}) — the spoke edited shared code:`,
|
|
646
|
+
"",
|
|
647
|
+
...result.libs.files.map((file) => ` ${file}`),
|
|
648
|
+
"",
|
|
649
|
+
result.libs.patch,
|
|
650
|
+
]
|
|
651
|
+
: []),
|
|
652
|
+
...(result.app.files.length
|
|
653
|
+
? [
|
|
654
|
+
`App changes (${result.app.files.length}):`,
|
|
655
|
+
"",
|
|
656
|
+
...result.app.files.map((file) => ` ${file}`),
|
|
657
|
+
"",
|
|
658
|
+
result.app.patch,
|
|
659
|
+
]
|
|
660
|
+
: []),
|
|
661
|
+
];
|
|
662
|
+
return sections.join("\n");
|
|
663
|
+
}
|
package/libSource.ts
CHANGED
|
@@ -91,6 +91,18 @@ export class LibSource {
|
|
|
91
91
|
return stamp;
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
+
/**
|
|
95
|
+
* Writes the stamp only when it would change. `syncedAt` moves on every write, so an unconditional
|
|
96
|
+
* write leaves the manifest dirty and defeats an idempotent caller — the hash is computed with the
|
|
97
|
+
* stamp removed, so comparing it first is sound.
|
|
98
|
+
*/
|
|
99
|
+
async syncStamp({ origin, sha }: Pick<LibSourceStamp, "origin" | "sha">) {
|
|
100
|
+
const [current, hash] = await Promise.all([this.read(), this.computeHash()]);
|
|
101
|
+
if (current?.origin === origin && current.sha === sha && current.hash === hash)
|
|
102
|
+
return { stamp: current, changed: false };
|
|
103
|
+
return { stamp: await this.write({ origin, sha }), changed: true };
|
|
104
|
+
}
|
|
105
|
+
|
|
94
106
|
async status(): Promise<LibStatus> {
|
|
95
107
|
const [stamp, hash] = await Promise.all([this.read(), this.computeHash()]);
|
|
96
108
|
const drift = !stamp ? "unstamped" : stamp.hash === hash ? "clean" : "drifted";
|