@akanjs/devkit 3.1.0-rc.20 → 3.1.0-rc.21

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/biome.base.json CHANGED
@@ -128,7 +128,8 @@
128
128
  "!**/*.constant.ts",
129
129
  "!**/common/**",
130
130
  "!**/env/**",
131
- "!{apps,libs}/*/native/**"
131
+ "!{apps,libs}/*/native/**",
132
+ "!apps/*/script/migration/**"
132
133
  ],
133
134
  "plugins": ["./node_modules/@akanjs/devkit/lint/no-throw-raw-error.grit"]
134
135
  },
@@ -41,6 +41,7 @@ export interface ArgsOption<Context = CommandContext> {
41
41
  example?: string | number | boolean;
42
42
  enum?: EnumChoices | DynamicEnum<Context>;
43
43
  ask?: string;
44
+ multiple?: boolean;
44
45
  }
45
46
  export interface ArgMeta<Context = CommandContext> {
46
47
  name: string;
@@ -25,11 +25,15 @@ import { camelToKebabCase } from "./camelToKebabCase";
25
25
  import { CommandContainer } from "./dependencyBuilder";
26
26
  import { ExitCodeError } from "./exitCodeError";
27
27
  import { formatCommandHelp, formatHelp } from "./helpFormatter";
28
- import { type CommandCls, getTargetCommandNames, getTargetMetas } from "./targetMeta";
28
+ import { type CommandCls, getTargetCommandNames, getTargetMetas, type TargetFailure } from "./targetMeta";
29
29
 
30
30
  const loggedCliErrorObjects = new WeakSet<object>();
31
31
  const loggedCliErrorMessages = new Set<string>();
32
32
 
33
+ export const markCliErrorPrinted = (error: unknown) => {
34
+ if (typeof error === "object" && error !== null) loggedCliErrorObjects.add(error);
35
+ };
36
+
33
37
  const formatCliError = (error: unknown): string => {
34
38
  if (error instanceof Error) return error.message || error.name;
35
39
  if (typeof error === "string") return error.trim() || "Unknown error";
@@ -65,10 +69,11 @@ const handleOption = (programCommand: Command, argMeta: ArgMeta) => {
65
69
  } = argMeta.argsOption;
66
70
  const kebabName = camelToKebabCase(argMeta.name);
67
71
  const choices = enumChoices && typeof enumChoices !== "function" ? normalizeEnumChoices(enumChoices) : null;
68
- programCommand.option(
69
- `-${flag}, --${kebabName}${type === "boolean" ? " [boolean]" : ` <${kebabName}>`}`,
70
- `${desc}${ask ? ` (${ask})` : ""}${example ? ` (example: ${example})` : ""}${choices ? ` (choices: ${choices.map((choice) => choice.name).join(", ")})` : ""}`,
71
- );
72
+ const flags = `-${flag}, --${kebabName}${type === "boolean" ? " [boolean]" : ` <${kebabName}>`}`;
73
+ const description = `${desc}${ask ? ` (${ask})` : ""}${example ? ` (example: ${example})` : ""}${choices ? ` (choices: ${choices.map((choice) => choice.name).join(", ")})` : ""}`;
74
+ if (argMeta.argsOption.multiple)
75
+ programCommand.option(flags, description, (value: string, previous: string[] = []) => [...previous, value]);
76
+ else programCommand.option(flags, description);
72
77
  if (type === "boolean" && argMeta.argsOption.default === true)
73
78
  programCommand.option(`--no-${kebabName}`, `turn off --${kebabName}`);
74
79
  return programCommand;
@@ -88,6 +93,19 @@ const convertArgValue = (value: string | boolean, type: "string" | "number" | "b
88
93
  else return value === true || value === "true";
89
94
  };
90
95
 
96
+ const givenOptionsOf = (argMetas: (ArgMeta | InternalArgMeta)[], opt: Record<string, unknown>) => {
97
+ const given: TargetFailure["given"] = {};
98
+ for (const argMeta of argMetas) {
99
+ if (argMeta.type !== "Option" || opt[argMeta.name] === undefined) continue;
100
+ const type = argMeta.argsOption.type ?? "string";
101
+ const value = opt[argMeta.name] as string | boolean | string[];
102
+ given[argMeta.name] = Array.isArray(value)
103
+ ? value.map((each) => convertArgValue(each, type))
104
+ : convertArgValue(value, type);
105
+ }
106
+ return given;
107
+ };
108
+
91
109
  const normalizeEnumChoices = (enumChoices: EnumChoices) =>
92
110
  enumChoices.map((choice: EnumChoice) =>
93
111
  typeof choice === "object"
@@ -135,8 +153,13 @@ const inputMessageOf = ({ name, argsOption: { desc, example, ask } }: ArgMeta) =
135
153
  export const getOptionValue = async (argMeta: ArgMeta, opt: Record<string, unknown>, context: CommandContext) => {
136
154
  const {
137
155
  name,
138
- argsOption: { enum: enumChoices, default: defaultValue, type, desc, nullable, ask },
156
+ argsOption: { enum: enumChoices, default: defaultValue, type, desc, nullable, ask, multiple },
139
157
  } = argMeta;
158
+ if (multiple) {
159
+ const values = (opt[argMeta.name] as string[] | undefined) ?? [];
160
+ for (const value of values) assertEnumChoice(argMeta, value);
161
+ return values.map((value) => convertArgValue(value, type ?? "string"));
162
+ }
140
163
  if (opt[argMeta.name] !== undefined) {
141
164
  assertEnumChoice(argMeta, opt[argMeta.name]);
142
165
  return convertArgValue(opt[argMeta.name] as string, type ?? "string");
@@ -324,6 +347,7 @@ export const getInternalArgumentValue = async (
324
347
 
325
348
  export const runCommands = async (...commands: CommandCls[]) => {
326
349
  let failureExitCode = 1;
350
+ let reportFailure = async (_error: unknown) => {};
327
351
  process.on("unhandledRejection", (error) => {
328
352
  printCliError(error);
329
353
  process.exit(ExitCodeError.codeOf(error) ?? failureExitCode);
@@ -372,10 +396,20 @@ It may cause unexpected behavior. Run \`akan update\` to update latest akanjs.`,
372
396
  hidden: targetMeta.targetOption.devOnly,
373
397
  });
374
398
  const targetFailureExitCode = targetMeta.targetOption.failureExitCode;
375
- //? commander exits 1 on a flag or argument it refuses, before any action runs.
376
- if (targetFailureExitCode !== undefined)
377
- programCommand.exitOverride((error) => process.exit(error.exitCode === 0 ? 0 : targetFailureExitCode));
399
+ const usageExitCode = targetMeta.targetOption.usageExitCode ?? targetFailureExitCode;
400
+ const { onFailure } = targetMeta.targetOption;
378
401
  const [allArgMetas] = getArgMetas(command, targetMeta.key);
402
+ //? commander exits 1 on a flag or argument it refuses, before any action runs, having printed why.
403
+ //? Thrown, not exited: the failure path below awaits onFailure, and an exit drops what a pipe still buffers.
404
+ if (usageExitCode !== undefined || onFailure)
405
+ programCommand.exitOverride((error) => {
406
+ if (error.exitCode === 0) process.exit(0);
407
+ const refused = new ExitCodeError(error.message.replace(/^error: /, ""), usageExitCode ?? error.exitCode);
408
+ markCliErrorPrinted(refused);
409
+ const given = givenOptionsOf(allArgMetas, programCommand.opts());
410
+ reportFailure = async (failure) => await onFailure?.(failure, { usage: true, given });
411
+ throw refused;
412
+ });
379
413
  for (const argMeta of allArgMetas) {
380
414
  if (argMeta.type === "Option") programCommand = handleOption(programCommand, argMeta);
381
415
  else if (argMeta.type === "Argument") programCommand = handleArgument(programCommand, argMeta);
@@ -408,6 +442,8 @@ It may cause unexpected behavior. Run \`akan update\` to update latest akanjs.`,
408
442
  if (!targetMeta.targetOption.stdio) Logger.rawLog();
409
443
  const cmdArgs = args.slice(0, args.length - 2);
410
444
  const opt = args[args.length - 2] as Record<string, unknown>;
445
+ reportFailure = async (failure) =>
446
+ await onFailure?.(failure, { usage: false, given: givenOptionsOf(allArgMetas, opt) });
411
447
  const commandArgs = [] as unknown[];
412
448
  if (targetMeta.targetOption.runsOnWorkspaceRoot) await assertCurrentDirectoryIsWorkspaceRoot();
413
449
  const workspace = WorkspaceExecutor.fromRoot();
@@ -452,8 +488,9 @@ It may cause unexpected behavior. Run \`akan update\` to update latest akanjs.`,
452
488
  }
453
489
  }
454
490
  // Handled here, not by the handler above: a rejection reaching the entry's top-level await prints Bun's own trace.
455
- await program.parseAsync(process.argv).catch((error: unknown) => {
491
+ await program.parseAsync(process.argv).catch(async (error: unknown) => {
456
492
  printCliError(error);
493
+ await reportFailure(error).catch(printCliError);
457
494
  process.exit(ExitCodeError.codeOf(error) ?? failureExitCode);
458
495
  });
459
496
  };
@@ -42,7 +42,7 @@ type ArgValue<Type extends PrimitiveArgType, Option> = [EnumValue<Option>] exten
42
42
  : EnumValue<Option>;
43
43
  type AddArg<Params extends unknown[], Type extends PrimitiveArgType, Option> = [
44
44
  ...Params,
45
- MaybeNullable<ArgValue<Type, Option>, Option>,
45
+ Option extends { multiple: true } ? ArgValue<Type, Option>[] : MaybeNullable<ArgValue<Type, Option>, Option>,
46
46
  ];
47
47
  type AddInternalArg<Params extends unknown[], Token extends InternalArgToken> = [...Params, Token["_value"]];
48
48
  type AddInternalArgs<Params extends unknown[], Tokens extends readonly InternalArgToken[]> = Tokens extends readonly [
@@ -79,9 +79,12 @@ const argMetaOf = (
79
79
  type: ArgType,
80
80
  name: string,
81
81
  primitive: PrimitiveArgType,
82
- argsOption: object | undefined,
82
+ argsOption: Pick<ArgsOption, "multiple"> | undefined,
83
83
  idx: number,
84
- ): ArgMeta => ({ name, argsOption: { ...argsOption, type: normalizePrimitiveArgType(primitive) }, key: "", idx, type });
84
+ ): ArgMeta => {
85
+ if (type === "Argument" && argsOption?.multiple) throw new Error(`argument '${name}' cannot be multiple`);
86
+ return { name, argsOption: { ...argsOption, type: normalizePrimitiveArgType(primitive) }, key: "", idx, type };
87
+ };
85
88
 
86
89
  class TargetBuilder<Deps extends readonly DependencyCls[], Params extends unknown[] = [], Context = object> {
87
90
  readonly #args: (ArgMeta | InternalArgMeta)[];
@@ -503,7 +503,7 @@ describe("a Sys that defaults to the only app", () => {
503
503
 
504
504
  describe("the exit code of a command that failed", () => {
505
505
  const track = tempRoots();
506
- const run = async (failure: string) => {
506
+ const run = async (failure: string, { option = "", args = [] as string[] } = {}) => {
507
507
  const { root } = track(await makeCliTempWorkspace());
508
508
  await writeText(path.join(root, "package.json"), '{ "name": "repo", "version": "1.0.0" }\n');
509
509
  await writeText(path.join(root, "tsconfig.json"), "{}\n");
@@ -511,11 +511,11 @@ describe("the exit code of a command that failed", () => {
511
511
  await writeText(
512
512
  entry,
513
513
  [
514
- `import { command, ExitCodeError, runCommands, runner, script } from ${JSON.stringify(path.join(import.meta.dir, "index.ts"))};`,
514
+ `import { command, ExitCodeError, markCliErrorPrinted, runCommands, runner, script } from ${JSON.stringify(path.join(import.meta.dir, "index.ts"))};`,
515
515
  'class VerdictRunner extends runner("verdict") {}',
516
516
  'class VerdictScript extends script("verdict", [VerdictRunner]) {}',
517
517
  'class VerdictCommand extends command("verdict", [VerdictScript], ({ public: target }) => ({',
518
- ' verdict: target({ desc: "fails" }).exec(async function () {',
518
+ ` verdict: target({ desc: "fails"${option} }).exec(async function () {`,
519
519
  ` throw ${failure};`,
520
520
  " }),",
521
521
  "})) {}",
@@ -523,9 +523,13 @@ describe("the exit code of a command that failed", () => {
523
523
  "",
524
524
  ].join("\n"),
525
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 };
526
+ const proc = Bun.spawn(["bun", entry, "verdict", ...args], { cwd: root, stdout: "pipe", stderr: "pipe" });
527
+ const [stdout, stderr, code] = await Promise.all([
528
+ new Response(proc.stdout).text(),
529
+ new Response(proc.stderr).text(),
530
+ proc.exited,
531
+ ]);
532
+ return { stdout, stderr, code };
529
533
  };
530
534
 
531
535
  test("is the one an ExitCodeError carries, and 1 for any other error", async () => {
@@ -534,4 +538,124 @@ describe("the exit code of a command that failed", () => {
534
538
  expect(verdict.code).toBe(2);
535
539
  expect((await run('new Error("broke")')).code).toBe(1);
536
540
  }, 30_000);
541
+
542
+ test("is usageExitCode for a flag commander refuses before the command runs, failureExitCode after", async () => {
543
+ const option = ", failureExitCode: 4, usageExitCode: 2";
544
+ expect((await run('new Error("broke")', { option, args: ["--nope"] })).code).toBe(2);
545
+ expect((await run('new Error("broke")', { option })).code).toBe(4);
546
+ }, 30_000);
547
+
548
+ test("runs onFailure last, told whether commander refused the line and what was given", async () => {
549
+ const report = [
550
+ ", stdio: true, usageExitCode: 2, onFailure: async (error, failure) => {",
551
+ ' const line = JSON.stringify({ message: error.message, ...failure }) + "\\n";',
552
+ " await new Promise((resolve) => process.stdout.write(line, resolve));",
553
+ "}",
554
+ ].join("\n");
555
+ const option = `${report} }).option("json", Boolean, { default: false }).option("count", Number, { nullable: true`;
556
+ const lastOf = (stdout: string) => JSON.parse(stdout.trim().split("\n").at(-1) ?? "null");
557
+
558
+ const refused = await run('new Error("broke")', { option, args: ["--count", "3", "--nope", "--json"] });
559
+ expect(refused.code).toBe(2);
560
+ expect(refused.stderr).toContain("unknown option '--nope'");
561
+ expect(lastOf(refused.stdout)).toEqual({
562
+ message: "unknown option '--nope'",
563
+ usage: true,
564
+ given: { count: 3, json: true },
565
+ });
566
+
567
+ const thrown = await run('new Error("broke")', { option, args: ["--json"] });
568
+ expect(thrown.code).toBe(1);
569
+ expect(thrown.stdout).toContain("broke\n");
570
+ expect(lastOf(thrown.stdout)).toEqual({ message: "broke", usage: false, given: { json: true } });
571
+
572
+ const help = await run('new Error("broke")', { option, args: ["--help"] });
573
+ expect(help.code).toBe(0);
574
+ expect(help.stdout).not.toContain('"usage"');
575
+ }, 60_000);
576
+
577
+ test("prints nothing more for an error the command marked as printed", async () => {
578
+ const marked = await run(
579
+ '(() => { const error = new Error("printed already"); markCliErrorPrinted(error); return error; })()',
580
+ );
581
+ expect(marked.code).toBe(1);
582
+ expect(`${marked.stdout}${marked.stderr}`).not.toContain("printed already");
583
+ }, 30_000);
584
+ });
585
+
586
+ describe("a repeatable option", () => {
587
+ const track = tempRoots();
588
+ const meta: ArgMeta = {
589
+ name: "param",
590
+ argsOption: { type: "string", multiple: true },
591
+ key: "run",
592
+ idx: 0,
593
+ type: "Option",
594
+ };
595
+ afterEach(() => {
596
+ CommandContainer.clear();
597
+ });
598
+
599
+ test("reads every value given, in order, each against the choices, and an empty list when left out", async () => {
600
+ expect(await getOptionValue(meta, { param: ["a=1", "b=2"] }, { values: {} })).toEqual(["a=1", "b=2"]);
601
+ expect(await getOptionValue(meta, {}, { values: {} })).toEqual([]);
602
+ const choices = { ...meta, argsOption: { ...meta.argsOption, enum: ["a", "b"] } };
603
+ await expect(getOptionValue(choices, { param: ["a", "c"] }, { values: {} })).rejects.toThrow(
604
+ "option '--param' argument 'c' is invalid",
605
+ );
606
+ });
607
+
608
+ test("types the exec parameter as a list, and a positional argument cannot repeat", () => {
609
+ const expectType = <Expected>(_value: Expected) => undefined;
610
+ class RepeatRunner extends runner("repeat") {}
611
+ class RepeatScript extends script("repeat", [RepeatRunner]) {}
612
+ class RepeatCommand extends command("repeat", [RepeatScript], ({ public: target }) => ({
613
+ run: target({ desc: "run" })
614
+ .option("param", String, { multiple: true })
615
+ .option("mode", String, { enum: ["a", "b"], multiple: true })
616
+ .exec(async (param, mode) => {
617
+ expectType<string[]>(param);
618
+ expectType<("a" | "b")[]>(mode);
619
+ }),
620
+ })) {}
621
+ expect(getTargetMetas(RepeatCommand)[0]?.key).toBe("run");
622
+ expect(() =>
623
+ command("repeatArg", [RepeatScript], ({ public: target }) => ({
624
+ run: target({ desc: "run" })
625
+ .arg("names", String, { multiple: true })
626
+ .exec(async () => undefined),
627
+ })),
628
+ ).toThrow("argument 'names' cannot be multiple");
629
+ });
630
+
631
+ test("collects each time the flag is given, long or short", async () => {
632
+ const { root } = track(await makeCliTempWorkspace());
633
+ await writeText(path.join(root, "package.json"), '{ "name": "repo", "version": "1.0.0" }\n');
634
+ await writeText(path.join(root, "tsconfig.json"), "{}\n");
635
+ const entry = path.join(root, "cli.ts");
636
+ await writeText(
637
+ entry,
638
+ [
639
+ `import { command, runCommands, runner, script } from ${JSON.stringify(path.join(import.meta.dir, "index.ts"))};`,
640
+ 'class CollectRunner extends runner("collect") {}',
641
+ 'class CollectScript extends script("collect", [CollectRunner]) {}',
642
+ 'class CollectCommand extends command("collect", [CollectScript], ({ public: target }) => ({',
643
+ ' collect: target({ desc: "collects", stdio: true })',
644
+ ' .option("param", String, { multiple: true })',
645
+ " .exec(async function (param) {",
646
+ " process.stdout.write(JSON.stringify(param));",
647
+ " }),",
648
+ "})) {}",
649
+ "await runCommands(CollectCommand);",
650
+ "",
651
+ ].join("\n"),
652
+ );
653
+ const collect = async (...args: string[]) => {
654
+ const proc = Bun.spawn(["bun", entry, "collect", ...args], { cwd: root, stdout: "pipe", stderr: "pipe" });
655
+ const [stdout] = await Promise.all([new Response(proc.stdout).text(), proc.exited]);
656
+ return JSON.parse(stdout) as unknown;
657
+ };
658
+ expect(await collect("--param", "a=1", "-p", "b=x=y")).toEqual(["a=1", "b=x=y"]);
659
+ expect(await collect()).toEqual([]);
660
+ }, 30_000);
537
661
  });
@@ -143,12 +143,13 @@ export const formatCommandHelp = (command: CommandCls, key: string) => {
143
143
  const optName = `${flag}--${kebabName}${negation}`;
144
144
  const optDesc = opt.desc ?? "";
145
145
  const defaultVal = opt.default !== undefined ? chalk.gray(` [default: ${String(opt.default)}]`) : "";
146
+ const repeatable = opt.multiple ? chalk.gray(" (repeatable)") : "";
146
147
  const choices = opt.enum
147
148
  ? chalk.gray(
148
149
  typeof opt.enum === "function" ? " ([dynamic choices])" : ` (${opt.enum.map(formatChoice).join(", ")})`,
149
150
  )
150
151
  : "";
151
- lines.push(` ${chalk.green(optName)} ${chalk.gray(optDesc)}${defaultVal}${choices}`);
152
+ lines.push(` ${chalk.green(optName)} ${chalk.gray(optDesc)}${defaultVal}${choices}${repeatable}`);
152
153
  }
153
154
  lines.push("");
154
155
  }
@@ -44,6 +44,16 @@ export interface TargetOption {
44
44
  runsOnWorkspaceRoot?: boolean;
45
45
  stdio?: boolean;
46
46
  failureExitCode?: number;
47
+ usageExitCode?: number;
48
+ /** Runs after the CLI printed a failure and before it exits, so what it writes ends the output. */
49
+ onFailure?: (error: unknown, failure: TargetFailure) => void | Promise<void>;
50
+ }
51
+
52
+ export interface TargetFailure {
53
+ /** commander refused a flag or argument before the command ran, and printed why. */
54
+ usage: boolean;
55
+ /** The options given on the command line as the command would receive them, without defaults or prompts. */
56
+ given: { [name: string]: unknown };
47
57
  }
48
58
 
49
59
  export const Commands = () => {
package/executors.test.ts CHANGED
@@ -1151,6 +1151,29 @@ describe("scan info construction", () => {
1151
1151
  expect(info.file.dictionary.services.has("auth")).toBe(true);
1152
1152
  expect(info.file.document.scalars.has("money")).toBe(true);
1153
1153
  });
1154
+
1155
+ test("lists script/migration by id with a checksum that ignores line endings and a BOM, and skips tests", async () => {
1156
+ const { root, app } = await createTempApp("migrating");
1157
+ expect(await AppInfo.scanMigrations(app)).toEqual([]);
1158
+ const dir = path.join(root, "apps", "migrating", "script", "migration");
1159
+ await writeText(path.join(dir, "202610211000_ownership.ts"), "export default 1;\r\nexport const a = 2;\r\n");
1160
+ await writeText(path.join(dir, "202609010900_backfill.ts"), "export default 1;\n");
1161
+ await writeText(path.join(dir, "202609010900_backfill.test.ts"), "test;\n");
1162
+ await writeText(path.join(dir, "202609010900_backfill.d.ts"), "export {};\n");
1163
+ await writeText(path.join(dir, "README.md"), "# notes\n");
1164
+ await writeText(path.join(root, "apps", "migrating", "script", "seed.ts"), "export {};\n");
1165
+
1166
+ const scanned = await AppInfo.scanMigrations(app);
1167
+ expect(scanned.map(({ id }) => id)).toEqual(["202609010900_backfill", "202610211000_ownership"]);
1168
+ await writeText(path.join(dir, "202610211000_ownership.ts"), "export default 1;\nexport const a = 2;\n");
1169
+ expect(await AppInfo.scanMigrations(app)).toEqual(scanned);
1170
+ await writeText(path.join(dir, "202610211000_ownership.ts"), "\uFEFFexport default 1;\nexport const a = 2;\n");
1171
+ expect(await AppInfo.scanMigrations(app)).toEqual(scanned);
1172
+ expect(scanned[0]?.checksum).toMatch(/^[0-9a-f]{64}$/);
1173
+
1174
+ await writeText(path.join(dir, "fixOwners.ts"), "export default 1;\n");
1175
+ await expect(AppInfo.scanMigrations(app)).rejects.toThrow("akan create-migration");
1176
+ });
1154
1177
  });
1155
1178
 
1156
1179
  describe("WorkspaceExecutor listing", () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akanjs/devkit",
3
- "version": "3.1.0-rc.20",
3
+ "version": "3.1.0-rc.21",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -48,7 +48,7 @@
48
48
  "@earendil-works/pi-tui": "1.3.0",
49
49
  "@inquirer/prompts": "^8.7.3",
50
50
  "@tailwindcss/node": "^4.3.3",
51
- "akanjs": "3.1.0-rc.20",
51
+ "akanjs": "3.1.0-rc.21",
52
52
  "chalk": "^6.0.1",
53
53
  "commander": "^15.0.0",
54
54
  "dayjs": "^1.11.23",
package/scanInfo.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { createHash } from "node:crypto";
1
2
  import path from "node:path";
2
3
  import type {
3
4
  AppConfigResult,
@@ -295,11 +296,19 @@ class ScanInfo {
295
296
  }
296
297
 
297
298
  export const isAkanFrameworkDependency = (dep: string) => dep === "akanjs" || dep.startsWith("akanjs/");
299
+ export interface MigrationScanEntry {
300
+ id: string;
301
+ checksum: string;
302
+ }
303
+
298
304
  export class AppInfo extends ScanInfo {
305
+ static readonly migrationDir = "script/migration";
306
+ static readonly #migrationIdRe = /^\d{12}_[A-Za-z][A-Za-z0-9]*$/;
299
307
  readonly type = "app";
300
308
  readonly exec: AppExecutor;
301
309
  readonly akanConfig: AppConfigResult;
302
310
  readonly libDeps: string[];
311
+ readonly migrations: MigrationScanEntry[];
303
312
 
304
313
  static appInfos = new Map<string, AppInfo>();
305
314
  static async fromExecutor(exec: AppExecutor, options: { refresh?: boolean } = {}) {
@@ -314,17 +323,21 @@ export class AppInfo extends ScanInfo {
314
323
  LibInfo.libInfos.set(libName, await LibInfo.fromExecutor(libExecutor));
315
324
  }),
316
325
  );
317
- const libDeps = await AppInfo.#getAllLibDeps(exec, scanResult.libDeps);
318
- const appInfo = new AppInfo(exec, scanResult as AppScanResult, libDeps);
326
+ const [libDeps, migrations] = await Promise.all([
327
+ AppInfo.#getAllLibDeps(exec, scanResult.libDeps),
328
+ AppInfo.scanMigrations(exec),
329
+ ]);
330
+ const appInfo = new AppInfo(exec, scanResult as AppScanResult, libDeps, migrations);
319
331
  AppInfo.appInfos.set(exec.name, appInfo);
320
332
  return appInfo;
321
333
  }
322
334
 
323
- constructor(exec: AppExecutor, scanResult: AppScanResult, libDeps: string[]) {
335
+ constructor(exec: AppExecutor, scanResult: AppScanResult, libDeps: string[], migrations: MigrationScanEntry[] = []) {
324
336
  super(scanResult);
325
337
  this.exec = exec;
326
338
  this.akanConfig = scanResult.akanConfig;
327
339
  this.libDeps = libDeps;
340
+ this.migrations = migrations;
328
341
  }
329
342
  override getScanResult(): AppScanResult {
330
343
  return this.scanResult as AppScanResult;
@@ -334,6 +347,26 @@ export class AppInfo extends ScanInfo {
334
347
  (this.scanResult as AppScanResult).routes = routes;
335
348
  }
336
349
 
350
+ //* Line endings and a BOM dropped: an editor or autocrlf would otherwise record another checksum for the same script.
351
+ static async scanMigrations(exec: AppExecutor) {
352
+ const files = (await exec.readdir(AppInfo.migrationDir)).filter(
353
+ (file) => file.endsWith(".ts") && !/\.(test|spec|d)\.ts$/.test(file),
354
+ );
355
+ return await Promise.all(
356
+ files.map(async (file) => {
357
+ const id = file.slice(0, -".ts".length);
358
+ if (!AppInfo.#migrationIdRe.test(id))
359
+ throw new Error(
360
+ `${AppInfo.migrationDir}/${file}: a migration is named <yyyyMMddHHmm>_<camelName>.ts; create one with \`akan create-migration\``,
361
+ );
362
+ const source = (await exec.readFile(`${AppInfo.migrationDir}/${file}`))
363
+ .replace(/^\uFEFF/, "")
364
+ .replaceAll("\r\n", "\n");
365
+ return { id, checksum: createHash("sha256").update(source).digest("hex") };
366
+ }),
367
+ );
368
+ }
369
+
337
370
  static async #getAllLibDeps(exec: AppExecutor, libDeps: string[], libSet = new Set<string>()) {
338
371
  await Promise.all(
339
372
  libDeps.map(async (libName) => {