@akanjs/devkit 3.0.0-alpha.2 → 3.0.0-alpha.21

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.
Files changed (47) hide show
  1. package/README.ko.md +1 -1
  2. package/README.md +1 -1
  3. package/agentsIndex.test.ts +10 -0
  4. package/agentsIndex.ts +47 -1
  5. package/aiEditor.ts +1 -1
  6. package/akanConfig/akanConfig.test.ts +0 -10
  7. package/akanConfig/akanConfig.ts +1 -11
  8. package/akanContext.ts +43 -31
  9. package/applicationBuildRunner.test.ts +1 -1
  10. package/applicationBuildRunner.ts +1 -2
  11. package/biome.base.json +317 -0
  12. package/biomeBase.ts +9 -0
  13. package/capacitorApp.test.ts +8 -0
  14. package/capacitorApp.ts +112 -18
  15. package/executors.test.ts +42 -5
  16. package/executors.ts +43 -4
  17. package/frontendBuild/allRoutesBuilder.ts +27 -8
  18. package/frontendBuild/buildRouteClient.test.ts +28 -2
  19. package/frontendBuild/clientBuildTypes.ts +4 -0
  20. package/frontendBuild/clientEntriesBundler.ts +4 -1
  21. package/frontendBuild/cssCandidateCache.test.ts +38 -0
  22. package/frontendBuild/cssCandidateCache.ts +15 -3
  23. package/frontendBuild/cssCompiler.ts +122 -11
  24. package/frontendBuild/cssImportResolver.ts +8 -7
  25. package/frontendBuild/frontendBuild.test.ts +88 -1
  26. package/frontendBuild/precompressArtifacts.ts +35 -7
  27. package/frontendBuild/routeClientBuilder.ts +26 -7
  28. package/frontendBuild/ssrBaseArtifactBuilder.ts +20 -4
  29. package/frontendBuild/vendorSpecifiers.ts +1 -0
  30. package/getCredentials.ts +1 -3
  31. package/incrementalBuilder/devWatchBatch.test.ts +18 -20
  32. package/lint/no-bang-comment-in-client.grit +20 -0
  33. package/lint/no-import-client-in-server.grit +48 -0
  34. package/lint/no-import-server-in-client.grit +45 -0
  35. package/lint/no-model-type-in-util-zone.grit +16 -0
  36. package/lint/no-return-in-store-action.grit +35 -0
  37. package/linter.ts +17 -12
  38. package/package.json +4 -4
  39. package/qualityScanner.test.ts +23 -0
  40. package/qualityScanner.ts +5 -44
  41. package/repoIdentity.ts +42 -0
  42. package/routeSourceValidator.test.ts +41 -0
  43. package/routeSourceValidator.ts +2 -44
  44. package/scanInfo.ts +2 -43
  45. package/transforms/externalizeFrameworkPlugin.ts +0 -1
  46. package/workspaceLayout.test.ts +26 -0
  47. package/workspaceLayout.ts +60 -0
@@ -11,6 +11,7 @@ import {
11
11
  clearRootCapacitorConfigs,
12
12
  formatAndroidReleaseSigningError,
13
13
  getAdbDeviceStateIssues,
14
+ getAndroidLocalServerHost,
14
15
  getMissingAndroidReleaseSigningKeys,
15
16
  isPlaceholderAppId,
16
17
  materializeCapacitorConfig,
@@ -339,6 +340,13 @@ describe("Android signing diagnostics", () => {
339
340
  "Android device abc123 is unauthorized. Confirm USB debugging authorization on the device.",
340
341
  "Android device xyz is offline. Reconnect the device or restart adb.",
341
342
  ]);
343
+ expect(getAndroidLocalServerHost("List of devices attached\nemulator-5554 device\n", "192.168.0.5")).toBe(
344
+ "10.0.2.2",
345
+ );
346
+ expect(getAndroidLocalServerHost("List of devices attached\nabc123 device\n", "192.168.0.5")).toBe("192.168.0.5");
347
+ expect(
348
+ getAndroidLocalServerHost("List of devices attached\nemulator-5554 device\nabc123 device\n", "192.168.0.5"),
349
+ ).toBe("192.168.0.5");
342
350
  });
343
351
  });
344
352
 
package/capacitorApp.ts CHANGED
@@ -24,7 +24,9 @@ interface RunIosConfig extends RunConfig {
24
24
  iosDeviceId?: string;
25
25
  }
26
26
 
27
- interface PrepareConfig extends RunConfig {}
27
+ interface PrepareConfig extends RunConfig {
28
+ iosRunTargetKind?: IosRunTargetKind;
29
+ }
28
30
 
29
31
  type MobileCommandEnv = Record<string, string | undefined>;
30
32
 
@@ -119,10 +121,11 @@ interface MaterializeCapacitorConfigOptions {
119
121
  localServerUrl?: string;
120
122
  localIp?: string;
121
123
  }
124
+ type MobilePlatform = "ios" | "android";
122
125
 
123
126
  export interface LocalDevHostResolution {
124
127
  host: string;
125
- source: "override" | "detected" | "loopback";
128
+ source: "override" | "detected" | "loopback" | "platform";
126
129
  candidates: { name: string; address: string }[];
127
130
  }
128
131
 
@@ -565,6 +568,16 @@ export function getAdbDeviceStateIssues(output: string) {
565
568
  });
566
569
  }
567
570
 
571
+ export function getAndroidLocalServerHost(adbDevicesOutput: string | undefined, fallbackHost: string) {
572
+ const onlineDeviceIds =
573
+ adbDevicesOutput
574
+ ?.split(/\r?\n/)
575
+ .map((line) => line.trim().split(/\s+/))
576
+ .filter(([id, state]) => id && state === "device")
577
+ .map(([id]) => id) ?? [];
578
+ return onlineDeviceIds.length === 1 && onlineDeviceIds[0]?.startsWith("emulator-") ? "10.0.2.2" : fallbackHost;
579
+ }
580
+
568
581
  export const ANDROID_MIN_SDK_VERSION = 26;
569
582
  export function raiseGradleMinSdkVersion(content: string, floor: number = ANDROID_MIN_SDK_VERSION): string | null {
570
583
  const match = content.match(/minSdkVersion\s*=\s*(\d+)/);
@@ -748,7 +761,7 @@ export class CapacitorApp {
748
761
  async save() {
749
762
  await this.project.commit();
750
763
  }
751
- async #prepareIos({ operation, env, regenerate = false }: PrepareConfig) {
764
+ async #prepareIos({ operation, env, regenerate = false, iosRunTargetKind }: PrepareConfig) {
752
765
  await this.init({ platform: "ios", operation, env, regenerate });
753
766
  await this.#prepareTargetAssets();
754
767
  await this.#prepareExternalFiles("ios");
@@ -757,9 +770,10 @@ export class CapacitorApp {
757
770
  await this.#applyDeepLinks("ios", { operation, env });
758
771
  await this.#flushIosEntitlements();
759
772
  await this.project.commit();
773
+ await this.#setCodeSignEntitlementsInIosIfExists("App/App.entitlements");
760
774
  await this.#generateAssets({ operation, env });
761
775
  this.app.verbose(`syncing iOS`);
762
- await this.#spawnMobile("npx", ["cap", "sync", "ios"], { operation, env });
776
+ await this.#spawnMobile("npx", ["cap", "sync", "ios"], { operation, env }, { iosRunTargetKind });
763
777
  this.app.verbose(`sync completed.`);
764
778
  }
765
779
  async buildIos({ env = "debug", regenerate = false }: { env?: RunConfig["env"]; regenerate?: boolean } = {}) {
@@ -777,14 +791,14 @@ export class CapacitorApp {
777
791
  }
778
792
  async runIos({ operation, env, regenerate = false, noAllowProvisioningUpdates = false, iosDeviceId }: RunIosConfig) {
779
793
  if (operation === "release") await this.prepareWww();
780
- await this.#prepareIos({ operation, env, regenerate });
781
794
  const runTarget = await this.#selectIosRunTarget(iosDeviceId);
795
+ await this.#prepareIos({ operation, env, regenerate, iosRunTargetKind: runTarget.kind });
782
796
  if (runTarget.kind === "simulator") {
783
797
  await this.#spawnMobile(
784
798
  "npx",
785
- ["cap", "run", "ios", "--target", runTarget.id],
799
+ ["cap", "run", "ios", "--target", runTarget.id, "--no-sync"],
786
800
  { operation, env },
787
- { stdio: "inherit" },
801
+ { stdio: "inherit", platform: "ios", iosRunTargetKind: runTarget.kind },
788
802
  );
789
803
  return;
790
804
  }
@@ -878,7 +892,10 @@ export class CapacitorApp {
878
892
  noAllowProvisioningUpdates: boolean;
879
893
  }) {
880
894
  const mobileEnv = sanitizeIosNativeRunEnv(await this.#commandEnv(operation, env));
881
- const configContent = await this.#writeCapacitorConfig({ operation }, mobileEnv);
895
+ const configContent = await this.#writeCapacitorConfig(
896
+ { operation, platform: "ios", iosRunTargetKind: runTarget.kind },
897
+ mobileEnv,
898
+ );
882
899
  await this.#writeRootCapacitorConfig(configContent);
883
900
  const scheme = this.#iosScheme();
884
901
  const command = buildIosNativeRunCommand({
@@ -943,6 +960,7 @@ export class CapacitorApp {
943
960
  await this.#applyAndroidMinSdkVersion();
944
961
  await this.#applyPermissions({ operation, env });
945
962
  await this.#applyDeepLinks("android", { operation, env });
963
+ await this.#disableNativeKeyboardResizeInAndroid();
946
964
  await this.project.commit();
947
965
  await this.#generateAssets({ operation, env });
948
966
  await this.#ensureAndroidAssetsDir();
@@ -1010,6 +1028,24 @@ export class CapacitorApp {
1010
1028
  async openAndroid() {
1011
1029
  await this.#spawnMobile("npx", ["cap", "open", "android"], { operation: "local", env: "local" });
1012
1030
  }
1031
+ async #disableNativeKeyboardResizeInAndroid() {
1032
+ const manifestPath = path.join(this.app.cwdPath, this.androidRootPath, "app/src/main/AndroidManifest.xml");
1033
+ let manifest = await readFile(manifestPath, "utf8");
1034
+ let changed = false;
1035
+ manifest = manifest.replace(/<activity\b[^>]*android:name="\.MainActivity"[^>]*>/, (activityTag) => {
1036
+ if (activityTag.includes("android:windowSoftInputMode=")) {
1037
+ const nextTag = activityTag.replace(
1038
+ /android:windowSoftInputMode="[^"]*"/,
1039
+ 'android:windowSoftInputMode="adjustNothing"',
1040
+ );
1041
+ changed ||= nextTag !== activityTag;
1042
+ return nextTag;
1043
+ }
1044
+ changed = true;
1045
+ return activityTag.replace(/>$/, '\n android:windowSoftInputMode="adjustNothing">');
1046
+ });
1047
+ if (changed) await writeFile(manifestPath, manifest);
1048
+ }
1013
1049
  async #ensureAndroidAssetsDir() {
1014
1050
  await mkdir(path.join(this.app.cwdPath, this.androidAssetsPath), { recursive: true });
1015
1051
  }
@@ -1100,12 +1136,19 @@ export class CapacitorApp {
1100
1136
  if (html.includes("window.__AKAN_MOBILE_TARGET__")) return html;
1101
1137
  return html.replace(/<\/head\s*>/i, `${script}\n</head>`);
1102
1138
  }
1103
- async #writeCapacitorConfig({ operation }: Pick<RunConfig, "operation">, commandEnv: MobileCommandEnv) {
1139
+ async #writeCapacitorConfig(
1140
+ {
1141
+ operation,
1142
+ platform,
1143
+ iosRunTargetKind,
1144
+ }: Pick<RunConfig, "operation"> & { platform?: MobilePlatform; iosRunTargetKind?: IosRunTargetKind },
1145
+ commandEnv: MobileCommandEnv,
1146
+ ) {
1104
1147
  await mkdir(this.targetRoot, { recursive: true });
1105
1148
  let localIp: string | undefined;
1106
1149
  if (operation === "local") {
1107
1150
  const override = commandEnv.AKAN_PUBLIC_CLIENT_HOST ?? process.env.AKAN_PUBLIC_CLIENT_HOST;
1108
- const resolution = selectLocalDevHost(os.networkInterfaces(), { override });
1151
+ const resolution = await this.#resolveLocalDevHost({ override, platform, iosRunTargetKind });
1109
1152
  localIp = resolution.host;
1110
1153
  this.#logDevHostResolution(resolution, commandEnv);
1111
1154
  }
@@ -1118,11 +1161,36 @@ export class CapacitorApp {
1118
1161
  await Bun.write(path.join(this.targetRoot, "capacitor.config.json"), content);
1119
1162
  return content;
1120
1163
  }
1164
+ // An explicit override always wins; an emulator/simulator run reaches the host machine through a
1165
+ // loopback alias (10.0.2.2 / localhost), so LAN detection only applies to physical-device runs.
1166
+ async #resolveLocalDevHost({
1167
+ override,
1168
+ platform,
1169
+ iosRunTargetKind,
1170
+ }: {
1171
+ override?: string;
1172
+ platform?: MobilePlatform;
1173
+ iosRunTargetKind?: IosRunTargetKind;
1174
+ }): Promise<LocalDevHostResolution> {
1175
+ const resolution = selectLocalDevHost(os.networkInterfaces(), { override });
1176
+ if (resolution.source === "override") return resolution;
1177
+ if (platform === "ios" && iosRunTargetKind === "simulator")
1178
+ return { ...resolution, host: "localhost", source: "platform" };
1179
+ if (platform === "android") {
1180
+ try {
1181
+ const host = getAndroidLocalServerHost(await this.#spawn("adb", ["devices"]), resolution.host);
1182
+ return host === resolution.host ? resolution : { ...resolution, host, source: "platform" };
1183
+ } catch {
1184
+ return resolution;
1185
+ }
1186
+ }
1187
+ return resolution;
1188
+ }
1121
1189
  // Surface the live-reload URL a physical device must reach, and warn when auto-detection landed on
1122
1190
  // a likely-unreachable host so a blank WebView is not mistaken for an app bug.
1123
1191
  #logDevHostResolution(resolution: LocalDevHostResolution, commandEnv: MobileCommandEnv) {
1124
1192
  this.app.log(`Mobile live-reload server: ${this.#localCsrUrl(resolution.host, commandEnv)}`);
1125
- if (resolution.source === "override") return;
1193
+ if (resolution.source === "override" || resolution.source === "platform") return;
1126
1194
  const suspicious = resolution.host === "127.0.0.1" || resolution.host.startsWith("169.254.");
1127
1195
  const alternatives = resolution.candidates.filter((candidate) => candidate.address !== resolution.host);
1128
1196
  if (!suspicious && alternatives.length === 0) return;
@@ -1332,20 +1400,29 @@ export class CapacitorApp {
1332
1400
  command: string,
1333
1401
  args: string[] = [],
1334
1402
  { operation, env }: Pick<RunConfig, "operation" | "env">,
1335
- options: Parameters<AppExecutor["spawn"]>[2] = {},
1403
+ options: SpawnMobileOptions = {},
1336
1404
  ) {
1405
+ const { iosRunTargetKind, platform, ...spawnOptions } = options;
1337
1406
  const mobileEnv = { ...(await this.#commandEnv(operation, env)), ...options.env };
1338
- const configContent = await this.#writeCapacitorConfig({ operation }, mobileEnv);
1407
+ const configContent = await this.#writeCapacitorConfig(
1408
+ { operation, platform: platform ?? this.#inferMobilePlatform(args), iosRunTargetKind },
1409
+ mobileEnv,
1410
+ );
1339
1411
  await this.#writeRootCapacitorConfig(configContent);
1340
1412
  try {
1341
1413
  return await this.#spawn(command, args, {
1342
- ...options,
1414
+ ...spawnOptions,
1343
1415
  env: mobileEnv,
1344
1416
  });
1345
1417
  } finally {
1346
1418
  await this.#clearRootCapacitorConfigs();
1347
1419
  }
1348
1420
  }
1421
+ #inferMobilePlatform(args: string[]): MobilePlatform | undefined {
1422
+ if (args.includes("android")) return "android";
1423
+ if (args.includes("ios")) return "ios";
1424
+ return undefined;
1425
+ }
1349
1426
  async addCamera() {
1350
1427
  await this.#setPermissionInIos({
1351
1428
  cameraUsageDescription: "$(PRODUCT_NAME) requires access to the camera to take photos.",
@@ -1446,14 +1523,31 @@ export class CapacitorApp {
1446
1523
  let end = index;
1447
1524
  while (end < lines.length && !/^\s*\};\s*$/.test(lines[end] ?? "")) end++;
1448
1525
  const settings = lines.slice(start, end + 1);
1449
- if (settings.some((setting) => setting.includes("CODE_SIGN_ENTITLEMENTS"))) continue;
1526
+ const configName = lines.slice(end + 1, end + 8).find((setting) => setting.includes("name = ")) ?? "";
1450
1527
  const indent = line.match(/^\s*/)?.[0] ?? "";
1451
- lines.splice(index, 0, `${indent}CODE_SIGN_ENTITLEMENTS = ${entitlementsRelPath};`);
1452
- index++;
1453
- changed = true;
1528
+ const insertSettings = [];
1529
+ if (!settings.some((setting) => setting.includes("CODE_SIGN_ENTITLEMENTS"))) {
1530
+ insertSettings.push(`${indent}CODE_SIGN_ENTITLEMENTS = ${entitlementsRelPath};`);
1531
+ }
1532
+ if (
1533
+ configName.includes("name = Debug;") &&
1534
+ !settings.some((setting) => setting.includes("CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION"))
1535
+ ) {
1536
+ insertSettings.push(`${indent}CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION = YES;`);
1537
+ }
1538
+ if (insertSettings.length > 0) {
1539
+ lines.splice(index, 0, ...insertSettings);
1540
+ index += insertSettings.length;
1541
+ changed = true;
1542
+ }
1454
1543
  }
1455
1544
  if (changed) await writeFile(pbxprojPath, lines.join("\n"));
1456
1545
  }
1546
+ async #setCodeSignEntitlementsInIosIfExists(entitlementsRelPath: string) {
1547
+ const entitlementsPath = path.join(this.app.cwdPath, this.iosProjectPath, entitlementsRelPath);
1548
+ if (!(await Bun.file(entitlementsPath).exists())) return;
1549
+ await this.#setCodeSignEntitlementsInIos(entitlementsRelPath);
1550
+ }
1457
1551
  async #setUrlSchemesInAndroid(schemes: string[]) {
1458
1552
  const manifestPath = path.join(this.app.cwdPath, this.androidRootPath, "app/src/main/AndroidManifest.xml");
1459
1553
  let manifest = await readFile(manifestPath, "utf8");
package/executors.test.ts CHANGED
@@ -31,7 +31,6 @@ const rootPackageJson = (extra: Partial<PackageJson> = {}): PackageJson => ({
31
31
  react: "19.0.0",
32
32
  "react-dom": "19.0.0",
33
33
  "react-server-dom-webpack": "19.0.0",
34
- sharp: "1.0.0",
35
34
  lodash: "4.0.0",
36
35
  },
37
36
  devDependencies: {
@@ -149,8 +148,10 @@ describe("Executor filesystem helpers", () => {
149
148
  "AI Development Guide",
150
149
  );
151
150
  expect(await readFile(path.join(root, "workspace/docs/GENERATED.md"), "utf8")).toContain("Generated Akan Files");
151
+ // Rules and their plugin registrations live in the package's base config, so a framework release reaches an
152
+ // existing workspace on `bun update` — the workspace file only extends it and scopes its own files.
152
153
  expect(await readFile(path.join(root, "workspace/biome.json"), "utf8")).toContain(
153
- "./node_modules/@akanjs/devkit/lint/no-import-client-functions.grit",
154
+ '"extends": ["@akanjs/devkit/biome.base.json"]',
154
155
  );
155
156
  });
156
157
 
@@ -758,6 +759,29 @@ describe("Workspace and app executor environment contracts", () => {
758
759
  expect(offsetMinimalStart.env.AKAN_PUBLIC_CLIENT_PORT).toBe("8286");
759
760
  expect(offsetMinimalStart.env.AKAN_PUBLIC_SERVER_PORT).toBe("8286");
760
761
  });
762
+
763
+ test("pins the start command to development so an ambient NODE_ENV cannot pick the production router", async () => {
764
+ const root = await makeTempRoot();
765
+ process.env.AKAN_PUBLIC_REPO_NAME = "repo";
766
+ process.env.AKAN_PUBLIC_SERVE_DOMAIN = "example.com";
767
+ process.env.AKAN_PUBLIC_ENV = "local";
768
+ await writeJson(path.join(root, "package.json"), rootPackageJson());
769
+ await mkdir(path.join(root, "apps/ambient"), { recursive: true });
770
+ await writeFile(path.join(root, "apps/ambient/akan.config.ts"), "export default {};\n");
771
+
772
+ const originalNodeEnv = process.env.NODE_ENV;
773
+ process.env.NODE_ENV = "production";
774
+ try {
775
+ const workspace = new WorkspaceExecutor({ workspaceRoot: root, repoName: "repo" });
776
+ const started = await AppExecutor.from(workspace, "ambient").prepareCommand("start");
777
+ expect(started.env.NODE_ENV).toBe("development");
778
+ // The builder runs in this process and bakes NODE_ENV into the dev bundles it emits.
779
+ expect(process.env.NODE_ENV).toBe("development");
780
+ } finally {
781
+ if (originalNodeEnv === undefined) delete process.env.NODE_ENV;
782
+ else process.env.NODE_ENV = originalNodeEnv;
783
+ }
784
+ });
761
785
  });
762
786
 
763
787
  describe("PkgExecutor package generation", () => {
@@ -771,7 +795,7 @@ describe("PkgExecutor package generation", () => {
771
795
  exports: { "./extra": { import: "./extra.ts" } },
772
796
  peerDependencies: { react: "19.0.0" },
773
797
  peerDependenciesMeta: { react: { optional: true } },
774
- optionalDependencies: { sharp: "1.0.0" },
798
+ optionalDependencies: { "@sample/native": "1.0.0" },
775
799
  });
776
800
 
777
801
  const workspace = new WorkspaceExecutor({ workspaceRoot: root, repoName: "repo" });
@@ -781,12 +805,12 @@ describe("PkgExecutor package generation", () => {
781
805
  expect(distPackageJson).toMatchObject({
782
806
  name: "@sample/tool",
783
807
  type: "module",
784
- engines: { bun: ">=1.3.13" },
808
+ engines: { bun: ">=1.4.0" },
785
809
  dependencies: { lodash: "4.0.0" },
786
810
  devDependencies: { typescript: "6.0.0" },
787
811
  peerDependencies: { react: "19.0.0" },
788
812
  peerDependenciesMeta: { react: { optional: true } },
789
- optionalDependencies: { sharp: "1.0.0" },
813
+ optionalDependencies: { "@sample/native": "1.0.0" },
790
814
  });
791
815
  expect(distPackageJson.exports?.["."]).toEqual({
792
816
  import: "./index.ts",
@@ -855,3 +879,16 @@ describe("scan info construction", () => {
855
879
  expect(info.file.document.scalars.has("money")).toBe(true);
856
880
  });
857
881
  });
882
+
883
+ describe("AppExecutor.shouldRelaunchForAppEnv", () => {
884
+ test("relaunches when the process was born without the app name, or with another app's", () => {
885
+ expect(AppExecutor.shouldRelaunchForAppEnv("sceny", undefined, undefined)).toBe(true);
886
+ expect(AppExecutor.shouldRelaunchForAppEnv("sceny", "other", undefined)).toBe(true);
887
+ });
888
+
889
+ test("runs in place when the name was present at birth, and never relaunches twice", () => {
890
+ expect(AppExecutor.shouldRelaunchForAppEnv("sceny", "sceny", undefined)).toBe(false);
891
+ expect(AppExecutor.shouldRelaunchForAppEnv("sceny", undefined, "1")).toBe(false);
892
+ expect(AppExecutor.shouldRelaunchForAppEnv("sceny", "other", "1")).toBe(false);
893
+ });
894
+ });
package/executors.ts CHANGED
@@ -42,6 +42,7 @@ import { AkanAppConfig, AkanLibConfig, decreaseBuildNum, increaseBuildNum } from
42
42
  import { FileSys } from "./fileSys";
43
43
  import { getDirname } from "./getDirname";
44
44
  import { Linter } from "./linter";
45
+ import { resolveRepoName } from "./repoIdentity";
45
46
  import { AppInfo, LibInfo, PkgInfo, WorkspaceInfo } from "./scanInfo";
46
47
  import { Spinner } from "./spinner";
47
48
  // Type-only: the implementation is loaded on demand in `getTypeChecker` to keep `typescript` out of
@@ -232,7 +233,7 @@ export class Executor {
232
233
  );
233
234
  });
234
235
  proc.on("exit", (code, signal) => {
235
- if (!!code || signal)
236
+ if (code || signal)
236
237
  reject(
237
238
  new CommandExecutionError({
238
239
  command,
@@ -341,7 +342,7 @@ export class Executor {
341
342
  );
342
343
  });
343
344
  proc.on("exit", (code, signal) => {
344
- if (!!code || signal)
345
+ if (code || signal)
345
346
  reject(
346
347
  new CommandExecutionError({
347
348
  command: modulePath,
@@ -788,7 +789,7 @@ export class WorkspaceExecutor extends Executor {
788
789
  static #execs = new Map<string, WorkspaceExecutor>();
789
790
  static fromRoot({
790
791
  workspaceRoot = process.cwd(),
791
- repoName = path.basename(process.cwd()),
792
+ repoName = resolveRepoName(workspaceRoot),
792
793
  }: {
793
794
  workspaceRoot?: string;
794
795
  repoName?: string;
@@ -1283,6 +1284,10 @@ interface AppExecutorOptions {
1283
1284
  workspace?: WorkspaceExecutor;
1284
1285
  name: string;
1285
1286
  }
1287
+ // Captured at module load, before the command dispatcher patches the resolved app name into `process.env` —
1288
+ // the relaunch guard below must judge what bundler macros will see, which is the env this process was born with.
1289
+ const bootAppName = process.env.AKAN_PUBLIC_APP_NAME;
1290
+
1286
1291
  export class AppExecutor extends SysExecutor {
1287
1292
  // `typescript` costs ~65MB resident, so keep it out of the module graph of every process that only
1288
1293
  // ever imports an executor. Route validation is the sole consumer here and is already async.
@@ -1341,6 +1346,34 @@ export class AppExecutor extends SysExecutor {
1341
1346
  ...env,
1342
1347
  };
1343
1348
  }
1349
+ /**
1350
+ * Bun macros snapshot `process.env` at process start, so the env `prepareCommand` publishes later is invisible
1351
+ * to them — and any hand-written `@libs/<lib>/server` import puts that lib's env files (module-scope `getEnv()`)
1352
+ * into the `getSerializedSignal` macro graph. `AKAN_PUBLIC_APP_NAME` is the one required key no workspace root
1353
+ * `.env` can carry, so the build relaunches itself once with it in place; the child sees it from birth.
1354
+ *
1355
+ * The decision compares the boot snapshot, never the live `process.env`: the command dispatcher writes the
1356
+ * resolved app into `process.env.AKAN_PUBLIC_APP_NAME` before any command method runs, which satisfies runtime
1357
+ * readers but not macros — trusting it is how this relaunch shipped once without ever firing.
1358
+ */
1359
+ static shouldRelaunchForAppEnv(
1360
+ appName: string,
1361
+ bootName: string | undefined = bootAppName,
1362
+ marker: string | undefined = process.env.AKAN_BUILD_RELAUNCHED,
1363
+ ) {
1364
+ if (marker === "1") return false;
1365
+ return bootName !== appName;
1366
+ }
1367
+ async relaunchWithAppEnv(): Promise<number | null> {
1368
+ if (!AppExecutor.shouldRelaunchForAppEnv(this.name)) return null;
1369
+ const child = Bun.spawn([process.execPath, ...process.argv.slice(1)], {
1370
+ env: { ...this.getCommandEnv(), AKAN_PUBLIC_APP_NAME: this.name, AKAN_BUILD_RELAUNCHED: "1" },
1371
+ stdin: "inherit",
1372
+ stdout: "inherit",
1373
+ stderr: "inherit",
1374
+ });
1375
+ return await child.exited;
1376
+ }
1344
1377
  async prepareCommand(type: "build" | "start") {
1345
1378
  const akanConfig = await this.getConfig();
1346
1379
  const databaseMode = process.env.AKAN_DATABASE_MODE ?? akanConfig.defaultDatabaseMode ?? "single";
@@ -1363,6 +1396,12 @@ export class AppExecutor extends SysExecutor {
1363
1396
  this.cp("public", `${this.dist.cwdPath}/public`, { dereference: true }),
1364
1397
  ]);
1365
1398
  } else await this.removeDir(".akan");
1399
+ //? `akan start` is the dev server, and it must say so rather than inherit an answer. Bun auto-loads the
1400
+ //? workspace `.env`, so NODE_ENV=production can reach this process without ever being exported in a shell —
1401
+ //? and a dev server that believes it is production serves from the production route cache, whose every
1402
+ //? route throws because a dev artifact carries no routes manifest. Written into `process.env` and not only
1403
+ //? into the child env because the builder runs here and bakes NODE_ENV into the bundles it emits.
1404
+ if (type === "start") process.env.NODE_ENV = "development";
1366
1405
  const devPort = type === "start" ? (await this.getDevPort()).toString() : undefined;
1367
1406
  const env = this.getCommandEnv({
1368
1407
  AKAN_COMMAND_TYPE: type,
@@ -1806,7 +1845,7 @@ export class PkgExecutor extends Executor {
1806
1845
  default: "./index.ts",
1807
1846
  },
1808
1847
  },
1809
- engines: { bun: ">=1.3.13" },
1848
+ engines: { bun: ">=1.4.0" },
1810
1849
  ...dependencyMaps,
1811
1850
  };
1812
1851
  await Promise.all([this.dist.writeJson("package.json", distPkgJson), this.writeJson("package.json", distPkgJson)]);
@@ -47,11 +47,24 @@ export class AllRoutesBuilder {
47
47
  this.#app.verbose(`[build-all] discovered ${seedIndex.entries.length} routes`);
48
48
  this.#discovery = await GraphClientEntryDiscovery.create(this.#app);
49
49
 
50
+ // Discovery first, bundling second. Chunk splitting only dedupes within one `Bun.build`, so a
51
+ // dependency shared by entries from different routes was emitted once per route that reached it.
52
+ // Discovery is cached and does no bundling, so collecting every entry up front costs almost nothing.
53
+ const allEntries: string[] = [];
54
+ const seen = new Set<string>();
50
55
  for (const entry of seedIndex.entries) {
51
56
  const seeds = Array.from(new Set([...seedIndex.globalLayoutFiles, ...entry.seeds]));
52
- const delta = await this.#buildRoute(entry.routeId, seeds);
53
- this.#mergeRoute(entry.routeId, delta);
57
+ for (const discovered of await this.#discovery.discover(seeds)) {
58
+ if (seen.has(discovered)) continue;
59
+ seen.add(discovered);
60
+ allEntries.push(discovered);
61
+ }
62
+ this.#routeIds.push(entry.routeId);
54
63
  }
64
+ this.#app.verbose(`[build-all] ${allEntries.length} client entries across ${this.#routeIds.length} routes`);
65
+
66
+ const delta = await this.#buildEntries(allEntries);
67
+ this.#mergeDelta(delta);
55
68
  this.#merged.knownEntries = Array.from(this.#knownSet);
56
69
 
57
70
  const manifest: RoutesManifest = {
@@ -76,28 +89,34 @@ export class AllRoutesBuilder {
76
89
  return { manifest, manifestPath, seedIndex };
77
90
  }
78
91
 
79
- async #buildRoute(routeId: string, seeds: string[]): Promise<BuildRouteClientResult> {
92
+ async #buildEntries(entries: string[]): Promise<BuildRouteClientResult> {
80
93
  if (!this.#discovery) throw new Error("[build-all] client entry discovery is not initialized");
94
+ if (entries.length === 0)
95
+ return {
96
+ manifestDelta: {},
97
+ ssrManifestDelta: { moduleLoading: null, moduleMap: {} },
98
+ newEntries: [],
99
+ clientDeps: [],
100
+ };
81
101
  const started = Date.now();
82
102
  const delta = await new RouteClientBuilder({
83
103
  app: this.#app,
84
- routeId,
85
- seeds,
104
+ seeds: [],
105
+ entries,
86
106
  artifact: this.#artifact,
87
107
  knownEntries: this.#knownSet,
88
108
  discovery: this.#discovery,
89
109
  command: this.#command,
90
110
  }).build();
91
- this.#app.verbose(`[build-all] ${routeId} +${delta.newEntries.length} entries (${Date.now() - started}ms)`);
111
+ this.#app.verbose(`[build-all] bundled ${delta.newEntries.length} entries (${Date.now() - started}ms)`);
92
112
  return delta;
93
113
  }
94
114
 
95
- #mergeRoute(routeId: string, delta: BuildRouteClientResult): void {
115
+ #mergeDelta(delta: BuildRouteClientResult): void {
96
116
  for (const [key, row] of Object.entries(delta.manifestDelta)) this.#merged.clientManifest[key] = row;
97
117
  for (const [url, byName] of Object.entries(delta.ssrManifestDelta.moduleMap)) {
98
118
  this.#merged.ssrManifest.moduleMap[url] = byName;
99
119
  }
100
120
  for (const abs of delta.newEntries) this.#knownSet.add(abs);
101
- this.#routeIds.push(routeId);
102
121
  }
103
122
  }
@@ -82,16 +82,42 @@ describe("route client store bootstrap", () => {
82
82
  });
83
83
 
84
84
  test("bundles akan fetch into production SSR client chunks", () => {
85
- expect(RouteClientBuilder.resolveSsrClientExternalOptions("start")).toMatchObject({
85
+ expect(RouteClientBuilder.resolveSsrClientBundleOptions("start")).toMatchObject({
86
86
  external: expect.arrayContaining(["akanjs/fetch"]),
87
87
  externalSubpaths: ["akanjs/fetch"],
88
88
  });
89
89
 
90
- expect(RouteClientBuilder.resolveSsrClientExternalOptions("build")).toEqual({
90
+ expect(RouteClientBuilder.resolveSsrClientBundleOptions("build")).toEqual({
91
+ target: "bun",
91
92
  external: ["react", "react-dom", "react-dom/client", "react/jsx-runtime", "react/jsx-dev-runtime"],
92
93
  });
93
94
  });
94
95
 
96
+ test("targets the server so SSR client chunks never resolve a browser export condition", async () => {
97
+ const root = await makeTempRoot();
98
+ const pkgDir = path.join(root, "node_modules/dom-conditioned-pkg");
99
+ await write(
100
+ path.join(pkgDir, "package.json"),
101
+ JSON.stringify({
102
+ name: "dom-conditioned-pkg",
103
+ type: "module",
104
+ exports: { ".": { browser: "./index.dom.js", default: "./index.js" } },
105
+ }),
106
+ );
107
+ await write(path.join(pkgDir, "index.dom.js"), 'export const element = document.createElement("i");\n');
108
+ await write(path.join(pkgDir, "index.js"), "export const element = null;\n");
109
+ const entry = path.join(root, "entry.ts");
110
+ await write(entry, 'export { element } from "dom-conditioned-pkg";\n');
111
+
112
+ for (const command of ["start", "build"] as const) {
113
+ const { target } = RouteClientBuilder.resolveSsrClientBundleOptions(command);
114
+ const built = await Bun.build({ entrypoints: [entry], target, format: "esm" });
115
+
116
+ expect(built.success).toBe(true);
117
+ expect(await built.outputs[0].text()).not.toContain("document.createElement");
118
+ }
119
+ });
120
+
95
121
  test("rewrites SSR external imports to runtime aliases", () => {
96
122
  const source = [
97
123
  'import React, { useState } from "react";',
@@ -55,6 +55,8 @@ export interface BuildClientResult {
55
55
  rscClientUrl: string;
56
56
  }
57
57
 
58
+ export type ClientBundleTarget = "browser" | "bun";
59
+
58
60
  export const CLIENT_BUNDLE_NAMING = {
59
61
  entry: "[name]-[hash].[ext]",
60
62
  chunk: "chunks/[hash].[ext]",
@@ -79,6 +81,8 @@ export interface BundleClientEntriesOptions {
79
81
  }
80
82
 
81
83
  export interface BundleClientEntriesInternalOptions extends BundleClientEntriesOptions {
84
+ /** Module-resolution target. `"bun"` for the server-executed `client-ssr` bundle, `"browser"` otherwise. */
85
+ target?: ClientBundleTarget;
82
86
  external?: readonly string[];
83
87
  externalSubpaths?: readonly string[];
84
88
  externalAliases?: Partial<Record<string, string>>;
@@ -7,6 +7,7 @@ import {
7
7
  type BundleClientEntriesInternalOptions,
8
8
  type BundleClientEntriesResult,
9
9
  CLIENT_BUNDLE_NAMING,
10
+ type ClientBundleTarget,
10
11
  type ClientManifest,
11
12
  type MetafileOutput,
12
13
  type OpaqueEntryAliases,
@@ -25,6 +26,7 @@ export class ClientEntriesBundler {
25
26
  #externalSubpaths: readonly string[];
26
27
  #externalAliases: Partial<Record<string, string>>;
27
28
  #command: "build" | "start";
29
+ #target: ClientBundleTarget;
28
30
  #outputSubdir: string;
29
31
  #reactFastRefresh: boolean;
30
32
  #artifactDir: string;
@@ -47,6 +49,7 @@ export class ClientEntriesBundler {
47
49
  this.#externalSubpaths = options.externalSubpaths ?? [];
48
50
  this.#externalAliases = options.externalAliases ?? {};
49
51
  this.#command = options.command ?? "start";
52
+ this.#target = options.target ?? "browser";
50
53
  this.#outputSubdir = options.outputSubdir ?? "client";
51
54
  this.#reactFastRefresh = options.reactFastRefresh ?? false;
52
55
  this.#artifactDir = `${this.#command === "build" ? this.#app.dist.cwdPath : this.#app.cwdPath}/.akan/artifact`;
@@ -61,7 +64,7 @@ export class ClientEntriesBundler {
61
64
  entrypoints: this.#opaqueEntries.entries,
62
65
  outdir: this.#outdir,
63
66
  splitting: true,
64
- target: "browser",
67
+ target: this.#target,
65
68
  format: "esm",
66
69
  naming: CLIENT_BUNDLE_NAMING,
67
70
  metafile: true,