@akanjs/devkit 3.2.0-rc.0 → 3.2.0-rc.10
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 +238 -11
- package/agentAssets.ts +1 -1
- package/akanApp/akanApp.host.test.ts +127 -0
- package/akanApp/akanApp.host.ts +50 -57
- package/akanApp/devHostPolicy.test.ts +11 -11
- package/akanApp/devHostPolicy.ts +2 -4
- package/akanContext.ts +2 -5
- package/akanMcpContract.ts +21 -10
- package/akanMcpToolError.ts +16 -0
- package/apiReport/apiDeclarationPrinter.ts +120 -0
- package/apiReport/apiReport.test.ts +497 -0
- package/apiReport/apiReport.ts +232 -0
- package/apiReport/apiReportDiff.ts +126 -0
- package/apiReport/apiReportPolicy.ts +65 -0
- package/apiReport/apiTargets.ts +311 -0
- package/apiReport/changesetBump.ts +19 -0
- package/applicationBuildReporter.test.ts +21 -0
- package/applicationBuildReporter.ts +8 -0
- package/applicationBuildRunner.test.ts +67 -0
- package/applicationBuildRunner.ts +6 -1
- package/artifact/implicitRootLayout.test.ts +4 -0
- package/artifact/implicitRootLayout.ts +13 -1
- package/artifact/storeBootstrap.ts +3 -2
- package/bareErrorScanner.ts +126 -0
- package/biome.base.json +2 -1
- package/codeAgent/skills/akan-debug/SKILL.md +7 -3
- package/codeAgent/skills/akan-err/SKILL.md +7 -0
- package/codeAgent/skills/akan-mcp/SKILL.md +7 -1
- package/codeAgent/skills/akan-scaffold/SKILL.md +3 -2
- package/codeAgent/skills/akan-transport/SKILL.md +2 -0
- package/codeAgent/skills/akan-validate/SKILL.md +1 -0
- package/codeAgent/tools/McpClient.ts +28 -6
- package/codeAgent/tools/McpToolPack.ts +1 -1
- package/codeAgent/tools/akanCodePlugins.test.ts +13 -1
- package/commandDecorators/argMeta.ts +4 -0
- package/commandDecorators/cliError.ts +72 -0
- package/commandDecorators/command.ts +186 -96
- package/commandDecorators/commandDecorators.test.ts +240 -17
- package/commandDecorators/exitCodeError.ts +6 -7
- package/commandDecorators/index.ts +1 -0
- package/commandDecorators/targetMeta.ts +3 -0
- package/conventionSet.test.ts +5 -2
- package/createTunnel.test.ts +12 -2
- package/csrE2e/csrE2eHarness.fixture.ts +1 -1
- package/errorCodeReference.test.ts +282 -0
- package/errorCodeReference.ts +71 -0
- package/errorCodeScan.ts +551 -0
- package/frontendBuild/allRoutesBuilder.test.ts +16 -4
- package/frontendBuild/allRoutesBuilder.ts +24 -6
- package/frontendBuild/buildRouteClient.test.ts +43 -0
- package/frontendBuild/bundleDefine.ts +2 -0
- package/frontendBuild/clientBuildTypes.ts +5 -2
- package/frontendBuild/clientEntryDiscovery.test.ts +80 -0
- package/frontendBuild/clientEntryDiscovery.ts +53 -9
- package/frontendBuild/csrArtifactBuilder.test.ts +48 -0
- package/frontendBuild/csrArtifactBuilder.ts +9 -3
- package/frontendBuild/csrDevBundler.ts +12 -2
- package/frontendBuild/csrDevModuleCompiler.test.ts +97 -0
- package/frontendBuild/csrDevModuleCompiler.ts +37 -8
- package/frontendBuild/csrDevPatcher.test.ts +89 -2
- package/frontendBuild/csrDevPatcher.ts +47 -7
- package/frontendBuild/csrDevTypes.ts +16 -0
- package/frontendBuild/cssCompiler.test.ts +63 -1
- package/frontendBuild/devBarrelImports.ts +132 -0
- package/frontendBuild/devRegistryBundler.ts +19 -4
- package/frontendBuild/mcpAppViewBuilder.test.ts +67 -0
- package/frontendBuild/nativeCore.test.ts +148 -0
- package/frontendBuild/precompressArtifacts.ts +1 -1
- package/frontendBuild/ssrBaseArtifactBuilder.test.ts +26 -0
- package/frontendBuild/ssrBaseArtifactBuilder.ts +23 -19
- package/frontendBuild/ssrDevBundler.ts +13 -2
- package/frontendBuild/vendorImportScanner.test.ts +24 -3
- package/frontendBuild/vendorImportScanner.ts +25 -10
- package/incrementalBuilder/incrementalBuilder.host.test.ts +30 -1
- package/incrementalBuilder/incrementalBuilder.host.ts +10 -22
- package/lint/__fixtures__/no-import-common-err/bad.tsx +13 -0
- package/lint/__fixtures__/no-import-common-err/good.tsx +12 -0
- package/lint/no-import-common-err.grit +31 -0
- package/lint/no-init-fetch-in-client.grit +1 -1
- package/lint/no-web-only-api-outside-webkit.grit +3 -3
- package/maintainabilityBaseline.test.ts +49 -0
- package/maintainabilityBaseline.ts +73 -8
- package/maintainabilityContract.test.ts +302 -1
- package/maintainabilityContract.ts +87 -22
- package/maintainabilityExceptionScanner.test.ts +14 -0
- package/maintainabilityExceptionScanner.ts +5 -1
- package/maintainabilityLimits.ts +12 -0
- package/maintainabilityReport.ts +20 -3
- package/moduleFieldNames.test.ts +56 -0
- package/moduleFieldNames.ts +25 -0
- package/package.json +3 -3
- package/preview/PreviewChrome.ts +3 -3
- package/preview/PreviewView.ts +48 -9
- package/preview/previewView.test.ts +154 -3
- package/routeJsBudget.test.ts +47 -11
- package/routeJsBudget.ts +88 -25
- package/transforms/barrelAnalyzer.ts +46 -1
- package/transforms/barrelImportsPlugin.ts +61 -45
- package/transforms/transforms.test.ts +67 -1
- package/typecheck/projectTypecheck.ts +5 -1
- package/typecheck/typecheck.proc.ts +3 -2
- package/typecheck/typecheckFailure.ts +6 -1
- package/workflow/artifacts.ts +25 -7
- package/workflow/fieldType.ts +92 -0
- package/workflow/index.ts +2 -0
- package/workflow/plainName.ts +28 -0
- package/workflow/plan.ts +67 -22
- package/workflow/source.ts +0 -5
- package/workspaceGraph.test.ts +200 -3
- package/workspaceGraph.ts +46 -8
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import { AppExecutor, LibExecutor, PkgExecutor, WorkspaceExecutor } from "../executors";
|
|
3
|
+
import { AppExecutor, LibExecutor, ModuleExecutor, PkgExecutor, WorkspaceExecutor } from "../executors";
|
|
4
4
|
import { makeCliTempWorkspace, tempRoots, writeText } from "../testHelpers";
|
|
5
5
|
import {
|
|
6
6
|
App,
|
|
@@ -440,6 +440,7 @@ describe("enum arg type inference", () => {
|
|
|
440
440
|
|
|
441
441
|
describe("a Sys that defaults to the only app", () => {
|
|
442
442
|
const ownIsTTY = Object.getOwnPropertyDescriptor(process.stdin, "isTTY");
|
|
443
|
+
const ownCwd = process.cwd();
|
|
443
444
|
const asked: unknown[] = [];
|
|
444
445
|
let answer = "";
|
|
445
446
|
const setTTY = (isTTY: boolean | undefined) =>
|
|
@@ -455,6 +456,7 @@ describe("a Sys that defaults to the only app", () => {
|
|
|
455
456
|
});
|
|
456
457
|
afterEach(() => {
|
|
457
458
|
CommandContainer.clear();
|
|
459
|
+
process.chdir(ownCwd);
|
|
458
460
|
if (ownIsTTY) Object.defineProperty(process.stdin, "isTTY", ownIsTTY);
|
|
459
461
|
else Reflect.deleteProperty(process.stdin, "isTTY");
|
|
460
462
|
});
|
|
@@ -476,6 +478,21 @@ describe("a Sys that defaults to the only app", () => {
|
|
|
476
478
|
if (!meta) throw new Error(`no Sys meta on ${key}`);
|
|
477
479
|
return meta;
|
|
478
480
|
};
|
|
481
|
+
const track = tempRoots();
|
|
482
|
+
//? From the workspace root, as the CLI runs: getDirInModule stats its entries relative to the process directory.
|
|
483
|
+
const moduleWorkspace = async () => {
|
|
484
|
+
const { root, workspace } = track(await makeCliTempWorkspace());
|
|
485
|
+
process.chdir(root);
|
|
486
|
+
for (const [sys, name] of [
|
|
487
|
+
["apps/web", "card"],
|
|
488
|
+
["libs/util", "photo"],
|
|
489
|
+
] as const) {
|
|
490
|
+
await writeText(path.join(root, sys, "akan.config.ts"), "export default {};\n");
|
|
491
|
+
await writeText(path.join(root, sys, "lib", name, `${name}.constant.ts`), "export {};\n");
|
|
492
|
+
}
|
|
493
|
+
await writeText(path.join(root, "apps/web/lib/_billing/billing.service.ts"), "export {};\n");
|
|
494
|
+
return workspace;
|
|
495
|
+
};
|
|
479
496
|
|
|
480
497
|
test("the opted-in command takes a one-app workspace's app without asking, even on a piped stdin", async () => {
|
|
481
498
|
setTTY(undefined);
|
|
@@ -525,19 +542,24 @@ describe("a Sys that defaults to the only app", () => {
|
|
|
525
542
|
expect(asked).toEqual([]);
|
|
526
543
|
});
|
|
527
544
|
|
|
528
|
-
test("without a terminal, the only lib, pkg or module
|
|
545
|
+
test("without a terminal, a command that deletes nothing takes the only lib, pkg or module; a terminal is asked", async () => {
|
|
529
546
|
setTTY(undefined);
|
|
530
547
|
const workspace = workspaceWith([]);
|
|
531
548
|
workspace.getExecs = async () => [[], ["util"], []];
|
|
532
549
|
workspace.getPkgs = async () => ["tools"];
|
|
533
|
-
|
|
534
|
-
await
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
550
|
+
workspace.getDirInModule = async (basePath) => (basePath === "libs/util/lib" ? ["photo"] : []);
|
|
551
|
+
const lib = await getInternalArgumentValue({ key: "", idx: 0, type: "Lib" }, undefined, workspace);
|
|
552
|
+
expect(lib instanceof LibExecutor ? lib.name : null).toBe("util");
|
|
553
|
+
const pkg = await getInternalArgumentValue({ key: "", idx: 0, type: "Pkg" }, undefined, workspace);
|
|
554
|
+
expect(pkg instanceof PkgExecutor ? pkg.name : null).toBe("tools");
|
|
555
|
+
const mod = await getInternalArgumentValue({ key: "", idx: 0, type: "Module" }, undefined, workspace);
|
|
556
|
+
expect(mod instanceof ModuleExecutor ? `${mod.sys.name}:${mod.name}` : null).toBe("util:photo");
|
|
540
557
|
expect(asked).toEqual([]);
|
|
558
|
+
|
|
559
|
+
setTTY(true);
|
|
560
|
+
answer = "util";
|
|
561
|
+
await getInternalArgumentValue({ key: "", idx: 0, type: "Lib" }, undefined, workspace);
|
|
562
|
+
expect(asked).toEqual([["util"]]);
|
|
541
563
|
});
|
|
542
564
|
|
|
543
565
|
test("without a terminal, a named target the workspace lacks is refused, not swapped for the only one", async () => {
|
|
@@ -551,6 +573,106 @@ describe("a Sys that defaults to the only app", () => {
|
|
|
551
573
|
);
|
|
552
574
|
expect(asked).toEqual([]);
|
|
553
575
|
});
|
|
576
|
+
|
|
577
|
+
test("a destructive command never takes the only app, lib, pkg or module, nor asks, terminal or not", async () => {
|
|
578
|
+
const workspace = workspaceWith([]);
|
|
579
|
+
workspace.getExecs = async () => [["web"], ["util"], []];
|
|
580
|
+
workspace.getPkgs = async () => ["tools"];
|
|
581
|
+
workspace.getDirInModule = async (basePath) => (basePath === "apps/web/lib" ? ["pokemon"] : []);
|
|
582
|
+
const refusedAs = (rerun: string, choices: string) =>
|
|
583
|
+
`: this command deletes files and never picks its target, so rerun it as \`${rerun}\` (choices: ${choices}).`;
|
|
584
|
+
const resolve = (type: InternalArgMeta["type"], commandName: string, operands: (string | undefined)[]) =>
|
|
585
|
+
getInternalArgumentValue(
|
|
586
|
+
{ key: "", idx: operands.length - 1, type },
|
|
587
|
+
operands.at(-1),
|
|
588
|
+
workspace,
|
|
589
|
+
rerunCommandOf(
|
|
590
|
+
[commandName, ...operands.filter((operand): operand is string => operand !== undefined)],
|
|
591
|
+
operands,
|
|
592
|
+
operands.length - 1,
|
|
593
|
+
),
|
|
594
|
+
{ destructive: true },
|
|
595
|
+
);
|
|
596
|
+
for (const isTTY of [true, undefined]) {
|
|
597
|
+
setTTY(isTTY);
|
|
598
|
+
answer = "web";
|
|
599
|
+
await expect(resolve("App", "remove-application", [undefined])).rejects.toThrow(
|
|
600
|
+
`Missing app${refusedAs("akan remove-application <app>", "web")}`,
|
|
601
|
+
);
|
|
602
|
+
await expect(resolve("Lib", "remove-library", [undefined])).rejects.toThrow(
|
|
603
|
+
`Missing lib${refusedAs("akan remove-library <lib>", "util")}`,
|
|
604
|
+
);
|
|
605
|
+
await expect(resolve("Pkg", "remove-package", [undefined])).rejects.toThrow(
|
|
606
|
+
`Missing pkg${refusedAs("akan remove-package <pkg>", "tools")}`,
|
|
607
|
+
);
|
|
608
|
+
await expect(resolve("Module", "remove-module", [undefined])).rejects.toThrow(
|
|
609
|
+
`Missing module${refusedAs("akan remove-module <sys:module>", "web:pokemon")}`,
|
|
610
|
+
);
|
|
611
|
+
await expect(resolve("Sys", "remove-scalar", ["place", undefined])).rejects.toThrow(
|
|
612
|
+
`Missing app or lib${refusedAs("akan remove-scalar place <app-or-lib>", "web, util")}`,
|
|
613
|
+
);
|
|
614
|
+
await expect(resolve("App", "remove-application", ["webb"])).rejects.toThrow(
|
|
615
|
+
`Unknown app 'webb'${refusedAs("akan remove-application <app>", "web")}`,
|
|
616
|
+
);
|
|
617
|
+
}
|
|
618
|
+
expect(asked).toEqual([]);
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
test("a destructive command takes the target it is named, while another still takes the only app", async () => {
|
|
622
|
+
setTTY(undefined);
|
|
623
|
+
const workspace = workspaceWith([]);
|
|
624
|
+
workspace.getExecs = async () => [["web"], [], []];
|
|
625
|
+
const named = await getInternalArgumentValue({ key: "", idx: 0, type: "App" }, "web", workspace, undefined, {
|
|
626
|
+
destructive: true,
|
|
627
|
+
});
|
|
628
|
+
expect(named instanceof AppExecutor ? named.name : null).toBe("web");
|
|
629
|
+
const taken = await getInternalArgumentValue(sysMetaOf("typecheck"), undefined, workspace);
|
|
630
|
+
expect(taken instanceof AppExecutor ? taken.name : null).toBe("web");
|
|
631
|
+
expect(asked).toEqual([]);
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
test("a module an app or a lib lacks is refused with the modules there are, in a terminal too", async () => {
|
|
635
|
+
setTTY(true);
|
|
636
|
+
answer = "util";
|
|
637
|
+
const workspace = await moduleWorkspace();
|
|
638
|
+
for (const value of ["util:nope", "web:nope"]) {
|
|
639
|
+
const sysName = value.split(":")[0];
|
|
640
|
+
await expect(
|
|
641
|
+
getInternalArgumentValue(
|
|
642
|
+
{ key: "", idx: 0, type: "Module" },
|
|
643
|
+
value,
|
|
644
|
+
workspace,
|
|
645
|
+
rerunCommandOf(["create-view", value], [value], 0),
|
|
646
|
+
),
|
|
647
|
+
).rejects.toThrow(
|
|
648
|
+
`Unknown module '${value}': ${sysName} has no such module, so rerun it as \`akan create-view <sys:module>\` (choices: web:card, util:photo).`,
|
|
649
|
+
);
|
|
650
|
+
}
|
|
651
|
+
expect(asked).toEqual([]);
|
|
652
|
+
const photo = await getInternalArgumentValue({ key: "", idx: 0, type: "Module" }, "util:photo", workspace);
|
|
653
|
+
expect(photo instanceof ModuleExecutor ? `${photo.sys.name}:${photo.name}` : null).toBe("util:photo");
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
test("a service module is taken by its exact name where the target opts in, as remove-module does", async () => {
|
|
657
|
+
setTTY(true);
|
|
658
|
+
const workspace = await moduleWorkspace();
|
|
659
|
+
const services = { key: "", idx: 0, type: "Module", option: { services: true } } as const;
|
|
660
|
+
const billing = await getInternalArgumentValue(services, "web:_billing", workspace, undefined, {
|
|
661
|
+
destructive: true,
|
|
662
|
+
});
|
|
663
|
+
expect(billing instanceof ModuleExecutor ? `${billing.sys.name}:${billing.name}` : null).toBe("web:_billing");
|
|
664
|
+
await expect(
|
|
665
|
+
getInternalArgumentValue(services, undefined, workspace, rerunCommandOf(["remove-module"], [undefined], 0), {
|
|
666
|
+
destructive: true,
|
|
667
|
+
}),
|
|
668
|
+
).rejects.toThrow(
|
|
669
|
+
"so rerun it as `akan remove-module <sys:module>` (choices: web:card, web:_billing, util:photo).",
|
|
670
|
+
);
|
|
671
|
+
await expect(
|
|
672
|
+
getInternalArgumentValue({ key: "", idx: 0, type: "Module" }, "web:_billing", workspace),
|
|
673
|
+
).rejects.toThrow("Unknown module 'web:_billing': web has no such module");
|
|
674
|
+
expect(asked).toEqual([]);
|
|
675
|
+
});
|
|
554
676
|
});
|
|
555
677
|
|
|
556
678
|
describe("the line a refused target is rerun with", () => {
|
|
@@ -573,7 +695,7 @@ describe("the line a refused target is rerun with", () => {
|
|
|
573
695
|
|
|
574
696
|
describe("a target left out where no terminal can ask for it", () => {
|
|
575
697
|
const track = tempRoots();
|
|
576
|
-
const run = async (apps: string[]) => {
|
|
698
|
+
const run = async (apps: string[], args = ["create-thing", "photo", "--force"]) => {
|
|
577
699
|
const { root } = track(await makeCliTempWorkspace());
|
|
578
700
|
await writeText(path.join(root, "package.json"), '{ "name": "repo", "version": "1.0.0" }\n');
|
|
579
701
|
await writeText(path.join(root, "tsconfig.json"), "{}\n");
|
|
@@ -582,7 +704,7 @@ describe("a target left out where no terminal can ask for it", () => {
|
|
|
582
704
|
await writeText(
|
|
583
705
|
entry,
|
|
584
706
|
[
|
|
585
|
-
`import { command, runCommands, Sys } from ${JSON.stringify(path.join(import.meta.dir, "index.ts"))};`,
|
|
707
|
+
`import { App, command, runCommands, Sys } from ${JSON.stringify(path.join(import.meta.dir, "index.ts"))};`,
|
|
586
708
|
'class ThingCommand extends command("thing", ({ public: target }) => ({',
|
|
587
709
|
' createThing: target({ desc: "creates a thing" })',
|
|
588
710
|
' .arg("thing", String, { desc: "name of the thing" })',
|
|
@@ -591,12 +713,17 @@ describe("a target left out where no terminal can ask for it", () => {
|
|
|
591
713
|
" .exec(async function (thing, sys, force) {",
|
|
592
714
|
' process.stdout.write(thing + " in " + sys.name + ", force " + force + "\\n");',
|
|
593
715
|
" }),",
|
|
716
|
+
' removeThing: target({ desc: "removes an app", destructive: true })',
|
|
717
|
+
" .with(App)",
|
|
718
|
+
" .exec(async function (app) {",
|
|
719
|
+
' process.stdout.write("removed " + app.name + "\\n");',
|
|
720
|
+
" }),",
|
|
594
721
|
"})) {}",
|
|
595
722
|
"await runCommands(ThingCommand);",
|
|
596
723
|
"",
|
|
597
724
|
].join("\n"),
|
|
598
725
|
);
|
|
599
|
-
const proc = Bun.spawn(["bun", entry,
|
|
726
|
+
const proc = Bun.spawn(["bun", entry, ...args], {
|
|
600
727
|
cwd: root,
|
|
601
728
|
stdin: "ignore",
|
|
602
729
|
stdout: "pipe",
|
|
@@ -621,18 +748,31 @@ describe("a target left out where no terminal can ask for it", () => {
|
|
|
621
748
|
|
|
622
749
|
test("exits at once with the choices and the line to rerun when there are several", async () => {
|
|
623
750
|
const refused = await run(["web", "admin"]);
|
|
624
|
-
expect(refused.code).toBe(
|
|
751
|
+
expect(refused.code).toBe(2);
|
|
625
752
|
expect(refused.stdout).toContain(
|
|
626
753
|
"Missing app or lib: stdin is not a terminal to prompt on, so rerun it as `akan create-thing photo <app-or-lib> --force`",
|
|
627
754
|
);
|
|
628
755
|
expect(refused.stdout).toMatch(/\(choices: (web, admin|admin, web)\)\./);
|
|
629
756
|
expect(refused.stdout).not.toContain("photo in");
|
|
630
757
|
}, 30_000);
|
|
758
|
+
|
|
759
|
+
test("a destructive command exits with the line to rerun even when the workspace has one app", async () => {
|
|
760
|
+
const refused = await run(["web"], ["remove-thing"]);
|
|
761
|
+
expect(refused.code).toBe(2);
|
|
762
|
+
expect(refused.stdout).toContain(
|
|
763
|
+
"Missing app: this command deletes files and never picks its target, so rerun it as `akan remove-thing <app>` (choices: web).",
|
|
764
|
+
);
|
|
765
|
+
expect(refused.stdout).not.toContain("removed");
|
|
766
|
+
expect((await run(["web"], ["remove-thing", "web"])).stdout).toContain("removed web\n");
|
|
767
|
+
}, 30_000);
|
|
631
768
|
});
|
|
632
769
|
|
|
633
770
|
describe("the exit code of a command that failed", () => {
|
|
634
771
|
const track = tempRoots();
|
|
635
|
-
const run = async (
|
|
772
|
+
const run = async (
|
|
773
|
+
failure: string,
|
|
774
|
+
{ option = "", args = [], argv = ["verdict", ...args] }: { option?: string; args?: string[]; argv?: string[] } = {},
|
|
775
|
+
) => {
|
|
636
776
|
const { root } = track(await makeCliTempWorkspace());
|
|
637
777
|
await writeText(path.join(root, "package.json"), '{ "name": "repo", "version": "1.0.0" }\n');
|
|
638
778
|
await writeText(path.join(root, "tsconfig.json"), "{}\n");
|
|
@@ -640,7 +780,8 @@ describe("the exit code of a command that failed", () => {
|
|
|
640
780
|
await writeText(
|
|
641
781
|
entry,
|
|
642
782
|
[
|
|
643
|
-
`import {
|
|
783
|
+
`import { AkanError } from ${JSON.stringify(Bun.resolveSync("akanjs/base", import.meta.dir))};`,
|
|
784
|
+
`import { CliError, command, ExitCodeError, markCliErrorPrinted, runCommands, runner, script } from ${JSON.stringify(path.join(import.meta.dir, "index.ts"))};`,
|
|
644
785
|
'class VerdictRunner extends runner("verdict") {}',
|
|
645
786
|
'class VerdictScript extends script("verdict", [VerdictRunner]) {}',
|
|
646
787
|
'class VerdictCommand extends command("verdict", [VerdictScript], ({ public: target }) => ({',
|
|
@@ -652,7 +793,7 @@ describe("the exit code of a command that failed", () => {
|
|
|
652
793
|
"",
|
|
653
794
|
].join("\n"),
|
|
654
795
|
);
|
|
655
|
-
const proc = Bun.spawn(["bun", entry,
|
|
796
|
+
const proc = Bun.spawn(["bun", entry, ...argv], { cwd: root, stdout: "pipe", stderr: "pipe" });
|
|
656
797
|
const [stdout, stderr, code] = await Promise.all([
|
|
657
798
|
new Response(proc.stdout).text(),
|
|
658
799
|
new Response(proc.stderr).text(),
|
|
@@ -668,6 +809,28 @@ describe("the exit code of a command that failed", () => {
|
|
|
668
809
|
expect((await run('new Error("broke")')).code).toBe(1);
|
|
669
810
|
}, 30_000);
|
|
670
811
|
|
|
812
|
+
test("is the table's for a CliError's code, and 1 for an AkanError of any other code", async () => {
|
|
813
|
+
const [checked, unknown, gone] = await Promise.all([
|
|
814
|
+
run('new CliError("checkFailed", "found problems")'),
|
|
815
|
+
run('new CliError("unknownTarget", "no app shop")'),
|
|
816
|
+
run('new AkanError("documentNotFound", "gone")'),
|
|
817
|
+
]);
|
|
818
|
+
expect([checked.code, unknown.code, gone.code]).toEqual([3, 2, 1]);
|
|
819
|
+
expect(checked.stdout).toContain("found problems");
|
|
820
|
+
}, 30_000);
|
|
821
|
+
|
|
822
|
+
test("is 2 for a flag commander refuses, a value outside the choices and an unknown command", async () => {
|
|
823
|
+
const choice = ' }).option("mode", String, { enum: ["a", "b"]';
|
|
824
|
+
const [flag, outside, unknownCommand] = await Promise.all([
|
|
825
|
+
run('new Error("broke")', { args: ["--nope"] }),
|
|
826
|
+
run('new Error("broke")', { option: choice, args: ["--mode", "c"] }),
|
|
827
|
+
run('new Error("broke")', { argv: ["verdikt"] }),
|
|
828
|
+
]);
|
|
829
|
+
expect([flag.code, outside.code, unknownCommand.code]).toEqual([2, 2, 2]);
|
|
830
|
+
expect(outside.stdout).toContain("option '--mode' argument 'c' is invalid");
|
|
831
|
+
expect(unknownCommand.stderr).toContain("unknown command 'verdikt'");
|
|
832
|
+
}, 30_000);
|
|
833
|
+
|
|
671
834
|
test("is usageExitCode for a flag commander refuses before the command runs, failureExitCode after", async () => {
|
|
672
835
|
const option = ", failureExitCode: 4, usageExitCode: 2";
|
|
673
836
|
expect((await run('new Error("broke")', { option, args: ["--nope"] })).code).toBe(2);
|
|
@@ -695,7 +858,7 @@ describe("the exit code of a command that failed", () => {
|
|
|
695
858
|
|
|
696
859
|
const thrown = await run('new Error("broke")', { option, args: ["--json"] });
|
|
697
860
|
expect(thrown.code).toBe(1);
|
|
698
|
-
expect(thrown.
|
|
861
|
+
expect(thrown.stderr).toContain("broke\n");
|
|
699
862
|
expect(lastOf(thrown.stdout)).toEqual({ message: "broke", usage: false, given: { json: true } });
|
|
700
863
|
|
|
701
864
|
const help = await run('new Error("broke")', { option, args: ["--help"] });
|
|
@@ -703,6 +866,66 @@ describe("the exit code of a command that failed", () => {
|
|
|
703
866
|
expect(help.stdout).not.toContain('"usage"');
|
|
704
867
|
}, 60_000);
|
|
705
868
|
|
|
869
|
+
test("with JSON asked for, ends stdout with ok, error and code, and prints the message to stderr", async () => {
|
|
870
|
+
const json = ' }).option("json", Boolean, { default: false';
|
|
871
|
+
const format = ' }).option("format", String, { default: "text", enum: ["text", "json"]';
|
|
872
|
+
const marked =
|
|
873
|
+
'(() => { const error = new CliError("checkFailed", "verify failed"); markCliErrorPrinted(error); return error; })()';
|
|
874
|
+
const [unknown, plain, refused, answered, text] = await Promise.all([
|
|
875
|
+
run('new CliError("unknownTarget", "no app shop")', { option: json, args: ["--json"] }),
|
|
876
|
+
run('new Error("broke")', { option: format, args: ["--format", "json"] }),
|
|
877
|
+
run('new Error("broke")', { option: json, args: ["--json", "--nope"] }),
|
|
878
|
+
run(marked, { option: format, args: ["--format", "json"] }),
|
|
879
|
+
run('new Error("broke")', { option: format }),
|
|
880
|
+
]);
|
|
881
|
+
expect(unknown.code).toBe(2);
|
|
882
|
+
expect(JSON.parse(unknown.stdout)).toEqual({ ok: false, error: "no app shop", code: "unknownTarget" });
|
|
883
|
+
expect(unknown.stderr).toContain("no app shop");
|
|
884
|
+
expect(plain.code).toBe(1);
|
|
885
|
+
expect(JSON.parse(plain.stdout)).toEqual({ ok: false, error: "broke", code: "failed" });
|
|
886
|
+
expect(refused.code).toBe(2);
|
|
887
|
+
expect(JSON.parse(refused.stdout)).toEqual({ ok: false, error: "unknown option '--nope'", code: "usage" });
|
|
888
|
+
expect(answered.code).toBe(3);
|
|
889
|
+
expect(answered.stdout.trim()).toBe("");
|
|
890
|
+
expect(text.stdout).toContain("broke\n");
|
|
891
|
+
expect(text.stdout).not.toContain('"code"');
|
|
892
|
+
}, 60_000);
|
|
893
|
+
|
|
894
|
+
test("is usageExitCode for a usage or unknown-target failure where 2 answers something else", async () => {
|
|
895
|
+
const option = ", usageExitCode: 1";
|
|
896
|
+
const [flag, unknown, verdict] = await Promise.all([
|
|
897
|
+
run('new Error("broke")', { option, args: ["--nope"] }),
|
|
898
|
+
run('new CliError("unknownTarget", "Unknown app \'shop\'")', { option }),
|
|
899
|
+
run('new ExitCodeError("needs a new store build", 2)', { option }),
|
|
900
|
+
]);
|
|
901
|
+
expect([flag.code, unknown.code, verdict.code]).toEqual([1, 1, 2]);
|
|
902
|
+
}, 30_000);
|
|
903
|
+
|
|
904
|
+
test("is 130 for a prompt closed with Ctrl+C", async () => {
|
|
905
|
+
const closed = await run(
|
|
906
|
+
'Object.assign(new Error("User force closed the prompt with SIGINT"), { name: "ExitPromptError" })',
|
|
907
|
+
);
|
|
908
|
+
expect(closed.code).toBe(130);
|
|
909
|
+
}, 30_000);
|
|
910
|
+
|
|
911
|
+
test("answers JSON for an ExitCodeError, a thrown object and an unhandled rejection", async () => {
|
|
912
|
+
const json = ' }).option("json", Boolean, { default: false';
|
|
913
|
+
const late = 'await (void Promise.reject(new CliError("refused", "late")), new Promise(() => {}))';
|
|
914
|
+
const [verdict, checked, thrown, rejected] = await Promise.all([
|
|
915
|
+
run('new ExitCodeError("needs a new store build", 2)', { option: json, args: ["--json"] }),
|
|
916
|
+
run('new ExitCodeError("found problems", 3)', { option: json, args: ["--json"] }),
|
|
917
|
+
run('{ reason: "an object" }', { option: json, args: ["--json"] }),
|
|
918
|
+
run(late, { option: json, args: ["--json"] }),
|
|
919
|
+
]);
|
|
920
|
+
expect(JSON.parse(verdict.stdout)).toEqual({ ok: false, error: "needs a new store build", code: "refused" });
|
|
921
|
+
expect(verdict.code).toBe(2);
|
|
922
|
+
expect(JSON.parse(checked.stdout)).toEqual({ ok: false, error: "found problems", code: "checkFailed" });
|
|
923
|
+
expect(checked.code).toBe(3);
|
|
924
|
+
expect(JSON.parse(thrown.stdout)).toEqual({ ok: false, error: '{"reason":"an object"}', code: "failed" });
|
|
925
|
+
expect(JSON.parse(rejected.stdout)).toEqual({ ok: false, error: "late", code: "refused" });
|
|
926
|
+
expect(rejected.code).toBe(2);
|
|
927
|
+
}, 60_000);
|
|
928
|
+
|
|
706
929
|
test("prints nothing more for an error the command marked as printed", async () => {
|
|
707
930
|
const marked = await run(
|
|
708
931
|
'(() => { const error = new Error("printed already"); markCliErrorPrinted(error); return error; })()',
|
|
@@ -1,15 +1,14 @@
|
|
|
1
|
+
import { CliError } from "./cliError";
|
|
2
|
+
|
|
1
3
|
//* A failure that is an answer, not a breakage: the CLI exits with its code, so a CI tells the two apart.
|
|
2
|
-
export class ExitCodeError extends
|
|
4
|
+
export class ExitCodeError extends CliError {
|
|
3
5
|
override readonly name = "ExitCodeError";
|
|
4
6
|
|
|
5
|
-
constructor(
|
|
6
|
-
|
|
7
|
-
readonly exitCode: number,
|
|
8
|
-
) {
|
|
9
|
-
super(message);
|
|
7
|
+
constructor(message: string, exitCode: number) {
|
|
8
|
+
super(exitCode === 2 ? "refused" : exitCode === 3 ? "checkFailed" : "failed", message, { exitCode });
|
|
10
9
|
}
|
|
11
10
|
|
|
12
11
|
static codeOf(error: unknown): number | null {
|
|
13
|
-
return error
|
|
12
|
+
return CliError.exitCodeOf(error);
|
|
14
13
|
}
|
|
15
14
|
}
|
|
@@ -44,7 +44,10 @@ export interface TargetOption {
|
|
|
44
44
|
runsOnWorkspaceRoot?: boolean;
|
|
45
45
|
stdio?: boolean;
|
|
46
46
|
failureExitCode?: number;
|
|
47
|
+
/** The exit of a `usage` or `unknownTarget` failure, for a command whose 2 already answers something else. */
|
|
47
48
|
usageExitCode?: number;
|
|
49
|
+
/** It deletes files in its app, lib, pkg or module, so one left out or unknown is refused, never picked or asked. */
|
|
50
|
+
destructive?: boolean;
|
|
48
51
|
/** Runs after the CLI printed a failure and before it exits, so what it writes ends the output. */
|
|
49
52
|
onFailure?: (error: unknown, failure: TargetFailure) => void | Promise<void>;
|
|
50
53
|
}
|
package/conventionSet.test.ts
CHANGED
|
@@ -3,6 +3,7 @@ import fsPromise from "node:fs/promises";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { ConventionSet } from "./conventionSet";
|
|
5
5
|
import { MaintainabilityContract } from "./maintainabilityContract";
|
|
6
|
+
import { MaintainabilityLimits } from "./maintainabilityLimits";
|
|
6
7
|
import { Prompter } from "./prompter";
|
|
7
8
|
|
|
8
9
|
describe("ConventionSet", () => {
|
|
@@ -49,7 +50,7 @@ describe("ConventionSet", () => {
|
|
|
49
50
|
}
|
|
50
51
|
});
|
|
51
52
|
|
|
52
|
-
test("names only checks that exist, and names every lint plugin and maintainability gate
|
|
53
|
+
test("names only checks that exist, and names every lint plugin and maintainability gate a workspace runs", async () => {
|
|
53
54
|
const set = await Prompter.getConventionSet();
|
|
54
55
|
const named = new Set(
|
|
55
56
|
[set.core, ...set.rules.map((rule) => rule.body)].flatMap((text) =>
|
|
@@ -59,7 +60,9 @@ describe("ConventionSet", () => {
|
|
|
59
60
|
const plugins = (await fsPromise.readdir(path.join(import.meta.dir, "lint")))
|
|
60
61
|
.filter((file) => file.endsWith(".grit"))
|
|
61
62
|
.map((file) => file.slice(0, -".grit".length));
|
|
62
|
-
const gates: readonly string[] = MaintainabilityContract.ruleIds
|
|
63
|
+
const gates: readonly string[] = MaintainabilityContract.ruleIds.filter(
|
|
64
|
+
(id) => !MaintainabilityLimits.repoOnlyRules.has(id),
|
|
65
|
+
);
|
|
63
66
|
const gateAreas = new Set(gates.map((id) => id.split(".").slice(0, 2).join(".")));
|
|
64
67
|
const isPluginName = (name: string) => /^non?-[a-z]+(-[a-z]+)+$/.test(name);
|
|
65
68
|
const isGateName = (name: string) =>
|
package/createTunnel.test.ts
CHANGED
|
@@ -20,10 +20,20 @@ const publicBlobOf = (publicKey: string) => {
|
|
|
20
20
|
return parsed.getPublicSSH();
|
|
21
21
|
};
|
|
22
22
|
|
|
23
|
+
//? ssh2 1.17's keygen strips a leading zero byte off an ed25519 public key, so 1 pair in 256 cannot be read back.
|
|
24
|
+
const ed25519Pair = () => {
|
|
25
|
+
for (let tries = 1; ; tries++) {
|
|
26
|
+
const pair = utils.generateKeyPairSync("ed25519");
|
|
27
|
+
const parsed = utils.parseKey(pair.private);
|
|
28
|
+
if (!(parsed instanceof Error)) return pair;
|
|
29
|
+
if (tries === 8) throw parsed;
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
|
|
23
33
|
const sshServers: { server: Server; clients: Set<Connection> }[] = [];
|
|
24
34
|
|
|
25
35
|
const startSshServer = async () => {
|
|
26
|
-
const hostKey =
|
|
36
|
+
const hostKey = ed25519Pair();
|
|
27
37
|
const methods: string[] = [];
|
|
28
38
|
const clients = new Set<Connection>();
|
|
29
39
|
const server = new Server({ hostKeys: [hostKey.private] }, (client) => {
|
|
@@ -136,7 +146,7 @@ describe("createTunnel", () => {
|
|
|
136
146
|
const ssh = await startSshServer();
|
|
137
147
|
const knownHosts = path.join(root, "known_hosts.json");
|
|
138
148
|
const keyPath = path.join(root, "id_tunnel");
|
|
139
|
-
await writeFile(keyPath,
|
|
149
|
+
await writeFile(keyPath, ed25519Pair().private);
|
|
140
150
|
Object.assign(process.env, {
|
|
141
151
|
SSH_TUNNEL_PORT: String(ssh.port),
|
|
142
152
|
SSH_TUNNEL_KNOWN_HOSTS: knownHosts,
|
|
@@ -43,7 +43,7 @@ interface CsrE2eServer {
|
|
|
43
43
|
|
|
44
44
|
const DEFAULT_PORT = 8391;
|
|
45
45
|
const RELOAD_MARKER = "__akanE2eBootMarker";
|
|
46
|
-
//? Longer than the frame's navigation lock (360ms after a transition commits, `webkit/
|
|
46
|
+
//? Longer than the frame's navigation lock (360ms after a transition commits, `webkit/useCsrNavigation.ts`).
|
|
47
47
|
const SETTLE_MS = 450;
|
|
48
48
|
|
|
49
49
|
/** Drives a page of a running `akan start` in a headless `Bun.WebView`: CSR (`?csr=true` + a mobile target) or SSR. */
|