@akanjs/devkit 3.1.1-rc.9 → 3.1.2-rc.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 +117 -0
- package/agentAssets.test.ts +65 -0
- package/agentAssets.ts +166 -0
- package/agentsIndex.ts +4 -4
- package/akanApp/akanApp.host.test.ts +12 -0
- package/akanApp/akanApp.host.ts +12 -0
- package/akanConfig/akanConfig.test.ts +105 -0
- package/akanConfig/akanConfig.ts +21 -1
- package/akanConfig/productionLockfile.test.ts +31 -0
- package/akanConfig/productionLockfile.ts +2 -0
- package/akanContext.ts +129 -4
- package/akanMcpContract.test.ts +28 -1
- package/akanMcpContract.ts +51 -3
- package/applicationBuildRunner.test.ts +53 -0
- package/applicationBuildRunner.ts +7 -4
- package/artifact/implicitRootLayout.ts +4 -2
- package/biome.base.json +4 -1
- package/cloud/globalConfig.test.ts +34 -0
- package/cloud/globalConfig.ts +2 -2
- package/codeAgent/agent/AkanCodeEnv.ts +26 -0
- package/codeAgent/agent/AkanCodeServices.ts +5 -2
- package/codeAgent/agent/CodeAgent.ts +11 -0
- package/codeAgent/agent/CodeAgentGate.ts +156 -10
- package/codeAgent/agent/CodeAgentProxy.ts +5 -4
- package/codeAgent/agent/CodeAgentRpcHost.ts +3 -0
- package/codeAgent/agent/CodeAgentRpcListener.ts +102 -16
- package/codeAgent/agent/SubagentPool.ts +5 -0
- package/codeAgent/agent/WorkspaceTrust.ts +200 -0
- package/codeAgent/agent/akanCodePaths.ts +8 -1
- package/codeAgent/agent/akanSystemPrompt.ts +5 -1
- package/codeAgent/agent/codeAgent.test.ts +41 -2
- package/codeAgent/agent/codeAgentContract.test.ts +119 -0
- package/codeAgent/agent/codeAgentHosts.ts +30 -0
- package/codeAgent/agent/codeAgentProxy.test.ts +53 -1
- package/codeAgent/agent/codeAgentRpcListener.test.ts +115 -13
- package/codeAgent/agent/stringArg.ts +1 -1
- package/codeAgent/feedback/PreviewFeedback.ts +2 -2
- package/codeAgent/feedback/VerifyFeedback.ts +3 -4
- package/codeAgent/index.ts +2 -2
- package/codeAgent/skills/akan-agent/SKILL.md +43 -0
- package/codeAgent/skills/akan-code/SKILL.md +38 -11
- package/codeAgent/skills/akan-debug/SKILL.md +41 -6
- package/codeAgent/skills/akan-deps/SKILL.md +1 -2
- package/codeAgent/skills/akan-err/SKILL.md +2 -1
- package/codeAgent/skills/akan-mcp/SKILL.md +40 -0
- package/codeAgent/skills/akan-page/SKILL.md +44 -0
- package/codeAgent/skills/akan-query/SKILL.md +39 -0
- package/codeAgent/skills/akan-runtime/SKILL.md +43 -0
- package/codeAgent/skills/akan-scaffold/SKILL.md +16 -6
- package/codeAgent/skills/akan-transport/SKILL.md +40 -0
- package/codeAgent/skills/akan-validate/SKILL.md +13 -11
- package/codeAgent/tools/AkanCli.ts +2 -1
- package/codeAgent/tools/AkanCodePlugins.ts +5 -1
- package/codeAgent/tools/AkanEditScope.ts +8 -3
- package/codeAgent/tools/AkanToolPack.ts +4 -10
- package/codeAgent/tools/AkanVerifier.ts +13 -31
- package/codeAgent/tools/McpServerConfig.ts +6 -4
- package/codeAgent/tools/McpSignIn.ts +7 -7
- package/codeAgent/tools/McpTokenStore.ts +63 -16
- package/codeAgent/tools/McpToolPack.ts +8 -1
- package/codeAgent/tools/WebToolPack.ts +50 -6
- package/codeAgent/tools/akanCodePlugins.test.ts +130 -60
- package/codeAgent/tools/mcpOAuth.test.ts +50 -14
- package/codeAgent/tools/mcpServerConfig.test.ts +130 -1
- package/commandDecorators/command.ts +4 -1
- package/commandDecorators/workspaceVersionCheck.test.ts +17 -7
- package/commandDecorators/workspaceVersionCheck.ts +4 -1
- package/commentDensityScanner.test.ts +121 -0
- package/commentDensityScanner.ts +101 -0
- package/componentSizeScanner.test.ts +36 -0
- package/componentSizeScanner.ts +52 -0
- package/componentWriteChainScanner.test.ts +137 -0
- package/componentWriteChainScanner.ts +104 -0
- package/conventionSet.test.ts +73 -0
- package/conventionSet.ts +67 -0
- package/createTunnel.test.ts +200 -2
- package/createTunnel.ts +80 -6
- package/csrE2e/ssrRegistry.e2e.test.ts +6 -1
- package/deprecatedCommentScanner.test.ts +54 -0
- package/deprecatedCommentScanner.ts +30 -0
- package/endpointReach.test.ts +58 -0
- package/endpointReach.ts +80 -0
- package/envSeed.test.ts +90 -0
- package/envSeed.ts +68 -0
- package/executors.test.ts +19 -5
- package/executors.ts +59 -4
- package/fileSys.test.ts +30 -2
- package/fileSys.ts +14 -0
- package/formSetterScanner.test.ts +44 -1
- package/formSetterScanner.ts +53 -8
- package/frontendBuild/csrArtifactBuilder.ts +7 -0
- package/frontendBuild/mcpAppViewBuilder.test.ts +63 -0
- package/frontendBuild/mcpAppViewBuilder.ts +7 -1
- package/frontendBuild/optionalPeers.test.ts +132 -0
- package/index.ts +1 -0
- package/integration/devStability.integration.test.ts +9 -5
- package/integration/ssrMemoryProbe.ts +6 -0
- package/kindBranchScanner.test.ts +89 -0
- package/kindBranchScanner.ts +158 -0
- package/lint/__fixtures__/no-oversized-list-limit/bad.tsx +18 -0
- package/lint/__fixtures__/no-oversized-list-limit/good.tsx +23 -0
- package/lint/no-oversized-list-limit.grit +73 -0
- package/lint/no-unguarded-endpoint.grit +5 -5
- package/linter.test.ts +12 -0
- package/linter.ts +35 -12
- package/maintainabilityBaseline.test.ts +280 -0
- package/maintainabilityBaseline.ts +220 -0
- package/maintainabilityContract.test.ts +846 -0
- package/maintainabilityContract.ts +404 -0
- package/maintainabilityExceptionScanner.test.ts +90 -0
- package/maintainabilityExceptionScanner.ts +85 -0
- package/maintainabilityLimits.ts +60 -0
- package/maintainabilityReport.test.ts +130 -0
- package/maintainabilityReport.ts +153 -0
- package/mobile/desktopRelease.test.ts +1 -1
- package/mobile/desktopRelease.ts +3 -1
- package/mobile/desktopServerStage.test.ts +1 -0
- package/mobile/desktopServerStage.ts +3 -1
- package/mobile/nativeApp.test.ts +20 -5
- package/mobile/nativeApp.ts +4 -1
- package/mobile/nativeConfig.test.ts +209 -5
- package/mobile/nativeConfig.ts +127 -6
- package/mobile/nativePublicEnv.test.ts +2 -0
- package/mobile/nativePublicEnv.ts +13 -0
- package/package.json +4 -2
- package/platformTest/LinuxDockerTarget.ts +3 -0
- package/{codeAgent/feedback → preview}/PreviewChrome.ts +13 -7
- package/preview/PreviewView.ts +219 -0
- package/preview/index.ts +2 -0
- package/preview/previewView.test.ts +76 -0
- package/prompter.ts +7 -0
- package/qualityScanner.test.ts +75 -0
- package/qualityScanner.ts +17 -1
- package/routeSourceValidator.test.ts +11 -2
- package/routeSourceValidator.ts +14 -2
- package/serviceLoopCallScanner.test.ts +56 -0
- package/serviceLoopCallScanner.ts +122 -0
- package/shellOnlyEnv.test.ts +65 -0
- package/shellOnlyEnv.ts +54 -0
- package/signalMutationIndex.test.ts +15 -0
- package/signalMutationIndex.ts +23 -82
- package/sourceComments.test.ts +45 -0
- package/sourceComments.ts +70 -0
- package/sshHostKeys.ts +59 -0
- package/storeAccessScanner.test.ts +84 -0
- package/storeAccessScanner.ts +71 -0
- package/storeSource.ts +160 -0
- package/storeWriteScanner.test.ts +292 -0
- package/storeWriteScanner.ts +50 -0
- package/subspace.test.ts +10 -0
- package/subspaceClone.ts +3 -1
- package/subspaceConfig.ts +6 -0
- package/testOnlyExportScanner.test.ts +83 -0
- package/testOnlyExportScanner.ts +124 -0
- package/transforms/barrelImportsPlugin.ts +10 -4
- package/transforms/mcpAppPageTrimmer.test.ts +8 -2
- package/transforms/mcpAppPageTrimmer.ts +5 -20
- package/transforms/pureLazyMarker.ts +1 -1
- package/typeChecker.ts +32 -0
- package/typecheck/projectTypecheck.test.ts +18 -0
- package/typecheck/projectTypecheck.ts +29 -5
- package/typecheck/typecheck.proc.ts +6 -0
- package/typecheck/typecheckFailure.ts +13 -0
- package/types.ts +1 -0
- package/workflow/artifacts.ts +16 -2
- package/workflow/executor.ts +22 -7
- package/workflow/guardCatalog.test.ts +59 -0
- package/workflow/guardCatalog.ts +108 -0
- package/workflow/index.ts +4 -0
- package/workflow/interactionScaffold.ts +122 -0
- package/workflow/mutationScaffold.ts +533 -0
- package/workflow/mutationSource.test.ts +108 -0
- package/workflow/mutationSource.ts +219 -0
- package/workflow/plan.ts +82 -0
- package/workflow/render.ts +19 -4
- package/workflow/source.test.ts +185 -0
- package/workflow/source.ts +151 -24
- package/workflow/sourceAst.ts +10 -48
- package/workflow/types.ts +18 -1
- package/workspaceLayout.ts +2 -0
- package/writePath.test.ts +109 -0
- package/writePath.ts +219 -0
- package/writePlacementScanner.test.ts +77 -0
- package/writePlacementScanner.ts +50 -0
- package/writingActionIndex.test.ts +99 -0
- package/writingActionIndex.ts +63 -0
- package/codeAgent/feedback/PreviewView.ts +0 -95
- /package/{codeAgent/feedback → preview}/previewChrome.test.ts +0 -0
package/akanConfig/akanConfig.ts
CHANGED
|
@@ -227,6 +227,7 @@ export class AkanAppConfig implements AppConfigResult {
|
|
|
227
227
|
this.i18n = resolveAkanI18nConfig(config?.i18n);
|
|
228
228
|
process.env.AKAN_PUBLIC_DEFAULT_LOCALE = this.i18n.defaultLocale;
|
|
229
229
|
process.env.AKAN_PUBLIC_LOCALES = this.i18n.locales.join(",");
|
|
230
|
+
process.env.AKAN_PUBLIC_LOCALE_PREFIX = this.i18n.localePrefix ?? "always";
|
|
230
231
|
this.api = {
|
|
231
232
|
prefix: normalizeRoutePrefix(config?.api?.prefix) ?? "/api",
|
|
232
233
|
websocketPrefix: normalizeRoutePrefix(config?.api?.websocketPrefix) ?? "/ws",
|
|
@@ -468,6 +469,8 @@ export class AkanAppConfig implements AppConfigResult {
|
|
|
468
469
|
"deepLinks",
|
|
469
470
|
"updates",
|
|
470
471
|
"devtools",
|
|
472
|
+
"csp",
|
|
473
|
+
"openUrls",
|
|
471
474
|
"ios",
|
|
472
475
|
"android",
|
|
473
476
|
"desktop",
|
|
@@ -623,6 +626,7 @@ export class AkanAppConfig implements AppConfigResult {
|
|
|
623
626
|
...(this.basePaths.size ? { AKAN_PUBLIC_BASE_PATHS: [...this.basePaths].join(",") } : {}),
|
|
624
627
|
AKAN_PUBLIC_DEFAULT_LOCALE: this.i18n.defaultLocale,
|
|
625
628
|
AKAN_PUBLIC_LOCALES: this.i18n.locales.join(","),
|
|
629
|
+
...(this.i18n.localePrefix === "as-needed" ? { AKAN_PUBLIC_LOCALE_PREFIX: this.i18n.localePrefix } : {}),
|
|
626
630
|
AKAN_PUBLIC_API_PREFIX: this.api.prefix,
|
|
627
631
|
AKAN_PUBLIC_WS_PREFIX: this.api.websocketPrefix,
|
|
628
632
|
AKAN_PUBLIC_OPERATION_MODE: "cloud",
|
|
@@ -634,6 +638,11 @@ export class AkanAppConfig implements AppConfigResult {
|
|
|
634
638
|
...(this.web.csr ? {} : { AKAN_CSR: "false" }),
|
|
635
639
|
};
|
|
636
640
|
}
|
|
641
|
+
//? An image runs where the build cannot see, and AKAN_PUBLIC_ENV=local serves the dev routes to every caller there.
|
|
642
|
+
static readonly localImageGuard = [
|
|
643
|
+
"ARG AKAN_ALLOW_LOCAL_IMAGE",
|
|
644
|
+
`RUN if [ "$AKAN_ALLOW_LOCAL_IMAGE" != "1" ]; then echo "This build ran with AKAN_PUBLIC_ENV=local, which serves the dev routes to every caller. Build with the environment it deploys to, or pass --build-arg AKAN_ALLOW_LOCAL_IMAGE=1." >&2; exit 1; fi`,
|
|
645
|
+
].join("\n");
|
|
637
646
|
dockerfileFor(environment: string, { lockfile = true }: { lockfile?: boolean } = {}): string {
|
|
638
647
|
return this.#makeDockerfile(environment, lockfile);
|
|
639
648
|
}
|
|
@@ -643,8 +652,9 @@ export class AkanAppConfig implements AppConfigResult {
|
|
|
643
652
|
const preRunScripts = this.#getDockerRunScripts(preRuns);
|
|
644
653
|
const postRunScripts = this.#getDockerRunScripts(postRuns);
|
|
645
654
|
const imageScript = this.#getDockerImageScript(image, DEFAULT_DOCKER_IMAGE);
|
|
655
|
+
const isLocal = (environment ?? this.baseDevEnv.env) === "local";
|
|
646
656
|
const envLines = Object.entries(this.getProductionEnv(environment)).map(([key, value]) => `ENV ${key}=${value}`);
|
|
647
|
-
return `${imageScript}
|
|
657
|
+
return `${imageScript}${isLocal ? `\n${AkanAppConfig.localImageGuard}` : ""}
|
|
648
658
|
RUN apt-get update && apt-get upgrade -y && apt-get install -y --no-install-recommends ca-certificates tzdata && rm -rf /var/lib/apt/lists/*
|
|
649
659
|
RUN ln -sf /usr/share/zoneinfo/Asia/Seoul /etc/localtime
|
|
650
660
|
ARG TARGETARCH
|
|
@@ -776,7 +786,15 @@ CMD [${command.map((c) => `"${c}"`).join(",")}]`;
|
|
|
776
786
|
static get akanVersion() {
|
|
777
787
|
return getAkanPackageJson().version;
|
|
778
788
|
}
|
|
789
|
+
//? The image installs from the registry alone: a pin to a path or a workspace package resolves nowhere there.
|
|
790
|
+
static #registryPins(pins: Record<string, string> | undefined) {
|
|
791
|
+
if (!pins) return undefined;
|
|
792
|
+
const kept = Object.entries(pins).filter(([, spec]) => !/^(?:file:|link:|workspace:|portal:|[./~])/.test(spec));
|
|
793
|
+
return kept.length ? Object.fromEntries(kept) : undefined;
|
|
794
|
+
}
|
|
779
795
|
getProductionPackageJson(data: Partial<PackageJson> = {}): PackageJson {
|
|
796
|
+
const overrides = AkanAppConfig.#registryPins(this.rootPackageJson.overrides);
|
|
797
|
+
const resolutions = AkanAppConfig.#registryPins(this.rootPackageJson.resolutions);
|
|
780
798
|
return {
|
|
781
799
|
name: this.app.name,
|
|
782
800
|
description: this.app.name,
|
|
@@ -789,6 +807,8 @@ CMD [${command.map((c) => `"${c}"`).join(",")}]`;
|
|
|
789
807
|
]),
|
|
790
808
|
),
|
|
791
809
|
...(this.trustedDependencies.length ? { trustedDependencies: this.trustedDependencies } : {}),
|
|
810
|
+
...(overrides ? { overrides } : {}),
|
|
811
|
+
...(resolutions ? { resolutions } : {}),
|
|
792
812
|
...data,
|
|
793
813
|
};
|
|
794
814
|
}
|
|
@@ -70,6 +70,37 @@ describe("ProductionLockfile", () => {
|
|
|
70
70
|
});
|
|
71
71
|
});
|
|
72
72
|
|
|
73
|
+
test("keeps a version an override pinned outside its parent's range", async () => {
|
|
74
|
+
const overriddenLock = `{
|
|
75
|
+
"lockfileVersion": 1,
|
|
76
|
+
"configVersion": 1,
|
|
77
|
+
"workspaces": {
|
|
78
|
+
"": {
|
|
79
|
+
"name": "repo",
|
|
80
|
+
"dependencies": {
|
|
81
|
+
"loose-envify": "^1.4.0",
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
"overrides": {
|
|
86
|
+
"js-tokens": "2.0.0",
|
|
87
|
+
},
|
|
88
|
+
"packages": {
|
|
89
|
+
"js-tokens": ["js-tokens@2.0.0", "", {}, "sha512-kk4RjoztHFeAlCNPdTr/uF04oN5z53L18LJfADBQ7YHkO3iR2rP2ZwOH0n33r2FRw0psRmjTDEkOWPE//zmujg=="],
|
|
90
|
+
|
|
91
|
+
"loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
`;
|
|
95
|
+
await withWorkspace(overriddenLock, async (root) => {
|
|
96
|
+
const lock = await ProductionLockfile.prune(root, {
|
|
97
|
+
...productionPackageJson,
|
|
98
|
+
overrides: { "js-tokens": "2.0.0" },
|
|
99
|
+
});
|
|
100
|
+
expect(lock).toContain('"js-tokens": ["js-tokens@2.0.0"');
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
73
104
|
test("refuses a workspace without a bun.lock, so the build can fall back to an unlocked install", async () => {
|
|
74
105
|
await withWorkspace(null, async (root) => {
|
|
75
106
|
await expect(ProductionLockfile.prune(root, productionPackageJson)).rejects.toThrow(
|
|
@@ -13,9 +13,11 @@ export class ProductionLockfile {
|
|
|
13
13
|
if (!(await workspaceLock.exists())) throw new Error(`the workspace root has no ${ProductionLockfile.fileName}`);
|
|
14
14
|
const dir = await mkdtemp(path.join(os.tmpdir(), "akan-production-lock-"));
|
|
15
15
|
try {
|
|
16
|
+
const npmrc = Bun.file(path.join(workspaceRoot, ".npmrc"));
|
|
16
17
|
await Promise.all([
|
|
17
18
|
Bun.write(path.join(dir, "package.json"), JSON.stringify(packageJson, null, 2)),
|
|
18
19
|
Bun.write(path.join(dir, ProductionLockfile.fileName), workspaceLock),
|
|
20
|
+
...((await npmrc.exists()) ? [Bun.write(path.join(dir, ".npmrc"), npmrc)] : []),
|
|
19
21
|
]);
|
|
20
22
|
const install = Bun.spawn([process.execPath, "install", "--lockfile-only", "--no-progress"], {
|
|
21
23
|
cwd: dir,
|
package/akanContext.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { readdir } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import { capitalize, isRecord } from "akanjs/common";
|
|
3
|
+
import { capitalize, isRecord, parseRouteModuleKey } from "akanjs/common";
|
|
4
4
|
import { extractBlockVersion, readDevkitVersion } from "./agentsIndex";
|
|
5
5
|
import { AppExecutor, LibExecutor, type SysExecutor, type WorkspaceExecutor } from "./executors";
|
|
6
6
|
import { FileSys } from "./fileSys";
|
|
@@ -48,12 +48,25 @@ export interface AkanModuleContext {
|
|
|
48
48
|
files: string[];
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
export interface AkanRouteContext {
|
|
52
|
+
/** The URL pattern without the `/:lang` segment every route starts with. */
|
|
53
|
+
path: string;
|
|
54
|
+
file: string;
|
|
55
|
+
search: string[];
|
|
56
|
+
/** The page or a layout above it calls `.guard()`. */
|
|
57
|
+
guarded: boolean;
|
|
58
|
+
devOnly: boolean;
|
|
59
|
+
}
|
|
60
|
+
|
|
51
61
|
export interface AkanSysContext {
|
|
52
62
|
type: "app" | "lib";
|
|
53
63
|
name: string;
|
|
54
64
|
path: string;
|
|
55
65
|
hasConfig: boolean;
|
|
56
66
|
modules: AkanModuleContext[];
|
|
67
|
+
/** Listed for the app `--app` names only; reading them parses every route file. */
|
|
68
|
+
routes?: AkanRouteContext[];
|
|
69
|
+
routeError?: string;
|
|
57
70
|
}
|
|
58
71
|
|
|
59
72
|
export interface AkanPackageContext {
|
|
@@ -280,6 +293,7 @@ const generatedFiles = [
|
|
|
280
293
|
];
|
|
281
294
|
|
|
282
295
|
const validationCommands = [
|
|
296
|
+
"akan verify [<app-or-lib-or-pkg>]",
|
|
283
297
|
"akan sync <app-or-lib>",
|
|
284
298
|
"akan lint <app-or-lib-or-pkg>",
|
|
285
299
|
"akan typecheck <app-or-lib>",
|
|
@@ -294,7 +308,7 @@ const unknownGeneratedFilesFreshness: GeneratedFilesFreshness = {
|
|
|
294
308
|
status: "unknown" as const,
|
|
295
309
|
message: "Run sync before validation so generated Akan files match the current source conventions.",
|
|
296
310
|
refreshCommand: "akan sync <app-or-lib>",
|
|
297
|
-
verifyingCommands: ["akan
|
|
311
|
+
verifyingCommands: ["akan verify <app-or-lib>"],
|
|
298
312
|
};
|
|
299
313
|
|
|
300
314
|
const repairAction = (
|
|
@@ -454,7 +468,7 @@ const generatedFreshnessFromStates = async (workspace: WorkspaceExecutor): Promi
|
|
|
454
468
|
? `Generated files were refreshed for ${lastFresh.target} at ${lastFresh.lastSyncedAt}.`
|
|
455
469
|
: "Generated sync state exists, but the last recorded repair did not pass.",
|
|
456
470
|
refreshCommand: "akan sync <app-or-lib>",
|
|
457
|
-
verifyingCommands: ["akan
|
|
471
|
+
verifyingCommands: ["akan verify <app-or-lib>"],
|
|
458
472
|
targets,
|
|
459
473
|
};
|
|
460
474
|
};
|
|
@@ -556,6 +570,34 @@ const getSysModules = async (
|
|
|
556
570
|
return modules.sort((a, b) => `${a.sysName}:${a.path}`.localeCompare(`${b.sysName}:${b.path}`));
|
|
557
571
|
};
|
|
558
572
|
|
|
573
|
+
const getAppRoutes = async (app: AppExecutor, workspaceRoot: string): Promise<AkanRouteContext[]> => {
|
|
574
|
+
const sources = await app.getRouteSources();
|
|
575
|
+
const guardedLayouts = new Set<string>();
|
|
576
|
+
const pages: { source: (typeof sources)[number]; parsed: ReturnType<typeof parseRouteModuleKey> }[] = [];
|
|
577
|
+
for (const source of sources) {
|
|
578
|
+
const parsed = parseRouteModuleKey(source.key);
|
|
579
|
+
if (parsed.kind === "layout" && source.info.chain?.guarded) guardedLayouts.add(parsed.routeSegments.join("/"));
|
|
580
|
+
else if (parsed.kind === "page") pages.push({ source, parsed });
|
|
581
|
+
}
|
|
582
|
+
return pages
|
|
583
|
+
.map(({ source, parsed }) => {
|
|
584
|
+
const segments = parsed.routeSegments;
|
|
585
|
+
//? The layouts that wrap a page, as the route seed index walks them: an `_index` sits inside its own folder.
|
|
586
|
+
const reach = parsed.leaf === "_index" ? segments.length : segments.length - 1;
|
|
587
|
+
const underGuard = Array.from({ length: reach + 1 }, (_, idx) => segments.slice(0, idx).join("/")).some(
|
|
588
|
+
(prefix) => guardedLayouts.has(prefix),
|
|
589
|
+
);
|
|
590
|
+
return {
|
|
591
|
+
path: parsed.pattern.replace(/^\/:lang/, "") || "/",
|
|
592
|
+
file: path.relative(workspaceRoot, source.absPath).replaceAll(path.sep, "/"),
|
|
593
|
+
search: source.info.chain?.search ?? [],
|
|
594
|
+
guarded: !!source.info.chain?.guarded || underGuard,
|
|
595
|
+
devOnly: source.info.devOnly,
|
|
596
|
+
};
|
|
597
|
+
})
|
|
598
|
+
.sort((a, b) => a.path.localeCompare(b.path));
|
|
599
|
+
};
|
|
600
|
+
|
|
559
601
|
const getSysContext = async (
|
|
560
602
|
workspace: WorkspaceExecutor,
|
|
561
603
|
type: "app" | "lib",
|
|
@@ -563,7 +605,7 @@ const getSysContext = async (
|
|
|
563
605
|
options: AkanContextOptions,
|
|
564
606
|
): Promise<AkanSysContext> => {
|
|
565
607
|
const sys = type === "app" ? AppExecutor.from(workspace, name) : LibExecutor.from(workspace, name);
|
|
566
|
-
|
|
608
|
+
const context: AkanSysContext = {
|
|
567
609
|
type,
|
|
568
610
|
name,
|
|
569
611
|
path: `${type}s/${name}`,
|
|
@@ -573,6 +615,12 @@ const getSysContext = async (
|
|
|
573
615
|
module: options.module,
|
|
574
616
|
}),
|
|
575
617
|
};
|
|
618
|
+
if (sys instanceof AppExecutor && options.app === name)
|
|
619
|
+
await getAppRoutes(sys, workspace.workspaceRoot).then(
|
|
620
|
+
(routes) => (context.routes = routes),
|
|
621
|
+
(error: unknown) => (context.routeError = error instanceof Error ? error.message : String(error)),
|
|
622
|
+
);
|
|
623
|
+
return context;
|
|
576
624
|
};
|
|
577
625
|
|
|
578
626
|
export class AkanContextAnalyzer {
|
|
@@ -632,6 +680,42 @@ export class AkanContextAnalyzer {
|
|
|
632
680
|
};
|
|
633
681
|
}
|
|
634
682
|
|
|
683
|
+
//? Codex joins every AGENTS.md from the repo root down to its folder and drops what passes 32 KiB (`project_doc_max_bytes`).
|
|
684
|
+
static async #agentGuideOversize(workspace: WorkspaceExecutor, sysPaths: string[]) {
|
|
685
|
+
const sizeOf = async (file: string) => {
|
|
686
|
+
const content = await safeReadText(path.join(workspace.workspaceRoot, file));
|
|
687
|
+
return { file, bytes: content === null ? 0 : Buffer.byteLength(content) };
|
|
688
|
+
};
|
|
689
|
+
const root = await sizeOf("AGENTS.md");
|
|
690
|
+
if (root.bytes === 0) return null;
|
|
691
|
+
const scoped = await Promise.all(sysPaths.map((sysPath) => sizeOf(`${sysPath}/AGENTS.md`)));
|
|
692
|
+
const largest = scoped.reduce((max, entry) => (entry.bytes > max.bytes ? entry : max), { file: "", bytes: 0 });
|
|
693
|
+
const total = root.bytes + largest.bytes;
|
|
694
|
+
if (total <= 32 * 1024 * 0.8) return null;
|
|
695
|
+
const subject = largest.bytes ? `AGENTS.md and ${largest.file} add up to` : "AGENTS.md is";
|
|
696
|
+
return `${subject} ${Math.ceil(total / 1024)} KiB of the 32 KiB Codex reads — run \`akan agent install\`, then shorten the text outside the akan:agent markers or move it into a skill.`;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
static async #agentGuideLeftovers(workspace: WorkspaceExecutor) {
|
|
700
|
+
const root = workspace.workspaceRoot;
|
|
701
|
+
const guide = (await safeReadText(path.join(root, "AGENTS.md"))) ?? "";
|
|
702
|
+
const leftovers: { path: string; message: string }[] = [];
|
|
703
|
+
for (const doc of ["docs/AI-DEVELOPMENT.md", "docs/GENERATED.md"]) {
|
|
704
|
+
const pointer = guide.includes(doc);
|
|
705
|
+
if ((await safeReadText(path.join(root, doc))) !== null)
|
|
706
|
+
leftovers.push({
|
|
707
|
+
path: doc,
|
|
708
|
+
message: `${doc} is an older template's copy of the agent guide, and nothing reads it now — delete it${pointer ? " and the AGENTS.md sentence that points to it" : ""}.`,
|
|
709
|
+
});
|
|
710
|
+
else if (pointer)
|
|
711
|
+
leftovers.push({
|
|
712
|
+
path: "AGENTS.md",
|
|
713
|
+
message: `AGENTS.md points to ${doc}, which is gone — delete that sentence.`,
|
|
714
|
+
});
|
|
715
|
+
}
|
|
716
|
+
return leftovers;
|
|
717
|
+
}
|
|
718
|
+
|
|
635
719
|
//? Older templates wrote these for Next.js: nothing installs or reads them, and editors warn about the plugin.
|
|
636
720
|
static async #configLeftovers(workspace: WorkspaceExecutor, sysPaths: string[]) {
|
|
637
721
|
const leftovers: { path: string; message: string }[] = [];
|
|
@@ -724,6 +808,11 @@ export class AkanContextAnalyzer {
|
|
|
724
808
|
repairAction("generated", "akan agent install agents-md", agentDrift.hint, true),
|
|
725
809
|
);
|
|
726
810
|
const sysPaths = [...context.apps, ...context.libs].map((sys) => sys.path);
|
|
811
|
+
const oversize = await AkanContextAnalyzer.#agentGuideOversize(workspace, sysPaths);
|
|
812
|
+
if (oversize)
|
|
813
|
+
diagnostics.push({ severity: "warning", code: "agent-guide-oversized", path: "AGENTS.md", message: oversize });
|
|
814
|
+
for (const leftover of await AkanContextAnalyzer.#agentGuideLeftovers(workspace))
|
|
815
|
+
diagnostics.push({ severity: "warning", code: "agent-guide-leftover", ...leftover });
|
|
727
816
|
for (const leftover of await AkanContextAnalyzer.#configLeftovers(workspace, sysPaths))
|
|
728
817
|
diagnostics.push({ severity: "warning", code: "config-leftover", ...leftover });
|
|
729
818
|
|
|
@@ -944,6 +1033,12 @@ export class AkanContextAnalyzer {
|
|
|
944
1033
|
...context.pkgs.map((pkg) => `- ${pkg.name}${pkg.version ? ` (${pkg.version})` : ""}`),
|
|
945
1034
|
);
|
|
946
1035
|
|
|
1036
|
+
for (const app of context.apps) {
|
|
1037
|
+
if (app.routeError) lines.push("", `## Routes: ${app.name}`, `- Not read: ${app.routeError}`);
|
|
1038
|
+
else if (app.routes)
|
|
1039
|
+
lines.push("", `## Routes: ${app.name}`, ...AkanContextAnalyzer.#routeLines(app.name, app.routes));
|
|
1040
|
+
}
|
|
1041
|
+
|
|
947
1042
|
const modules = AkanContextAnalyzer.findModules(context, moduleName);
|
|
948
1043
|
lines.push("", "## Modules");
|
|
949
1044
|
for (const module of modules) {
|
|
@@ -958,4 +1053,34 @@ export class AkanContextAnalyzer {
|
|
|
958
1053
|
lines.push("", "## Validation", ...context.validationCommands.map((command) => `- \`${command}\``));
|
|
959
1054
|
return `${lines.join("\n")}\n`;
|
|
960
1055
|
}
|
|
1056
|
+
|
|
1057
|
+
static readonly routeListLimit = 60;
|
|
1058
|
+
|
|
1059
|
+
//? Past the limit the biggest top folders fold into one line each: a docs section alone would fill the context.
|
|
1060
|
+
static #routeLines(appName: string, routes: AkanRouteContext[]) {
|
|
1061
|
+
const folderOf = (route: AkanRouteContext) => route.file.split("/").slice(0, 4).join("/");
|
|
1062
|
+
const groups = Map.groupBy(routes, folderOf);
|
|
1063
|
+
const folded = new Set<string>();
|
|
1064
|
+
let listed = routes.length;
|
|
1065
|
+
for (const [folder, members] of [...groups].sort((a, b) => b[1].length - a[1].length)) {
|
|
1066
|
+
if (listed <= AkanContextAnalyzer.routeListLimit) break;
|
|
1067
|
+
folded.add(folder);
|
|
1068
|
+
listed -= members.length;
|
|
1069
|
+
}
|
|
1070
|
+
const lines = [`Paths omit the \`/:lang\` prefix; \`akan preview ${appName} <path>\` opens one.`];
|
|
1071
|
+
for (const route of routes) {
|
|
1072
|
+
if (folded.has(folderOf(route))) continue;
|
|
1073
|
+
const search = route.search.length ? ` ?${route.search.join("&")}` : "";
|
|
1074
|
+
const marks = `${route.guarded ? " (guarded)" : ""}${route.devOnly ? " (dev only)" : ""}`;
|
|
1075
|
+
lines.push(`- ${route.path}${search}${marks} — ${route.file}`);
|
|
1076
|
+
}
|
|
1077
|
+
for (const folder of folded) {
|
|
1078
|
+
const members = groups.get(folder) ?? [];
|
|
1079
|
+
const examples = members.slice(0, 3).map((route) => route.path);
|
|
1080
|
+
lines.push(
|
|
1081
|
+
`- ${folder}/: ${members.length} routes folded (${examples.join(", ")}, …); \`--format json\` lists every route`,
|
|
1082
|
+
);
|
|
1083
|
+
}
|
|
1084
|
+
return lines;
|
|
1085
|
+
}
|
|
961
1086
|
}
|
package/akanMcpContract.test.ts
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import {
|
|
2
|
+
import { mkdir, symlink } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { createAkanValidationContract, workflowArtifactPath } from "./akanMcpContract";
|
|
5
|
+
import { createTempApp, tempRoots } from "./testHelpers";
|
|
6
|
+
|
|
7
|
+
const track = tempRoots();
|
|
3
8
|
|
|
4
9
|
describe("createAkanValidationContract", () => {
|
|
5
10
|
test("exposes reference-first tooling rollout gate semantics", () => {
|
|
@@ -35,3 +40,25 @@ describe("createAkanValidationContract", () => {
|
|
|
35
40
|
);
|
|
36
41
|
});
|
|
37
42
|
});
|
|
43
|
+
|
|
44
|
+
describe("workflowArtifactPath", () => {
|
|
45
|
+
test("keeps the plans and reports an MCP call names inside the workspace's workflow folder", async () => {
|
|
46
|
+
const { root, workspace } = track(await createTempApp("demo"));
|
|
47
|
+
const workflows = path.join(root, ".akan/workflows");
|
|
48
|
+
await mkdir(path.join(workflows, "plans"), { recursive: true });
|
|
49
|
+
await symlink(path.join(root, "package.json"), path.join(workflows, "plans/linked.json"));
|
|
50
|
+
|
|
51
|
+
expect(workflowArtifactPath(workspace, ".akan/workflows/plans/a.json")).toBe(path.join(workflows, "plans/a.json"));
|
|
52
|
+
expect(workflowArtifactPath(workspace, `${workflows}/runs/b.json`)).toBe(path.join(workflows, "runs/b.json"));
|
|
53
|
+
expect(workflowArtifactPath(workspace, "add-field-123")).toBe(path.join(workflows, "runs/add-field-123.json"));
|
|
54
|
+
for (const outside of [
|
|
55
|
+
"package.json",
|
|
56
|
+
`${root}/package.json`,
|
|
57
|
+
".akan/workflows/../../package.json",
|
|
58
|
+
".akan/workflows",
|
|
59
|
+
".akan/workflows/plans/linked.json",
|
|
60
|
+
"/tmp/plan.json",
|
|
61
|
+
])
|
|
62
|
+
expect(() => workflowArtifactPath(workspace, outside)).toThrow("MCP workflow files live under .akan/workflows/");
|
|
63
|
+
});
|
|
64
|
+
});
|
package/akanMcpContract.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { lstatSync, realpathSync } from "node:fs";
|
|
1
2
|
import path from "node:path";
|
|
2
3
|
import { isRecord } from "akanjs/common";
|
|
3
4
|
import { AkanContextAnalyzer, type AkanDiagnostic, type AkanModuleContext } from "./akanContext";
|
|
@@ -127,8 +128,55 @@ const isInspectAkanContextRequestType = (value: unknown): value is InspectAkanCo
|
|
|
127
128
|
|
|
128
129
|
export const parseJsonOutput = (output: string) => JSON.parse(output) as unknown;
|
|
129
130
|
|
|
130
|
-
|
|
131
|
-
|
|
131
|
+
const workflowArtifactDir = ".akan/workflows";
|
|
132
|
+
const workflowRunIdPattern = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
133
|
+
|
|
134
|
+
const isInside = (base: string, target: string) => {
|
|
135
|
+
const relative = path.relative(base, target);
|
|
136
|
+
return !!relative && !relative.startsWith("..") && !path.isAbsolute(relative);
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const hasEntry = (filePath: string) => {
|
|
140
|
+
try {
|
|
141
|
+
lstatSync(filePath);
|
|
142
|
+
return true;
|
|
143
|
+
} catch {
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
const realPathOfNearest = (filePath: string) => {
|
|
149
|
+
const rest: string[] = [];
|
|
150
|
+
let current = filePath;
|
|
151
|
+
while (!hasEntry(current) && path.dirname(current) !== current) {
|
|
152
|
+
rest.unshift(path.basename(current));
|
|
153
|
+
current = path.dirname(current);
|
|
154
|
+
}
|
|
155
|
+
return path.join(realpathSync(current), ...rest);
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const staysInside = (workspaceRoot: string, root: string, resolved: string) => {
|
|
159
|
+
try {
|
|
160
|
+
const realRoot = realPathOfNearest(root);
|
|
161
|
+
return isInside(realPathOfNearest(workspaceRoot), realRoot) && isInside(realRoot, realPathOfNearest(resolved));
|
|
162
|
+
} catch {
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
//? An MCP caller acts on text it was shown, so the plans and reports it names stay in the workspace's workflow folder.
|
|
168
|
+
export const workflowArtifactPath = (workspace: Workspace, filePath: string) => {
|
|
169
|
+
const root = path.resolve(workspace.workspaceRoot, workflowArtifactDir);
|
|
170
|
+
const resolved =
|
|
171
|
+
workflowRunIdPattern.test(filePath) && !filePath.endsWith(".json")
|
|
172
|
+
? path.join(root, "runs", `${filePath}.json`)
|
|
173
|
+
: path.resolve(workspace.workspaceRoot, filePath);
|
|
174
|
+
if (!isInside(root, resolved) || !staysInside(workspace.workspaceRoot, root, resolved))
|
|
175
|
+
throw new Error(
|
|
176
|
+
`MCP workflow files live under ${workflowArtifactDir}/ of the workspace, not ${JSON.stringify(filePath)}.`,
|
|
177
|
+
);
|
|
178
|
+
return resolved;
|
|
179
|
+
};
|
|
132
180
|
|
|
133
181
|
export const defaultWorkflowPlanPath = (workflow: string, inputs: WorkflowPlanInputs) => {
|
|
134
182
|
const slug = [
|
|
@@ -490,7 +538,7 @@ export const inspectAkanContext = async (
|
|
|
490
538
|
if (props.request.type === "workflowDiagnostics") {
|
|
491
539
|
const doctor = await AkanContextAnalyzer.doctor(workspace, {
|
|
492
540
|
strict: true,
|
|
493
|
-
runIdOrPlan:
|
|
541
|
+
runIdOrPlan: workflowArtifactPath(workspace, props.request.runIdOrPlan),
|
|
494
542
|
});
|
|
495
543
|
const toWorkflowDiagnostic = (
|
|
496
544
|
{ severity, code, message, scope }: AkanDiagnostic,
|
|
@@ -9,6 +9,7 @@ import type { App } from "./commandDecorators";
|
|
|
9
9
|
import { AppExecutor, Executor } from "./executors";
|
|
10
10
|
import { bundleDefine } from "./frontendBuild/bundleDefine";
|
|
11
11
|
import { createTempApp } from "./testHelpers";
|
|
12
|
+
import { ProjectTypecheck } from "./typecheck/projectTypecheck";
|
|
12
13
|
|
|
13
14
|
describe("ApplicationBuildRunner", () => {
|
|
14
15
|
test("externalizes Akan optional backend dependencies", () => {
|
|
@@ -83,6 +84,42 @@ console.info(new SampleService().name);
|
|
|
83
84
|
}
|
|
84
85
|
});
|
|
85
86
|
|
|
87
|
+
test("writes beside main.js the env its Dockerfile sets, so a host without the image runs the build as is", async () => {
|
|
88
|
+
const root = await mkdtemp(path.join(os.tmpdir(), "akan-build-meta-"));
|
|
89
|
+
const config = new AkanAppConfig(
|
|
90
|
+
{ name: "portal" } as never,
|
|
91
|
+
[],
|
|
92
|
+
{ name: "repo", version: "1.0.0", description: "repo" },
|
|
93
|
+
{},
|
|
94
|
+
{ repoName: "akanjs", serveDomain: "akanjs.com", env: "local", portOffset: 0, workspaceRoot: root },
|
|
95
|
+
);
|
|
96
|
+
const app = {
|
|
97
|
+
getConfig: async () => config,
|
|
98
|
+
dist: new Executor("dist", root),
|
|
99
|
+
workspace: { workspaceRoot: root },
|
|
100
|
+
logger: { warn: () => undefined },
|
|
101
|
+
} as unknown as App;
|
|
102
|
+
const builtEnv = async () => JSON.parse(await readFile(path.join(root, "akan.env.json"), "utf8")) as object;
|
|
103
|
+
const dockerfileEnv = async () =>
|
|
104
|
+
Object.fromEntries(
|
|
105
|
+
(await readFile(path.join(root, "Dockerfile"), "utf8"))
|
|
106
|
+
.split("\n")
|
|
107
|
+
.filter((line) => line.startsWith("ENV "))
|
|
108
|
+
.map((line) => line.slice("ENV ".length).split(/=(.*)/s).slice(0, 2)),
|
|
109
|
+
);
|
|
110
|
+
try {
|
|
111
|
+
await new ApplicationBuildRunner(app, { environment: "debug" }).buildAppMeta();
|
|
112
|
+
expect(await builtEnv()).toEqual(config.getProductionEnv("debug"));
|
|
113
|
+
expect(await builtEnv()).toEqual(await dockerfileEnv());
|
|
114
|
+
expect(await builtEnv()).toMatchObject({ AKAN_PUBLIC_APP_NAME: "portal", AKAN_PUBLIC_ENV: "debug" });
|
|
115
|
+
await new ApplicationBuildRunner(app).buildAppMeta();
|
|
116
|
+
expect(await builtEnv()).toMatchObject({ AKAN_PUBLIC_ENV: "local" });
|
|
117
|
+
expect(await builtEnv()).toEqual(await dockerfileEnv());
|
|
118
|
+
} finally {
|
|
119
|
+
await rm(root, { recursive: true, force: true });
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
86
123
|
test("writes the pruned lockfile beside package.json, or warns and leaves the image's install unlocked", async () => {
|
|
87
124
|
const root = await mkdtemp(path.join(os.tmpdir(), "akan-build-meta-"));
|
|
88
125
|
const config = new AkanAppConfig(
|
|
@@ -249,4 +286,20 @@ console.info(new SampleService().name);
|
|
|
249
286
|
await rm(root, { recursive: true, force: true });
|
|
250
287
|
}
|
|
251
288
|
});
|
|
289
|
+
|
|
290
|
+
test("akan typecheck names the script/ files nothing imports, and a build's typecheck leaves them out", async () => {
|
|
291
|
+
const { root, app } = await createTempApp("portal");
|
|
292
|
+
const run = spyOn(ProjectTypecheck.prototype, "run").mockImplementation(async () => "");
|
|
293
|
+
try {
|
|
294
|
+
const runner = new ApplicationBuildRunner(app);
|
|
295
|
+
await runner.typecheck({ tests: true });
|
|
296
|
+
await runner.typecheck();
|
|
297
|
+
const [checked, built] = run.mock.calls.map(([entries]) => entries);
|
|
298
|
+
expect(checked).toContain("../../script/**/*.ts");
|
|
299
|
+
expect(built).not.toContain("../../script/**/*.ts");
|
|
300
|
+
} finally {
|
|
301
|
+
run.mockRestore();
|
|
302
|
+
await rm(root, { recursive: true, force: true });
|
|
303
|
+
}
|
|
304
|
+
});
|
|
252
305
|
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import { toError } from "akanjs/common";
|
|
3
|
+
import { BUILD_ENV_FILE } from "akanjs/server/lifecycle/buildEnv";
|
|
3
4
|
import type { BunPlugin } from "bun";
|
|
4
5
|
import { ProductionLockfile } from "./akanConfig/productionLockfile";
|
|
5
6
|
import type {
|
|
@@ -25,8 +26,9 @@ import type { BaseDevEnv, PackageJson } from "./types";
|
|
|
25
26
|
export interface TypecheckOptions {
|
|
26
27
|
clean?: boolean;
|
|
27
28
|
incremental?: boolean;
|
|
28
|
-
/** The app's own tests and
|
|
29
|
+
/** The app's own tests, specs and `script/` files too (`akan typecheck`); a build leaves them out. */
|
|
29
30
|
tests?: boolean;
|
|
31
|
+
report?: "pretty" | "records";
|
|
30
32
|
}
|
|
31
33
|
|
|
32
34
|
export type BuildPhaseId = "prepare" | "typecheck" | "backend" | "ssr" | "csr" | "assets" | "compress" | "metadata";
|
|
@@ -205,6 +207,7 @@ export class ApplicationBuildRunner {
|
|
|
205
207
|
"../../page/**/*.ts",
|
|
206
208
|
"../../page/**/*.tsx",
|
|
207
209
|
...libPageIncludes,
|
|
210
|
+
...(options.tests ? ["../../script/**/*.ts"] : []),
|
|
208
211
|
"../../../../pkgs/akanjs/*/types/**/*.d.ts",
|
|
209
212
|
],
|
|
210
213
|
options,
|
|
@@ -238,13 +241,13 @@ export class ApplicationBuildRunner {
|
|
|
238
241
|
|
|
239
242
|
async buildAppMeta() {
|
|
240
243
|
const akanConfig = await this.#app.getConfig();
|
|
244
|
+
const environment = this.#environment ?? akanConfig.baseDevEnv.env;
|
|
241
245
|
const packageJson = akanConfig.getProductionPackageJson();
|
|
242
246
|
const lockfile = await this.#pruneLockfile(packageJson);
|
|
243
|
-
const dockerfile = akanConfig.dockerfileFor(
|
|
244
|
-
lockfile: lockfile !== null,
|
|
245
|
-
});
|
|
247
|
+
const dockerfile = akanConfig.dockerfileFor(environment, { lockfile: lockfile !== null });
|
|
246
248
|
await Promise.all([
|
|
247
249
|
this.#app.dist.writeJson("package.json", packageJson),
|
|
250
|
+
this.#app.dist.writeJson(BUILD_ENV_FILE, akanConfig.getProductionEnv(environment)),
|
|
248
251
|
...(lockfile !== null
|
|
249
252
|
? [this.#app.dist.writeFile(`${this.#app.dist.cwdPath}/${ProductionLockfile.fileName}`, lockfile)]
|
|
250
253
|
: []),
|
|
@@ -33,7 +33,7 @@ async function writeGeneratedOverridesLayoutFile(opts: {
|
|
|
33
33
|
const filename = `${opts.key.replace(/^\.\//, "").replace(/[^a-zA-Z0-9]+/g, "_")}.tsx`;
|
|
34
34
|
const absPath = path.join(path.resolve(opts.appCwdPath), IMPLICIT_OVERRIDES_DIR, filename);
|
|
35
35
|
const source = `"use client";
|
|
36
|
-
import { UiOverrideProvider } from "akanjs/ui";
|
|
36
|
+
import { UiOverrideProvider } from "akanjs/ui/UiOverride/UiOverride.tsx";
|
|
37
37
|
import { createElement, type ReactNode } from "react";
|
|
38
38
|
import value from ${JSON.stringify(importSpecifier(absPath, opts.userAbsPath))};
|
|
39
39
|
|
|
@@ -213,11 +213,13 @@ ${guardExport}
|
|
|
213
213
|
|
|
214
214
|
${layoutSignature}({ children, params, searchParams }: LayoutProps) {
|
|
215
215
|
${layoutBinding} return `;
|
|
216
|
+
//? System from its file, not the barrel: a dev registry compiles every module the barrel names, and with them the
|
|
217
|
+
//? optional peers (react-icons) an app drawing none of those components never installs.
|
|
216
218
|
const source = opts.includeSystemProvider
|
|
217
219
|
? `import type { LayoutModule, LayoutProps, PageProps } from "akanjs/client";
|
|
218
220
|
import { assertGuardOnChain, loadFonts, RouteGuards, resolveRouteModule, RouteHead } from "akanjs/client";
|
|
219
221
|
import type { ReactNode } from "react";
|
|
220
|
-
import { System } from "akanjs/ui";
|
|
222
|
+
import { System } from "akanjs/ui/System/index.tsx";
|
|
221
223
|
import type { McpAppRootProps } from "akanjs/webkit";
|
|
222
224
|
import { env } from "@apps/${opts.appName}/env/env.client";
|
|
223
225
|
import { allDictionary } from ${JSON.stringify(dictMacroSpecifier)};
|
package/biome.base.json
CHANGED
|
@@ -211,7 +211,10 @@
|
|
|
211
211
|
"!**/*.spec.ts",
|
|
212
212
|
"!**/*.spec.tsx"
|
|
213
213
|
],
|
|
214
|
-
"plugins": [
|
|
214
|
+
"plugins": [
|
|
215
|
+
"./node_modules/@akanjs/devkit/lint/no-init-fetch-in-client.grit",
|
|
216
|
+
"./node_modules/@akanjs/devkit/lint/no-oversized-list-limit.grit"
|
|
217
|
+
]
|
|
215
218
|
},
|
|
216
219
|
{
|
|
217
220
|
"includes": [
|
|
@@ -1,6 +1,24 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdir, stat } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { tempDirs } from "../testHelpers";
|
|
2
5
|
import { GlobalConfig } from "./globalConfig";
|
|
3
6
|
|
|
7
|
+
const tempHome = tempDirs("akan-global-config-");
|
|
8
|
+
//* A child process, so `~/.akan` resolves under the temp HOME and never the person's own.
|
|
9
|
+
const writeConfigUnder = async (home: string) => {
|
|
10
|
+
const proc = Bun.spawn(
|
|
11
|
+
[
|
|
12
|
+
"bun",
|
|
13
|
+
"-e",
|
|
14
|
+
`await (await import(${JSON.stringify(path.join(import.meta.dir, "globalConfig.ts"))})).GlobalConfig.setTestTargets({});`,
|
|
15
|
+
],
|
|
16
|
+
{ env: { ...process.env, HOME: home, USERPROFILE: home }, stdout: "pipe", stderr: "pipe" },
|
|
17
|
+
);
|
|
18
|
+
expect(await proc.exited).toBe(0);
|
|
19
|
+
};
|
|
20
|
+
const modeOf = async (target: string) => (await stat(target)).mode & 0o777;
|
|
21
|
+
|
|
4
22
|
const workspace = (cloudHost: string | null) => ({ getConfig: async () => ({ cloudHost }) });
|
|
5
23
|
|
|
6
24
|
describe("GlobalConfig", () => {
|
|
@@ -14,4 +32,20 @@ describe("GlobalConfig", () => {
|
|
|
14
32
|
expect(await GlobalConfig.hostFor(workspace(null), "http://localhost:8283/")).toBe("http://localhost:8283");
|
|
15
33
|
expect(await GlobalConfig.hostFor(workspace(null))).toBe(GlobalConfig.akanCloudHost);
|
|
16
34
|
});
|
|
35
|
+
|
|
36
|
+
test.skipIf(process.platform === "win32")(
|
|
37
|
+
"keeps ~/.akan to its owner: a new one is made 0700 and a looser one it owns is tightened",
|
|
38
|
+
async () => {
|
|
39
|
+
const fresh = await tempHome();
|
|
40
|
+
await writeConfigUnder(fresh);
|
|
41
|
+
expect(await modeOf(path.join(fresh, ".akan"))).toBe(0o700);
|
|
42
|
+
expect(await modeOf(path.join(fresh, ".akan", "config.json"))).toBe(0o600);
|
|
43
|
+
|
|
44
|
+
const loose = await tempHome();
|
|
45
|
+
await mkdir(path.join(loose, ".akan"), { mode: 0o755 });
|
|
46
|
+
await writeConfigUnder(loose);
|
|
47
|
+
expect(await modeOf(path.join(loose, ".akan"))).toBe(0o700);
|
|
48
|
+
},
|
|
49
|
+
20_000,
|
|
50
|
+
);
|
|
17
51
|
});
|
package/cloud/globalConfig.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { rm, writeFile } from "node:fs/promises";
|
|
2
2
|
import { trimTrailingSlashes } from "akanjs/common";
|
|
3
3
|
import dayjs from "dayjs";
|
|
4
4
|
import { FileSys } from "../fileSys";
|
|
@@ -36,7 +36,7 @@ export class GlobalConfig {
|
|
|
36
36
|
// Holds the cloud jwt and refresh token, so owner-only 0600: written to a 0600 sibling and renamed over the file,
|
|
37
37
|
// which also replaces a world-readable one and never shows another CLI process a half-written file.
|
|
38
38
|
static async #setAkanGlobalConfig(akanConfig: AkanGlobalConfig) {
|
|
39
|
-
|
|
39
|
+
FileSys.ensurePrivateDir(basePath);
|
|
40
40
|
const temp = `${configPath}.${process.pid}.${crypto.randomUUID()}.tmp`;
|
|
41
41
|
try {
|
|
42
42
|
await writeFile(temp, JSON.stringify(akanConfig, null, 2), { mode: 0o600 });
|