@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.
- package/akanConfig/akanConfig.test.ts +51 -5
- package/akanConfig/akanConfig.ts +31 -12
- package/akanConfig/akanWorkspaceConfig.test.ts +6 -6
- package/akanConfig/akanWorkspaceConfig.ts +1 -1
- package/applicationBuildRunner.test.ts +33 -1
- package/applicationBuildRunner.ts +4 -5
- package/cloud/cloudApi.ts +22 -15
- package/cloud/cloudHttpError.test.ts +32 -0
- package/cloud/cloudHttpError.ts +54 -0
- package/cloud/cloudUpdates.ts +138 -0
- package/cloud/globalConfig.test.ts +17 -0
- package/cloud/globalConfig.ts +8 -2
- package/cloud/index.ts +3 -0
- package/cloud/pipelineSecrets.ts +114 -0
- package/cloud/updatePackUpload.test.ts +100 -45
- package/cloud/updatePackUpload.ts +38 -18
- package/commandDecorators/argMeta.ts +11 -2
- package/commandDecorators/command.ts +40 -6
- package/commandDecorators/commandBuilder.ts +6 -1
- package/commandDecorators/commandDecorators.test.ts +205 -4
- package/commandDecorators/exitCodeError.ts +15 -0
- package/commandDecorators/index.ts +1 -0
- package/executors.test.ts +69 -0
- package/executors.ts +30 -27
- package/frontendBuild/csrArtifactBuilder.ts +18 -1
- package/incrementalBuilder/buildBatch.proc.ts +12 -7
- package/incrementalBuilder/incrementalBuilder.proc.test.ts +63 -0
- package/incrementalBuilder/incrementalBuilder.proc.ts +7 -5
- package/mobile/desktopServerStage.test.ts +13 -0
- package/mobile/desktopServerStage.ts +5 -7
- package/mobile/index.ts +2 -0
- package/mobile/nativeApi.test.ts +26 -0
- package/mobile/nativeApi.ts +5 -2
- package/mobile/nativeApp.test.ts +135 -33
- package/mobile/nativeApp.ts +109 -73
- package/mobile/nativeConfig.ts +2 -1
- package/mobile/nativePublicEnv.test.ts +131 -0
- package/mobile/nativePublicEnv.ts +116 -0
- package/mobile/serverBundleSecrets.test.ts +79 -0
- package/mobile/serverBundleSecrets.ts +60 -16
- package/mobile/updatePackFolder.test.ts +98 -0
- package/mobile/updatePackFolder.ts +131 -0
- package/mobile/updateUploader.test.ts +114 -22
- package/mobile/updateUploader.ts +75 -8
- package/package.json +2 -2
- package/pipeline/PipelineCompiler.test.ts +157 -1
- package/pipeline/PipelineCompiler.ts +80 -46
- package/pipeline/PipelineDefinition.test.ts +9 -0
- package/pipeline/PipelineDefinition.ts +4 -3
- package/pipeline/PipelineDryRun.test.ts +27 -0
- package/pipeline/PipelineDryRun.ts +2 -2
- package/pipeline/PipelineInputReader.ts +157 -253
- package/pipeline/PipelineRules.ts +92 -0
- package/pipeline/index.ts +1 -0
- package/qualityScanner.test.ts +5 -3
- package/slicePlanner.ts +5 -3
- package/subspace.test.ts +90 -19
- package/subspace.ts +52 -500
- package/subspaceClone.ts +361 -0
- package/subspaceConfig.ts +1 -2
- package/subspaceReport.ts +162 -0
- package/transforms/serverEnvPlugin.test.ts +76 -0
- package/transforms/serverEnvPlugin.ts +9 -25
- package/transforms/serverEnvProfile.test.ts +189 -0
- package/transforms/serverEnvProfile.ts +324 -60
- 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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
return
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
if (
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
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
|
|
78
|
-
'
|
|
79
|
-
"
|
|
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: {
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
88
|
-
new
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
63
|
-
path.join(
|
|
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(`[
|
|
70
|
+
throw new Error(`[devkit] typecheck worker entry not found; looked in: ${candidates.join(", ")}`);
|
|
70
71
|
}
|
|
71
72
|
}
|