@akanjs/devkit 2.4.0-rc.1 → 2.4.0-rc.2

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.
@@ -588,16 +588,22 @@ export class AkanAppHost {
588
588
  }
589
589
  async #handleInvalidate(message: Extract<BuilderMessage, { type: "invalidate" }>) {
590
590
  this.#logDevPlan(message);
591
- if (shouldRestartBuilderByDevPlan(message)) {
591
+ // Config changes subsume builder restarts: the dev-host restart recycles builder and backend
592
+ // AND re-runs the prepare step, so check it first when a batch carries both actions.
593
+ if (shouldRestartDevHostByDevPlan(message)) {
592
594
  try {
593
- await this.#restartDevChildren(message);
595
+ await this.#restartDevHost(message);
594
596
  } catch (err) {
595
- this.#recordDevHostRestartFailure(message, err);
597
+ this.#recordDevHostRestartFailure(message, err, "Config");
596
598
  }
597
599
  return;
598
600
  }
599
- if (shouldRestartDevHostByDevPlan(message)) {
600
- this.#recordDevHostRestartRequired(message);
601
+ if (shouldRestartBuilderByDevPlan(message)) {
602
+ try {
603
+ await this.#restartDevChildren(message);
604
+ } catch (err) {
605
+ this.#recordDevHostRestartFailure(message, err, "Runtime metadata");
606
+ }
601
607
  return;
602
608
  }
603
609
  if (await this.#shouldRestartBackend(message)) {
@@ -611,6 +617,27 @@ export class AkanAppHost {
611
617
  this.logger.warn(
612
618
  `[dev-host] recycling builder/backend for runtime metadata generation=${generation ?? "(unknown)"} files=${message.files.length}`,
613
619
  );
620
+ await this.#recycleDevChildren(message);
621
+ }
622
+ /**
623
+ * Controlled dev-host restart for config changes (akan.config.ts, tsconfig, package.json):
624
+ * re-runs the prepare step so env and codegen reflect the new config, then recycles the builder
625
+ * and backend. The config module is re-imported with a cache-busting query; modules it imports
626
+ * keep their cached instances, so a change inside an imported plugin file still needs a manual
627
+ * `akan start` restart.
628
+ */
629
+ async #restartDevHost(message: Extract<BuilderMessage, { type: "invalidate" }>): Promise<void> {
630
+ const generation = message.devPlan?.generation ?? message.generation;
631
+ this.logger.warn(
632
+ `[dev-host] config change detected; restarting dev host generation=${generation ?? "(unknown)"} files=${message.files.length}`,
633
+ );
634
+ await this.#recycleDevChildren(message, { refreshConfig: true });
635
+ }
636
+ async #recycleDevChildren(
637
+ message: Extract<BuilderMessage, { type: "invalidate" }>,
638
+ { refreshConfig = false }: { refreshConfig?: boolean } = {},
639
+ ): Promise<void> {
640
+ const generation = message.devPlan?.generation ?? message.generation;
614
641
  if (this.#restartTimer) {
615
642
  clearTimeout(this.#restartTimer);
616
643
  this.#restartTimer = null;
@@ -625,6 +652,13 @@ export class AkanAppHost {
625
652
  this.#pendingBuildStatusReplay = [];
626
653
  await this.#stopBackend();
627
654
  this.#stopBuilder();
655
+ if (refreshConfig) {
656
+ await this.app.getConfig({ refresh: true });
657
+ // Merge instead of replace: start() enriched this.env with values prepare doesn't produce
658
+ // (e.g. REDIS_HOST from the tunnel), and the spawned children must keep seeing them.
659
+ const { env } = await this.app.prepareCommand("start");
660
+ Object.assign(this.env, env);
661
+ }
628
662
  await this.#backendGraph.refresh();
629
663
  await this.#startBuilder();
630
664
  this.#startBackend({ generation, files: message.files });
@@ -646,34 +680,20 @@ export class AkanAppHost {
646
680
  `[last-good] css generation=${message.data.generation ?? "(unknown)"} assets=${Object.keys(message.data.cssAssets).length}`,
647
681
  );
648
682
  }
649
- #recordDevHostRestartRequired(message: Extract<BuilderMessage, { type: "invalidate" }>): void {
650
- const generation = message.devPlan?.generation ?? message.generation;
651
- const detail = `generation=${generation ?? "(unknown)"} files=${message.files.length}`;
652
- this.logger.warn(
653
- `[dev-host] config change requires a manual restart until controlled dev-host restart is implemented (${detail})`,
654
- );
655
- if (typeof generation === "number") {
656
- const status: DevBuildStatus = {
657
- generation,
658
- phase: "scan",
659
- ok: false,
660
- files: message.files,
661
- message: "Config change requires restarting `akan start` to apply.",
662
- };
663
- this.#recordBuildStatus(status);
664
- this.#sendOrQueueBuildStatus(status);
665
- }
666
- }
667
- #recordDevHostRestartFailure(message: Extract<BuilderMessage, { type: "invalidate" }>, err: unknown): void {
683
+ #recordDevHostRestartFailure(
684
+ message: Extract<BuilderMessage, { type: "invalidate" }>,
685
+ err: unknown,
686
+ kind: "Config" | "Runtime metadata",
687
+ ): void {
668
688
  const generation = message.devPlan?.generation ?? message.generation ?? this.#nextBackendBuildStatusGeneration();
669
689
  const detail = err instanceof Error ? err.message : String(err);
670
- this.logger.warn(`[dev-host] runtime metadata restart failed generation=${generation}: ${detail}`);
690
+ this.logger.warn(`[dev-host] ${kind.toLowerCase()} restart failed generation=${generation}: ${detail}`);
671
691
  const status: DevBuildStatus = {
672
692
  generation,
673
693
  phase: "scan",
674
694
  ok: false,
675
695
  files: message.files,
676
- message: `Runtime metadata change requires restarting \`akan start\` to apply: ${detail}`,
696
+ message: `${kind} change requires restarting \`akan start\` to apply: ${detail}`,
677
697
  };
678
698
  this.#recordBuildStatus(status);
679
699
  this.#sendOrQueueBuildStatus(status);
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import fs from "node:fs";
3
+ import os from "node:os";
3
4
  import path from "node:path";
4
5
  import { fileURLToPath } from "node:url";
5
6
  import type { PackageJson } from "../types";
@@ -344,3 +345,26 @@ describe("AkanLibConfig", () => {
344
345
  expect(new AkanLibConfig(lib, config).externalLibs).toEqual(["firebase-admin"]);
345
346
  });
346
347
  });
348
+
349
+ describe("AkanAppConfig.importConfigModule", () => {
350
+ test("busting the import cache re-evaluates an edited config module", async () => {
351
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "akan-config-bust-"));
352
+ try {
353
+ const configPath = path.join(root, "akan.config.ts");
354
+ fs.writeFileSync(configPath, "export default { basePaths: ['first'] };\n");
355
+ const first = await AkanAppConfig.importConfigModule<{ basePaths: string[] }>(root);
356
+ expect(first.basePaths).toEqual(["first"]);
357
+
358
+ fs.writeFileSync(configPath, "export default { basePaths: ['second'] };\n");
359
+ const stale = await AkanAppConfig.importConfigModule<{ basePaths: string[] }>(root);
360
+ expect(stale.basePaths).toEqual(["first"]);
361
+
362
+ const busted = await AkanAppConfig.importConfigModule<{ basePaths: string[] }>(root, {
363
+ bustImportCache: true,
364
+ });
365
+ expect(busted.basePaths).toEqual(["second"]);
366
+ } finally {
367
+ fs.rmSync(root, { recursive: true, force: true });
368
+ }
369
+ });
370
+ });
@@ -339,9 +339,26 @@ CMD [${command.map((c) => `"${c}"`).join(",")}]`;
339
339
  command,
340
340
  };
341
341
  }
342
- static async from(app: App) {
342
+ static #importGeneration = 0;
343
+ /**
344
+ * Bun caches dynamic imports by path, so a plain re-import after the user edits the config file
345
+ * returns the stale module. `bustImportCache` appends a fresh query string to force re-evaluation
346
+ * of the config module itself; modules it imports keep their cached instances.
347
+ */
348
+ static async importConfigModule<T = unknown>(
349
+ cwdPath: string,
350
+ { bustImportCache = false }: { bustImportCache?: boolean } = {},
351
+ ): Promise<T> {
352
+ const configPath = `${cwdPath}/akan.config.ts`;
353
+ const importPath = bustImportCache
354
+ ? `${configPath}?akanConfigGeneration=${++AkanAppConfig.#importGeneration}`
355
+ : configPath;
356
+ return (await import(importPath).then((mod: { default: T }) => mod.default)) as T;
357
+ }
358
+
359
+ static async from(app: App, { bustImportCache = false }: { bustImportCache?: boolean } = {}) {
343
360
  const [configImp, baseDevEnv, libs, rootPackageJson] = await Promise.all([
344
- import(`${app.cwdPath}/akan.config.ts`).then((mod) => mod.default),
361
+ AkanAppConfig.importConfigModule(app.cwdPath, { bustImportCache }),
345
362
  WorkspaceExecutor.getBaseDevEnv(path.join(app.workspace.workspaceRoot, ".env")),
346
363
  app.workspace.getLibs(),
347
364
  app.workspace.getPackageJson(),
package/executors.ts CHANGED
@@ -1463,7 +1463,9 @@ export class AppExecutor extends SysExecutor {
1463
1463
  #akanConfig: AkanAppConfig | null = null;
1464
1464
  override async getConfig({ refresh }: { refresh?: boolean } = {}) {
1465
1465
  if (this.#akanConfig && !refresh) return this.#akanConfig;
1466
- this.#akanConfig = await AkanAppConfig.from(this);
1466
+ // A refresh means the config file may have been edited; bust the import cache so the fresh
1467
+ // module is evaluated instead of Bun's cached instance.
1468
+ this.#akanConfig = await AkanAppConfig.from(this, { bustImportCache: refresh });
1467
1469
  return this.#akanConfig;
1468
1470
  }
1469
1471
 
@@ -356,7 +356,9 @@ class IncrementalBuilder {
356
356
  this.#sendBuildStatus("pages", { generation, ok: false, files, message });
357
357
  }
358
358
  }
359
- if (kinds.includes("code") || kinds.includes("css")) {
359
+ // Server-only code edits cannot introduce class names the CSS scanner would pick up; only a
360
+ // client rebuild or a direct stylesheet edit can change the compiled CSS.
361
+ if (kinds.includes("css") || (kinds.includes("code") && rebuildClient)) {
360
362
  this.scheduleCssRebuild(artifactDir, { refresh: true, generation, changedFiles: files });
361
363
  this.#logger.verbose(`css-rebuild scheduled generation=${generation}`);
362
364
  }
@@ -203,6 +203,32 @@ export const dictionary = serviceDictionary(["en", "ko"])
203
203
  hmr?.close();
204
204
  });
205
205
 
206
+ integrationTest("config edits restart the dev host and keep serving", async () => {
207
+ const harness = await createHarness();
208
+ const host = await harness.startHost();
209
+ const initialHtml = await harness.tryWaitForHttpText("initial-shared-marker", 3_000);
210
+ if (!initialHtml) {
211
+ expect(host.proc.killed).toBe(false);
212
+ return;
213
+ }
214
+ const mark = host.markLog();
215
+
216
+ await harness.writeFile(
217
+ "akan.config.ts",
218
+ `import type { AppConfig } from "akanjs";
219
+
220
+ const config: AppConfig = { externalLibs: [] };
221
+ export default config;
222
+ `,
223
+ );
224
+
225
+ await host.waitForLogSince(mark, /\[dev-plan\].*actions=.*restart-dev-host/);
226
+ await host.waitForLogSince(mark, /\[dev-host\] config change detected; restarting dev host/);
227
+ await host.waitForLogSince(mark, /backend ready pid=(\d+)|AkanApp gateway is running on port/);
228
+ await harness.waitForHttpText("initial-shared-marker");
229
+ expect(host.proc.killed).toBe(false);
230
+ });
231
+
206
232
  integrationTest("client build failure reports error and recovers after fix", async () => {
207
233
  const harness = await createHarness();
208
234
  const host = await harness.startHost();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akanjs/devkit",
3
- "version": "2.4.0-rc.1",
3
+ "version": "2.4.0-rc.2",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -32,7 +32,7 @@
32
32
  "@langchain/openai": "^1.4.6",
33
33
  "@tailwindcss/node": "^4.3.0",
34
34
  "@trapezedev/project": "^7.1.4",
35
- "akanjs": "2.4.0-rc.1",
35
+ "akanjs": "2.4.0-rc.2",
36
36
  "chalk": "^5.6.2",
37
37
  "commander": "^14.0.3",
38
38
  "daisyui": "5.5.23",
package/scanInfo.ts CHANGED
@@ -1,5 +1,5 @@
1
- import path from "node:path";
2
1
  import { rm } from "node:fs/promises";
2
+ import path from "node:path";
3
3
  import type {
4
4
  AppConfigResult,
5
5
  AppScanResult,
@@ -71,6 +71,7 @@ const appRootAllowedDirs = new Set([
71
71
  "srvkit",
72
72
  "webkit",
73
73
  "common",
74
+ "secrets",
74
75
  ]);
75
76
  const libRootAllowedFiles = new Set([
76
77
  "cnst.ts",