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

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";
@@ -264,8 +265,22 @@ describe("AkanAppConfig", () => {
264
265
  `postgres@${runtimeDependencies.postgres}`,
265
266
  `protobufjs@${runtimeDependencies.protobufjs}`,
266
267
  ]);
267
- expect(config.getMobileRuntimePackages()).toEqual(["firebase"]);
268
- expect(config.getMissingMobileDependencySpecs()).toEqual([`firebase@${runtimeDependencies.firebase}`]);
268
+ expect(config.getMobileRuntimePackages()).toEqual([
269
+ "firebase",
270
+ "@capacitor/cli",
271
+ "@capacitor/core",
272
+ "@capacitor/ios",
273
+ "@capacitor/android",
274
+ "@capacitor/assets",
275
+ ]);
276
+ expect(config.getMissingMobileDependencySpecs()).toEqual([
277
+ `firebase@${runtimeDependencies.firebase}`,
278
+ `@capacitor/cli@${runtimeDependencies["@capacitor/cli"]}`,
279
+ `@capacitor/core@${runtimeDependencies["@capacitor/core"]}`,
280
+ `@capacitor/ios@${runtimeDependencies["@capacitor/ios"]}`,
281
+ `@capacitor/android@${runtimeDependencies["@capacitor/android"]}`,
282
+ `@capacitor/assets@${runtimeDependencies["@capacitor/assets"]}`,
283
+ ]);
269
284
  });
270
285
 
271
286
  test("normalizes multiple mobile targets and validates base paths", () => {
@@ -344,3 +359,26 @@ describe("AkanLibConfig", () => {
344
359
  expect(new AkanLibConfig(lib, config).externalLibs).toEqual(["firebase-admin"]);
345
360
  });
346
361
  });
362
+
363
+ describe("AkanAppConfig.importConfigModule", () => {
364
+ test("busting the import cache re-evaluates an edited config module", async () => {
365
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "akan-config-bust-"));
366
+ try {
367
+ const configPath = path.join(root, "akan.config.ts");
368
+ fs.writeFileSync(configPath, "export default { basePaths: ['first'] };\n");
369
+ const first = await AkanAppConfig.importConfigModule<{ basePaths: string[] }>(root);
370
+ expect(first.basePaths).toEqual(["first"]);
371
+
372
+ fs.writeFileSync(configPath, "export default { basePaths: ['second'] };\n");
373
+ const stale = await AkanAppConfig.importConfigModule<{ basePaths: string[] }>(root);
374
+ expect(stale.basePaths).toEqual(["first"]);
375
+
376
+ const busted = await AkanAppConfig.importConfigModule<{ basePaths: string[] }>(root, {
377
+ bustImportCache: true,
378
+ });
379
+ expect(busted.basePaths).toEqual(["second"]);
380
+ } finally {
381
+ fs.rmSync(root, { recursive: true, force: true });
382
+ }
383
+ });
384
+ });
@@ -52,9 +52,18 @@ const WORKSPACE_BARREL_FACETS = ["ui", "webkit", "common", "client", "server"] a
52
52
  const SSR_RUNTIME_PACKAGES = ["react", "react-dom", "react-server-dom-webpack"] as const;
53
53
  const NATIVE_RUNTIME_PACKAGES = ["sharp"] as const;
54
54
  const DEFAULT_BACKEND_RUNTIME_PACKAGES = ["croner"] as const;
55
- // Native-only client packages that are not bundled into the base bootstrap; installed
56
- // on demand when a mobile target is built or started (e.g. firebase for push tokens).
57
- const MOBILE_RUNTIME_PACKAGES = ["firebase"] as const;
55
+ // Packages required to build/run a mobile target that are not part of the base bootstrap;
56
+ // installed on demand when a mobile target is built or started. Covers the firebase client
57
+ // (push tokens) plus the Capacitor toolchain (`npx cap` / `npx @capacitor/assets`), which is
58
+ // otherwise declared only as optional peers and never auto-installed on a fresh workspace.
59
+ const MOBILE_RUNTIME_PACKAGES = [
60
+ "firebase",
61
+ "@capacitor/cli",
62
+ "@capacitor/core",
63
+ "@capacitor/ios",
64
+ "@capacitor/android",
65
+ "@capacitor/assets",
66
+ ] as const;
58
67
  const DATABASE_MODE_RUNTIME_PACKAGES = {
59
68
  single: [],
60
69
  multiple: ["@libsql/client", "bullmq", "ioredis", "protobufjs"],
@@ -339,9 +348,26 @@ CMD [${command.map((c) => `"${c}"`).join(",")}]`;
339
348
  command,
340
349
  };
341
350
  }
342
- static async from(app: App) {
351
+ static #importGeneration = 0;
352
+ /**
353
+ * Bun caches dynamic imports by path, so a plain re-import after the user edits the config file
354
+ * returns the stale module. `bustImportCache` appends a fresh query string to force re-evaluation
355
+ * of the config module itself; modules it imports keep their cached instances.
356
+ */
357
+ static async importConfigModule<T = unknown>(
358
+ cwdPath: string,
359
+ { bustImportCache = false }: { bustImportCache?: boolean } = {},
360
+ ): Promise<T> {
361
+ const configPath = `${cwdPath}/akan.config.ts`;
362
+ const importPath = bustImportCache
363
+ ? `${configPath}?akanConfigGeneration=${++AkanAppConfig.#importGeneration}`
364
+ : configPath;
365
+ return (await import(importPath).then((mod: { default: T }) => mod.default)) as T;
366
+ }
367
+
368
+ static async from(app: App, { bustImportCache = false }: { bustImportCache?: boolean } = {}) {
343
369
  const [configImp, baseDevEnv, libs, rootPackageJson] = await Promise.all([
344
- import(`${app.cwdPath}/akan.config.ts`).then((mod) => mod.default),
370
+ AkanAppConfig.importConfigModule(app.cwdPath, { bustImportCache }),
345
371
  WorkspaceExecutor.getBaseDevEnv(path.join(app.workspace.workspaceRoot, ".env")),
346
372
  app.workspace.getLibs(),
347
373
  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.3",
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.3",
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",