@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.
@@ -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, type WorkspaceExecutor } from "./executors";
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
- await this.#writeAnchorFile(branch);
557
+ const changedFiles = dirty.split("\n").length;
504
558
  if (verify) {
505
- await clone.spawn("bun", ["install"]);
506
- for (const app of this.#declaration.apps) await clone.spawn("bunx", ["akan", "sync", app]);
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: dirty.split("\n").length,
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.map((result) => {
653
+ ...results.flatMap((result) => {
599
654
  const detail =
600
655
  result.outcome === "pushed" ? `${result.commit} (${result.changedFiles} files)` : (result.reason ?? "");
601
- return ` ${result.outcome.padEnd(8)} ${result.name} ${detail}`;
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.79",
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.79",
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",