@akanjs/devkit 3.0.0-beta.18 → 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 (63) hide show
  1. package/abstractDoc.ts +4 -1
  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 +29 -3
  8. package/artifact/routeSeedIndex.test.ts +7 -6
  9. package/capacitorApp.test.ts +2 -2
  10. package/cloud/constants.ts +23 -0
  11. package/cloud/globalConfig.ts +13 -0
  12. package/codeAgent/agent/AkanCodeServices.ts +22 -3
  13. package/codeAgent/agent/CodeAgent.ts +33 -18
  14. package/codeAgent/agent/CodeAgentAsks.ts +3 -1
  15. package/codeAgent/agent/CodeAgentEventMapper.ts +9 -1
  16. package/codeAgent/agent/CodeAgentProxy.ts +91 -0
  17. package/codeAgent/agent/CodeAgentRpcHost.ts +29 -9
  18. package/codeAgent/agent/CodeAgentRpcListener.ts +117 -0
  19. package/codeAgent/agent/CodeAgentSuspended.ts +92 -0
  20. package/codeAgent/agent/akanCodeModel.ts +1 -1
  21. package/codeAgent/agent/akanContextFiles.test.ts +2 -1
  22. package/codeAgent/agent/codeAgentContract.test.ts +17 -0
  23. package/codeAgent/agent/codeAgentProxy.test.ts +74 -0
  24. package/codeAgent/agent/codeAgentRpcListener.test.ts +113 -0
  25. package/codeAgent/agent/codeAgentSuspended.test.ts +47 -0
  26. package/codeAgent/tools/mcpOAuth.test.ts +6 -2
  27. package/dependencyScanner.ts +4 -2
  28. package/devkitUtils.test.ts +1 -1
  29. package/executors.linkFallback.test.ts +103 -0
  30. package/executors.test.ts +8 -0
  31. package/executors.ts +31 -8
  32. package/frontendBuild/autoImportSync.test.ts +13 -0
  33. package/frontendBuild/autoImportSync.ts +19 -1
  34. package/frontendBuild/buildRouteClient.test.ts +52 -1
  35. package/frontendBuild/cssImportResolver.ts +1 -1
  36. package/frontendBuild/fontOptimizer.test.ts +1 -1
  37. package/frontendBuild/frontendBuild.test.ts +19 -17
  38. package/frontendBuild/hmrWatcher.test.ts +1 -1
  39. package/frontendBuild/routeClientBuilder.ts +27 -10
  40. package/frontendBuild/sourceMtimeIndex.test.ts +6 -5
  41. package/getDirname.test.ts +4 -2
  42. package/incrementalBuilder/devWatchBatch.test.ts +7 -6
  43. package/incrementalBuilder/incrementalBuilder.proc.ts +1 -0
  44. package/integration/devStabilityHarness.test.ts +7 -2
  45. package/lint/gritRules.test.ts +2 -1
  46. package/lint/no-deep-internal-import.grit +2 -2
  47. package/lint/no-redeclare-predefined-endpoint.grit +2 -1
  48. package/linter.ts +4 -1
  49. package/package.json +2 -2
  50. package/platformTest/BunTestReport.test.ts +60 -0
  51. package/platformTest/BunTestReport.ts +65 -0
  52. package/platformTest/IdleWatchedProcess.test.ts +64 -0
  53. package/platformTest/IdleWatchedProcess.ts +77 -0
  54. package/platformTest/LinuxDockerTarget.ts +120 -0
  55. package/platformTest/PlatformTestRun.test.ts +68 -0
  56. package/platformTest/PlatformTestRun.ts +141 -0
  57. package/platformTest/PlatformTestTarget.ts +120 -0
  58. package/platformTest/TestSnapshot.test.ts +80 -0
  59. package/platformTest/TestSnapshot.ts +107 -0
  60. package/platformTest/WindowsSshTarget.ts +167 -0
  61. package/subspace.test.ts +5 -2
  62. package/subspace.ts +19 -8
  63. package/workflow/rolloutGate.test.ts +1 -1
@@ -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/subspace.test.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { afterEach, beforeEach, describe, expect, test } from "bun:test";
1
+ import { afterEach, beforeEach, describe, expect, setDefaultTimeout, test } from "bun:test";
2
2
  import { mkdir, mkdtemp, readdir, rm, writeFile } from "node:fs/promises";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
@@ -7,6 +7,9 @@ import { FileSys } from "./fileSys";
7
7
  import { formatSubspaceDiff, formatSubspacePushResults, formatSubspaceStatuses, Subspace } from "./subspace";
8
8
  import { SubspaceConfig } from "./subspaceConfig";
9
9
 
10
+ //? Each case drives several real git clones and pushes; on a 4-core Windows VM that alone lands at 2-5s.
11
+ setDefaultTimeout(20_000);
12
+
10
13
  const tempRoots: string[] = [];
11
14
  const originalEnv = { ...process.env };
12
15
 
@@ -96,7 +99,7 @@ const makeMirror = async (servedApp: string, privateApp: string, libName: string
96
99
  await write(path.join(wsRoot, "benchmarks/bench.ts"), "export {};\n");
97
100
  await write(
98
101
  path.join(wsRoot, "akan.subspace.ts"),
99
- `export default { pushableBranches: ["develop"], exclude: ["benchmarks"], subspaces: [{ name: "acme", repo: "${bareRoot}", apps: ["${servedApp}"] }] };\n`,
102
+ `export default { pushableBranches: ["develop"], exclude: ["benchmarks"], subspaces: [{ name: "acme", repo: ${JSON.stringify(bareRoot)}, apps: ["${servedApp}"] }] };\n`,
100
103
  );
101
104
 
102
105
  await makeSys(wsRoot, "apps", servedApp, {
package/subspace.ts CHANGED
@@ -296,15 +296,25 @@ export class Subspace {
296
296
  /**
297
297
  * `git archive` emits exactly the tracked files at HEAD, which is why the copy needs no exclude list of
298
298
  * its own: generated barrels, the `(libs)`/`public/libs` symlinks, env values, secrets and the lockfile
299
- * are all outside git and cannot enter the archive. Directory arguments only — a file list would put
300
- * route paths like `page/(docs)/…` through a shell.
299
+ * are all outside git and cannot enter the archive. Spawned without a shell, whose quoting differs between
300
+ * `sh` and `cmd.exe`.
301
301
  */
302
302
  async #extract(paths: string[], excludes: string[] = []) {
303
- const pathspec = paths.map((entry) => `'${entry}'`).join(" ");
304
- const excludeArgs = excludes.map((entry) => `--exclude='${entry}'`).join(" ");
305
- await this.#workspace.exec(
306
- `git archive HEAD -- ${pathspec} | tar -x -C '${this.#clonePath}' ${excludeArgs}`.trim(),
307
- );
303
+ // Relative, because GNU tar reads a drive-letter archive name (`C:\…`) as `host:path` on a remote host.
304
+ const archivePath = path.relative(this.#workspace.workspaceRoot, `${this.#clonePath}.tar`);
305
+ await this.#workspace.spawn("git", ["archive", "-o", archivePath, "HEAD", "--", ...paths]);
306
+ try {
307
+ await this.#workspace.spawn("tar", [
308
+ "-x",
309
+ "-f",
310
+ archivePath,
311
+ "-C",
312
+ this.#clonePath,
313
+ ...excludes.map((entry) => `--exclude=${entry}`),
314
+ ]);
315
+ } finally {
316
+ await rm(path.join(this.#workspace.workspaceRoot, archivePath), { force: true });
317
+ }
308
318
  }
309
319
 
310
320
  /**
@@ -399,7 +409,8 @@ export class Subspace {
399
409
  const gitignorePath = path.join(this.#clonePath, ".gitignore");
400
410
  if (!(await FileSys.fileExists(gitignorePath))) return;
401
411
  const [begin = "", end = ""] = Subspace.secretsMarkers;
402
- const lines = (await FileSys.readText(gitignorePath)).split("\n");
412
+ // `git archive` checks text out as CRLF under Windows Git's default `core.autocrlf=true`.
413
+ const lines = (await FileSys.readText(gitignorePath)).split(/\r?\n/);
403
414
  const beginIdx = lines.indexOf(begin);
404
415
  const endIdx = lines.indexOf(end);
405
416
  const filtered =
@@ -24,7 +24,7 @@ const workspacePackageManifestPaths = async () => {
24
24
  for (const workspace of workspaces) {
25
25
  const glob = new Bun.Glob(`${workspace}/package.json`);
26
26
  for await (const relativePath of glob.scan({ cwd: workspaceRoot, onlyFiles: true })) {
27
- paths.add(relativePath);
27
+ paths.add(relativePath.split(path.sep).join("/"));
28
28
  }
29
29
  }
30
30
  return [...paths].sort();