@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.
- package/CHANGELOG.md +15 -2
- package/akanApp/akanApp.host.test.ts +62 -0
- package/akanApp/akanApp.host.ts +13 -2
- package/akanApp/devHostPolicy.test.ts +14 -0
- package/akanApp/devHostPolicy.ts +11 -0
- package/akanConfig/akanConfig.test.ts +4 -1
- package/akanConfig/akanConfig.ts +3 -1
- package/akanContext.ts +18 -1
- package/biome.base.json +8 -1
- package/commandDecorators/command.ts +3 -2
- package/commandDecorators/commandDecorators.test.ts +7 -0
- package/createTunnel.test.ts +51 -0
- package/createTunnel.ts +32 -14
- package/csrE2e/csrPageStack.e2e.test.ts +12 -3
- package/executors.test.ts +329 -4
- package/executors.ts +49 -47
- package/frontendBuild/csrArtifactBuilder.test.ts +34 -0
- package/frontendBuild/csrArtifactBuilder.ts +14 -8
- package/frontendBuild/csrDevModuleCompiler.ts +0 -1
- package/frontendBuild/csrDevResolver.ts +0 -2
- package/frontendBuild/devGeneratedIndexSync.test.ts +13 -1
- package/frontendBuild/devGeneratedIndexSync.ts +21 -4
- package/frontendBuild/frontendBuild.test.ts +1 -1
- package/jsonFormatter.test.ts +64 -0
- package/jsonFormatter.ts +78 -0
- package/lint/__fixtures__/README.md +12 -3
- package/lint/__fixtures__/no-double-brace-placeholder/bad.ts +22 -0
- package/lint/__fixtures__/no-double-brace-placeholder/fixture.json +1 -0
- package/lint/__fixtures__/no-double-brace-placeholder/good.ts +15 -0
- package/lint/__fixtures__/no-inline-color/bad.tsx +17 -0
- package/lint/__fixtures__/no-inline-color/good.tsx +13 -0
- package/lint/__fixtures__/no-throw-raw-error/bad.tsx +8 -0
- package/lint/__fixtures__/no-throw-raw-error/good.tsx +8 -1
- package/lint/__fixtures__/no-unguarded-endpoint/bad.ts +20 -0
- package/lint/__fixtures__/no-unguarded-endpoint/fixture.json +1 -0
- package/lint/__fixtures__/no-unguarded-endpoint/good.ts +28 -0
- package/lint/__fixtures__/no-web-only-api-outside-webkit/bad.tsx +7 -7
- package/lint/gritRules.test.ts +23 -15
- package/lint/no-double-brace-placeholder.grit +15 -0
- package/lint/no-inline-color.grit +21 -5
- package/lint/no-throw-raw-error.grit +18 -33
- package/lint/no-unguarded-endpoint.grit +73 -0
- package/manifestDependencies.ts +65 -0
- package/mobile/desktopBin.test.ts +1 -1
- package/mobile/desktopServerStage.test.ts +7 -4
- package/mobile/nativeApp.test.ts +57 -8
- package/mobile/nativeApp.ts +27 -9
- package/mobile/nativeConfig.test.ts +26 -4
- package/mobile/nativeConfig.ts +19 -1
- package/package.json +2 -2
- package/platformTest/LinuxDockerTarget.ts +1 -1
- package/platformTest/PlatformTestTarget.ts +3 -1
- package/platformTest/WindowsSshTarget.ts +1 -1
- package/qualityScanner.test.ts +51 -0
- package/qualityScanner.ts +22 -1
- package/scanInfo.ts +14 -2
- package/testHelpers.ts +17 -0
- package/transforms/transforms.test.ts +13 -0
- package/tsconfig.json +4 -15
- package/workspaceLayout.test.ts +78 -1
- 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 {
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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
|
-
...
|
|
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
|
-
|
|
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
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
const
|
|
971
|
-
const
|
|
972
|
-
|
|
973
|
-
...
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
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:
|
|
1123
|
-
static readonly #keptOnStart = [path.join(".akan", "
|
|
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
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
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
|
-
|
|
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"],
|