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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/akanConfig/akanConfig.test.ts +51 -5
  2. package/akanConfig/akanConfig.ts +31 -12
  3. package/akanConfig/akanWorkspaceConfig.test.ts +6 -6
  4. package/akanConfig/akanWorkspaceConfig.ts +1 -1
  5. package/applicationBuildRunner.test.ts +33 -1
  6. package/applicationBuildRunner.ts +4 -5
  7. package/cloud/cloudApi.ts +22 -15
  8. package/cloud/cloudHttpError.test.ts +32 -0
  9. package/cloud/cloudHttpError.ts +54 -0
  10. package/cloud/cloudUpdates.ts +138 -0
  11. package/cloud/globalConfig.test.ts +17 -0
  12. package/cloud/globalConfig.ts +8 -2
  13. package/cloud/index.ts +3 -0
  14. package/cloud/pipelineSecrets.ts +114 -0
  15. package/cloud/updatePackUpload.test.ts +100 -45
  16. package/cloud/updatePackUpload.ts +38 -18
  17. package/commandDecorators/argMeta.ts +11 -2
  18. package/commandDecorators/command.ts +40 -6
  19. package/commandDecorators/commandBuilder.ts +6 -1
  20. package/commandDecorators/commandDecorators.test.ts +205 -4
  21. package/commandDecorators/exitCodeError.ts +15 -0
  22. package/commandDecorators/index.ts +1 -0
  23. package/executors.test.ts +69 -0
  24. package/executors.ts +30 -27
  25. package/frontendBuild/csrArtifactBuilder.ts +18 -1
  26. package/incrementalBuilder/buildBatch.proc.ts +12 -7
  27. package/incrementalBuilder/incrementalBuilder.proc.test.ts +63 -0
  28. package/incrementalBuilder/incrementalBuilder.proc.ts +7 -5
  29. package/mobile/desktopServerStage.test.ts +13 -0
  30. package/mobile/desktopServerStage.ts +5 -7
  31. package/mobile/index.ts +2 -0
  32. package/mobile/nativeApi.test.ts +26 -0
  33. package/mobile/nativeApi.ts +5 -2
  34. package/mobile/nativeApp.test.ts +135 -33
  35. package/mobile/nativeApp.ts +109 -73
  36. package/mobile/nativeConfig.ts +2 -1
  37. package/mobile/nativePublicEnv.test.ts +131 -0
  38. package/mobile/nativePublicEnv.ts +116 -0
  39. package/mobile/serverBundleSecrets.test.ts +79 -0
  40. package/mobile/serverBundleSecrets.ts +60 -16
  41. package/mobile/updatePackFolder.test.ts +98 -0
  42. package/mobile/updatePackFolder.ts +131 -0
  43. package/mobile/updateUploader.test.ts +114 -22
  44. package/mobile/updateUploader.ts +75 -8
  45. package/package.json +2 -2
  46. package/pipeline/PipelineCompiler.test.ts +157 -1
  47. package/pipeline/PipelineCompiler.ts +80 -46
  48. package/pipeline/PipelineDefinition.test.ts +9 -0
  49. package/pipeline/PipelineDefinition.ts +4 -3
  50. package/pipeline/PipelineDryRun.test.ts +27 -0
  51. package/pipeline/PipelineDryRun.ts +2 -2
  52. package/pipeline/PipelineInputReader.ts +157 -253
  53. package/pipeline/PipelineRules.ts +92 -0
  54. package/pipeline/index.ts +1 -0
  55. package/qualityScanner.test.ts +5 -3
  56. package/slicePlanner.ts +5 -3
  57. package/subspace.test.ts +90 -19
  58. package/subspace.ts +52 -500
  59. package/subspaceClone.ts +361 -0
  60. package/subspaceConfig.ts +1 -2
  61. package/subspaceReport.ts +162 -0
  62. package/transforms/serverEnvPlugin.test.ts +76 -0
  63. package/transforms/serverEnvPlugin.ts +9 -25
  64. package/transforms/serverEnvProfile.test.ts +189 -0
  65. package/transforms/serverEnvProfile.ts +324 -60
  66. package/typecheck/projectTypecheck.ts +6 -5
@@ -1,4 +1,6 @@
1
+ import { existsSync, realpathSync } from "node:fs";
1
2
  import path from "node:path";
3
+ import { Logger, toError } from "akanjs/common";
2
4
 
3
5
  export interface ServerEnvProfileProps {
4
6
  envDir: string;
@@ -6,92 +8,348 @@ export interface ServerEnvProfileProps {
6
8
  profile: string;
7
9
  }
8
10
 
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;
11
+ export interface ServerEnvSource {
12
+ loader: "ts" | "js";
13
+ contents: string;
14
+ }
15
+
16
+ export interface LibEnvSource extends ServerEnvSource {
17
+ profileFile: boolean;
18
+ stripped: string[];
19
+ }
20
+
21
+ type EnvLoad = (envFile: string) => Promise<ServerEnvSource>;
22
+
23
+ interface Stripped {
24
+ value: unknown;
25
+ stripped: string[];
26
+ }
27
+
28
+ interface EnvFold<T> {
29
+ text(value: string, at: string, underSecret: boolean): T;
30
+ other(value: unknown): T;
31
+ node(value: object, children: (readonly [string, T])[]): T;
32
+ }
33
+
34
+ type EvaluatedMessage = { load: string } | { env: string; lossy: string[] } | { failure: string };
14
35
 
15
36
  //* A server env profile is bundled in place of the environment's own file, for a server that runs where its bundle
16
37
  //* can be read: the computers a desktop app is installed on. So it may hold no secret.
17
38
  export class ServerEnvProfile {
18
39
  static readonly namePattern = /^[a-z][a-zA-Z0-9-]*$/;
40
+ static readonly envFilePattern = /[\\/]env[\\/]env\.server\.[A-Za-z0-9-]+\.ts$/;
19
41
 
20
42
  static fileName(environment: string, profile: string) {
21
43
  return `env.server.${environment}.${profile}.ts`;
22
44
  }
23
45
 
24
- /** `env.server.<name>.ts` → `env.server.<name>.<profile>.ts`, the file a profile build bundles in its place. */
25
46
  static profileFileOf(file: string, profile: string) {
26
47
  return file.replace(/\.ts$/, `.${profile}.ts`);
27
48
  }
28
49
 
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 }] : [];
50
+ static #url(value: string) {
51
+ if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(value)) return null;
52
+ try {
53
+ return new URL(value);
54
+ } catch {
55
+ return null;
58
56
  }
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)),
57
+ }
58
+
59
+ static #carriesCredential(url: URL) {
60
+ return !!url.password || [...url.searchParams.keys()].some((name) => Logger.isSecretKey(name));
61
+ }
62
+
63
+ static #isCredentialUrl(value: string) {
64
+ const url = ServerEnvProfile.#url(value);
65
+ return !!url && ServerEnvProfile.#carriesCredential(url);
66
+ }
67
+
68
+ static #isSecret(value: string, underSecret: boolean) {
69
+ return (underSecret && !!value.trim()) || ServerEnvProfile.#isCredentialUrl(value);
70
+ }
71
+
72
+ //* The one walk over an env value: each string with its path, and whether a secret-named key holds it at any depth.
73
+ static #fold<T>(value: unknown, fold: EnvFold<T>, at = "", underSecret = false): T {
74
+ if (typeof value === "string") return fold.text(value, at, underSecret);
75
+ if (!value || typeof value !== "object") return fold.other(value);
76
+ return fold.node(
77
+ value,
78
+ Object.entries(value).map(
79
+ ([key, child]) =>
80
+ [
81
+ key,
82
+ ServerEnvProfile.#fold(child, fold, at ? `${at}.${key}` : key, underSecret || Logger.isSecretKey(key)),
83
+ ] as const,
84
+ ),
85
+ );
86
+ }
87
+
88
+ static #collect<T>(text: EnvFold<T[]>["text"]): EnvFold<T[]> {
89
+ return { text, other: () => [], node: (_, children) => children.flatMap(([, found]) => found) };
90
+ }
91
+
92
+ static strip(value: unknown): Stripped {
93
+ return ServerEnvProfile.#fold<Stripped>(value, {
94
+ text: (text, at, underSecret) =>
95
+ ServerEnvProfile.#isSecret(text, underSecret)
96
+ ? { value: undefined, stripped: [at] }
97
+ : { value: text, stripped: [] },
98
+ other: (other) => ({ value: other, stripped: [] }),
99
+ node: (node, children) => {
100
+ const stripped = children.flatMap(([, result]) => result.stripped);
101
+ //? An array leaves out a stripped element whole, so neither a `null` nor an emptied `{}` takes its place.
102
+ if (Array.isArray(node))
103
+ return {
104
+ value: children.flatMap(([, result]) =>
105
+ result.value === undefined ||
106
+ (result.stripped.length && typeof result.value === "object" && !Object.keys(result.value ?? {}).length)
107
+ ? []
108
+ : [result.value],
109
+ ),
110
+ stripped,
111
+ };
112
+ return {
113
+ value: Object.fromEntries(
114
+ children.flatMap(([key, result]) => (result.value === undefined ? [] : [[key, result.value]])),
115
+ ),
116
+ stripped,
117
+ };
118
+ },
119
+ });
120
+ }
121
+
122
+ //* The values to look for in a bundle: each under a secret-named key, and each long one in a block that is a
123
+ //* credential whole — but not a lowercase constant there (`service_account`), nor a URL holding no credential
124
+ //* (a token endpoint), which library code holds as well. A webhook's URL is a credential whole.
125
+ static secretValues(value: unknown): { path: string; value: string }[] {
126
+ return ServerEnvProfile.#fold(
127
+ value,
128
+ ServerEnvProfile.#collect((text, at, underSecret) => {
129
+ const url = ServerEnvProfile.#url(text);
130
+ if (url)
131
+ return ServerEnvProfile.#carriesCredential(url) ||
132
+ (underSecret && (/webhook/i.test(at) || ServerEnvProfile.distinctive(text)))
133
+ ? [...new Set([text, url.password])]
134
+ .filter((part) => part.length >= 8)
135
+ .map((part) => ({ path: at, value: part }))
136
+ : [];
137
+ const named = Logger.isSecretKey(at.split(".").at(-1) ?? "");
138
+ return underSecret && text.length >= 8 && (named || ServerEnvProfile.distinctive(text))
139
+ ? [{ path: at, value: text }]
140
+ : [];
141
+ }),
62
142
  );
63
143
  }
64
144
 
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)),
145
+ static distinctive(value: string): boolean {
146
+ const url = ServerEnvProfile.#url(value);
147
+ if (url)
148
+ return (
149
+ ServerEnvProfile.#carriesCredential(url) ||
150
+ [...url.pathname.split("/"), ...url.searchParams.values()].some((part) => ServerEnvProfile.distinctive(part))
151
+ );
152
+ return value.length >= 16 && !/^https?:\/\//.test(value) && !/^[a-z]+(?:[._-][a-z]+)*$/.test(value);
153
+ }
154
+
155
+ static secretPaths(value: unknown): string[] {
156
+ return ServerEnvProfile.#fold(
157
+ value,
158
+ ServerEnvProfile.#collect((text, at, underSecret) => (ServerEnvProfile.#isSecret(text, underSecret) ? [at] : [])),
71
159
  );
72
160
  }
73
161
 
74
162
  //* 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> {
163
+ //* The result comes back over IPC, as an import may write to stdout; `load` answers for each env file it reaches.
164
+ static async evaluate(file: string, cwd: string, load?: EnvLoad): Promise<unknown> {
165
+ return (await ServerEnvProfile.#evaluated(file, cwd, load)).value;
166
+ }
167
+
168
+ //? Bun prints a failing module's source line with its error, so stderr is never read: the child sends the error's
169
+ //? name and place, and its message without JSC's `(evaluating '…')` or any string literal of the files at fault.
170
+ static async #evaluated(file: string, cwd: string, load?: EnvLoad): Promise<{ value: unknown; lossy: string[] }> {
171
+ const pattern = (regex: RegExp, flags = "") => `new RegExp(${JSON.stringify(regex.source)}, "${flags}")`;
172
+ const literal = /(["'`])((?:\\[\s\S]|(?!\1)[^\\])*)\1/;
173
+ const frame = /^\s*at (?:[^(]*\()?(.+):(\d+):(\d+)\)?\s*$/;
76
174
  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));",
175
+ "const waiting = new Map();",
176
+ 'process.on("message", ({ load, error, loader, contents }) => {',
177
+ " const reply = waiting.get(load);",
178
+ " waiting.delete(load);",
179
+ " if (error === undefined) reply?.resolve({ loader, contents });",
180
+ " else reply?.reject(new Error(error));",
181
+ "});",
182
+ "if (process.env.AKAN_PROFILE_ENV_LOAD)",
183
+ " Bun.plugin({",
184
+ ' name: "akan-server-env",',
185
+ " setup(build) {",
186
+ " build.onLoad({ filter: new RegExp(process.env.AKAN_PROFILE_ENV_LOAD) }, (args) =>",
187
+ " new Promise((resolve, reject) => {",
188
+ " waiting.set(args.path, { resolve, reject });",
189
+ " process.send({ load: args.path });",
190
+ " }));",
191
+ " },",
192
+ " });",
193
+ `const literal = ${pattern(literal, "g")};`,
194
+ `const interpolation = ${pattern(/\$\{[^}]*\}/)};`,
195
+ `const frameOf = ${pattern(frame)};`,
196
+ `const evaluating = ${pattern(/\s*\(evaluating '[\s\S]*'\)\s*$/)};`,
197
+ "const literalsOf = async (file) =>",
198
+ ' [...(await Bun.file(file).text().catch(() => "")).matchAll(literal)].flatMap((match) => match[2].split(interpolation));',
199
+ "const describe = async (error) => {",
200
+ " const first = Array.isArray(error?.errors) && error.errors.length ? error.errors[0] : error;",
201
+ ' const frame = String(first?.stack ?? "").split("\\n").map((line) => frameOf.exec(line)).find(Boolean);',
202
+ " const at = first?.position?.file ? [first.position.file, first.position.line, first.position.column] : frame ? frame.slice(1, 4) : [];",
203
+ " const files = [process.env.AKAN_PROFILE_ENV_FILE, at[0]].filter(Boolean);",
204
+ " const literals = (await Promise.all(files.map(literalsOf))).flat().filter((part) => part.length >= 4);",
205
+ ' let message = String(first?.message ?? first).replace(evaluating, "");',
206
+ " for (const part of literals.sort((a, b) => b.length - a.length))",
207
+ ' message = message.split(part).join("[redacted]");',
208
+ ' const name = typeof first?.name === "string" && /^[A-Za-z]+$/.test(first.name) ? first.name : "Error";',
209
+ ' return name + ": " + message.slice(0, 200) + (at.length ? " (at " + at.join(":") + ")" : "");',
210
+ "};",
211
+ "try {",
212
+ " const { env } = await import(process.env.AKAN_PROFILE_ENV_FILE);",
213
+ " const lossy = [];",
214
+ " const walk = (value, at) => {",
215
+ ' if (typeof value === "function" || typeof value === "symbol" || typeof value === "bigint" || (typeof value === "number" && !Number.isFinite(value))) return lossy.push(at);',
216
+ ' if (!value || typeof value !== "object") return;',
217
+ " const proto = Object.getPrototypeOf(value);",
218
+ " if (!Array.isArray(value) && proto !== Object.prototype && proto !== null) return lossy.push(at);",
219
+ ' for (const [key, child] of Object.entries(value)) walk(child, at ? at + "." + key : key);',
220
+ " };",
221
+ ' walk(env, "");',
222
+ ' const plain = (_, value) => (typeof value === "function" || typeof value === "symbol" ? undefined : typeof value === "bigint" ? String(value) : value);',
223
+ ' process.send({ env: JSON.stringify(env ?? null, plain) ?? "null", lossy }, undefined, undefined, () => process.disconnect());',
224
+ "} catch (error) {",
225
+ " process.exitCode = 1;",
226
+ " process.send({ failure: await describe(error) }, undefined, undefined, () => process.disconnect());",
227
+ "}",
80
228
  ].join("\n");
229
+ let sent: { env: string; lossy: string[] } | undefined;
230
+ let failure: string | undefined;
231
+ let loadError: unknown;
81
232
  const child = Bun.spawn([process.execPath, "--eval", script], {
82
233
  cwd,
83
- env: { PATH: process.env.PATH ?? "", HOME: process.env.HOME ?? "", AKAN_PROFILE_ENV_FILE: file },
84
- stdout: "pipe",
85
- stderr: "pipe",
234
+ env: {
235
+ PATH: process.env.PATH ?? "",
236
+ HOME: process.env.HOME ?? "",
237
+ AKAN_PROFILE_ENV_FILE: file,
238
+ ...(load ? { AKAN_PROFILE_ENV_LOAD: ServerEnvProfile.envFilePattern.source } : {}),
239
+ },
240
+ stdout: "ignore",
241
+ stderr: "ignore",
242
+ serialization: "json",
243
+ ipc: (message: EvaluatedMessage, subprocess) => {
244
+ if ("env" in message) {
245
+ sent = message;
246
+ return;
247
+ }
248
+ if ("failure" in message) {
249
+ failure = message.failure;
250
+ return;
251
+ }
252
+ if (!load) return;
253
+ const requested = message.load;
254
+ void load(requested)
255
+ .then(
256
+ ({ loader, contents }) => ({ load: requested, loader, contents }),
257
+ (error: unknown) => {
258
+ loadError ??= error;
259
+ return { load: requested, error: toError(error).message };
260
+ },
261
+ )
262
+ .then((reply) => subprocess.send(reply))
263
+ //? The child is gone, and its exit says why.
264
+ .catch(() => undefined);
265
+ },
266
+ });
267
+ const exitCode = await child.exited;
268
+ if (exitCode !== 0 || !sent) {
269
+ if (loadError) throw loadError;
270
+ throw new Error(
271
+ `${path.basename(file)} could not be evaluated to check it for secrets: ${failure ?? `its process exited with code ${exitCode} and sent nothing`}`,
272
+ );
273
+ }
274
+ return { value: JSON.parse(sent.env) as unknown, lossy: sent.lossy };
275
+ }
276
+
277
+ //? A profile is served under the path of the file it replaces, so its import of that file would import itself.
278
+ static async sourceInPlaceOf(profileFile: string, replacedFile: string, profile: string) {
279
+ const contents = await Bun.file(profileFile).text();
280
+ const dir = path.dirname(profileFile);
281
+ const replaced = ServerEnvProfile.#real(replacedFile);
282
+ const selfImports = new Bun.Transpiler({ loader: "ts" }).scanImports(contents).flatMap(({ path: specifier }) => {
283
+ try {
284
+ return ServerEnvProfile.#real(Bun.resolveSync(specifier, dir)) === replaced ? [specifier] : [];
285
+ } catch {
286
+ //? An import that resolves nowhere fails the build on its own.
287
+ return [];
288
+ }
86
289
  });
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;
290
+ if (selfImports.length)
291
+ throw new Error(
292
+ `${path.basename(profileFile)} imports ${selfImports.join(", ")}, the file a "${profile}" build bundles it in place of, so it would import itself. Write the values it needs into it instead.`,
293
+ );
294
+ return contents;
295
+ }
296
+
297
+ static #real(file: string) {
298
+ return existsSync(file) ? realpathSync.native(file) : path.resolve(file);
299
+ }
300
+
301
+ //* A lib's `server.ts` hands its `env.server.testing.ts` to the server as defaults, so a profile swaps those as it
302
+ //* swaps the app's: for the lib's `env.server.<name>.<profile>.ts`, else for the defaults without a secret in them.
303
+ static async libEnvSource(file: string, profile: string, chain: string[] = []): Promise<LibEnvSource> {
304
+ if (chain.includes(file))
305
+ throw new Error(
306
+ `${[...chain, file].join(" -> ")}: lib env files reach each other, which a "${profile}" build cannot swap.`,
307
+ );
308
+ const profileFile = ServerEnvProfile.profileFileOf(file, profile);
309
+ const libRoot = path.dirname(path.dirname(file));
310
+ if (await Bun.file(profileFile).exists()) {
311
+ const contents = await ServerEnvProfile.sourceInPlaceOf(profileFile, file, profile);
312
+ const reached = [...chain, file];
313
+ const value = await ServerEnvProfile.evaluate(profileFile, libRoot, (envFile) =>
314
+ ServerEnvProfile.#underProfile(envFile, profile, new Set(), reached),
315
+ );
316
+ const secrets = ServerEnvProfile.secretPaths(value);
317
+ if (secrets.length)
318
+ throw new Error(
319
+ `${profileFile} holds secret-named values, which the "${profile}" bundle carries to every computer it runs on: ${secrets.join(", ")}.`,
320
+ );
321
+ return { loader: "ts", contents, profileFile: true, stripped: [] };
322
+ }
323
+ const { exports } = new Bun.Transpiler({ loader: "ts" }).scan(await Bun.file(file).text());
324
+ if (exports.some((name) => name !== "env"))
325
+ throw new Error(
326
+ `${file} exports ${exports.join(", ")}: a "${profile}" build carries only a lib env's \`env\`, so give the lib ${path.basename(profileFile)}.`,
327
+ );
328
+ const evaluated = await ServerEnvProfile.#evaluated(file, libRoot);
329
+ if (evaluated.lossy.length)
330
+ throw new Error(
331
+ `${file} holds ${evaluated.lossy.join(", ")}, which a "${profile}" build cannot carry: it bundles a lib env's defaults as JSON, which keeps no function, Map, Set, Date or class instance. Give the lib ${path.basename(profileFile)}.`,
332
+ );
333
+ const { value, stripped } = ServerEnvProfile.strip(evaluated.value);
334
+ return {
335
+ loader: "js",
336
+ contents: `export const env = ${JSON.stringify(value ?? {})};\n`,
337
+ profileFile: false,
338
+ stripped,
339
+ };
340
+ }
341
+
342
+ static async #underProfile(
343
+ envFile: string,
344
+ profile: string,
345
+ appEnvDirs: ReadonlySet<string>,
346
+ chain: string[],
347
+ ): Promise<ServerEnvSource> {
348
+ const name = path.basename(envFile, ".ts").slice("env.server.".length);
349
+ if (appEnvDirs.has(path.dirname(envFile)) || name === "type" || name === "example")
350
+ return { loader: "ts", contents: await Bun.file(envFile).text() };
351
+ const { loader, contents } = await ServerEnvProfile.libEnvSource(envFile, profile, chain);
352
+ return { loader, contents };
95
353
  }
96
354
 
97
355
  static async assertShippable({ envDir, environment, profile }: ServerEnvProfileProps) {
@@ -105,7 +363,13 @@ export class ServerEnvProfile {
105
363
  throw new Error(
106
364
  `The server env profile "${profile}" bundles env/${name} in place of env/env.server.${environment}.ts, and there is no such file.`,
107
365
  );
108
- const secrets = ServerEnvProfile.secretPaths(await ServerEnvProfile.evaluate(file, path.dirname(envDir)));
366
+ await ServerEnvProfile.sourceInPlaceOf(file, path.join(envDir, `env.server.${environment}.ts`), profile);
367
+ //? Evaluated as the bundle carries it: the lib env defaults it reaches swapped, as the build's plugin swaps them.
368
+ const appEnvDirs = new Set([path.resolve(envDir), realpathSync.native(envDir)]);
369
+ const value = await ServerEnvProfile.evaluate(file, path.dirname(envDir), (envFile) =>
370
+ ServerEnvProfile.#underProfile(envFile, profile, appEnvDirs, []),
371
+ );
372
+ const secrets = ServerEnvProfile.secretPaths(value);
109
373
  if (secrets.length)
110
374
  throw new Error(
111
375
  `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.`,
@@ -43,7 +43,7 @@ export class ProjectTypecheck {
43
43
  }
44
44
 
45
45
  async #check(tsconfigPath: string) {
46
- const proc = Bun.spawn([process.execPath, await this.#workerEntry()], {
46
+ const proc = Bun.spawn([process.execPath, await ProjectTypecheck.workerEntry(this.workspaceRoot)], {
47
47
  cwd: this.workspaceRoot,
48
48
  env: { ...this.env, AKAN_COMMAND_TYPE: "typecheck", AKAN_TYPECHECK_TSCONFIG: tsconfigPath },
49
49
  stdout: "pipe",
@@ -57,15 +57,16 @@ export class ProjectTypecheck {
57
57
  if (exitCode !== 0) throw new Error((stderr || stdout).trim() || `Typecheck failed with exit code ${exitCode}`);
58
58
  }
59
59
 
60
- async #workerEntry() {
60
+ //? The workspace's own devkit first, then an installed one, then the entry a bundle puts beside its chunks.
61
+ static async workerEntry(workspaceRoot: string) {
61
62
  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"),
63
+ path.join(workspaceRoot, "pkgs/@akanjs/devkit/typecheck/typecheck.proc.ts"),
64
+ path.join(workspaceRoot, "node_modules/@akanjs/devkit/typecheck/typecheck.proc.ts"),
64
65
  path.join(import.meta.dir, "typecheck.proc.js"),
65
66
  path.join(import.meta.dir, "typecheck.proc.ts"),
66
67
  path.join(import.meta.dir, "..", "typecheck.proc.js"),
67
68
  ];
68
69
  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
+ throw new Error(`[devkit] typecheck worker entry not found; looked in: ${candidates.join(", ")}`);
70
71
  }
71
72
  }