@akanjs/devkit 3.0.2 → 3.1.0-rc.1

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 (71) hide show
  1. package/CHANGELOG.md +46 -7
  2. package/akanApp/akanApp.host.ts +3 -2
  3. package/akanConfig/akanConfig.test.ts +76 -6
  4. package/akanConfig/akanConfig.ts +111 -13
  5. package/akanConfig/akanWorkspaceConfig.test.ts +79 -0
  6. package/akanConfig/akanWorkspaceConfig.ts +63 -0
  7. package/akanConfig/index.ts +1 -0
  8. package/akanConfig/types.ts +23 -1
  9. package/akanContext.ts +1 -1
  10. package/applicationBuildRunner.test.ts +1 -1
  11. package/applicationBuildRunner.ts +73 -84
  12. package/artifact/implicitRootLayout.test.ts +6 -2
  13. package/artifact/implicitRootLayout.ts +12 -7
  14. package/biome.base.json +7 -2
  15. package/cloud/cloudApi.ts +87 -0
  16. package/cloud/globalConfig.ts +4 -0
  17. package/cloud/index.ts +1 -0
  18. package/cloud/updatePackUpload.test.ts +250 -0
  19. package/cloud/updatePackUpload.ts +132 -0
  20. package/commandDecorators/command.ts +8 -2
  21. package/commandDecorators/targetMeta.ts +1 -0
  22. package/executors.test.ts +21 -0
  23. package/executors.ts +9 -2
  24. package/frontendBuild/csrArtifactBuilder.ts +10 -1
  25. package/frontendBuild/cssCandidateCache.test.ts +10 -0
  26. package/frontendBuild/cssCandidateCache.ts +4 -2
  27. package/frontendBuild/pagesBundleBuilder.ts +5 -1
  28. package/frontendBuild/ssrBaseArtifactBuilder.ts +6 -2
  29. package/incrementalBuilder/buildBatch.proc.ts +14 -2
  30. package/incrementalBuilder/buildBatchProtocol.ts +3 -2
  31. package/incrementalBuilder/incrementalBuilder.proc.test.ts +96 -0
  32. package/incrementalBuilder/incrementalBuilder.proc.ts +30 -1
  33. package/lint/__fixtures__/no-import-external-library-in-test/bad.tsx +6 -0
  34. package/lint/__fixtures__/no-import-external-library-in-test/good.tsx +6 -0
  35. package/lint/no-import-external-library-in-test.grit +24 -0
  36. package/mobile/desktopServerStage.test.ts +6 -0
  37. package/mobile/desktopServerStage.ts +25 -3
  38. package/mobile/index.ts +1 -0
  39. package/mobile/nativeApp.test.ts +34 -0
  40. package/mobile/nativeApp.ts +106 -34
  41. package/mobile/nativeConfig.test.ts +59 -1
  42. package/mobile/nativeConfig.ts +13 -3
  43. package/mobile/serverBundleSecrets.test.ts +61 -0
  44. package/mobile/serverBundleSecrets.ts +85 -0
  45. package/mobile/updateUploader.test.ts +49 -0
  46. package/mobile/updateUploader.ts +71 -0
  47. package/package.json +3 -2
  48. package/pipeline/PipelineCompiler.test.ts +631 -0
  49. package/pipeline/PipelineCompiler.ts +646 -0
  50. package/pipeline/PipelineDefinition.test.ts +89 -0
  51. package/pipeline/PipelineDefinition.ts +117 -0
  52. package/pipeline/PipelineDryRun.test.ts +93 -0
  53. package/pipeline/PipelineDryRun.ts +89 -0
  54. package/pipeline/PipelineInputReader.ts +739 -0
  55. package/pipeline/index.ts +4 -0
  56. package/qualityScanner.test.ts +9 -3
  57. package/qualityScanner.ts +5 -2
  58. package/scanInfo.ts +13 -6
  59. package/subspace.test.ts +108 -4
  60. package/subspace.ts +162 -10
  61. package/subspaceConfig.ts +11 -14
  62. package/testHelpers.ts +4 -0
  63. package/transforms/externalizeFrameworkPlugin.ts +1 -7
  64. package/transforms/serverEnvPlugin.test.ts +93 -2
  65. package/transforms/serverEnvPlugin.ts +71 -18
  66. package/transforms/serverEnvProfile.test.ts +82 -0
  67. package/transforms/serverEnvProfile.ts +114 -0
  68. package/typecheck/projectTypecheck.test.ts +32 -0
  69. package/typecheck/projectTypecheck.ts +71 -0
  70. package/workspaceLayout.test.ts +26 -3
  71. package/workspaceLayout.ts +15 -4
@@ -34,13 +34,22 @@ const writeApp = async (root: string) => {
34
34
  return { envDir, entry };
35
35
  };
36
36
 
37
- const build = async (root: string, environment: string) => {
37
+ const build = async (root: string, environment: string, profile?: string) => {
38
38
  const { envDir, entry } = await writeApp(root);
39
+ if (profile)
40
+ await write(
41
+ path.join(envDir, `env.server.${environment}.${profile}.ts`),
42
+ [
43
+ 'import { libEnv } from "./env.server.type";',
44
+ `export const env = { ...libEnv, origin: "${environment}-${profile}-origin" };`,
45
+ "",
46
+ ].join("\n"),
47
+ );
39
48
  const result = await Bun.build({
40
49
  entrypoints: [entry],
41
50
  outdir: path.join(root, "out"),
42
51
  target: "bun",
43
- plugins: [createServerEnvPlugin({ envDir, environment, environments })],
52
+ plugins: [createServerEnvPlugin({ envDir, environment, environments, profile })],
44
53
  });
45
54
  expect(result.success).toBe(true);
46
55
  const output = result.outputs.find((artifact) => artifact.kind === "entry-point");
@@ -87,6 +96,88 @@ describe("createServerEnvPlugin", () => {
87
96
  expect(text).not.toContain("main-secret");
88
97
  });
89
98
 
99
+ test("a profile bundles its own file in place of the environment's, which never enters the bundle", async () => {
100
+ const { file, text } = await build(await makeTempRoot(), "main", "edge");
101
+ expect(text).toContain("main-edge-origin");
102
+ expect(text).not.toContain("main-secret");
103
+ const proc = run(file, "main");
104
+ expect(proc.exitCode).toBe(0);
105
+ expect(JSON.parse(proc.stdout.toString())).toEqual({ shared: "shared-value", origin: "main-edge-origin" });
106
+ });
107
+
108
+ test("a profile carries a lib's env defaults without their secrets, and still stubs the app's other environments", async () => {
109
+ const root = await makeTempRoot();
110
+ const libEnv = path.join(root, "libs/shared/env/env.server.testing.ts");
111
+ await write(
112
+ libEnv,
113
+ 'export const env = { hostname: "lib-host", ttl: 30, security: { jwtSecret: "lib-jwt", accessTokenSeconds: 900 }, mailer: { auth: { user: "mailer-user", pass: "lib-pass" } }, cloudflare: { authKey: "lib-cf" } };\n',
114
+ );
115
+ const { envDir, entry } = await writeApp(root);
116
+ await write(path.join(envDir, "env.server.main.edge.ts"), 'export const env = { origin: "edge" };\n');
117
+ await write(
118
+ entry,
119
+ `import { env as app } from "./env/env.server";\nimport { env as lib } from ${JSON.stringify(libEnv)};\nconsole.info(JSON.stringify({ app, lib }));\n`,
120
+ );
121
+ const reports: unknown[] = [];
122
+ const result = await Bun.build({
123
+ entrypoints: [entry],
124
+ outdir: path.join(root, "out"),
125
+ target: "bun",
126
+ plugins: [
127
+ createServerEnvPlugin({
128
+ envDir,
129
+ environment: "main",
130
+ environments,
131
+ profile: "edge",
132
+ onLibEnv: (file, report) => reports.push({ file: path.basename(file), ...report }),
133
+ }),
134
+ ],
135
+ });
136
+ expect(result.success).toBe(true);
137
+ const output = result.outputs.find((artifact) => artifact.kind === "entry-point");
138
+ const text = (await output?.text()) ?? "";
139
+ for (const secret of ["lib-jwt", "lib-pass", "lib-cf", "develop-secret", "main-secret"])
140
+ expect(text).not.toContain(secret);
141
+ expect(reports).toEqual([
142
+ {
143
+ file: "env.server.testing.ts",
144
+ profileFile: false,
145
+ stripped: ["security.jwtSecret", "mailer.auth.pass", "cloudflare.authKey"],
146
+ },
147
+ ]);
148
+ const proc = run(output?.path ?? "", "main");
149
+ expect(JSON.parse(proc.stdout.toString()).lib).toEqual({
150
+ hostname: "lib-host",
151
+ ttl: 30,
152
+ security: { accessTokenSeconds: 900 },
153
+ mailer: { auth: { user: "mailer-user" } },
154
+ cloudflare: {},
155
+ });
156
+ });
157
+
158
+ test("a lib's own profile file is bundled in place of its defaults, and refused when it holds a secret", async () => {
159
+ const root = await makeTempRoot();
160
+ const libEnv = path.join(root, "libs/util/env/env.server.testing.ts");
161
+ await write(libEnv, 'export const env = { hostname: "lib-host", security: { jwtSecret: "lib-jwt" } };\n');
162
+ const edgeEnv = path.join(root, "libs/util/env/env.server.testing.edge.ts");
163
+ await write(edgeEnv, 'export const env = { hostname: "lib-edge-host" };\n');
164
+ const { envDir, entry } = await writeApp(root);
165
+ await write(path.join(envDir, "env.server.main.edge.ts"), "export const env = {};\n");
166
+ await write(entry, `import { env } from ${JSON.stringify(libEnv)};\nconsole.info(JSON.stringify(env));\n`);
167
+ const plugin = () => createServerEnvPlugin({ envDir, environment: "main", environments, profile: "edge" });
168
+ const ok = await Bun.build({ entrypoints: [entry], target: "bun", plugins: [plugin()] });
169
+ expect(ok.success).toBe(true);
170
+ const text = (await ok.outputs[0]?.text()) ?? "";
171
+ expect(text).toContain("lib-edge-host");
172
+ expect(text).not.toContain("lib-jwt");
173
+
174
+ await write(edgeEnv, 'export const env = { mailer: { pass: "edge-pass" } };\n');
175
+ const refused = await Bun.build({ entrypoints: [entry], target: "bun", plugins: [plugin()], throw: false });
176
+ expect(refused.success).toBe(false);
177
+ expect(refused.logs.map((log) => log.message).join("\n")).toContain("holds secret-named values");
178
+ expect(refused.logs.map((log) => log.message).join("\n")).toContain("mailer.pass");
179
+ });
180
+
90
181
  test("leaves env files outside the app's env directory alone", async () => {
91
182
  const root = await makeTempRoot();
92
183
  const other = path.join(root, "libs/shared/env/env.server.testing.ts");
@@ -1,41 +1,94 @@
1
1
  import { existsSync, realpathSync } from "node:fs";
2
2
  import path from "node:path";
3
- import type { BunPlugin } from "bun";
3
+ import type { BunPlugin, OnLoadResult } from "bun";
4
+ import { ServerEnvProfile } from "./serverEnvProfile";
4
5
 
5
6
  export interface ServerEnvPluginOptions {
6
7
  envDir: string;
7
8
  /** The environment the image runs: its Dockerfile fixes `AKAN_PUBLIC_ENV` to it. */
8
9
  environment: string;
9
10
  environments: Iterable<string>;
11
+ /** Bundled in place of the environment's own file: `env.server.<environment>.<profile>.ts`, read from the same folder. */
12
+ profile?: string;
13
+ /** Under a profile, once per lib env file: whether the lib gave one of the profile's, and what was left out if not. */
14
+ onLibEnv?: (file: string, report: { profileFile: boolean; stripped: string[] }) => void;
10
15
  }
11
16
 
12
17
  const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
13
18
 
14
19
  //* `env.server.ts` imports every environment's file, so an image would hand out the secrets of each environment it
15
20
  //* never runs. Every file but the image's own is swapped for exports that refuse to be read.
16
- export const createServerEnvPlugin = ({ envDir, environment, environments }: ServerEnvPluginOptions): BunPlugin => {
21
+ export const createServerEnvPlugin = ({
22
+ envDir,
23
+ environment,
24
+ environments,
25
+ profile,
26
+ onLibEnv,
27
+ }: ServerEnvPluginOptions): BunPlugin => {
17
28
  const others = [...new Set(environments)].filter((name) => name !== environment);
18
29
  //? Bun hands onLoad the real path, which differs from the given one under a symlink (macOS's /var -> /private/var).
19
30
  const dirs = new Set([path.resolve(envDir), existsSync(envDir) ? realpathSync.native(envDir) : envDir]);
20
- const names = others.map(escapeRegExp).join("|");
21
- const filter = new RegExp(`^(?:${[...dirs].map(escapeRegExp).join("|")})[\\\\/]env\\.server\\.(?:${names})\\.ts$`);
31
+ const folder = `^(?:${[...dirs].map(escapeRegExp).join("|")})[\\\\/]env\\.server\\.`;
32
+ const filter = new RegExp(`${folder}(?:${others.map(escapeRegExp).join("|")})\\.ts$`);
33
+ const own = new RegExp(`${folder}${escapeRegExp(environment)}\\.ts$`);
34
+ const libEnvs = new Map<string, Promise<OnLoadResult>>();
35
+ //* A lib's `server.ts` hands its `env.server.testing.ts` to the server as defaults, so a profile swaps those as it
36
+ //* swaps the app's: for the lib's `env.server.<name>.<profile>.ts`, else for the defaults without a secret in them.
37
+ const libEnv = async (file: string): Promise<OnLoadResult> => {
38
+ const profileFile = ServerEnvProfile.profileFileOf(file, profile ?? "");
39
+ const libRoot = path.dirname(path.dirname(file));
40
+ if (await Bun.file(profileFile).exists()) {
41
+ const secrets = ServerEnvProfile.secretPaths(await ServerEnvProfile.evaluate(profileFile, libRoot));
42
+ if (secrets.length)
43
+ throw new Error(
44
+ `${profileFile} holds secret-named values, which the "${profile}" bundle carries to every computer it runs on: ${secrets.join(", ")}.`,
45
+ );
46
+ onLibEnv?.(file, { profileFile: true, stripped: [] });
47
+ return { loader: "ts", contents: await Bun.file(profileFile).text() };
48
+ }
49
+ const { exports } = new Bun.Transpiler({ loader: "ts" }).scan(await Bun.file(file).text());
50
+ if (exports.some((name) => name !== "env"))
51
+ throw new Error(
52
+ `${file} exports ${exports.join(", ")}: a "${profile}" build carries only a lib env's \`env\`, so give the lib ${path.basename(profileFile)}.`,
53
+ );
54
+ const { value, stripped } = ServerEnvProfile.strip(await ServerEnvProfile.evaluate(file, libRoot));
55
+ onLibEnv?.(file, { profileFile: false, stripped });
56
+ return { loader: "js", contents: `export const env = ${JSON.stringify(value ?? {})};\n` };
57
+ };
22
58
  return {
23
59
  name: "akan-server-env",
24
60
  setup(build) {
25
- if (!others.length) return;
26
- build.onLoad({ filter }, async (args) => {
27
- const name = path.basename(args.path, ".ts").slice("env.server.".length);
28
- const { exports } = new Bun.Transpiler({ loader: "ts" }).scan(await Bun.file(args.path).text());
29
- const message = `env/env.server.${name}.ts is not in this build: \`akan build\` bundles the server env of AKAN_PUBLIC_ENV=${environment} alone, the environment its image runs. To run ${name}, build for it: AKAN_PUBLIC_ENV=${name} in the workspace's .env, which outranks the shell, or --env ${name} on a desktop or mobile build.`;
30
- return {
31
- loader: "js",
32
- contents: [
33
- `const refuse = () => { throw new Error(${JSON.stringify(message)}); };`,
34
- "const unbuilt = new Proxy({}, { get: refuse, has: refuse, ownKeys: refuse, getOwnPropertyDescriptor: refuse });",
35
- `export { ${exports.map((exported) => `unbuilt as ${JSON.stringify(exported)}`).join(", ")} };`,
36
- ].join("\n"),
37
- };
38
- });
61
+ //* The environment's own file never enters a profile's bundle: its contents are the profile file's.
62
+ if (profile)
63
+ build.onLoad({ filter: own }, async (args) => ({
64
+ loader: "ts",
65
+ contents: await Bun.file(
66
+ path.join(path.dirname(args.path), `env.server.${environment}.${profile}.ts`),
67
+ ).text(),
68
+ }));
69
+ if (others.length)
70
+ build.onLoad({ filter }, async (args) => {
71
+ const name = path.basename(args.path, ".ts").slice("env.server.".length);
72
+ const { exports } = new Bun.Transpiler({ loader: "ts" }).scan(await Bun.file(args.path).text());
73
+ const message = `env/env.server.${name}.ts is not in this build: \`akan build\` bundles the server env of AKAN_PUBLIC_ENV=${environment} alone, the environment its image runs. To run ${name}, build for it: AKAN_PUBLIC_ENV=${name} in the workspace's .env, which outranks the shell, or --env ${name} on a desktop or mobile build.`;
74
+ return {
75
+ loader: "js",
76
+ contents: [
77
+ `const refuse = () => { throw new Error(${JSON.stringify(message)}); };`,
78
+ "const unbuilt = new Proxy({}, { get: refuse, has: refuse, ownKeys: refuse, getOwnPropertyDescriptor: refuse });",
79
+ `export { ${exports.map((exported) => `unbuilt as ${JSON.stringify(exported)}`).join(", ")} };`,
80
+ ].join("\n"),
81
+ };
82
+ });
83
+ //? Last: the app's own env files above are taken by the handlers before it, which match them first.
84
+ if (profile)
85
+ build.onLoad({ filter: /[\\/]env[\\/]env\.server\.[A-Za-z0-9-]+\.ts$/ }, async (args) => {
86
+ const name = path.basename(args.path, ".ts").slice("env.server.".length);
87
+ if (dirs.has(path.dirname(args.path)) || name === "type" || name === "example")
88
+ return { loader: "ts", contents: await Bun.file(args.path).text() };
89
+ if (!libEnvs.has(args.path)) libEnvs.set(args.path, libEnv(args.path));
90
+ return (await libEnvs.get(args.path)) as OnLoadResult;
91
+ });
39
92
  },
40
93
  };
41
94
  };
@@ -0,0 +1,82 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import path from "node:path";
3
+ import { tempDirs, writeText as write } from "../testHelpers";
4
+ import { ServerEnvProfile } from "./serverEnvProfile";
5
+
6
+ const makeTempRoot = tempDirs("akan-devkit-server-env-profile-");
7
+
8
+ const writeProfile = async (body: string) => {
9
+ const envDir = path.join(await makeTempRoot(), "apps/demo/env");
10
+ await write(path.join(envDir, "env.server.main.edge.ts"), body);
11
+ return envDir;
12
+ };
13
+
14
+ describe("ServerEnvProfile.secretPaths", () => {
15
+ test("names every secret-named key holding a string, and every string beneath one", () => {
16
+ expect(
17
+ ServerEnvProfile.secretPaths({
18
+ hostname: "edge.example.com",
19
+ security: { jwtSecret: "s", sso: { google: { clientID: "id", clientSecret: "cs" } } },
20
+ llm: { model: "m", apiKey: "k" },
21
+ slack: { webhookUrl: "https://hooks" },
22
+ privateKey: { pem: "-----BEGIN" },
23
+ nvrAuth: { id: "admin", password: "" },
24
+ enrollKey: "fleet",
25
+ security2: { verifies: [["password", "phone"]] },
26
+ mailer: { auth: { user: "u", pass: "p" } },
27
+ cloudflare: { authEmail: "e", authKey: "k" },
28
+ iapVerify: { apple: "shared", google: { client_email: "c" } },
29
+ }),
30
+ ).toEqual([
31
+ "security.jwtSecret",
32
+ "security.sso.google.clientSecret",
33
+ "llm.apiKey",
34
+ "slack.webhookUrl",
35
+ "privateKey.pem",
36
+ "mailer.auth.pass",
37
+ "cloudflare.authKey",
38
+ "iapVerify.apple",
39
+ "iapVerify.google.client_email",
40
+ ]);
41
+ });
42
+ });
43
+
44
+ describe("ServerEnvProfile.assertShippable", () => {
45
+ test("passes a profile with no secret in it", async () => {
46
+ const envDir = await writeProfile('export const env = { hostname: "edge", cloudOrigin: "https://cloud" };\n');
47
+ await expect(ServerEnvProfile.assertShippable({ envDir, environment: "main", profile: "edge" })).resolves.toBe(
48
+ undefined,
49
+ );
50
+ });
51
+
52
+ test("refuses a literal fallback the bundle would carry, even when the build's shell sets the variable", async () => {
53
+ const envDir = await writeProfile(
54
+ 'export const env = { security: { jwtSecret: process.env.EDGE_JWT_SECRET ?? "fallback" } };\n',
55
+ );
56
+ process.env.EDGE_JWT_SECRET = "from-the-shell";
57
+ try {
58
+ await expect(ServerEnvProfile.assertShippable({ envDir, environment: "main", profile: "edge" })).rejects.toThrow(
59
+ "env/env.server.main.edge.ts holds secret-named values, which a profile's bundle carries to every computer it runs on: security.jwtSecret.",
60
+ );
61
+ } finally {
62
+ delete process.env.EDGE_JWT_SECRET;
63
+ }
64
+ });
65
+
66
+ test("passes a secret read from the server's own process env with no fallback", async () => {
67
+ const envDir = await writeProfile("export const env = { security: { jwtSecret: process.env.EDGE_JWT_SECRET } };\n");
68
+ await expect(ServerEnvProfile.assertShippable({ envDir, environment: "main", profile: "edge" })).resolves.toBe(
69
+ undefined,
70
+ );
71
+ });
72
+
73
+ test("refuses a missing profile file and a malformed name", async () => {
74
+ const envDir = await writeProfile("export const env = {};\n");
75
+ await expect(ServerEnvProfile.assertShippable({ envDir, environment: "develop", profile: "edge" })).rejects.toThrow(
76
+ 'The server env profile "edge" bundles env/env.server.develop.edge.ts in place of env/env.server.develop.ts, and there is no such file.',
77
+ );
78
+ await expect(ServerEnvProfile.assertShippable({ envDir, environment: "main", profile: "../x" })).rejects.toThrow(
79
+ "must be a lowercase-first name",
80
+ );
81
+ });
82
+ });
@@ -0,0 +1,114 @@
1
+ import path from "node:path";
2
+
3
+ export interface ServerEnvProfileProps {
4
+ envDir: string;
5
+ environment: string;
6
+ profile: string;
7
+ }
8
+
9
+ //* The logger's redaction pattern plus what a config carries as a credential under another name: a webhook URL, a
10
+ //* cloud access key, a mailer's `pass`, an HMAC key, a registry's `regcred`, and blocks that are credentials whole —
11
+ //* `iapVerify` (its `apple` is the App Store shared secret) and a service account.
12
+ const secretKey =
13
+ /password|passwd|passphrase|^pass$|token|jwt|authorization|cookie|secret|api[-_]?key|private[-_]?key|access[-_]?key|auth[-_]?key|hash[-_]?key|webhook|cred|iap[-_]?verify|service[-_]?account/i;
14
+
15
+ //* A server env profile is bundled in place of the environment's own file, for a server that runs where its bundle
16
+ //* can be read: the computers a desktop app is installed on. So it may hold no secret.
17
+ export class ServerEnvProfile {
18
+ static readonly namePattern = /^[a-z][a-zA-Z0-9-]*$/;
19
+
20
+ static fileName(environment: string, profile: string) {
21
+ return `env.server.${environment}.${profile}.ts`;
22
+ }
23
+
24
+ /** `env.server.<name>.ts` → `env.server.<name>.<profile>.ts`, the file a profile build bundles in its place. */
25
+ static profileFileOf(file: string, profile: string) {
26
+ return file.replace(/\.ts$/, `.${profile}.ts`);
27
+ }
28
+
29
+ /** The value with every string `secretPaths` names left out, and those paths. */
30
+ static strip(value: unknown, at = "", underSecret = false): { value: unknown; stripped: string[] } {
31
+ if (typeof value === "string")
32
+ return underSecret && value.trim() ? { value: undefined, stripped: [at] } : { value, stripped: [] };
33
+ if (!value || typeof value !== "object") return { value, stripped: [] };
34
+ const stripped: string[] = [];
35
+ const strip = (key: string, child: unknown) => {
36
+ const result = ServerEnvProfile.strip(child, at ? `${at}.${key}` : key, underSecret || secretKey.test(key));
37
+ stripped.push(...result.stripped);
38
+ return result.value;
39
+ };
40
+ if (Array.isArray(value)) return { value: value.map((child, idx) => strip(String(idx), child)), stripped };
41
+ const kept = Object.fromEntries(
42
+ Object.entries(value)
43
+ .map(([key, child]) => [key, strip(key, child)] as const)
44
+ .filter(([, child]) => child !== undefined),
45
+ );
46
+ return { value: kept, stripped };
47
+ }
48
+
49
+ /**
50
+ * The values to look for in a bundle: each under a secret-named key, and each long one in a block that is a
51
+ * credential whole — not a URL or a lowercase constant there (`service_account`), which library code holds as well.
52
+ */
53
+ static secretValues(value: unknown, at = "", underSecret = false): { path: string; value: string }[] {
54
+ if (typeof value === "string") {
55
+ const named = secretKey.test(at.split(".").at(-1) ?? "");
56
+ const distinct = value.length >= 16 && !/^https?:\/\//.test(value) && !/^[a-z]+(?:[._-][a-z]+)*$/.test(value);
57
+ return underSecret && value.length >= 8 && (named || distinct) ? [{ path: at, value }] : [];
58
+ }
59
+ if (!value || typeof value !== "object") return [];
60
+ return Object.entries(value).flatMap(([key, child]) =>
61
+ ServerEnvProfile.secretValues(child, at ? `${at}.${key}` : key, underSecret || secretKey.test(key)),
62
+ );
63
+ }
64
+
65
+ /** Every secret-named key holding a non-empty string, or one beneath it, as a dotted path. */
66
+ static secretPaths(value: unknown, at = "", underSecret = false): string[] {
67
+ if (typeof value === "string") return underSecret && value.trim() ? [at] : [];
68
+ if (!value || typeof value !== "object") return [];
69
+ return Object.entries(value).flatMap(([key, child]) =>
70
+ ServerEnvProfile.secretPaths(child, at ? `${at}.${key}` : key, underSecret || secretKey.test(key)),
71
+ );
72
+ }
73
+
74
+ //* Evaluated in a child with no app env, so `process.env.X ?? "fallback"` reads as the fallback the bundle carries.
75
+ static async evaluate(file: string, cwd: string): Promise<unknown> {
76
+ const script = [
77
+ "const { env } = await import(process.env.AKAN_PROFILE_ENV_FILE);",
78
+ 'const plain = (_, value) => (typeof value === "function" || typeof value === "symbol" ? undefined : typeof value === "bigint" ? String(value) : value);',
79
+ "process.stdout.write(JSON.stringify(env ?? null, plain));",
80
+ ].join("\n");
81
+ const child = Bun.spawn([process.execPath, "--eval", script], {
82
+ cwd,
83
+ env: { PATH: process.env.PATH ?? "", HOME: process.env.HOME ?? "", AKAN_PROFILE_ENV_FILE: file },
84
+ stdout: "pipe",
85
+ stderr: "pipe",
86
+ });
87
+ const [stdout, stderr, exitCode] = await Promise.all([
88
+ new Response(child.stdout).text(),
89
+ new Response(child.stderr).text(),
90
+ child.exited,
91
+ ]);
92
+ if (exitCode !== 0)
93
+ throw new Error(`${path.basename(file)} could not be evaluated to check it for secrets:\n${stderr.trim()}`);
94
+ return JSON.parse(stdout || "null") as unknown;
95
+ }
96
+
97
+ static async assertShippable({ envDir, environment, profile }: ServerEnvProfileProps) {
98
+ if (!ServerEnvProfile.namePattern.test(profile))
99
+ throw new Error(
100
+ `The server env profile "${profile}" must be a lowercase-first name of letters, digits and dashes.`,
101
+ );
102
+ const name = ServerEnvProfile.fileName(environment, profile);
103
+ const file = path.join(envDir, name);
104
+ if (!(await Bun.file(file).exists()))
105
+ throw new Error(
106
+ `The server env profile "${profile}" bundles env/${name} in place of env/env.server.${environment}.ts, and there is no such file.`,
107
+ );
108
+ const secrets = ServerEnvProfile.secretPaths(await ServerEnvProfile.evaluate(file, path.dirname(envDir)));
109
+ if (secrets.length)
110
+ throw new Error(
111
+ `env/${name} holds secret-named values, which a profile's bundle carries to every computer it runs on: ${secrets.join(", ")}. Leave them out, or read them from the process env of the server that needs them.`,
112
+ );
113
+ }
114
+ }
@@ -0,0 +1,32 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import path from "node:path";
3
+ import { tempDirs, writeText as write } from "../testHelpers";
4
+ import { ProjectTypecheck } from "./projectTypecheck";
5
+
6
+ const makeTempRoot = tempDirs("akan-devkit-project-typecheck-");
7
+
8
+ const project = async () => {
9
+ const root = await makeTempRoot();
10
+ const dir = path.join(root, "libs/demo");
11
+ await write(
12
+ path.join(dir, "tsconfig.json"),
13
+ JSON.stringify({ compilerOptions: { strict: true, types: [], target: "esnext", module: "esnext", noEmit: true } }),
14
+ );
15
+ await write(path.join(dir, "server.ts"), "export const port: number = 8282;\n");
16
+ await write(path.join(dir, "lib/user/user.signal.test.ts"), 'export const port: number = "8282";\n');
17
+ await write(path.join(dir, ".akan/stale/old.test.ts"), 'export const port: number = "8282";\n');
18
+ return { root, dir };
19
+ };
20
+
21
+ describe("ProjectTypecheck", () => {
22
+ test("checks a test nothing imports only when asked, and leaves .akan out", async () => {
23
+ const { root, dir } = await project();
24
+ const typecheck = new ProjectTypecheck(dir, root, { ...process.env });
25
+
26
+ await typecheck.run(["../../server.ts"]);
27
+ const error = await typecheck.run(["../../server.ts"], { tests: true }).catch((e: Error) => e);
28
+ expect(error).toBeInstanceOf(Error);
29
+ expect((error as Error).message).toContain("lib/user/user.signal.test.ts");
30
+ expect((error as Error).message).not.toContain("old.test.ts");
31
+ }, 60_000);
32
+ });
@@ -0,0 +1,71 @@
1
+ import { mkdir, rm } from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ export interface ProjectTypecheckOptions {
5
+ clean?: boolean;
6
+ incremental?: boolean;
7
+ /** The project's own `*.test.ts` / `*.spec.ts` as well. */
8
+ tests?: boolean;
9
+ }
10
+
11
+ //* tsc checks what the entries import, and nothing imports a test, so a test is checked only when it is included by
12
+ //* name. Each mode keeps a tsconfig and an incremental cache of its own, so one never invalidates the other.
13
+ export class ProjectTypecheck {
14
+ static readonly #testIncludes = ["**/*.test.ts", "**/*.test.tsx", "**/*.spec.ts", "**/*.spec.tsx"];
15
+ static readonly #excludes = [".akan/**", "node_modules/**", "local/**"];
16
+
17
+ constructor(
18
+ readonly projectDir: string,
19
+ readonly workspaceRoot: string,
20
+ readonly env: Record<string, string | undefined>,
21
+ ) {}
22
+
23
+ get typecheckDir() {
24
+ return path.join(this.projectDir, ".akan", "typecheck");
25
+ }
26
+
27
+ /** `entries`: relative to the typecheck folder (`../../server.ts`). */
28
+ async run(entries: string[], { clean = false, incremental = true, tests = false }: ProjectTypecheckOptions = {}) {
29
+ const name = tests ? "tsconfig.tests" : "tsconfig";
30
+ await mkdir(this.typecheckDir, { recursive: true });
31
+ const tsconfig = {
32
+ extends: "../../tsconfig.json",
33
+ compilerOptions: { noEmit: true, incremental, tsBuildInfoFile: `./${name}.tsbuildinfo` },
34
+ include: [...entries, ...(tests ? ProjectTypecheck.#testIncludes.map((glob) => `../../${glob}`) : [])],
35
+ ...(tests ? { exclude: ProjectTypecheck.#excludes.map((glob) => `../../${glob}`) } : {}),
36
+ references: [],
37
+ };
38
+ const tsconfigPath = path.join(this.typecheckDir, `${name}.json`);
39
+ await Bun.write(tsconfigPath, `${JSON.stringify(tsconfig, null, 2)}\n`);
40
+ if (clean) await rm(path.join(this.typecheckDir, `${name}.tsbuildinfo`), { force: true });
41
+ await this.#check(tsconfigPath);
42
+ return tsconfigPath;
43
+ }
44
+
45
+ async #check(tsconfigPath: string) {
46
+ const proc = Bun.spawn([process.execPath, await this.#workerEntry()], {
47
+ cwd: this.workspaceRoot,
48
+ env: { ...this.env, AKAN_COMMAND_TYPE: "typecheck", AKAN_TYPECHECK_TSCONFIG: tsconfigPath },
49
+ stdout: "pipe",
50
+ stderr: "pipe",
51
+ });
52
+ const [stdout, stderr, exitCode] = await Promise.all([
53
+ new Response(proc.stdout).text(),
54
+ new Response(proc.stderr).text(),
55
+ proc.exited,
56
+ ]);
57
+ if (exitCode !== 0) throw new Error((stderr || stdout).trim() || `Typecheck failed with exit code ${exitCode}`);
58
+ }
59
+
60
+ async #workerEntry() {
61
+ const candidates = [
62
+ path.join(this.workspaceRoot, "pkgs/@akanjs/devkit/typecheck/typecheck.proc.ts"),
63
+ path.join(this.workspaceRoot, "node_modules/@akanjs/devkit/typecheck/typecheck.proc.ts"),
64
+ path.join(import.meta.dir, "typecheck.proc.js"),
65
+ path.join(import.meta.dir, "typecheck.proc.ts"),
66
+ path.join(import.meta.dir, "..", "typecheck.proc.js"),
67
+ ];
68
+ for (const candidate of candidates) if (await Bun.file(candidate).exists()) return candidate;
69
+ throw new Error(`[cli] typecheck worker entry not found; looked in: ${candidates.join(", ")}`);
70
+ }
71
+ }
@@ -7,8 +7,10 @@ import {
7
7
  facetIndexShadowOf,
8
8
  isAllowedLibFacetRootFile,
9
9
  isScannedRootEntry,
10
+ libFacetRootHintOf,
10
11
  libRootAllowedDirs,
11
12
  libRootAllowedFiles,
13
+ moduleTestTargetOf,
12
14
  rootEntryHintOf,
13
15
  } from "./workspaceLayout";
14
16
 
@@ -104,17 +106,38 @@ describe("hand-written facet index", () => {
104
106
  });
105
107
 
106
108
  describe("lib facet root allowlist", () => {
107
- test("admits the generated support facets and a root signal test", () => {
109
+ test("admits the generated support facets", () => {
108
110
  expect(isAllowedLibFacetRootFile("cnst.ts")).toBe(true);
109
111
  expect(isAllowedLibFacetRootFile("option.ts")).toBe(true);
110
- expect(isAllowedLibFacetRootFile("user.signal.test.ts")).toBe(true);
111
- expect(isAllowedLibFacetRootFile("user.signal.spec.ts")).toBe(true);
112
112
  });
113
113
 
114
114
  test("rejects a hand-written file beside the barrels", () => {
115
115
  expect(isAllowedLibFacetRootFile("helper.ts")).toBe(false);
116
116
  expect(isAllowedLibFacetRootFile("user.test.ts")).toBe(false);
117
117
  });
118
+
119
+ test("rejects a test at the lib root and points it at its module folder or srvkit", () => {
120
+ for (const filename of ["cloud.signal.test.ts", "user.signal.spec.ts", "release.test.tsx"]) {
121
+ expect(isAllowedLibFacetRootFile(filename)).toBe(false);
122
+ expect(libFacetRootHintOf(filename)).toContain("lib/<model>/<model>.signal.test.ts");
123
+ expect(libFacetRootHintOf(filename)).toContain("srvkit/");
124
+ }
125
+ expect(libFacetRootHintOf("helper.ts")).toBeNull();
126
+ });
127
+ });
128
+
129
+ describe("module test target", () => {
130
+ test("names the module file a test covers", () => {
131
+ expect(moduleTestTargetOf("chatMessage.signal.test.ts")).toBe("chatMessage.signal.ts");
132
+ expect(moduleTestTargetOf("chatMessage.signal.spec.ts")).toBe("chatMessage.signal.ts");
133
+ expect(moduleTestTargetOf("ChatMessage.Zone.test.tsx")).toBe("ChatMessage.Zone.tsx");
134
+ expect(moduleTestTargetOf("chatMessage.trim.signal.test.ts")).toBe("chatMessage.trim.signal.ts");
135
+ });
136
+
137
+ test("leaves a file that is not a test alone", () => {
138
+ expect(moduleTestTargetOf("chatMessage.signal.ts")).toBeNull();
139
+ expect(moduleTestTargetOf("chatMessage.testing.ts")).toBeNull();
140
+ });
118
141
  });
119
142
 
120
143
  const templatesDir = path.join(import.meta.dir, "..", "cli", "templates");
@@ -78,11 +78,22 @@ export const libFacetRootAllowedFiles = new Set([
78
78
  "useServer.ts",
79
79
  ]);
80
80
 
81
- /** `<model>.signal.test.ts` — lib 파싯 루트에 놓이는 유일한 비생성 파일 (테스트가 배럴 전체를 부팅한다). */
82
- const libFacetRootTestPattern = /^[A-Za-z][A-Za-z0-9_-]*\.signal\.(test|spec)\.(ts|tsx)$/;
81
+ export const isAllowedLibFacetRootFile = (filename: string) => libFacetRootAllowedFiles.has(filename);
83
82
 
84
- export const isAllowedLibFacetRootFile = (filename: string) =>
85
- libFacetRootAllowedFiles.has(filename) || libFacetRootTestPattern.test(filename);
83
+ const testSuffix = /\.(test|spec)(\.tsx?)$/;
84
+
85
+ export const libFacetRootHintOf = (filename: string) =>
86
+ testSuffix.test(filename)
87
+ ? "a test beside the generated barrels; move it into the module folder it covers " +
88
+ "(lib/<model>/<model>.signal.test.ts), or into srvkit/ when it drives several modules"
89
+ : null;
90
+
91
+ export const moduleTestTargetOf = (filename: string) =>
92
+ testSuffix.test(filename) ? filename.replace(testSuffix, "$2") : null;
93
+
94
+ export const moduleTestHint =
95
+ "a module test is named after the one module file it covers (<model>.<role>.test.ts); add a describe block to " +
96
+ "that file's suite instead, or move a suite that drives several modules into srvkit/";
86
97
 
87
98
  export const barrelFacetDirs = ["ui", "webkit", "srvkit", "common", "plugin"] as const;
88
99