@akanjs/cli 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/.build-stamp +1 -1
- package/agent.command-q2rgd8jz.js +1 -1
- package/{application.command-tfrqva07.js → application.command-kd5zj0k9.js} +4 -2
- package/{cloud.command-pjtg4azj.js → cloud.command-v60xnfdx.js} +6 -4
- package/commandManifest.json +1 -1
- package/{context.command-zx88k7e0.js → context.command-hac3x83c.js} +5 -5
- package/fleet.command-jv24wcae.js +793 -0
- package/index-19nz5chq.js +76 -0
- package/{index-7x5ks4ph.js → index-47zynjx7.js} +1 -1
- package/{index-ym4xqjhy.js → index-7605ykh6.js} +4 -68
- package/{index-hffr3myr.js → index-dz1q44m8.js} +1 -1
- package/index-jn9b717k.js +109 -0
- package/{index-e00k7eme.js → index-psx0z6hb.js} +2 -2
- package/{index-kpxg3p1v.js → index-pzsryyjv.js} +6 -6
- package/{index-4930mtpt.js → index-zp0cmfgd.js} +5 -105
- package/index.js +11 -10
- package/{library.command-sjnqhzn2.js → library.command-nvp0wetm.js} +2 -1
- package/{localRegistry.command-02dy0h6v.js → localRegistry.command-dnefn893.js} +5 -3
- package/{module.command-8vfy9pgk.js → module.command-xs6ded4j.js} +2 -2
- package/package.json +2 -2
- package/{primitive.command-63b8ytkf.js → primitive.command-qjf4mbth.js} +3 -3
- package/{scalar.command-8ampjwsr.js → scalar.command-ax8taqh1.js} +2 -2
- package/{workflow.command-jkfmkbzh.js → workflow.command-gwkw0jae.js} +4 -4
- package/{workspace.command-pg2qfpn7.js → workspace.command-53qpkgzp.js} +11 -9
- package/{index-ws5zt62x.js → index-pj3hk9kt.js} +3 -3
- package/{index-0cdwa7qj.js → index-ta1mk0xh.js} +3 -3
|
@@ -0,0 +1,793 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
import {
|
|
3
|
+
SlicePlanner
|
|
4
|
+
} from "./index-jn9b717k.js";
|
|
5
|
+
import {
|
|
6
|
+
LibSource
|
|
7
|
+
} from "./index-19nz5chq.js";
|
|
8
|
+
import {
|
|
9
|
+
Workspace,
|
|
10
|
+
command,
|
|
11
|
+
runner,
|
|
12
|
+
script
|
|
13
|
+
} from "./index-1szrbtj9.js";
|
|
14
|
+
import {
|
|
15
|
+
AppExecutor,
|
|
16
|
+
Executor,
|
|
17
|
+
LibExecutor
|
|
18
|
+
} from "./index-zkrzv21w.js";
|
|
19
|
+
import"./index-dp66vwr6.js";
|
|
20
|
+
import"./index-mxvakhsm.js";
|
|
21
|
+
import"./index-1577bej2.js";
|
|
22
|
+
import {
|
|
23
|
+
FileSys
|
|
24
|
+
} from "./index-67546d0j.js";
|
|
25
|
+
import"./index-yb2wsc7f.js";
|
|
26
|
+
|
|
27
|
+
// pkgs/@akanjs/devkit/fleetGuard.ts
|
|
28
|
+
import path from "path";
|
|
29
|
+
class FleetGuard {
|
|
30
|
+
static anchorFile = "akan.fleet.json";
|
|
31
|
+
static hubOwnedRootFiles = ["package.json", "tsconfig.json", "biome.json", "bunfig.toml"];
|
|
32
|
+
static spokeOwnedDirs = ["env"];
|
|
33
|
+
static contact = "Ask the akansoft team to make this change in the hub, then pull it back down.";
|
|
34
|
+
#workspace;
|
|
35
|
+
constructor(workspace) {
|
|
36
|
+
this.#workspace = workspace;
|
|
37
|
+
}
|
|
38
|
+
async#readAnchorFile() {
|
|
39
|
+
const anchorPath = path.join(this.#workspace.workspaceRoot, FleetGuard.anchorFile);
|
|
40
|
+
if (!await FileSys.fileExists(anchorPath))
|
|
41
|
+
return null;
|
|
42
|
+
return await FileSys.readJson(anchorPath);
|
|
43
|
+
}
|
|
44
|
+
async#anchorCommit() {
|
|
45
|
+
const commit = await this.#workspace.spawn("git", ["log", "-1", "--format=%H", "--", FleetGuard.anchorFile]);
|
|
46
|
+
return commit.trim() || null;
|
|
47
|
+
}
|
|
48
|
+
#isSpokeOwned(file) {
|
|
49
|
+
const segments = file.split("/");
|
|
50
|
+
if (segments[0] !== "apps" && segments[0] !== "libs")
|
|
51
|
+
return false;
|
|
52
|
+
return FleetGuard.spokeOwnedDirs.includes(segments[2] ?? "");
|
|
53
|
+
}
|
|
54
|
+
#violationOf(file) {
|
|
55
|
+
if (this.#isSpokeOwned(file))
|
|
56
|
+
return null;
|
|
57
|
+
if (file === FleetGuard.anchorFile)
|
|
58
|
+
return { path: file, reason: "the fleet anchor is written by a push" };
|
|
59
|
+
if (FleetGuard.hubOwnedRootFiles.includes(file))
|
|
60
|
+
return { path: file, reason: "workspace manifests are owned by the hub" };
|
|
61
|
+
if (file.startsWith("libs/"))
|
|
62
|
+
return { path: file, reason: "shared libraries are owned by the hub" };
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
async#changedFiles({ staged }) {
|
|
66
|
+
if (staged) {
|
|
67
|
+
const cached = await this.#workspace.spawn("git", ["diff", "--cached", "--name-only"]);
|
|
68
|
+
return cached.split(`
|
|
69
|
+
`).filter((file) => !!file.trim());
|
|
70
|
+
}
|
|
71
|
+
const anchor = await this.#anchorCommit();
|
|
72
|
+
const sinceAnchor = anchor ? (await this.#workspace.spawn("git", ["diff", "--name-only", `${anchor}..HEAD`])).split(`
|
|
73
|
+
`).filter((file) => !!file.trim()) : [];
|
|
74
|
+
const working = (await this.#workspace.spawn("git", ["status", "--porcelain"])).split(`
|
|
75
|
+
`).filter((line) => !!line.trim()).map((line) => line.slice(3).split(" -> ").at(-1) ?? "").filter((file) => !!file);
|
|
76
|
+
return [...new Set([...sinceAnchor, ...working])];
|
|
77
|
+
}
|
|
78
|
+
async#libDrift() {
|
|
79
|
+
const libNames = await this.#workspace.getLibs();
|
|
80
|
+
const statuses = await Promise.all(libNames.map(async (libName) => await new LibSource(LibExecutor.from(this.#workspace, libName)).status()));
|
|
81
|
+
return statuses.filter((status) => status.drift === "drifted").map((status) => ({ lib: status.lib, expected: status.stamp?.hash ?? "", actual: status.hash }));
|
|
82
|
+
}
|
|
83
|
+
async check({ staged = false } = {}) {
|
|
84
|
+
const spoke = await this.#readAnchorFile();
|
|
85
|
+
if (!spoke)
|
|
86
|
+
return { spoke: null, anchor: null, violations: [], driftedLibs: [] };
|
|
87
|
+
const [anchor, changed, driftedLibs] = await Promise.all([
|
|
88
|
+
this.#anchorCommit(),
|
|
89
|
+
this.#changedFiles({ staged }),
|
|
90
|
+
staged ? Promise.resolve([]) : this.#libDrift()
|
|
91
|
+
]);
|
|
92
|
+
const violations = changed.map((file) => this.#violationOf(file)).filter((violation) => !!violation);
|
|
93
|
+
return { spoke, anchor, violations, driftedLibs };
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
function formatFleetGuardReport(report, { warn = false } = {}) {
|
|
97
|
+
if (!report.spoke)
|
|
98
|
+
return "Akan Fleet Guard: not a fleet spoke (no akan.fleet.json) \u2014 nothing to check.";
|
|
99
|
+
const clean = !report.violations.length && !report.driftedLibs.length;
|
|
100
|
+
const sections = [
|
|
101
|
+
"Akan Fleet Guard",
|
|
102
|
+
`hub: ${report.spoke.hub}#${report.spoke.branch} apps: ${report.spoke.apps.join(", ")}`,
|
|
103
|
+
`last push: ${report.anchor?.slice(0, 12) ?? "(none)"}`,
|
|
104
|
+
"",
|
|
105
|
+
...clean ? [" no hub-owned file was changed here."] : [],
|
|
106
|
+
...report.violations.length ? [
|
|
107
|
+
`Hub-owned files changed in this repo (${report.violations.length}):`,
|
|
108
|
+
"",
|
|
109
|
+
...report.violations.map((violation) => ` ${violation.path} \u2014 ${violation.reason}`)
|
|
110
|
+
] : [],
|
|
111
|
+
...report.driftedLibs.length ? [
|
|
112
|
+
"",
|
|
113
|
+
`Libraries edited since the last push (${report.driftedLibs.length}):`,
|
|
114
|
+
"",
|
|
115
|
+
...report.driftedLibs.map((drift) => ` libs/${drift.lib} stamped ${drift.expected.slice(0, 12)}, now ${drift.actual.slice(0, 12)}`)
|
|
116
|
+
] : [],
|
|
117
|
+
...clean ? [] : ["", ` ${FleetGuard.contact}`, ...warn ? [" (warning only \u2014 this commit is not blocked)"] : []]
|
|
118
|
+
];
|
|
119
|
+
return sections.join(`
|
|
120
|
+
`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// pkgs/@akanjs/devkit/fleetSpoke.ts
|
|
124
|
+
import { mkdir, readdir, rename, rm } from "fs/promises";
|
|
125
|
+
import path2 from "path";
|
|
126
|
+
class FleetSpoke {
|
|
127
|
+
static anchorFile = "akan.fleet.json";
|
|
128
|
+
static hubOnlyEntries = ["akan.fleet.ts"];
|
|
129
|
+
static memberDirs = ["apps", "libs"];
|
|
130
|
+
static spokeOwnedDirs = ["env"];
|
|
131
|
+
static secretsMarkers = ["# akan:secrets (managed by akan.config.ts \u2014 do not edit)", "# akan:secrets:end"];
|
|
132
|
+
static holdDir = ".akan-fleet-hold";
|
|
133
|
+
static guardHookLine = "bunx akan fleet check --staged --warn";
|
|
134
|
+
static guardWorkflow = [
|
|
135
|
+
"name: akan fleet guard",
|
|
136
|
+
"on: [push, pull_request]",
|
|
137
|
+
"jobs:",
|
|
138
|
+
" guard:",
|
|
139
|
+
" runs-on: ubuntu-latest",
|
|
140
|
+
" steps:",
|
|
141
|
+
" - uses: actions/checkout@v4",
|
|
142
|
+
" with:",
|
|
143
|
+
" fetch-depth: 0",
|
|
144
|
+
" - uses: oven-sh/setup-bun@v2",
|
|
145
|
+
" - run: bun install",
|
|
146
|
+
" - run: bunx akan fleet check",
|
|
147
|
+
""
|
|
148
|
+
].join(`
|
|
149
|
+
`);
|
|
150
|
+
#workspace;
|
|
151
|
+
#config;
|
|
152
|
+
#declaration;
|
|
153
|
+
constructor(workspace, config, declaration) {
|
|
154
|
+
this.#workspace = workspace;
|
|
155
|
+
this.#config = config;
|
|
156
|
+
this.#declaration = declaration;
|
|
157
|
+
}
|
|
158
|
+
get name() {
|
|
159
|
+
return this.#declaration.name;
|
|
160
|
+
}
|
|
161
|
+
get apps() {
|
|
162
|
+
return this.#declaration.apps;
|
|
163
|
+
}
|
|
164
|
+
get #remote() {
|
|
165
|
+
return `spoke-${this.#declaration.name}`;
|
|
166
|
+
}
|
|
167
|
+
get #clonePath() {
|
|
168
|
+
return path2.join(this.#workspace.workspaceRoot, ".akan/fleet", this.#declaration.name);
|
|
169
|
+
}
|
|
170
|
+
async#git(args) {
|
|
171
|
+
return await this.#workspace.spawn("git", args);
|
|
172
|
+
}
|
|
173
|
+
async branch() {
|
|
174
|
+
return (await this.#git(["rev-parse", "--abbrev-ref", "HEAD"])).trim();
|
|
175
|
+
}
|
|
176
|
+
async headSha() {
|
|
177
|
+
return (await this.#git(["rev-parse", "--short", "HEAD"])).trim();
|
|
178
|
+
}
|
|
179
|
+
async fetch(branch) {
|
|
180
|
+
const remotes = (await this.#git(["remote"])).split(`
|
|
181
|
+
`).map((line) => line.trim());
|
|
182
|
+
if (remotes.includes(this.#remote))
|
|
183
|
+
await this.#git(["remote", "set-url", this.#remote, this.#declaration.repo]);
|
|
184
|
+
else
|
|
185
|
+
await this.#git(["remote", "add", this.#remote, this.#declaration.repo]);
|
|
186
|
+
try {
|
|
187
|
+
await this.#git(["fetch", "--quiet", this.#remote, branch]);
|
|
188
|
+
return true;
|
|
189
|
+
} catch {
|
|
190
|
+
return false;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
async anchor(branch) {
|
|
194
|
+
const ref = `${this.#remote}/${branch}`;
|
|
195
|
+
const commit = (await this.#git(["log", "-1", "--format=%H", ref, "--", FleetSpoke.anchorFile])).trim();
|
|
196
|
+
if (!commit)
|
|
197
|
+
return { commit: null, incoming: [] };
|
|
198
|
+
const log = await this.#git(["log", "--format=%H%x09%an%x09%s", `${commit}..${ref}`]);
|
|
199
|
+
const incoming = log.split(`
|
|
200
|
+
`).filter((line) => !!line.trim()).map((line) => {
|
|
201
|
+
const [sha = "", author = "", ...subject] = line.split("\t");
|
|
202
|
+
return { sha: sha.slice(0, 12), author, subject: subject.join("\t") };
|
|
203
|
+
});
|
|
204
|
+
return { commit, incoming };
|
|
205
|
+
}
|
|
206
|
+
async slice() {
|
|
207
|
+
const plans = await Promise.all(this.#declaration.apps.map(async (appName) => await new SlicePlanner(AppExecutor.from(this.#workspace, appName)).plan()));
|
|
208
|
+
const libs = [...new Set(plans.flatMap((plan) => plan.libs))].sort();
|
|
209
|
+
const paths = [...this.#declaration.apps.map((app) => `apps/${app}`), ...libs.map((lib) => `libs/${lib}`)];
|
|
210
|
+
return { plans, libs, paths };
|
|
211
|
+
}
|
|
212
|
+
#isSpokeOwned(file) {
|
|
213
|
+
const segments = file.split("/");
|
|
214
|
+
if (!FleetSpoke.memberDirs.includes(segments[0] ?? ""))
|
|
215
|
+
return false;
|
|
216
|
+
return FleetSpoke.spokeOwnedDirs.includes(segments[2] ?? "");
|
|
217
|
+
}
|
|
218
|
+
#isStamped(file) {
|
|
219
|
+
return /^libs\/[^/]+\/package\.json$/.test(file);
|
|
220
|
+
}
|
|
221
|
+
async#differsBeyondStamp(ref, file) {
|
|
222
|
+
const [theirs, ours] = await Promise.all([
|
|
223
|
+
this.#git(["show", `${ref}:${file}`]).catch(() => ""),
|
|
224
|
+
this.#git(["show", `HEAD:${file}`]).catch(() => "")
|
|
225
|
+
]);
|
|
226
|
+
if (!theirs || !ours)
|
|
227
|
+
return true;
|
|
228
|
+
const normalize = (content) => {
|
|
229
|
+
try {
|
|
230
|
+
const manifest = JSON.parse(content);
|
|
231
|
+
delete manifest[LibSource.manifestKey];
|
|
232
|
+
return JSON.stringify(manifest);
|
|
233
|
+
} catch {
|
|
234
|
+
return content;
|
|
235
|
+
}
|
|
236
|
+
};
|
|
237
|
+
return normalize(theirs) !== normalize(ours);
|
|
238
|
+
}
|
|
239
|
+
async#meaningfulFiles(ref, files) {
|
|
240
|
+
const kept = await Promise.all(files.map(async (file) => this.#isStamped(file) ? await this.#differsBeyondStamp(ref, file) : true));
|
|
241
|
+
return files.filter((_, index) => kept[index]);
|
|
242
|
+
}
|
|
243
|
+
async status(branch) {
|
|
244
|
+
const hasBranch = await this.fetch(branch);
|
|
245
|
+
const [{ paths, libs }, anchor] = await Promise.all([
|
|
246
|
+
this.slice(),
|
|
247
|
+
hasBranch ? this.anchor(branch) : Promise.resolve({ commit: null, incoming: [] })
|
|
248
|
+
]);
|
|
249
|
+
if (!hasBranch)
|
|
250
|
+
return { name: this.name, branch, hasBranch, anchor, behindPaths: [], driftedLibs: [] };
|
|
251
|
+
const ref = `${this.#remote}/${branch}`;
|
|
252
|
+
const diff = await this.#git(["diff", "--name-only", "HEAD", ref, "--", ...paths]);
|
|
253
|
+
const behindPaths = await this.#meaningfulFiles(ref, diff.split(`
|
|
254
|
+
`).filter((file) => !!file.trim()).filter((file) => !this.#isSpokeOwned(file)));
|
|
255
|
+
const driftedLibs = libs.filter((lib) => behindPaths.some((file) => file.startsWith(`libs/${lib}/`)));
|
|
256
|
+
return { name: this.name, branch, hasBranch, anchor, behindPaths, driftedLibs };
|
|
257
|
+
}
|
|
258
|
+
async#ensureClone(branch) {
|
|
259
|
+
if (!await FileSys.dirExists(path2.join(this.#clonePath, ".git"))) {
|
|
260
|
+
await rm(this.#clonePath, { recursive: true, force: true });
|
|
261
|
+
await mkdir(path2.dirname(this.#clonePath), { recursive: true });
|
|
262
|
+
await this.#workspace.spawn("git", ["clone", "--quiet", this.#declaration.repo, this.#clonePath]);
|
|
263
|
+
}
|
|
264
|
+
const clone = new Executor(`spoke-${this.name}`, this.#clonePath);
|
|
265
|
+
await clone.spawn("git", ["fetch", "--quiet", "origin", branch]);
|
|
266
|
+
await clone.spawn("git", ["checkout", "--quiet", "-B", branch, `origin/${branch}`]);
|
|
267
|
+
await clone.spawn("git", ["clean", "-qfd"]);
|
|
268
|
+
return clone;
|
|
269
|
+
}
|
|
270
|
+
async#holdSpokeOwned() {
|
|
271
|
+
const held = [];
|
|
272
|
+
const holdRoot = path2.join(this.#clonePath, FleetSpoke.holdDir);
|
|
273
|
+
await rm(holdRoot, { recursive: true, force: true });
|
|
274
|
+
for (const member of FleetSpoke.memberDirs) {
|
|
275
|
+
const memberRoot = path2.join(this.#clonePath, member);
|
|
276
|
+
if (!await FileSys.dirExists(memberRoot))
|
|
277
|
+
continue;
|
|
278
|
+
for (const name of await readdir(memberRoot)) {
|
|
279
|
+
for (const owned of FleetSpoke.spokeOwnedDirs) {
|
|
280
|
+
const from = path2.join(memberRoot, name, owned);
|
|
281
|
+
if (!await FileSys.dirExists(from))
|
|
282
|
+
continue;
|
|
283
|
+
const to = path2.join(holdRoot, member, name, owned);
|
|
284
|
+
await mkdir(path2.dirname(to), { recursive: true });
|
|
285
|
+
await rename(from, to);
|
|
286
|
+
held.push({ from, to });
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
return held;
|
|
291
|
+
}
|
|
292
|
+
async#restoreSpokeOwned(held) {
|
|
293
|
+
for (const { from, to } of held)
|
|
294
|
+
await FleetSpoke.#copyMissing(to, from);
|
|
295
|
+
await rm(path2.join(this.#clonePath, FleetSpoke.holdDir), { recursive: true, force: true });
|
|
296
|
+
}
|
|
297
|
+
static async#copyMissing(from, to) {
|
|
298
|
+
await mkdir(to, { recursive: true });
|
|
299
|
+
for (const entry of await readdir(from, { withFileTypes: true })) {
|
|
300
|
+
const source = path2.join(from, entry.name);
|
|
301
|
+
const target = path2.join(to, entry.name);
|
|
302
|
+
if (entry.isDirectory())
|
|
303
|
+
await FleetSpoke.#copyMissing(source, target);
|
|
304
|
+
else if (!await FileSys.entryExists(target))
|
|
305
|
+
await rename(source, target);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
async#extract(paths, excludes = []) {
|
|
309
|
+
const pathspec = paths.map((entry) => `'${entry}'`).join(" ");
|
|
310
|
+
const excludeArgs = excludes.map((entry) => `--exclude='${entry}'`).join(" ");
|
|
311
|
+
await this.#workspace.exec(`git archive HEAD -- ${pathspec} | tar -x -C '${this.#clonePath}' ${excludeArgs}`.trim());
|
|
312
|
+
}
|
|
313
|
+
async#applySlice(clone, branch) {
|
|
314
|
+
const { libs, paths } = await this.slice();
|
|
315
|
+
const held = await this.#holdSpokeOwned();
|
|
316
|
+
for (const member of FleetSpoke.memberDirs)
|
|
317
|
+
await rm(path2.join(this.#clonePath, member), { recursive: true, force: true });
|
|
318
|
+
await this.#extract(["."], ["apps/*", "libs/*", "pkgs/*"]);
|
|
319
|
+
await this.#extract(paths);
|
|
320
|
+
await this.#restoreSpokeOwned(held);
|
|
321
|
+
for (const entry of [...FleetSpoke.hubOnlyEntries, ...this.#config.exclude])
|
|
322
|
+
await rm(path2.join(this.#clonePath, entry), { recursive: true, force: true });
|
|
323
|
+
await this.#rewriteGitignore();
|
|
324
|
+
await this.#rewriteWorkspaceSection(libs);
|
|
325
|
+
await this.#writeGuards();
|
|
326
|
+
for (const lib of libs)
|
|
327
|
+
await this.#stampLib(clone, lib, branch);
|
|
328
|
+
return { libs };
|
|
329
|
+
}
|
|
330
|
+
async#rewriteGitignore() {
|
|
331
|
+
const gitignorePath = path2.join(this.#clonePath, ".gitignore");
|
|
332
|
+
if (!await FileSys.fileExists(gitignorePath))
|
|
333
|
+
return;
|
|
334
|
+
const [begin = "", end = ""] = FleetSpoke.secretsMarkers;
|
|
335
|
+
const lines = (await FileSys.readText(gitignorePath)).split(`
|
|
336
|
+
`);
|
|
337
|
+
const beginIdx = lines.indexOf(begin);
|
|
338
|
+
const endIdx = lines.indexOf(end);
|
|
339
|
+
const filtered = beginIdx >= 0 && endIdx > beginIdx ? [
|
|
340
|
+
...lines.slice(0, beginIdx + 1),
|
|
341
|
+
...lines.slice(beginIdx + 1, endIdx).filter((line) => this.#declaration.apps.some((app) => line.startsWith(`apps/${app}/`))),
|
|
342
|
+
...lines.slice(endIdx)
|
|
343
|
+
] : lines;
|
|
344
|
+
await FileSys.writeText(gitignorePath, filtered.filter((line) => line.trim() !== "**/bun.lock").join(`
|
|
345
|
+
`));
|
|
346
|
+
}
|
|
347
|
+
async#rewriteWorkspaceSection(libs) {
|
|
348
|
+
const replacements = [
|
|
349
|
+
[/^- Repo: .*$/m, `- Repo: ${this.name}`],
|
|
350
|
+
[/^- Apps: .*$/m, `- Apps: ${this.#declaration.apps.join(", ")}`],
|
|
351
|
+
[/^- Libraries: .*$/m, `- Libraries: ${libs.length ? libs.join(", ") : "(none)"}`]
|
|
352
|
+
];
|
|
353
|
+
for (const file of ["AGENTS.md", "CLAUDE.md"]) {
|
|
354
|
+
const filePath = path2.join(this.#clonePath, file);
|
|
355
|
+
if (!await FileSys.fileExists(filePath))
|
|
356
|
+
continue;
|
|
357
|
+
const content = await FileSys.readText(filePath);
|
|
358
|
+
const rewritten = replacements.reduce((text, [pattern, value]) => text.replace(pattern, value), content);
|
|
359
|
+
if (rewritten !== content)
|
|
360
|
+
await FileSys.writeText(filePath, rewritten);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
async#stampLib(clone, lib, branch) {
|
|
364
|
+
const manifestPath = path2.join(this.#clonePath, "libs", lib, "package.json");
|
|
365
|
+
if (!await FileSys.fileExists(manifestPath))
|
|
366
|
+
return;
|
|
367
|
+
const origin = `${this.#workspace.repoName}#${branch}`;
|
|
368
|
+
const sha = await this.headSha();
|
|
369
|
+
const [hash, previous] = await Promise.all([this.#hashLib(clone, lib), this.#committedStamp(clone, lib)]);
|
|
370
|
+
const manifest = await FileSys.readJson(manifestPath);
|
|
371
|
+
const akan = manifest[LibSource.manifestKey] ?? {};
|
|
372
|
+
const unchanged = previous?.origin === origin && previous.sha === sha && previous.hash === hash;
|
|
373
|
+
const syncedAt = unchanged ? previous.syncedAt : new Date().toISOString();
|
|
374
|
+
manifest[LibSource.manifestKey] = { ...akan, source: { origin, sha, hash, syncedAt } };
|
|
375
|
+
await FileSys.writeJson(manifestPath, manifest);
|
|
376
|
+
}
|
|
377
|
+
async#committedStamp(clone, lib) {
|
|
378
|
+
try {
|
|
379
|
+
const committed = await clone.spawn("git", ["show", `HEAD:libs/${lib}/package.json`]);
|
|
380
|
+
const manifest = JSON.parse(committed);
|
|
381
|
+
const akan = manifest[LibSource.manifestKey] ?? {};
|
|
382
|
+
return akan.source;
|
|
383
|
+
} catch {
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
async#hashLib(clone, lib) {
|
|
388
|
+
const listed = await clone.spawn("git", [
|
|
389
|
+
"ls-files",
|
|
390
|
+
"-z",
|
|
391
|
+
"--cached",
|
|
392
|
+
"--others",
|
|
393
|
+
"--exclude-standard",
|
|
394
|
+
"--",
|
|
395
|
+
`libs/${lib}`
|
|
396
|
+
]);
|
|
397
|
+
const files = listed.split("\x00").filter((file) => !!file && !this.#isSpokeOwned(file)).sort();
|
|
398
|
+
const hasher = new Bun.CryptoHasher("sha256");
|
|
399
|
+
for (const file of files) {
|
|
400
|
+
const content = await FileSys.readText(path2.join(this.#clonePath, file));
|
|
401
|
+
hasher.update(file);
|
|
402
|
+
hasher.update("\x00");
|
|
403
|
+
if (file === `libs/${lib}/package.json`) {
|
|
404
|
+
const manifest = JSON.parse(content);
|
|
405
|
+
delete manifest[LibSource.manifestKey];
|
|
406
|
+
hasher.update(JSON.stringify(manifest));
|
|
407
|
+
} else
|
|
408
|
+
hasher.update(content);
|
|
409
|
+
hasher.update("\x00");
|
|
410
|
+
}
|
|
411
|
+
return hasher.digest("hex").slice(0, 32);
|
|
412
|
+
}
|
|
413
|
+
async diff(branch, filter) {
|
|
414
|
+
if (!await this.fetch(branch))
|
|
415
|
+
throw new Error(`Spoke "${this.name}" has no branch "${branch}"`);
|
|
416
|
+
const { libs, paths } = await this.slice();
|
|
417
|
+
const libPaths = libs.map((lib) => `libs/${lib}`);
|
|
418
|
+
const appPaths = paths.filter((entry) => !libPaths.includes(entry));
|
|
419
|
+
const [app, libSection] = await Promise.all([
|
|
420
|
+
this.#diffSection(branch, appPaths, filter),
|
|
421
|
+
this.#diffSection(branch, libPaths, filter)
|
|
422
|
+
]);
|
|
423
|
+
return { name: this.name, branch, app, libs: libSection };
|
|
424
|
+
}
|
|
425
|
+
async#diffSection(branch, paths, filter) {
|
|
426
|
+
const scoped = filter ? paths.filter((entry) => entry.startsWith(filter) || filter.startsWith(entry)) : paths;
|
|
427
|
+
if (!scoped.length)
|
|
428
|
+
return { files: [], patch: "" };
|
|
429
|
+
const from = `${this.#remote}/${branch}`;
|
|
430
|
+
const pathspec = filter ? [filter] : scoped;
|
|
431
|
+
const names = await this.#git(["diff", "--name-only", from, "HEAD", "--", ...pathspec]);
|
|
432
|
+
const files = await this.#meaningfulFiles(from, names.split(`
|
|
433
|
+
`).filter((file) => !!file.trim()).filter((file) => !this.#isSpokeOwned(file)));
|
|
434
|
+
if (!files.length)
|
|
435
|
+
return { files: [], patch: "" };
|
|
436
|
+
return { files, patch: await this.#git(["diff", from, "HEAD", "--", ...files]) };
|
|
437
|
+
}
|
|
438
|
+
async#writeGuards() {
|
|
439
|
+
const { ci, preCommit } = this.#config.guard;
|
|
440
|
+
if (ci === "github") {
|
|
441
|
+
const workflowPath = path2.join(this.#clonePath, ".github/workflows/akan-fleet-guard.yml");
|
|
442
|
+
await mkdir(path2.dirname(workflowPath), { recursive: true });
|
|
443
|
+
await FileSys.writeText(workflowPath, FleetSpoke.guardWorkflow);
|
|
444
|
+
}
|
|
445
|
+
if (!preCommit)
|
|
446
|
+
return;
|
|
447
|
+
const hookPath = path2.join(this.#clonePath, ".husky/pre-commit");
|
|
448
|
+
const existing = await FileSys.fileExists(hookPath) ? await FileSys.readText(hookPath) : "";
|
|
449
|
+
if (existing.includes(FleetSpoke.guardHookLine))
|
|
450
|
+
return;
|
|
451
|
+
await mkdir(path2.dirname(hookPath), { recursive: true });
|
|
452
|
+
const content = existing.trim() ? `${existing.replace(/\n*$/, `
|
|
453
|
+
`)}${FleetSpoke.guardHookLine}
|
|
454
|
+
` : `${FleetSpoke.guardHookLine}
|
|
455
|
+
`;
|
|
456
|
+
await FileSys.writeText(hookPath, content);
|
|
457
|
+
}
|
|
458
|
+
async push(branch, { verify = true } = {}) {
|
|
459
|
+
this.#config.assertPushable(branch);
|
|
460
|
+
if (await this.#workspace.hasChanges())
|
|
461
|
+
return { name: this.name, outcome: "refused", reason: "hub working tree is dirty", changedFiles: 0 };
|
|
462
|
+
if (!await this.fetch(branch))
|
|
463
|
+
return { name: this.name, outcome: "refused", reason: `spoke has no branch "${branch}"`, changedFiles: 0 };
|
|
464
|
+
const clone = await this.#ensureClone(branch);
|
|
465
|
+
await this.#applySlice(clone, branch);
|
|
466
|
+
const dirty = (await clone.spawn("git", ["status", "--porcelain"])).trim();
|
|
467
|
+
if (!dirty)
|
|
468
|
+
return { name: this.name, outcome: "skipped", reason: "already up to date", changedFiles: 0 };
|
|
469
|
+
await this.#writeAnchorFile(branch);
|
|
470
|
+
if (verify) {
|
|
471
|
+
await clone.spawn("bun", ["install"]);
|
|
472
|
+
for (const app of this.#declaration.apps)
|
|
473
|
+
await clone.spawn("bunx", ["akan", "sync", app]);
|
|
474
|
+
}
|
|
475
|
+
await clone.spawn("git", ["add", "-A"]);
|
|
476
|
+
const message = `chore(fleet): sync from ${this.#workspace.repoName}@${await this.headSha()}`;
|
|
477
|
+
await clone.spawn("git", ["commit", "--quiet", "-m", message]);
|
|
478
|
+
await clone.spawn("git", ["push", "--quiet", "origin", branch]);
|
|
479
|
+
return {
|
|
480
|
+
name: this.name,
|
|
481
|
+
outcome: "pushed",
|
|
482
|
+
commit: (await clone.spawn("git", ["rev-parse", "--short", "HEAD"])).trim(),
|
|
483
|
+
changedFiles: dirty.split(`
|
|
484
|
+
`).length
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
async#writeAnchorFile(branch) {
|
|
488
|
+
await FileSys.writeJson(path2.join(this.#clonePath, FleetSpoke.anchorFile), {
|
|
489
|
+
hub: this.#workspace.repoName,
|
|
490
|
+
hubSha: await this.headSha(),
|
|
491
|
+
branch,
|
|
492
|
+
apps: this.#declaration.apps,
|
|
493
|
+
syncedAt: new Date().toISOString()
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
async pull(branch, { adoptLibs = false } = {}) {
|
|
497
|
+
if (!await this.fetch(branch))
|
|
498
|
+
throw new Error(`Spoke "${this.name}" has no branch "${branch}"`);
|
|
499
|
+
const anchor = await this.anchor(branch);
|
|
500
|
+
if (!anchor.commit)
|
|
501
|
+
throw new Error(`Spoke "${this.name}" carries no ${FleetSpoke.anchorFile} \u2014 it has never received a push`);
|
|
502
|
+
if (!anchor.incoming.length)
|
|
503
|
+
return { name: this.name, applied: [], libPatch: null, ignored: [], incoming: [] };
|
|
504
|
+
const range = `${anchor.commit}..${this.#remote}/${branch}`;
|
|
505
|
+
const changed = (await this.#git(["diff", "--name-only", range])).split(`
|
|
506
|
+
`).filter((file) => !!file.trim());
|
|
507
|
+
const appPrefixes = this.#declaration.apps.map((app) => `apps/${app}/`);
|
|
508
|
+
const appFiles = changed.filter((file) => appPrefixes.some((prefix) => file.startsWith(prefix)) && !this.#isSpokeOwned(file));
|
|
509
|
+
const libFiles = changed.filter((file) => file.startsWith("libs/") && !this.#isSpokeOwned(file));
|
|
510
|
+
const ignored = changed.filter((file) => !appFiles.includes(file) && !libFiles.includes(file));
|
|
511
|
+
const applied = adoptLibs ? [...appFiles, ...libFiles] : appFiles;
|
|
512
|
+
if (applied.length)
|
|
513
|
+
await this.#applyPatch(range, applied, "incoming");
|
|
514
|
+
const saved = !adoptLibs && libFiles.length ? await this.#savePatch(range, libFiles, "libs") : null;
|
|
515
|
+
const libPatch = saved ? { path: saved.path, files: saved.files } : null;
|
|
516
|
+
return { name: this.name, applied, libPatch, ignored, incoming: anchor.incoming };
|
|
517
|
+
}
|
|
518
|
+
async#applyPatch(range, files, label) {
|
|
519
|
+
const { absolute } = await this.#savePatch(range, files, label);
|
|
520
|
+
await this.#git(["apply", "--3way", absolute]);
|
|
521
|
+
}
|
|
522
|
+
async#savePatch(range, files, label) {
|
|
523
|
+
const patchPath = path2.join(this.#workspace.workspaceRoot, ".akan/fleet", `${this.name}-${label}.patch`);
|
|
524
|
+
await mkdir(path2.dirname(patchPath), { recursive: true });
|
|
525
|
+
await FileSys.writeText(patchPath, await this.#git(["diff", range, "--", ...files]));
|
|
526
|
+
return { path: path2.relative(this.#workspace.workspaceRoot, patchPath), absolute: patchPath, files };
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
function formatFleetStatuses(statuses) {
|
|
530
|
+
const sections = [
|
|
531
|
+
"Akan Fleet Status",
|
|
532
|
+
`branch: ${statuses[0]?.branch ?? "(none)"}`,
|
|
533
|
+
"",
|
|
534
|
+
...statuses.flatMap((status) => {
|
|
535
|
+
if (!status.hasBranch)
|
|
536
|
+
return [` ${status.name}: no such branch in the spoke \u2014 push is refused`];
|
|
537
|
+
const behind = status.behindPaths.length ? `${status.behindPaths.length} file(s) behind` : "up to date";
|
|
538
|
+
const incoming = status.anchor.incoming.length ? `${status.anchor.incoming.length} customer commit(s) to pull` : "no customer commits";
|
|
539
|
+
const drift = status.driftedLibs.length ? ` DRIFTED LIBS: ${status.driftedLibs.join(", ")}` : null;
|
|
540
|
+
return [
|
|
541
|
+
` ${status.name}: ${behind}, ${incoming}`,
|
|
542
|
+
...status.anchor.incoming.map((commit) => ` ${commit.sha} ${commit.author} ${commit.subject}`),
|
|
543
|
+
...drift ? [drift] : []
|
|
544
|
+
];
|
|
545
|
+
})
|
|
546
|
+
];
|
|
547
|
+
return sections.join(`
|
|
548
|
+
`);
|
|
549
|
+
}
|
|
550
|
+
function formatFleetPushResults(results) {
|
|
551
|
+
const sections = [
|
|
552
|
+
"Akan Fleet Push",
|
|
553
|
+
"",
|
|
554
|
+
...results.map((result) => {
|
|
555
|
+
const detail = result.outcome === "pushed" ? `${result.commit} (${result.changedFiles} files)` : result.reason ?? "";
|
|
556
|
+
return ` ${result.outcome.padEnd(8)} ${result.name} ${detail}`;
|
|
557
|
+
})
|
|
558
|
+
];
|
|
559
|
+
return sections.join(`
|
|
560
|
+
`);
|
|
561
|
+
}
|
|
562
|
+
function formatFleetPullResult(result) {
|
|
563
|
+
const sections = [
|
|
564
|
+
`Akan Fleet Pull \u2014 ${result.name}`,
|
|
565
|
+
"",
|
|
566
|
+
`Customer commits since the last push (${result.incoming.length}):`,
|
|
567
|
+
"",
|
|
568
|
+
...result.incoming.length ? result.incoming.map((commit) => ` ${commit.sha} ${commit.author} ${commit.subject}`) : [" (none \u2014 nothing to pull)"],
|
|
569
|
+
"",
|
|
570
|
+
`Applied to the working tree, uncommitted (${result.applied.length}):`,
|
|
571
|
+
"",
|
|
572
|
+
...result.applied.length ? result.applied.map((file) => ` ${file}`) : [" (none)"],
|
|
573
|
+
...result.libPatch ? [
|
|
574
|
+
"",
|
|
575
|
+
`Library edits NOT applied (${result.libPatch.files.length}) \u2014 review before adopting:`,
|
|
576
|
+
"",
|
|
577
|
+
...result.libPatch.files.map((file) => ` ${file}`),
|
|
578
|
+
"",
|
|
579
|
+
` patch: ${result.libPatch.path}`,
|
|
580
|
+
" adopt with: akan fleet pull <name> --adopt-libs"
|
|
581
|
+
] : [],
|
|
582
|
+
...result.ignored.length ? ["", `Ignored (hub-owned or spoke-owned): ${result.ignored.length} file(s)`] : []
|
|
583
|
+
];
|
|
584
|
+
return sections.join(`
|
|
585
|
+
`);
|
|
586
|
+
}
|
|
587
|
+
function formatFleetDiff(result) {
|
|
588
|
+
const total = result.app.files.length + result.libs.files.length;
|
|
589
|
+
const sections = [
|
|
590
|
+
`Akan Fleet Diff \u2014 ${result.name} (${result.branch})`,
|
|
591
|
+
"what `akan fleet push` would change in the spoke",
|
|
592
|
+
"",
|
|
593
|
+
...total ? [] : [" spoke is identical to the hub for this slice."],
|
|
594
|
+
...result.libs.files.length ? [
|
|
595
|
+
`LIBRARY changes (${result.libs.files.length}) \u2014 the spoke edited shared code:`,
|
|
596
|
+
"",
|
|
597
|
+
...result.libs.files.map((file) => ` ${file}`),
|
|
598
|
+
"",
|
|
599
|
+
result.libs.patch
|
|
600
|
+
] : [],
|
|
601
|
+
...result.app.files.length ? [
|
|
602
|
+
`App changes (${result.app.files.length}):`,
|
|
603
|
+
"",
|
|
604
|
+
...result.app.files.map((file) => ` ${file}`),
|
|
605
|
+
"",
|
|
606
|
+
result.app.patch
|
|
607
|
+
] : []
|
|
608
|
+
];
|
|
609
|
+
return sections.join(`
|
|
610
|
+
`);
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
// pkgs/@akanjs/cli/fleet/fleet.script.ts
|
|
614
|
+
import { Logger } from "akanjs/common";
|
|
615
|
+
|
|
616
|
+
// pkgs/@akanjs/devkit/fleetConfig.ts
|
|
617
|
+
import path3 from "path";
|
|
618
|
+
class FleetConfig {
|
|
619
|
+
static fileName = "akan.fleet.ts";
|
|
620
|
+
static defaultPushableBranches = ["main", "develop", "debug"];
|
|
621
|
+
static defaultGuard = { ci: "github", preCommit: true };
|
|
622
|
+
static async from(workspace) {
|
|
623
|
+
const configPath = path3.join(workspace.workspaceRoot, FleetConfig.fileName);
|
|
624
|
+
if (!await FileSys.fileExists(configPath))
|
|
625
|
+
return null;
|
|
626
|
+
const input = await import(configPath).then((mod) => mod.default);
|
|
627
|
+
return new FleetConfig(input);
|
|
628
|
+
}
|
|
629
|
+
pushableBranches;
|
|
630
|
+
exclude;
|
|
631
|
+
guard;
|
|
632
|
+
spokes;
|
|
633
|
+
constructor(input) {
|
|
634
|
+
this.pushableBranches = input.pushableBranches ?? FleetConfig.defaultPushableBranches;
|
|
635
|
+
this.exclude = input.exclude ?? [];
|
|
636
|
+
this.guard = { ...FleetConfig.defaultGuard, ...input.guard };
|
|
637
|
+
this.spokes = input.spokes ?? [];
|
|
638
|
+
this.#assertValid();
|
|
639
|
+
}
|
|
640
|
+
#assertValid() {
|
|
641
|
+
const seenNames = new Set;
|
|
642
|
+
const appOwners = new Map;
|
|
643
|
+
for (const spoke of this.spokes) {
|
|
644
|
+
if (!spoke.name || !spoke.repo)
|
|
645
|
+
throw new Error(`${FleetConfig.fileName}: every spoke needs a name and a repo`);
|
|
646
|
+
if (seenNames.has(spoke.name))
|
|
647
|
+
throw new Error(`${FleetConfig.fileName}: duplicate spoke "${spoke.name}"`);
|
|
648
|
+
seenNames.add(spoke.name);
|
|
649
|
+
if (!spoke.apps?.length)
|
|
650
|
+
throw new Error(`${FleetConfig.fileName}: spoke "${spoke.name}" declares no apps`);
|
|
651
|
+
for (const app of spoke.apps) {
|
|
652
|
+
const owner = appOwners.get(app);
|
|
653
|
+
if (owner)
|
|
654
|
+
throw new Error(`${FleetConfig.fileName}: app "${app}" is claimed by both "${owner}" and "${spoke.name}"`);
|
|
655
|
+
appOwners.set(app, spoke.name);
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
select(names) {
|
|
660
|
+
if (!names.length)
|
|
661
|
+
return this.spokes;
|
|
662
|
+
return names.map((name) => {
|
|
663
|
+
const spoke = this.spokes.find((candidate) => candidate.name === name);
|
|
664
|
+
if (!spoke)
|
|
665
|
+
throw new Error(`${FleetConfig.fileName}: unknown spoke "${name}"`);
|
|
666
|
+
return spoke;
|
|
667
|
+
});
|
|
668
|
+
}
|
|
669
|
+
assertPushable(branch) {
|
|
670
|
+
if (this.pushableBranches.includes(branch))
|
|
671
|
+
return;
|
|
672
|
+
throw new Error(`Branch "${branch}" is not pushable. ${FleetConfig.fileName} allows: ${this.pushableBranches.join(", ")}`);
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// pkgs/@akanjs/cli/fleet/fleet.runner.ts
|
|
677
|
+
class FleetRunner extends runner("fleet") {
|
|
678
|
+
async spokes(workspace, names) {
|
|
679
|
+
const config = await FleetConfig.from(workspace);
|
|
680
|
+
if (!config)
|
|
681
|
+
throw new Error(`No ${FleetConfig.fileName} at the workspace root. Declare the customer repos there first.`);
|
|
682
|
+
const spokes = config.select(names).map((declaration) => new FleetSpoke(workspace, config, declaration));
|
|
683
|
+
const branch = (await workspace.spawn("git", ["rev-parse", "--abbrev-ref", "HEAD"])).trim();
|
|
684
|
+
return { config, spokes, branch };
|
|
685
|
+
}
|
|
686
|
+
async status(workspace, names) {
|
|
687
|
+
const { spokes, branch } = await this.spokes(workspace, names);
|
|
688
|
+
const statuses = [];
|
|
689
|
+
for (const spoke of spokes)
|
|
690
|
+
statuses.push(await spoke.status(branch));
|
|
691
|
+
return statuses;
|
|
692
|
+
}
|
|
693
|
+
async push(workspace, names, { verify } = {}) {
|
|
694
|
+
const { config, spokes, branch } = await this.spokes(workspace, names);
|
|
695
|
+
config.assertPushable(branch);
|
|
696
|
+
const results = [];
|
|
697
|
+
for (const spoke of spokes)
|
|
698
|
+
results.push(await spoke.push(branch, { verify }));
|
|
699
|
+
return results;
|
|
700
|
+
}
|
|
701
|
+
async diff(workspace, name, filter) {
|
|
702
|
+
const { spokes, branch } = await this.spokes(workspace, [name]);
|
|
703
|
+
const [spoke] = spokes;
|
|
704
|
+
if (!spoke)
|
|
705
|
+
throw new Error(`Unknown spoke "${name}"`);
|
|
706
|
+
return await spoke.diff(branch, filter);
|
|
707
|
+
}
|
|
708
|
+
async check(workspace, { staged } = {}) {
|
|
709
|
+
return await new FleetGuard(workspace).check({ staged });
|
|
710
|
+
}
|
|
711
|
+
async pull(workspace, name, { adoptLibs } = {}) {
|
|
712
|
+
const { spokes, branch } = await this.spokes(workspace, [name]);
|
|
713
|
+
const [spoke] = spokes;
|
|
714
|
+
if (!spoke)
|
|
715
|
+
throw new Error(`Unknown spoke "${name}"`);
|
|
716
|
+
return await spoke.pull(branch, { adoptLibs });
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// pkgs/@akanjs/cli/fleet/fleet.script.ts
|
|
721
|
+
class FleetScript extends script("fleet", [FleetRunner]) {
|
|
722
|
+
async status(workspace, names, format = "text") {
|
|
723
|
+
const spinner = workspace.spinning("Checking spokes...");
|
|
724
|
+
const statuses = await this.fleetRunner.status(workspace, names);
|
|
725
|
+
const drifted = statuses.filter((status) => status.driftedLibs.length).length;
|
|
726
|
+
spinner.succeed(`Checked ${statuses.length} spoke(s) (${drifted} with library drift)`);
|
|
727
|
+
Logger.rawLog(format === "json" ? JSON.stringify(statuses, null, 2) : formatFleetStatuses(statuses));
|
|
728
|
+
}
|
|
729
|
+
async push(workspace, names, { verify, format }) {
|
|
730
|
+
const spinner = workspace.spinning("Pushing to spokes...");
|
|
731
|
+
const results = await this.fleetRunner.push(workspace, names, { verify });
|
|
732
|
+
const pushed = results.filter((result) => result.outcome === "pushed").length;
|
|
733
|
+
spinner.succeed(`Pushed ${pushed} of ${results.length} spoke(s)`);
|
|
734
|
+
Logger.rawLog(format === "json" ? JSON.stringify(results, null, 2) : formatFleetPushResults(results));
|
|
735
|
+
if (results.some((result) => result.outcome === "refused"))
|
|
736
|
+
process.exitCode = 1;
|
|
737
|
+
}
|
|
738
|
+
async diff(workspace, name, filter, format = "text") {
|
|
739
|
+
const spinner = workspace.spinning(`Diffing ${name}...`);
|
|
740
|
+
const result = await this.fleetRunner.diff(workspace, name, filter);
|
|
741
|
+
const total = result.app.files.length + result.libs.files.length;
|
|
742
|
+
spinner.succeed(`${total} file(s) differ (${result.libs.files.length} in shared libraries)`);
|
|
743
|
+
Logger.rawLog(format === "json" ? JSON.stringify(result, null, 2) : formatFleetDiff(result));
|
|
744
|
+
}
|
|
745
|
+
async check(workspace, { staged, warn, format }) {
|
|
746
|
+
const report = await this.fleetRunner.check(workspace, { staged });
|
|
747
|
+
Logger.rawLog(format === "json" ? JSON.stringify(report, null, 2) : formatFleetGuardReport(report, { warn }));
|
|
748
|
+
const failed = !!report.violations.length || !!report.driftedLibs.length;
|
|
749
|
+
if (failed && !warn)
|
|
750
|
+
process.exitCode = 1;
|
|
751
|
+
}
|
|
752
|
+
async pull(workspace, name, { adoptLibs, format }) {
|
|
753
|
+
const spinner = workspace.spinning(`Pulling customer commits from ${name}...`);
|
|
754
|
+
const result = await this.fleetRunner.pull(workspace, name, { adoptLibs });
|
|
755
|
+
spinner.succeed(`${result.incoming.length} customer commit(s), ${result.applied.length} file(s) applied`);
|
|
756
|
+
Logger.rawLog(format === "json" ? JSON.stringify(result, null, 2) : formatFleetPullResult(result));
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
// pkgs/@akanjs/cli/fleet/fleet.command.ts
|
|
761
|
+
class FleetCommand extends command("fleet", [FleetScript], ({ public: target }) => ({
|
|
762
|
+
fleet: target({ desc: "Mirror apps and libraries between this hub workspace and the customer repos it serves" }).arg("action", String, {
|
|
763
|
+
desc: "fleet action",
|
|
764
|
+
default: "status",
|
|
765
|
+
enum: ["status", "diff", "push", "pull", "check"]
|
|
766
|
+
}).arg("spoke", String, { desc: "spoke name; every spoke when omitted (required for pull)", nullable: true }).with(Workspace).option("format", String, { desc: "output format", default: "text", enum: ["text", "json"] }).option("verify", Boolean, {
|
|
767
|
+
flag: "y",
|
|
768
|
+
desc: "run bun install + akan sync in the spoke before committing",
|
|
769
|
+
default: true
|
|
770
|
+
}).option("adoptLibs", Boolean, { desc: "pull: also apply the spoke's library edits", default: false }).option("path", String, { desc: "diff: limit to one path", nullable: true }).option("staged", Boolean, { desc: "check: only look at what is staged", default: false }).option("warn", Boolean, { desc: "check: report without failing", default: false }).exec(async function(action, spoke, workspace, format, verify, adoptLibs, filePath, staged, warn) {
|
|
771
|
+
const names = spoke ? [spoke] : [];
|
|
772
|
+
const output = format;
|
|
773
|
+
if (action === "status")
|
|
774
|
+
await this.fleetScript.status(workspace, names, output);
|
|
775
|
+
else if (action === "check")
|
|
776
|
+
await this.fleetScript.check(workspace, { staged, warn, format: output });
|
|
777
|
+
else if (action === "push")
|
|
778
|
+
await this.fleetScript.push(workspace, names, { verify, format: output });
|
|
779
|
+
else if (action === "diff" || action === "pull") {
|
|
780
|
+
if (!spoke)
|
|
781
|
+
throw new Error(`\`akan fleet ${action}\` needs a spoke name \u2014 it is reviewed one repo at a time.`);
|
|
782
|
+
if (action === "diff")
|
|
783
|
+
await this.fleetScript.diff(workspace, spoke, filePath, output);
|
|
784
|
+
else
|
|
785
|
+
await this.fleetScript.pull(workspace, spoke, { adoptLibs, format: output });
|
|
786
|
+
} else
|
|
787
|
+
throw new Error(`Unknown fleet action: ${action}. Use status, diff, push, pull or check.`);
|
|
788
|
+
})
|
|
789
|
+
})) {
|
|
790
|
+
}
|
|
791
|
+
export {
|
|
792
|
+
FleetCommand
|
|
793
|
+
};
|