@akanjs/devkit 3.0.0-alpha.79 → 3.0.0-alpha.80
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/fleetSpoke.test.ts +26 -1
- package/fleetSpoke.ts +63 -7
- package/package.json +2 -2
package/fleetSpoke.test.ts
CHANGED
|
@@ -5,7 +5,7 @@ import path from "node:path";
|
|
|
5
5
|
import { Executor, WorkspaceExecutor } from "./executors";
|
|
6
6
|
import { FileSys } from "./fileSys";
|
|
7
7
|
import { FleetConfig } from "./fleetConfig";
|
|
8
|
-
import { FleetSpoke, formatFleetDiff, formatFleetStatuses } from "./fleetSpoke";
|
|
8
|
+
import { FleetSpoke, formatFleetDiff, formatFleetPushResults, formatFleetStatuses } from "./fleetSpoke";
|
|
9
9
|
|
|
10
10
|
const tempRoots: string[] = [];
|
|
11
11
|
const originalEnv = { ...process.env };
|
|
@@ -203,6 +203,31 @@ describe("FleetSpoke push", () => {
|
|
|
203
203
|
const hook = await FileSys.readText(path.join(spokeRoot, ".husky/pre-commit"));
|
|
204
204
|
expect(hook.trim()).toBe(FleetSpoke.guardHookLine);
|
|
205
205
|
});
|
|
206
|
+
|
|
207
|
+
//? The fixture hub deliberately does not gitignore `.env`, so this also proves the clone-local exclude.
|
|
208
|
+
test("leaves the clone a runnable workspace root without shipping the file that makes it one", async () => {
|
|
209
|
+
const { bareRoot, workRoot, spoke, clonePath } = await makeFleet("env-served", "env-private", "env-kit");
|
|
210
|
+
await spoke.push("develop", { verify: false });
|
|
211
|
+
|
|
212
|
+
const localEnv = await FileSys.readText(path.join(clonePath, ".env"));
|
|
213
|
+
expect(localEnv).toContain("AKAN_PUBLIC_REPO_NAME=acme");
|
|
214
|
+
expect(localEnv).toContain("AKAN_PUBLIC_SERVE_DOMAIN=example.com");
|
|
215
|
+
expect(localEnv).not.toContain("hub");
|
|
216
|
+
expect(await FileSys.readText(path.join(clonePath, ".git/info/exclude"))).toContain("/.env");
|
|
217
|
+
|
|
218
|
+
const spokeRoot = await cloneSpoke(workRoot, bareRoot, "env-check");
|
|
219
|
+
expect(await FileSys.fileExists(path.join(spokeRoot, ".env"))).toBe(false);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test("reports a refusal's whole reason, indented, instead of one truncated line", () => {
|
|
223
|
+
const report = formatFleetPushResults([
|
|
224
|
+
{ name: "acme", outcome: "refused", reason: "verification failed\nexit code: 1", changedFiles: 12 },
|
|
225
|
+
{ name: "beta", outcome: "pushed", commit: "abc1234", changedFiles: 3 },
|
|
226
|
+
]);
|
|
227
|
+
expect(report).toContain("refused acme verification failed");
|
|
228
|
+
expect(report).toContain(" exit code: 1");
|
|
229
|
+
expect(report).toContain("pushed beta abc1234 (3 files)");
|
|
230
|
+
});
|
|
206
231
|
});
|
|
207
232
|
|
|
208
233
|
describe("FleetSpoke status", () => {
|
package/fleetSpoke.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { mkdir, readdir, rename, rm } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import { AppExecutor, Executor,
|
|
3
|
+
import { AppExecutor, Executor, WorkspaceExecutor } from "./executors";
|
|
4
4
|
import { FileSys } from "./fileSys";
|
|
5
5
|
import type { FleetConfig, FleetSpokeDeclaration } from "./fleetConfig";
|
|
6
6
|
import { LibSource } from "./libSource";
|
|
@@ -306,6 +306,43 @@ export class FleetSpoke {
|
|
|
306
306
|
);
|
|
307
307
|
}
|
|
308
308
|
|
|
309
|
+
/**
|
|
310
|
+
* Verify-only, and an allowlist rather than a filter so no hub secret can reach a customer's clone even
|
|
311
|
+
* by accident. `AKAN_PUBLIC_*` values are the ones akan embeds in every client bundle, so they are public
|
|
312
|
+
* by construction; the workspace id is pinned to `local` so the hub's cloud workspace never travels.
|
|
313
|
+
*/
|
|
314
|
+
#localEnv() {
|
|
315
|
+
const { serveDomain } = WorkspaceExecutor.getBaseDevEnv();
|
|
316
|
+
return {
|
|
317
|
+
AKAN_WORKSPACE_ID: "local",
|
|
318
|
+
AKAN_PUBLIC_REPO_NAME: this.name,
|
|
319
|
+
AKAN_PUBLIC_SERVE_DOMAIN: serveDomain,
|
|
320
|
+
AKAN_PUBLIC_ENV: "local",
|
|
321
|
+
AKAN_PUBLIC_OPERATION_MODE: "local",
|
|
322
|
+
AKAN_PUBLIC_LOG_LEVEL: "warn",
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* The CLI identifies a workspace root by `package.json` + `tsconfig.json` + `.env`, so a clone with no
|
|
328
|
+
* `.env` cannot run `akan sync` at all — and one can never arrive with the slice, since every akan
|
|
329
|
+
* workspace gitignores it and the spoke's real values are spoke-owned. Excluded through the clone's own
|
|
330
|
+
* `.git/info/exclude` rather than trusting the hub's `.gitignore`: a hub that omitted the pattern would
|
|
331
|
+
* otherwise commit this file into the customer's repo.
|
|
332
|
+
*/
|
|
333
|
+
async #writeLocalEnv() {
|
|
334
|
+
const excludePath = path.join(this.#clonePath, ".git/info/exclude");
|
|
335
|
+
const exclude = (await FileSys.fileExists(excludePath)) ? await FileSys.readText(excludePath) : "";
|
|
336
|
+
if (!exclude.split("\n").includes("/.env")) {
|
|
337
|
+
await mkdir(path.dirname(excludePath), { recursive: true });
|
|
338
|
+
await FileSys.writeText(excludePath, exclude.trim() ? `${exclude.replace(/\n*$/, "\n")}/.env\n` : "/.env\n");
|
|
339
|
+
}
|
|
340
|
+
const envPath = path.join(this.#clonePath, ".env");
|
|
341
|
+
if (await FileSys.fileExists(envPath)) return;
|
|
342
|
+
const lines = Object.entries(this.#localEnv()).map(([key, value]) => `${key}=${value}`);
|
|
343
|
+
await FileSys.writeText(envPath, `${lines.join("\n")}\n`);
|
|
344
|
+
}
|
|
345
|
+
|
|
309
346
|
async #applySlice(clone: Executor, branch: string) {
|
|
310
347
|
const { libs, paths } = await this.slice();
|
|
311
348
|
const held = await this.#holdSpokeOwned();
|
|
@@ -324,6 +361,7 @@ export class FleetSpoke {
|
|
|
324
361
|
await this.#rewriteGitignore();
|
|
325
362
|
await this.#rewriteWorkspaceSection(libs);
|
|
326
363
|
await this.#writeGuards();
|
|
364
|
+
await this.#writeLocalEnv();
|
|
327
365
|
for (const lib of libs) await this.#stampLib(clone, lib, branch);
|
|
328
366
|
return { libs };
|
|
329
367
|
}
|
|
@@ -487,6 +525,22 @@ export class FleetSpoke {
|
|
|
487
525
|
await FileSys.writeText(hookPath, content);
|
|
488
526
|
}
|
|
489
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.
|
|
532
|
+
*/
|
|
533
|
+
async #verify(clone: Executor) {
|
|
534
|
+
const env = { ...process.env, ...this.#localEnv() };
|
|
535
|
+
try {
|
|
536
|
+
await clone.spawn("bun", ["install"], { env });
|
|
537
|
+
for (const app of this.#declaration.apps) await clone.spawn("bunx", ["akan", "sync", app], { env });
|
|
538
|
+
return null;
|
|
539
|
+
} catch (error) {
|
|
540
|
+
return error instanceof Error ? error.message : String(error);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
490
544
|
async push(branch: string, { verify = true }: { verify?: boolean } = {}): Promise<FleetPushResult> {
|
|
491
545
|
this.#config.assertPushable(branch);
|
|
492
546
|
if (await this.#workspace.hasChanges())
|
|
@@ -500,11 +554,12 @@ export class FleetSpoke {
|
|
|
500
554
|
const dirty = (await clone.spawn("git", ["status", "--porcelain"])).trim();
|
|
501
555
|
if (!dirty) return { name: this.name, outcome: "skipped", reason: "already up to date", changedFiles: 0 };
|
|
502
556
|
|
|
503
|
-
|
|
557
|
+
const changedFiles = dirty.split("\n").length;
|
|
504
558
|
if (verify) {
|
|
505
|
-
await clone
|
|
506
|
-
|
|
559
|
+
const failure = await this.#verify(clone);
|
|
560
|
+
if (failure) return { name: this.name, outcome: "refused", reason: failure, changedFiles };
|
|
507
561
|
}
|
|
562
|
+
await this.#writeAnchorFile(branch);
|
|
508
563
|
await clone.spawn("git", ["add", "-A"]);
|
|
509
564
|
const message = `chore(fleet): sync from ${this.#workspace.repoName}@${await this.headSha()}`;
|
|
510
565
|
await clone.spawn("git", ["commit", "--quiet", "-m", message]);
|
|
@@ -514,7 +569,7 @@ export class FleetSpoke {
|
|
|
514
569
|
name: this.name,
|
|
515
570
|
outcome: "pushed",
|
|
516
571
|
commit: (await clone.spawn("git", ["rev-parse", "--short", "HEAD"])).trim(),
|
|
517
|
-
changedFiles
|
|
572
|
+
changedFiles,
|
|
518
573
|
};
|
|
519
574
|
}
|
|
520
575
|
|
|
@@ -595,10 +650,11 @@ export function formatFleetPushResults(results: FleetPushResult[]) {
|
|
|
595
650
|
const sections = [
|
|
596
651
|
"Akan Fleet Push",
|
|
597
652
|
"",
|
|
598
|
-
...results.
|
|
653
|
+
...results.flatMap((result) => {
|
|
599
654
|
const detail =
|
|
600
655
|
result.outcome === "pushed" ? `${result.commit} (${result.changedFiles} files)` : (result.reason ?? "");
|
|
601
|
-
|
|
656
|
+
const [first = "", ...rest] = detail.split("\n");
|
|
657
|
+
return [` ${result.outcome.padEnd(8)} ${result.name} ${first}`, ...rest.map((line) => ` ${line}`)];
|
|
602
658
|
}),
|
|
603
659
|
];
|
|
604
660
|
return sections.join("\n");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@akanjs/devkit",
|
|
3
|
-
"version": "3.0.0-alpha.
|
|
3
|
+
"version": "3.0.0-alpha.80",
|
|
4
4
|
"sourceType": "module",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"publishConfig": {
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
"@langchain/openai": "^1.4.6",
|
|
46
46
|
"@tailwindcss/node": "^4.3.0",
|
|
47
47
|
"@trapezedev/project": "^7.1.4",
|
|
48
|
-
"akanjs": "3.0.0-alpha.
|
|
48
|
+
"akanjs": "3.0.0-alpha.80",
|
|
49
49
|
"chalk": "^5.6.2",
|
|
50
50
|
"commander": "^14.0.3",
|
|
51
51
|
"dayjs": "^1.11.20",
|