@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
|
@@ -43,12 +43,14 @@ describe("NativePublicEnv", () => {
|
|
|
43
43
|
AKAN_PUBLIC_MAP_TOKEN: "x",
|
|
44
44
|
AKAN_PUBLIC_PORT: 8282,
|
|
45
45
|
AKAN_PUBLIC_NOTE: "line\nbreak",
|
|
46
|
+
AKAN_PUBLIC_SIGNER: "-----BEGIN EC PRIVATE KEY-----\\nMHc\\n-----END EC PRIVATE KEY-----",
|
|
46
47
|
}),
|
|
47
48
|
).toEqual([
|
|
48
49
|
"AKAN_PUBLIC_lower is not an AKAN_PUBLIC_[A-Z0-9_] name",
|
|
49
50
|
"AKAN_PUBLIC_MAP_TOKEN names a token, which a page bundle must not carry",
|
|
50
51
|
"AKAN_PUBLIC_PORT is not a string",
|
|
51
52
|
"AKAN_PUBLIC_NOTE holds a control character",
|
|
53
|
+
"AKAN_PUBLIC_SIGNER holds a private key",
|
|
52
54
|
]);
|
|
53
55
|
const many = Object.fromEntries(Array.from({ length: 257 }, (_, idx) => [`AKAN_PUBLIC_K${idx}`, "v"]));
|
|
54
56
|
expect(NativePublicEnv.problems(many)).toEqual(["257 keys, more than 256"]);
|
|
@@ -16,6 +16,7 @@ export class NativePublicEnv {
|
|
|
16
16
|
"AKAN_PUBLIC_RENDER_ENV",
|
|
17
17
|
"AKAN_PUBLIC_DEFAULT_LOCALE",
|
|
18
18
|
"AKAN_PUBLIC_LOCALES",
|
|
19
|
+
"AKAN_PUBLIC_LOCALE_PREFIX",
|
|
19
20
|
];
|
|
20
21
|
//? Akan Cloud's own limits on a recorded env: a build it would refuse fails here, before anything is signed.
|
|
21
22
|
static readonly #keyPattern = /^AKAN_PUBLIC_[A-Z0-9_]{1,128}$/;
|
|
@@ -46,6 +47,7 @@ export class NativePublicEnv {
|
|
|
46
47
|
const word = NativePublicEnv.#secretWords.find((secret) => key.includes(secret));
|
|
47
48
|
if (word) return [`${key} names a ${word.toLowerCase()}, which a page bundle must not carry`];
|
|
48
49
|
if (typeof value !== "string") return [`${key} is not a string`];
|
|
50
|
+
if (/-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----/.test(value)) return [`${key} holds a private key`];
|
|
49
51
|
// biome-ignore lint/suspicious/noControlCharactersInRegex: the control characters are what is refused
|
|
50
52
|
if (/[\u0000-\u001f\u007f]/.test(value)) return [`${key} holds a control character`];
|
|
51
53
|
return [];
|
|
@@ -71,6 +73,17 @@ export class NativePublicEnv {
|
|
|
71
73
|
return NativePublicEnv.of((await file.json()) as Record<string, unknown>);
|
|
72
74
|
}
|
|
73
75
|
|
|
76
|
+
/** Every `AKAN_PUBLIC_*` the last `akan build` inlined, derived keys included, else the ones this process has. */
|
|
77
|
+
static async inlined(app: App): Promise<PublicEnv> {
|
|
78
|
+
const file = Bun.file(CsrArtifactBuilder.publicEnvPath(app));
|
|
79
|
+
const env = (await file.exists()) ? ((await file.json()) as Record<string, unknown>) : process.env;
|
|
80
|
+
return Object.fromEntries(
|
|
81
|
+
Object.entries(env).flatMap(([key, value]) =>
|
|
82
|
+
key.startsWith("AKAN_PUBLIC_") && typeof value === "string" ? [[key, value]] : [],
|
|
83
|
+
),
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
74
87
|
/** A bundle.json's record, or null when it has none (a build from before the record, or no file at all). */
|
|
75
88
|
static async recordedIn(bundleFile: string): Promise<PublicEnv | null> {
|
|
76
89
|
const file = Bun.file(bundleFile);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@akanjs/devkit",
|
|
3
|
-
"version": "3.1.1",
|
|
3
|
+
"version": "3.1.2-rc.1",
|
|
4
4
|
"sourceType": "module",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"publishConfig": {
|
|
@@ -41,6 +41,7 @@
|
|
|
41
41
|
"./*": "./*.ts"
|
|
42
42
|
},
|
|
43
43
|
"dependencies": {
|
|
44
|
+
"@akanjs/language": "3.1.2-rc.1",
|
|
44
45
|
"@earendil-works/pi-agent-core": "1.3.0",
|
|
45
46
|
"@earendil-works/pi-ai": "1.3.0",
|
|
46
47
|
"@earendil-works/pi-codemode": "1.3.0",
|
|
@@ -49,7 +50,7 @@
|
|
|
49
50
|
"@earendil-works/pi-tui": "1.3.0",
|
|
50
51
|
"@inquirer/prompts": "^8.7.3",
|
|
51
52
|
"@tailwindcss/node": "^4.3.3",
|
|
52
|
-
"akanjs": "3.1.1",
|
|
53
|
+
"akanjs": "3.1.2-rc.1",
|
|
53
54
|
"chalk": "^6.0.1",
|
|
54
55
|
"commander": "^15.0.0",
|
|
55
56
|
"dayjs": "^1.11.23",
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { ShellOnlyEnv } from "./shellOnlyEnv";
|
|
6
|
+
|
|
7
|
+
const key = "AKAN_SHELL_ONLY_TEST_KEY";
|
|
8
|
+
const shellValue = "from-shell";
|
|
9
|
+
|
|
10
|
+
const inRepo = (files: Record<string, string>, run: (env: ShellOnlyEnv<typeof key>) => void) => {
|
|
11
|
+
const repo = mkdtempSync(path.join(tmpdir(), "akan-shell-only-env-"));
|
|
12
|
+
const cwd = process.cwd();
|
|
13
|
+
try {
|
|
14
|
+
for (const [name, content] of Object.entries(files)) writeFileSync(path.join(repo, name), content);
|
|
15
|
+
process.chdir(repo);
|
|
16
|
+
process.env[key] = shellValue;
|
|
17
|
+
run(new ShellOnlyEnv([key]));
|
|
18
|
+
} finally {
|
|
19
|
+
process.chdir(cwd);
|
|
20
|
+
delete process.env[key];
|
|
21
|
+
rmSync(repo, { recursive: true, force: true });
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
describe("ShellOnlyEnv", () => {
|
|
26
|
+
test.each([
|
|
27
|
+
".env",
|
|
28
|
+
".env.local",
|
|
29
|
+
".env.development",
|
|
30
|
+
".env.development.local",
|
|
31
|
+
".env.production",
|
|
32
|
+
".env.production.local",
|
|
33
|
+
".env.test",
|
|
34
|
+
".env.test.local",
|
|
35
|
+
])("ignores a key that %s names", (file) => {
|
|
36
|
+
inRepo({ [file]: `${key}=placeholder\n` }, (env) => {
|
|
37
|
+
expect(env.get(key)).toBeUndefined();
|
|
38
|
+
expect(env.ignored()).toEqual([
|
|
39
|
+
`${key} is set in ${file}, which the repository controls, so it is ignored. Export it in your shell instead.`,
|
|
40
|
+
]);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test.each([
|
|
45
|
+
`${key}=placeholder`,
|
|
46
|
+
`export ${key}=placeholder`,
|
|
47
|
+
` ${key} = placeholder`,
|
|
48
|
+
`${key}: placeholder`,
|
|
49
|
+
`export ${key}: placeholder`,
|
|
50
|
+
`${key}\t:\tplaceholder`,
|
|
51
|
+
`${key}:`,
|
|
52
|
+
`${key}=placeholder\r`,
|
|
53
|
+
])("ignores a key set as %j", (line) => {
|
|
54
|
+
inRepo({ ".env": `OTHER_KEY=placeholder\n${line}\n` }, (env) => {
|
|
55
|
+
expect(env.get(key)).toBeUndefined();
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("reads the shell value when no dotenv file names the key", () => {
|
|
60
|
+
inRepo({ ".env": `OTHER_KEY=placeholder\n# ${key}=placeholder\n${key}_SUFFIX=placeholder\n` }, (env) => {
|
|
61
|
+
expect(env.get(key)).toBe(shellValue);
|
|
62
|
+
expect(env.ignored()).toEqual([]);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
});
|
package/shellOnlyEnv.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
// Bun loads the ones NODE_ENV picks from the working directory before code runs, so a cloned repo's file can set them.
|
|
5
|
+
const dotenvFiles = [
|
|
6
|
+
".env",
|
|
7
|
+
".env.local",
|
|
8
|
+
".env.development",
|
|
9
|
+
".env.development.local",
|
|
10
|
+
".env.production",
|
|
11
|
+
".env.production.local",
|
|
12
|
+
".env.test",
|
|
13
|
+
".env.test.local",
|
|
14
|
+
];
|
|
15
|
+
// Bun also reads `KEY: value` lines.
|
|
16
|
+
const assignedKey = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*[=:]/;
|
|
17
|
+
|
|
18
|
+
// Judged by presence, not value: a comparison would trust whatever a parser difference with Bun's reader lets through.
|
|
19
|
+
export class ShellOnlyEnv<Key extends string> {
|
|
20
|
+
readonly #keys: ReadonlySet<string>;
|
|
21
|
+
#cache: { cwd: string; found: Map<string, string> } | undefined;
|
|
22
|
+
|
|
23
|
+
constructor(keys: readonly Key[]) {
|
|
24
|
+
this.#keys = new Set(keys);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
get(key: Key): string | undefined {
|
|
28
|
+
if (this.#found().has(key)) return undefined;
|
|
29
|
+
return process.env[key];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
ignored() {
|
|
33
|
+
return [...this.#found()].map(
|
|
34
|
+
([key, file]) =>
|
|
35
|
+
`${key} is set in ${file}, which the repository controls, so it is ignored. Export it in your shell instead.`,
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
#found() {
|
|
40
|
+
const cwd = process.cwd();
|
|
41
|
+
if (this.#cache?.cwd === cwd) return this.#cache.found;
|
|
42
|
+
const found = new Map<string, string>();
|
|
43
|
+
for (const name of dotenvFiles) {
|
|
44
|
+
const file = path.join(cwd, name);
|
|
45
|
+
if (!existsSync(file)) continue;
|
|
46
|
+
for (const line of readFileSync(file, "utf8").split("\n")) {
|
|
47
|
+
const key = line.match(assignedKey)?.[1];
|
|
48
|
+
if (key && this.#keys.has(key) && !found.has(key)) found.set(key, name);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
this.#cache = { cwd, found };
|
|
52
|
+
return found;
|
|
53
|
+
}
|
|
54
|
+
}
|
package/signalMutationIndex.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { createAst, createFactReader, type FileFacts } from "@akanjs/language";
|
|
2
|
+
import { modelNames } from "akanjs/common";
|
|
1
3
|
import ts from "typescript";
|
|
2
4
|
import type { SourceFileInfo } from "./qualityScanner";
|
|
3
5
|
|
|
@@ -8,8 +10,14 @@ interface EndpointShape {
|
|
|
8
10
|
|
|
9
11
|
export class SignalMutationIndex {
|
|
10
12
|
//? As the client generates them: `update<Model>(id, data, option?)`, `add<Model>Files(files, parentId?, option?)`.
|
|
11
|
-
static #generatedArgCounts =
|
|
12
|
-
|
|
13
|
+
static #generatedArgCounts = [
|
|
14
|
+
["createModel", 1],
|
|
15
|
+
["updateModel", 2],
|
|
16
|
+
["removeModel", 1],
|
|
17
|
+
["mergeModel", 2],
|
|
18
|
+
["addModelFiles", 2],
|
|
19
|
+
] as const;
|
|
20
|
+
static #reader = createFactReader(ts, createAst(ts));
|
|
13
21
|
readonly #shapesByOwner = new Map<string, Map<string, EndpointShape>>();
|
|
14
22
|
|
|
15
23
|
constructor(sourceFiles: SourceFileInfo[]) {
|
|
@@ -17,7 +25,7 @@ export class SignalMutationIndex {
|
|
|
17
25
|
if (!/^(apps|libs)\/[^/]+\/lib\/.+\.signal\.ts$/.test(file)) continue;
|
|
18
26
|
const owner = file.split("/").slice(0, 2).join("/");
|
|
19
27
|
const shapes = this.#shapesByOwner.get(owner) ?? new Map<string, EndpointShape>();
|
|
20
|
-
SignalMutationIndex.#collect(sourceFile, shapes);
|
|
28
|
+
SignalMutationIndex.#collect(SignalMutationIndex.#reader.read(sourceFile, owner), shapes);
|
|
21
29
|
this.#shapesByOwner.set(owner, shapes);
|
|
22
30
|
}
|
|
23
31
|
}
|
|
@@ -43,85 +51,18 @@ export class SignalMutationIndex {
|
|
|
43
51
|
return undefined;
|
|
44
52
|
}
|
|
45
53
|
|
|
46
|
-
static #collect(
|
|
47
|
-
const
|
|
48
|
-
if (
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
ts.forEachChild(node, visit);
|
|
53
|
-
};
|
|
54
|
-
visit(sourceFile);
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
static #collectGenerated(slice: ts.CallExpression, kinds: Map<string, EndpointShape>) {
|
|
58
|
-
let service: ts.Expression | undefined = slice.arguments[0];
|
|
59
|
-
while (service && ts.isCallExpression(service) && ts.isPropertyAccessExpression(service.expression))
|
|
60
|
-
service = service.expression.expression;
|
|
61
|
-
if (!service || !ts.isPropertyAccessExpression(service) || !SignalMutationIndex.#isNamed(service.expression, "srv"))
|
|
62
|
-
return;
|
|
63
|
-
const model = service.name.text;
|
|
64
|
-
const capModel = `${model.charAt(0).toUpperCase()}${model.slice(1)}`;
|
|
65
|
-
for (const [verb, argCount] of Object.entries(SignalMutationIndex.#generatedArgCounts))
|
|
66
|
-
kinds.set(`${verb}${capModel}`, { kind: "mutation", argCount });
|
|
67
|
-
kinds.set(`add${capModel}Files`, { kind: "mutation", argCount: 2 });
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
static #collectEndpoints(endpoint: ts.CallExpression, kinds: Map<string, EndpointShape>) {
|
|
71
|
-
const builder = endpoint.arguments[1];
|
|
72
|
-
if (!builder || (!ts.isArrowFunction(builder) && !ts.isFunctionExpression(builder))) return;
|
|
73
|
-
const map = SignalMutationIndex.#returnedObjectOf(builder);
|
|
74
|
-
if (!map) return;
|
|
75
|
-
const verbOf = SignalMutationIndex.#verbReaderOf(builder.parameters[0]);
|
|
76
|
-
for (const property of map.properties) {
|
|
77
|
-
if (!ts.isPropertyAssignment(property)) continue;
|
|
78
|
-
if (!ts.isIdentifier(property.name) && !ts.isStringLiteral(property.name)) continue;
|
|
79
|
-
const { root, argCount } = SignalMutationIndex.#chainOf(property.initializer);
|
|
80
|
-
const verb = verbOf(root);
|
|
81
|
-
//? A socket message writes with the visitor's credential as a mutation does, and the client refuses it alike.
|
|
82
|
-
const kind = verb === "message" ? "mutation" : verb;
|
|
83
|
-
if (kind === "query" || kind === "mutation") kinds.set(property.name.text, { kind, argCount });
|
|
54
|
+
static #collect(facts: FileFacts, shapes: Map<string, EndpointShape>) {
|
|
55
|
+
for (const { model } of facts.slices) {
|
|
56
|
+
if (!model) continue;
|
|
57
|
+
const names = modelNames(model);
|
|
58
|
+
for (const [role, argCount] of SignalMutationIndex.#generatedArgCounts)
|
|
59
|
+
shapes.set(names[role], { kind: "mutation", argCount });
|
|
84
60
|
}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
while (body && ts.isParenthesizedExpression(body)) body = body.expression;
|
|
91
|
-
return body && ts.isObjectLiteralExpression(body) ? body : null;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
static #verbReaderOf(parameter: ts.ParameterDeclaration | undefined) {
|
|
95
|
-
const verbs = new Map<string, string>();
|
|
96
|
-
if (parameter && ts.isObjectBindingPattern(parameter.name))
|
|
97
|
-
for (const element of parameter.name.elements) {
|
|
98
|
-
const verb = element.propertyName ?? element.name;
|
|
99
|
-
if (ts.isIdentifier(verb) && ts.isIdentifier(element.name)) verbs.set(element.name.text, verb.text);
|
|
61
|
+
for (const { keys } of facts.endpoints)
|
|
62
|
+
for (const { name, verb, argCount } of keys) {
|
|
63
|
+
//? A socket message writes with the visitor's credential as a mutation does, and the client refuses it alike.
|
|
64
|
+
const kind = verb === "message" ? "mutation" : verb;
|
|
65
|
+
if (kind === "query" || kind === "mutation") shapes.set(name, { kind, argCount });
|
|
100
66
|
}
|
|
101
|
-
const holder = parameter && ts.isIdentifier(parameter.name) ? parameter.name.text : null;
|
|
102
|
-
return (callee: ts.Expression | undefined) => {
|
|
103
|
-
if (callee && ts.isIdentifier(callee)) return verbs.get(callee.text);
|
|
104
|
-
if (callee && ts.isPropertyAccessExpression(callee) && SignalMutationIndex.#isNamed(callee.expression, holder))
|
|
105
|
-
return callee.name.text;
|
|
106
|
-
return undefined;
|
|
107
|
-
};
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
static #isNamed(expression: ts.Expression, name: string | null) {
|
|
111
|
-
return ts.isIdentifier(expression) && expression.text === name;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
static #chainOf(initializer: ts.Expression) {
|
|
115
|
-
let root = initializer;
|
|
116
|
-
let argCount = 0;
|
|
117
|
-
while (
|
|
118
|
-
ts.isCallExpression(root) &&
|
|
119
|
-
ts.isPropertyAccessExpression(root.expression) &&
|
|
120
|
-
ts.isCallExpression(root.expression.expression)
|
|
121
|
-
) {
|
|
122
|
-
if (SignalMutationIndex.#argMethods.has(root.expression.name.text)) argCount += 1;
|
|
123
|
-
root = root.expression.expression;
|
|
124
|
-
}
|
|
125
|
-
return { root: ts.isCallExpression(root) ? root.expression : undefined, argCount };
|
|
126
67
|
}
|
|
127
68
|
}
|
package/sshHostKeys.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { mkdir, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { ConfigLock } from "./cloud/configLock";
|
|
5
|
+
import { FileSys } from "./fileSys";
|
|
6
|
+
|
|
7
|
+
type KnownHosts = Record<string, string>;
|
|
8
|
+
|
|
9
|
+
export interface HostKeyVerdict {
|
|
10
|
+
trusted: boolean;
|
|
11
|
+
firstUse: boolean;
|
|
12
|
+
fingerprint: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export class SshHostKeys {
|
|
16
|
+
static defaultPath() {
|
|
17
|
+
return path.join(Bun.env.HOME ?? Bun.env.USERPROFILE ?? "", ".akan", "ssh_known_hosts.json");
|
|
18
|
+
}
|
|
19
|
+
static fingerprintOf(key: Buffer) {
|
|
20
|
+
return `SHA256:${createHash("sha256").update(key).digest("base64").replace(/=+$/, "")}`;
|
|
21
|
+
}
|
|
22
|
+
static parsePinned(value: string | undefined) {
|
|
23
|
+
return (value ?? "")
|
|
24
|
+
.split(",")
|
|
25
|
+
.map((fingerprint) => fingerprint.trim())
|
|
26
|
+
.filter(Boolean)
|
|
27
|
+
.map((fingerprint) => (fingerprint.startsWith("SHA256:") ? fingerprint : `SHA256:${fingerprint}`));
|
|
28
|
+
}
|
|
29
|
+
readonly filePath: string;
|
|
30
|
+
constructor(filePath: string = SshHostKeys.defaultPath()) {
|
|
31
|
+
this.filePath = filePath;
|
|
32
|
+
}
|
|
33
|
+
//? Pinned fingerprints decide alone; without them the first key a host shows is kept and any other is refused.
|
|
34
|
+
async verify(hostId: string, key: Buffer, pinned: string[] = []): Promise<HostKeyVerdict> {
|
|
35
|
+
const fingerprint = SshHostKeys.fingerprintOf(key);
|
|
36
|
+
if (pinned.length) return { trusted: pinned.includes(fingerprint), firstUse: false, fingerprint };
|
|
37
|
+
return await ConfigLock.run(`${this.filePath}.lock`, async () => {
|
|
38
|
+
const known = await this.#read();
|
|
39
|
+
const stored = known[hostId];
|
|
40
|
+
if (stored) return { trusted: stored === fingerprint, firstUse: false, fingerprint };
|
|
41
|
+
await this.#write({ ...known, [hostId]: fingerprint });
|
|
42
|
+
return { trusted: true, firstUse: true, fingerprint };
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
async #read(): Promise<KnownHosts> {
|
|
46
|
+
return (await FileSys.fileExists(this.filePath)) ? await FileSys.readJson<KnownHosts>(this.filePath) : {};
|
|
47
|
+
}
|
|
48
|
+
async #write(known: KnownHosts) {
|
|
49
|
+
await mkdir(path.dirname(this.filePath), { recursive: true, mode: 0o700 });
|
|
50
|
+
const temp = `${this.filePath}.${process.pid}.${crypto.randomUUID()}.tmp`;
|
|
51
|
+
try {
|
|
52
|
+
await writeFile(temp, JSON.stringify(known, null, 2), { mode: 0o600 });
|
|
53
|
+
await FileSys.replace(temp, this.filePath);
|
|
54
|
+
} catch (error) {
|
|
55
|
+
await rm(temp, { force: true });
|
|
56
|
+
throw error;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
package/storeSource.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { modelNames } from "akanjs/common";
|
|
1
2
|
import ts from "typescript";
|
|
2
3
|
import type { SourceFileInfo } from "./qualityScanner";
|
|
3
4
|
|
|
@@ -28,15 +29,15 @@ export class StoreSource {
|
|
|
28
29
|
|
|
29
30
|
//? The actions `store()` generates that send a mutation; `upload<Field>On<Model>` only stages a file the form saves.
|
|
30
31
|
static generatedWritesOf(refName: string) {
|
|
31
|
-
const
|
|
32
|
+
const names = modelNames(refName);
|
|
32
33
|
return [
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
34
|
+
names.createModel,
|
|
35
|
+
names.updateModel,
|
|
36
|
+
names.removeModel,
|
|
37
|
+
names.mergeModel,
|
|
38
|
+
names.createModelInForm,
|
|
39
|
+
names.updateModelInForm,
|
|
40
|
+
names.submitModel,
|
|
40
41
|
];
|
|
41
42
|
}
|
|
42
43
|
|
package/subspace.test.ts
CHANGED
|
@@ -513,6 +513,16 @@ describe("SubspaceConfig", () => {
|
|
|
513
513
|
).toThrow(/duplicate subspace/);
|
|
514
514
|
});
|
|
515
515
|
|
|
516
|
+
test("refuses a subspace name that is not a plain folder name", () => {
|
|
517
|
+
for (const name of ["..", "../outside", "a/b", ".hidden", "-x", "a b"])
|
|
518
|
+
expect(() => new SubspaceConfig({ subspaces: [{ name, repo: "a.git", apps: ["one"] }] })).toThrow(
|
|
519
|
+
/subspace name .* takes letters, digits/,
|
|
520
|
+
);
|
|
521
|
+
expect(
|
|
522
|
+
new SubspaceConfig({ subspaces: [{ name: "acme.v2_beta-1", repo: "a.git", apps: ["one"] }] }).subspaces,
|
|
523
|
+
).toHaveLength(1);
|
|
524
|
+
});
|
|
525
|
+
|
|
516
526
|
test("refuses two subspaces on one cloud workspace, and takes a declaration with none", () => {
|
|
517
527
|
expect(
|
|
518
528
|
() =>
|
package/subspaceClone.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { Executor, WorkspaceExecutor } from "./executors";
|
|
|
5
5
|
import { FileSys } from "./fileSys";
|
|
6
6
|
import { LibSource } from "./libSource";
|
|
7
7
|
import { type SlicePlan, SlicePlanner } from "./slicePlanner";
|
|
8
|
-
import type
|
|
8
|
+
import { SubspaceConfig, type SubspaceDeclaration } from "./subspaceConfig";
|
|
9
9
|
import type { PackageJson } from "./types";
|
|
10
10
|
|
|
11
11
|
export class SubspaceClone {
|
|
@@ -43,6 +43,8 @@ export class SubspaceClone {
|
|
|
43
43
|
constructor(workspace: WorkspaceExecutor, declaration: SubspaceDeclaration) {
|
|
44
44
|
this.#workspace = workspace;
|
|
45
45
|
this.#declaration = declaration;
|
|
46
|
+
if (!SubspaceConfig.namePattern.test(declaration.name))
|
|
47
|
+
throw new Error(`Subspace name "${declaration.name}" cannot name a folder under .akan/subspace.`);
|
|
46
48
|
this.#clonePath = path.join(workspace.workspaceRoot, ".akan/subspace", declaration.name);
|
|
47
49
|
this.#clone = new Executor(`subspace-${declaration.name}`, this.#clonePath);
|
|
48
50
|
}
|
package/subspaceConfig.ts
CHANGED
|
@@ -7,6 +7,7 @@ export type { SubspaceConfigInput, SubspaceDeclaration };
|
|
|
7
7
|
export class SubspaceConfig {
|
|
8
8
|
static readonly source = "subspace in the workspace root akan.config.ts";
|
|
9
9
|
static readonly defaultPushableBranches = ["main", "develop", "debug"];
|
|
10
|
+
static readonly namePattern = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
10
11
|
|
|
11
12
|
static async from(workspace: WorkspaceExecutor): Promise<SubspaceConfig | null> {
|
|
12
13
|
const { subspace } = await workspace.getConfig();
|
|
@@ -31,6 +32,11 @@ export class SubspaceConfig {
|
|
|
31
32
|
for (const subspace of this.subspaces) {
|
|
32
33
|
if (!subspace.name || !subspace.repo)
|
|
33
34
|
throw new Error(`${SubspaceConfig.source}: every subspace needs a name and a repo`);
|
|
35
|
+
//? The name is the clone folder under .akan/subspace, which a fresh clone empties first.
|
|
36
|
+
if (!SubspaceConfig.namePattern.test(subspace.name))
|
|
37
|
+
throw new Error(
|
|
38
|
+
`${SubspaceConfig.source}: subspace name "${subspace.name}" takes letters, digits, ".", "_" and "-", starting with a letter or digit`,
|
|
39
|
+
);
|
|
34
40
|
if (seenNames.has(subspace.name))
|
|
35
41
|
throw new Error(`${SubspaceConfig.source}: duplicate subspace "${subspace.name}"`);
|
|
36
42
|
seenNames.add(subspace.name);
|
|
@@ -12,14 +12,17 @@ export interface BarrelImportsPluginOptions {
|
|
|
12
12
|
pipeAfter?: (source: string, args: { path: string }) => string | Promise<string | null> | null;
|
|
13
13
|
/** Barrels the bundle leaves external: a rewritten import would inline the modules the import map already serves. */
|
|
14
14
|
external?: readonly string[];
|
|
15
|
+
/** The barrels to rewrite, instead of the app config's `barrelImports`. */
|
|
16
|
+
barrels?: readonly string[];
|
|
15
17
|
}
|
|
16
18
|
|
|
17
19
|
export const createBarrelImportsPlugin = async (
|
|
18
20
|
app: App,
|
|
19
|
-
{ skipPath = defaultSkipPath, pipeAfter, external = [] }: BarrelImportsPluginOptions = {},
|
|
21
|
+
{ skipPath = defaultSkipPath, pipeAfter, external = [], barrels: only }: BarrelImportsPluginOptions = {},
|
|
20
22
|
): Promise<BunPlugin> => {
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
+
const barrels = [...new Set(only ?? (await app.getConfig()).barrelImports)].filter(
|
|
24
|
+
(barrel) => barrel && !external.includes(barrel),
|
|
25
|
+
);
|
|
23
26
|
const analyzer = new BarrelAnalyzer({
|
|
24
27
|
resolvePackage: await createTsconfigPackageResolver(app),
|
|
25
28
|
});
|
|
@@ -37,7 +40,10 @@ export const createBarrelImportsPlugin = async (
|
|
|
37
40
|
async (args) => {
|
|
38
41
|
const realPath = args.path.replace(/\?v=\d+$/, "");
|
|
39
42
|
const loader = loaderFor(realPath);
|
|
40
|
-
|
|
43
|
+
const file = Bun.file(realPath);
|
|
44
|
+
//? A build's `files` entry has no copy on disk: Bun loads it itself.
|
|
45
|
+
if (!(await file.exists())) return undefined;
|
|
46
|
+
let source = await file.text();
|
|
41
47
|
if (skipPath(realPath)) return { contents: source, loader };
|
|
42
48
|
|
|
43
49
|
// Bun's macro evaluator races when a rewritten module holds `with { type: "macro" }` imports while another
|
|
@@ -2,9 +2,15 @@ import { describe, expect, test } from "bun:test";
|
|
|
2
2
|
import { symlink } from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { tempDirs, writeText } from "../testHelpers";
|
|
5
|
+
import { createBarrelImportsPlugin } from "./barrelImportsPlugin";
|
|
5
6
|
import { McpAppPageTrimmer } from "./mcpAppPageTrimmer";
|
|
6
7
|
|
|
7
8
|
const makeTempRoot = tempDirs("akan-mcp-app-trim-");
|
|
9
|
+
const trimmerPlugin = async (root: string, pagePaths: string[]) =>
|
|
10
|
+
await createBarrelImportsPlugin(
|
|
11
|
+
{ workspace: { workspaceRoot: root }, getTsConfig: async () => ({ compilerOptions: {} }) } as never,
|
|
12
|
+
{ barrels: [], pipeAfter: McpAppPageTrimmer.pipe(pagePaths) },
|
|
13
|
+
);
|
|
8
14
|
|
|
9
15
|
describe("McpAppPageTrimmer", () => {
|
|
10
16
|
test("hands every stage but the apps a function that draws nothing, and keeps the rest of the chain as written", () => {
|
|
@@ -51,7 +57,7 @@ describe("McpAppPageTrimmer", () => {
|
|
|
51
57
|
await writeText(path.join(root, "entry.ts"), 'import login from "./linked/login.tsx";\nexport { login };\n');
|
|
52
58
|
const result = await Bun.build({
|
|
53
59
|
entrypoints: [path.join(root, "entry.ts")],
|
|
54
|
-
plugins: [
|
|
60
|
+
plugins: [await trimmerPlugin(root, [path.join(root, "linked/login.tsx")])],
|
|
55
61
|
});
|
|
56
62
|
const [output] = result.outputs;
|
|
57
63
|
expect(result.success).toBe(true);
|
|
@@ -73,7 +79,7 @@ describe("McpAppPageTrimmer", () => {
|
|
|
73
79
|
await writeText(path.join(root, "entry.ts"), 'import trip from "./trip.tsx";\nconsole.info(trip);\n');
|
|
74
80
|
const result = await Bun.build({
|
|
75
81
|
entrypoints: [path.join(root, "entry.ts")],
|
|
76
|
-
plugins: [
|
|
82
|
+
plugins: [await trimmerPlugin(root, [path.join(root, "trip.tsx")])],
|
|
77
83
|
splitting: false,
|
|
78
84
|
});
|
|
79
85
|
const text = (await result.outputs[0]?.text()) ?? "";
|
|
@@ -1,6 +1,4 @@
|
|
|
1
1
|
import { realpathSync } from "node:fs";
|
|
2
|
-
import path from "node:path";
|
|
3
|
-
import type { BunPlugin } from "bun";
|
|
4
2
|
import ts from "typescript";
|
|
5
3
|
import { PureLazyMarker } from "./pureLazyMarker";
|
|
6
4
|
|
|
@@ -11,24 +9,11 @@ type ChainStage = ts.CallExpression & { expression: ts.PropertyAccessExpression
|
|
|
11
9
|
export class McpAppPageTrimmer {
|
|
12
10
|
static readonly #unused = new Set(["render", "head", "loading", "guard"]);
|
|
13
11
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
if (!pagePaths.length) return;
|
|
20
|
-
//? The bundler loads a module by the path a link points at: a synced lib page is one under `page/(libs)`.
|
|
21
|
-
const loadedPaths = new Set(
|
|
22
|
-
pagePaths.flatMap((pagePath) => [pagePath, McpAppPageTrimmer.#realPathOf(pagePath)]),
|
|
23
|
-
);
|
|
24
|
-
const filter = new RegExp(`^(?:${[...loadedPaths].map((pagePath) => RegExp.escape(pagePath)).join("|")})$`);
|
|
25
|
-
//? The bundler runs one onLoad per module, so this one also marks the page's own `lazy()` components.
|
|
26
|
-
build.onLoad({ filter }, async (args) => ({
|
|
27
|
-
contents: PureLazyMarker.mark(McpAppPageTrimmer.trim(await Bun.file(args.path).text(), args.path), args.path),
|
|
28
|
-
loader: path.extname(args.path).slice(1) as "tsx" | "ts" | "jsx" | "js",
|
|
29
|
-
}));
|
|
30
|
-
},
|
|
31
|
-
};
|
|
12
|
+
static pipe(pagePaths: string[]): (source: string, args: { path: string }) => string {
|
|
13
|
+
//? The bundler loads a module by the path a link points at: a synced lib page is one under `page/(libs)`.
|
|
14
|
+
const loadedPaths = new Set(pagePaths.flatMap((pagePath) => [pagePath, McpAppPageTrimmer.#realPathOf(pagePath)]));
|
|
15
|
+
return (source, { path: filePath }) =>
|
|
16
|
+
PureLazyMarker.mark(loadedPaths.has(filePath) ? McpAppPageTrimmer.trim(source, filePath) : source, filePath);
|
|
32
17
|
}
|
|
33
18
|
|
|
34
19
|
static trim(source: string, filePath: string): string {
|
|
@@ -25,7 +25,7 @@ export class PureLazyMarker {
|
|
|
25
25
|
|
|
26
26
|
static mark(source: string, filePath: string): string {
|
|
27
27
|
if (!source.includes("lazy(")) return source;
|
|
28
|
-
const kind = filePath
|
|
28
|
+
const kind = /\.[jt]sx$/.test(filePath) ? ts.ScriptKind.TSX : ts.ScriptKind.TS;
|
|
29
29
|
const sourceFile = ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true, kind);
|
|
30
30
|
const starts: number[] = [];
|
|
31
31
|
const visit = (node: ts.Node) => {
|