@akanjs/devkit 3.1.0-rc.1 → 3.1.0-rc.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/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 +46 -0
- package/executors.ts +26 -23
- 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
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
} from "./argMeta";
|
|
24
24
|
import { camelToKebabCase } from "./camelToKebabCase";
|
|
25
25
|
import { CommandContainer } from "./dependencyBuilder";
|
|
26
|
+
import { ExitCodeError } from "./exitCodeError";
|
|
26
27
|
import { formatCommandHelp, formatHelp } from "./helpFormatter";
|
|
27
28
|
import { type CommandCls, getTargetCommandNames, getTargetMetas } from "./targetMeta";
|
|
28
29
|
|
|
@@ -102,6 +103,9 @@ const confirm = ((config, context) => prompts().then((m) => m.confirm(config, co
|
|
|
102
103
|
const input = ((config, context) => prompts().then((m) => m.input(config, context))) as typeof inquirerInput;
|
|
103
104
|
const checkbox = ((config, context) => prompts().then((m) => m.checkbox(config, context))) as typeof inquirerCheckbox;
|
|
104
105
|
|
|
106
|
+
const argLabelOf = (argMeta: ArgMeta) =>
|
|
107
|
+
argMeta.type === "Option" ? `option '--${camelToKebabCase(argMeta.name)}'` : `argument '${argMeta.name}'`;
|
|
108
|
+
|
|
105
109
|
// A DynamicEnum is not checked: it resolves against a context the internal args have not populated yet.
|
|
106
110
|
// Compared as strings: the value is still commander's raw string while a numeric choice list holds numbers.
|
|
107
111
|
const assertEnumChoice = (argMeta: ArgMeta, value: unknown) => {
|
|
@@ -109,10 +113,19 @@ const assertEnumChoice = (argMeta: ArgMeta, value: unknown) => {
|
|
|
109
113
|
if (!enumChoices || typeof enumChoices === "function") return;
|
|
110
114
|
const choices = normalizeEnumChoices(enumChoices);
|
|
111
115
|
if (choices.some((choice) => String(choice.value) === String(value))) return;
|
|
112
|
-
const label =
|
|
113
|
-
argMeta.type === "Option" ? `option '--${camelToKebabCase(argMeta.name)}'` : `argument '${argMeta.name}'`;
|
|
114
116
|
throw new Error(
|
|
115
|
-
`${
|
|
117
|
+
`${argLabelOf(argMeta)} argument '${String(value)}' is invalid. Allowed choices are ${choices.map((choice) => choice.name).join(", ")}.`,
|
|
118
|
+
);
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
// On a piped stdin a prompt takes the pipe as its answer and echoes it: a piped secret printed as the secret's name.
|
|
122
|
+
export const assertPromptable = (
|
|
123
|
+
problem: string,
|
|
124
|
+
{ choices = [], remedy = "pass it on the command line" }: { choices?: readonly unknown[]; remedy?: string } = {},
|
|
125
|
+
) => {
|
|
126
|
+
if (process.stdin.isTTY) return;
|
|
127
|
+
throw new Error(
|
|
128
|
+
`${problem}: stdin is not a terminal to prompt on, so ${remedy}${choices.length ? ` (choices: ${choices.join(", ")})` : ""}.`,
|
|
116
129
|
);
|
|
117
130
|
};
|
|
118
131
|
|
|
@@ -136,8 +149,11 @@ export const getOptionValue = async (argMeta: ArgMeta, opt: Record<string, unkno
|
|
|
136
149
|
(typeof enumChoices === "function" ? await enumChoices(context) : enumChoices) ?? [],
|
|
137
150
|
);
|
|
138
151
|
if (choices.length === 1) return choices[0]?.value;
|
|
152
|
+
assertPromptable(`Missing ${argLabelOf(argMeta)}`, { choices: choices.map((choice) => choice.value) });
|
|
139
153
|
return await select({ message: ask ?? desc ?? `Select the ${name} value`, choices });
|
|
140
|
-
}
|
|
154
|
+
}
|
|
155
|
+
assertPromptable(`Missing ${argLabelOf(argMeta)}`);
|
|
156
|
+
if (type === "boolean") {
|
|
141
157
|
const message = ask ?? desc ?? `Do you want to set ${name}? ${desc ? ` (${desc})` : ""}: `;
|
|
142
158
|
return await confirm({ message });
|
|
143
159
|
} else return convertArgValue(await input({ message: inputMessageOf(argMeta) }), type ?? "string");
|
|
@@ -150,6 +166,13 @@ export const getArgumentValue = async (argMeta: ArgMeta, value: string | undefin
|
|
|
150
166
|
return convertArgValue(value, type ?? "string");
|
|
151
167
|
} else if (defaultValue !== undefined) return defaultValue;
|
|
152
168
|
else if (nullable) return null;
|
|
169
|
+
const { enum: enumChoices } = argMeta.argsOption;
|
|
170
|
+
assertPromptable(`Missing ${argLabelOf(argMeta)}`, {
|
|
171
|
+
choices:
|
|
172
|
+
enumChoices && typeof enumChoices !== "function"
|
|
173
|
+
? normalizeEnumChoices(enumChoices).map(({ value }) => value)
|
|
174
|
+
: [],
|
|
175
|
+
});
|
|
153
176
|
return convertArgValue(await input({ message: inputMessageOf(argMeta) }), type ?? "string");
|
|
154
177
|
};
|
|
155
178
|
|
|
@@ -205,6 +228,7 @@ export const getAppsArgumentValue = async (
|
|
|
205
228
|
return [...new Set(requested)].map((name) => AppExecutor.from(workspace, name));
|
|
206
229
|
}
|
|
207
230
|
if (appNames.length === 1 && appNames[0]) return [AppExecutor.from(workspace, appNames[0])];
|
|
231
|
+
assertPromptable("Missing apps", { choices: [...appNames, "all"] });
|
|
208
232
|
const remembered = new Set(await AppSelectionMemory.read(workspace.workspaceRoot));
|
|
209
233
|
const picked = await checkbox<string>({
|
|
210
234
|
message: "Select the apps to run (space to toggle, enter to confirm)",
|
|
@@ -224,6 +248,7 @@ export const getInternalArgumentValue = async (
|
|
|
224
248
|
if (argMeta.type === "Workspace") return workspace;
|
|
225
249
|
const sysType = argMeta.type.toLowerCase();
|
|
226
250
|
const [appNames, libNames, pkgNames] = await workspace.getExecs();
|
|
251
|
+
const problemOf = (kind: string) => (value ? `Unknown ${kind} '${value}'` : `Missing ${kind}`);
|
|
227
252
|
if (sysType === "sys" || sysType === "exec") {
|
|
228
253
|
const pkgChoices = sysType === "exec" ? pkgNames : [];
|
|
229
254
|
const execOf = (name: string) => {
|
|
@@ -234,6 +259,11 @@ export const getInternalArgumentValue = async (
|
|
|
234
259
|
};
|
|
235
260
|
const found = value ? execOf(value) : null;
|
|
236
261
|
if (found) return found;
|
|
262
|
+
if (!value && argMeta.option?.onlyApp && appNames.length === 1 && appNames[0])
|
|
263
|
+
return AppExecutor.from(workspace, appNames[0]);
|
|
264
|
+
assertPromptable(problemOf(sysType === "exec" ? "app, lib or pkg" : "app or lib"), {
|
|
265
|
+
choices: [...appNames, ...libNames, ...pkgChoices],
|
|
266
|
+
});
|
|
237
267
|
const name = await select<string>({
|
|
238
268
|
message: sysType === "exec" ? `Select the App or Lib or Pkg name` : `Select the App or Lib name`,
|
|
239
269
|
choices: [...appNames, ...libNames, ...pkgChoices],
|
|
@@ -244,15 +274,18 @@ export const getInternalArgumentValue = async (
|
|
|
244
274
|
} else if (sysType === "app") {
|
|
245
275
|
if (value && appNames.includes(value)) return AppExecutor.from(workspace, value);
|
|
246
276
|
if (!value && appNames.length === 1 && appNames[0]) return AppExecutor.from(workspace, appNames[0]);
|
|
277
|
+
assertPromptable(problemOf("app"), { choices: appNames });
|
|
247
278
|
const appName = await select<string>({ message: `Select the ${sysType} name`, choices: appNames });
|
|
248
279
|
return AppExecutor.from(workspace, appName);
|
|
249
280
|
} else if (sysType === "lib") {
|
|
250
281
|
if (value && libNames.includes(value)) return LibExecutor.from(workspace, value);
|
|
282
|
+
assertPromptable(problemOf("lib"), { choices: libNames });
|
|
251
283
|
const libName = await select<string>({ message: `Select the ${sysType} name`, choices: libNames });
|
|
252
284
|
return LibExecutor.from(workspace, libName);
|
|
253
285
|
} else if (sysType === "pkg") {
|
|
254
286
|
const pkgs = await workspace.getPkgs();
|
|
255
287
|
if (value && pkgs.includes(value)) return PkgExecutor.from(workspace, value);
|
|
288
|
+
assertPromptable(problemOf("pkg"), { choices: pkgs });
|
|
256
289
|
const pkgName = await select<string>({ message: `Select the ${sysType} name`, choices: pkgs });
|
|
257
290
|
return PkgExecutor.from(workspace, pkgName);
|
|
258
291
|
} else if (sysType === "module") {
|
|
@@ -270,6 +303,7 @@ export const getInternalArgumentValue = async (
|
|
|
270
303
|
if (modules.includes(moduleName)) return ModuleExecutor.from(lib, moduleName);
|
|
271
304
|
} else throw new Error(`Invalid system name: ${sysName}`);
|
|
272
305
|
}
|
|
306
|
+
assertPromptable(problemOf("module"));
|
|
273
307
|
const { type, name } = await select<{ type: "app" | "lib"; name: string }>({
|
|
274
308
|
message: `select the App or Lib name`,
|
|
275
309
|
choices: [
|
|
@@ -291,7 +325,7 @@ export const runCommands = async (...commands: CommandCls[]) => {
|
|
|
291
325
|
let failureExitCode = 1;
|
|
292
326
|
process.on("unhandledRejection", (error) => {
|
|
293
327
|
printCliError(error);
|
|
294
|
-
process.exit(failureExitCode);
|
|
328
|
+
process.exit(ExitCodeError.codeOf(error) ?? failureExitCode);
|
|
295
329
|
});
|
|
296
330
|
const __dirname = getDirname(import.meta.url);
|
|
297
331
|
const packageJsonCandidates = [`${path.dirname(Bun.main)}/package.json`, `${__dirname}/../package.json`];
|
|
@@ -419,6 +453,6 @@ It may cause unexpected behavior. Run \`akan update\` to update latest akanjs.`,
|
|
|
419
453
|
// Handled here, not by the handler above: a rejection reaching the entry's top-level await prints Bun's own trace.
|
|
420
454
|
await program.parseAsync(process.argv).catch((error: unknown) => {
|
|
421
455
|
printCliError(error);
|
|
422
|
-
process.exit(failureExitCode);
|
|
456
|
+
process.exit(ExitCodeError.codeOf(error) ?? failureExitCode);
|
|
423
457
|
});
|
|
424
458
|
};
|
|
@@ -115,7 +115,12 @@ class TargetBuilder<Deps extends readonly DependencyCls[], Params extends unknow
|
|
|
115
115
|
|
|
116
116
|
with<const Tokens extends readonly InternalArgToken[]>(...tokens: Tokens) {
|
|
117
117
|
return this.#add<AddInternalArgs<Params, Tokens>, AddInternalContext<Context, Tokens>>(
|
|
118
|
-
...tokens.map((token, offset) => ({
|
|
118
|
+
...tokens.map((token, offset) => ({
|
|
119
|
+
key: "",
|
|
120
|
+
idx: this.#args.length + offset,
|
|
121
|
+
type: token.type,
|
|
122
|
+
...(token.option ? { option: token.option } : {}),
|
|
123
|
+
})),
|
|
119
124
|
);
|
|
120
125
|
}
|
|
121
126
|
|
|
@@ -1,7 +1,21 @@
|
|
|
1
|
-
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
-
import
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { AppExecutor, LibExecutor, WorkspaceExecutor } from "../executors";
|
|
4
|
+
import { makeCliTempWorkspace, tempRoots, writeText } from "../testHelpers";
|
|
5
|
+
import {
|
|
6
|
+
App,
|
|
7
|
+
type ArgMeta,
|
|
8
|
+
type ArgsOption,
|
|
9
|
+
getArgMetas,
|
|
10
|
+
type InternalArgMeta,
|
|
11
|
+
Lib,
|
|
12
|
+
Module,
|
|
13
|
+
Pkg,
|
|
14
|
+
Sys,
|
|
15
|
+
SysOrOnlyApp,
|
|
16
|
+
Workspace,
|
|
17
|
+
} from "./argMeta";
|
|
18
|
+
import { getAppsArgumentValue, getArgumentValue, getInternalArgumentValue, getOptionValue } from "./command";
|
|
5
19
|
import { command } from "./commandBuilder";
|
|
6
20
|
import {
|
|
7
21
|
assertUniqueDependencies,
|
|
@@ -289,6 +303,89 @@ describe("enum arg choices", () => {
|
|
|
289
303
|
});
|
|
290
304
|
});
|
|
291
305
|
|
|
306
|
+
describe("prompts on a stdin that is not a terminal", () => {
|
|
307
|
+
const ownIsTTY = Object.getOwnPropertyDescriptor(process.stdin, "isTTY");
|
|
308
|
+
beforeEach(() => {
|
|
309
|
+
Object.defineProperty(process.stdin, "isTTY", { value: undefined, configurable: true });
|
|
310
|
+
});
|
|
311
|
+
afterEach(() => {
|
|
312
|
+
if (ownIsTTY) Object.defineProperty(process.stdin, "isTTY", ownIsTTY);
|
|
313
|
+
else Reflect.deleteProperty(process.stdin, "isTTY");
|
|
314
|
+
});
|
|
315
|
+
const metaOf = (type: "Argument" | "Option", name: string, argsOption: ArgsOption = {}): ArgMeta => ({
|
|
316
|
+
name,
|
|
317
|
+
argsOption: { type: "string", ...argsOption },
|
|
318
|
+
key: "run",
|
|
319
|
+
idx: 0,
|
|
320
|
+
type,
|
|
321
|
+
});
|
|
322
|
+
const context = { values: {} };
|
|
323
|
+
const workspaceWith = (appNames: string[]) => {
|
|
324
|
+
const workspace = new WorkspaceExecutor({ workspaceRoot: "/workspace", repoName: "repo" });
|
|
325
|
+
workspace.getExecs = async () => [appNames, ["util"], []];
|
|
326
|
+
workspace.getApps = async () => appNames;
|
|
327
|
+
return workspace;
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
test("a missing positional is refused by name instead of read from the pipe", async () => {
|
|
331
|
+
await expect(getArgumentValue(metaOf("Argument", "name", { desc: "secret name" }), undefined)).rejects.toThrow(
|
|
332
|
+
"Missing argument 'name': stdin is not a terminal to prompt on, so pass it on the command line.",
|
|
333
|
+
);
|
|
334
|
+
await expect(getArgumentValue(metaOf("Argument", "action", { enum: ["scan", "ssr"] }), undefined)).rejects.toThrow(
|
|
335
|
+
"Missing argument 'action': stdin is not a terminal to prompt on, so pass it on the command line (choices: scan, ssr).",
|
|
336
|
+
);
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
test("a missing option names its flag and the values it takes", async () => {
|
|
340
|
+
await expect(
|
|
341
|
+
getOptionValue(metaOf("Option", "platform", { enum: ["ios", "android"] }), {}, context),
|
|
342
|
+
).rejects.toThrow(
|
|
343
|
+
"Missing option '--platform': stdin is not a terminal to prompt on, so pass it on the command line (choices: ios, android).",
|
|
344
|
+
);
|
|
345
|
+
await expect(
|
|
346
|
+
getOptionValue(
|
|
347
|
+
metaOf("Option", "format", { enum: [{ label: "JSON output", value: "json" }, "text"] }),
|
|
348
|
+
{},
|
|
349
|
+
context,
|
|
350
|
+
),
|
|
351
|
+
).rejects.toThrow("(choices: json, text)");
|
|
352
|
+
await expect(getOptionValue(metaOf("Option", "assembleType"), {}, context)).rejects.toThrow(
|
|
353
|
+
"Missing option '--assemble-type'",
|
|
354
|
+
);
|
|
355
|
+
await expect(getOptionValue(metaOf("Option", "force", { type: "boolean" }), {}, context)).rejects.toThrow(
|
|
356
|
+
"Missing option '--force'",
|
|
357
|
+
);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
test("a value given, defaulted, nullable or the only choice still resolves without a prompt", async () => {
|
|
361
|
+
expect(await getArgumentValue(metaOf("Argument", "name"), "NPM_TOKEN")).toBe("NPM_TOKEN");
|
|
362
|
+
expect(await getOptionValue(metaOf("Option", "env", { default: "main" }), {}, context)).toBe("main");
|
|
363
|
+
expect(await getOptionValue(metaOf("Option", "host", { nullable: true }), {}, context)).toBe(null);
|
|
364
|
+
expect(await getOptionValue(metaOf("Option", "target", { enum: async () => ["default"] }), {}, context)).toBe(
|
|
365
|
+
"default",
|
|
366
|
+
);
|
|
367
|
+
const [app] = await getAppsArgumentValue(undefined, workspaceWith(["web"]));
|
|
368
|
+
expect(app?.name).toBe("web");
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
test("an app that is missing or unknown is refused with the workspace's apps", async () => {
|
|
372
|
+
const workspace = workspaceWith(["web", "admin"]);
|
|
373
|
+
await expect(getInternalArgumentValue({ key: "", idx: 0, type: "App" }, undefined, workspace)).rejects.toThrow(
|
|
374
|
+
"Missing app: stdin is not a terminal to prompt on, so pass it on the command line (choices: web, admin).",
|
|
375
|
+
);
|
|
376
|
+
await expect(getInternalArgumentValue({ key: "", idx: 0, type: "App" }, "globex", workspace)).rejects.toThrow(
|
|
377
|
+
"Unknown app 'globex'",
|
|
378
|
+
);
|
|
379
|
+
await expect(getInternalArgumentValue({ key: "", idx: 0, type: "Sys" }, "globex", workspace)).rejects.toThrow(
|
|
380
|
+
"Unknown app or lib 'globex': stdin is not a terminal to prompt on, so pass it on the command line (choices: web, admin, util).",
|
|
381
|
+
);
|
|
382
|
+
await expect(getInternalArgumentValue({ key: "", idx: 0, type: "Module" }, undefined, workspace)).rejects.toThrow(
|
|
383
|
+
"Missing module",
|
|
384
|
+
);
|
|
385
|
+
await expect(getAppsArgumentValue(undefined, workspace)).rejects.toThrow("(choices: web, admin, all)");
|
|
386
|
+
});
|
|
387
|
+
});
|
|
388
|
+
|
|
292
389
|
describe("enum arg type inference", () => {
|
|
293
390
|
afterEach(() => {
|
|
294
391
|
CommandContainer.clear();
|
|
@@ -334,3 +431,107 @@ describe("enum arg type inference", () => {
|
|
|
334
431
|
]);
|
|
335
432
|
});
|
|
336
433
|
});
|
|
434
|
+
|
|
435
|
+
describe("a Sys that defaults to the only app", () => {
|
|
436
|
+
const ownIsTTY = Object.getOwnPropertyDescriptor(process.stdin, "isTTY");
|
|
437
|
+
const asked: unknown[] = [];
|
|
438
|
+
let answer = "";
|
|
439
|
+
const setTTY = (isTTY: boolean | undefined) =>
|
|
440
|
+
Object.defineProperty(process.stdin, "isTTY", { value: isTTY, configurable: true });
|
|
441
|
+
beforeEach(() => {
|
|
442
|
+
asked.length = 0;
|
|
443
|
+
mock.module("@inquirer/prompts", () => ({
|
|
444
|
+
select: async ({ choices }: { choices: unknown[] }) => {
|
|
445
|
+
asked.push(choices);
|
|
446
|
+
return answer;
|
|
447
|
+
},
|
|
448
|
+
}));
|
|
449
|
+
});
|
|
450
|
+
afterEach(() => {
|
|
451
|
+
CommandContainer.clear();
|
|
452
|
+
if (ownIsTTY) Object.defineProperty(process.stdin, "isTTY", ownIsTTY);
|
|
453
|
+
else Reflect.deleteProperty(process.stdin, "isTTY");
|
|
454
|
+
});
|
|
455
|
+
const workspaceWith = (appNames: string[]) => {
|
|
456
|
+
const workspace = new WorkspaceExecutor({ workspaceRoot: "/workspace", repoName: "repo" });
|
|
457
|
+
workspace.getExecs = async () => [appNames, ["util"], []];
|
|
458
|
+
return workspace;
|
|
459
|
+
};
|
|
460
|
+
const sysMetaOf = (key: "typecheck" | "sync") => {
|
|
461
|
+
class OnlyAppCommand extends command("onlyApp", ({ public: target }) => ({
|
|
462
|
+
typecheck: target({ desc: "opted in" })
|
|
463
|
+
.with(SysOrOnlyApp)
|
|
464
|
+
.exec((sys) => sys),
|
|
465
|
+
sync: target({ desc: "plain" })
|
|
466
|
+
.with(Sys)
|
|
467
|
+
.exec((sys) => sys),
|
|
468
|
+
})) {}
|
|
469
|
+
const meta = getArgMetas(OnlyAppCommand, key)[0].find((arg): arg is InternalArgMeta => arg.type === "Sys");
|
|
470
|
+
if (!meta) throw new Error(`no Sys meta on ${key}`);
|
|
471
|
+
return meta;
|
|
472
|
+
};
|
|
473
|
+
|
|
474
|
+
test("the opted-in command takes a one-app workspace's app without asking, even on a piped stdin", async () => {
|
|
475
|
+
setTTY(undefined);
|
|
476
|
+
const meta = sysMetaOf("typecheck");
|
|
477
|
+
expect(meta.option).toEqual({ onlyApp: true });
|
|
478
|
+
|
|
479
|
+
const app = await getInternalArgumentValue(meta, undefined, workspaceWith(["web"]));
|
|
480
|
+
expect(app instanceof AppExecutor ? app.name : null).toBe("web");
|
|
481
|
+
expect(asked).toEqual([]);
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
test("a plain Sys command still asks app or library in a one-app workspace", async () => {
|
|
485
|
+
setTTY(true);
|
|
486
|
+
answer = "util";
|
|
487
|
+
const meta = sysMetaOf("sync");
|
|
488
|
+
expect(meta.option).toBeUndefined();
|
|
489
|
+
|
|
490
|
+
const lib = await getInternalArgumentValue(meta, undefined, workspaceWith(["web"]));
|
|
491
|
+
expect(lib instanceof LibExecutor ? lib.name : null).toBe("util");
|
|
492
|
+
expect(asked).toEqual([["web", "util"]]);
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
test("the opted-in command still asks when the workspace has two apps", async () => {
|
|
496
|
+
setTTY(true);
|
|
497
|
+
answer = "admin";
|
|
498
|
+
const app = await getInternalArgumentValue(sysMetaOf("typecheck"), undefined, workspaceWith(["web", "admin"]));
|
|
499
|
+
expect(app instanceof AppExecutor ? app.name : null).toBe("admin");
|
|
500
|
+
expect(asked).toEqual([["web", "admin", "util"]]);
|
|
501
|
+
});
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
describe("the exit code of a command that failed", () => {
|
|
505
|
+
const track = tempRoots();
|
|
506
|
+
const run = async (failure: string) => {
|
|
507
|
+
const { root } = track(await makeCliTempWorkspace());
|
|
508
|
+
await writeText(path.join(root, "package.json"), '{ "name": "repo", "version": "1.0.0" }\n');
|
|
509
|
+
await writeText(path.join(root, "tsconfig.json"), "{}\n");
|
|
510
|
+
const entry = path.join(root, "cli.ts");
|
|
511
|
+
await writeText(
|
|
512
|
+
entry,
|
|
513
|
+
[
|
|
514
|
+
`import { command, ExitCodeError, runCommands, runner, script } from ${JSON.stringify(path.join(import.meta.dir, "index.ts"))};`,
|
|
515
|
+
'class VerdictRunner extends runner("verdict") {}',
|
|
516
|
+
'class VerdictScript extends script("verdict", [VerdictRunner]) {}',
|
|
517
|
+
'class VerdictCommand extends command("verdict", [VerdictScript], ({ public: target }) => ({',
|
|
518
|
+
' verdict: target({ desc: "fails" }).exec(async function () {',
|
|
519
|
+
` throw ${failure};`,
|
|
520
|
+
" }),",
|
|
521
|
+
"})) {}",
|
|
522
|
+
"await runCommands(VerdictCommand);",
|
|
523
|
+
"",
|
|
524
|
+
].join("\n"),
|
|
525
|
+
);
|
|
526
|
+
const proc = Bun.spawn(["bun", entry, "verdict"], { cwd: root, stdout: "pipe", stderr: "pipe" });
|
|
527
|
+
const [stdout, code] = await Promise.all([new Response(proc.stdout).text(), proc.exited]);
|
|
528
|
+
return { stdout, code };
|
|
529
|
+
};
|
|
530
|
+
|
|
531
|
+
test("is the one an ExitCodeError carries, and 1 for any other error", async () => {
|
|
532
|
+
const verdict = await run('new ExitCodeError("needs a new store build", 2)');
|
|
533
|
+
expect(verdict.stdout).toContain("needs a new store build");
|
|
534
|
+
expect(verdict.code).toBe(2);
|
|
535
|
+
expect((await run('new Error("broke")')).code).toBe(1);
|
|
536
|
+
}, 30_000);
|
|
537
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
//* A failure that is an answer, not a breakage: the CLI exits with its code, so a CI tells the two apart.
|
|
2
|
+
export class ExitCodeError extends Error {
|
|
3
|
+
override readonly name = "ExitCodeError";
|
|
4
|
+
|
|
5
|
+
constructor(
|
|
6
|
+
message: string,
|
|
7
|
+
readonly exitCode: number,
|
|
8
|
+
) {
|
|
9
|
+
super(message);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
static codeOf(error: unknown): number | null {
|
|
13
|
+
return error instanceof ExitCodeError ? error.exitCode : null;
|
|
14
|
+
}
|
|
15
|
+
}
|
package/executors.test.ts
CHANGED
|
@@ -188,6 +188,35 @@ describe("Executor filesystem helpers", () => {
|
|
|
188
188
|
expect(formatted).toEqual([path.join(root, "apps/demo/page/task/_index.tsx")]);
|
|
189
189
|
});
|
|
190
190
|
|
|
191
|
+
test("hands the formatter only the files a template wrote, never one overwrite: false kept", async () => {
|
|
192
|
+
const { root, app } = trackRoot(await createTempApp("demo"));
|
|
193
|
+
const formatted: string[] = [];
|
|
194
|
+
app.getLinter = () =>
|
|
195
|
+
({
|
|
196
|
+
fixFiles: async (filePaths: string[]) => {
|
|
197
|
+
formatted.push(...filePaths);
|
|
198
|
+
return { fixed: [] };
|
|
199
|
+
},
|
|
200
|
+
}) as unknown as ReturnType<Executor["getLinter"]>;
|
|
201
|
+
const scalarDir = path.join(root, "apps/demo/lib/__scalar/money");
|
|
202
|
+
const own = "export class Money {}\n";
|
|
203
|
+
await writeText(path.join(scalarDir, "money.constant.ts"), own);
|
|
204
|
+
|
|
205
|
+
const applied = await app.applyTemplate({
|
|
206
|
+
basePath: "./lib/__scalar",
|
|
207
|
+
template: "__scalar",
|
|
208
|
+
dict: { model: "money", models: "moneys", sysName: "demo" },
|
|
209
|
+
overwrite: false,
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
expect(applied.map((fileContent) => fileContent.filePath)).toContain(path.join(scalarDir, "money.constant.ts"));
|
|
213
|
+
expect(formatted.sort((a, b) => a.localeCompare(b))).toEqual([
|
|
214
|
+
path.join(scalarDir, "money.dictionary.ts"),
|
|
215
|
+
path.join(scalarDir, "money.document.ts"),
|
|
216
|
+
]);
|
|
217
|
+
expect(await readFile(path.join(scalarDir, "money.constant.ts"), "utf8")).toBe(own);
|
|
218
|
+
});
|
|
219
|
+
|
|
191
220
|
test("a formatter that cannot run does not fail the scaffold", async () => {
|
|
192
221
|
// create-akan-workspace scaffolds before `bun install`, so Biome may be absent; a lint fix beats a failed scaffold.
|
|
193
222
|
const root = await makeTempRoot();
|
|
@@ -263,6 +292,23 @@ describe("Executor filesystem helpers", () => {
|
|
|
263
292
|
await readFile(path.join(templateRoot, "favicon.ico")),
|
|
264
293
|
);
|
|
265
294
|
});
|
|
295
|
+
|
|
296
|
+
test("keeps a static file already there when overwrite is false, and reports it unwritten", async () => {
|
|
297
|
+
const root = await makeTempRoot();
|
|
298
|
+
const exec = new Executor("fixture", root);
|
|
299
|
+
await writeText(path.join(root, "app/public/logo.png"), "the app's own logo");
|
|
300
|
+
|
|
301
|
+
const applied = await exec.applyTemplate({
|
|
302
|
+
basePath: "app/public",
|
|
303
|
+
template: "app/public/logo.png",
|
|
304
|
+
overwrite: false,
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
expect(await readFile(path.join(root, "app/public/logo.png"), "utf8")).toBe("the app's own logo");
|
|
308
|
+
expect(applied).toEqual([
|
|
309
|
+
expect.objectContaining({ filePath: path.join(root, "app/public/logo.png"), written: false }),
|
|
310
|
+
]);
|
|
311
|
+
});
|
|
266
312
|
});
|
|
267
313
|
|
|
268
314
|
describe("Workspace and app executor environment contracts", () => {
|
package/executors.ts
CHANGED
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
isRouteSourceFile,
|
|
29
29
|
Logger,
|
|
30
30
|
parseRouteModuleKey,
|
|
31
|
+
toError,
|
|
31
32
|
validatePageSourceFile,
|
|
32
33
|
validateSubRoutePageKey,
|
|
33
34
|
} from "akanjs/common";
|
|
@@ -52,6 +53,7 @@ import { AppInfo, isAkanFrameworkDependency, LibInfo, PkgInfo, WorkspaceInfo } f
|
|
|
52
53
|
import { Spinner } from "./spinner";
|
|
53
54
|
// Type-only: `getTypeChecker` loads it on demand to keep `typescript` out of the resident module graph.
|
|
54
55
|
import type { TypeChecker } from "./typeChecker";
|
|
56
|
+
import { ProjectTypecheck } from "./typecheck/projectTypecheck";
|
|
55
57
|
import type { FileContent, PackageJson, TsConfigJson } from "./types";
|
|
56
58
|
import { barrelFacetDirs } from "./workspaceLayout";
|
|
57
59
|
|
|
@@ -64,6 +66,10 @@ export interface PageRoot {
|
|
|
64
66
|
keyPrefix: string;
|
|
65
67
|
}
|
|
66
68
|
|
|
69
|
+
export interface WrittenFileContent extends FileContent {
|
|
70
|
+
written: boolean;
|
|
71
|
+
}
|
|
72
|
+
|
|
67
73
|
const staticTemplateFileExtensions = new Set(
|
|
68
74
|
".avif .bmp .cjs .css .eot .gif .html .ico .jpeg .jpg .js .json .map .md .mjs .mp3 .mp4 .ogg .otf .pdf .png .svg"
|
|
69
75
|
.split(" ")
|
|
@@ -302,17 +308,19 @@ export class Executor {
|
|
|
302
308
|
filePath: string,
|
|
303
309
|
content: string | object,
|
|
304
310
|
{ overwrite = true, silent = false }: { overwrite?: boolean; silent?: boolean } = {},
|
|
305
|
-
): Promise<
|
|
311
|
+
): Promise<WrittenFileContent> {
|
|
306
312
|
const writePath = this.getPath(filePath);
|
|
307
313
|
const dir = path.dirname(writePath);
|
|
308
314
|
if (!(await FileSys.dirExists(dir))) await mkdir(dir, { recursive: true });
|
|
309
315
|
let contentStr = typeof content === "string" ? content : JsonFormatter.stringify(content, writePath);
|
|
316
|
+
let written = true;
|
|
310
317
|
|
|
311
318
|
if (await FileSys.fileExists(writePath)) {
|
|
312
319
|
const currentContent = await FileSys.readText(writePath);
|
|
313
320
|
if (currentContent === contentStr || !overwrite) {
|
|
314
321
|
this.logger.verbose(`File ${writePath} is unchanged`);
|
|
315
322
|
contentStr = currentContent;
|
|
323
|
+
written = false;
|
|
316
324
|
} else {
|
|
317
325
|
await FileSys.writeText(writePath, contentStr);
|
|
318
326
|
if (Logger.isVerbose()) this.logger.rawLog(chalk.yellow(`File Update: ${filePath}`));
|
|
@@ -321,7 +329,7 @@ export class Executor {
|
|
|
321
329
|
await FileSys.writeText(writePath, contentStr);
|
|
322
330
|
if (!silent) this.logger.rawLog(chalk.green(`File Create: ${filePath}`));
|
|
323
331
|
}
|
|
324
|
-
return { filePath: writePath, content: contentStr };
|
|
332
|
+
return { filePath: writePath, content: contentStr, written };
|
|
325
333
|
}
|
|
326
334
|
async writeJson(filePath: string, content: object) {
|
|
327
335
|
await this.writeFile(filePath, content);
|
|
@@ -418,7 +426,7 @@ export class Executor {
|
|
|
418
426
|
},
|
|
419
427
|
dict: { [key: string]: string } = {},
|
|
420
428
|
options: { [key: string]: unknown } = {},
|
|
421
|
-
): Promise<
|
|
429
|
+
): Promise<WrittenFileContent | null> {
|
|
422
430
|
const convertPath = (target: string) =>
|
|
423
431
|
Object.entries(dict).reduce(
|
|
424
432
|
(converted, [key, value]) => converted.replace(new RegExp(`__${key}__`, "g"), value),
|
|
@@ -451,11 +459,13 @@ export class Executor {
|
|
|
451
459
|
} else if (staticTemplateFileExtensions.has(path.extname(targetPath).toLowerCase())) {
|
|
452
460
|
const convertedTargetPath = convertPath(targetPath);
|
|
453
461
|
const writePath = this.getPath(convertedTargetPath);
|
|
462
|
+
if (!overwrite && (await FileSys.fileExists(writePath)))
|
|
463
|
+
return { filePath: writePath, content: "", written: false };
|
|
454
464
|
const dirname = path.dirname(writePath);
|
|
455
465
|
if (!(await FileSys.dirExists(dirname))) await mkdir(dirname, { recursive: true });
|
|
456
466
|
await copyFile(templatePath, writePath);
|
|
457
467
|
this.logger.verbose(`Apply template ${templatePath} to ${convertedTargetPath}`);
|
|
458
|
-
return { filePath: writePath, content: "" };
|
|
468
|
+
return { filePath: writePath, content: "", written: true };
|
|
459
469
|
} else return null;
|
|
460
470
|
}
|
|
461
471
|
async _applyTemplate({
|
|
@@ -472,7 +482,7 @@ export class Executor {
|
|
|
472
482
|
dict?: { [key: string]: string };
|
|
473
483
|
options?: { [key: string]: unknown };
|
|
474
484
|
overwrite?: boolean;
|
|
475
|
-
}): Promise<
|
|
485
|
+
}): Promise<WrittenFileContent[]> {
|
|
476
486
|
const templateRoot = await this.#resolveTemplateRoot();
|
|
477
487
|
const templatePath = `${templateRoot}${template ? `/${template}` : ""}`;
|
|
478
488
|
if ((await stat(templatePath)).isFile()) {
|
|
@@ -486,7 +496,7 @@ export class Executor {
|
|
|
486
496
|
dict,
|
|
487
497
|
options,
|
|
488
498
|
);
|
|
489
|
-
return fileContent ? [fileContent] : ([] as
|
|
499
|
+
return fileContent ? [fileContent] : ([] as WrittenFileContent[]);
|
|
490
500
|
} else {
|
|
491
501
|
const subdirs = await readDirEntries(templatePath);
|
|
492
502
|
const fileContents = (
|
|
@@ -504,7 +514,7 @@ export class Executor {
|
|
|
504
514
|
dict,
|
|
505
515
|
options,
|
|
506
516
|
);
|
|
507
|
-
return fileContent ? [fileContent] : ([] as
|
|
517
|
+
return fileContent ? [fileContent] : ([] as WrittenFileContent[]);
|
|
508
518
|
} else
|
|
509
519
|
return await this._applyTemplate({
|
|
510
520
|
basePath: path.join(basePath, subdir),
|
|
@@ -539,7 +549,7 @@ export class Executor {
|
|
|
539
549
|
overwrite?: boolean;
|
|
540
550
|
}): Promise<FileContent[]> {
|
|
541
551
|
const fileContents = await this._applyTemplate({ ...options, dict: withCapitalizedDict(options.dict) });
|
|
542
|
-
await this.#formatAppliedTemplate(fileContents);
|
|
552
|
+
await this.#formatAppliedTemplate(fileContents.filter((fileContent) => fileContent.written));
|
|
543
553
|
return fileContents;
|
|
544
554
|
}
|
|
545
555
|
|
|
@@ -554,7 +564,7 @@ export class Executor {
|
|
|
554
564
|
const { fixed } = await this.getLinter().fixFiles(filePaths);
|
|
555
565
|
if (fixed.length > 0) this.logger.verbose(`Formatted ${fixed.length} scaffolded file(s)`);
|
|
556
566
|
} catch (err) {
|
|
557
|
-
this.logger.verbose(`Skipped formatting scaffolded files: ${err
|
|
567
|
+
this.logger.verbose(`Skipped formatting scaffolded files: ${toError(err).message}`);
|
|
558
568
|
}
|
|
559
569
|
}
|
|
560
570
|
// Async so `typescript` (~65MB resident) loads only in the commands that typecheck.
|
|
@@ -572,7 +582,7 @@ export class Executor {
|
|
|
572
582
|
}
|
|
573
583
|
async typeCheckAsync(filePath: string) {
|
|
574
584
|
const path = this.getPath(filePath);
|
|
575
|
-
const entry = await
|
|
585
|
+
const entry = await ProjectTypecheck.workerEntry(process.cwd());
|
|
576
586
|
const proc = Bun.spawn([process.execPath, entry], {
|
|
577
587
|
cwd: this.cwdPath,
|
|
578
588
|
env: {
|
|
@@ -603,18 +613,6 @@ export class Executor {
|
|
|
603
613
|
message: result.message,
|
|
604
614
|
};
|
|
605
615
|
}
|
|
606
|
-
async #resolveTypecheckWorkerEntry() {
|
|
607
|
-
const dirname = getDirname(import.meta.url);
|
|
608
|
-
const candidates = [
|
|
609
|
-
path.join(process.cwd(), "pkgs/@akanjs/devkit/typecheck/typecheck.proc.ts"),
|
|
610
|
-
path.join(process.cwd(), "node_modules/@akanjs/devkit/typecheck/typecheck.proc.ts"),
|
|
611
|
-
path.join(dirname, "typecheck/typecheck.proc.ts"),
|
|
612
|
-
path.join(dirname, "typecheck.proc.js"),
|
|
613
|
-
path.join(dirname, "typecheck.proc.ts"),
|
|
614
|
-
];
|
|
615
|
-
for (const candidate of candidates) if (await Bun.file(candidate).exists()) return candidate;
|
|
616
|
-
throw new Error(`[devkit] typecheck worker entry not found; looked in: ${candidates.join(", ")}`);
|
|
617
|
-
}
|
|
618
616
|
getLinter() {
|
|
619
617
|
this.linter ??= new Linter(this.cwdPath);
|
|
620
618
|
return this.linter;
|
|
@@ -662,9 +660,14 @@ export class WorkspaceExecutor extends Executor {
|
|
|
662
660
|
} = {}) {
|
|
663
661
|
return new WorkspaceExecutor({ workspaceRoot, repoName });
|
|
664
662
|
}
|
|
663
|
+
/** The public env a native build was told to reproduce (`--public-env`): it outranks `.env`, which outranks the rest. */
|
|
664
|
+
static publicEnvPin: Record<string, string> | null = null;
|
|
665
665
|
static getBaseDevEnv(envPath?: string) {
|
|
666
666
|
// Bun auto-loads .env, so we use process.env directly
|
|
667
|
-
const sourceEnv =
|
|
667
|
+
const sourceEnv = {
|
|
668
|
+
...(envPath ? { ...process.env, ...parseEnvFile(envPath) } : process.env),
|
|
669
|
+
...WorkspaceExecutor.publicEnvPin,
|
|
670
|
+
};
|
|
668
671
|
|
|
669
672
|
const appName = sourceEnv.AKAN_PUBLIC_APP_NAME;
|
|
670
673
|
const workspaceRoot = sourceEnv.AKAN_WORKSPACE_ROOT;
|
|
@@ -61,8 +61,9 @@ export class CsrArtifactBuilder {
|
|
|
61
61
|
).flat(),
|
|
62
62
|
);
|
|
63
63
|
|
|
64
|
+
const options = CsrArtifactBuilder.bundleOptions(this.#app, this.#command, akanConfig.optimizeImports);
|
|
64
65
|
const result = await Bun.build({
|
|
65
|
-
...
|
|
66
|
+
...options,
|
|
66
67
|
entrypoints: htmlBasePaths.map((basePath) => this.#generatedPath(CsrArtifactBuilder.htmlFilename(basePath))),
|
|
67
68
|
files: generatedFiles,
|
|
68
69
|
root: this.#generatedDir,
|
|
@@ -75,6 +76,7 @@ export class CsrArtifactBuilder {
|
|
|
75
76
|
}
|
|
76
77
|
|
|
77
78
|
await this.#inlineCsrArtifacts(cssAssets);
|
|
79
|
+
if (this.#command === "build") await CsrArtifactBuilder.writePublicEnv(this.#app, options.define);
|
|
78
80
|
this.#app.verbose(`[csr-build] output -> ${this.#outputDir}`);
|
|
79
81
|
return { outputDir: this.#outputDir };
|
|
80
82
|
}
|
|
@@ -92,6 +94,21 @@ export class CsrArtifactBuilder {
|
|
|
92
94
|
};
|
|
93
95
|
}
|
|
94
96
|
|
|
97
|
+
/** Where a build records the `AKAN_PUBLIC_*` it inlined, which nothing in the minified bundle names. */
|
|
98
|
+
static publicEnvPath(app: App) {
|
|
99
|
+
return path.join(app.dist.cwdPath, ".akan", "artifact", "csr-public-env.json");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
static async writePublicEnv(app: App, define: Record<string, string>) {
|
|
103
|
+
const prefix = "process.env.";
|
|
104
|
+
const publicEnv = Object.fromEntries(
|
|
105
|
+
Object.entries(define)
|
|
106
|
+
.filter(([key]) => key.startsWith(`${prefix}AKAN_PUBLIC_`))
|
|
107
|
+
.map(([key, value]) => [key.slice(prefix.length), JSON.parse(value) as unknown]),
|
|
108
|
+
);
|
|
109
|
+
await Bun.write(CsrArtifactBuilder.publicEnvPath(app), `${JSON.stringify(publicEnv, null, 2)}\n`);
|
|
110
|
+
}
|
|
111
|
+
|
|
95
112
|
/** The routes one basePath's HTML boots: its own plus every route outside any basePath, matching `bootCsr`. */
|
|
96
113
|
static pageEntriesForBasePath(pageEntries: PageEntry[], basePath: string, basePaths: string[]): PageEntry[] {
|
|
97
114
|
return CsrEntryFiles.pageEntriesForBasePath(pageEntries, basePath, basePaths);
|