@akanjs/devkit 3.1.0-rc.2 → 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/abstractDoc.ts +1 -1
- package/agentsIndex.test.ts +4 -1
- package/akanConfig/akanConfig.ts +2 -2
- package/akanContext.ts +4 -4
- package/akanMcpContract.ts +1 -1
- package/applicationBuildRunner.test.ts +24 -1
- package/applicationBuildRunner.ts +11 -1
- package/artifact/implicitRootLayout.test.ts +2 -0
- package/artifact/implicitRootLayout.ts +1 -0
- package/biome.base.json +3 -12
- package/biomeBase.ts +1 -1
- package/cloud/configLock.ts +2 -1
- package/cloud/globalConfig.ts +3 -2
- package/codeAgent/agent/AkanCodeCompaction.ts +49 -0
- package/codeAgent/agent/AkanCodeServices.ts +2 -3
- package/codeAgent/agent/CodeAgent.ts +46 -2
- package/codeAgent/agent/CodeAgentProxy.ts +2 -2
- package/codeAgent/agent/CodeAgentRpcHost.ts +3 -0
- package/codeAgent/agent/akanCodeCompaction.test.ts +36 -0
- package/codeAgent/agent/akanCodeModel.ts +1 -1
- package/codeAgent/agent/akanContextFiles.test.ts +3 -1
- package/codeAgent/agent/codeAgent.test.ts +61 -3
- package/codeAgent/agent/codeAgentContract.test.ts +1 -0
- package/codeAgent/index.ts +1 -0
- package/codeAgent/skills/akan-code/SKILL.md +4 -0
- package/codeAgent/skills/akan-store/SKILL.md +2 -1
- package/codeAgent/tools/AkanCodePlugins.ts +3 -1
- package/codeAgent/tools/DeepSeekWeb.ts +127 -0
- package/codeAgent/tools/WebToolPack.ts +128 -29
- package/codeAgent/tools/akanCodePlugins.test.ts +143 -16
- package/codeAgent/tools/deepSeekWeb.test.ts +113 -0
- package/commandDecorators/argMeta.ts +1 -0
- package/commandDecorators/command.ts +52 -14
- package/commandDecorators/commandBuilder.ts +6 -3
- package/commandDecorators/commandDecorators.test.ts +130 -6
- package/commandDecorators/helpFormatter.ts +2 -1
- package/commandDecorators/targetMeta.ts +10 -0
- package/createTunnel.test.ts +14 -0
- package/createTunnel.ts +3 -1
- package/dependencyScanner.ts +1 -1
- package/devkitUtils.test.ts +1 -1
- package/executors.test.ts +108 -3
- package/executors.ts +30 -13
- package/frontendBuild/autoImportSync.test.ts +3 -1
- package/frontendBuild/clientEntryDiscovery.ts +1 -1
- package/frontendBuild/csrArtifactBuilder.test.ts +30 -0
- package/frontendBuild/csrArtifactBuilder.ts +5 -3
- package/frontendBuild/csrDevPatcher.test.ts +4 -1
- package/frontendBuild/cssBlockParser.test.ts +123 -0
- package/frontendBuild/cssBlockParser.ts +132 -0
- package/frontendBuild/cssCandidateCache.test.ts +16 -0
- package/frontendBuild/cssCandidateCache.ts +8 -6
- package/frontendBuild/cssCompiler.test.ts +53 -0
- package/frontendBuild/cssCompiler.ts +8 -4
- package/frontendBuild/fontOptimizer.ts +2 -1
- package/frontendBuild/frontendBuild.test.ts +14 -0
- package/frontendBuild/hmrWatcher.ts +1 -1
- package/frontendBuild/pagesBundleBuilder.test.ts +98 -0
- package/frontendBuild/pagesBundleBuilder.ts +64 -9
- package/frontendBuild/routeClientBuilder.ts +1 -1
- package/frontendBuild/sourceMtimeIndex.test.ts +1 -1
- package/frontendBuild/themeValidator.test.ts +83 -2
- package/frontendBuild/themeValidator.ts +37 -15
- package/getRelatedCnsts.ts +2 -1
- package/integration/devStabilityHarness.ts +5 -3
- package/lint/__fixtures__/no-deep-internal-import/bad.tsx +1 -0
- package/lint/__fixtures__/no-deep-internal-import/good.tsx +1 -0
- package/lint/__fixtures__/no-import-client-functions/bad.tsx +1 -0
- package/lint/__fixtures__/no-import-client-functions/good.tsx +1 -0
- package/lint/__fixtures__/no-import-client-in-server/bad.tsx +1 -0
- package/lint/__fixtures__/no-import-client-in-server/good.tsx +1 -0
- package/lint/__fixtures__/no-import-external-library/bad.tsx +1 -0
- package/lint/__fixtures__/no-import-external-library/good.tsx +1 -0
- package/lint/__fixtures__/no-import-external-library-in-test/bad.tsx +1 -0
- package/lint/__fixtures__/no-import-external-library-in-test/good.tsx +1 -0
- package/lint/__fixtures__/no-import-server-in-client/bad.tsx +1 -0
- package/lint/__fixtures__/no-import-server-in-client/good.tsx +1 -0
- package/lint/__fixtures__/no-init-fetch-in-client/store-file/bad.ts +1 -1
- package/lint/__fixtures__/no-static-in-object-light-model/bad.ts +2 -2
- package/mobile/mobileTarget.ts +1 -2
- package/mobile/nativeApp.test.ts +1 -1
- package/mobile/nativeConfig.test.ts +3 -0
- package/mobile/nativeConfig.ts +3 -2
- package/mobile/nativeWebDir.ts +3 -2
- package/mobile/updateUploader.test.ts +1 -0
- package/mobile/updateUploader.ts +3 -3
- package/package.json +20 -20
- package/piDependencyPins.test.ts +18 -19
- package/platformTest/LinuxDockerTarget.ts +2 -1
- package/platformTest/PlatformTestTarget.ts +7 -5
- package/platformTest/WindowsSshTarget.ts +2 -1
- package/qualityScanner.ts +1 -1
- package/recipeScanner.test.ts +9 -3
- package/scanInfo.ts +37 -4
- package/subspaceClone.ts +5 -4
- package/testHelpers.ts +2 -0
- package/transforms/barrelImportsPlugin.ts +1 -1
- package/transforms/browserDirnamePlugin.test.ts +88 -0
- package/transforms/browserDirnamePlugin.ts +25 -0
- package/transforms/rscUseClientTransform.ts +2 -2
- package/transforms/transforms.test.ts +18 -2
- package/transforms/tsconfigPackageResolver.test.ts +9 -0
- package/types.ts +1 -0
- package/workflow/artifacts.ts +1 -1
- package/workflow/rolloutGate.test.ts +5 -1
- package/workflow/source.ts +2 -2
|
@@ -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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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");
|
|
@@ -186,20 +209,21 @@ const assignCommandContext = (context: CommandContext, argMeta: ArgMeta | Intern
|
|
|
186
209
|
else context.values[argMeta.type.toLowerCase()] = value;
|
|
187
210
|
};
|
|
188
211
|
|
|
212
|
+
//? Not `.env`, which a fresh clone lacks; an app or a package folder has a package.json and tsconfig.json of its own.
|
|
189
213
|
const assertCurrentDirectoryIsWorkspaceRoot = async () => {
|
|
190
214
|
const cwd = process.cwd();
|
|
191
|
-
const [hasPackageJson, hasTsConfig,
|
|
215
|
+
const [hasPackageJson, hasTsConfig, ...hasMemberDirs] = await Promise.all([
|
|
192
216
|
FileSys.fileExists(`${cwd}/package.json`),
|
|
193
217
|
FileSys.fileExists(`${cwd}/tsconfig.json`),
|
|
194
|
-
FileSys.
|
|
218
|
+
...["apps", "libs", "pkgs"].map((dir) => FileSys.dirExists(`${cwd}/${dir}`)),
|
|
195
219
|
]);
|
|
196
|
-
if (hasPackageJson && hasTsConfig &&
|
|
220
|
+
if (hasPackageJson && hasTsConfig && hasMemberDirs.some(Boolean)) return;
|
|
197
221
|
|
|
198
222
|
throw new Error(
|
|
199
223
|
[
|
|
200
224
|
"Akan CLI commands must be run from the workspace root.",
|
|
201
225
|
`Current directory: ${cwd}`,
|
|
202
|
-
"Move to the directory that contains package.json, tsconfig.json, and
|
|
226
|
+
"Move to the directory that contains package.json, tsconfig.json, and an apps/, libs/ or pkgs/ folder, then run the command again.",
|
|
203
227
|
].join("\n"),
|
|
204
228
|
);
|
|
205
229
|
};
|
|
@@ -323,6 +347,7 @@ export const getInternalArgumentValue = async (
|
|
|
323
347
|
|
|
324
348
|
export const runCommands = async (...commands: CommandCls[]) => {
|
|
325
349
|
let failureExitCode = 1;
|
|
350
|
+
let reportFailure = async (_error: unknown) => {};
|
|
326
351
|
process.on("unhandledRejection", (error) => {
|
|
327
352
|
printCliError(error);
|
|
328
353
|
process.exit(ExitCodeError.codeOf(error) ?? failureExitCode);
|
|
@@ -371,10 +396,20 @@ It may cause unexpected behavior. Run \`akan update\` to update latest akanjs.`,
|
|
|
371
396
|
hidden: targetMeta.targetOption.devOnly,
|
|
372
397
|
});
|
|
373
398
|
const targetFailureExitCode = targetMeta.targetOption.failureExitCode;
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
programCommand.exitOverride((error) => process.exit(error.exitCode === 0 ? 0 : targetFailureExitCode));
|
|
399
|
+
const usageExitCode = targetMeta.targetOption.usageExitCode ?? targetFailureExitCode;
|
|
400
|
+
const { onFailure } = targetMeta.targetOption;
|
|
377
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
|
+
});
|
|
378
413
|
for (const argMeta of allArgMetas) {
|
|
379
414
|
if (argMeta.type === "Option") programCommand = handleOption(programCommand, argMeta);
|
|
380
415
|
else if (argMeta.type === "Argument") programCommand = handleArgument(programCommand, argMeta);
|
|
@@ -407,6 +442,8 @@ It may cause unexpected behavior. Run \`akan update\` to update latest akanjs.`,
|
|
|
407
442
|
if (!targetMeta.targetOption.stdio) Logger.rawLog();
|
|
408
443
|
const cmdArgs = args.slice(0, args.length - 2);
|
|
409
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) });
|
|
410
447
|
const commandArgs = [] as unknown[];
|
|
411
448
|
if (targetMeta.targetOption.runsOnWorkspaceRoot) await assertCurrentDirectoryIsWorkspaceRoot();
|
|
412
449
|
const workspace = WorkspaceExecutor.fromRoot();
|
|
@@ -451,8 +488,9 @@ It may cause unexpected behavior. Run \`akan update\` to update latest akanjs.`,
|
|
|
451
488
|
}
|
|
452
489
|
}
|
|
453
490
|
// Handled here, not by the handler above: a rejection reaching the entry's top-level await prints Bun's own trace.
|
|
454
|
-
await program.parseAsync(process.argv).catch((error: unknown) => {
|
|
491
|
+
await program.parseAsync(process.argv).catch(async (error: unknown) => {
|
|
455
492
|
printCliError(error);
|
|
493
|
+
await reportFailure(error).catch(printCliError);
|
|
456
494
|
process.exit(ExitCodeError.codeOf(error) ?? failureExitCode);
|
|
457
495
|
});
|
|
458
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:
|
|
82
|
+
argsOption: Pick<ArgsOption, "multiple"> | undefined,
|
|
83
83
|
idx: number,
|
|
84
|
-
): ArgMeta =>
|
|
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
|
-
|
|
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([
|
|
528
|
-
|
|
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/createTunnel.test.ts
CHANGED
|
@@ -48,4 +48,18 @@ describe("createTunnel", () => {
|
|
|
48
48
|
expect(failure.message).toContain(reason.message);
|
|
49
49
|
}
|
|
50
50
|
});
|
|
51
|
+
|
|
52
|
+
test("refuses to dial a cluster host built on the local default serve domain", async () => {
|
|
53
|
+
const { app } = track(await createTempApp("demo"));
|
|
54
|
+
delete process.env.AKAN_PUBLIC_SERVE_DOMAIN;
|
|
55
|
+
const connect = spyOn(Client.prototype, "connect");
|
|
56
|
+
try {
|
|
57
|
+
await expect(createTunnel("redis", { app, environment: "debug" })).rejects.toThrow(
|
|
58
|
+
'The redis tunnel for the "debug" environment needs AKAN_PUBLIC_SERVE_DOMAIN (would be "localhost") set',
|
|
59
|
+
);
|
|
60
|
+
expect(connect).not.toHaveBeenCalled();
|
|
61
|
+
} finally {
|
|
62
|
+
connect.mockRestore();
|
|
63
|
+
}
|
|
64
|
+
});
|
|
51
65
|
});
|
package/createTunnel.ts
CHANGED
|
@@ -10,7 +10,9 @@ export const createTunnel = async (
|
|
|
10
10
|
service: "redis" | "postgres",
|
|
11
11
|
{ app, environment, port = service === "postgres" ? 5432 : 6379 }: TunnelOption,
|
|
12
12
|
) => {
|
|
13
|
-
const
|
|
13
|
+
const baseDevEnv = WorkspaceExecutor.getBaseDevEnv();
|
|
14
|
+
WorkspaceExecutor.assertDeployableEnv(baseDevEnv, environment, `The ${service} tunnel`);
|
|
15
|
+
const { serveDomain, repoName } = baseDevEnv;
|
|
14
16
|
const host = `${app.name}-${environment}.${serveDomain}`;
|
|
15
17
|
const sshPort = process.env.SSH_TUNNEL_PORT ? parseInt(process.env.SSH_TUNNEL_PORT) : 32767;
|
|
16
18
|
const dstHost = `${service}-0.${service}-svc.${app.name}-${environment}.svc.cluster.local`;
|
package/dependencyScanner.ts
CHANGED
|
@@ -107,7 +107,7 @@ export class TypeScriptDependencyScanner {
|
|
|
107
107
|
return {
|
|
108
108
|
npmDeps: [...runtimeDeps].sort(),
|
|
109
109
|
npmDevDeps: [...devDeps].sort(),
|
|
110
|
-
missingDeps: missingDeps.sort(),
|
|
110
|
+
missingDeps: missingDeps.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)),
|
|
111
111
|
};
|
|
112
112
|
}
|
|
113
113
|
|
package/devkitUtils.test.ts
CHANGED
|
@@ -76,7 +76,7 @@ describe("TypeScriptDependencyScanner", () => {
|
|
|
76
76
|
});
|
|
77
77
|
expect(deps.pkgDeps).toEqual(["akanjs/tool"]);
|
|
78
78
|
expect(deps.libDeps).toEqual(["shared"]);
|
|
79
|
-
expect(deps.npmDeps.sort()).toEqual(["lodash", "react"]);
|
|
79
|
+
expect(deps.npmDeps.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))).toEqual(["lodash", "react"]);
|
|
80
80
|
expect(deps.npmDevDeps).toEqual(["typescript"]);
|
|
81
81
|
|
|
82
82
|
const graph = scanner.generateDependencyGraph();
|
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");
|
|
@@ -312,7 +335,7 @@ describe("Executor filesystem helpers", () => {
|
|
|
312
335
|
});
|
|
313
336
|
|
|
314
337
|
describe("Workspace and app executor environment contracts", () => {
|
|
315
|
-
test("reads base development environment
|
|
338
|
+
test("reads base development environment", () => {
|
|
316
339
|
process.env.AKAN_PUBLIC_REPO_NAME = "repo";
|
|
317
340
|
process.env.AKAN_PUBLIC_SERVE_DOMAIN = "example.com";
|
|
318
341
|
process.env.AKAN_PUBLIC_ENV = "local";
|
|
@@ -328,10 +351,53 @@ describe("Workspace and app executor environment contracts", () => {
|
|
|
328
351
|
env: "local",
|
|
329
352
|
portOffset: 10,
|
|
330
353
|
workspaceId: undefined,
|
|
354
|
+
defaulted: [],
|
|
331
355
|
});
|
|
356
|
+
});
|
|
332
357
|
|
|
333
|
-
|
|
334
|
-
|
|
358
|
+
test("unset, the repo name is the origin remote's and the serve domain localhost, both marked defaulted", async () => {
|
|
359
|
+
const root = await makeTempRoot();
|
|
360
|
+
for (const args of [
|
|
361
|
+
["init", "-q"],
|
|
362
|
+
["remote", "add", "origin", "git@github.com:akan-team/fixture-repo.git"],
|
|
363
|
+
])
|
|
364
|
+
expect(Bun.spawnSync(["git", ...args], { cwd: root }).exitCode).toBe(0);
|
|
365
|
+
for (const key of ["AKAN_PUBLIC_REPO_NAME", "AKAN_PUBLIC_SERVE_DOMAIN", "AKAN_PUBLIC_ENV", "AKAN_WORKSPACE_ROOT"])
|
|
366
|
+
delete process.env[key];
|
|
367
|
+
|
|
368
|
+
expect(WorkspaceExecutor.getBaseDevEnv(path.join(root, ".env"))).toMatchObject({
|
|
369
|
+
repoName: "fixture-repo",
|
|
370
|
+
serveDomain: "localhost",
|
|
371
|
+
env: "debug",
|
|
372
|
+
defaulted: ["AKAN_PUBLIC_REPO_NAME", "AKAN_PUBLIC_SERVE_DOMAIN"],
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
process.env.AKAN_PUBLIC_SERVE_DOMAIN = "example.com";
|
|
376
|
+
expect(WorkspaceExecutor.getBaseDevEnv(path.join(root, ".env"))).toMatchObject({
|
|
377
|
+
repoName: "fixture-repo",
|
|
378
|
+
serveDomain: "example.com",
|
|
379
|
+
defaulted: ["AKAN_PUBLIC_REPO_NAME"],
|
|
380
|
+
});
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
test("a build or tunnel for a real environment refuses a defaulted value, and a local one takes it", () => {
|
|
384
|
+
const baseDevEnv = {
|
|
385
|
+
repoName: "fixture-repo",
|
|
386
|
+
serveDomain: "localhost",
|
|
387
|
+
env: "debug" as const,
|
|
388
|
+
portOffset: 0,
|
|
389
|
+
workspaceRoot: undefined,
|
|
390
|
+
};
|
|
391
|
+
const defaulted = { ...baseDevEnv, defaulted: ["AKAN_PUBLIC_SERVE_DOMAIN" as const] };
|
|
392
|
+
|
|
393
|
+
expect(() => WorkspaceExecutor.assertDeployableEnv(defaulted, "main", "akan build")).toThrow(
|
|
394
|
+
'akan build for the "main" environment needs AKAN_PUBLIC_SERVE_DOMAIN (would be "localhost") set',
|
|
395
|
+
);
|
|
396
|
+
expect(() => WorkspaceExecutor.assertDeployableEnv(defaulted, "local", "akan build")).not.toThrow();
|
|
397
|
+
expect(() =>
|
|
398
|
+
WorkspaceExecutor.assertDeployableEnv({ ...baseDevEnv, defaulted: [] }, "main", "akan build"),
|
|
399
|
+
).not.toThrow();
|
|
400
|
+
expect(() => WorkspaceExecutor.assertDeployableEnv(baseDevEnv, "main", "akan build")).not.toThrow();
|
|
335
401
|
});
|
|
336
402
|
|
|
337
403
|
test("reads base development environment from an explicit env file", async () => {
|
|
@@ -359,6 +425,7 @@ describe("Workspace and app executor environment contracts", () => {
|
|
|
359
425
|
env: "develop",
|
|
360
426
|
portOffset: 7,
|
|
361
427
|
workspaceId: undefined,
|
|
428
|
+
defaulted: [],
|
|
362
429
|
});
|
|
363
430
|
});
|
|
364
431
|
|
|
@@ -417,6 +484,21 @@ describe("Workspace and app executor environment contracts", () => {
|
|
|
417
484
|
expect((await stat(path.join(root, "dist/apps/demo/public"))).isDirectory()).toBe(true);
|
|
418
485
|
});
|
|
419
486
|
|
|
487
|
+
test("the app command env hands the dev server the local defaults that stood in for unset values", async () => {
|
|
488
|
+
const root = await makeTempRoot();
|
|
489
|
+
delete process.env.AKAN_PUBLIC_REPO_NAME;
|
|
490
|
+
delete process.env.AKAN_PUBLIC_SERVE_DOMAIN;
|
|
491
|
+
process.env.AKAN_WORKSPACE_ROOT = root;
|
|
492
|
+
const app = AppExecutor.from(new WorkspaceExecutor({ workspaceRoot: root, repoName: "repo" }), "defaults");
|
|
493
|
+
|
|
494
|
+
const env = app.getCommandEnv();
|
|
495
|
+
expect(env.AKAN_PUBLIC_SERVE_DOMAIN).toBe("localhost");
|
|
496
|
+
expect(env.AKAN_PUBLIC_REPO_NAME).toBe(WorkspaceExecutor.getBaseDevEnv().repoName);
|
|
497
|
+
|
|
498
|
+
process.env.AKAN_PUBLIC_SERVE_DOMAIN = "example.com";
|
|
499
|
+
expect(app.getCommandEnv().AKAN_PUBLIC_SERVE_DOMAIN).toBe("example.com");
|
|
500
|
+
});
|
|
501
|
+
|
|
420
502
|
test("akan start clears the dev output and keeps the bin downloads and a native folder from before dist/native", async () => {
|
|
421
503
|
const root = await makeTempRoot();
|
|
422
504
|
process.env.AKAN_PUBLIC_REPO_NAME = "repo";
|
|
@@ -1069,6 +1151,29 @@ describe("scan info construction", () => {
|
|
|
1069
1151
|
expect(info.file.dictionary.services.has("auth")).toBe(true);
|
|
1070
1152
|
expect(info.file.document.scalars.has("money")).toBe(true);
|
|
1071
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
|
+
});
|
|
1072
1177
|
});
|
|
1073
1178
|
|
|
1074
1179
|
describe("WorkspaceExecutor listing", () => {
|