@akanjs/devkit 3.0.0-beta.17 → 3.0.0-beta.19

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.
Files changed (96) hide show
  1. package/abstractDoc.ts +5 -4
  2. package/akanApp/akanApp.host.ts +23 -9
  3. package/akanConfig/akanConfig.test.ts +55 -30
  4. package/akanConfig/akanConfig.ts +29 -17
  5. package/akanConfig/types.ts +1 -0
  6. package/applicationBuildRunner.test.ts +5 -3
  7. package/applicationBuildRunner.ts +33 -6
  8. package/artifact/implicitRootLayout.test.ts +6 -11
  9. package/artifact/implicitRootLayout.ts +0 -15
  10. package/artifact/routeSeedIndex.test.ts +7 -6
  11. package/biome.base.json +6 -1
  12. package/capacitorApp.test.ts +28 -2
  13. package/capacitorApp.ts +25 -4
  14. package/cloud/constants.ts +23 -0
  15. package/cloud/globalConfig.ts +13 -0
  16. package/codeAgent/agent/AkanCodeServices.ts +38 -12
  17. package/codeAgent/agent/CodeAgent.ts +94 -30
  18. package/codeAgent/agent/CodeAgentAsks.ts +3 -1
  19. package/codeAgent/agent/CodeAgentEventMapper.ts +9 -1
  20. package/codeAgent/agent/CodeAgentProxy.ts +91 -0
  21. package/codeAgent/agent/CodeAgentRpcHost.ts +29 -9
  22. package/codeAgent/agent/CodeAgentRpcListener.ts +117 -0
  23. package/codeAgent/agent/CodeAgentSuspended.ts +92 -0
  24. package/codeAgent/agent/akanCodeModel.ts +1 -1
  25. package/codeAgent/agent/akanCodePaths.ts +23 -3
  26. package/codeAgent/agent/akanContextFiles.test.ts +2 -1
  27. package/codeAgent/agent/akanSystemPrompt.ts +5 -0
  28. package/codeAgent/agent/codeAgentContract.test.ts +17 -0
  29. package/codeAgent/agent/codeAgentProxy.test.ts +74 -0
  30. package/codeAgent/agent/codeAgentRpcListener.test.ts +113 -0
  31. package/codeAgent/agent/codeAgentSuspended.test.ts +47 -0
  32. package/codeAgent/skills/akan-code/SKILL.md +122 -0
  33. package/codeAgent/skills/akan-module/SKILL.md +2 -2
  34. package/codeAgent/skills/akan-store/SKILL.md +6 -2
  35. package/codeAgent/tools/AkanCodePlugins.ts +19 -3
  36. package/codeAgent/tools/AskToolPack.ts +111 -0
  37. package/codeAgent/tools/McpClient.ts +51 -5
  38. package/codeAgent/tools/McpOAuth.ts +268 -0
  39. package/codeAgent/tools/McpOAuthLoopback.ts +77 -0
  40. package/codeAgent/tools/McpServerConfig.ts +69 -34
  41. package/codeAgent/tools/McpSignIn.ts +161 -0
  42. package/codeAgent/tools/McpTokenStore.ts +92 -0
  43. package/codeAgent/tools/McpToolPack.ts +16 -3
  44. package/codeAgent/tools/askToolPack.test.ts +119 -0
  45. package/codeAgent/tools/mcpOAuth.test.ts +324 -0
  46. package/codeAgent/tools/mcpServerConfig.test.ts +106 -12
  47. package/commandDecorators/command.ts +2 -0
  48. package/commandDecorators/commandDecorators.test.ts +1 -1
  49. package/commandDecorators/helpFormatter.ts +2 -1
  50. package/dependencyScanner.ts +4 -2
  51. package/devkitUtils.test.ts +1 -1
  52. package/executors.linkFallback.test.ts +103 -0
  53. package/executors.test.ts +18 -46
  54. package/executors.ts +31 -8
  55. package/frontendBuild/autoImportSync.test.ts +13 -0
  56. package/frontendBuild/autoImportSync.ts +19 -1
  57. package/frontendBuild/buildRouteClient.test.ts +52 -1
  58. package/frontendBuild/cssImportResolver.ts +1 -1
  59. package/frontendBuild/fontOptimizer.test.ts +1 -1
  60. package/frontendBuild/frontendBuild.test.ts +19 -17
  61. package/frontendBuild/hmrWatcher.test.ts +1 -1
  62. package/frontendBuild/routeClientBuilder.ts +27 -10
  63. package/frontendBuild/sourceMtimeIndex.test.ts +6 -5
  64. package/getDirname.test.ts +4 -2
  65. package/incrementalBuilder/devWatchBatch.test.ts +7 -6
  66. package/incrementalBuilder/incrementalBuilder.proc.ts +1 -0
  67. package/index.ts +0 -1
  68. package/integration/devStabilityHarness.test.ts +7 -2
  69. package/lint/gritRules.test.ts +2 -1
  70. package/lint/no-deep-internal-import.grit +2 -2
  71. package/lint/no-redeclare-predefined-endpoint.grit +2 -1
  72. package/linter.ts +4 -1
  73. package/package.json +2 -2
  74. package/platformTest/BunTestReport.test.ts +60 -0
  75. package/platformTest/BunTestReport.ts +65 -0
  76. package/platformTest/IdleWatchedProcess.test.ts +64 -0
  77. package/platformTest/IdleWatchedProcess.ts +77 -0
  78. package/platformTest/LinuxDockerTarget.ts +120 -0
  79. package/platformTest/PlatformTestRun.test.ts +68 -0
  80. package/platformTest/PlatformTestRun.ts +141 -0
  81. package/platformTest/PlatformTestTarget.ts +120 -0
  82. package/platformTest/TestSnapshot.test.ts +80 -0
  83. package/platformTest/TestSnapshot.ts +107 -0
  84. package/platformTest/WindowsSshTarget.ts +167 -0
  85. package/prompter.ts +0 -7
  86. package/qualityScanner.test.ts +3 -2
  87. package/qualityScanner.ts +1 -1
  88. package/routeSourceValidator.test.ts +0 -1
  89. package/routeSourceValidator.ts +0 -12
  90. package/scanInfo.ts +5 -2
  91. package/subspace.test.ts +5 -2
  92. package/subspace.ts +19 -8
  93. package/workflow/executor.ts +52 -0
  94. package/workflow/rolloutGate.test.ts +1 -1
  95. package/workflow/source.ts +19 -13
  96. package/guideline.ts +0 -19
@@ -0,0 +1,141 @@
1
+ import path from "node:path";
2
+ import chalk from "chalk";
3
+ import type { TestTargetsConfig } from "../cloud/constants";
4
+ import { GlobalConfig } from "../cloud/globalConfig";
5
+ import { LinuxDockerTarget } from "./LinuxDockerTarget";
6
+ import { type PlatformTestResult, PlatformTestTarget, type RemoteTestPlatform } from "./PlatformTestTarget";
7
+ import { TestSnapshot } from "./TestSnapshot";
8
+ import { WindowsSshTarget } from "./WindowsSshTarget";
9
+
10
+ interface PlatformTestRunOptions {
11
+ workspaceRoot: string;
12
+ platforms: RemoteTestPlatform[];
13
+ pkgs: string[];
14
+ targets: TestTargetsConfig;
15
+ onProgress: (message: string) => void;
16
+ }
17
+
18
+ export interface PlatformVerdict {
19
+ blocking: PlatformTestResult[];
20
+ warning: PlatformTestResult[];
21
+ }
22
+
23
+ export class PlatformTestRun {
24
+ //? The CLI refuses a root without these, and the cli suite builds temp workspaces from them; none is a secret.
25
+ static readonly envKeys = [
26
+ "USE_AKANJS_PKGS",
27
+ "AKAN_PUBLIC_REPO_NAME",
28
+ "AKAN_PUBLIC_SERVE_DOMAIN",
29
+ "AKAN_PUBLIC_ENV",
30
+ "AKAN_PUBLIC_OPERATION_MODE",
31
+ "APP_OPERATION_MODE",
32
+ "SERVER_MODE",
33
+ ] as const;
34
+ static readonly promoteAfterGreenRuns = 3;
35
+
36
+ readonly runId: string;
37
+ readonly logDir: string;
38
+ readonly snapshot: TestSnapshot;
39
+ readonly #results: Promise<PlatformTestResult[]>;
40
+
41
+ constructor(runId: string, logDir: string, snapshot: TestSnapshot, results: Promise<PlatformTestResult[]>) {
42
+ this.runId = runId;
43
+ this.logDir = logDir;
44
+ this.snapshot = snapshot;
45
+ this.#results = results;
46
+ }
47
+
48
+ static async start({ workspaceRoot, platforms, pkgs, targets, onProgress }: PlatformTestRunOptions) {
49
+ const runId = PlatformTestRun.newRunId();
50
+ const logDir = path.join(workspaceRoot, "local", "deploy-test", runId);
51
+ const snapshot = await TestSnapshot.create(workspaceRoot, logDir);
52
+ const envPath = path.join(logDir, "env");
53
+ await Bun.write(envPath, await PlatformTestRun.allowlistedEnv(workspaceRoot));
54
+ const instances = platforms.map((platform) => {
55
+ if (platform === "linux") return new LinuxDockerTarget(targets.linux);
56
+ if (!targets.windows) throw new Error("the windows test target is not configured");
57
+ return new WindowsSshTarget(targets.windows);
58
+ });
59
+ const results = Promise.all(
60
+ instances.map((target) => target.run({ snapshot, envPath, runId, logDir, pkgs, onProgress })),
61
+ );
62
+ return new PlatformTestRun(runId, logDir, snapshot, results);
63
+ }
64
+
65
+ async results() {
66
+ return await this.#results;
67
+ }
68
+
69
+ static parsePlatforms(value: string): RemoteTestPlatform[] {
70
+ const names = value
71
+ .split(",")
72
+ .map((name) => name.trim().toLowerCase())
73
+ .filter((name) => name && name !== "none");
74
+ const unknown = names.filter((name) => name !== "linux" && name !== "windows");
75
+ if (unknown.length)
76
+ throw new Error(`Unknown test platform: ${unknown.join(", ")} (expected linux, windows, or none)`);
77
+ return [...new Set(names as RemoteTestPlatform[])];
78
+ }
79
+
80
+ static newRunId(now = new Date()) {
81
+ const pad = (value: number) => String(value).padStart(2, "0");
82
+ return `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
83
+ }
84
+
85
+ static async allowlistedEnv(workspaceRoot: string) {
86
+ const envFile = Bun.file(path.join(workspaceRoot, ".env"));
87
+ const lines = (await envFile.exists()) ? (await envFile.text()).split(/\r?\n/) : [];
88
+ const allowed = new Set<string>(PlatformTestRun.envKeys);
89
+ const kept = lines.filter((line) => allowed.has(line.split("=", 1)[0]?.trim() ?? ""));
90
+ return `${kept.join("\n")}\n`;
91
+ }
92
+
93
+ static failed(result: PlatformTestResult) {
94
+ return !!result.infraError || result.packages.some((pkg) => pkg.status !== "pass");
95
+ }
96
+
97
+ static verdict(results: PlatformTestResult[]): PlatformVerdict {
98
+ const failed = results.filter((result) => PlatformTestRun.failed(result));
99
+ return {
100
+ blocking: failed.filter((result) => result.policy === "gate"),
101
+ warning: failed.filter((result) => result.policy === "warn"),
102
+ };
103
+ }
104
+
105
+ format(results: PlatformTestResult[], { maxFailures = 15 }: { maxFailures?: number } = {}) {
106
+ const lines = [
107
+ chalk.bold(
108
+ `Platform tests · snapshot ${this.snapshot.digest} (${this.snapshot.fileCount} files) · ${path.relative(this.snapshot.root, this.logDir)}`,
109
+ ),
110
+ ];
111
+ for (const result of results) {
112
+ const mark = PlatformTestRun.failed(result) ? chalk.red("✖") : chalk.green("✔");
113
+ lines.push(`${mark} ${result.platform} (${result.policy}, ${Math.round(result.ms / 1000)}s)`);
114
+ if (result.infraError) lines.push(chalk.red(` setup: ${result.infraError}`));
115
+ for (const warning of result.envWarnings) lines.push(chalk.yellow(` env: ${warning}`));
116
+ for (const pkg of result.packages) {
117
+ const line = ` ${PlatformTestTarget.describe(pkg)}`;
118
+ lines.push(pkg.status === "pass" ? line : chalk.red(line));
119
+ for (const failure of pkg.report.failures.slice(0, maxFailures))
120
+ lines.push(chalk.dim(` ${failure.file ? `${failure.file} › ` : ""}${failure.name}`));
121
+ if (pkg.report.failures.length > maxFailures)
122
+ lines.push(chalk.dim(` … ${pkg.report.failures.length - maxFailures} more in ${pkg.logPath}`));
123
+ }
124
+ }
125
+ return lines.join("\n");
126
+ }
127
+
128
+ /** A `warn` platform becomes a gate once it has been green for enough deploys in a row. */
129
+ static async recordStreaks(results: PlatformTestResult[]) {
130
+ const targets = await GlobalConfig.getTestTargets();
131
+ const windows = targets.windows;
132
+ const result = results.find((one) => one.platform === "windows");
133
+ if (!windows || !result || (windows.policy ?? "warn") !== "warn") return null;
134
+ const greenStreak = PlatformTestRun.failed(result) ? 0 : (windows.greenStreak ?? 0) + 1;
135
+ const promoted = greenStreak >= PlatformTestRun.promoteAfterGreenRuns;
136
+ await GlobalConfig.setTestTargets({
137
+ windows: { ...windows, greenStreak, ...(promoted ? { policy: "gate" } : {}) },
138
+ });
139
+ return { platform: "windows" as const, greenStreak, promoted };
140
+ }
141
+ }
@@ -0,0 +1,120 @@
1
+ import path from "node:path";
2
+ import type { PlatformGatePolicy } from "../cloud/constants";
3
+ import { BunTestReport } from "./BunTestReport";
4
+ import type { IdleWatchedResult } from "./IdleWatchedProcess";
5
+ import type { TestSnapshot } from "./TestSnapshot";
6
+
7
+ export type RemoteTestPlatform = "linux" | "windows";
8
+ export type PackageTestStatus = "pass" | "fail" | "hung";
9
+
10
+ export interface PackageTestResult {
11
+ pkg: string;
12
+ status: PackageTestStatus;
13
+ report: BunTestReport;
14
+ ms: number;
15
+ logPath: string;
16
+ }
17
+
18
+ export interface PlatformTestResult {
19
+ platform: RemoteTestPlatform;
20
+ policy: PlatformGatePolicy;
21
+ packages: PackageTestResult[];
22
+ envWarnings: string[];
23
+ infraError: string | null;
24
+ ms: number;
25
+ }
26
+
27
+ export interface PlatformRunContext {
28
+ snapshot: TestSnapshot;
29
+ envPath: string;
30
+ runId: string;
31
+ logDir: string;
32
+ pkgs: string[];
33
+ onProgress: (message: string) => void;
34
+ }
35
+
36
+ export abstract class PlatformTestTarget {
37
+ static readonly installIdleMs = 5 * 60_000;
38
+ static readonly testIdleMs = 2 * 60_000;
39
+
40
+ abstract readonly platform: RemoteTestPlatform;
41
+ abstract readonly policy: PlatformGatePolicy;
42
+ protected readonly tolerateScriptFailures: string[] = [];
43
+
44
+ protected abstract prepare(context: PlatformRunContext): Promise<void>;
45
+ protected abstract exec(command: string, logPath: string, idleMs: number): Promise<IdleWatchedResult>;
46
+ protected abstract cleanup(context: PlatformRunContext): Promise<void>;
47
+
48
+ async run(context: PlatformRunContext): Promise<PlatformTestResult> {
49
+ const startedAt = performance.now();
50
+ const envWarnings: string[] = [];
51
+ const packages: PackageTestResult[] = [];
52
+ const result = (infraError: string | null): PlatformTestResult => ({
53
+ platform: this.platform,
54
+ policy: this.policy,
55
+ packages,
56
+ envWarnings,
57
+ infraError,
58
+ ms: performance.now() - startedAt,
59
+ });
60
+ try {
61
+ await this.prepare(context);
62
+ await this.#install(context, envWarnings);
63
+ const build = await this.exec(
64
+ "bun run buildAkan",
65
+ this.#logPath(context, "build"),
66
+ PlatformTestTarget.installIdleMs,
67
+ );
68
+ if (build.exitCode !== 0) throw new Error(`buildAkan failed — see ${this.#logPath(context, "build")}`);
69
+ for (const pkg of context.pkgs) {
70
+ const logPath = this.#logPath(context, `test-${pkg.replaceAll("/", "__")}`);
71
+ const run = await this.exec(
72
+ `bun dist/pkgs/@akanjs/cli/index.js test ${pkg}`,
73
+ logPath,
74
+ PlatformTestTarget.testIdleMs,
75
+ );
76
+ const report = new BunTestReport(run.output);
77
+ const status: PackageTestStatus =
78
+ run.hung || run.timedOut ? "hung" : report.passed && run.exitCode === 0 ? "pass" : "fail";
79
+ packages.push({ pkg, status, report, ms: run.ms, logPath });
80
+ context.onProgress(`[${this.platform}] ${PlatformTestTarget.describe(packages.at(-1) as PackageTestResult)}`);
81
+ }
82
+ return result(null);
83
+ } catch (error) {
84
+ return result(error instanceof Error ? error.message : String(error));
85
+ } finally {
86
+ await this.cleanup(context).catch(() => undefined);
87
+ }
88
+ }
89
+
90
+ static describe({ pkg, status, report, ms }: PackageTestResult) {
91
+ const seconds = `${Math.round(ms / 1000)}s`;
92
+ if (status === "hung") return `${pkg}: hung${report.lastFile ? ` in ${report.lastFile}` : ""} (${seconds})`;
93
+ return `${pkg}: ${report.pass} pass / ${report.fail} fail${report.errors ? ` / ${report.errors} errors` : ""} (${seconds})`;
94
+ }
95
+
96
+ async #install(context: PlatformRunContext, envWarnings: string[]) {
97
+ const logPath = this.#logPath(context, "install");
98
+ const install = await this.exec("bun install --frozen-lockfile", logPath, PlatformTestTarget.installIdleMs);
99
+ if (install.exitCode === 0) return;
100
+ const failedScripts = [...install.output.matchAll(/(?:pre|post)?install script from "([^"]+)" exited/g)].map(
101
+ (match) => match[1] ?? "",
102
+ );
103
+ const tolerable =
104
+ failedScripts.length > 0 && failedScripts.every((pkg) => this.tolerateScriptFailures.includes(pkg));
105
+ if (!tolerable) throw new Error(`bun install failed — see ${logPath}`);
106
+ //* A failed lifecycle script makes bun skip linking the workspace root's `.bin`, so every tool the suites
107
+ //* spawn from there disappears; a second pass without scripts links them.
108
+ envWarnings.push(`install script failed for ${failedScripts.join(", ")} — tolerated on ${this.platform}`);
109
+ const relink = await this.exec(
110
+ "bun install --frozen-lockfile --ignore-scripts",
111
+ this.#logPath(context, "install-relink"),
112
+ PlatformTestTarget.installIdleMs,
113
+ );
114
+ if (relink.exitCode !== 0) throw new Error(`bun install --ignore-scripts failed — see ${logPath}`);
115
+ }
116
+
117
+ #logPath(context: PlatformRunContext, name: string) {
118
+ return path.join(context.logDir, this.platform, `${name}.log`);
119
+ }
120
+ }
@@ -0,0 +1,80 @@
1
+ import { afterAll, describe, expect, test } from "bun:test";
2
+ import { mkdir, mkdtemp, rm, unlink, writeFile } from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { TestSnapshot } from "./TestSnapshot";
6
+
7
+ const roots: string[] = [];
8
+ const git = async (cwd: string, ...args: string[]) => {
9
+ const proc = Bun.spawn(["git", ...args], {
10
+ cwd,
11
+ stdout: "ignore",
12
+ stderr: "ignore",
13
+ env: {
14
+ ...process.env,
15
+ GIT_AUTHOR_NAME: "t",
16
+ GIT_AUTHOR_EMAIL: "t@t",
17
+ GIT_COMMITTER_NAME: "t",
18
+ GIT_COMMITTER_EMAIL: "t@t",
19
+ },
20
+ });
21
+ expect(await proc.exited).toBe(0);
22
+ };
23
+ const write = async (root: string, file: string, content: string) => {
24
+ await mkdir(path.dirname(path.join(root, file)), { recursive: true });
25
+ await writeFile(path.join(root, file), content);
26
+ };
27
+ const makeRepo = async () => {
28
+ const root = await mkdtemp(path.join(os.tmpdir(), "akan-snapshot-"));
29
+ roots.push(root);
30
+ await git(root, "init", "-q");
31
+ await write(root, ".gitignore", "ignored/\nlocal/\n.env\n");
32
+ await write(root, "tracked.ts", "export {};\n");
33
+ await write(root, "gone.ts", "export {};\n");
34
+ await git(root, "add", ".");
35
+ await git(root, "commit", "-q", "-m", "init");
36
+ await unlink(path.join(root, "gone.ts"));
37
+ await write(root, "untracked.ts", "export const a = 1;\n");
38
+ await write(root, "ignored/secret.ts", "export {};\n");
39
+ await write(root, ".env", "OPENAI_API_KEY=nope\n");
40
+ return root;
41
+ };
42
+ const tarEntries = async (tarPath: string) => {
43
+ const proc = Bun.spawn(["tar", "-tf", tarPath], { stdout: "pipe" });
44
+ return (await new Response(proc.stdout).text()).split(/\r?\n/).filter(Boolean).sort();
45
+ };
46
+
47
+ afterAll(async () => {
48
+ await Promise.all(roots.map((root) => rm(root, { recursive: true, force: true })));
49
+ });
50
+
51
+ describe("TestSnapshot", () => {
52
+ test("packs what git would show — tracked and untracked, never ignored or deleted", async () => {
53
+ const root = await makeRepo();
54
+ const snapshot = await TestSnapshot.create(root, path.join(root, "local", "run"));
55
+
56
+ expect(await tarEntries(snapshot.tarPath)).toEqual([".gitignore", "tracked.ts", "untracked.ts"]);
57
+ expect(snapshot.fileCount).toBe(3);
58
+ });
59
+
60
+ test("reports what changed underneath it", async () => {
61
+ const root = await makeRepo();
62
+ const snapshot = await TestSnapshot.create(root, path.join(root, "local", "run"));
63
+ await write(root, "tracked.ts", "export const changed = true;\n");
64
+ await write(root, "added.ts", "export {};\n");
65
+ await unlink(path.join(root, "untracked.ts"));
66
+
67
+ expect(await snapshot.drift()).toEqual({ changed: ["tracked.ts"], added: ["added.ts"], removed: ["untracked.ts"] });
68
+ });
69
+
70
+ test("refuses an env file git does not ignore instead of shipping it to a test host", async () => {
71
+ const root = await makeRepo();
72
+ await write(root, "apps/demo/env/env.server.local.ts", "export default {};\n");
73
+ await write(root, "apps/demo/env/env.server.example.ts", "export default {};\n");
74
+ await write(root, "templates/.env.template", "KEY=\n");
75
+
76
+ await expect(TestSnapshot.create(root, path.join(root, "local", "run"))).rejects.toThrow(
77
+ "apps/demo/env/env.server.local.ts",
78
+ );
79
+ });
80
+ });
@@ -0,0 +1,107 @@
1
+ import { mkdir, stat, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ export interface SnapshotDrift {
5
+ changed: string[];
6
+ added: string[];
7
+ removed: string[];
8
+ }
9
+
10
+ /**
11
+ * The exact bytes a platform run tests: every tracked file plus every untracked one git would show, which is
12
+ * what `akan deploy-akan` goes on to build and publish. The per-file hashes are kept so the tree can be
13
+ * compared again before publishing — a test run is only a gate if nothing changed underneath it.
14
+ */
15
+ export class TestSnapshot {
16
+ static readonly #secretPatterns = [
17
+ /(^|\/)\.env(\.(?!(template|example|sample)$)[^/]+)?$/,
18
+ /(^|\/)env\/env\.(client|server)\.(?!(type|example)\.ts$)[^/]+\.ts$/,
19
+ ];
20
+
21
+ readonly root: string;
22
+ readonly tarPath: string;
23
+ readonly digest: string;
24
+ readonly #hashes: Map<string, string>;
25
+
26
+ constructor(root: string, tarPath: string, hashes: Map<string, string>) {
27
+ this.root = root;
28
+ this.tarPath = tarPath;
29
+ this.#hashes = hashes;
30
+ this.digest = TestSnapshot.#digestOf(hashes);
31
+ }
32
+
33
+ get fileCount() {
34
+ return this.#hashes.size;
35
+ }
36
+
37
+ static async create(root: string, outDir: string) {
38
+ const hashes = await TestSnapshot.#hashTree(root);
39
+ //? A tracked file is already in the repository; only an untracked one nobody ignored can leak by being copied.
40
+ const untracked = await TestSnapshot.#gitList(root, ["--others", "--exclude-standard"]);
41
+ const secrets = untracked.filter((file) => TestSnapshot.#secretPatterns.some((re) => re.test(file)));
42
+ if (secrets.length)
43
+ throw new Error(
44
+ `Refusing to snapshot env files git does not ignore — they would be copied to every test target: ${secrets.join(", ")}`,
45
+ );
46
+ await mkdir(outDir, { recursive: true });
47
+ const listPath = path.join(outDir, "files.txt");
48
+ const tarPath = path.join(outDir, "src.tar");
49
+ await writeFile(listPath, `${[...hashes.keys()].join("\n")}\n`);
50
+ const tar = Bun.spawn(
51
+ ["tar", ...(process.platform === "darwin" ? ["--no-xattrs"] : []), "-cf", tarPath, "-T", listPath],
52
+ { cwd: root, env: { ...process.env, COPYFILE_DISABLE: "1" }, stdout: "ignore", stderr: "pipe" },
53
+ );
54
+ if ((await tar.exited) !== 0) throw new Error(`tar failed: ${await new Response(tar.stderr).text()}`);
55
+ return new TestSnapshot(root, tarPath, hashes);
56
+ }
57
+
58
+ async drift(): Promise<SnapshotDrift> {
59
+ const current = await TestSnapshot.#hashTree(this.root);
60
+ const changed = [...current].filter(([file, hash]) => this.#hashes.has(file) && this.#hashes.get(file) !== hash);
61
+ return {
62
+ changed: changed.map(([file]) => file),
63
+ added: [...current.keys()].filter((file) => !this.#hashes.has(file)),
64
+ removed: [...this.#hashes.keys()].filter((file) => !current.has(file)),
65
+ };
66
+ }
67
+
68
+ static async #gitList(root: string, args: string[]) {
69
+ const proc = Bun.spawn(["git", "ls-files", ...args, "-z"], { cwd: root, stdout: "pipe", stderr: "pipe" });
70
+ const [stdout, stderr, exitCode] = await Promise.all([
71
+ new Response(proc.stdout).text(),
72
+ new Response(proc.stderr).text(),
73
+ proc.exited,
74
+ ]);
75
+ if (exitCode !== 0) throw new Error(`git ls-files failed in ${root}: ${stderr.trim()}`);
76
+ return [...new Set(stdout.split("\0").filter(Boolean))].sort();
77
+ }
78
+
79
+ static async #listFiles(root: string) {
80
+ const listed = await TestSnapshot.#gitList(root, ["--cached", "--others", "--exclude-standard"]);
81
+ //? `--cached` still lists a tracked file deleted from the working tree, which tar would then fail on.
82
+ const present = await Promise.all(
83
+ listed.map(async (file) => ((await stat(path.join(root, file)).catch(() => null))?.isFile() ? file : null)),
84
+ );
85
+ return present.filter((file): file is string => file !== null);
86
+ }
87
+
88
+ static async #hashTree(root: string) {
89
+ const files = await TestSnapshot.#listFiles(root);
90
+ const hashes = new Map<string, string>();
91
+ for (let start = 0; start < files.length; start += 64) {
92
+ const batch = files.slice(start, start + 64);
93
+ const batchHashes = await Promise.all(
94
+ batch.map(async (file) => Bun.hash(await Bun.file(path.join(root, file)).bytes()).toString(16)),
95
+ );
96
+ for (const [idx, file] of batch.entries()) hashes.set(file, batchHashes[idx] ?? "");
97
+ }
98
+ return hashes;
99
+ }
100
+
101
+ static #digestOf(hashes: Map<string, string>) {
102
+ return Bun.hash([...hashes].map(([file, hash]) => `${file}:${hash}`).join("\n"))
103
+ .toString(16)
104
+ .padStart(16, "0")
105
+ .slice(0, 12);
106
+ }
107
+ }
@@ -0,0 +1,167 @@
1
+ import { existsSync } from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import type { PlatformGatePolicy, WindowsTestTargetConfig } from "../cloud/constants";
5
+ import { IdleWatchedProcess } from "./IdleWatchedProcess";
6
+ import { type PlatformRunContext, PlatformTestTarget } from "./PlatformTestTarget";
7
+
8
+ export class WindowsSshTarget extends PlatformTestTarget {
9
+ static readonly utmctlPath = "/Applications/UTM.app/Contents/MacOS/utmctl";
10
+ static readonly defaultWorkRoot = "C:\\akan-test";
11
+ //* arm64 has neither a sharp@0.32 prebuilt nor a Chrome for puppeteer's postinstall; x64 users get both.
12
+ static readonly defaultTolerateScriptFailures = ["sharp", "puppeteer"];
13
+ static readonly #runDirPattern = "^\\d{8}-\\d{6}$";
14
+ static readonly #keptRuns = 2;
15
+
16
+ readonly platform = "windows";
17
+ readonly policy: PlatformGatePolicy;
18
+ protected override readonly tolerateScriptFailures: string[];
19
+ readonly #config: WindowsTestTargetConfig;
20
+ #host: string;
21
+ #runDir: string | null = null;
22
+
23
+ constructor(config: WindowsTestTargetConfig) {
24
+ super();
25
+ this.#config = config;
26
+ this.#host = process.env.AKAN_TEST_WINDOWS_HOST ?? config.host;
27
+ this.policy = config.policy ?? "warn";
28
+ this.tolerateScriptFailures = config.tolerateScriptFailures ?? WindowsSshTarget.defaultTolerateScriptFailures;
29
+ }
30
+
31
+ get #workRoot() {
32
+ return this.#config.workRoot ?? WindowsSshTarget.defaultWorkRoot;
33
+ }
34
+
35
+ protected async prepare(context: PlatformRunContext) {
36
+ await this.#connect();
37
+ const bunVersion = (await this.#ssh("bun --version")).stdout.trim();
38
+ if (bunVersion !== Bun.version)
39
+ throw new Error(
40
+ `the Windows host runs Bun ${bunVersion || "(none)"}, this host ${Bun.version} — align them first`,
41
+ );
42
+ if ((await this.#ssh("git --version")).exitCode !== 0) throw new Error("git is not installed on the Windows host");
43
+ const runDir = `${this.#workRoot}\\${context.runId}`;
44
+ const mkdir = await this.#ssh(`New-Item -ItemType Directory -Force -Path '${runDir}\\w' | Out-Null`);
45
+ if (mkdir.exitCode !== 0) throw new Error(`could not create ${runDir}: ${mkdir.stderr.trim()}`);
46
+ this.#runDir = runDir;
47
+ for (const [local, remoteName] of [
48
+ [context.snapshot.tarPath, "src.tar"],
49
+ [context.envPath, "env"],
50
+ ] as const) {
51
+ const copied = await WindowsSshTarget.#run([
52
+ ...this.#scpBase(),
53
+ local,
54
+ this.#remoteScpPath(`${runDir}\\${remoteName}`),
55
+ ]);
56
+ if (copied.exitCode !== 0) throw new Error(`scp ${remoteName} failed: ${copied.stderr.trim()}`);
57
+ }
58
+ const extract = await this.exec(
59
+ "tar -xf ..\\src.tar && copy /Y ..\\env .env >nul && git init -q",
60
+ path.join(context.logDir, this.platform, "extract.log"),
61
+ PlatformTestTarget.installIdleMs,
62
+ );
63
+ if (extract.exitCode !== 0) throw new Error("extracting the snapshot on the Windows host failed");
64
+ }
65
+
66
+ protected async exec(command: string, logPath: string, idleMs: number) {
67
+ const runDir = this.#runDir;
68
+ if (!runDir) throw new Error("the Windows run directory was not prepared");
69
+ let remotePid: string | null = null;
70
+ const script = [
71
+ "$ProgressPreference = 'SilentlyContinue'",
72
+ "[Console]::OutputEncoding = [Text.Encoding]::UTF8",
73
+ "'PID=' + $PID",
74
+ "$env:GIT_AUTHOR_NAME = 'akan-test'",
75
+ "$env:GIT_AUTHOR_EMAIL = 'akan-test@localhost'",
76
+ "$env:GIT_COMMITTER_NAME = 'akan-test'",
77
+ "$env:GIT_COMMITTER_EMAIL = 'akan-test@localhost'",
78
+ "$env:GIT_CONFIG_COUNT = '1'",
79
+ "$env:GIT_CONFIG_KEY_0 = 'init.defaultBranch'",
80
+ "$env:GIT_CONFIG_VALUE_0 = 'main'",
81
+ `Set-Location -LiteralPath '${runDir}\\w'`,
82
+ `cmd /c "${command} 2>&1"`,
83
+ "exit $LASTEXITCODE",
84
+ ].join("; ");
85
+ return await new IdleWatchedProcess([...this.#sshBase(), script], {
86
+ logPath,
87
+ idleMs,
88
+ onOutput: (text) => {
89
+ remotePid ??= /PID=(\d+)/.exec(text)?.[1] ?? null;
90
+ },
91
+ onIdle: async () => {
92
+ if (remotePid) await this.#ssh(`taskkill /F /T /PID ${remotePid} | Out-Null`);
93
+ },
94
+ }).run();
95
+ }
96
+
97
+ protected async cleanup() {
98
+ this.#runDir = null;
99
+ await this.#ssh(
100
+ [
101
+ `Get-ChildItem -LiteralPath '${this.#workRoot}' -Directory`,
102
+ `Where-Object { $_.Name -match '${WindowsSshTarget.#runDirPattern}' }`,
103
+ "Sort-Object Name -Descending",
104
+ `Select-Object -Skip ${WindowsSshTarget.#keptRuns}`,
105
+ "ForEach-Object { cmd /c rmdir /s /q $_.FullName }",
106
+ ].join(" | "),
107
+ );
108
+ }
109
+
110
+ async #connect() {
111
+ if ((await this.#ssh("exit 0")).exitCode === 0) return;
112
+ const vm = this.#config.utmVm;
113
+ if (!vm || !existsSync(WindowsSshTarget.utmctlPath))
114
+ throw new Error(`cannot reach ${this.#config.user}@${this.#host} over ssh`);
115
+ const status = (await WindowsSshTarget.#run([WindowsSshTarget.utmctlPath, "status", vm])).stdout.trim();
116
+ if (status !== "started") await WindowsSshTarget.#run([WindowsSshTarget.utmctlPath, "start", vm]);
117
+ for (let attempt = 0; attempt < 24; attempt++) {
118
+ const addresses = (await WindowsSshTarget.#run([WindowsSshTarget.utmctlPath, "ip-address", vm])).stdout;
119
+ const ipv4 = addresses.split(/\s+/).find((address) => /^\d+\.\d+\.\d+\.\d+$/.test(address));
120
+ if (ipv4) this.#host = ipv4;
121
+ if ((await this.#ssh("exit 0")).exitCode === 0) return;
122
+ await Bun.sleep(5_000);
123
+ }
124
+ throw new Error(`UTM VM "${vm}" did not answer ssh at ${this.#host}`);
125
+ }
126
+
127
+ #sshOptions() {
128
+ const expand = (file: string) => (file.startsWith("~/") ? path.join(os.homedir(), file.slice(2)) : file);
129
+ return [
130
+ "-i",
131
+ expand(this.#config.identityFile),
132
+ ...(this.#config.knownHostsFile ? ["-o", `UserKnownHostsFile=${expand(this.#config.knownHostsFile)}`] : []),
133
+ "-o",
134
+ "BatchMode=yes",
135
+ "-o",
136
+ "ConnectTimeout=8",
137
+ "-o",
138
+ "ServerAliveInterval=30",
139
+ ];
140
+ }
141
+
142
+ #sshBase() {
143
+ return ["ssh", ...this.#sshOptions(), `${this.#config.user}@${this.#host}`];
144
+ }
145
+
146
+ #scpBase() {
147
+ return ["scp", "-q", ...this.#sshOptions()];
148
+ }
149
+
150
+ #remoteScpPath(windowsPath: string) {
151
+ return `${this.#config.user}@${this.#host}:/${windowsPath.replaceAll("\\", "/")}`;
152
+ }
153
+
154
+ async #ssh(script: string) {
155
+ return await WindowsSshTarget.#run([...this.#sshBase(), script]);
156
+ }
157
+
158
+ static async #run(command: string[]) {
159
+ const proc = Bun.spawn(command, { stdin: "ignore", stdout: "pipe", stderr: "pipe" });
160
+ const [stdout, stderr, exitCode] = await Promise.all([
161
+ new Response(proc.stdout).text(),
162
+ new Response(proc.stderr).text(),
163
+ proc.exited,
164
+ ]);
165
+ return { stdout, stderr, exitCode };
166
+ }
167
+ }
package/prompter.ts CHANGED
@@ -2,7 +2,6 @@ import fsPromise from "node:fs/promises";
2
2
  import { input, select } from "@inquirer/prompts";
3
3
 
4
4
  import { getDirname } from "./getDirname";
5
- import type { GuideGenerateJson } from "./guideline";
6
5
 
7
6
  interface FileUpdateRequestProps {
8
7
  context: string;
@@ -34,12 +33,6 @@ export class Prompter {
34
33
  const guidelineRoot = await Prompter.#getGuidelineRoot();
35
34
  return (await fsPromise.readdir(guidelineRoot)).filter((name) => !name.startsWith("_")).sort();
36
35
  }
37
- static async getGuideJson(guideName: string): Promise<GuideGenerateJson> {
38
- const guidelineRoot = await Prompter.#getGuidelineRoot();
39
- const filePath = `${guidelineRoot}/${guideName}/${guideName}.generate.json`;
40
- const guideJson = await fsPromise.readFile(filePath, "utf-8");
41
- return JSON.parse(guideJson) as GuideGenerateJson;
42
- }
43
36
  /**
44
37
  * The name is checked against the directory listing rather than interpolated straight into a path:
45
38
  * it arrives from an MCP tool argument, and an unknown one has to name the valid set instead of
@@ -26,7 +26,7 @@ afterEach(async () => {
26
26
  });
27
27
 
28
28
  describe("AkanQualityScanner abstract rule", () => {
29
- test("warns on an abstract over the line limit and points at akan compact", async () => {
29
+ test("warns on an abstract over the line limit and says how to trim it", async () => {
30
30
  const root = await makeWorkspace({
31
31
  "apps/demo/lib/post/post.abstract.md": abstractOf(AbstractDoc.maxLines + 1),
32
32
  "apps/demo/lib/post/post.constant.ts": "export class Post {}\n",
@@ -41,7 +41,8 @@ describe("AkanQualityScanner abstract rule", () => {
41
41
  expect(warnings).toHaveLength(1);
42
42
  expect(warnings[0]?.file).toBe("apps/demo/lib/post/post.abstract.md");
43
43
  expect(warnings[0]?.message).toContain(`${AbstractDoc.maxLines + 1} lines`);
44
- expect(warnings[0]?.fix).toContain("akan compact");
44
+ expect(warnings[0]?.fix).toContain("## Rules");
45
+ expect(warnings[0]?.fix).not.toContain("akan compact");
45
46
  });
46
47
  });
47
48
 
package/qualityScanner.ts CHANGED
@@ -114,7 +114,7 @@ const RULE_FIXES: Record<string, string> = {
114
114
  "Split the file by responsibility — move Zones, Utils, or subcomponents into sibling files.",
115
115
  "akan.file.max-lines": "Break the file into smaller focused modules; keep one primary responsibility per file.",
116
116
  "akan.file.abstract-max-lines":
117
- "Run `akan compact <app-or-lib>` to rewrite the abstract with the AI editor, keeping only the invariants and workflows the source files cannot show.",
117
+ "Trim the abstract by hand to a title, one sentence naming what the module owns, a `## Rules` list of the invariants the source cannot show, and an optional workflow chain.",
118
118
  "akan.file.placeholder-export":
119
119
  "Remove the placeholder export; generated indexes should only re-export real modules.",
120
120
  "akan.file.bang-comment-in-client":
@@ -15,7 +15,6 @@ describe("RouteSourceValidator", () => {
15
15
  "export const reconnect = true;",
16
16
  "export const wsConnect = true;",
17
17
  "export const layoutStyle = {};",
18
- 'export const gaTrackingId = "G-1";',
19
18
  ].join("\n");
20
19
 
21
20
  expect(() => validate(source, "layout", true)).not.toThrow();