@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
@@ -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
- `${label} argument '${String(value)}' is invalid. Allowed choices are ${choices.map((choice) => choice.name).join(", ")}.`,
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
- } else if (type === "boolean") {
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) => ({ key: "", idx: this.#args.length + offset, type: token.type })),
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 { AppExecutor, WorkspaceExecutor } from "../executors";
3
- import { App, type ArgMeta, type ArgsOption, getArgMetas, Lib, Module, Pkg, Sys, Workspace } from "./argMeta";
4
- import { getArgumentValue, getInternalArgumentValue, getOptionValue } from "./command";
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
+ }
@@ -2,5 +2,6 @@ export * from "./argMeta";
2
2
  export * from "./command";
3
3
  export * from "./commandBuilder";
4
4
  export * from "./dependencyBuilder";
5
+ export * from "./exitCodeError";
5
6
  export * from "./helpFormatter";
6
7
  export * from "./targetMeta";
package/executors.test.ts CHANGED
@@ -99,6 +99,29 @@ describe("Executor filesystem helpers", () => {
99
99
  expect(entries.dirs).toContain("nested");
100
100
  });
101
101
 
102
+ test("lists a folder in name order, whatever order the filesystem keeps it in", async () => {
103
+ const root = await makeTempRoot();
104
+ const exec = new Executor("fixture", root);
105
+ for (const name of ["summary", "file", "banner", "_noti", "admin"]) await exec.mkdir(`lib/${name}`);
106
+ for (const name of ["st.ts", "cnst.ts", "dict.ts"]) await exec.writeFile(`lib/${name}`, "");
107
+
108
+ expect(await exec.readdir("lib")).toEqual([
109
+ "_noti",
110
+ "admin",
111
+ "banner",
112
+ "cnst.ts",
113
+ "dict.ts",
114
+ "file",
115
+ "st.ts",
116
+ "summary",
117
+ ]);
118
+ expect(await exec.getFilesAndDirs("lib")).toEqual({
119
+ files: ["cnst.ts", "dict.ts", "st.ts"],
120
+ dirs: ["_noti", "admin", "banner", "file", "summary"],
121
+ });
122
+ expect(await exec.getAllFiles("**/*", { cwd: exec.getPath("lib") })).toEqual(["cnst.ts", "dict.ts", "st.ts"]);
123
+ });
124
+
102
125
  test("a refreshed tsconfig re-reads the config it extends", async () => {
103
126
  const root = await makeTempRoot();
104
127
  const appDir = path.join(root, "apps/demo");
@@ -188,6 +211,35 @@ describe("Executor filesystem helpers", () => {
188
211
  expect(formatted).toEqual([path.join(root, "apps/demo/page/task/_index.tsx")]);
189
212
  });
190
213
 
214
+ test("hands the formatter only the files a template wrote, never one overwrite: false kept", async () => {
215
+ const { root, app } = trackRoot(await createTempApp("demo"));
216
+ const formatted: string[] = [];
217
+ app.getLinter = () =>
218
+ ({
219
+ fixFiles: async (filePaths: string[]) => {
220
+ formatted.push(...filePaths);
221
+ return { fixed: [] };
222
+ },
223
+ }) as unknown as ReturnType<Executor["getLinter"]>;
224
+ const scalarDir = path.join(root, "apps/demo/lib/__scalar/money");
225
+ const own = "export class Money {}\n";
226
+ await writeText(path.join(scalarDir, "money.constant.ts"), own);
227
+
228
+ const applied = await app.applyTemplate({
229
+ basePath: "./lib/__scalar",
230
+ template: "__scalar",
231
+ dict: { model: "money", models: "moneys", sysName: "demo" },
232
+ overwrite: false,
233
+ });
234
+
235
+ expect(applied.map((fileContent) => fileContent.filePath)).toContain(path.join(scalarDir, "money.constant.ts"));
236
+ expect(formatted.sort((a, b) => a.localeCompare(b))).toEqual([
237
+ path.join(scalarDir, "money.dictionary.ts"),
238
+ path.join(scalarDir, "money.document.ts"),
239
+ ]);
240
+ expect(await readFile(path.join(scalarDir, "money.constant.ts"), "utf8")).toBe(own);
241
+ });
242
+
191
243
  test("a formatter that cannot run does not fail the scaffold", async () => {
192
244
  // create-akan-workspace scaffolds before `bun install`, so Biome may be absent; a lint fix beats a failed scaffold.
193
245
  const root = await makeTempRoot();
@@ -263,6 +315,23 @@ describe("Executor filesystem helpers", () => {
263
315
  await readFile(path.join(templateRoot, "favicon.ico")),
264
316
  );
265
317
  });
318
+
319
+ test("keeps a static file already there when overwrite is false, and reports it unwritten", async () => {
320
+ const root = await makeTempRoot();
321
+ const exec = new Executor("fixture", root);
322
+ await writeText(path.join(root, "app/public/logo.png"), "the app's own logo");
323
+
324
+ const applied = await exec.applyTemplate({
325
+ basePath: "app/public",
326
+ template: "app/public/logo.png",
327
+ overwrite: false,
328
+ });
329
+
330
+ expect(await readFile(path.join(root, "app/public/logo.png"), "utf8")).toBe("the app's own logo");
331
+ expect(applied).toEqual([
332
+ expect.objectContaining({ filePath: path.join(root, "app/public/logo.png"), written: false }),
333
+ ]);
334
+ });
266
335
  });
267
336
 
268
337
  describe("Workspace and app executor environment contracts", () => {