@akanjs/devkit 3.1.0-rc.9 → 3.1.0
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/CHANGELOG.md +382 -0
- package/README.ko.md +1 -1
- package/README.md +1 -1
- package/akanConfig/akanConfig.test.ts +8 -4
- package/akanConfig/akanConfig.ts +2 -1
- package/applicationBuildRunner.test.ts +77 -2
- package/applicationBuildRunner.ts +28 -5
- package/artifact/implicitRootLayout.test.ts +11 -1
- package/artifact/implicitRootLayout.ts +32 -7
- package/biome.base.json +8 -3
- package/biomeBase.test.ts +78 -0
- package/biomeBase.ts +1 -1
- package/codeAgent/agent/AkanCodeCompaction.ts +49 -0
- package/codeAgent/agent/AkanCodeServices.ts +2 -3
- package/codeAgent/agent/CodeAgent.ts +46 -2
- package/codeAgent/agent/CodeAgentRpcHost.ts +3 -0
- package/codeAgent/agent/akanCodeCompaction.test.ts +36 -0
- 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 +4 -2
- package/codeAgent/tools/AkanCodePlugins.ts +3 -1
- package/codeAgent/tools/DeepSeekWeb.ts +127 -0
- package/codeAgent/tools/WebToolPack.ts +149 -25
- package/codeAgent/tools/akanCodePlugins.test.ts +191 -0
- package/codeAgent/tools/deepSeekWeb.test.ts +113 -0
- package/commandDecorators/argMeta.ts +1 -0
- package/commandDecorators/command.ts +49 -10
- package/commandDecorators/commandBuilder.ts +6 -3
- package/commandDecorators/commandDecorators.test.ts +169 -6
- package/commandDecorators/helpFormatter.ts +2 -1
- package/commandDecorators/targetMeta.ts +10 -0
- package/executors.test.ts +53 -1
- package/executors.ts +10 -6
- package/frontendBuild/csrArtifactBuilder.test.ts +40 -0
- package/frontendBuild/csrArtifactBuilder.ts +24 -1
- package/frontendBuild/cssCandidateCache.test.ts +16 -0
- package/frontendBuild/cssCandidateCache.ts +8 -6
- package/frontendBuild/fontPruner.ts +1 -0
- package/frontendBuild/mcpAppViewBuilder.test.ts +170 -0
- package/frontendBuild/mcpAppViewBuilder.ts +245 -0
- package/incrementalBuilder/buildBatch.proc.ts +3 -3
- package/incrementalBuilder/buildBatchProtocol.ts +2 -2
- package/lint/__fixtures__/no-conditional-st-hook/bad.tsx +45 -0
- package/lint/__fixtures__/no-conditional-st-hook/good.tsx +33 -0
- package/lint/no-conditional-st-hook.grit +146 -0
- package/mobile/desktopServerStage.ts +1 -0
- package/package.json +15 -15
- package/routeSourceValidator.test.ts +19 -4
- package/routeSourceValidator.ts +62 -14
- package/scanInfo.ts +36 -3
- package/subspace.test.ts +3 -2
- package/subspaceClone.ts +1 -12
- package/transforms/mcpAppPageTrimmer.test.ts +89 -0
- package/transforms/mcpAppPageTrimmer.ts +70 -0
- package/transforms/pureLazyMarker.test.ts +58 -0
- package/transforms/pureLazyMarker.ts +42 -0
- package/transforms/sideEffectImportOrder.test.ts +183 -0
- package/transforms/sideEffectImportOrder.ts +109 -0
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { DeepSeekWeb } from "./DeepSeekWeb";
|
|
3
|
+
|
|
4
|
+
interface Received {
|
|
5
|
+
path: string;
|
|
6
|
+
body: { query?: string; count?: number; url?: string };
|
|
7
|
+
auth: string | null;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const received: Received[] = [];
|
|
11
|
+
let answers: Response[] = [];
|
|
12
|
+
let server: ReturnType<typeof Bun.serve>;
|
|
13
|
+
beforeAll(() => {
|
|
14
|
+
server = Bun.serve({
|
|
15
|
+
port: 0,
|
|
16
|
+
fetch: async (request) => {
|
|
17
|
+
received.push({
|
|
18
|
+
path: new URL(request.url).pathname,
|
|
19
|
+
body: (await request.json()) as Received["body"],
|
|
20
|
+
auth: request.headers.get("authorization"),
|
|
21
|
+
});
|
|
22
|
+
return answers.shift() ?? Response.json({ results: [] });
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
afterAll(() => server.stop(true));
|
|
27
|
+
|
|
28
|
+
const client = () => new DeepSeekWeb("sk-test", `http://127.0.0.1:${server.port}/v1/tools`);
|
|
29
|
+
const rateLimited = () =>
|
|
30
|
+
Response.json({ error: { message: "QPS exceeded" } }, { status: 429, headers: { "retry-after": "0.01" } });
|
|
31
|
+
|
|
32
|
+
describe("DeepSeekWeb", () => {
|
|
33
|
+
test("search lists each page once, without tracking parameters, with its date and a clipped summary", async () => {
|
|
34
|
+
answers = [
|
|
35
|
+
Response.json({
|
|
36
|
+
results: [
|
|
37
|
+
{
|
|
38
|
+
title: "Bun v1.3.13",
|
|
39
|
+
url: "https://bun.com/blog/bun-v1.3.13?utm_source=x&utm_medium=y&ref=keep",
|
|
40
|
+
summary: "word ".repeat(400),
|
|
41
|
+
date: "2026-04-19T16:00:00+00:00",
|
|
42
|
+
},
|
|
43
|
+
{ title: "Isolate issue", url: "https://github.com/oven-sh/bun/issues/31771#1", snippet: "only a snippet" },
|
|
44
|
+
{ title: "Same issue again", url: "https://github.com/oven-sh/bun/issues/31771", summary: "dup" },
|
|
45
|
+
],
|
|
46
|
+
}),
|
|
47
|
+
];
|
|
48
|
+
const text = await client().search("bun isolate", 5);
|
|
49
|
+
expect(received.at(-1)).toEqual({
|
|
50
|
+
path: "/v1/tools/search",
|
|
51
|
+
body: { query: "bun isolate", count: 5 },
|
|
52
|
+
auth: "Bearer sk-test",
|
|
53
|
+
});
|
|
54
|
+
expect(text).toContain("- Bun v1.3.13\n https://bun.com/blog/bun-v1.3.13?ref=keep · 2026-04-19\n word word");
|
|
55
|
+
expect(text).toContain("- Isolate issue\n https://github.com/oven-sh/bun/issues/31771\n only a snippet");
|
|
56
|
+
expect(text).not.toContain("Same issue again");
|
|
57
|
+
expect(text.split("\n")[2]?.trim().length).toBe(DeepSeekWeb.summaryChars);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("a query longer than DeepSeek takes is refused before any request is spent", async () => {
|
|
61
|
+
const before = received.length;
|
|
62
|
+
await expect(client().search("x".repeat(DeepSeekWeb.maxQueryChars + 1), 5)).rejects.toThrow("shorten the query");
|
|
63
|
+
expect(received.length).toBe(before);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("search waits out a rate limit, and gives up with DeepSeek's own message after the last attempt", async () => {
|
|
67
|
+
answers = [rateLimited(), Response.json({ results: [{ title: "t", url: "https://a.com/", summary: "s" }] })];
|
|
68
|
+
expect(await client().search("q", 1)).toContain("https://a.com/");
|
|
69
|
+
|
|
70
|
+
answers = [rateLimited(), rateLimited(), rateLimited()];
|
|
71
|
+
await expect(client().search("q", 1)).rejects.toThrow("429: QPS exceeded");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("fetch waits only on its quota and hands any other failure straight back", async () => {
|
|
75
|
+
answers = [rateLimited(), Response.json({ title: "Page", content: "body" })];
|
|
76
|
+
expect(await client().fetch("https://a.com/")).toEqual({ title: "Page", content: "body" });
|
|
77
|
+
|
|
78
|
+
const before = received.length;
|
|
79
|
+
answers = [Response.json({ error: { message: "downstream timeout" } }, { status: 504 })];
|
|
80
|
+
await expect(client().fetch("https://a.com/")).rejects.toThrow("504: downstream timeout");
|
|
81
|
+
expect(received.length).toBe(before + 1);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("fetch hands back Markdown with its blanket escaping undone", async () => {
|
|
85
|
+
answers = [
|
|
86
|
+
Response.json({
|
|
87
|
+
title: "Example",
|
|
88
|
+
content: "\\#\\# Usage\n\nCall \\`foo\\(1\\)\\` \\- it returns 2\\.\n\nconst re \\= \\/\\\\s\\+\\/\\;",
|
|
89
|
+
}),
|
|
90
|
+
];
|
|
91
|
+
const page = await client().fetch("https://example.com/usage");
|
|
92
|
+
expect(received.at(-1)?.body).toEqual({ url: "https://example.com/usage" });
|
|
93
|
+
expect(page).toEqual({
|
|
94
|
+
title: "Example",
|
|
95
|
+
content: "## Usage\n\nCall `foo(1)` - it returns 2.\n\nconst re = /\\s+/;",
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("a page that was never escaped keeps the backslashes it has", () => {
|
|
100
|
+
const clean = "Match a dot with `a\\.b`. See (the docs), [a link](x) and {a: 1}; done!";
|
|
101
|
+
expect(DeepSeekWeb.unescape(clean)).toBe(clean);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("the key comes from the model runtime, and never from behind a proxy", async () => {
|
|
105
|
+
const runtime = (apiKey?: string) =>
|
|
106
|
+
({ getAuth: async () => (apiKey ? { auth: { apiKey } } : undefined) }) as unknown as Parameters<
|
|
107
|
+
typeof DeepSeekWeb.fromRuntime
|
|
108
|
+
>[0];
|
|
109
|
+
expect(await DeepSeekWeb.fromRuntime(runtime("sk"), false)).toBeInstanceOf(DeepSeekWeb);
|
|
110
|
+
expect(await DeepSeekWeb.fromRuntime(runtime("sk"), true)).toBeUndefined();
|
|
111
|
+
expect(await DeepSeekWeb.fromRuntime(runtime(), false)).toBeUndefined();
|
|
112
|
+
});
|
|
113
|
+
});
|
|
@@ -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");
|
|
@@ -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);
|
|
@@ -360,6 +384,8 @@ export const runCommands = async (...commands: CommandCls[]) => {
|
|
|
360
384
|
Akan CLI version is mismatch with installed package. ${process.env.AKAN_VERSION} (global) vs ${installedAkanPackageJson.version} (akanjs)
|
|
361
385
|
It may cause unexpected behavior. Run \`akan update\` to update latest akanjs.`,
|
|
362
386
|
),
|
|
387
|
+
undefined,
|
|
388
|
+
"error",
|
|
363
389
|
);
|
|
364
390
|
}
|
|
365
391
|
|
|
@@ -372,10 +398,20 @@ It may cause unexpected behavior. Run \`akan update\` to update latest akanjs.`,
|
|
|
372
398
|
hidden: targetMeta.targetOption.devOnly,
|
|
373
399
|
});
|
|
374
400
|
const targetFailureExitCode = targetMeta.targetOption.failureExitCode;
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
programCommand.exitOverride((error) => process.exit(error.exitCode === 0 ? 0 : targetFailureExitCode));
|
|
401
|
+
const usageExitCode = targetMeta.targetOption.usageExitCode ?? targetFailureExitCode;
|
|
402
|
+
const { onFailure } = targetMeta.targetOption;
|
|
378
403
|
const [allArgMetas] = getArgMetas(command, targetMeta.key);
|
|
404
|
+
//? commander exits 1 on a flag or argument it refuses, before any action runs, having printed why.
|
|
405
|
+
//? Thrown, not exited: the failure path below awaits onFailure, and an exit drops what a pipe still buffers.
|
|
406
|
+
if (usageExitCode !== undefined || onFailure)
|
|
407
|
+
programCommand.exitOverride((error) => {
|
|
408
|
+
if (error.exitCode === 0) process.exit(0);
|
|
409
|
+
const refused = new ExitCodeError(error.message.replace(/^error: /, ""), usageExitCode ?? error.exitCode);
|
|
410
|
+
markCliErrorPrinted(refused);
|
|
411
|
+
const given = givenOptionsOf(allArgMetas, programCommand.opts());
|
|
412
|
+
reportFailure = async (failure) => await onFailure?.(failure, { usage: true, given });
|
|
413
|
+
throw refused;
|
|
414
|
+
});
|
|
379
415
|
for (const argMeta of allArgMetas) {
|
|
380
416
|
if (argMeta.type === "Option") programCommand = handleOption(programCommand, argMeta);
|
|
381
417
|
else if (argMeta.type === "Argument") programCommand = handleArgument(programCommand, argMeta);
|
|
@@ -408,6 +444,8 @@ It may cause unexpected behavior. Run \`akan update\` to update latest akanjs.`,
|
|
|
408
444
|
if (!targetMeta.targetOption.stdio) Logger.rawLog();
|
|
409
445
|
const cmdArgs = args.slice(0, args.length - 2);
|
|
410
446
|
const opt = args[args.length - 2] as Record<string, unknown>;
|
|
447
|
+
reportFailure = async (failure) =>
|
|
448
|
+
await onFailure?.(failure, { usage: false, given: givenOptionsOf(allArgMetas, opt) });
|
|
411
449
|
const commandArgs = [] as unknown[];
|
|
412
450
|
if (targetMeta.targetOption.runsOnWorkspaceRoot) await assertCurrentDirectoryIsWorkspaceRoot();
|
|
413
451
|
const workspace = WorkspaceExecutor.fromRoot();
|
|
@@ -452,8 +490,9 @@ It may cause unexpected behavior. Run \`akan update\` to update latest akanjs.`,
|
|
|
452
490
|
}
|
|
453
491
|
}
|
|
454
492
|
// 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) => {
|
|
493
|
+
await program.parseAsync(process.argv).catch(async (error: unknown) => {
|
|
456
494
|
printCliError(error);
|
|
495
|
+
await reportFailure(error).catch(printCliError);
|
|
457
496
|
process.exit(ExitCodeError.codeOf(error) ?? failureExitCode);
|
|
458
497
|
});
|
|
459
498
|
};
|
|
@@ -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,163 @@ 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);
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
describe("a workspace whose akanjs is not the CLI's version", () => {
|
|
664
|
+
const track = tempRoots();
|
|
665
|
+
|
|
666
|
+
test("hears so on stderr, leaving stdout to what the command prints", async () => {
|
|
667
|
+
const { root } = track(await makeCliTempWorkspace());
|
|
668
|
+
await writeText(path.join(root, "package.json"), '{ "name": "repo", "version": "1.0.0" }\n');
|
|
669
|
+
await writeText(path.join(root, "tsconfig.json"), "{}\n");
|
|
670
|
+
await writeText(
|
|
671
|
+
path.join(root, "node_modules", "akanjs", "package.json"),
|
|
672
|
+
'{ "name": "akanjs", "version": "0.0.1-older" }\n',
|
|
673
|
+
);
|
|
674
|
+
const entry = path.join(root, "cli.ts");
|
|
675
|
+
await writeText(
|
|
676
|
+
entry,
|
|
677
|
+
[
|
|
678
|
+
`import { command, runCommands, runner, script } from ${JSON.stringify(path.join(import.meta.dir, "index.ts"))};`,
|
|
679
|
+
'class PlanRunner extends runner("plan") {}',
|
|
680
|
+
'class PlanScript extends script("plan", [PlanRunner]) {}',
|
|
681
|
+
'class PlanCommand extends command("plan", [PlanScript], ({ public: target }) => ({',
|
|
682
|
+
' plan: target({ desc: "plans", stdio: true }).exec(async function () {',
|
|
683
|
+
" process.stdout.write(JSON.stringify({ phases: 1 }));",
|
|
684
|
+
" }),",
|
|
685
|
+
"})) {}",
|
|
686
|
+
"await runCommands(PlanCommand);",
|
|
687
|
+
"",
|
|
688
|
+
].join("\n"),
|
|
689
|
+
);
|
|
690
|
+
const proc = Bun.spawn(["bun", entry, "plan"], { cwd: root, stdout: "pipe", stderr: "pipe" });
|
|
691
|
+
const [stdout, stderr, code] = await Promise.all([
|
|
692
|
+
new Response(proc.stdout).text(),
|
|
693
|
+
new Response(proc.stderr).text(),
|
|
694
|
+
proc.exited,
|
|
695
|
+
]);
|
|
696
|
+
expect(code).toBe(0);
|
|
697
|
+
expect(stdout).toBe('{"phases":1}');
|
|
698
|
+
expect(stderr).toContain("0.0.1-older (akanjs)");
|
|
699
|
+
}, 30_000);
|
|
537
700
|
});
|
|
@@ -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
|
@@ -429,6 +429,35 @@ describe("Workspace and app executor environment contracts", () => {
|
|
|
429
429
|
});
|
|
430
430
|
});
|
|
431
431
|
|
|
432
|
+
test("reads an explicit env file as Bun's own loader does, leaving inline comments out of the values", async () => {
|
|
433
|
+
const root = await makeTempRoot();
|
|
434
|
+
await writeFile(
|
|
435
|
+
path.join(root, ".env"),
|
|
436
|
+
[
|
|
437
|
+
"# the workspace env",
|
|
438
|
+
"AKAN_PUBLIC_SERVE_DOMAIN=example.com # prod domain",
|
|
439
|
+
'AKAN_PUBLIC_REPO_NAME="file repo" # note',
|
|
440
|
+
"AKAN_PUBLIC_ENV=develop#no space needed",
|
|
441
|
+
"export AKAN_WORKSPACE_ID='ws # kept' # dropped",
|
|
442
|
+
'AKAN_PUBLIC_APP_NAME="say \\"hi\\"\\nagain" # c',
|
|
443
|
+
'AKAN_WORKSPACE_ROOT="/unterminated # c',
|
|
444
|
+
"PORT_OFFSET=7",
|
|
445
|
+
"",
|
|
446
|
+
].join("\r\n"),
|
|
447
|
+
);
|
|
448
|
+
|
|
449
|
+
expect(WorkspaceExecutor.getBaseDevEnv(path.join(root, ".env"))).toEqual({
|
|
450
|
+
appName: 'say \\"hi\\"\nagain',
|
|
451
|
+
workspaceRoot: '"/unterminated',
|
|
452
|
+
repoName: "file repo",
|
|
453
|
+
serveDomain: "example.com",
|
|
454
|
+
env: "develop",
|
|
455
|
+
portOffset: 7,
|
|
456
|
+
workspaceId: "ws # kept",
|
|
457
|
+
defaulted: [],
|
|
458
|
+
});
|
|
459
|
+
});
|
|
460
|
+
|
|
432
461
|
test("builds app command environment and prepareCommand artifacts", async () => {
|
|
433
462
|
const root = await makeTempRoot();
|
|
434
463
|
process.env.AKAN_PUBLIC_REPO_NAME = "repo";
|
|
@@ -1078,7 +1107,7 @@ describe("PkgExecutor package generation", () => {
|
|
|
1078
1107
|
expect(distPackageJson).toMatchObject({
|
|
1079
1108
|
name: "@sample/tool",
|
|
1080
1109
|
type: "module",
|
|
1081
|
-
engines: { bun: ">=1.4.
|
|
1110
|
+
engines: { bun: ">=1.4.4" },
|
|
1082
1111
|
dependencies: { lodash: "4.0.0" },
|
|
1083
1112
|
devDependencies: { typescript: "6.0.0" },
|
|
1084
1113
|
peerDependencies: { react: "19.0.0" },
|
|
@@ -1151,6 +1180,29 @@ describe("scan info construction", () => {
|
|
|
1151
1180
|
expect(info.file.dictionary.services.has("auth")).toBe(true);
|
|
1152
1181
|
expect(info.file.document.scalars.has("money")).toBe(true);
|
|
1153
1182
|
});
|
|
1183
|
+
|
|
1184
|
+
test("lists script/migration by id with a checksum that ignores line endings and a BOM, and skips tests", async () => {
|
|
1185
|
+
const { root, app } = await createTempApp("migrating");
|
|
1186
|
+
expect(await AppInfo.scanMigrations(app)).toEqual([]);
|
|
1187
|
+
const dir = path.join(root, "apps", "migrating", "script", "migration");
|
|
1188
|
+
await writeText(path.join(dir, "202610211000_ownership.ts"), "export default 1;\r\nexport const a = 2;\r\n");
|
|
1189
|
+
await writeText(path.join(dir, "202609010900_backfill.ts"), "export default 1;\n");
|
|
1190
|
+
await writeText(path.join(dir, "202609010900_backfill.test.ts"), "test;\n");
|
|
1191
|
+
await writeText(path.join(dir, "202609010900_backfill.d.ts"), "export {};\n");
|
|
1192
|
+
await writeText(path.join(dir, "README.md"), "# notes\n");
|
|
1193
|
+
await writeText(path.join(root, "apps", "migrating", "script", "seed.ts"), "export {};\n");
|
|
1194
|
+
|
|
1195
|
+
const scanned = await AppInfo.scanMigrations(app);
|
|
1196
|
+
expect(scanned.map(({ id }) => id)).toEqual(["202609010900_backfill", "202610211000_ownership"]);
|
|
1197
|
+
await writeText(path.join(dir, "202610211000_ownership.ts"), "export default 1;\nexport const a = 2;\n");
|
|
1198
|
+
expect(await AppInfo.scanMigrations(app)).toEqual(scanned);
|
|
1199
|
+
await writeText(path.join(dir, "202610211000_ownership.ts"), "\uFEFFexport default 1;\nexport const a = 2;\n");
|
|
1200
|
+
expect(await AppInfo.scanMigrations(app)).toEqual(scanned);
|
|
1201
|
+
expect(scanned[0]?.checksum).toMatch(/^[0-9a-f]{64}$/);
|
|
1202
|
+
|
|
1203
|
+
await writeText(path.join(dir, "fixOwners.ts"), "export default 1;\n");
|
|
1204
|
+
await expect(AppInfo.scanMigrations(app)).rejects.toThrow("akan create-migration");
|
|
1205
|
+
});
|
|
1154
1206
|
});
|
|
1155
1207
|
|
|
1156
1208
|
describe("WorkspaceExecutor listing", () => {
|
package/executors.ts
CHANGED
|
@@ -140,6 +140,14 @@ export const execEmoji = {
|
|
|
140
140
|
default: "✈️",
|
|
141
141
|
};
|
|
142
142
|
|
|
143
|
+
//? As Bun's own .env loader reads it (1.4.4), which fills process.env too: `#` ends an unquoted value even mid-word.
|
|
144
|
+
const parseEnvValue = (value: string) => {
|
|
145
|
+
const [, quote, inner] = value.match(/^(["'`])((?:\\.|(?!\1)[^\\])*)\1/) ?? [];
|
|
146
|
+
if (!quote) return value.replace(/#.*/, "").trim();
|
|
147
|
+
if (quote !== '"') return inner;
|
|
148
|
+
return inner.replace(/\\(.)/g, (pair, char: string) => (char === "n" ? "\n" : char === "r" ? "\r" : pair));
|
|
149
|
+
};
|
|
150
|
+
|
|
143
151
|
const parseEnvFile = (envPath: string): Record<string, string> => {
|
|
144
152
|
const env: Record<string, string> = {};
|
|
145
153
|
const content = (() => {
|
|
@@ -156,11 +164,7 @@ const parseEnvFile = (envPath: string): Record<string, string> => {
|
|
|
156
164
|
const separatorIndex = normalized.indexOf("=");
|
|
157
165
|
if (separatorIndex <= 0) continue;
|
|
158
166
|
const key = normalized.slice(0, separatorIndex).trim();
|
|
159
|
-
|
|
160
|
-
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
|
161
|
-
value = value.slice(1, -1);
|
|
162
|
-
}
|
|
163
|
-
env[key] = value;
|
|
167
|
+
env[key] = parseEnvValue(normalized.slice(separatorIndex + 1).trim());
|
|
164
168
|
}
|
|
165
169
|
return env;
|
|
166
170
|
};
|
|
@@ -1640,7 +1644,7 @@ export class PkgExecutor extends Executor {
|
|
|
1640
1644
|
default: "./index.ts",
|
|
1641
1645
|
},
|
|
1642
1646
|
},
|
|
1643
|
-
engines: { bun: ">=1.4.
|
|
1647
|
+
engines: { bun: ">=1.4.4" },
|
|
1644
1648
|
...dependencyMaps,
|
|
1645
1649
|
};
|
|
1646
1650
|
await Promise.all([this.dist.writeJson("package.json", distPkgJson), this.writeJson("package.json", distPkgJson)]);
|