@akanjs/cli 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/.build-stamp +1 -1
- package/{CodeAgent-0035exh3.js → CodeAgent-6cm97kay.js} +2 -2
- package/{CodeAgentRpcHost-qvwfv0n9.js → CodeAgentRpcHost-p7h6n2hp.js} +1 -1
- package/CodeAgentRpcListener-7p7z5dd7.js +97 -0
- package/{CodeTui-eweg1hep.js → CodeTui-za4jsm41.js} +1 -1
- package/{TunnelShare-r5tc2qk5.js → TunnelShare-2j6s511d.js} +2 -2
- package/{agent.command-65zqfkg0.js → agent.command-e0wpbbyg.js} +5 -5
- package/akanCode.js +4 -4
- package/{application.command-2wcj5da3.js → application.command-8wyxqc8q.js} +15 -9
- package/{applicationBuildRunner-m0hhgwsx.js → applicationBuildRunner-n9gzaxd0.js} +31 -7
- package/{applicationReleasePackager-vykfzgx3.js → applicationReleasePackager-vpggfqgq.js} +1 -1
- package/buildBatch.proc.js +3 -3
- package/{capacitorApp-2daqhb5h.js → capacitorApp-q3z0qht0.js} +2 -2
- package/{cloud.command-960bgbf4.js → cloud.command-4zwa5dsx.js} +34 -12
- package/cloud.script-fwx3vb34.js +20 -0
- package/{code.command-4feh3ef7.js → code.command-468c9d7s.js} +41 -19
- package/commandManifest.json +1 -1
- package/{context.command-kzehkx48.js → context.command-dsnw0jby.js} +13 -13
- package/context.runner-rs4rj15d.js +22 -0
- package/{dependencyScanner-shs82ynp.js → dependencyScanner-0z2h2tks.js} +1 -1
- package/{guideline.command-3w2g20x4.js → guideline.command-vhw13pqr.js} +2 -2
- package/guidelines/agentRule/agentRule.instruction.md +27 -10
- package/guidelines/conventions/conventions.instruction.md +16 -4
- package/guidelines/queryRule/queryRule.instruction.md +16 -6
- package/guidelines/runtimeRule/runtimeRule.instruction.md +74 -0
- package/incrementalBuilder.proc.js +3 -2
- package/{index-dw943k93.js → index-10dkrrww.js} +4 -4
- package/{index-rpgxs245.js → index-18zybgkc.js} +2 -2
- package/{index-mt2154s2.js → index-22xtgmhn.js} +1 -1
- package/{index-yw6g3bbv.js → index-2446k7b4.js} +1 -1
- package/{index-cjw1sj1x.js → index-3jrp517k.js} +10 -10
- package/{index-q9c3c0hy.js → index-4m929vvn.js} +2 -2
- package/{index-ef228gzf.js → index-53176s7j.js} +3 -3
- package/{index-jwrpsrg3.js → index-667w0yah.js} +1 -1
- package/{index-6q589y9t.js → index-7ep8qtvp.js} +2 -2
- package/{index-w8kmx1b6.js → index-89hy31kf.js} +30 -7
- package/{index-jd01kj5r.js → index-9d6nvkz2.js} +1 -1
- package/{index-zq0mr3hh.js → index-a1w6934z.js} +3 -3
- package/{index-sbfk9daf.js → index-ab0c38ea.js} +4 -4
- package/index-bc88j1ry.js +1166 -0
- package/{index-h94faz96.js → index-bftkz6cp.js} +274 -60
- package/{index-kcxmv48c.js → index-bhtmj233.js} +4 -1
- package/{index-m41ptvat.js → index-bwf965h6.js} +3 -3
- package/{index-bwxpk2cj.js → index-crmeqnmf.js} +52 -26
- package/{index-8k16atqy.js → index-d2242ksy.js} +23 -6
- package/{index-jwexzh42.js → index-e4v6vhxq.js} +222 -56
- package/{index-pn8mhpps.js → index-esk82rvk.js} +4 -4
- package/{index-4r42ext5.js → index-kwaq3n9n.js} +1 -1
- package/{index-gm76f0qb.js → index-n3cv0tm4.js} +1 -1
- package/{index-ycy302an.js → index-qdgzsnhg.js} +1 -1
- package/{index-wcg582a8.js → index-sq2dzkzm.js} +35 -10
- package/{index-myj01w9r.js → index-srmay5jw.js} +15 -2
- package/{index-zmj143x7.js → index-txr7dj2g.js} +3 -3
- package/{index-erfc1x40.js → index-vqx8bdxz.js} +1 -1
- package/{index-d9fjz06h.js → index-zqgj2rt2.js} +4 -4
- package/index.js +21 -21
- package/{library.command-fp0nhr62.js → library.command-cqr175dy.js} +3 -3
- package/{localRegistry.command-8y0ecxb9.js → localRegistry.command-49z55qqz.js} +9 -9
- package/{module.command-kqkn48t2.js → module.command-9p8mm3b0.js} +5 -5
- package/{package.command-qverjh22.js → package.command-wxyz909t.js} +4 -4
- package/package.json +2 -2
- package/{page.command-94bwhd0g.js → page.command-gsrsfqqs.js} +3 -3
- package/{primitive.command-2ydvtah2.js → primitive.command-ns1mptje.js} +6 -6
- package/{quality.command-fa0gth4p.js → quality.command-e76k2q9k.js} +17 -15
- package/{repair.command-4qng2hpm.js → repair.command-pkrsm3cw.js} +5 -5
- package/{scalar.command-pb2y95sh.js → scalar.command-0cpt3fc8.js} +4 -4
- package/{subspace.command-vkxmwp74.js → subspace.command-fktpwp0g.js} +20 -10
- package/templates/appSample/lib/task/task.signal.test.ts +1 -1
- package/templates/localDev/docker-compose.yaml.template +3 -14
- package/{tunnel.command-02cvp9nf.js → tunnel.command-d5jy9gk8.js} +4 -4
- package/{workflow.command-132txnrh.js → workflow.command-6tkcysmx.js} +9 -9
- package/{workspace.command-d5wkswek.js → workspace.command-gv84eh1p.js} +24 -23
- package/cloud.script-qhcnqt1n.js +0 -20
- package/context.runner-z0rrcz7b.js +0 -22
- package/index-3hpxeys1.js +0 -462
|
@@ -0,0 +1,1166 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
import {
|
|
3
|
+
getDefaultHostConfig,
|
|
4
|
+
GlobalConfig,
|
|
5
|
+
CloudApi
|
|
6
|
+
} from "./index-srmay5jw.js";
|
|
7
|
+
import {
|
|
8
|
+
runner
|
|
9
|
+
} from "./index-qdgzsnhg.js";
|
|
10
|
+
import {
|
|
11
|
+
WorkspaceExecutor,
|
|
12
|
+
AppExecutor
|
|
13
|
+
} from "./index-crmeqnmf.js";
|
|
14
|
+
import {
|
|
15
|
+
openBrowser
|
|
16
|
+
} from "./index-9dp031by.js";
|
|
17
|
+
|
|
18
|
+
// pkgs/@akanjs/devkit/platformTest/PlatformTestRun.ts
|
|
19
|
+
import path6 from "path";
|
|
20
|
+
import chalk from "chalk";
|
|
21
|
+
|
|
22
|
+
// pkgs/@akanjs/devkit/platformTest/LinuxDockerTarget.ts
|
|
23
|
+
import os from "os";
|
|
24
|
+
import path3 from "path";
|
|
25
|
+
|
|
26
|
+
// pkgs/@akanjs/devkit/platformTest/IdleWatchedProcess.ts
|
|
27
|
+
import { mkdir } from "fs/promises";
|
|
28
|
+
import path from "path";
|
|
29
|
+
|
|
30
|
+
class IdleWatchedProcess {
|
|
31
|
+
#command;
|
|
32
|
+
#options;
|
|
33
|
+
constructor(command, options) {
|
|
34
|
+
this.#command = command;
|
|
35
|
+
this.#options = options;
|
|
36
|
+
}
|
|
37
|
+
async run() {
|
|
38
|
+
const { logPath, idleMs, totalMs, cwd, env, onIdle, onOutput } = this.#options;
|
|
39
|
+
await mkdir(path.dirname(logPath), { recursive: true });
|
|
40
|
+
const log = Bun.file(logPath).writer();
|
|
41
|
+
const startedAt = performance.now();
|
|
42
|
+
let lastOutputAt = startedAt;
|
|
43
|
+
let output = "";
|
|
44
|
+
let hung = false;
|
|
45
|
+
let timedOut = false;
|
|
46
|
+
const proc = Bun.spawn(this.#command, { cwd, env, stdin: "ignore", stdout: "pipe", stderr: "pipe" });
|
|
47
|
+
const pump = async (stream) => {
|
|
48
|
+
const decoder = new TextDecoder;
|
|
49
|
+
for await (const chunk of stream) {
|
|
50
|
+
lastOutputAt = performance.now();
|
|
51
|
+
const text = decoder.decode(chunk, { stream: true });
|
|
52
|
+
output += text;
|
|
53
|
+
log.write(chunk);
|
|
54
|
+
onOutput?.(text);
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
const pumping = Promise.all([pump(proc.stdout), pump(proc.stderr)]);
|
|
58
|
+
const watch = setInterval(() => {
|
|
59
|
+
const now = performance.now();
|
|
60
|
+
if (now - lastOutputAt >= idleMs)
|
|
61
|
+
hung = true;
|
|
62
|
+
else if (totalMs !== undefined && now - startedAt >= totalMs)
|
|
63
|
+
timedOut = true;
|
|
64
|
+
else
|
|
65
|
+
return;
|
|
66
|
+
clearInterval(watch);
|
|
67
|
+
(async () => {
|
|
68
|
+
await onIdle?.().catch(() => {
|
|
69
|
+
return;
|
|
70
|
+
});
|
|
71
|
+
proc.kill();
|
|
72
|
+
})();
|
|
73
|
+
}, Math.max(50, Math.min(1000, idleMs / 4)));
|
|
74
|
+
const exitCode = await proc.exited;
|
|
75
|
+
clearInterval(watch);
|
|
76
|
+
await pumping;
|
|
77
|
+
await log.end();
|
|
78
|
+
return { exitCode: hung || timedOut ? null : exitCode, hung, timedOut, output, ms: performance.now() - startedAt };
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// pkgs/@akanjs/devkit/platformTest/PlatformTestTarget.ts
|
|
83
|
+
import path2 from "path";
|
|
84
|
+
|
|
85
|
+
// pkgs/@akanjs/devkit/platformTest/BunTestReport.ts
|
|
86
|
+
import { stripVTControlCharacters } from "util";
|
|
87
|
+
|
|
88
|
+
class BunTestReport {
|
|
89
|
+
static #fileHeaderPattern = /^(\S+\.(?:test|spec)\.[cm]?[jt]sx?):$/;
|
|
90
|
+
static #relistPattern = /^\d+ tests? failed:$/;
|
|
91
|
+
static #durationSuffix = /\s+\[[\d.]+m?s\]$/;
|
|
92
|
+
pass;
|
|
93
|
+
fail;
|
|
94
|
+
skip;
|
|
95
|
+
errors;
|
|
96
|
+
files;
|
|
97
|
+
hasSummary;
|
|
98
|
+
failures;
|
|
99
|
+
lastFile;
|
|
100
|
+
constructor(output) {
|
|
101
|
+
const lines = stripVTControlCharacters(output).split(/\r?\n/);
|
|
102
|
+
let currentFile = null;
|
|
103
|
+
let lastFile = null;
|
|
104
|
+
let relisted = false;
|
|
105
|
+
const failures = new Map;
|
|
106
|
+
for (const line of lines) {
|
|
107
|
+
if (BunTestReport.#relistPattern.test(line))
|
|
108
|
+
relisted = true;
|
|
109
|
+
const header = BunTestReport.#fileHeaderPattern.exec(line);
|
|
110
|
+
if (header?.[1] && !relisted) {
|
|
111
|
+
currentFile = header[1].replaceAll("\\", "/");
|
|
112
|
+
lastFile = currentFile;
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (!line.startsWith("(fail) "))
|
|
116
|
+
continue;
|
|
117
|
+
const name = line.slice("(fail) ".length).replace(BunTestReport.#durationSuffix, "");
|
|
118
|
+
if (!failures.has(name))
|
|
119
|
+
failures.set(name, { file: relisted ? null : currentFile, name });
|
|
120
|
+
}
|
|
121
|
+
const ran = lines.findLast((line) => /^Ran \d+ tests? across \d+ files?/.test(line));
|
|
122
|
+
this.hasSummary = !!ran;
|
|
123
|
+
this.files = ran ? Number(/across (\d+) files?/.exec(ran)?.[1] ?? 0) : 0;
|
|
124
|
+
this.failures = [...failures.values()];
|
|
125
|
+
this.lastFile = lastFile;
|
|
126
|
+
this.pass = ran ? BunTestReport.#lastCount(lines, /^\s*(\d+) pass$/) : lines.filter((line) => line.startsWith("(pass) ")).length;
|
|
127
|
+
this.fail = ran ? BunTestReport.#lastCount(lines, /^\s*(\d+) fail$/) : this.failures.length;
|
|
128
|
+
this.skip = ran ? BunTestReport.#lastCount(lines, /^\s*(\d+) skip$/) : 0;
|
|
129
|
+
this.errors = ran ? BunTestReport.#lastCount(lines, /^\s*(\d+) errors?$/) : 0;
|
|
130
|
+
}
|
|
131
|
+
get passed() {
|
|
132
|
+
return this.hasSummary && this.fail === 0 && this.errors === 0;
|
|
133
|
+
}
|
|
134
|
+
static #lastCount(lines, pattern) {
|
|
135
|
+
for (const line of lines.toReversed()) {
|
|
136
|
+
const count = pattern.exec(line)?.[1];
|
|
137
|
+
if (count)
|
|
138
|
+
return Number(count);
|
|
139
|
+
}
|
|
140
|
+
return 0;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// pkgs/@akanjs/devkit/platformTest/PlatformTestTarget.ts
|
|
145
|
+
class PlatformTestTarget {
|
|
146
|
+
static installIdleMs = 5 * 60000;
|
|
147
|
+
static testIdleMs = 2 * 60000;
|
|
148
|
+
tolerateScriptFailures = [];
|
|
149
|
+
async run(context) {
|
|
150
|
+
const startedAt = performance.now();
|
|
151
|
+
const envWarnings = [];
|
|
152
|
+
const packages = [];
|
|
153
|
+
const result = (infraError) => ({
|
|
154
|
+
platform: this.platform,
|
|
155
|
+
policy: this.policy,
|
|
156
|
+
packages,
|
|
157
|
+
envWarnings,
|
|
158
|
+
infraError,
|
|
159
|
+
ms: performance.now() - startedAt
|
|
160
|
+
});
|
|
161
|
+
try {
|
|
162
|
+
await this.prepare(context);
|
|
163
|
+
await this.#install(context, envWarnings);
|
|
164
|
+
const build = await this.exec("bun run buildAkan", this.#logPath(context, "build"), PlatformTestTarget.installIdleMs);
|
|
165
|
+
if (build.exitCode !== 0)
|
|
166
|
+
throw new Error(`buildAkan failed \u2014 see ${this.#logPath(context, "build")}`);
|
|
167
|
+
for (const pkg of context.pkgs) {
|
|
168
|
+
const logPath = this.#logPath(context, `test-${pkg.replaceAll("/", "__")}`);
|
|
169
|
+
const run = await this.exec(`bun dist/pkgs/@akanjs/cli/index.js test ${pkg}`, logPath, PlatformTestTarget.testIdleMs);
|
|
170
|
+
const report = new BunTestReport(run.output);
|
|
171
|
+
const status = run.hung || run.timedOut ? "hung" : report.passed && run.exitCode === 0 ? "pass" : "fail";
|
|
172
|
+
packages.push({ pkg, status, report, ms: run.ms, logPath });
|
|
173
|
+
context.onProgress(`[${this.platform}] ${PlatformTestTarget.describe(packages.at(-1))}`);
|
|
174
|
+
}
|
|
175
|
+
return result(null);
|
|
176
|
+
} catch (error) {
|
|
177
|
+
return result(error instanceof Error ? error.message : String(error));
|
|
178
|
+
} finally {
|
|
179
|
+
await this.cleanup(context).catch(() => {
|
|
180
|
+
return;
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
static describe({ pkg, status, report, ms }) {
|
|
185
|
+
const seconds = `${Math.round(ms / 1000)}s`;
|
|
186
|
+
if (status === "hung")
|
|
187
|
+
return `${pkg}: hung${report.lastFile ? ` in ${report.lastFile}` : ""} (${seconds})`;
|
|
188
|
+
return `${pkg}: ${report.pass} pass / ${report.fail} fail${report.errors ? ` / ${report.errors} errors` : ""} (${seconds})`;
|
|
189
|
+
}
|
|
190
|
+
async#install(context, envWarnings) {
|
|
191
|
+
const logPath = this.#logPath(context, "install");
|
|
192
|
+
const install = await this.exec("bun install --frozen-lockfile", logPath, PlatformTestTarget.installIdleMs);
|
|
193
|
+
if (install.exitCode === 0)
|
|
194
|
+
return;
|
|
195
|
+
const failedScripts = [...install.output.matchAll(/(?:pre|post)?install script from "([^"]+)" exited/g)].map((match) => match[1] ?? "");
|
|
196
|
+
const tolerable = failedScripts.length > 0 && failedScripts.every((pkg) => this.tolerateScriptFailures.includes(pkg));
|
|
197
|
+
if (!tolerable)
|
|
198
|
+
throw new Error(`bun install failed \u2014 see ${logPath}`);
|
|
199
|
+
envWarnings.push(`install script failed for ${failedScripts.join(", ")} \u2014 tolerated on ${this.platform}`);
|
|
200
|
+
const relink = await this.exec("bun install --frozen-lockfile --ignore-scripts", this.#logPath(context, "install-relink"), PlatformTestTarget.installIdleMs);
|
|
201
|
+
if (relink.exitCode !== 0)
|
|
202
|
+
throw new Error(`bun install --ignore-scripts failed \u2014 see ${logPath}`);
|
|
203
|
+
}
|
|
204
|
+
#logPath(context, name) {
|
|
205
|
+
return path2.join(context.logDir, this.platform, `${name}.log`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// pkgs/@akanjs/devkit/platformTest/LinuxDockerTarget.ts
|
|
210
|
+
class LinuxDockerTarget extends PlatformTestTarget {
|
|
211
|
+
static cacheVolume = "akan-test-bun-cache";
|
|
212
|
+
platform = "linux";
|
|
213
|
+
policy;
|
|
214
|
+
#config;
|
|
215
|
+
#container = null;
|
|
216
|
+
constructor(config = {}) {
|
|
217
|
+
super();
|
|
218
|
+
this.#config = config;
|
|
219
|
+
this.policy = config.policy ?? "gate";
|
|
220
|
+
}
|
|
221
|
+
static dockerfile = [
|
|
222
|
+
`FROM oven/bun:${Bun.version}-slim`,
|
|
223
|
+
"RUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates procps lsof && rm -rf /var/lib/apt/lists/*"
|
|
224
|
+
].join(`
|
|
225
|
+
`);
|
|
226
|
+
get image() {
|
|
227
|
+
return `akan-test-linux:bun-${Bun.version}-${Bun.hash(LinuxDockerTarget.dockerfile).toString(16).slice(0, 8)}`;
|
|
228
|
+
}
|
|
229
|
+
async prepare(context) {
|
|
230
|
+
if ((await LinuxDockerTarget.#docker(["info"])).exitCode !== 0)
|
|
231
|
+
throw new Error("Docker is not running \u2014 start Docker Desktop, or leave linux out of --platforms");
|
|
232
|
+
await this.#ensureImage();
|
|
233
|
+
const container = `akan-test-${context.runId}`;
|
|
234
|
+
const cpus = this.#config.cpus ?? Math.max(2, Math.floor(os.availableParallelism() / 2));
|
|
235
|
+
const started = await LinuxDockerTarget.#docker([
|
|
236
|
+
"run",
|
|
237
|
+
"-d",
|
|
238
|
+
"--rm",
|
|
239
|
+
"--name",
|
|
240
|
+
container,
|
|
241
|
+
"--cpus",
|
|
242
|
+
String(cpus),
|
|
243
|
+
...this.#config.memory ? ["--memory", this.#config.memory] : [],
|
|
244
|
+
"-v",
|
|
245
|
+
`${LinuxDockerTarget.cacheVolume}:/root/.bun/install/cache`,
|
|
246
|
+
"-v",
|
|
247
|
+
`${path3.dirname(context.snapshot.tarPath)}:/io:ro`,
|
|
248
|
+
"-v",
|
|
249
|
+
`${context.envPath}:/io-env:ro`,
|
|
250
|
+
"-w",
|
|
251
|
+
"/w",
|
|
252
|
+
this.image,
|
|
253
|
+
"sleep",
|
|
254
|
+
"infinity"
|
|
255
|
+
]);
|
|
256
|
+
if (started.exitCode !== 0)
|
|
257
|
+
throw new Error(`docker run failed: ${started.stderr.trim()}`);
|
|
258
|
+
this.#container = container;
|
|
259
|
+
const extract = await this.exec(`tar -xf /io/${path3.basename(context.snapshot.tarPath)} -C /w && cp /io-env /w/.env && git -C /w init -q`, path3.join(context.logDir, this.platform, "extract.log"), PlatformTestTarget.installIdleMs);
|
|
260
|
+
if (extract.exitCode !== 0)
|
|
261
|
+
throw new Error("extracting the snapshot into the container failed");
|
|
262
|
+
}
|
|
263
|
+
async exec(command, logPath, idleMs) {
|
|
264
|
+
const container = this.#container;
|
|
265
|
+
if (!container)
|
|
266
|
+
throw new Error("the linux container is not running");
|
|
267
|
+
return await new IdleWatchedProcess(["docker", "exec", ...LinuxDockerTarget.#gitIdentityEnv(), container, "sh", "-c", command], {
|
|
268
|
+
logPath,
|
|
269
|
+
idleMs,
|
|
270
|
+
onIdle: async () => {
|
|
271
|
+
await LinuxDockerTarget.#docker(["exec", container, "pkill", "-9", "bun"]);
|
|
272
|
+
}
|
|
273
|
+
}).run();
|
|
274
|
+
}
|
|
275
|
+
async cleanup() {
|
|
276
|
+
if (this.#container)
|
|
277
|
+
await LinuxDockerTarget.#docker(["rm", "-f", this.#container]);
|
|
278
|
+
this.#container = null;
|
|
279
|
+
}
|
|
280
|
+
async#ensureImage() {
|
|
281
|
+
if ((await LinuxDockerTarget.#docker(["image", "inspect", this.image])).exitCode === 0)
|
|
282
|
+
return;
|
|
283
|
+
const built = await LinuxDockerTarget.#docker(["build", "-t", this.image, "-"], LinuxDockerTarget.dockerfile);
|
|
284
|
+
if (built.exitCode !== 0)
|
|
285
|
+
throw new Error(`building ${this.image} failed: ${built.stderr.trim().slice(-400)}`);
|
|
286
|
+
}
|
|
287
|
+
static #gitIdentityEnv() {
|
|
288
|
+
return Object.entries({
|
|
289
|
+
GIT_AUTHOR_NAME: "akan-test",
|
|
290
|
+
GIT_AUTHOR_EMAIL: "akan-test@localhost",
|
|
291
|
+
GIT_COMMITTER_NAME: "akan-test",
|
|
292
|
+
GIT_COMMITTER_EMAIL: "akan-test@localhost",
|
|
293
|
+
GIT_CONFIG_COUNT: "1",
|
|
294
|
+
GIT_CONFIG_KEY_0: "init.defaultBranch",
|
|
295
|
+
GIT_CONFIG_VALUE_0: "main"
|
|
296
|
+
}).flatMap(([key, value]) => ["-e", `${key}=${value}`]);
|
|
297
|
+
}
|
|
298
|
+
static async#docker(args, stdin) {
|
|
299
|
+
const proc = Bun.spawn(["docker", ...args], {
|
|
300
|
+
stdin: stdin === undefined ? "ignore" : new Response(stdin),
|
|
301
|
+
stdout: "pipe",
|
|
302
|
+
stderr: "pipe"
|
|
303
|
+
});
|
|
304
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
305
|
+
new Response(proc.stdout).text(),
|
|
306
|
+
new Response(proc.stderr).text(),
|
|
307
|
+
proc.exited
|
|
308
|
+
]);
|
|
309
|
+
return { stdout, stderr, exitCode };
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// pkgs/@akanjs/devkit/platformTest/TestSnapshot.ts
|
|
314
|
+
import { mkdir as mkdir2, stat, writeFile } from "fs/promises";
|
|
315
|
+
import path4 from "path";
|
|
316
|
+
|
|
317
|
+
class TestSnapshot {
|
|
318
|
+
static #secretPatterns = [
|
|
319
|
+
/(^|\/)\.env(\.(?!(template|example|sample)$)[^/]+)?$/,
|
|
320
|
+
/(^|\/)env\/env\.(client|server)\.(?!(type|example)\.ts$)[^/]+\.ts$/
|
|
321
|
+
];
|
|
322
|
+
root;
|
|
323
|
+
tarPath;
|
|
324
|
+
digest;
|
|
325
|
+
#hashes;
|
|
326
|
+
constructor(root, tarPath, hashes) {
|
|
327
|
+
this.root = root;
|
|
328
|
+
this.tarPath = tarPath;
|
|
329
|
+
this.#hashes = hashes;
|
|
330
|
+
this.digest = TestSnapshot.#digestOf(hashes);
|
|
331
|
+
}
|
|
332
|
+
get fileCount() {
|
|
333
|
+
return this.#hashes.size;
|
|
334
|
+
}
|
|
335
|
+
static async create(root, outDir) {
|
|
336
|
+
const hashes = await TestSnapshot.#hashTree(root);
|
|
337
|
+
const untracked = await TestSnapshot.#gitList(root, ["--others", "--exclude-standard"]);
|
|
338
|
+
const secrets = untracked.filter((file) => TestSnapshot.#secretPatterns.some((re) => re.test(file)));
|
|
339
|
+
if (secrets.length)
|
|
340
|
+
throw new Error(`Refusing to snapshot env files git does not ignore \u2014 they would be copied to every test target: ${secrets.join(", ")}`);
|
|
341
|
+
await mkdir2(outDir, { recursive: true });
|
|
342
|
+
const listPath = path4.join(outDir, "files.txt");
|
|
343
|
+
const tarPath = path4.join(outDir, "src.tar");
|
|
344
|
+
await writeFile(listPath, `${[...hashes.keys()].join(`
|
|
345
|
+
`)}
|
|
346
|
+
`);
|
|
347
|
+
const tar = Bun.spawn(["tar", ...process.platform === "darwin" ? ["--no-xattrs"] : [], "-cf", tarPath, "-T", listPath], { cwd: root, env: { ...process.env, COPYFILE_DISABLE: "1" }, stdout: "ignore", stderr: "pipe" });
|
|
348
|
+
if (await tar.exited !== 0)
|
|
349
|
+
throw new Error(`tar failed: ${await new Response(tar.stderr).text()}`);
|
|
350
|
+
return new TestSnapshot(root, tarPath, hashes);
|
|
351
|
+
}
|
|
352
|
+
async drift() {
|
|
353
|
+
const current = await TestSnapshot.#hashTree(this.root);
|
|
354
|
+
const changed = [...current].filter(([file, hash]) => this.#hashes.has(file) && this.#hashes.get(file) !== hash);
|
|
355
|
+
return {
|
|
356
|
+
changed: changed.map(([file]) => file),
|
|
357
|
+
added: [...current.keys()].filter((file) => !this.#hashes.has(file)),
|
|
358
|
+
removed: [...this.#hashes.keys()].filter((file) => !current.has(file))
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
static async#gitList(root, args) {
|
|
362
|
+
const proc = Bun.spawn(["git", "ls-files", ...args, "-z"], { cwd: root, stdout: "pipe", stderr: "pipe" });
|
|
363
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
364
|
+
new Response(proc.stdout).text(),
|
|
365
|
+
new Response(proc.stderr).text(),
|
|
366
|
+
proc.exited
|
|
367
|
+
]);
|
|
368
|
+
if (exitCode !== 0)
|
|
369
|
+
throw new Error(`git ls-files failed in ${root}: ${stderr.trim()}`);
|
|
370
|
+
return [...new Set(stdout.split("\x00").filter(Boolean))].sort();
|
|
371
|
+
}
|
|
372
|
+
static async#listFiles(root) {
|
|
373
|
+
const listed = await TestSnapshot.#gitList(root, ["--cached", "--others", "--exclude-standard"]);
|
|
374
|
+
const present = await Promise.all(listed.map(async (file) => (await stat(path4.join(root, file)).catch(() => null))?.isFile() ? file : null));
|
|
375
|
+
return present.filter((file) => file !== null);
|
|
376
|
+
}
|
|
377
|
+
static async#hashTree(root) {
|
|
378
|
+
const files = await TestSnapshot.#listFiles(root);
|
|
379
|
+
const hashes = new Map;
|
|
380
|
+
for (let start = 0;start < files.length; start += 64) {
|
|
381
|
+
const batch = files.slice(start, start + 64);
|
|
382
|
+
const batchHashes = await Promise.all(batch.map(async (file) => Bun.hash(await Bun.file(path4.join(root, file)).bytes()).toString(16)));
|
|
383
|
+
for (const [idx, file] of batch.entries())
|
|
384
|
+
hashes.set(file, batchHashes[idx] ?? "");
|
|
385
|
+
}
|
|
386
|
+
return hashes;
|
|
387
|
+
}
|
|
388
|
+
static #digestOf(hashes) {
|
|
389
|
+
return Bun.hash([...hashes].map(([file, hash]) => `${file}:${hash}`).join(`
|
|
390
|
+
`)).toString(16).padStart(16, "0").slice(0, 12);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// pkgs/@akanjs/devkit/platformTest/WindowsSshTarget.ts
|
|
395
|
+
import { existsSync } from "fs";
|
|
396
|
+
import os2 from "os";
|
|
397
|
+
import path5 from "path";
|
|
398
|
+
class WindowsSshTarget extends PlatformTestTarget {
|
|
399
|
+
static utmctlPath = "/Applications/UTM.app/Contents/MacOS/utmctl";
|
|
400
|
+
static defaultWorkRoot = "C:\\akan-test";
|
|
401
|
+
static defaultTolerateScriptFailures = ["sharp", "puppeteer"];
|
|
402
|
+
static #runDirPattern = "^\\d{8}-\\d{6}$";
|
|
403
|
+
static #keptRuns = 2;
|
|
404
|
+
platform = "windows";
|
|
405
|
+
policy;
|
|
406
|
+
tolerateScriptFailures;
|
|
407
|
+
#config;
|
|
408
|
+
#host;
|
|
409
|
+
#runDir = null;
|
|
410
|
+
constructor(config) {
|
|
411
|
+
super();
|
|
412
|
+
this.#config = config;
|
|
413
|
+
this.#host = process.env.AKAN_TEST_WINDOWS_HOST ?? config.host;
|
|
414
|
+
this.policy = config.policy ?? "warn";
|
|
415
|
+
this.tolerateScriptFailures = config.tolerateScriptFailures ?? WindowsSshTarget.defaultTolerateScriptFailures;
|
|
416
|
+
}
|
|
417
|
+
get #workRoot() {
|
|
418
|
+
return this.#config.workRoot ?? WindowsSshTarget.defaultWorkRoot;
|
|
419
|
+
}
|
|
420
|
+
async prepare(context) {
|
|
421
|
+
await this.#connect();
|
|
422
|
+
const bunVersion = (await this.#ssh("bun --version")).stdout.trim();
|
|
423
|
+
if (bunVersion !== Bun.version)
|
|
424
|
+
throw new Error(`the Windows host runs Bun ${bunVersion || "(none)"}, this host ${Bun.version} \u2014 align them first`);
|
|
425
|
+
if ((await this.#ssh("git --version")).exitCode !== 0)
|
|
426
|
+
throw new Error("git is not installed on the Windows host");
|
|
427
|
+
const runDir = `${this.#workRoot}\\${context.runId}`;
|
|
428
|
+
const mkdir = await this.#ssh(`New-Item -ItemType Directory -Force -Path '${runDir}\\w' | Out-Null`);
|
|
429
|
+
if (mkdir.exitCode !== 0)
|
|
430
|
+
throw new Error(`could not create ${runDir}: ${mkdir.stderr.trim()}`);
|
|
431
|
+
this.#runDir = runDir;
|
|
432
|
+
for (const [local, remoteName] of [
|
|
433
|
+
[context.snapshot.tarPath, "src.tar"],
|
|
434
|
+
[context.envPath, "env"]
|
|
435
|
+
]) {
|
|
436
|
+
const copied = await WindowsSshTarget.#run([
|
|
437
|
+
...this.#scpBase(),
|
|
438
|
+
local,
|
|
439
|
+
this.#remoteScpPath(`${runDir}\\${remoteName}`)
|
|
440
|
+
]);
|
|
441
|
+
if (copied.exitCode !== 0)
|
|
442
|
+
throw new Error(`scp ${remoteName} failed: ${copied.stderr.trim()}`);
|
|
443
|
+
}
|
|
444
|
+
const extract = await this.exec("tar -xf ..\\src.tar && copy /Y ..\\env .env >nul && git init -q", path5.join(context.logDir, this.platform, "extract.log"), PlatformTestTarget.installIdleMs);
|
|
445
|
+
if (extract.exitCode !== 0)
|
|
446
|
+
throw new Error("extracting the snapshot on the Windows host failed");
|
|
447
|
+
}
|
|
448
|
+
async exec(command, logPath, idleMs) {
|
|
449
|
+
const runDir = this.#runDir;
|
|
450
|
+
if (!runDir)
|
|
451
|
+
throw new Error("the Windows run directory was not prepared");
|
|
452
|
+
let remotePid = null;
|
|
453
|
+
const script = [
|
|
454
|
+
"$ProgressPreference = 'SilentlyContinue'",
|
|
455
|
+
"[Console]::OutputEncoding = [Text.Encoding]::UTF8",
|
|
456
|
+
"'PID=' + $PID",
|
|
457
|
+
"$env:GIT_AUTHOR_NAME = 'akan-test'",
|
|
458
|
+
"$env:GIT_AUTHOR_EMAIL = 'akan-test@localhost'",
|
|
459
|
+
"$env:GIT_COMMITTER_NAME = 'akan-test'",
|
|
460
|
+
"$env:GIT_COMMITTER_EMAIL = 'akan-test@localhost'",
|
|
461
|
+
"$env:GIT_CONFIG_COUNT = '1'",
|
|
462
|
+
"$env:GIT_CONFIG_KEY_0 = 'init.defaultBranch'",
|
|
463
|
+
"$env:GIT_CONFIG_VALUE_0 = 'main'",
|
|
464
|
+
`Set-Location -LiteralPath '${runDir}\\w'`,
|
|
465
|
+
`cmd /c "${command} 2>&1"`,
|
|
466
|
+
"exit $LASTEXITCODE"
|
|
467
|
+
].join("; ");
|
|
468
|
+
return await new IdleWatchedProcess([...this.#sshBase(), script], {
|
|
469
|
+
logPath,
|
|
470
|
+
idleMs,
|
|
471
|
+
onOutput: (text) => {
|
|
472
|
+
remotePid ??= /PID=(\d+)/.exec(text)?.[1] ?? null;
|
|
473
|
+
},
|
|
474
|
+
onIdle: async () => {
|
|
475
|
+
if (remotePid)
|
|
476
|
+
await this.#ssh(`taskkill /F /T /PID ${remotePid} | Out-Null`);
|
|
477
|
+
}
|
|
478
|
+
}).run();
|
|
479
|
+
}
|
|
480
|
+
async cleanup() {
|
|
481
|
+
this.#runDir = null;
|
|
482
|
+
await this.#ssh([
|
|
483
|
+
`Get-ChildItem -LiteralPath '${this.#workRoot}' -Directory`,
|
|
484
|
+
`Where-Object { $_.Name -match '${WindowsSshTarget.#runDirPattern}' }`,
|
|
485
|
+
"Sort-Object Name -Descending",
|
|
486
|
+
`Select-Object -Skip ${WindowsSshTarget.#keptRuns}`,
|
|
487
|
+
"ForEach-Object { cmd /c rmdir /s /q $_.FullName }"
|
|
488
|
+
].join(" | "));
|
|
489
|
+
}
|
|
490
|
+
async#connect() {
|
|
491
|
+
if ((await this.#ssh("exit 0")).exitCode === 0)
|
|
492
|
+
return;
|
|
493
|
+
const vm = this.#config.utmVm;
|
|
494
|
+
if (!vm || !existsSync(WindowsSshTarget.utmctlPath))
|
|
495
|
+
throw new Error(`cannot reach ${this.#config.user}@${this.#host} over ssh`);
|
|
496
|
+
const status = (await WindowsSshTarget.#run([WindowsSshTarget.utmctlPath, "status", vm])).stdout.trim();
|
|
497
|
+
if (status !== "started")
|
|
498
|
+
await WindowsSshTarget.#run([WindowsSshTarget.utmctlPath, "start", vm]);
|
|
499
|
+
for (let attempt = 0;attempt < 24; attempt++) {
|
|
500
|
+
const addresses = (await WindowsSshTarget.#run([WindowsSshTarget.utmctlPath, "ip-address", vm])).stdout;
|
|
501
|
+
const ipv4 = addresses.split(/\s+/).find((address) => /^\d+\.\d+\.\d+\.\d+$/.test(address));
|
|
502
|
+
if (ipv4)
|
|
503
|
+
this.#host = ipv4;
|
|
504
|
+
if ((await this.#ssh("exit 0")).exitCode === 0)
|
|
505
|
+
return;
|
|
506
|
+
await Bun.sleep(5000);
|
|
507
|
+
}
|
|
508
|
+
throw new Error(`UTM VM "${vm}" did not answer ssh at ${this.#host}`);
|
|
509
|
+
}
|
|
510
|
+
#sshOptions() {
|
|
511
|
+
const expand = (file) => file.startsWith("~/") ? path5.join(os2.homedir(), file.slice(2)) : file;
|
|
512
|
+
return [
|
|
513
|
+
"-i",
|
|
514
|
+
expand(this.#config.identityFile),
|
|
515
|
+
...this.#config.knownHostsFile ? ["-o", `UserKnownHostsFile=${expand(this.#config.knownHostsFile)}`] : [],
|
|
516
|
+
"-o",
|
|
517
|
+
"BatchMode=yes",
|
|
518
|
+
"-o",
|
|
519
|
+
"ConnectTimeout=8",
|
|
520
|
+
"-o",
|
|
521
|
+
"ServerAliveInterval=30"
|
|
522
|
+
];
|
|
523
|
+
}
|
|
524
|
+
#sshBase() {
|
|
525
|
+
return ["ssh", ...this.#sshOptions(), `${this.#config.user}@${this.#host}`];
|
|
526
|
+
}
|
|
527
|
+
#scpBase() {
|
|
528
|
+
return ["scp", "-q", ...this.#sshOptions()];
|
|
529
|
+
}
|
|
530
|
+
#remoteScpPath(windowsPath) {
|
|
531
|
+
return `${this.#config.user}@${this.#host}:/${windowsPath.replaceAll("\\", "/")}`;
|
|
532
|
+
}
|
|
533
|
+
async#ssh(script) {
|
|
534
|
+
return await WindowsSshTarget.#run([...this.#sshBase(), script]);
|
|
535
|
+
}
|
|
536
|
+
static async#run(command) {
|
|
537
|
+
const proc = Bun.spawn(command, { stdin: "ignore", stdout: "pipe", stderr: "pipe" });
|
|
538
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
539
|
+
new Response(proc.stdout).text(),
|
|
540
|
+
new Response(proc.stderr).text(),
|
|
541
|
+
proc.exited
|
|
542
|
+
]);
|
|
543
|
+
return { stdout, stderr, exitCode };
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// pkgs/@akanjs/devkit/platformTest/PlatformTestRun.ts
|
|
548
|
+
class PlatformTestRun {
|
|
549
|
+
static envKeys = [
|
|
550
|
+
"USE_AKANJS_PKGS",
|
|
551
|
+
"AKAN_PUBLIC_REPO_NAME",
|
|
552
|
+
"AKAN_PUBLIC_SERVE_DOMAIN",
|
|
553
|
+
"AKAN_PUBLIC_ENV",
|
|
554
|
+
"AKAN_PUBLIC_OPERATION_MODE",
|
|
555
|
+
"APP_OPERATION_MODE",
|
|
556
|
+
"SERVER_MODE"
|
|
557
|
+
];
|
|
558
|
+
static promoteAfterGreenRuns = 3;
|
|
559
|
+
runId;
|
|
560
|
+
logDir;
|
|
561
|
+
snapshot;
|
|
562
|
+
#results;
|
|
563
|
+
constructor(runId, logDir, snapshot, results) {
|
|
564
|
+
this.runId = runId;
|
|
565
|
+
this.logDir = logDir;
|
|
566
|
+
this.snapshot = snapshot;
|
|
567
|
+
this.#results = results;
|
|
568
|
+
}
|
|
569
|
+
static async start({ workspaceRoot, platforms, pkgs, targets, onProgress }) {
|
|
570
|
+
const runId = PlatformTestRun.newRunId();
|
|
571
|
+
const logDir = path6.join(workspaceRoot, "local", "deploy-test", runId);
|
|
572
|
+
const snapshot = await TestSnapshot.create(workspaceRoot, logDir);
|
|
573
|
+
const envPath = path6.join(logDir, "env");
|
|
574
|
+
await Bun.write(envPath, await PlatformTestRun.allowlistedEnv(workspaceRoot));
|
|
575
|
+
const instances = platforms.map((platform) => {
|
|
576
|
+
if (platform === "linux")
|
|
577
|
+
return new LinuxDockerTarget(targets.linux);
|
|
578
|
+
if (!targets.windows)
|
|
579
|
+
throw new Error("the windows test target is not configured");
|
|
580
|
+
return new WindowsSshTarget(targets.windows);
|
|
581
|
+
});
|
|
582
|
+
const results = Promise.all(instances.map((target) => target.run({ snapshot, envPath, runId, logDir, pkgs, onProgress })));
|
|
583
|
+
return new PlatformTestRun(runId, logDir, snapshot, results);
|
|
584
|
+
}
|
|
585
|
+
async results() {
|
|
586
|
+
return await this.#results;
|
|
587
|
+
}
|
|
588
|
+
static parsePlatforms(value) {
|
|
589
|
+
const names = value.split(",").map((name) => name.trim().toLowerCase()).filter((name) => name && name !== "none");
|
|
590
|
+
const unknown = names.filter((name) => name !== "linux" && name !== "windows");
|
|
591
|
+
if (unknown.length)
|
|
592
|
+
throw new Error(`Unknown test platform: ${unknown.join(", ")} (expected linux, windows, or none)`);
|
|
593
|
+
return [...new Set(names)];
|
|
594
|
+
}
|
|
595
|
+
static newRunId(now = new Date) {
|
|
596
|
+
const pad = (value) => String(value).padStart(2, "0");
|
|
597
|
+
return `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
|
|
598
|
+
}
|
|
599
|
+
static async allowlistedEnv(workspaceRoot) {
|
|
600
|
+
const envFile = Bun.file(path6.join(workspaceRoot, ".env"));
|
|
601
|
+
const lines = await envFile.exists() ? (await envFile.text()).split(/\r?\n/) : [];
|
|
602
|
+
const allowed = new Set(PlatformTestRun.envKeys);
|
|
603
|
+
const kept = lines.filter((line) => allowed.has(line.split("=", 1)[0]?.trim() ?? ""));
|
|
604
|
+
return `${kept.join(`
|
|
605
|
+
`)}
|
|
606
|
+
`;
|
|
607
|
+
}
|
|
608
|
+
static failed(result) {
|
|
609
|
+
return !!result.infraError || result.packages.some((pkg) => pkg.status !== "pass");
|
|
610
|
+
}
|
|
611
|
+
static verdict(results) {
|
|
612
|
+
const failed = results.filter((result) => PlatformTestRun.failed(result));
|
|
613
|
+
return {
|
|
614
|
+
blocking: failed.filter((result) => result.policy === "gate"),
|
|
615
|
+
warning: failed.filter((result) => result.policy === "warn")
|
|
616
|
+
};
|
|
617
|
+
}
|
|
618
|
+
format(results, { maxFailures = 15 } = {}) {
|
|
619
|
+
const lines = [
|
|
620
|
+
chalk.bold(`Platform tests \xB7 snapshot ${this.snapshot.digest} (${this.snapshot.fileCount} files) \xB7 ${path6.relative(this.snapshot.root, this.logDir)}`)
|
|
621
|
+
];
|
|
622
|
+
for (const result of results) {
|
|
623
|
+
const mark = PlatformTestRun.failed(result) ? chalk.red("\u2716") : chalk.green("\u2714");
|
|
624
|
+
lines.push(`${mark} ${result.platform} (${result.policy}, ${Math.round(result.ms / 1000)}s)`);
|
|
625
|
+
if (result.infraError)
|
|
626
|
+
lines.push(chalk.red(` setup: ${result.infraError}`));
|
|
627
|
+
for (const warning of result.envWarnings)
|
|
628
|
+
lines.push(chalk.yellow(` env: ${warning}`));
|
|
629
|
+
for (const pkg of result.packages) {
|
|
630
|
+
const line = ` ${PlatformTestTarget.describe(pkg)}`;
|
|
631
|
+
lines.push(pkg.status === "pass" ? line : chalk.red(line));
|
|
632
|
+
for (const failure of pkg.report.failures.slice(0, maxFailures))
|
|
633
|
+
lines.push(chalk.dim(` ${failure.file ? `${failure.file} \u203A ` : ""}${failure.name}`));
|
|
634
|
+
if (pkg.report.failures.length > maxFailures)
|
|
635
|
+
lines.push(chalk.dim(` \u2026 ${pkg.report.failures.length - maxFailures} more in ${pkg.logPath}`));
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
return lines.join(`
|
|
639
|
+
`);
|
|
640
|
+
}
|
|
641
|
+
static async recordStreaks(results) {
|
|
642
|
+
const targets = await GlobalConfig.getTestTargets();
|
|
643
|
+
const windows = targets.windows;
|
|
644
|
+
const result = results.find((one) => one.platform === "windows");
|
|
645
|
+
if (!windows || !result || (windows.policy ?? "warn") !== "warn")
|
|
646
|
+
return null;
|
|
647
|
+
const greenStreak = PlatformTestRun.failed(result) ? 0 : (windows.greenStreak ?? 0) + 1;
|
|
648
|
+
const promoted = greenStreak >= PlatformTestRun.promoteAfterGreenRuns;
|
|
649
|
+
await GlobalConfig.setTestTargets({
|
|
650
|
+
windows: { ...windows, greenStreak, ...promoted ? { policy: "gate" } : {} }
|
|
651
|
+
});
|
|
652
|
+
return { platform: "windows", greenStreak, promoted };
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// pkgs/@akanjs/cli/cloud/cloud.runner.ts
|
|
657
|
+
import path7 from "path";
|
|
658
|
+
import { confirm, input, select } from "@inquirer/prompts";
|
|
659
|
+
import { Logger, sleep } from "akanjs/common";
|
|
660
|
+
import chalk2 from "chalk";
|
|
661
|
+
import * as QRcode from "qrcode";
|
|
662
|
+
|
|
663
|
+
// pkgs/@akanjs/cli/npmRegistry.ts
|
|
664
|
+
var defaultNpmRegistry = "https://registry.npmjs.org";
|
|
665
|
+
var getNpmRegistryUrl = (registryUrl = process.env.AKAN_NPM_REGISTRY ?? defaultNpmRegistry) => registryUrl.replace(/\/+$/, "");
|
|
666
|
+
async function getLatestPackageVersion(packageName, tag = "latest", registryUrl) {
|
|
667
|
+
const registry = getNpmRegistryUrl(registryUrl);
|
|
668
|
+
const url = `${registry}/${encodeURIComponent(packageName).replace(/^%40/, "@")}`;
|
|
669
|
+
const res = await fetch(url);
|
|
670
|
+
if (!res.ok)
|
|
671
|
+
throw new Error(`Failed to fetch ${packageName} metadata from ${registry}`);
|
|
672
|
+
const metadata = await res.json();
|
|
673
|
+
const version = metadata["dist-tags"]?.[tag];
|
|
674
|
+
if (!version)
|
|
675
|
+
throw new Error(`No npm dist-tag "${tag}" found for ${packageName}`);
|
|
676
|
+
return version;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// pkgs/@akanjs/cli/cloud/cloud.runner.ts
|
|
680
|
+
var addRemoteEnvServerValue = "__addRemoteEnvServer";
|
|
681
|
+
var removeRemoteEnvServerValue = "__removeRemoteEnvServer";
|
|
682
|
+
|
|
683
|
+
class CloudRunner extends runner("cloud") {
|
|
684
|
+
#akanFrameworkPackages = new Set(["akanjs", "@akanjs/devkit", "@akanjs/cli", "create-akan-workspace"]);
|
|
685
|
+
#getRegistryArgs(registryUrl) {
|
|
686
|
+
return registryUrl ? ["--registry", getNpmRegistryUrl(registryUrl)] : [];
|
|
687
|
+
}
|
|
688
|
+
#getLocalRegistryAuthArgs(registryUrl) {
|
|
689
|
+
if (!registryUrl)
|
|
690
|
+
return [];
|
|
691
|
+
const { host, pathname } = new URL(getNpmRegistryUrl(registryUrl));
|
|
692
|
+
const registryPath = pathname === "/" ? "/" : `${pathname.replace(/\/+$/, "")}/`;
|
|
693
|
+
return [`--//${host}${registryPath}:_authToken=akan-local-registry`];
|
|
694
|
+
}
|
|
695
|
+
#getRegistryEnv(registryUrl) {
|
|
696
|
+
return registryUrl ? {
|
|
697
|
+
...process.env,
|
|
698
|
+
AKAN_NPM_REGISTRY: getNpmRegistryUrl(registryUrl),
|
|
699
|
+
NPM_CONFIG_REGISTRY: getNpmRegistryUrl(registryUrl)
|
|
700
|
+
} : process.env;
|
|
701
|
+
}
|
|
702
|
+
async#addRemoteEnvServer() {
|
|
703
|
+
const name = (await input({
|
|
704
|
+
message: "Remote server name: ",
|
|
705
|
+
validate: (value) => value.trim() ? true : "Remote server name is required"
|
|
706
|
+
})).trim();
|
|
707
|
+
const host = (await input({
|
|
708
|
+
message: "Remote server host: ",
|
|
709
|
+
validate: (value) => value.trim() ? true : "Remote server host is required"
|
|
710
|
+
})).trim();
|
|
711
|
+
const username = (await input({ message: "Remote server username (optional): " })).trim() || undefined;
|
|
712
|
+
const portInput = (await input({
|
|
713
|
+
message: "Remote server SSH port (optional): ",
|
|
714
|
+
validate: (value) => {
|
|
715
|
+
const trimmed = value.trim();
|
|
716
|
+
if (!trimmed)
|
|
717
|
+
return true;
|
|
718
|
+
const port = Number(trimmed);
|
|
719
|
+
return Number.isInteger(port) && port > 0 ? true : "SSH port must be a positive integer";
|
|
720
|
+
}
|
|
721
|
+
})).trim();
|
|
722
|
+
const config = {
|
|
723
|
+
host,
|
|
724
|
+
...username ? { username } : {},
|
|
725
|
+
...portInput ? { port: Number(portInput) } : {}
|
|
726
|
+
};
|
|
727
|
+
await GlobalConfig.setRemoteEnvServer(name, config);
|
|
728
|
+
return { name, config };
|
|
729
|
+
}
|
|
730
|
+
async#selectRemoteEnvServer() {
|
|
731
|
+
const servers = await GlobalConfig.getRemoteEnvServers();
|
|
732
|
+
const serverEntries = Object.entries(servers).sort(([nameA], [nameB]) => nameA.localeCompare(nameB));
|
|
733
|
+
if (serverEntries.length === 0) {
|
|
734
|
+
Logger.info("No remote env servers configured. Add the first remote server for SCP mode.");
|
|
735
|
+
return await this.#addRemoteEnvServer();
|
|
736
|
+
}
|
|
737
|
+
const selectedName = await select({
|
|
738
|
+
message: "Select the remote env server",
|
|
739
|
+
choices: [
|
|
740
|
+
...serverEntries.map(([name, config]) => ({
|
|
741
|
+
name: `${name} (${config.username ? `${config.username}@` : ""}${config.host}${config.port ? `:${config.port}` : ""})`,
|
|
742
|
+
value: name
|
|
743
|
+
})),
|
|
744
|
+
{ name: "Add new remote server", value: addRemoteEnvServerValue },
|
|
745
|
+
{ name: "Remove remote server", value: removeRemoteEnvServerValue }
|
|
746
|
+
]
|
|
747
|
+
});
|
|
748
|
+
if (selectedName === addRemoteEnvServerValue)
|
|
749
|
+
return await this.#addRemoteEnvServer();
|
|
750
|
+
if (selectedName === removeRemoteEnvServerValue) {
|
|
751
|
+
await this.#removeRemoteEnvServer(serverEntries);
|
|
752
|
+
return await this.#selectRemoteEnvServer();
|
|
753
|
+
}
|
|
754
|
+
const config = servers[selectedName];
|
|
755
|
+
if (!config)
|
|
756
|
+
throw new Error(`Remote env server is not found: ${selectedName}`);
|
|
757
|
+
return { name: selectedName, config };
|
|
758
|
+
}
|
|
759
|
+
async#removeRemoteEnvServer(serverEntries) {
|
|
760
|
+
const selectedName = await select({
|
|
761
|
+
message: "Select the remote env server to remove",
|
|
762
|
+
choices: serverEntries.map(([name, config]) => ({
|
|
763
|
+
name: `${name} (${config.username ? `${config.username}@` : ""}${config.host}${config.port ? `:${config.port}` : ""})`,
|
|
764
|
+
value: name
|
|
765
|
+
}))
|
|
766
|
+
});
|
|
767
|
+
const shouldRemove = await confirm({
|
|
768
|
+
message: `Remove remote env server "${selectedName}"?`,
|
|
769
|
+
default: false
|
|
770
|
+
});
|
|
771
|
+
if (!shouldRemove)
|
|
772
|
+
return;
|
|
773
|
+
await GlobalConfig.removeRemoteEnvServer(selectedName);
|
|
774
|
+
Logger.info(`Removed remote env server "${selectedName}"`);
|
|
775
|
+
}
|
|
776
|
+
async#getRemoteEnvServerWithUsername() {
|
|
777
|
+
const remoteServer = await this.#selectRemoteEnvServer();
|
|
778
|
+
if (remoteServer.config.username)
|
|
779
|
+
return remoteServer;
|
|
780
|
+
const username = (await input({
|
|
781
|
+
message: `SSH username for ${remoteServer.config.host} (optional): `
|
|
782
|
+
})).trim();
|
|
783
|
+
return {
|
|
784
|
+
...remoteServer,
|
|
785
|
+
config: {
|
|
786
|
+
...remoteServer.config,
|
|
787
|
+
...username ? { username } : {}
|
|
788
|
+
}
|
|
789
|
+
};
|
|
790
|
+
}
|
|
791
|
+
#getRemoteEnvArchivePath() {
|
|
792
|
+
return `${this.#getRemoteEnvArchiveDir()}/env.tar`;
|
|
793
|
+
}
|
|
794
|
+
#getRemoteEnvArchiveDir() {
|
|
795
|
+
const { repoName } = WorkspaceExecutor.getBaseDevEnv();
|
|
796
|
+
return `~/secrets/${repoName}`;
|
|
797
|
+
}
|
|
798
|
+
#getScpTarget(config, remotePath) {
|
|
799
|
+
return `${config.username ? `${config.username}@` : ""}${config.host}:${remotePath}`;
|
|
800
|
+
}
|
|
801
|
+
#getSshTarget(config) {
|
|
802
|
+
return `${config.username ? `${config.username}@` : ""}${config.host}`;
|
|
803
|
+
}
|
|
804
|
+
#getScpArgs(config, source, target) {
|
|
805
|
+
return [...config.port ? ["-P", config.port.toString()] : [], source, target];
|
|
806
|
+
}
|
|
807
|
+
#getSshArgs(config, command) {
|
|
808
|
+
return [...config.port ? ["-p", config.port.toString()] : [], this.#getSshTarget(config), command];
|
|
809
|
+
}
|
|
810
|
+
async login(host, workspace) {
|
|
811
|
+
const config = await GlobalConfig.getHostConfig(host);
|
|
812
|
+
const cloudApi = new CloudApi(workspace, config);
|
|
813
|
+
const self = config.auth ? await cloudApi.getRemoteSelf() : null;
|
|
814
|
+
if (self) {
|
|
815
|
+
Logger.rawLog(chalk2.green(`
|
|
816
|
+
\u2713 Already logged in akan cloud as ${self.nickname}
|
|
817
|
+
`));
|
|
818
|
+
return true;
|
|
819
|
+
}
|
|
820
|
+
const remoteId = crypto.randomUUID();
|
|
821
|
+
const signinUrl = `${cloudApi.host}/remoteAuth?remoteId=${encodeURIComponent(remoteId)}`;
|
|
822
|
+
Logger.rawLog(chalk2.bold(`
|
|
823
|
+
${chalk2.green("\u27A4")} Authentication Required`));
|
|
824
|
+
Logger.rawLog(chalk2.dim("Please visit or click the following URL:"));
|
|
825
|
+
Logger.rawLog(`${chalk2.cyan.underline(signinUrl)}
|
|
826
|
+
`);
|
|
827
|
+
try {
|
|
828
|
+
const qrcode = await new Promise((resolve, reject) => {
|
|
829
|
+
QRcode.toString(signinUrl, { type: "terminal", small: true }, (err, data) => {
|
|
830
|
+
if (err)
|
|
831
|
+
reject(err);
|
|
832
|
+
resolve(data);
|
|
833
|
+
});
|
|
834
|
+
});
|
|
835
|
+
Logger.rawLog(qrcode);
|
|
836
|
+
await openBrowser(signinUrl);
|
|
837
|
+
Logger.rawLog(chalk2.dim("Opening browser..."));
|
|
838
|
+
} catch {
|
|
839
|
+
Logger.rawLog(chalk2.yellow("Could not open browser. Please visit the URL manually."));
|
|
840
|
+
}
|
|
841
|
+
Logger.rawLog(chalk2.dim("Waiting for authentication..."));
|
|
842
|
+
const MAX_RETRY = 300;
|
|
843
|
+
for (let i = 0;i < MAX_RETRY; i++) {
|
|
844
|
+
const accessToken = await cloudApi.getRemoteAuthToken(remoteId);
|
|
845
|
+
const self = await cloudApi.getRemoteSelf();
|
|
846
|
+
if (accessToken && self) {
|
|
847
|
+
await GlobalConfig.setHostConfig({ host: config.host, auth: { accessToken, self } });
|
|
848
|
+
Logger.rawLog(chalk2.green(`\r\u2713 Authentication successful!`));
|
|
849
|
+
Logger.rawLog(chalk2.green.bold(`
|
|
850
|
+
\u2728 Welcome aboard, ${self.nickname ?? "anonymous"}!`));
|
|
851
|
+
Logger.rawLog(chalk2.dim(`You're now ready to use Akan CLI!
|
|
852
|
+
`));
|
|
853
|
+
return true;
|
|
854
|
+
}
|
|
855
|
+
await sleep(2000);
|
|
856
|
+
}
|
|
857
|
+
throw new Error(chalk2.red("\u2716 Authentication timed out after 10 minutes. Please try again."));
|
|
858
|
+
}
|
|
859
|
+
async logout(host) {
|
|
860
|
+
const config = await GlobalConfig.getHostConfig(host);
|
|
861
|
+
if (config.auth?.self) {
|
|
862
|
+
await GlobalConfig.setHostConfig(getDefaultHostConfig(config.host));
|
|
863
|
+
Logger.rawLog(chalk2.magenta.bold(`
|
|
864
|
+
\uD83D\uDC4B Goodbye, ${config.auth.self.nickname ?? "anonymous"}!`));
|
|
865
|
+
Logger.rawLog(chalk2.dim(`\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
866
|
+
`));
|
|
867
|
+
Logger.rawLog(chalk2.cyan("You have been successfully logged out."));
|
|
868
|
+
Logger.rawLog(chalk2.dim(`Thank you for using Akan CLI. Come back soon! \uD83C\uDF1F
|
|
869
|
+
`));
|
|
870
|
+
} else {
|
|
871
|
+
Logger.rawLog(chalk2.yellow.bold(`
|
|
872
|
+
\u26A0\uFE0F No active session found`));
|
|
873
|
+
Logger.rawLog(chalk2.dim(`You were not logged in to begin with
|
|
874
|
+
`));
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
async startPlatformTests(workspace, platforms, pkgs) {
|
|
878
|
+
const targets = await GlobalConfig.getTestTargets();
|
|
879
|
+
if (platforms.includes("windows") && !targets.windows) {
|
|
880
|
+
targets.windows = await this.#askWindowsTestTarget();
|
|
881
|
+
await GlobalConfig.setTestTargets({ windows: targets.windows });
|
|
882
|
+
}
|
|
883
|
+
Logger.info(`Testing ${pkgs.join(", ")} on ${platforms.join(", ")} in the background...`);
|
|
884
|
+
return await PlatformTestRun.start({
|
|
885
|
+
workspaceRoot: workspace.workspaceRoot,
|
|
886
|
+
platforms,
|
|
887
|
+
pkgs,
|
|
888
|
+
targets,
|
|
889
|
+
onProgress: (message) => Logger.info(message)
|
|
890
|
+
});
|
|
891
|
+
}
|
|
892
|
+
async settlePlatformTests(run, { interactive, recordStreaks, checkDrift }) {
|
|
893
|
+
const results = await run.results();
|
|
894
|
+
Logger.rawLog(`
|
|
895
|
+
${run.format(results)}
|
|
896
|
+
`);
|
|
897
|
+
if (recordStreaks) {
|
|
898
|
+
const streak = await PlatformTestRun.recordStreaks(results);
|
|
899
|
+
if (streak?.promoted)
|
|
900
|
+
Logger.info(`${streak.platform} passed ${streak.greenStreak} deploys in a row and now gates every deploy`);
|
|
901
|
+
}
|
|
902
|
+
const { blocking, warning } = PlatformTestRun.verdict(results);
|
|
903
|
+
const unreachable = blocking.filter((result) => result.infraError && !result.packages.length);
|
|
904
|
+
const failing = blocking.filter((result) => !unreachable.includes(result));
|
|
905
|
+
if (failing.length)
|
|
906
|
+
throw new Error(`Platform tests failed on ${failing.map((result) => result.platform).join(", ")} \u2014 ${run.logDir}`);
|
|
907
|
+
for (const result of [...unreachable, ...warning]) {
|
|
908
|
+
const reason = result.infraError ?? "failing tests";
|
|
909
|
+
if (!interactive)
|
|
910
|
+
continue;
|
|
911
|
+
const proceed = await confirm({
|
|
912
|
+
message: `${result.platform} did not pass (${reason}). Continue without it?`,
|
|
913
|
+
default: false
|
|
914
|
+
});
|
|
915
|
+
if (!proceed)
|
|
916
|
+
throw new Error(`Stopped after ${result.platform} platform tests \u2014 ${run.logDir}`);
|
|
917
|
+
}
|
|
918
|
+
if (!interactive && unreachable.length)
|
|
919
|
+
throw new Error(`Could not run ${unreachable.map((result) => result.platform).join(", ")} \u2014 ${run.logDir}`);
|
|
920
|
+
if (!checkDrift)
|
|
921
|
+
return results;
|
|
922
|
+
const drift = await run.snapshot.drift();
|
|
923
|
+
const drifted = [...drift.changed, ...drift.added, ...drift.removed];
|
|
924
|
+
if (drifted.length)
|
|
925
|
+
throw new Error(`The tree changed while it was being tested, so the results do not describe what would be published: ${drifted.slice(0, 20).join(", ")}${drifted.length > 20 ? ", \u2026" : ""}`);
|
|
926
|
+
return results;
|
|
927
|
+
}
|
|
928
|
+
async#askWindowsTestTarget() {
|
|
929
|
+
Logger.info("No Windows test target is configured yet; it is saved to ~/.akan/config.json once entered.");
|
|
930
|
+
const host = (await input({ message: "Windows host (ip or name): ", validate: (value) => !!value.trim() })).trim();
|
|
931
|
+
const user = (await input({ message: "SSH user: ", validate: (value) => !!value.trim() })).trim();
|
|
932
|
+
const identityFile = (await input({ message: "SSH private key path: ", validate: (value) => !!value.trim() })).trim();
|
|
933
|
+
const knownHostsFile = (await input({ message: "known_hosts file (optional): " })).trim();
|
|
934
|
+
const utmVm = (await input({ message: "UTM VM name, to start it and find its ip (optional): " })).trim();
|
|
935
|
+
return {
|
|
936
|
+
host,
|
|
937
|
+
user,
|
|
938
|
+
identityFile,
|
|
939
|
+
...knownHostsFile ? { knownHostsFile } : {},
|
|
940
|
+
...utmVm ? { utmVm } : {}
|
|
941
|
+
};
|
|
942
|
+
}
|
|
943
|
+
async getAkanPkgs(workspace) {
|
|
944
|
+
const pkgs = await workspace.getPkgs();
|
|
945
|
+
return pkgs.filter((pkg) => pkg === "akanjs" || pkg === "create-akan-workspace" || pkg.startsWith("@akanjs/"));
|
|
946
|
+
}
|
|
947
|
+
async deployAkan(workspace, akanPkgs, { registryUrl, confirmPublish = true, tag: distTag } = {}) {
|
|
948
|
+
const registry = registryUrl ? getNpmRegistryUrl(registryUrl) : undefined;
|
|
949
|
+
const akanPackageJson = await workspace.readJson("pkgs/akanjs/package.json");
|
|
950
|
+
const [majorVersion, minorVersion, patchVersion, devPatchVersion] = akanPackageJson.version.split(".");
|
|
951
|
+
const isOfficialRelease = !devPatchVersion;
|
|
952
|
+
const targetVersionPrefix = isOfficialRelease ? `${majorVersion}.${minorVersion}` : `${majorVersion}.${minorVersion}.${patchVersion}`;
|
|
953
|
+
const tag = distTag ?? (isOfficialRelease ? "latest" : patchVersion.split("-").at(1) ?? "dev");
|
|
954
|
+
const getNextVersion = async (prefix, tag) => {
|
|
955
|
+
try {
|
|
956
|
+
const latestPublishedVersion = await getLatestPackageVersion("akanjs", tag, registry);
|
|
957
|
+
const latestPatch = latestPublishedVersion.startsWith(prefix) ? parseInt(latestPublishedVersion.split(".").at(-1) ?? "-1") : -1;
|
|
958
|
+
const nextVersion = `${prefix}.${latestPatch + 1}`;
|
|
959
|
+
return { nextVersion, latestPublishedVersion };
|
|
960
|
+
} catch {
|
|
961
|
+
return { nextVersion: `${prefix}.0`, latestPublishedVersion: null };
|
|
962
|
+
}
|
|
963
|
+
};
|
|
964
|
+
const { nextVersion, latestPublishedVersion } = await getNextVersion(targetVersionPrefix, tag);
|
|
965
|
+
Logger.info(`Latest published version of akanjs: ${latestPublishedVersion ?? "none"}`);
|
|
966
|
+
Logger.info(`Next version of akanjs: ${nextVersion}`);
|
|
967
|
+
for (const library of akanPkgs) {
|
|
968
|
+
const packageJson = await workspace.readJson(`pkgs/${library}/package.json`);
|
|
969
|
+
const newPackageJsonStr = JSON.stringify(this.#normalizeAkanPackageJson(packageJson, library, nextVersion), null, 2);
|
|
970
|
+
await workspace.writeFile(`pkgs/${library}/package.json`, newPackageJsonStr);
|
|
971
|
+
const distPackageJson = await workspace.readJson(`dist/pkgs/${library}/package.json`);
|
|
972
|
+
const newDistPackageJson = this.#normalizeAkanPackageJson(distPackageJson, library, nextVersion);
|
|
973
|
+
await workspace.writeJson(`dist/pkgs/${library}/package.json`, newDistPackageJson);
|
|
974
|
+
}
|
|
975
|
+
if (confirmPublish) {
|
|
976
|
+
const isDeployConfirmed = await confirm({
|
|
977
|
+
message: "Are you sure you want to deploy the libraries?"
|
|
978
|
+
});
|
|
979
|
+
if (!isDeployConfirmed) {
|
|
980
|
+
Logger.error("Deployment cancelled");
|
|
981
|
+
return;
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
if (!registry) {
|
|
985
|
+
Logger.info("Logging in to npm...");
|
|
986
|
+
await workspace.spawn("npm", ["login"], { stdio: "inherit" });
|
|
987
|
+
Logger.info("Logged in to npm");
|
|
988
|
+
}
|
|
989
|
+
for (const library of akanPkgs) {
|
|
990
|
+
Logger.info(`Publishing ${library}@${nextVersion} to ${registry ?? "npm"}...`);
|
|
991
|
+
await workspace.spawn("npm", ["publish", "--tag", tag, ...this.#getRegistryArgs(registry), ...this.#getLocalRegistryAuthArgs(registry)], {
|
|
992
|
+
cwd: path7.join(workspace.workspaceRoot, "dist/pkgs", library),
|
|
993
|
+
env: this.#getRegistryEnv(registry),
|
|
994
|
+
stdio: "inherit"
|
|
995
|
+
});
|
|
996
|
+
Logger.info(`${library}@${nextVersion} is published to ${registry ?? "npm"}`);
|
|
997
|
+
}
|
|
998
|
+
Logger.info(`All libraries are published to ${registry ?? "npm"}`);
|
|
999
|
+
}
|
|
1000
|
+
async update(workspace, tag = "latest", { registryUrl } = {}) {
|
|
1001
|
+
const registry = registryUrl ? getNpmRegistryUrl(registryUrl) : undefined;
|
|
1002
|
+
const registryArgs = this.#getRegistryArgs(registry);
|
|
1003
|
+
const env = this.#getRegistryEnv(registry);
|
|
1004
|
+
const globalCliArgs = ["add", "-g", `@akanjs/cli@${tag}`, ...registryArgs];
|
|
1005
|
+
if (!await workspace.exists("package.json"))
|
|
1006
|
+
await workspace.spawn("bun", globalCliArgs, { env });
|
|
1007
|
+
else
|
|
1008
|
+
await Promise.all([
|
|
1009
|
+
workspace.spawn("bun", globalCliArgs, { env }),
|
|
1010
|
+
this.#updateAkanPkgs(workspace, tag, registry)
|
|
1011
|
+
]);
|
|
1012
|
+
}
|
|
1013
|
+
async#updateAkanPkgs(workspace, tag = "latest", registryUrl) {
|
|
1014
|
+
const latestPublishedVersion = await getLatestPackageVersion("akanjs", tag, registryUrl);
|
|
1015
|
+
const rootPackageJson = await workspace.getPackageJson();
|
|
1016
|
+
if (!rootPackageJson.dependencies)
|
|
1017
|
+
throw new Error("No dependencies found in package.json");
|
|
1018
|
+
if (rootPackageJson.dependencies.akanjs)
|
|
1019
|
+
rootPackageJson.dependencies.akanjs = latestPublishedVersion;
|
|
1020
|
+
if (rootPackageJson.devDependencies?.akanjs)
|
|
1021
|
+
rootPackageJson.devDependencies.akanjs = latestPublishedVersion;
|
|
1022
|
+
if (rootPackageJson.dependencies["@akanjs/devkit"])
|
|
1023
|
+
rootPackageJson.dependencies["@akanjs/devkit"] = latestPublishedVersion;
|
|
1024
|
+
if (rootPackageJson.devDependencies?.["@akanjs/devkit"])
|
|
1025
|
+
rootPackageJson.devDependencies["@akanjs/devkit"] = latestPublishedVersion;
|
|
1026
|
+
await workspace.setPackageJson(rootPackageJson);
|
|
1027
|
+
await workspace.spawn("bun", ["install", ...this.#getRegistryArgs(registryUrl)], {
|
|
1028
|
+
env: this.#getRegistryEnv(registryUrl)
|
|
1029
|
+
});
|
|
1030
|
+
}
|
|
1031
|
+
#normalizeAkanPackageJson(packageJson, packageName, version) {
|
|
1032
|
+
const normalized = { ...packageJson, version };
|
|
1033
|
+
for (const field of ["dependencies", "devDependencies", "peerDependencies", "optionalDependencies"]) {
|
|
1034
|
+
const dependencies = normalized[field];
|
|
1035
|
+
if (!dependencies)
|
|
1036
|
+
continue;
|
|
1037
|
+
normalized[field] = Object.fromEntries(Object.entries(dependencies).map(([dep, depVersion]) => [
|
|
1038
|
+
dep,
|
|
1039
|
+
dep !== packageName && this.#akanFrameworkPackages.has(dep) ? version : depVersion
|
|
1040
|
+
]));
|
|
1041
|
+
}
|
|
1042
|
+
return normalized;
|
|
1043
|
+
}
|
|
1044
|
+
async downloadEnv(cloudApi, workspace, workspaceId) {
|
|
1045
|
+
await workspace.mkdir("local");
|
|
1046
|
+
const localPath = await cloudApi.downloadEnv(workspaceId);
|
|
1047
|
+
const relativePath = path7.relative(workspace.workspaceRoot, localPath).split(path7.sep).join("/");
|
|
1048
|
+
await workspace.spawn("tar", ["-xf", relativePath], { cwd: workspace.workspaceRoot });
|
|
1049
|
+
await workspace.remove(localPath);
|
|
1050
|
+
}
|
|
1051
|
+
async uploadEnv(cloudApi, workspaceId, filePath) {
|
|
1052
|
+
const file = new File([Bun.file(filePath)], path7.basename(filePath));
|
|
1053
|
+
await cloudApi.uploadEnv(workspaceId, file);
|
|
1054
|
+
}
|
|
1055
|
+
async downloadEnvByScp(workspace) {
|
|
1056
|
+
const envArchivePath = "local/env.tar";
|
|
1057
|
+
const remoteServer = await this.#getRemoteEnvServerWithUsername();
|
|
1058
|
+
const remoteArchivePath = this.#getRemoteEnvArchivePath();
|
|
1059
|
+
const remoteTarget = this.#getScpTarget(remoteServer.config, remoteArchivePath);
|
|
1060
|
+
await workspace.mkdir("local");
|
|
1061
|
+
await workspace.remove(envArchivePath);
|
|
1062
|
+
try {
|
|
1063
|
+
Logger.info(`Downloading env archive from remote server "${remoteServer.name}"...`);
|
|
1064
|
+
await workspace.spawn("scp", this.#getScpArgs(remoteServer.config, remoteTarget, envArchivePath), {
|
|
1065
|
+
cwd: workspace.workspaceRoot,
|
|
1066
|
+
stdio: "inherit"
|
|
1067
|
+
});
|
|
1068
|
+
await workspace.spawn("tar", ["-xf", envArchivePath], {
|
|
1069
|
+
cwd: workspace.workspaceRoot
|
|
1070
|
+
});
|
|
1071
|
+
await workspace.remove(envArchivePath);
|
|
1072
|
+
} catch (error) {
|
|
1073
|
+
throw new Error(`Failed to download env archive from remote server "${remoteServer.name}"`, { cause: error });
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
async uploadEnvByScp(workspace, filePath) {
|
|
1077
|
+
const remoteServer = await this.#getRemoteEnvServerWithUsername();
|
|
1078
|
+
const remoteArchiveDir = this.#getRemoteEnvArchiveDir();
|
|
1079
|
+
const remoteArchivePath = this.#getRemoteEnvArchivePath();
|
|
1080
|
+
const remoteTarget = this.#getScpTarget(remoteServer.config, remoteArchivePath);
|
|
1081
|
+
try {
|
|
1082
|
+
await workspace.spawn("ssh", this.#getSshArgs(remoteServer.config, `mkdir -p ${remoteArchiveDir}`), {
|
|
1083
|
+
cwd: workspace.workspaceRoot,
|
|
1084
|
+
stdio: "inherit"
|
|
1085
|
+
});
|
|
1086
|
+
Logger.info(`Uploading env archive to remote server "${remoteServer.name}"...`);
|
|
1087
|
+
await workspace.spawn("scp", this.#getScpArgs(remoteServer.config, filePath, remoteTarget), {
|
|
1088
|
+
cwd: workspace.workspaceRoot,
|
|
1089
|
+
stdio: "inherit"
|
|
1090
|
+
});
|
|
1091
|
+
} catch (error) {
|
|
1092
|
+
throw new Error(`Failed to upload env archive to remote server "${remoteServer.name}"`, { cause: error });
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
async gatherEnvFiles(workspace, { scope, archivePath = "local/env.tar" } = {}) {
|
|
1096
|
+
const envFilePattern = /^env\.(client|server)\.(?!(type|example)\.ts$).+\.ts$/;
|
|
1097
|
+
const [workspaceAppNames, workspaceLibNames] = await workspace.getExecs();
|
|
1098
|
+
const appNames = scope?.apps ?? workspaceAppNames;
|
|
1099
|
+
const libNames = scope?.libs ?? workspaceLibNames;
|
|
1100
|
+
const envDirs = [
|
|
1101
|
+
...appNames.map((appName) => `apps/${appName}/env`),
|
|
1102
|
+
...libNames.map((libName) => `libs/${libName}/env`)
|
|
1103
|
+
];
|
|
1104
|
+
const defaultEnvFilePaths = (await Promise.all(envDirs.map(async (envDir) => (await workspace.readdir(envDir)).filter((fileName) => envFilePattern.test(fileName)).map((fileName) => `${envDir}/${fileName}`)))).flat();
|
|
1105
|
+
await this.#syncSecretGitignore(workspace, workspaceAppNames);
|
|
1106
|
+
const customSecretPaths = await this.#gatherCustomSecretFiles(workspace, appNames);
|
|
1107
|
+
const envFilePaths = [...new Set([...defaultEnvFilePaths, ...customSecretPaths])].sort();
|
|
1108
|
+
await workspace.mkdir("local");
|
|
1109
|
+
await workspace.remove(archivePath);
|
|
1110
|
+
if (envFilePaths.length === 0)
|
|
1111
|
+
throw new Error(scope ? `No environment files found to archive for ${appNames.join(", ") || "(no apps)"}` : "No environment files found to archive");
|
|
1112
|
+
await workspace.spawn("tar", ["-cf", archivePath, ...envFilePaths], {
|
|
1113
|
+
cwd: workspace.workspaceRoot
|
|
1114
|
+
});
|
|
1115
|
+
Logger.info(`Archived ${envFilePaths.length} environment files to ${archivePath}`);
|
|
1116
|
+
return { files: envFilePaths, path: archivePath };
|
|
1117
|
+
}
|
|
1118
|
+
async#gatherCustomSecretFiles(workspace, appNames) {
|
|
1119
|
+
const secretPaths = await Promise.all(appNames.map(async (appName) => {
|
|
1120
|
+
const config = await AppExecutor.from(workspace, appName).getConfig();
|
|
1121
|
+
const secretGlobs = config.secrets ?? [];
|
|
1122
|
+
if (secretGlobs.length === 0)
|
|
1123
|
+
return [];
|
|
1124
|
+
const appDir = path7.join(workspace.workspaceRoot, "apps", appName);
|
|
1125
|
+
return secretGlobs.flatMap((pattern) => Array.from(new Bun.Glob(pattern).scanSync({ cwd: appDir, onlyFiles: true })).map((match) => `apps/${appName}/${match.split(path7.sep).join("/")}`));
|
|
1126
|
+
}));
|
|
1127
|
+
return secretPaths.flat();
|
|
1128
|
+
}
|
|
1129
|
+
async#syncSecretGitignore(workspace, appNames) {
|
|
1130
|
+
const patterns = (await Promise.all(appNames.map(async (appName) => {
|
|
1131
|
+
const config = await AppExecutor.from(workspace, appName).getConfig();
|
|
1132
|
+
return (config.secrets ?? []).map((pattern) => `apps/${appName}/${pattern.replace(/^\/+/, "")}`);
|
|
1133
|
+
}))).flat();
|
|
1134
|
+
const uniquePatterns = [...new Set(patterns)].sort();
|
|
1135
|
+
const existing = await workspace.exists(".gitignore") ? await workspace.readFile(".gitignore") : "";
|
|
1136
|
+
const nextContent = this.#applySecretGitignoreBlock(existing, uniquePatterns);
|
|
1137
|
+
if (nextContent === existing)
|
|
1138
|
+
return;
|
|
1139
|
+
await workspace.writeFile(".gitignore", nextContent);
|
|
1140
|
+
Logger.info(uniquePatterns.length ? `Synced ${uniquePatterns.length} secret pattern(s) from akan.config.ts to .gitignore` : "Removed managed secret patterns from .gitignore");
|
|
1141
|
+
}
|
|
1142
|
+
#applySecretGitignoreBlock(content, patterns) {
|
|
1143
|
+
const beginMarker = "# akan:secrets (managed by akan.config.ts \u2014 do not edit)";
|
|
1144
|
+
const endMarker = "# akan:secrets:end";
|
|
1145
|
+
const lines = content.split(`
|
|
1146
|
+
`);
|
|
1147
|
+
const beginIdx = lines.indexOf(beginMarker);
|
|
1148
|
+
const endIdx = lines.indexOf(endMarker);
|
|
1149
|
+
const stripped = beginIdx !== -1 && endIdx !== -1 && endIdx >= beginIdx ? [...lines.slice(0, beginIdx), ...lines.slice(endIdx + 1)] : [...lines];
|
|
1150
|
+
while (stripped.length && stripped[stripped.length - 1]?.trim() === "")
|
|
1151
|
+
stripped.pop();
|
|
1152
|
+
if (patterns.length === 0)
|
|
1153
|
+
return stripped.length ? `${stripped.join(`
|
|
1154
|
+
`)}
|
|
1155
|
+
` : "";
|
|
1156
|
+
const body = stripped.length ? `${stripped.join(`
|
|
1157
|
+
`)}
|
|
1158
|
+
|
|
1159
|
+
` : "";
|
|
1160
|
+
return `${body}${[beginMarker, ...patterns, endMarker].join(`
|
|
1161
|
+
`)}
|
|
1162
|
+
`;
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
export { getNpmRegistryUrl, getLatestPackageVersion, PlatformTestRun, CloudRunner };
|