@akanjs/devkit 3.1.0-rc.20 → 3.1.0-rc.22
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/applicationBuildRunner.test.ts +41 -2
- package/applicationBuildRunner.ts +23 -2
- package/artifact/implicitRootLayout.test.ts +11 -1
- package/artifact/implicitRootLayout.ts +32 -7
- package/biome.base.json +6 -2
- package/commandDecorators/argMeta.ts +1 -0
- package/commandDecorators/command.ts +47 -10
- 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/executors.test.ts +23 -0
- package/frontendBuild/csrArtifactBuilder.ts +22 -0
- 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 +24 -0
- package/lint/__fixtures__/no-conditional-st-hook/good.tsx +22 -0
- package/lint/no-conditional-st-hook.grit +75 -0
- package/mobile/desktopServerStage.ts +1 -0
- package/package.json +2 -2
- package/routeSourceValidator.test.ts +19 -4
- package/routeSourceValidator.ts +62 -14
- package/scanInfo.ts +36 -3
- 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
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
1
|
+
import { describe, expect, spyOn, test } from "bun:test";
|
|
2
|
+
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { AkanAppConfig } from "./akanConfig";
|
|
@@ -111,6 +111,45 @@ console.info(new SampleService().name);
|
|
|
111
111
|
}
|
|
112
112
|
});
|
|
113
113
|
|
|
114
|
+
test("bakes into the bundles every AKAN_PUBLIC_* its Dockerfile carries, and names the shell values it outranked", async () => {
|
|
115
|
+
const { root, workspace } = await createTempApp("portal");
|
|
116
|
+
const keys = ["AKAN_PUBLIC_ENV", "AKAN_PUBLIC_REPO_NAME", "AKAN_PUBLIC_SERVE_DOMAIN"] as const;
|
|
117
|
+
const shellEnv = Object.fromEntries(keys.map((key) => [key, process.env[key]]));
|
|
118
|
+
await writeFile(
|
|
119
|
+
path.join(root, ".env"),
|
|
120
|
+
"AKAN_PUBLIC_ENV=main\nAKAN_PUBLIC_REPO_NAME=repo\nAKAN_PUBLIC_SERVE_DOMAIN=akanjs.com\n",
|
|
121
|
+
);
|
|
122
|
+
await Promise.all(["private", "public"].map((dir) => mkdir(path.join(root, "apps", "portal", dir))));
|
|
123
|
+
Object.assign(process.env, {
|
|
124
|
+
AKAN_PUBLIC_ENV: "local",
|
|
125
|
+
AKAN_PUBLIC_REPO_NAME: "shell-repo",
|
|
126
|
+
AKAN_PUBLIC_SERVE_DOMAIN: "localhost",
|
|
127
|
+
});
|
|
128
|
+
const app = AppExecutor.from(workspace, "portal");
|
|
129
|
+
const warn = spyOn(app.logger, "warn").mockImplementation(() => undefined);
|
|
130
|
+
const runner = new ApplicationBuildRunner(app);
|
|
131
|
+
runner.typecheck = async () => {
|
|
132
|
+
throw new Error("stopped after prepare");
|
|
133
|
+
};
|
|
134
|
+
try {
|
|
135
|
+
await expect(runner.build()).rejects.toThrow("stopped after prepare");
|
|
136
|
+
const define = bundleDefine(app, "build", "csr") as { [key: string]: string };
|
|
137
|
+
const imageEnv = (await app.getConfig()).getProductionEnv();
|
|
138
|
+
for (const key of keys) expect(define[`process.env.${key}`]).toBe(JSON.stringify(imageEnv[key]));
|
|
139
|
+
expect(imageEnv.AKAN_PUBLIC_SERVE_DOMAIN).toBe("akanjs.com");
|
|
140
|
+
expect(warn).toHaveBeenCalledTimes(1);
|
|
141
|
+
expect(warn.mock.calls[0]?.[0]).toBe(
|
|
142
|
+
"The workspace .env outranks this process's environment: the bundles and the image take AKAN_PUBLIC_ENV=main (not local), AKAN_PUBLIC_REPO_NAME=repo (not shell-repo), AKAN_PUBLIC_SERVE_DOMAIN=akanjs.com (not localhost).",
|
|
143
|
+
);
|
|
144
|
+
} finally {
|
|
145
|
+
for (const key of keys) {
|
|
146
|
+
if (shellEnv[key] === undefined) delete process.env[key];
|
|
147
|
+
else process.env[key] = shellEnv[key];
|
|
148
|
+
}
|
|
149
|
+
await rm(root, { recursive: true, force: true });
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
|
|
114
153
|
test("a build for a real environment stops before any phase when the serve domain is only the local default", async () => {
|
|
115
154
|
const { root, workspace } = await createTempApp("portal");
|
|
116
155
|
await writeFile(path.join(root, ".env"), "AKAN_PUBLIC_REPO_NAME=repo\n");
|
|
@@ -18,6 +18,7 @@ import { Spinner } from "./spinner";
|
|
|
18
18
|
import { createServerEnvPlugin } from "./transforms/serverEnvPlugin";
|
|
19
19
|
import { ServerEnvProfile } from "./transforms/serverEnvProfile";
|
|
20
20
|
import { ProjectTypecheck } from "./typecheck/projectTypecheck";
|
|
21
|
+
import type { BaseDevEnv } from "./types";
|
|
21
22
|
|
|
22
23
|
export interface TypecheckOptions {
|
|
23
24
|
clean?: boolean;
|
|
@@ -97,14 +98,34 @@ export class ApplicationBuildRunner {
|
|
|
97
98
|
return path.join(this.#app.cwdPath, "env");
|
|
98
99
|
}
|
|
99
100
|
|
|
101
|
+
//? Bundles bake AKAN_PUBLIC_* from process.env, where the shell outranks the .env that the server env and the
|
|
102
|
+
//? Dockerfile read: a page built otherwise drops the image's tokens as foreign or dials another serve domain.
|
|
103
|
+
#pinImageEnv(baseDevEnv: BaseDevEnv, environment: string) {
|
|
104
|
+
const imageEnv = {
|
|
105
|
+
AKAN_PUBLIC_ENV: environment,
|
|
106
|
+
AKAN_PUBLIC_REPO_NAME: baseDevEnv.repoName,
|
|
107
|
+
AKAN_PUBLIC_SERVE_DOMAIN: baseDevEnv.serveDomain,
|
|
108
|
+
};
|
|
109
|
+
const outranked = Object.entries(imageEnv).filter(([key, value]) => {
|
|
110
|
+
const given = process.env[key];
|
|
111
|
+
return !!given && given !== value && !(key === "AKAN_PUBLIC_ENV" && this.#environment);
|
|
112
|
+
});
|
|
113
|
+
if (outranked.length)
|
|
114
|
+
this.#app.logger.warn(
|
|
115
|
+
`The workspace .env outranks this process's environment: the bundles and the image take ${outranked
|
|
116
|
+
.map(([key, value]) => `${key}=${value} (not ${process.env[key]})`)
|
|
117
|
+
.join(", ")}.`,
|
|
118
|
+
);
|
|
119
|
+
Object.assign(process.env, imageEnv);
|
|
120
|
+
}
|
|
121
|
+
|
|
100
122
|
async build({ spinner = false }: BuildOptions = {}): Promise<BuildResult> {
|
|
101
123
|
// serial build is needed because of Bun.build is unstable for parallel build
|
|
102
124
|
this.#spinner = spinner;
|
|
103
125
|
const { web, assets, baseDevEnv } = await this.#app.getConfig();
|
|
104
126
|
const environment = this.#environment ?? baseDevEnv.env;
|
|
105
127
|
WorkspaceExecutor.assertDeployableEnv(baseDevEnv, environment, "akan build");
|
|
106
|
-
|
|
107
|
-
process.env.AKAN_PUBLIC_ENV = environment;
|
|
128
|
+
this.#pinImageEnv(baseDevEnv, environment);
|
|
108
129
|
//? Before any phase: a profile that would ship a secret stops the build, not the image after it.
|
|
109
130
|
if (this.#profile)
|
|
110
131
|
await ServerEnvProfile.assertShippable({ envDir: this.#envDir, environment, profile: this.#profile });
|
|
@@ -36,7 +36,9 @@ describe("resolveSsrPageEntries", () => {
|
|
|
36
36
|
const generatedSource = await Bun.file(groupedRoot?.moduleAbsPath ?? "").text();
|
|
37
37
|
// Both user modules go through the route resolver, so a `rootLayout()` chain and the legacy exports read alike.
|
|
38
38
|
expect(generatedSource).toContain('import * as inheritedModule from "../../../page/_layout.tsx";');
|
|
39
|
-
expect(generatedSource).toContain(
|
|
39
|
+
expect(generatedSource).toContain(
|
|
40
|
+
'const inheritedLayout = assertGuardOnChain("page/_layout.tsx", resolveRouteModule(inheritedModule as never',
|
|
41
|
+
);
|
|
40
42
|
expect(generatedSource).not.toContain("<System.Provider");
|
|
41
43
|
expect(generatedSource).toContain("export async function generateHead(props: PageProps)");
|
|
42
44
|
// The ancestor root is a level of the route tree, which merges its head; this boundary returns only its own.
|
|
@@ -48,6 +50,9 @@ describe("resolveSsrPageEntries", () => {
|
|
|
48
50
|
expect(generatedSource).toContain("export const pageConfig = userLayout.pageConfig ?? inheritedLayout.pageConfig;");
|
|
49
51
|
// The ancestor root is its own level of the stack, so inheriting its loading would nest it inside itself.
|
|
50
52
|
expect(generatedSource).toContain("export const Loading = userLayout.Loading;");
|
|
53
|
+
// Its guard runs from that level too, ahead of this one, and an MCP view takes its providers from there.
|
|
54
|
+
expect(generatedSource).toContain("export const guard = userLayout.guard;");
|
|
55
|
+
expect(generatedSource).not.toContain("McpAppRoot");
|
|
51
56
|
expect(generatedSource).toContain(
|
|
52
57
|
"<UserLayout params={params} searchParams={searchParams}>{children}</UserLayout>",
|
|
53
58
|
);
|
|
@@ -77,6 +82,11 @@ describe("resolveSsrPageEntries", () => {
|
|
|
77
82
|
expect(generatedSource).toContain("export const viewport = userLayout.viewport ?? inheritedLayout.viewport;");
|
|
78
83
|
expect(generatedSource).toContain("viewport={viewport}");
|
|
79
84
|
expect(generatedSource).toContain("[inheritedLayout, userLayout].map((layout)");
|
|
85
|
+
expect(generatedSource).toContain(
|
|
86
|
+
"export const guard = RouteGuards.compose(inheritedLayout.guard, userLayout.guard);",
|
|
87
|
+
);
|
|
88
|
+
expect(generatedSource).toContain("export function McpAppRoot({ children, params, path }: McpAppRootProps) {");
|
|
89
|
+
expect(generatedSource).toContain("<System.App");
|
|
80
90
|
expect(generatedSource).toContain('import { allDictionary } from "../dict/useDict.ts";');
|
|
81
91
|
expect(generatedSource).toContain(
|
|
82
92
|
'allDictionary={process.env.AKAN_PUBLIC_RENDER_ENV === "ssr" ? allDictionary : undefined}',
|
|
@@ -16,8 +16,11 @@ const IMPLICIT_DICT_DIR = path.join(".akan", "generated", "dict");
|
|
|
16
16
|
const IMPLICIT_OVERRIDES_DIR = path.join(".akan", "generated", "overrides");
|
|
17
17
|
const OVERRIDES_KEY_RE = /^\.\/(.+\/)?_overrides\.(tsx|ts|jsx|js)$/;
|
|
18
18
|
|
|
19
|
+
const posixRelative = (fromDir: string, toAbsPath: string) =>
|
|
20
|
+
path.relative(fromDir, toAbsPath).split(path.sep).join("/");
|
|
21
|
+
|
|
19
22
|
const importSpecifier = (fromAbsPath: string, toAbsPath: string) => {
|
|
20
|
-
const rel =
|
|
23
|
+
const rel = posixRelative(path.dirname(fromAbsPath), toAbsPath);
|
|
21
24
|
return rel.startsWith(".") ? rel : `./${rel}`;
|
|
22
25
|
};
|
|
23
26
|
|
|
@@ -162,14 +165,14 @@ async function writeGeneratedRootLayoutFile(opts: {
|
|
|
162
165
|
? `import { st } from "@apps/${opts.appName}/client";\nvoid st;\n`
|
|
163
166
|
: `import "@apps/${opts.appName}/client";\n`;
|
|
164
167
|
//? resolveRouteModule reads a rootLayout() chain and the legacy named exports alike.
|
|
165
|
-
const inheritedLabel = inheritedSourceAbsPath ?
|
|
168
|
+
const inheritedLabel = inheritedSourceAbsPath ? posixRelative(opts.appCwdPath, inheritedSourceAbsPath) : "";
|
|
166
169
|
const inheritedImport = inheritedSourceSpecifier
|
|
167
|
-
? `import * as inheritedModule from ${JSON.stringify(inheritedSourceSpecifier)};\nconst inheritedLayout = resolveRouteModule(inheritedModule as never, ${JSON.stringify(inheritedLabel)}).module as LayoutModule;\n`
|
|
170
|
+
? `import * as inheritedModule from ${JSON.stringify(inheritedSourceSpecifier)};\nconst inheritedLayout = assertGuardOnChain(${JSON.stringify(inheritedLabel)}, resolveRouteModule(inheritedModule as never, ${JSON.stringify(inheritedLabel)})).module as LayoutModule;\n`
|
|
168
171
|
: "const inheritedLayout: LayoutModule = {};\n";
|
|
169
172
|
const prefix = opts.boundary.segments.find((segment) => !/^\(.+\)$/.test(segment)) ?? null;
|
|
170
|
-
const userLabel = opts.boundary.sourceAbsPath ?
|
|
173
|
+
const userLabel = opts.boundary.sourceAbsPath ? posixRelative(opts.appCwdPath, opts.boundary.sourceAbsPath) : "";
|
|
171
174
|
const userImport = sourceSpecifier
|
|
172
|
-
? `import * as userModule from ${JSON.stringify(sourceSpecifier)};\nconst userLayout = resolveRouteModule(userModule as never, ${JSON.stringify(userLabel)}).module as LayoutModule;\nconst UserLayout = userLayout.default as (props: LayoutProps) => ReactNode | Promise<ReactNode>;\n`
|
|
175
|
+
? `import * as userModule from ${JSON.stringify(sourceSpecifier)};\nconst userLayout = assertGuardOnChain(${JSON.stringify(userLabel)}, resolveRouteModule(userModule as never, ${JSON.stringify(userLabel)})).module as LayoutModule;\nconst UserLayout = userLayout.default as (props: LayoutProps) => ReactNode | Promise<ReactNode>;\n`
|
|
173
176
|
: "const UserLayout = ({ children }: LayoutProps) => children;\nconst userLayout: LayoutModule = {};\n";
|
|
174
177
|
const isAsyncUserLayout = opts.boundary.sourceAbsPath
|
|
175
178
|
? await AsyncDefaultExportDetector.detect(opts.boundary.sourceAbsPath)
|
|
@@ -191,6 +194,10 @@ async function writeGeneratedRootLayoutFile(opts: {
|
|
|
191
194
|
const layoutReturn = isAsyncUserLayout ? "layout" : userLayoutElement;
|
|
192
195
|
//? Under an ancestor root boundary the route tree merges that root's head itself; merging it here would repeat it.
|
|
193
196
|
const headLayouts = opts.includeSystemProvider ? "[inheritedLayout, userLayout]" : "[userLayout]";
|
|
197
|
+
//? Likewise for the guard: an ancestor root boundary runs its own from its own layer.
|
|
198
|
+
const guardExport = opts.includeSystemProvider
|
|
199
|
+
? "export const guard = RouteGuards.compose(inheritedLayout.guard, userLayout.guard);"
|
|
200
|
+
: "export const guard = userLayout.guard;";
|
|
194
201
|
const layoutExports = `export async function generateHead(props: PageProps) {
|
|
195
202
|
const heads = await Promise.all(
|
|
196
203
|
${headLayouts}.map((layout) => (layout.generateHead ? layout.generateHead(props) : layout.head)),
|
|
@@ -202,14 +209,16 @@ export const NotFound = userLayout.NotFound ?? inheritedLayout.NotFound;
|
|
|
202
209
|
export const Error = userLayout.Error ?? inheritedLayout.Error;
|
|
203
210
|
export const pageConfig = userLayout.pageConfig ?? inheritedLayout.pageConfig;
|
|
204
211
|
export const Loading = userLayout.Loading;
|
|
212
|
+
${guardExport}
|
|
205
213
|
|
|
206
214
|
${layoutSignature}({ children, params, searchParams }: LayoutProps) {
|
|
207
215
|
${layoutBinding} return `;
|
|
208
216
|
const source = opts.includeSystemProvider
|
|
209
217
|
? `import type { LayoutModule, LayoutProps, PageProps } from "akanjs/client";
|
|
210
|
-
import { loadFonts, resolveRouteModule, RouteHead } from "akanjs/client";
|
|
218
|
+
import { assertGuardOnChain, loadFonts, RouteGuards, resolveRouteModule, RouteHead } from "akanjs/client";
|
|
211
219
|
import type { ReactNode } from "react";
|
|
212
220
|
import { System } from "akanjs/ui";
|
|
221
|
+
import type { McpAppRootProps } from "akanjs/webkit";
|
|
213
222
|
import { env } from "@apps/${opts.appName}/env/env.client";
|
|
214
223
|
import { allDictionary } from ${JSON.stringify(dictMacroSpecifier)};
|
|
215
224
|
${clientImport}${inheritedImport}${userImport}
|
|
@@ -242,9 +251,25 @@ ${layoutExports}(
|
|
|
242
251
|
</System.Provider>
|
|
243
252
|
);
|
|
244
253
|
}
|
|
254
|
+
|
|
255
|
+
export function McpAppRoot({ children, params, path }: McpAppRootProps) {
|
|
256
|
+
return (
|
|
257
|
+
<System.App
|
|
258
|
+
params={params}
|
|
259
|
+
path={path}
|
|
260
|
+
env={env}
|
|
261
|
+
theme={theme}
|
|
262
|
+
viewport={viewport}
|
|
263
|
+
fonts={loadFonts(userFonts)}
|
|
264
|
+
className={defaultFontClassName}
|
|
265
|
+
>
|
|
266
|
+
{children}
|
|
267
|
+
</System.App>
|
|
268
|
+
);
|
|
269
|
+
}
|
|
245
270
|
`
|
|
246
271
|
: `import type { LayoutModule, LayoutProps, PageProps } from "akanjs/client";
|
|
247
|
-
import { resolveRouteModule, RouteHead } from "akanjs/client";
|
|
272
|
+
import { assertGuardOnChain, resolveRouteModule, RouteHead } from "akanjs/client";
|
|
248
273
|
import type { ReactNode } from "react";
|
|
249
274
|
${inheritedImport}${userImport}
|
|
250
275
|
${layoutExports}${layoutReturn};
|
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
|
},
|
|
@@ -195,7 +196,10 @@
|
|
|
195
196
|
},
|
|
196
197
|
{
|
|
197
198
|
"includes": ["{apps,libs}/**/*.tsx", "!**/*.test.tsx", "!**/*.spec.tsx"],
|
|
198
|
-
"plugins": [
|
|
199
|
+
"plugins": [
|
|
200
|
+
"./node_modules/@akanjs/devkit/lint/no-unpublished-form-setter.grit",
|
|
201
|
+
"./node_modules/@akanjs/devkit/lint/no-conditional-st-hook.grit"
|
|
202
|
+
]
|
|
199
203
|
},
|
|
200
204
|
{
|
|
201
205
|
"includes": [
|
|
@@ -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);
|
|
@@ -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
|
-
|
|
376
|
-
|
|
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:
|
|
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/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", () => {
|