@akanjs/devkit 3.1.1 → 3.1.2-rc.1
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/akanConfig/akanConfig.test.ts +75 -0
- package/akanConfig/akanConfig.ts +11 -1
- package/akanMcpContract.test.ts +28 -1
- package/akanMcpContract.ts +51 -3
- package/applicationBuildRunner.test.ts +36 -0
- package/applicationBuildRunner.ts +4 -3
- package/artifact/implicitRootLayout.ts +4 -2
- 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 +7 -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/WorkspaceTrust.ts +200 -0
- package/codeAgent/agent/akanCodePaths.ts +8 -1
- package/codeAgent/agent/akanSystemPrompt.ts +4 -0
- 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/skills/akan-agent/SKILL.md +8 -1
- package/codeAgent/skills/akan-code/SKILL.md +37 -11
- package/codeAgent/skills/akan-debug/SKILL.md +2 -1
- package/codeAgent/skills/akan-err/SKILL.md +2 -1
- package/codeAgent/skills/akan-query/SKILL.md +3 -2
- package/codeAgent/skills/akan-runtime/SKILL.md +3 -0
- 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 +70 -2
- package/codeAgent/tools/mcpOAuth.test.ts +50 -14
- package/codeAgent/tools/mcpServerConfig.test.ts +130 -1
- package/commandDecorators/command.ts +4 -1
- package/createTunnel.test.ts +200 -2
- package/createTunnel.ts +80 -6
- package/envSeed.test.ts +90 -0
- package/envSeed.ts +68 -0
- package/executors.test.ts +19 -1
- package/executors.ts +41 -2
- 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/integration/ssrMemoryProbe.ts +6 -0
- package/lint/no-unguarded-endpoint.grit +5 -5
- package/maintainabilityReport.ts +1 -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 +3 -2
- package/shellOnlyEnv.test.ts +65 -0
- package/shellOnlyEnv.ts +54 -0
- package/signalMutationIndex.ts +22 -81
- package/sshHostKeys.ts +59 -0
- package/storeSource.ts +9 -8
- package/subspace.test.ts +10 -0
- package/subspaceClone.ts +3 -1
- package/subspaceConfig.ts +6 -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/workflow/mutationScaffold.ts +35 -21
- package/workflow/plan.ts +28 -0
- package/workflow/sourceAst.ts +10 -48
|
@@ -4,6 +4,7 @@ import os from "node:os";
|
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
import { AppExecutor, WorkspaceExecutor } from "../executors";
|
|
7
|
+
import { NativeConfig } from "../mobile/nativeConfig";
|
|
7
8
|
import type { PackageJson } from "../types";
|
|
8
9
|
import { AkanAppConfig, AkanLibConfig, deriveDefaultAppId } from "./akanConfig";
|
|
9
10
|
import type { LibConfigInput } from "./types";
|
|
@@ -165,6 +166,23 @@ describe("AkanAppConfig", () => {
|
|
|
165
166
|
expect(unlocked).not.toContain("bun.lock");
|
|
166
167
|
});
|
|
167
168
|
|
|
169
|
+
test("writes a Dockerfile for a local build that refuses docker build without an explicit build arg", () => {
|
|
170
|
+
const config = new AkanAppConfig(app, [], packageJson, {}, baseDevEnv);
|
|
171
|
+
const local = config.dockerfileFor("local");
|
|
172
|
+
const guard = local.split("\n").find((line) => line.includes("AKAN_ALLOW_LOCAL_IMAGE") && line.startsWith("RUN "));
|
|
173
|
+
if (!guard) throw new Error("expected the local image guard");
|
|
174
|
+
const run = (allow?: string) =>
|
|
175
|
+
Bun.spawnSync(["sh", "-c", guard.slice("RUN ".length)], {
|
|
176
|
+
env: { PATH: process.env.PATH ?? "", ...(allow ? { AKAN_ALLOW_LOCAL_IMAGE: allow } : {}) },
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
expect(local).toContain(`FROM ${pinnedBunImage}\nARG AKAN_ALLOW_LOCAL_IMAGE\nRUN if`);
|
|
180
|
+
expect(config.dockerfileFor("main")).not.toContain("AKAN_ALLOW_LOCAL_IMAGE");
|
|
181
|
+
expect(run().exitCode).toBe(1);
|
|
182
|
+
expect(run().stderr.toString()).toContain("--build-arg AKAN_ALLOW_LOCAL_IMAGE=1");
|
|
183
|
+
expect(run("1").exitCode).toBe(0);
|
|
184
|
+
});
|
|
185
|
+
|
|
168
186
|
test("defaults both web surfaces on and keeps the image free of web env overrides", () => {
|
|
169
187
|
const config = new AkanAppConfig(app, [], packageJson, {}, baseDevEnv);
|
|
170
188
|
|
|
@@ -220,6 +238,17 @@ describe("AkanAppConfig", () => {
|
|
|
220
238
|
expect(config.dockerfile).toContain(`COPY . .\n${envBlock}\nCMD ["bun","main.js"]`);
|
|
221
239
|
});
|
|
222
240
|
|
|
241
|
+
test('publishes localePrefix "as-needed" to the bundles and the image, and leaves the default out of the image', () => {
|
|
242
|
+
const asNeeded = new AkanAppConfig(app, [], packageJson, { i18n: { localePrefix: "as-needed" } }, baseDevEnv);
|
|
243
|
+
expect(process.env.AKAN_PUBLIC_LOCALE_PREFIX).toBe("as-needed");
|
|
244
|
+
expect(asNeeded.getProductionEnv().AKAN_PUBLIC_LOCALE_PREFIX).toBe("as-needed");
|
|
245
|
+
expect(asNeeded.dockerfile).toContain("ENV AKAN_PUBLIC_LOCALE_PREFIX=as-needed");
|
|
246
|
+
|
|
247
|
+
const always = new AkanAppConfig(app, [], packageJson, {}, baseDevEnv);
|
|
248
|
+
expect(process.env.AKAN_PUBLIC_LOCALE_PREFIX).toBe("always");
|
|
249
|
+
expect(always.getProductionEnv()).not.toHaveProperty("AKAN_PUBLIC_LOCALE_PREFIX");
|
|
250
|
+
});
|
|
251
|
+
|
|
223
252
|
test("writes the Dockerfile instructions it wrote before its env came from getProductionEnv", () => {
|
|
224
253
|
//? 9adfb95c's output for this config, less the blank lines its empty interpolations left and its floating tag.
|
|
225
254
|
const before = [
|
|
@@ -679,6 +708,52 @@ describe("AkanAppConfig", () => {
|
|
|
679
708
|
).toThrow("native.updates in apps/portal/akan.config.ts has no url or publicKeys.");
|
|
680
709
|
});
|
|
681
710
|
|
|
711
|
+
test("takes the page policy, the opener's links and the release checks from akan.config.ts into the native build", () => {
|
|
712
|
+
const updates = { url: "https://releases.example.com/portal", publicKey: "key=", expiresIn: 86_400 };
|
|
713
|
+
const native = new AkanAppConfig(
|
|
714
|
+
app,
|
|
715
|
+
[],
|
|
716
|
+
packageJson,
|
|
717
|
+
{
|
|
718
|
+
native: {
|
|
719
|
+
csp: { "connect-src": ["https://upload.example.com"] },
|
|
720
|
+
openUrls: ["https://*.example.com/*"],
|
|
721
|
+
updates,
|
|
722
|
+
targets: {
|
|
723
|
+
default: {},
|
|
724
|
+
kiosk: { csp: false, openUrls: ["*"], updates: { windowsSigners: ["AB12"] } },
|
|
725
|
+
},
|
|
726
|
+
},
|
|
727
|
+
},
|
|
728
|
+
baseDevEnv,
|
|
729
|
+
).native;
|
|
730
|
+
const build = (name: string) =>
|
|
731
|
+
NativeConfig.build({
|
|
732
|
+
appPath: "/repo/apps/portal",
|
|
733
|
+
target: native.targets[name] as NonNullable<(typeof native.targets)[string]>,
|
|
734
|
+
webDir: "/web",
|
|
735
|
+
contributions: [],
|
|
736
|
+
locales: ["en"],
|
|
737
|
+
platform: "windows",
|
|
738
|
+
publicEnv: { AKAN_PUBLIC_APP_NAME: "portal", AKAN_PUBLIC_ENV: "main", AKAN_PUBLIC_SERVE_DOMAIN: "example.com" },
|
|
739
|
+
}).config;
|
|
740
|
+
const opener = (config: ReturnType<typeof build>) =>
|
|
741
|
+
config.capabilities?.[0]?.permissions.find((permission) => JSON.stringify(permission).includes("opener"));
|
|
742
|
+
|
|
743
|
+
const store = build("default");
|
|
744
|
+
expect(store.security?.csp).toMatchObject({
|
|
745
|
+
"connect-src": expect.arrayContaining(["https://upload.example.com"]),
|
|
746
|
+
});
|
|
747
|
+
expect(opener(store)).toEqual({ identifier: "opener:default", allow: [{ url: "https://*.example.com/*" }] });
|
|
748
|
+
expect(store.updates).toMatchObject({ expiresIn: 86_400 });
|
|
749
|
+
expect(store.updates).not.toHaveProperty("windowsSigners");
|
|
750
|
+
|
|
751
|
+
const kiosk = build("kiosk");
|
|
752
|
+
expect(kiosk.security).toBeUndefined();
|
|
753
|
+
expect(opener(kiosk)).toBe("opener:default");
|
|
754
|
+
expect(kiosk.updates).toMatchObject({ expiresIn: 86_400, windowsSigners: ["AB12"] });
|
|
755
|
+
});
|
|
756
|
+
|
|
682
757
|
test("refuses updates holding both publicKey and publicKeys, a target's own key beside the section's list included", () => {
|
|
683
758
|
const url = "https://releases.example.com/portal";
|
|
684
759
|
const resolve = (native: object) =>
|
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
|
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,
|
|
@@ -84,6 +84,42 @@ console.info(new SampleService().name);
|
|
|
84
84
|
}
|
|
85
85
|
});
|
|
86
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
|
+
|
|
87
123
|
test("writes the pruned lockfile beside package.json, or warns and leaves the image's install unlocked", async () => {
|
|
88
124
|
const root = await mkdtemp(path.join(os.tmpdir(), "akan-build-meta-"));
|
|
89
125
|
const config = new AkanAppConfig(
|
|
@@ -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 {
|
|
@@ -240,13 +241,13 @@ export class ApplicationBuildRunner {
|
|
|
240
241
|
|
|
241
242
|
async buildAppMeta() {
|
|
242
243
|
const akanConfig = await this.#app.getConfig();
|
|
244
|
+
const environment = this.#environment ?? akanConfig.baseDevEnv.env;
|
|
243
245
|
const packageJson = akanConfig.getProductionPackageJson();
|
|
244
246
|
const lockfile = await this.#pruneLockfile(packageJson);
|
|
245
|
-
const dockerfile = akanConfig.dockerfileFor(
|
|
246
|
-
lockfile: lockfile !== null,
|
|
247
|
-
});
|
|
247
|
+
const dockerfile = akanConfig.dockerfileFor(environment, { lockfile: lockfile !== null });
|
|
248
248
|
await Promise.all([
|
|
249
249
|
this.#app.dist.writeJson("package.json", packageJson),
|
|
250
|
+
this.#app.dist.writeJson(BUILD_ENV_FILE, akanConfig.getProductionEnv(environment)),
|
|
250
251
|
...(lockfile !== null
|
|
251
252
|
? [this.#app.dist.writeFile(`${this.#app.dist.cwdPath}/${ProductionLockfile.fileName}`, lockfile)]
|
|
252
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)};
|
|
@@ -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 });
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { ShellOnlyEnv } from "../../shellOnlyEnv";
|
|
2
|
+
|
|
3
|
+
export const akanCodeGuardedEnv = [
|
|
4
|
+
"AKAN_CODE_HOME",
|
|
5
|
+
"AKAN_CODE_CAN_PROMPT",
|
|
6
|
+
"AKAN_CODE_PROXY_URL",
|
|
7
|
+
"AKAN_CODE_PROXY_TOKEN",
|
|
8
|
+
"AKAN_CODE_PROXY_TOKEN_FILE",
|
|
9
|
+
"AKAN_CODE_PROXY_PROVIDERS",
|
|
10
|
+
"AKAN_CODE_RPC_TOKEN_FILE",
|
|
11
|
+
] as const;
|
|
12
|
+
|
|
13
|
+
export type AkanCodeGuardedEnvKey = (typeof akanCodeGuardedEnv)[number];
|
|
14
|
+
|
|
15
|
+
// Each key moves the credential store, a confirmation or the model traffic.
|
|
16
|
+
export class AkanCodeEnv {
|
|
17
|
+
static readonly #env = new ShellOnlyEnv(akanCodeGuardedEnv);
|
|
18
|
+
|
|
19
|
+
static get(key: AkanCodeGuardedEnvKey): string | undefined {
|
|
20
|
+
return AkanCodeEnv.#env.get(key);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
static ignored() {
|
|
24
|
+
return AkanCodeEnv.#env.ignored();
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -10,6 +10,7 @@ import { akanCodePaths } from "./akanCodePaths";
|
|
|
10
10
|
import { akanSystemPrompt } from "./akanSystemPrompt";
|
|
11
11
|
import type { CodeAgentProxy } from "./CodeAgentProxy";
|
|
12
12
|
import { CodeSessionIndex } from "./CodeSessionIndex";
|
|
13
|
+
import { WorkspaceTrust } from "./WorkspaceTrust";
|
|
13
14
|
|
|
14
15
|
export interface AkanCodeServicesOptions {
|
|
15
16
|
workspaceRoot: string;
|
|
@@ -34,7 +35,7 @@ export class AkanCodeServices {
|
|
|
34
35
|
|
|
35
36
|
// The engine locks and rewrites `authPath` itself, so two `akan code` refreshing a token at once cannot clobber it.
|
|
36
37
|
static async runtime(workspaceRoot: string, proxy: CodeAgentProxy | null = null) {
|
|
37
|
-
|
|
38
|
+
akanCodePaths.ensureGlobalDir();
|
|
38
39
|
const runtime = await ModelRuntime.create({
|
|
39
40
|
//* Behind a proxy no real key may reach a request, so credentials live in a throwaway file.
|
|
40
41
|
authPath: proxy
|
|
@@ -88,7 +89,9 @@ export class AkanCodeServices {
|
|
|
88
89
|
: agentsFiles,
|
|
89
90
|
}),
|
|
90
91
|
appendSystemPrompt: [akanSystemPrompt(options.profile)],
|
|
91
|
-
additionalExtensionPaths:
|
|
92
|
+
additionalExtensionPaths: WorkspaceTrust.isTrusted(options.workspaceRoot, "extensions")
|
|
93
|
+
? present(akanCodePaths.extensionsDir(options.workspaceRoot))
|
|
94
|
+
: [],
|
|
92
95
|
additionalSkillPaths: AkanCodeServices.#skillPaths(options.profile, options.workspaceRoot),
|
|
93
96
|
extensionFactories: options.extensions,
|
|
94
97
|
};
|
|
@@ -535,6 +535,13 @@ export class CodeAgent {
|
|
|
535
535
|
const verdict = this.#gate.verdict(event.toolName, event.input);
|
|
536
536
|
if (verdict.block) return this.#refuse(event.toolCallId, event.toolName, event.input, verdict.block);
|
|
537
537
|
if (!verdict.approval) return undefined;
|
|
538
|
+
if (verdict.strict && !this.#profile.ui.canPrompt)
|
|
539
|
+
return this.#refuse(
|
|
540
|
+
event.toolCallId,
|
|
541
|
+
event.toolName,
|
|
542
|
+
event.input,
|
|
543
|
+
"This reaches a host outside the profile's list, which needs the user's approval, and nobody can be asked in this session. Use a listed host, or say what you need fetched.",
|
|
544
|
+
);
|
|
538
545
|
const approved = await this.#requestApproval(event.toolCallId, event.toolName, event.input, verdict.approval);
|
|
539
546
|
if (approved) return undefined;
|
|
540
547
|
return this.#refuse(event.toolCallId, event.toolName, event.input, "The user declined this action.");
|