@akanjs/devkit 3.0.1 → 3.0.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.
Files changed (61) hide show
  1. package/CHANGELOG.md +15 -2
  2. package/akanApp/akanApp.host.test.ts +62 -0
  3. package/akanApp/akanApp.host.ts +13 -2
  4. package/akanApp/devHostPolicy.test.ts +14 -0
  5. package/akanApp/devHostPolicy.ts +11 -0
  6. package/akanConfig/akanConfig.test.ts +4 -1
  7. package/akanConfig/akanConfig.ts +3 -1
  8. package/akanContext.ts +18 -1
  9. package/biome.base.json +8 -1
  10. package/commandDecorators/command.ts +3 -2
  11. package/commandDecorators/commandDecorators.test.ts +7 -0
  12. package/createTunnel.test.ts +51 -0
  13. package/createTunnel.ts +32 -14
  14. package/csrE2e/csrPageStack.e2e.test.ts +12 -3
  15. package/executors.test.ts +329 -4
  16. package/executors.ts +49 -47
  17. package/frontendBuild/csrArtifactBuilder.test.ts +34 -0
  18. package/frontendBuild/csrArtifactBuilder.ts +14 -8
  19. package/frontendBuild/csrDevModuleCompiler.ts +0 -1
  20. package/frontendBuild/csrDevResolver.ts +0 -2
  21. package/frontendBuild/devGeneratedIndexSync.test.ts +13 -1
  22. package/frontendBuild/devGeneratedIndexSync.ts +21 -4
  23. package/frontendBuild/frontendBuild.test.ts +1 -1
  24. package/jsonFormatter.test.ts +64 -0
  25. package/jsonFormatter.ts +78 -0
  26. package/lint/__fixtures__/README.md +12 -3
  27. package/lint/__fixtures__/no-double-brace-placeholder/bad.ts +22 -0
  28. package/lint/__fixtures__/no-double-brace-placeholder/fixture.json +1 -0
  29. package/lint/__fixtures__/no-double-brace-placeholder/good.ts +15 -0
  30. package/lint/__fixtures__/no-inline-color/bad.tsx +17 -0
  31. package/lint/__fixtures__/no-inline-color/good.tsx +13 -0
  32. package/lint/__fixtures__/no-throw-raw-error/bad.tsx +8 -0
  33. package/lint/__fixtures__/no-throw-raw-error/good.tsx +8 -1
  34. package/lint/__fixtures__/no-unguarded-endpoint/bad.ts +20 -0
  35. package/lint/__fixtures__/no-unguarded-endpoint/fixture.json +1 -0
  36. package/lint/__fixtures__/no-unguarded-endpoint/good.ts +28 -0
  37. package/lint/__fixtures__/no-web-only-api-outside-webkit/bad.tsx +7 -7
  38. package/lint/gritRules.test.ts +23 -15
  39. package/lint/no-double-brace-placeholder.grit +15 -0
  40. package/lint/no-inline-color.grit +21 -5
  41. package/lint/no-throw-raw-error.grit +18 -33
  42. package/lint/no-unguarded-endpoint.grit +73 -0
  43. package/manifestDependencies.ts +65 -0
  44. package/mobile/desktopBin.test.ts +1 -1
  45. package/mobile/desktopServerStage.test.ts +7 -4
  46. package/mobile/nativeApp.test.ts +57 -8
  47. package/mobile/nativeApp.ts +27 -9
  48. package/mobile/nativeConfig.test.ts +26 -4
  49. package/mobile/nativeConfig.ts +19 -1
  50. package/package.json +2 -2
  51. package/platformTest/LinuxDockerTarget.ts +1 -1
  52. package/platformTest/PlatformTestTarget.ts +3 -1
  53. package/platformTest/WindowsSshTarget.ts +1 -1
  54. package/qualityScanner.test.ts +51 -0
  55. package/qualityScanner.ts +22 -1
  56. package/scanInfo.ts +14 -2
  57. package/testHelpers.ts +17 -0
  58. package/transforms/transforms.test.ts +13 -0
  59. package/tsconfig.json +4 -15
  60. package/workspaceLayout.test.ts +78 -1
  61. package/workspaceLayout.ts +19 -2
package/executors.test.ts CHANGED
@@ -1,15 +1,33 @@
1
- import { describe, expect, test } from "bun:test";
1
+ import { describe, expect, spyOn, test } from "bun:test";
2
2
  import { existsSync } from "node:fs";
3
3
  import { lstat, mkdir, readFile, readlink, rm, stat, writeFile } from "node:fs/promises";
4
4
  import path from "node:path";
5
5
  import { AkanAppConfig } from "./akanConfig";
6
- import { AppExecutor, CommandExecutionError, Executor, PkgExecutor, WorkspaceExecutor } from "./executors";
6
+ import {
7
+ AppExecutor,
8
+ CommandExecutionError,
9
+ Executor,
10
+ type LibExecutor,
11
+ PkgExecutor,
12
+ WorkspaceExecutor,
13
+ } from "./executors";
14
+ import { DevGeneratedIndexSync } from "./frontendBuild/devGeneratedIndexSync";
7
15
  import { AppInfo } from "./scanInfo";
8
- import { isolateEnv, tempDirs, writeJson, writeText } from "./testHelpers";
16
+ import {
17
+ createTempLib,
18
+ formatWithBiome,
19
+ hasBiome,
20
+ isolateEnv,
21
+ tempDirs,
22
+ tempRoots,
23
+ writeJson,
24
+ writeText,
25
+ } from "./testHelpers";
9
26
  import type { PackageJson } from "./types";
10
27
 
11
28
  isolateEnv();
12
29
  const makeTempRoot = tempDirs("akan-devkit-");
30
+ const trackRoot = tempRoots();
13
31
 
14
32
  const PAGE_SOURCE = "export default function Page() {\n return null;\n}\n";
15
33
 
@@ -309,11 +327,16 @@ describe("Workspace and app executor environment contracts", () => {
309
327
  expect(env.EXTRA).toBe("ok");
310
328
 
311
329
  const shellOperationMode = process.env.AKAN_PUBLIC_OPERATION_MODE;
330
+ const shellServerPort = process.env.AKAN_PUBLIC_SERVER_PORT;
312
331
  process.env.AKAN_PUBLIC_OPERATION_MODE = "local";
332
+ process.env.AKAN_PUBLIC_SERVER_PORT = "8282";
313
333
  const prepared = await app.prepareCommand("build");
314
334
  const bakedOperationMode = process.env.AKAN_PUBLIC_OPERATION_MODE;
335
+ const bakedServerPort = process.env.AKAN_PUBLIC_SERVER_PORT;
315
336
  if (shellOperationMode === undefined) delete process.env.AKAN_PUBLIC_OPERATION_MODE;
316
337
  else process.env.AKAN_PUBLIC_OPERATION_MODE = shellOperationMode;
338
+ if (shellServerPort !== undefined) process.env.AKAN_PUBLIC_SERVER_PORT = shellServerPort;
339
+ expect(bakedServerPort).toBeUndefined();
317
340
  expect(bakedOperationMode).toBeUndefined();
318
341
  expect(prepared.env.AKAN_COMMAND_TYPE).toBe("build");
319
342
  expect(prepared.env.AKAN_PUBLIC_BASE_PATHS).toBe("admin");
@@ -327,7 +350,7 @@ describe("Workspace and app executor environment contracts", () => {
327
350
  expect((await stat(path.join(root, "dist/apps/demo/public"))).isDirectory()).toBe(true);
328
351
  });
329
352
 
330
- test("akan start clears the dev output and keeps native builds, update releases and the bin downloads", async () => {
353
+ test("akan start clears the dev output and keeps the bin downloads and a native folder from before dist/native", async () => {
331
354
  const root = await makeTempRoot();
332
355
  process.env.AKAN_PUBLIC_REPO_NAME = "repo";
333
356
  process.env.AKAN_PUBLIC_SERVE_DOMAIN = "example.com";
@@ -1055,3 +1078,305 @@ describe("SysExecutor module listing", () => {
1055
1078
  ]);
1056
1079
  });
1057
1080
  });
1081
+
1082
+ interface ScannableLibOptions {
1083
+ root?: Pick<PackageJson, "dependencies" | "devDependencies">;
1084
+ manifest?: Partial<PackageJson>;
1085
+ config?: string;
1086
+ files?: Record<string, string>;
1087
+ }
1088
+
1089
+ //* `LibInfo.libInfos` caches a scan by lib name for the whole file, so every test passes a name of its own.
1090
+ const createScannableLib = async (
1091
+ libName: string,
1092
+ { root = {}, manifest = {}, config = "export default {};\n", files = {} }: ScannableLibOptions = {},
1093
+ ) => {
1094
+ const temp = trackRoot(await createTempLib(libName));
1095
+ const libDir = path.join(temp.root, "libs", libName);
1096
+ await writeJson(path.join(temp.root, "package.json"), {
1097
+ name: "repo",
1098
+ version: "1.0.0",
1099
+ description: "repo",
1100
+ ...root,
1101
+ });
1102
+ await writeJson(path.join(libDir, "package.json"), {
1103
+ type: "module",
1104
+ name: `@${libName}`,
1105
+ version: "0.0.1",
1106
+ ...manifest,
1107
+ });
1108
+ await writeJson(path.join(libDir, "tsconfig.json"), { compilerOptions: { target: "ESNext", paths: {} } });
1109
+ await writeText(path.join(libDir, "akan.config.ts"), config);
1110
+ await mkdir(path.join(libDir, "lib", "__scalar"), { recursive: true });
1111
+ for (const [file, content] of Object.entries(files)) await writeText(path.join(libDir, file), content);
1112
+ return { ...temp, libDir };
1113
+ };
1114
+
1115
+ describe("SysExecutor scan", () => {
1116
+ test("gives a facet folder with nothing to export an empty barrel, so a barrel a deleted file left heals", async () => {
1117
+ const { lib } = await createScannableLib("barrelheal", {
1118
+ files: {
1119
+ "common/index.ts": 'export * from "./commonLogic";\n',
1120
+ "webkit/thing.helper.ts": "export const thing = 1;\n",
1121
+ },
1122
+ });
1123
+
1124
+ await lib.scan();
1125
+
1126
+ expect(await lib.readFile("common/index.ts")).toBe("export {};\n");
1127
+ expect(await lib.readFile("webkit/index.ts")).toBe("export {};\n");
1128
+ expect(await lib.exists("ui")).toBe(false);
1129
+ });
1130
+
1131
+ test("imports into the generated lib/dict.ts and lib/srv.ts only the helpers their code calls", async () => {
1132
+ const { lib: bare } = await createScannableLib("nomodulelib");
1133
+ await bare.scan();
1134
+ expect(await bare.readFile("lib/dict.ts")).toContain(
1135
+ 'import { makeDictionary, makeTrans, dictionary as base } from "akanjs/dictionary";',
1136
+ );
1137
+ const bareSrv = await bare.readFile("lib/srv.ts");
1138
+ for (const unused of ["ServiceModel", '"./cnst"', '"./db"']) expect(bareSrv).not.toContain(unused);
1139
+
1140
+ const { lib: full } = await createScannableLib("allmodulelib", {
1141
+ files: {
1142
+ "lib/post/post.constant.ts": "export class Post {}\n",
1143
+ "lib/post/post.service.ts": "export class PostService {}\n",
1144
+ "lib/_mail/mail.service.ts": "export class MailService {}\n",
1145
+ "lib/__scalar/money/money.constant.ts": "export class Money {}\n",
1146
+ },
1147
+ });
1148
+ await full.scan();
1149
+ expect(await full.readFile("lib/dict.ts")).toContain(
1150
+ "import { makeDictionary, makeTrans, registerScalarTrans, registerServiceTrans, registerModelTrans, dictionary as base }",
1151
+ );
1152
+ const fullSrv = await full.readFile("lib/srv.ts");
1153
+ expect(fullSrv).toContain('import { ServiceModel } from "akanjs/service";');
1154
+ expect(fullSrv).toContain('import * as cnst from "./cnst";\nimport * as db from "./db";');
1155
+ });
1156
+
1157
+ test.skipIf(!hasBiome)("writes barrels, module indexes and akan.lib.json the way Biome prints them", async () => {
1158
+ const component = "export const C = () => null;\n";
1159
+ const uiFiles = ["QRCode", "Qa", "SSOButton", "Select", "Icon10", "Icon2"].map((name) => [
1160
+ `ui/${name}.tsx`,
1161
+ component,
1162
+ ]);
1163
+ const srvkitFiles = ["aB", "aa", "a10", "a9"].map((name) => [`srvkit/${name}.ts`, "export const x = 1;\n"]);
1164
+ const moduleFiles = ["Template", "Unit", "View"].map((role) => [`lib/post/Post.${role}.tsx`, component]);
1165
+ const { root, lib, libDir } = await createScannableLib("biomestable", {
1166
+ files: Object.fromEntries([
1167
+ ...uiFiles,
1168
+ ...srvkitFiles,
1169
+ ...moduleFiles,
1170
+ ["lib/post/post.constant.ts", "export class Post {}\n"],
1171
+ ]),
1172
+ });
1173
+ expect(await formatWithBiome('export * from "./b";\nexport * from "./a";\n', "libs/x/srvkit/index.ts")).toBe(
1174
+ 'export * from "./a";\nexport * from "./b";\n',
1175
+ );
1176
+
1177
+ await lib.scan();
1178
+
1179
+ expect(await lib.readFile("ui/index.ts")).toBe(
1180
+ ["Icon2", "Icon10", "Qa", "QRCode", "Select", "SSOButton"].map((name) => `export * from "./${name}";\n`).join(""),
1181
+ );
1182
+ expect(await lib.readFile("srvkit/index.ts")).toBe(
1183
+ ["a9", "a10", "aa", "aB"].map((name) => `export * from "./${name}";\n`).join(""),
1184
+ );
1185
+ for (const file of ["ui/index.ts", "srvkit/index.ts", "lib/post/index.ts", "akan.lib.json"]) {
1186
+ const generated = await lib.readFile(file);
1187
+ expect(await formatWithBiome(generated, `libs/biomestable/${file}`)).toBe(generated);
1188
+ }
1189
+ const devSync = new DevGeneratedIndexSync({ workspaceRoot: root });
1190
+ const events = ["ui/QRCode.tsx", "srvkit/a9.ts", "lib/post"].map((file) => path.join(libDir, file));
1191
+ expect(await devSync.syncForBatch(events)).toEqual({ changedFiles: [], errors: [] });
1192
+ });
1193
+
1194
+ test("refuses a hand-written ui/index.tsx beside the generated barrel, naming the file and the fix", async () => {
1195
+ const { lib } = await createScannableLib("shadowedbarrel", {
1196
+ files: {
1197
+ "ui/Chat.tsx": "export const Chat = () => null;\n",
1198
+ "ui/index.tsx": 'export { Chat } from "./Chat";\n',
1199
+ "ui/Page/index.tsx": 'export { Inner } from "./Inner";\n',
1200
+ "ui/Page/index_.tsx": '"use client";\nexport { Inner } from "./Inner";\n',
1201
+ },
1202
+ });
1203
+
1204
+ const error = await lib.scan().catch((caught: unknown) => caught);
1205
+
1206
+ expect(error).toBeInstanceOf(Error);
1207
+ const { message } = error as Error;
1208
+ expect(message).toContain("libs/shadowedbarrel/ui/index.tsx: shadows the generated ui/index.ts");
1209
+ expect(message).toContain("Delete it");
1210
+ expect(message).not.toContain("ui/Page/");
1211
+ });
1212
+ });
1213
+
1214
+ describe("SysExecutor dependency sync", () => {
1215
+ const root: Pick<PackageJson, "dependencies" | "devDependencies"> = {
1216
+ dependencies: {
1217
+ chalk: "^5.6.2",
1218
+ esbuild: "^0.25.0",
1219
+ lodash: "^4.17.21",
1220
+ react: "19.3.0",
1221
+ "react-dom": "19.3.0",
1222
+ sharp: "^0.34.0",
1223
+ zod: "3.25.76",
1224
+ },
1225
+ devDependencies: { dayjs: "^1.11.20", "happy-dom": "20.11.2", typescript: "^6.0.3" },
1226
+ };
1227
+ const readManifest = async (libDir: string) =>
1228
+ (await Bun.file(path.join(libDir, "package.json")).json()) as PackageJson;
1229
+ const silenced = (lib: LibExecutor) => ({
1230
+ info: spyOn(lib.logger, "info").mockImplementation(() => undefined),
1231
+ warn: spyOn(lib.logger, "warn").mockImplementation(() => undefined),
1232
+ });
1233
+
1234
+ test("prunes what the root declares and nothing imports, and keeps and warns once about what it does not", async () => {
1235
+ const { lib, libDir } = await createScannableLib("prunedeps", {
1236
+ root,
1237
+ manifest: {
1238
+ dependencies: { lodash: "^4.0.0", "react-dom": "19.2.6", "left-pad": "^1.3.0", "is-odd": "^3.0.1" },
1239
+ devDependencies: {},
1240
+ },
1241
+ files: { "srvkit/pad.ts": 'import padStart from "lodash/padStart";\nexport const pad = padStart;\n' },
1242
+ });
1243
+ const { info, warn } = silenced(lib);
1244
+
1245
+ await lib.scan();
1246
+
1247
+ expect((await readManifest(libDir)).dependencies).toEqual({
1248
+ lodash: "^4.17.21",
1249
+ "left-pad": "^1.3.0",
1250
+ "is-odd": "^3.0.1",
1251
+ });
1252
+ expect(info).toHaveBeenCalledTimes(1);
1253
+ expect(info.mock.calls[0]?.[0]).toContain(": react-dom (");
1254
+ expect(warn).toHaveBeenCalledTimes(1);
1255
+ expect(warn.mock.calls[0]?.[0]).toContain("libs/prunedeps/package.json lists left-pad, is-odd");
1256
+ });
1257
+
1258
+ test("keeps what externalLibs, trustedDependencies or akan.keepDependencies names", async () => {
1259
+ const akan = { keepDependencies: ["chalk"] };
1260
+ const { lib, libDir } = await createScannableLib("keptdeps", {
1261
+ root,
1262
+ config: 'export default { externalLibs: ["sharp"], trustedDependencies: ["esbuild"] };\n',
1263
+ manifest: {
1264
+ dependencies: { sharp: "^0.33.0", esbuild: "^0.24.0", chalk: "^5.0.0", "react-dom": "19.2.6" },
1265
+ devDependencies: {},
1266
+ akan,
1267
+ },
1268
+ });
1269
+ silenced(lib);
1270
+
1271
+ await lib.scan();
1272
+
1273
+ const manifest = await readManifest(libDir);
1274
+ expect(manifest.dependencies).toEqual({ sharp: "^0.34.0", esbuild: "^0.25.0", chalk: "^5.6.2" });
1275
+ expect(manifest.akan).toEqual(akan);
1276
+ });
1277
+
1278
+ test("gives every surviving entry the root version, leaving peer, optional and the akan source alone", async () => {
1279
+ const untouched = {
1280
+ peerDependencies: { react: "^19.0.0" },
1281
+ optionalDependencies: { fsevents: "^2.3.3" },
1282
+ akan: { source: { origin: "akanjs", sha: "3.0.0", hash: "0f0f", syncedAt: "2026-01-01T00:00:00.000Z" } },
1283
+ };
1284
+ const { lib, libDir } = await createScannableLib("realigneddeps", {
1285
+ root,
1286
+ manifest: {
1287
+ dependencies: { react: "19.2.7", lodash: "^4.0.0" },
1288
+ devDependencies: { dayjs: "^1.11.13" },
1289
+ ...untouched,
1290
+ },
1291
+ files: {
1292
+ "ui/Thing.tsx": 'import { useState } from "react";\nexport const Thing = () => useState(0);\n',
1293
+ "srvkit/pick.ts": 'import pick from "lodash/pick";\nexport const pickOf = pick;\n',
1294
+ },
1295
+ });
1296
+ silenced(lib);
1297
+
1298
+ await lib.scan();
1299
+
1300
+ const manifest = await readManifest(libDir);
1301
+ expect(manifest.dependencies).toEqual({ react: "19.3.0", lodash: "^4.17.21" });
1302
+ expect(manifest.devDependencies).toEqual({ dayjs: "^1.11.20" });
1303
+ expect(manifest).toMatchObject(untouched);
1304
+ });
1305
+
1306
+ test("keeps the existing order, appends new names sorted, and a second scan rewrites nothing", async () => {
1307
+ const { lib, libDir } = await createScannableLib("ordereddeps", {
1308
+ root,
1309
+ manifest: { dependencies: { zod: "3.25.76", lodash: "^4.17.21" }, devDependencies: {} },
1310
+ files: {
1311
+ "srvkit/uses.ts": [
1312
+ 'import { z } from "zod";',
1313
+ 'import chunk from "lodash/chunk";',
1314
+ 'import { createElement } from "react";',
1315
+ 'import chalk from "chalk";',
1316
+ "export const uses = [z, chunk, createElement, chalk];",
1317
+ "",
1318
+ ].join("\n"),
1319
+ },
1320
+ });
1321
+ silenced(lib);
1322
+
1323
+ await lib.scan();
1324
+ const manifestFile = path.join(libDir, "package.json");
1325
+ const [first, { mtimeMs }] = await Promise.all([readFile(manifestFile, "utf8"), stat(manifestFile)]);
1326
+ expect(Object.keys((JSON.parse(first) as PackageJson).dependencies ?? {})).toEqual([
1327
+ "zod",
1328
+ "lodash",
1329
+ "chalk",
1330
+ "react",
1331
+ ]);
1332
+
1333
+ await lib.scan({ refresh: true });
1334
+ expect(await readFile(manifestFile, "utf8")).toBe(first);
1335
+ expect((await stat(manifestFile)).mtimeMs).toBe(mtimeMs);
1336
+ });
1337
+
1338
+ test("moves a type-only import to devDependencies", async () => {
1339
+ const { lib, libDir } = await createScannableLib("typeonlydeps", {
1340
+ root,
1341
+ manifest: { dependencies: { zod: "3.25.76" }, devDependencies: {} },
1342
+ files: { "common/schema.ts": 'import type { ZodType } from "zod";\nexport type Schema = ZodType;\n' },
1343
+ });
1344
+ silenced(lib);
1345
+
1346
+ await lib.scan();
1347
+
1348
+ const manifest = await readManifest(libDir);
1349
+ expect(manifest.dependencies).toEqual({});
1350
+ expect(manifest.devDependencies).toEqual({ zod: "3.25.76" });
1351
+ });
1352
+
1353
+ test("leaves a runtime import the root lists only in devDependencies in devDependencies", async () => {
1354
+ const { lib, libDir } = await createScannableLib("devonlydeps", {
1355
+ root,
1356
+ manifest: { dependencies: {}, devDependencies: { "happy-dom": "20.0.0" } },
1357
+ files: {
1358
+ "srvkit/dom.ts": 'import { Window } from "happy-dom";\nexport const createWindow = () => new Window();\n',
1359
+ },
1360
+ });
1361
+ silenced(lib);
1362
+
1363
+ await lib.scan();
1364
+
1365
+ const manifest = await readManifest(libDir);
1366
+ expect(manifest.dependencies).toEqual({});
1367
+ expect(manifest.devDependencies).toEqual({ "happy-dom": "20.11.2" });
1368
+ });
1369
+
1370
+ test("has written the manifest by the time scan returns", async () => {
1371
+ const { lib, libDir } = await createScannableLib("settleddeps", {
1372
+ root,
1373
+ manifest: { dependencies: { "react-dom": "19.2.6" }, devDependencies: {} },
1374
+ files: { "srvkit/pick.ts": 'import pick from "lodash/pick";\nexport const pickOf = pick;\n' },
1375
+ });
1376
+ silenced(lib);
1377
+
1378
+ await lib.scan({ writeLib: false });
1379
+
1380
+ expect((await readManifest(libDir)).dependencies).toEqual({ lodash: "^4.17.21" });
1381
+ });
1382
+ });
package/executors.ts CHANGED
@@ -44,13 +44,16 @@ import { AkanAppConfig, AkanLibConfig, decreaseBuildNum, increaseBuildNum } from
44
44
  import { getRootBoundarySegments, isRootBoundarySegments } from "./artifact/implicitRootLayout";
45
45
  import { CodegenLock } from "./codegenLock";
46
46
  import { FileSys, getDirname } from "./fileSys";
47
+ import { JsonFormatter } from "./jsonFormatter";
47
48
  import { Linter } from "./linter";
49
+ import { ManifestDependencies } from "./manifestDependencies";
48
50
  import { resolveRepoName } from "./repoIdentity";
49
- import { AppInfo, LibInfo, PkgInfo, WorkspaceInfo } from "./scanInfo";
51
+ import { AppInfo, isAkanFrameworkDependency, LibInfo, PkgInfo, WorkspaceInfo } from "./scanInfo";
50
52
  import { Spinner } from "./spinner";
51
53
  // Type-only: `getTypeChecker` loads it on demand to keep `typescript` out of the resident module graph.
52
54
  import type { TypeChecker } from "./typeChecker";
53
55
  import type { FileContent, PackageJson, TsConfigJson } from "./types";
56
+ import { barrelFacetDirs } from "./workspaceLayout";
54
57
 
55
58
  export interface PageRoot {
56
59
  /** App-relative location of the route files, i.e. the symlink for a synced lib. */
@@ -303,8 +306,7 @@ export class Executor {
303
306
  const writePath = this.getPath(filePath);
304
307
  const dir = path.dirname(writePath);
305
308
  if (!(await FileSys.dirExists(dir))) await mkdir(dir, { recursive: true });
306
- //? Biome ends every .json with a newline; without one, `akan lint` and `akan sync` would flip-flop the file.
307
- let contentStr = typeof content === "string" ? content : `${JSON.stringify(content, null, 2)}\n`;
309
+ let contentStr = typeof content === "string" ? content : JsonFormatter.stringify(content, writePath);
308
310
 
309
311
  if (await FileSys.fileExists(writePath)) {
310
312
  const currentContent = await FileSys.readText(writePath);
@@ -839,8 +841,6 @@ interface SysExecutorOptions extends NamedExecutorOptions {
839
841
  type: "app" | "lib";
840
842
  }
841
843
 
842
- const scanFacetDirs = ["ui", "webkit", "srvkit", "common", "plugin"] as const;
843
-
844
844
  export class SysExecutor extends Executor {
845
845
  workspace: WorkspaceExecutor;
846
846
  override name: string;
@@ -896,7 +896,7 @@ export class SysExecutor extends Executor {
896
896
  this._applyTemplate({ basePath: ".", template: "server.ts", scanInfo }),
897
897
  this._applyTemplate({ basePath: ".", template: "client.ts", scanInfo }),
898
898
  this.type === "lib" ? this._applyTemplate({ basePath: ".", template: "index.ts", scanInfo }) : null,
899
- ...scanFacetDirs.map((facet) =>
899
+ ...barrelFacetDirs.map((facet) =>
900
900
  this._applyTemplate({
901
901
  basePath: facet,
902
902
  template: "facetIndex/index.ts",
@@ -936,11 +936,9 @@ export class SysExecutor extends Executor {
936
936
  await CodegenLock.run(this.workspace.workspaceRoot, `scan:${this.name}`, async () => {
937
937
  await Promise.all(this.#getScanTemplateTasks(scanInfo));
938
938
  await this.writeJson(`akan.${this.type}.json`, scanInfo.getScanResult());
939
- if (this.type === "lib") this.#updateDependencies(scanInfo);
940
-
939
+ await this.#updateDependencies(scanInfo);
941
940
  if (writeLib) {
942
941
  const libInfos = [...scanInfo.getLibInfos().values()];
943
- await this.#updateDependencies(scanInfo);
944
942
  await Promise.all(libInfos.flatMap((libInfo) => libInfo.exec.#getScanTemplateTasks(libInfo)));
945
943
  }
946
944
  await this.syncAgentsIndex(scanInfo);
@@ -962,39 +960,35 @@ export class SysExecutor extends Executor {
962
960
  // CLAUDE.md 는 얇은 포인터라 최초 1회만 깔아준다 — 사용자가 지우거나 고친 것을 되살리지 않는다.
963
961
  if (!(await this.exists("CLAUDE.md"))) await this.writeFile("CLAUDE.md", renderScopeClaudeMd(scope));
964
962
  }
963
+ //* The root is the cached copy the dependency scan read, so a name it declares is judged by the same snapshot.
965
964
  async #updateDependencies(scanInfo: AppInfo | LibInfo) {
966
- const rootPackageJson = await this.workspace.getPackageJson();
967
- const libPackageJson = await this.getPackageJson();
968
- const dependencies = scanInfo.getScanResult().dependencies;
969
- const devDependencies = scanInfo.getScanResult().devDependencies;
970
- const dependencySet = new Set(dependencies);
971
- const devDependencySet = new Set(devDependencies);
972
- const libPkgJsonWithDeps: PackageJson = {
973
- ...libPackageJson,
974
- dependencies: {
975
- ...Object.fromEntries(
976
- Object.entries(libPackageJson.dependencies ?? {}).filter(([dep]) => !devDependencySet.has(dep)),
977
- ),
978
- ...(Object.fromEntries(
979
- dependencies
980
- .filter((dep) => rootPackageJson.dependencies?.[dep])
981
- .sort()
982
- .map((dep) => [dep, rootPackageJson.dependencies?.[dep]]),
983
- ) as Record<string, string>),
984
- },
985
- devDependencies: {
986
- ...Object.fromEntries(
987
- Object.entries(libPackageJson.devDependencies ?? {}).filter(([dep]) => !dependencySet.has(dep)),
988
- ),
989
- ...(Object.fromEntries(
990
- devDependencies
991
- .filter((dep) => rootPackageJson.dependencies?.[dep] || rootPackageJson.devDependencies?.[dep])
992
- .sort()
993
- .map((dep) => [dep, rootPackageJson.devDependencies?.[dep] ?? rootPackageJson.dependencies?.[dep]]),
994
- ) as Record<string, string>),
995
- },
996
- };
997
- await this.setPackageJson(libPkgJsonWithDeps);
965
+ const [rootPackageJson, packageJson] = await Promise.all([
966
+ this.workspace.getPackageJson(),
967
+ this.getPackageJson({ refresh: true }),
968
+ ]);
969
+ const { dependencies: runtime, devDependencies: typeOnly, akanConfig } = scanInfo.getScanResult();
970
+ const kept = new Set([
971
+ ...ManifestDependencies.keptBy(packageJson),
972
+ ...akanConfig.externalLibs,
973
+ ...akanConfig.trustedDependencies,
974
+ ]);
975
+ //? The scan leaves the framework out of its imports on purpose, so it is never a prune candidate.
976
+ const isKept = (dep: string) => kept.has(dep) || isAkanFrameworkDependency(dep);
977
+ const { dependencies, devDependencies, removed, unverifiable } = new ManifestDependencies(
978
+ rootPackageJson,
979
+ { runtime, typeOnly },
980
+ isKept,
981
+ ).realign(packageJson);
982
+ const manifestPath = `${this.type}s/${this.name}/package.json`;
983
+ if (removed.length)
984
+ this.logger.info(
985
+ `Removed from ${manifestPath}, declared by the root and imported nowhere: ${removed.join(", ")} (list one in akan.keepDependencies to keep it)`,
986
+ );
987
+ if (unverifiable.length)
988
+ this.logger.warn(
989
+ `${manifestPath} lists ${unverifiable.join(", ")}, which the root package.json does not declare, so sync cannot tell whether it is used; kept as is`,
990
+ );
991
+ await this.setPackageJson({ ...packageJson, dependencies, devDependencies });
998
992
  }
999
993
  override async getLocalFile(targetPath: string) {
1000
994
  const filePath = path.isAbsolute(targetPath) ? targetPath : `${this.type}s/${this.name}/${targetPath}`;
@@ -1119,13 +1113,21 @@ export class AppExecutor extends SysExecutor {
1119
1113
  static from(executor: SysExecutor | WorkspaceExecutor, name: string) {
1120
1114
  return new AppExecutor({ workspace: executor instanceof WorkspaceExecutor ? executor : executor.workspace, name });
1121
1115
  }
1122
- //* Not dev output: native builds and the update releases waiting to be uploaded, and the downloaded `bin` sources.
1123
- static readonly #keptOnStart = [path.join(".akan", "native"), path.join(".akan", "cache", "bin")];
1116
+ //* Not dev output: the downloaded `bin` sources.
1117
+ static readonly #keptOnStart = [path.join(".akan", "cache", "bin")];
1118
+ //* Native builds moved to dist/native/<app>; the old folder may hold a signed release nobody uploaded yet.
1119
+ static readonly #legacyNative = path.join(".akan", "native");
1124
1120
  async #removeDevOutput(dir = ".akan") {
1125
1121
  const entries = await readDirEntries(this.getPath(dir)).catch(() => [] as string[]);
1126
1122
  await Promise.all(
1127
1123
  entries.map(async (name) => {
1128
1124
  const entry = path.join(dir, name);
1125
+ if (entry === AppExecutor.#legacyNative) {
1126
+ this.logger.warn(
1127
+ `apps/${this.name}/.akan/native holds native builds from before they moved to dist/native/${this.name}: upload any release waiting in its updates folders, then delete it.`,
1128
+ );
1129
+ return;
1130
+ }
1129
1131
  if (AppExecutor.#keptOnStart.includes(entry)) return;
1130
1132
  if (AppExecutor.#keptOnStart.some((kept) => kept.startsWith(`${entry}${path.sep}`)))
1131
1133
  return await this.#removeDevOutput(entry);
@@ -1209,10 +1211,10 @@ export class AppExecutor extends SysExecutor {
1209
1211
  // Ports and the operation mode are dropped: `define` would bake them in and outrank the container's own values.
1210
1212
  if (type === "build") {
1211
1213
  const buildEnv = { ...env };
1212
- delete buildEnv.AKAN_PUBLIC_CLIENT_PORT;
1213
- delete buildEnv.AKAN_PUBLIC_SERVER_PORT;
1214
- delete buildEnv.AKAN_PUBLIC_OPERATION_MODE;
1215
- delete process.env.AKAN_PUBLIC_OPERATION_MODE;
1214
+ for (const key of ["AKAN_PUBLIC_CLIENT_PORT", "AKAN_PUBLIC_SERVER_PORT", "AKAN_PUBLIC_OPERATION_MODE"]) {
1215
+ delete buildEnv[key];
1216
+ delete process.env[key];
1217
+ }
1216
1218
  Object.assign(process.env, buildEnv);
1217
1219
  }
1218
1220
  return { env };
@@ -0,0 +1,34 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import path from "node:path";
3
+ import { tempDirs, writeText } from "../testHelpers";
4
+
5
+ const makeTempRoot = tempDirs("akan-csr-artifact-");
6
+
7
+ describe("CsrArtifactBuilder.bundleOptions", () => {
8
+ //? A child process: Bun reads `.env` only at start, and the bug was that this snapshot outranked `define`.
9
+ test("bakes the env a native build sets on process.env, not the .env value Bun loaded at start", async () => {
10
+ const root = await makeTempRoot();
11
+ await writeText(path.join(root, ".env"), "AKAN_PUBLIC_ENV=local\n");
12
+ await writeText(path.join(root, "entry.ts"), 'console.info("envmark:" + process.env.AKAN_PUBLIC_ENV);\n');
13
+ await writeText(
14
+ path.join(root, "build.ts"),
15
+ `import { CsrArtifactBuilder } from ${JSON.stringify(path.join(import.meta.dir, "csrArtifactBuilder.ts"))};
16
+ process.env.AKAN_PUBLIC_ENV = "debug";
17
+ const app = { getPublicEnv: () => ({ AKAN_PUBLIC_ENV: process.env.AKAN_PUBLIC_ENV }) };
18
+ const result = await Bun.build({ ...CsrArtifactBuilder.bundleOptions(app as never, "build"), entrypoints: ["./entry.ts"] });
19
+ console.info(await result.outputs[0].text());
20
+ `,
21
+ );
22
+ const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith("AKAN_PUBLIC_")));
23
+ const proc = Bun.spawn([process.execPath, "build.ts"], { cwd: root, env, stdout: "pipe", stderr: "pipe" });
24
+ const [stdout, stderr, exitCode] = await Promise.all([
25
+ new Response(proc.stdout).text(),
26
+ new Response(proc.stderr).text(),
27
+ proc.exited,
28
+ ]);
29
+ expect(stderr).toBe("");
30
+ expect(exitCode).toBe(0);
31
+ expect(stdout).toContain("envmark:debug");
32
+ expect(stdout).not.toContain("envmark:local");
33
+ });
34
+ });
@@ -54,18 +54,11 @@ export class CsrArtifactBuilder {
54
54
  );
55
55
 
56
56
  const result = await Bun.build({
57
- target: "browser",
57
+ ...CsrArtifactBuilder.bundleOptions(this.#app, this.#command, akanConfig.optimizeImports),
58
58
  entrypoints: htmlBasePaths.map((basePath) => this.#generatedPath(CsrArtifactBuilder.htmlFilename(basePath))),
59
59
  files: generatedFiles,
60
60
  root: this.#generatedDir,
61
61
  outdir: this.#outputDir,
62
- splitting: false,
63
- minify: true,
64
- env: "AKAN_PUBLIC_*",
65
- define: bundleDefine(this.#app, this.#command, "csr"),
66
- optimizeImports: akanConfig.optimizeImports,
67
- // A raw `.css` in the route graph is Tailwind source; the base artifact's compiled sheet is the only stylesheet.
68
- plugins: [PagesBundleBuilder.createCssStubPlugin()],
69
62
  });
70
63
 
71
64
  if (!result.success) {
@@ -78,6 +71,19 @@ export class CsrArtifactBuilder {
78
71
  return { outputDir: this.#outputDir };
79
72
  }
80
73
 
74
+ //? No `env` option: Bun inlines the `.env` it read at process start over `define`, so a native build's `--env` lost.
75
+ static bundleOptions(app: App, command: "build" | "start", optimizeImports: string[] = []) {
76
+ return {
77
+ target: "browser" as const,
78
+ splitting: false,
79
+ minify: true,
80
+ define: bundleDefine(app, command, "csr"),
81
+ optimizeImports,
82
+ // A raw `.css` in the route graph is Tailwind source; the base artifact's compiled sheet is the only stylesheet.
83
+ plugins: [PagesBundleBuilder.createCssStubPlugin()],
84
+ };
85
+ }
86
+
81
87
  /** The routes one basePath's HTML boots: its own plus every route outside any basePath, matching `bootCsr`. */
82
88
  static pageEntriesForBasePath(pageEntries: PageEntry[], basePath: string, basePaths: string[]): PageEntry[] {
83
89
  return CsrEntryFiles.pageEntriesForBasePath(pageEntries, basePath, basePaths);
@@ -154,7 +154,6 @@ export class CsrDevModuleCompiler {
154
154
  naming: { entry: "[dir]/[name].[ext]", asset: "assets/[name]-[hash].[ext]" },
155
155
  publicPath: this.#routePrefix,
156
156
  metafile: true,
157
- env: "AKAN_PUBLIC_*",
158
157
  //? A factory runs as a classic script, where `import.meta` is a SyntaxError that takes the whole vendor file
159
158
  //? down, and Bun's cjs output keeps `import.meta.env` and spells `import.meta.url` as this disk's `file://` path.
160
159
  define: { ...this.#context.define, "import.meta": "__akanMeta" },
@@ -88,7 +88,6 @@ export class CsrDevResolver {
88
88
  target: "browser",
89
89
  metafile: true,
90
90
  external: [...(this.#context.externals ?? [])],
91
- env: "AKAN_PUBLIC_*",
92
91
  define: this.#context.define,
93
92
  optimizeImports: this.#context.optimizeImports,
94
93
  plugins: [PagesBundleBuilder.createCssStubPlugin()],
@@ -112,7 +111,6 @@ export class CsrDevResolver {
112
111
  metafile: true,
113
112
  throw: false,
114
113
  external: [...(this.#context.externals ?? [])],
115
- env: "AKAN_PUBLIC_*",
116
114
  define: this.#context.define,
117
115
  plugins: [
118
116
  {
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { tempDirs } from "../testHelpers";
5
5
  import { DevGeneratedIndexSync } from "./devGeneratedIndexSync";
@@ -40,6 +40,18 @@ describe("DevGeneratedIndexSync facet barrels", () => {
40
40
  expect(content).toBe(`export * from "./aes";\nexport * from "./cloudflareApi";\nexport * from "./storageApi";\n`);
41
41
  });
42
42
 
43
+ test("a facet with nothing to export keeps an empty barrel, and a deleted facet gets none", async () => {
44
+ const root = await makeTempRoot();
45
+ const content = await barrelFor(root, "common", { files: ["foo.helper.ts"], dirs: [], trigger: "foo.helper.ts" });
46
+ expect(content).toBe("export {};\n");
47
+
48
+ const dir = path.join(root, "libs", "util", "common");
49
+ await rm(dir, { recursive: true });
50
+ const sync = new DevGeneratedIndexSync({ workspaceRoot: root });
51
+ expect(await sync.syncForBatch([path.join(dir, "foo.helper.ts")])).toEqual({ changedFiles: [], errors: [] });
52
+ expect(await Bun.file(path.join(dir, "index.ts")).exists()).toBe(false);
53
+ });
54
+
43
55
  test("ui facet exports only clean PascalCase names", async () => {
44
56
  const content = await barrelFor(await makeTempRoot(), "ui", {
45
57
  files: ["Globe.tsx", "AkanLogo.tsx", "Globe_Dynamic.tsx", "lowerStart.tsx"],