@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.
- package/abstractDoc.ts +4 -1
- package/akanApp/akanApp.host.ts +23 -9
- package/akanConfig/akanConfig.test.ts +55 -30
- package/akanConfig/akanConfig.ts +29 -17
- package/akanConfig/types.ts +1 -0
- package/applicationBuildRunner.test.ts +5 -3
- package/applicationBuildRunner.ts +29 -3
- package/artifact/routeSeedIndex.test.ts +7 -6
- package/capacitorApp.test.ts +2 -2
- package/cloud/constants.ts +23 -0
- package/cloud/globalConfig.ts +13 -0
- package/codeAgent/agent/AkanCodeServices.ts +22 -3
- package/codeAgent/agent/CodeAgent.ts +33 -18
- package/codeAgent/agent/CodeAgentAsks.ts +3 -1
- package/codeAgent/agent/CodeAgentEventMapper.ts +9 -1
- package/codeAgent/agent/CodeAgentProxy.ts +91 -0
- package/codeAgent/agent/CodeAgentRpcHost.ts +29 -9
- package/codeAgent/agent/CodeAgentRpcListener.ts +117 -0
- package/codeAgent/agent/CodeAgentSuspended.ts +92 -0
- package/codeAgent/agent/akanCodeModel.ts +1 -1
- package/codeAgent/agent/akanContextFiles.test.ts +2 -1
- package/codeAgent/agent/codeAgentContract.test.ts +17 -0
- package/codeAgent/agent/codeAgentProxy.test.ts +74 -0
- package/codeAgent/agent/codeAgentRpcListener.test.ts +113 -0
- package/codeAgent/agent/codeAgentSuspended.test.ts +47 -0
- package/codeAgent/tools/mcpOAuth.test.ts +6 -2
- package/dependencyScanner.ts +4 -2
- package/devkitUtils.test.ts +1 -1
- package/executors.linkFallback.test.ts +103 -0
- package/executors.test.ts +8 -0
- package/executors.ts +31 -8
- package/frontendBuild/autoImportSync.test.ts +13 -0
- package/frontendBuild/autoImportSync.ts +19 -1
- package/frontendBuild/buildRouteClient.test.ts +52 -1
- package/frontendBuild/cssImportResolver.ts +1 -1
- package/frontendBuild/fontOptimizer.test.ts +1 -1
- package/frontendBuild/frontendBuild.test.ts +19 -17
- package/frontendBuild/hmrWatcher.test.ts +1 -1
- package/frontendBuild/routeClientBuilder.ts +27 -10
- package/frontendBuild/sourceMtimeIndex.test.ts +6 -5
- package/getDirname.test.ts +4 -2
- package/incrementalBuilder/devWatchBatch.test.ts +7 -6
- package/incrementalBuilder/incrementalBuilder.proc.ts +1 -0
- package/integration/devStabilityHarness.test.ts +7 -2
- package/lint/gritRules.test.ts +2 -1
- package/lint/no-deep-internal-import.grit +2 -2
- package/lint/no-redeclare-predefined-endpoint.grit +2 -1
- package/linter.ts +4 -1
- package/package.json +2 -2
- package/platformTest/BunTestReport.test.ts +60 -0
- package/platformTest/BunTestReport.ts +65 -0
- package/platformTest/IdleWatchedProcess.test.ts +64 -0
- package/platformTest/IdleWatchedProcess.ts +77 -0
- package/platformTest/LinuxDockerTarget.ts +120 -0
- package/platformTest/PlatformTestRun.test.ts +68 -0
- package/platformTest/PlatformTestRun.ts +141 -0
- package/platformTest/PlatformTestTarget.ts +120 -0
- package/platformTest/TestSnapshot.test.ts +80 -0
- package/platformTest/TestSnapshot.ts +107 -0
- package/platformTest/WindowsSshTarget.ts +167 -0
- package/subspace.test.ts +5 -2
- package/subspace.ts +19 -8
- 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:
|
|
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.
|
|
300
|
-
*
|
|
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
|
-
|
|
304
|
-
const
|
|
305
|
-
await this.#workspace.
|
|
306
|
-
|
|
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
|
-
|
|
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();
|