@aamini/config 0.2.1 → 0.2.2
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/README.md +10 -0
- package/dist/bootstrap/cli.d.mts +1 -0
- package/dist/bootstrap/cli.mjs +481 -0
- package/dist/bootstrap/cli.mjs.map +1 -0
- package/dist/browser-CSDVICWx.mjs +84 -0
- package/dist/browser-CSDVICWx.mjs.map +1 -0
- package/dist/browser-brqdbdWe.d.mts +23 -0
- package/dist/index-1oJRRJ46.d.mts +30 -0
- package/dist/tanstack/index.d.mts +1 -1
- package/dist/vitest/browser.d.mts +2 -22
- package/dist/vitest/browser.mjs +1 -83
- package/dist/vitest/index.d.mts +2 -29
- package/dist/vitest/react.d.mts +1 -1
- package/dist/vitest/react.mjs +1 -1
- package/package.json +7 -1
- package/dist/vitest/browser.mjs.map +0 -1
package/README.md
CHANGED
|
@@ -24,6 +24,16 @@ pnpm add -D @aamini/config vite-plus oxlint-plugin-eslint
|
|
|
24
24
|
| `/tsconfig/react.json` | Base plus React JSX, DOM, and Vite client types |
|
|
25
25
|
| `/tsconfig/astro.json` | Base plus Astro client types |
|
|
26
26
|
|
|
27
|
+
## CLI
|
|
28
|
+
|
|
29
|
+
`aamini bootstrap [--verbose]` configures a TanStack checkout: workspace ports
|
|
30
|
+
and `.env.workspace.local`, orphaned Docker cleanup, env validation, Docker
|
|
31
|
+
services, migrations, and the Pitchfork dev daemon. It then waits for the app to
|
|
32
|
+
answer. It needs `gum`, and the checkout needs the `compose:up` and `db:migrate`
|
|
33
|
+
scripts. Install tools and packages first, since the bin ships in this package.
|
|
34
|
+
The TanStack template's `mise run bootstrap` does that, then runs
|
|
35
|
+
`vp exec aamini bootstrap`.
|
|
36
|
+
|
|
27
37
|
## TanStack Start
|
|
28
38
|
|
|
29
39
|
```ts
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {}
|
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { basename } from "node:path";
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import { existsSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { $, execaSync } from "execa";
|
|
6
|
+
import { setTimeout } from "node:timers/promises";
|
|
7
|
+
import { randomBytes } from "node:crypto";
|
|
8
|
+
//#region src/bootstrap/args.ts
|
|
9
|
+
const USAGE = "Usage: aamini bootstrap [--verbose]";
|
|
10
|
+
/**
|
|
11
|
+
* Parses the arguments after `aamini bootstrap`. `usageVerbose` is the
|
|
12
|
+
* `usage_verbose` variable that `mise run` sets from the task's `usage` flag.
|
|
13
|
+
*/
|
|
14
|
+
function parseBootstrapArgs(args, usageVerbose) {
|
|
15
|
+
if (args.length > 1 || args[0] && !["--verbose", "--help"].includes(args[0])) return {
|
|
16
|
+
kind: "error",
|
|
17
|
+
message: USAGE
|
|
18
|
+
};
|
|
19
|
+
if (args[0] === "--help") return { kind: "help" };
|
|
20
|
+
return {
|
|
21
|
+
kind: "run",
|
|
22
|
+
options: { verbose: args[0] === "--verbose" || usageVerbose === "true" }
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
//#endregion
|
|
26
|
+
//#region src/bootstrap/gc.ts
|
|
27
|
+
function lines(value) {
|
|
28
|
+
return value.split("\n").map((line) => line.trim()).filter(Boolean);
|
|
29
|
+
}
|
|
30
|
+
async function collectGarbage() {
|
|
31
|
+
const host = process.env.DOCKER_HOST;
|
|
32
|
+
if (host && !host.startsWith("unix://")) throw new Error(`gc: refusing to run against a remote Docker daemon: ${host}`);
|
|
33
|
+
const { stdout } = await $`docker ps -a --format ${"{{.Label \"com.docker.compose.project\"}}"}`;
|
|
34
|
+
const projects = [...new Set(lines(stdout))].sort();
|
|
35
|
+
let removed = 0;
|
|
36
|
+
let failed = 0;
|
|
37
|
+
for (const project of projects) {
|
|
38
|
+
const directory = lines((await $`docker ps -a --filter ${`label=com.docker.compose.project=${project}`} --format ${"{{.Label \"com.docker.compose.project.working_dir\"}}"}`).stdout)[0];
|
|
39
|
+
if (!directory || statSync(directory, { throwIfNoEntry: false })?.isDirectory()) continue;
|
|
40
|
+
console.log(`Removing orphaned stack: ${project} (${directory})`);
|
|
41
|
+
if ((await $({
|
|
42
|
+
env: { DOCKER_SUBNET: "10.255.255.0/24" },
|
|
43
|
+
stdio: "inherit",
|
|
44
|
+
reject: false
|
|
45
|
+
})`docker compose -p ${project} down --remove-orphans`).exitCode === 0) removed++;
|
|
46
|
+
else {
|
|
47
|
+
console.error(`Failed to remove: ${project}`);
|
|
48
|
+
failed++;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
const networks = await $`docker network ls --filter label=com.docker.compose.project --format ${"{{.Name}}"}`;
|
|
52
|
+
for (const network of lines(networks.stdout)) {
|
|
53
|
+
const project = (await $`docker network inspect ${network} --format ${"{{index .Labels \"com.docker.compose.project\"}}"}`).stdout.trim();
|
|
54
|
+
if (!project) continue;
|
|
55
|
+
if ((await $`docker ps -a --filter ${`label=com.docker.compose.project=${project}`} --format ${"{{.ID}}"}`).stdout.trim()) continue;
|
|
56
|
+
console.log(`Removing orphaned network: ${network} (${project})`);
|
|
57
|
+
if ((await $({
|
|
58
|
+
stdio: "inherit",
|
|
59
|
+
reject: false
|
|
60
|
+
})`docker network rm ${network}`).exitCode !== 0) {
|
|
61
|
+
console.error(`Failed to remove: ${network}`);
|
|
62
|
+
failed++;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if (failed > 0) throw new Error(`gc: ${failed} removal(s) failed`);
|
|
66
|
+
return removed;
|
|
67
|
+
}
|
|
68
|
+
//#endregion
|
|
69
|
+
//#region src/bootstrap/readiness.ts
|
|
70
|
+
async function verifyApp(url, restart, { attempts = 30, interval = 1e3 } = {}) {
|
|
71
|
+
async function poll() {
|
|
72
|
+
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
73
|
+
if ((await $({ reject: false })`curl -skf --max-time 5 -o /dev/null ${url}`).exitCode === 0) return true;
|
|
74
|
+
if (attempt + 1 < attempts) await setTimeout(interval);
|
|
75
|
+
}
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
if (await poll()) return;
|
|
79
|
+
if (restart) {
|
|
80
|
+
await restart();
|
|
81
|
+
if (await poll()) return;
|
|
82
|
+
}
|
|
83
|
+
throw new Error(`App did not answer at ${url}`);
|
|
84
|
+
}
|
|
85
|
+
//#endregion
|
|
86
|
+
//#region src/bootstrap/workspace.ts
|
|
87
|
+
const MASK_64 = (1n << 64n) - 1n;
|
|
88
|
+
function rotateLeft(value, bits) {
|
|
89
|
+
return (value << bits | value >> 64n - bits) & MASK_64;
|
|
90
|
+
}
|
|
91
|
+
function sipRound(state) {
|
|
92
|
+
state[0] = state[0] + state[1] & MASK_64;
|
|
93
|
+
state[1] = rotateLeft(state[1], 13n) ^ state[0];
|
|
94
|
+
state[0] = rotateLeft(state[0], 32n);
|
|
95
|
+
state[2] = state[2] + state[3] & MASK_64;
|
|
96
|
+
state[3] = rotateLeft(state[3], 16n) ^ state[2];
|
|
97
|
+
state[0] = state[0] + state[3] & MASK_64;
|
|
98
|
+
state[3] = rotateLeft(state[3], 21n) ^ state[0];
|
|
99
|
+
state[2] = state[2] + state[1] & MASK_64;
|
|
100
|
+
state[1] = rotateLeft(state[1], 17n) ^ state[2];
|
|
101
|
+
state[2] = rotateLeft(state[2], 32n);
|
|
102
|
+
}
|
|
103
|
+
function worktrunkHash(value) {
|
|
104
|
+
const bytes = Buffer.concat([Buffer.from(value), Buffer.from([255])]);
|
|
105
|
+
const state = [
|
|
106
|
+
8317987319222330741n,
|
|
107
|
+
7237128888997146477n,
|
|
108
|
+
7816392313619706465n,
|
|
109
|
+
8387220255154660723n
|
|
110
|
+
];
|
|
111
|
+
let offset = 0;
|
|
112
|
+
while (offset + 8 <= bytes.length) {
|
|
113
|
+
const message = bytes.readBigUInt64LE(offset);
|
|
114
|
+
state[3] ^= message;
|
|
115
|
+
sipRound(state);
|
|
116
|
+
state[0] ^= message;
|
|
117
|
+
offset += 8;
|
|
118
|
+
}
|
|
119
|
+
let tail = BigInt(bytes.length) << 56n & MASK_64;
|
|
120
|
+
for (let index = offset; index < bytes.length; index++) tail |= BigInt(bytes.readUInt8(index)) << BigInt((index - offset) * 8);
|
|
121
|
+
state[3] ^= tail;
|
|
122
|
+
sipRound(state);
|
|
123
|
+
state[0] ^= tail;
|
|
124
|
+
state[2] ^= 255n;
|
|
125
|
+
for (let round = 0; round < 3; round++) sipRound(state);
|
|
126
|
+
return state[0] ^ state[1] ^ state[2] ^ state[3];
|
|
127
|
+
}
|
|
128
|
+
function hashPort(value) {
|
|
129
|
+
return 1e4 + Number(worktrunkHash(value) % 10000n);
|
|
130
|
+
}
|
|
131
|
+
function shortHash(value) {
|
|
132
|
+
const characters = "0123456789abcdefghijklmnopqrstuvwxyz";
|
|
133
|
+
const hash = worktrunkHash(value);
|
|
134
|
+
return [
|
|
135
|
+
hash % 36n,
|
|
136
|
+
hash / 36n % 36n,
|
|
137
|
+
hash / 1296n % 36n
|
|
138
|
+
].map((index) => characters[Number(index)]).join("");
|
|
139
|
+
}
|
|
140
|
+
function sanitizeDatabaseName(value) {
|
|
141
|
+
let result = value.replace(/[A-Z]/g, (character) => character.toLowerCase()).replace(/[^a-z0-9]+/g, "_").replace(/^_+/, "");
|
|
142
|
+
if (!result) result = "workspace";
|
|
143
|
+
if (!/^[a-z]/.test(result)) result = `w_${result}`;
|
|
144
|
+
result = result.slice(0, 44);
|
|
145
|
+
if (!result.endsWith("_")) result += "_";
|
|
146
|
+
return `${result}${shortHash(value)}`;
|
|
147
|
+
}
|
|
148
|
+
function run(command, args) {
|
|
149
|
+
return execaSync(command, args, { stdio: [
|
|
150
|
+
"ignore",
|
|
151
|
+
"pipe",
|
|
152
|
+
"ignore"
|
|
153
|
+
] }).stdout.trim();
|
|
154
|
+
}
|
|
155
|
+
function detectWorkspace() {
|
|
156
|
+
try {
|
|
157
|
+
const root = realpathSync(run("jj", ["workspace", "root"]));
|
|
158
|
+
const branch = run("jj", [
|
|
159
|
+
"workspace",
|
|
160
|
+
"list",
|
|
161
|
+
"-T",
|
|
162
|
+
"self.name() ++ \"\\n\""
|
|
163
|
+
]).split("\n").find((name) => {
|
|
164
|
+
try {
|
|
165
|
+
return realpathSync(run("jj", [
|
|
166
|
+
"workspace",
|
|
167
|
+
"root",
|
|
168
|
+
"--name",
|
|
169
|
+
name
|
|
170
|
+
])) === root;
|
|
171
|
+
} catch {
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
if (!branch) throw new Error("Could not identify the current jj workspace");
|
|
176
|
+
return {
|
|
177
|
+
branch,
|
|
178
|
+
worktree: basename(root)
|
|
179
|
+
};
|
|
180
|
+
} catch (error) {
|
|
181
|
+
if (error instanceof Error && error.message.startsWith("Could not")) throw error;
|
|
182
|
+
}
|
|
183
|
+
try {
|
|
184
|
+
const root = realpathSync(run("git", ["rev-parse", "--show-toplevel"]));
|
|
185
|
+
return {
|
|
186
|
+
branch: run("git", ["branch", "--show-current"]) || basename(root),
|
|
187
|
+
worktree: basename(root)
|
|
188
|
+
};
|
|
189
|
+
} catch {
|
|
190
|
+
throw new Error("Run setup from inside a jj workspace or Git worktree");
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
const POSTGRES_USER = "app_user";
|
|
194
|
+
const POSTGRES_PASSWORD = "app_dev";
|
|
195
|
+
const AWS_ACCESS_KEY_ID = "app_minio";
|
|
196
|
+
const AWS_SECRET_ACCESS_KEY = "app_minio_secret";
|
|
197
|
+
const ENV_SPEC_HEADER = [
|
|
198
|
+
"# ---",
|
|
199
|
+
"# @defaultSensitive=false",
|
|
200
|
+
"# ---",
|
|
201
|
+
""
|
|
202
|
+
];
|
|
203
|
+
function updateEnvFile(path, groups, replace = false) {
|
|
204
|
+
const updates = Object.assign({}, ...groups);
|
|
205
|
+
const lines = !replace && existsSync(path) ? readFileSync(path, "utf8").split(/\r?\n/) : [];
|
|
206
|
+
const specIndex = lines.findIndex((line) => line.includes("@defaultSensitive"));
|
|
207
|
+
if (specIndex === -1) lines.unshift(...ENV_SPEC_HEADER);
|
|
208
|
+
else {
|
|
209
|
+
if (lines[specIndex + 1]?.trim() !== "# ---") lines.splice(specIndex + 1, 0, "# ---", "");
|
|
210
|
+
if (specIndex === 0 || lines[specIndex - 1]?.trim() !== "# ---") lines.splice(specIndex, 0, "# ---");
|
|
211
|
+
}
|
|
212
|
+
const remaining = new Set(Object.keys(updates));
|
|
213
|
+
for (let index = 0; index < lines.length; index++) {
|
|
214
|
+
const key = lines[index]?.match(/^\s*([^#=\s]+)\s*=/)?.[1];
|
|
215
|
+
if (!key || !(key in updates)) continue;
|
|
216
|
+
lines[index] = `${key}="${updates[key]}"`;
|
|
217
|
+
remaining.delete(key);
|
|
218
|
+
}
|
|
219
|
+
while (lines.at(-1) === "") lines.pop();
|
|
220
|
+
for (const group of groups) {
|
|
221
|
+
const pending = Object.keys(group).filter((key) => remaining.has(key));
|
|
222
|
+
if (pending.length === 0) continue;
|
|
223
|
+
if (lines.length > 0) lines.push("");
|
|
224
|
+
for (const key of pending) lines.push(`${key}="${updates[key]}"`);
|
|
225
|
+
}
|
|
226
|
+
writeFileSync(path, `${lines.join("\n")}\n`);
|
|
227
|
+
}
|
|
228
|
+
function setDaemonPort(appPort) {
|
|
229
|
+
const base = "pitchfork.toml";
|
|
230
|
+
if (!existsSync(base)) return;
|
|
231
|
+
const contents = readFileSync(base, "utf8").replace(/^port = \d+\n/m, "");
|
|
232
|
+
writeFileSync("pitchfork.local.toml", `${contents.trimEnd()}\nport = ${appPort}\n`);
|
|
233
|
+
}
|
|
234
|
+
function readEnvFile(path) {
|
|
235
|
+
if (!existsSync(path)) return {};
|
|
236
|
+
return Object.fromEntries(readFileSync(path, "utf8").split(/\r?\n/).flatMap((line) => {
|
|
237
|
+
const match = line.match(/^\s*([^#=\s]+)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^#]*?))\s*(?:#.*)?$/);
|
|
238
|
+
return match ? [[match[1], match[2] ?? match[3] ?? match[4] ?? ""]] : [];
|
|
239
|
+
}));
|
|
240
|
+
}
|
|
241
|
+
function defaultWorkspaceRoot() {
|
|
242
|
+
try {
|
|
243
|
+
return realpathSync(run("jj", [
|
|
244
|
+
"workspace",
|
|
245
|
+
"root",
|
|
246
|
+
"--name",
|
|
247
|
+
"default"
|
|
248
|
+
]));
|
|
249
|
+
} catch {
|
|
250
|
+
return realpathSync(".");
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
function pitchfork(args) {
|
|
254
|
+
return execaSync("pitchfork", args, {
|
|
255
|
+
stdio: [
|
|
256
|
+
"ignore",
|
|
257
|
+
"pipe",
|
|
258
|
+
"ignore"
|
|
259
|
+
],
|
|
260
|
+
timeout: 1e4
|
|
261
|
+
}).stdout.trim();
|
|
262
|
+
}
|
|
263
|
+
function pitchforkAvailable() {
|
|
264
|
+
try {
|
|
265
|
+
pitchfork(["list"]);
|
|
266
|
+
return true;
|
|
267
|
+
} catch {
|
|
268
|
+
return false;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
function proxyTld() {
|
|
272
|
+
try {
|
|
273
|
+
return pitchfork([
|
|
274
|
+
"settings",
|
|
275
|
+
"get",
|
|
276
|
+
"proxy.tld"
|
|
277
|
+
]) || "localhost";
|
|
278
|
+
} catch {
|
|
279
|
+
return "localhost";
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
function slugify(value) {
|
|
283
|
+
const slug = value.toLowerCase().replaceAll(/[^a-z0-9-]+/g, "-").replaceAll(/-+/g, "-").replace(/^-+|-+$/g, "");
|
|
284
|
+
if (!slug) return "workspace";
|
|
285
|
+
if (slug.length <= 63) return slug;
|
|
286
|
+
const suffix = worktrunkHash(value).toString(36).padStart(8, "0").slice(0, 8);
|
|
287
|
+
return `${slug.slice(0, 63 - suffix.length - 1).replace(/-+$/, "")}-${suffix}`;
|
|
288
|
+
}
|
|
289
|
+
function registerProxySlug(mainRoot) {
|
|
290
|
+
const slug = slugify(basename(mainRoot));
|
|
291
|
+
if (realpathSync(".") === mainRoot) registerSlug(slug, mainRoot);
|
|
292
|
+
return slug;
|
|
293
|
+
}
|
|
294
|
+
function registerWorktreeSlug(slug) {
|
|
295
|
+
registerSlug(slug, realpathSync("."));
|
|
296
|
+
}
|
|
297
|
+
function registerSlug(slug, dir) {
|
|
298
|
+
try {
|
|
299
|
+
pitchfork([
|
|
300
|
+
"settings",
|
|
301
|
+
"set",
|
|
302
|
+
"proxy.enable",
|
|
303
|
+
"true",
|
|
304
|
+
"--global"
|
|
305
|
+
]);
|
|
306
|
+
pitchfork([
|
|
307
|
+
"proxy",
|
|
308
|
+
"add",
|
|
309
|
+
slug,
|
|
310
|
+
"--daemon",
|
|
311
|
+
"dev",
|
|
312
|
+
"--dir",
|
|
313
|
+
dir
|
|
314
|
+
]);
|
|
315
|
+
} catch {}
|
|
316
|
+
}
|
|
317
|
+
function existingPorts(worktree) {
|
|
318
|
+
const entries = readEnvFile(".env.workspace.local");
|
|
319
|
+
if (entries["WORKTREE_NAME"] !== worktree) return {};
|
|
320
|
+
return entries;
|
|
321
|
+
}
|
|
322
|
+
function setupWorkspace() {
|
|
323
|
+
const { branch, worktree } = detectWorkspace();
|
|
324
|
+
const compose = sanitizeDatabaseName(worktree);
|
|
325
|
+
const existing = existingPorts(worktree);
|
|
326
|
+
const isForeign = existsSync(".env.workspace.local") && readEnvFile(".env.workspace.local")["WORKTREE_NAME"] !== worktree;
|
|
327
|
+
const database = existing["POSTGRES_DB"] ?? sanitizeDatabaseName(branch);
|
|
328
|
+
const mainRoot = defaultWorkspaceRoot();
|
|
329
|
+
const repo = basename(mainRoot);
|
|
330
|
+
const appPort = Number(existing["APP_PORT"]) || hashPort(`${repo}:${branch}`);
|
|
331
|
+
const postgresPort = Number(existing["POSTGRES_PORT"]) || hashPort(`db-${repo}:${branch}`);
|
|
332
|
+
const minioPort = Number(existing["MINIO_PORT"]) || hashPort(`minio-${repo}:${branch}`);
|
|
333
|
+
const minioConsolePort = Number(existing["MINIO_CONSOLE_PORT"]) || hashPort(`minio-console-${repo}:${branch}`);
|
|
334
|
+
const tld = proxyTld();
|
|
335
|
+
const proxySlug = registerProxySlug(mainRoot);
|
|
336
|
+
const worktreeLabel = slugify(worktree);
|
|
337
|
+
const isRoot = realpathSync(".") === mainRoot;
|
|
338
|
+
const slug = isRoot || worktreeLabel === proxySlug ? proxySlug : slugify(`${proxySlug}-${worktreeLabel}`);
|
|
339
|
+
if (!isRoot && slug !== proxySlug) registerWorktreeSlug(slug);
|
|
340
|
+
const proxyHost = `${slug}.${tld}`;
|
|
341
|
+
const proxyUp = pitchforkAvailable();
|
|
342
|
+
updateEnvFile(".env.workspace.local", [
|
|
343
|
+
{
|
|
344
|
+
APP_PORT: String(appPort),
|
|
345
|
+
BASE_URL: proxyUp ? `https://${proxyHost}` : `http://localhost:${appPort}`
|
|
346
|
+
},
|
|
347
|
+
{
|
|
348
|
+
POSTGRES_PORT: String(postgresPort),
|
|
349
|
+
POSTGRES_DB: database,
|
|
350
|
+
POSTGRES_USER,
|
|
351
|
+
POSTGRES_PASSWORD,
|
|
352
|
+
DATABASE_URL: "postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:${POSTGRES_PORT}/${POSTGRES_DB}"
|
|
353
|
+
},
|
|
354
|
+
{
|
|
355
|
+
MINIO_PORT: String(minioPort),
|
|
356
|
+
MINIO_CONSOLE_PORT: String(minioConsolePort)
|
|
357
|
+
},
|
|
358
|
+
{
|
|
359
|
+
AWS_ENDPOINT_URL: "http://localhost:${MINIO_PORT}",
|
|
360
|
+
AWS_ACCESS_KEY_ID,
|
|
361
|
+
AWS_SECRET_ACCESS_KEY,
|
|
362
|
+
AWS_S3_BUCKET_NAME: "app"
|
|
363
|
+
},
|
|
364
|
+
{
|
|
365
|
+
WORKTREE_NAME: worktree,
|
|
366
|
+
COMPOSE_PROJECT_NAME: compose
|
|
367
|
+
}
|
|
368
|
+
], isForeign);
|
|
369
|
+
setDaemonPort(appPort);
|
|
370
|
+
const summary = [
|
|
371
|
+
`app: http://localhost:${appPort}`,
|
|
372
|
+
`postgres: localhost:${postgresPort}/${database}`,
|
|
373
|
+
`minio: http://localhost:${minioPort}`
|
|
374
|
+
];
|
|
375
|
+
if (proxyUp) summary.push(`proxy: https://${proxyHost}`);
|
|
376
|
+
if (seedDevLocalFile()) summary.push("Seeded a local BETTER_AUTH_SECRET.");
|
|
377
|
+
return {
|
|
378
|
+
baseUrl: proxyUp ? `https://${proxyHost}` : `http://localhost:${appPort}`,
|
|
379
|
+
summary
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
function seedDevLocalFile() {
|
|
383
|
+
const path = ".env.development.local";
|
|
384
|
+
if (readEnvFile(path)["BETTER_AUTH_SECRET"]) return false;
|
|
385
|
+
const existing = existsSync(path) ? readFileSync(path, "utf8").split(/\r?\n/).filter((line) => !/^\s*BETTER_AUTH_SECRET\s*=/.test(line)).join("\n").trimEnd() : "";
|
|
386
|
+
if (readEnvFile(".env.local")["BETTER_AUTH_SECRET"]) {
|
|
387
|
+
if (existsSync(path)) writeFileSync(path, existing ? `${existing}\n` : "");
|
|
388
|
+
return false;
|
|
389
|
+
}
|
|
390
|
+
writeFileSync(path, [
|
|
391
|
+
existing || "# Local overrides; setup adds missing defaults and leaves existing values alone.",
|
|
392
|
+
`BETTER_AUTH_SECRET="${randomBytes(32).toString("base64url")}"`,
|
|
393
|
+
""
|
|
394
|
+
].join("\n"));
|
|
395
|
+
return true;
|
|
396
|
+
}
|
|
397
|
+
//#endregion
|
|
398
|
+
//#region src/bootstrap/bootstrap.ts
|
|
399
|
+
/**
|
|
400
|
+
* Configures the checkout in the current directory and starts the app. The
|
|
401
|
+
* caller installs tools and packages and prints the banner first, because this
|
|
402
|
+
* code ships in a package that a fresh clone does not have yet.
|
|
403
|
+
*/
|
|
404
|
+
async function bootstrap({ verbose }) {
|
|
405
|
+
process.env._VARLOCK_FORCE_FILE_ENCRYPTION_FALLBACK = "1";
|
|
406
|
+
const gum = spawnSync("gum", ["--version"], { stdio: "ignore" });
|
|
407
|
+
if (gum.error || gum.status !== 0) throw new Error("gum is required; run mise install before bootstrap");
|
|
408
|
+
const inherited = $({ stdio: "inherit" });
|
|
409
|
+
async function complete(title) {
|
|
410
|
+
await inherited`gum style --foreground 82 ${` ✓ ${title}`}`;
|
|
411
|
+
}
|
|
412
|
+
async function step(title, command) {
|
|
413
|
+
if (verbose) {
|
|
414
|
+
console.log(` ${title}`);
|
|
415
|
+
await inherited`${command}`;
|
|
416
|
+
} else await inherited`gum spin --show-error --title ${` ${title}...`} -- ${command}`;
|
|
417
|
+
await complete(title);
|
|
418
|
+
}
|
|
419
|
+
const { baseUrl, summary } = setupWorkspace();
|
|
420
|
+
await complete("Configure workspace");
|
|
421
|
+
await complete(`Remove orphaned compose stacks (${await collectGarbage()} removed)`);
|
|
422
|
+
await step("Validate env with varlock", [
|
|
423
|
+
"vp",
|
|
424
|
+
"exec",
|
|
425
|
+
"varlock",
|
|
426
|
+
"load",
|
|
427
|
+
"--agent",
|
|
428
|
+
"--format",
|
|
429
|
+
"pretty"
|
|
430
|
+
]);
|
|
431
|
+
await step("Start Docker services", [
|
|
432
|
+
"vp",
|
|
433
|
+
"run",
|
|
434
|
+
"compose:up"
|
|
435
|
+
]);
|
|
436
|
+
await step("Apply database migrations", [
|
|
437
|
+
"vp",
|
|
438
|
+
"run",
|
|
439
|
+
"db:migrate"
|
|
440
|
+
]);
|
|
441
|
+
const pitchfork = await $({ reject: false })`pitchfork --version`;
|
|
442
|
+
const hasDaemon = existsSync("pitchfork.toml") && pitchfork.exitCode === 0;
|
|
443
|
+
if (hasDaemon) await step("Start dev daemon", [
|
|
444
|
+
"pitchfork",
|
|
445
|
+
"start",
|
|
446
|
+
"dev"
|
|
447
|
+
]);
|
|
448
|
+
await verifyApp(baseUrl, hasDaemon ? async () => {
|
|
449
|
+
await inherited`gum style --foreground 220 ${" App not answering; force-restarting dev daemon"}`;
|
|
450
|
+
await inherited`pitchfork restart dev --force`;
|
|
451
|
+
} : void 0);
|
|
452
|
+
await complete("Verify app responds");
|
|
453
|
+
const heading = await $`gum style --bold --foreground 82 ${"✓ Bootstrap complete"}`;
|
|
454
|
+
const details = await Promise.all(summary.map(async (line) => {
|
|
455
|
+
return (await $`gum style --foreground 39 ${line}`).stdout;
|
|
456
|
+
}));
|
|
457
|
+
await inherited`gum style --border rounded --border-foreground 82 --padding ${"0 3"} --margin ${"1 0"} ${heading.stdout} ${details}`;
|
|
458
|
+
}
|
|
459
|
+
//#endregion
|
|
460
|
+
//#region src/bootstrap/cli.ts
|
|
461
|
+
const [command, ...args] = process.argv.slice(2);
|
|
462
|
+
if (command !== "bootstrap") {
|
|
463
|
+
console.error(USAGE);
|
|
464
|
+
process.exitCode = 2;
|
|
465
|
+
} else {
|
|
466
|
+
const parsed = parseBootstrapArgs(args, process.env["usage_verbose"]);
|
|
467
|
+
if (parsed.kind === "help") console.log(USAGE);
|
|
468
|
+
else if (parsed.kind === "error") {
|
|
469
|
+
console.error(parsed.message);
|
|
470
|
+
process.exitCode = 2;
|
|
471
|
+
} else try {
|
|
472
|
+
await bootstrap(parsed.options);
|
|
473
|
+
} catch (error) {
|
|
474
|
+
console.error(`✗ Bootstrap failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
475
|
+
process.exitCode = 1;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
//#endregion
|
|
479
|
+
export {};
|
|
480
|
+
|
|
481
|
+
//# sourceMappingURL=cli.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cli.mjs","names":[],"sources":["../../src/bootstrap/args.ts","../../src/bootstrap/gc.ts","../../src/bootstrap/readiness.ts","../../src/bootstrap/workspace.ts","../../src/bootstrap/bootstrap.ts","../../src/bootstrap/cli.ts"],"sourcesContent":["export interface BootstrapOptions {\n\tverbose: boolean\n}\n\nexport type ParsedArgs =\n\t| { kind: 'run'; options: BootstrapOptions }\n\t| { kind: 'help' }\n\t| { kind: 'error'; message: string }\n\nexport const USAGE = 'Usage: aamini bootstrap [--verbose]'\n\n/**\n * Parses the arguments after `aamini bootstrap`. `usageVerbose` is the\n * `usage_verbose` variable that `mise run` sets from the task's `usage` flag.\n */\nexport function parseBootstrapArgs(\n\targs: string[],\n\tusageVerbose?: string,\n): ParsedArgs {\n\tif (\n\t\targs.length > 1 ||\n\t\t(args[0] && !['--verbose', '--help'].includes(args[0]))\n\t) {\n\t\treturn { kind: 'error', message: USAGE }\n\t}\n\n\tif (args[0] === '--help') return { kind: 'help' }\n\n\treturn {\n\t\tkind: 'run',\n\t\toptions: { verbose: args[0] === '--verbose' || usageVerbose === 'true' },\n\t}\n}\n","import { statSync } from 'node:fs'\n\nimport { $ } from 'execa'\n\nfunction lines(value: string): string[] {\n\treturn value\n\t\t.split('\\n')\n\t\t.map((line) => line.trim())\n\t\t.filter(Boolean)\n}\n\nexport async function collectGarbage(): Promise<number> {\n\t// Compose directory labels describe the daemon host, not a remote client.\n\tconst host = process.env.DOCKER_HOST\n\n\tif (host && !host.startsWith('unix://')) {\n\t\tthrow new Error(\n\t\t\t`gc: refusing to run against a remote Docker daemon: ${host}`,\n\t\t)\n\t}\n\n\tconst { stdout } =\n\t\tawait $`docker ps -a --format ${'{{.Label \"com.docker.compose.project\"}}'}`\n\n\tconst projects = [...new Set(lines(stdout))].sort()\n\tlet removed = 0\n\tlet failed = 0\n\n\tfor (const project of projects) {\n\t\tconst result =\n\t\t\tawait $`docker ps -a --filter ${`label=com.docker.compose.project=${project}`} --format ${'{{.Label \"com.docker.compose.project.working_dir\"}}'}`\n\n\t\tconst directory = lines(result.stdout)[0]\n\n\t\tif (\n\t\t\t!directory ||\n\t\t\tstatSync(directory, { throwIfNoEntry: false })?.isDirectory()\n\t\t) {\n\t\t\tcontinue\n\t\t}\n\n\t\tconsole.log(`Removing orphaned stack: ${project} (${directory})`)\n\n\t\t// Compose interpolates the current config even for down on a lost workspace.\n\t\tconst down = await $({\n\t\t\tenv: { DOCKER_SUBNET: '10.255.255.0/24' },\n\t\t\tstdio: 'inherit',\n\t\t\treject: false,\n\t\t})`docker compose -p ${project} down --remove-orphans`\n\n\t\tif (down.exitCode === 0) {\n\t\t\tremoved++\n\t\t} else {\n\t\t\tconsole.error(`Failed to remove: ${project}`)\n\t\t\tfailed++\n\t\t}\n\t}\n\n\t// Docker 29 exposes network labels as a map and omits working_dir.\n\tconst networks =\n\t\tawait $`docker network ls --filter label=com.docker.compose.project --format ${'{{.Name}}'}`\n\n\tfor (const network of lines(networks.stdout)) {\n\t\tconst label =\n\t\t\tawait $`docker network inspect ${network} --format ${'{{index .Labels \"com.docker.compose.project\"}}'}`\n\n\t\tconst project = label.stdout.trim()\n\n\t\tif (!project) continue\n\n\t\tconst containers =\n\t\t\tawait $`docker ps -a --filter ${`label=com.docker.compose.project=${project}`} --format ${'{{.ID}}'}`\n\n\t\tif (containers.stdout.trim()) continue\n\n\t\tconsole.log(`Removing orphaned network: ${network} (${project})`)\n\n\t\tconst result = await $({\n\t\t\tstdio: 'inherit',\n\t\t\treject: false,\n\t\t})`docker network rm ${network}`\n\n\t\tif (result.exitCode !== 0) {\n\t\t\tconsole.error(`Failed to remove: ${network}`)\n\t\t\tfailed++\n\t\t}\n\t}\n\n\tif (failed > 0) throw new Error(`gc: ${failed} removal(s) failed`)\n\n\treturn removed\n}\n","import { setTimeout } from 'node:timers/promises'\n\nimport { $ } from 'execa'\n\nexport async function verifyApp(\n\turl: string,\n\trestart: (() => Promise<void>) | undefined,\n\t{ attempts = 30, interval = 1_000 } = {},\n): Promise<void> {\n\tasync function poll(): Promise<boolean> {\n\t\tfor (let attempt = 0; attempt < attempts; attempt++) {\n\t\t\tconst result = await $({\n\t\t\t\treject: false,\n\t\t\t})`curl -skf --max-time 5 -o /dev/null ${url}`\n\n\t\t\tif (result.exitCode === 0) return true\n\n\t\t\tif (attempt + 1 < attempts) await setTimeout(interval)\n\t\t}\n\n\t\treturn false\n\t}\n\n\tif (await poll()) return\n\n\t// Pitchfork can report a stale daemon as started without a fresh ready probe.\n\tif (restart) {\n\t\tawait restart()\n\n\t\tif (await poll()) return\n\t}\n\n\tthrow new Error(`App did not answer at ${url}`)\n}\n","import { randomBytes } from 'node:crypto'\nimport { existsSync, readFileSync, realpathSync, writeFileSync } from 'node:fs'\nimport { basename } from 'node:path'\n\nimport { execaSync } from 'execa'\n\nconst MASK_64 = (1n << 64n) - 1n\n\nfunction rotateLeft(value: bigint, bits: bigint): bigint {\n\treturn ((value << bits) | (value >> (64n - bits))) & MASK_64\n}\n\ntype SipState = [bigint, bigint, bigint, bigint]\n\nfunction sipRound(state: SipState): void {\n\tstate[0] = (state[0] + state[1]) & MASK_64\n\tstate[1] = rotateLeft(state[1], 13n) ^ state[0]\n\tstate[0] = rotateLeft(state[0], 32n)\n\tstate[2] = (state[2] + state[3]) & MASK_64\n\tstate[3] = rotateLeft(state[3], 16n) ^ state[2]\n\tstate[0] = (state[0] + state[3]) & MASK_64\n\tstate[3] = rotateLeft(state[3], 21n) ^ state[0]\n\tstate[2] = (state[2] + state[1]) & MASK_64\n\tstate[1] = rotateLeft(state[1], 17n) ^ state[2]\n\tstate[2] = rotateLeft(state[2], 32n)\n}\n\n// Worktrunk uses Rust's DefaultHasher (SipHash 1-3 with zero keys).\nexport function worktrunkHash(value: string): bigint {\n\tconst bytes = Buffer.concat([Buffer.from(value), Buffer.from([0xff])])\n\n\tconst state: SipState = [\n\t\t0x736f6d6570736575n,\n\t\t0x646f72616e646f6dn,\n\t\t0x6c7967656e657261n,\n\t\t0x7465646279746573n,\n\t]\n\n\tlet offset = 0\n\n\twhile (offset + 8 <= bytes.length) {\n\t\tconst message = bytes.readBigUInt64LE(offset)\n\t\tstate[3] ^= message\n\t\tsipRound(state)\n\t\tstate[0] ^= message\n\t\toffset += 8\n\t}\n\n\tlet tail = (BigInt(bytes.length) << 56n) & MASK_64\n\n\tfor (let index = offset; index < bytes.length; index++) {\n\t\ttail |= BigInt(bytes.readUInt8(index)) << BigInt((index - offset) * 8)\n\t}\n\n\tstate[3] ^= tail\n\tsipRound(state)\n\tstate[0] ^= tail\n\tstate[2] ^= 0xffn\n\n\tfor (let round = 0; round < 3; round++) sipRound(state)\n\n\treturn state[0] ^ state[1] ^ state[2] ^ state[3]\n}\n\nexport function hashPort(value: string): number {\n\treturn 10_000 + Number(worktrunkHash(value) % 10_000n)\n}\n\nfunction shortHash(value: string): string {\n\tconst characters = '0123456789abcdefghijklmnopqrstuvwxyz'\n\tconst hash = worktrunkHash(value)\n\n\treturn [hash % 36n, (hash / 36n) % 36n, (hash / 1296n) % 36n]\n\t\t.map((index) => characters[Number(index)])\n\t\t.join('')\n}\n\nexport function sanitizeDatabaseName(value: string): string {\n\tlet result = value\n\t\t.replace(/[A-Z]/g, (character) => character.toLowerCase())\n\t\t.replace(/[^a-z0-9]+/g, '_')\n\t\t.replace(/^_+/, '')\n\n\tif (!result) result = 'workspace'\n\n\tif (!/^[a-z]/.test(result)) result = `w_${result}`\n\tresult = result.slice(0, 44)\n\n\tif (!result.endsWith('_')) result += '_'\n\n\treturn `${result}${shortHash(value)}`\n}\n\nfunction run(command: string, args: string[]): string {\n\treturn execaSync(command, args, {\n\t\tstdio: ['ignore', 'pipe', 'ignore'],\n\t}).stdout.trim()\n}\n\ninterface WorkspaceInfo {\n\tbranch: string\n\tworktree: string\n}\n\nfunction detectWorkspace(): WorkspaceInfo {\n\ttry {\n\t\tconst root = realpathSync(run('jj', ['workspace', 'root']))\n\n\t\tconst names = run('jj', [\n\t\t\t'workspace',\n\t\t\t'list',\n\t\t\t'-T',\n\t\t\t'self.name() ++ \"\\\\n\"',\n\t\t]).split('\\n')\n\n\t\tconst branch = names.find((name) => {\n\t\t\ttry {\n\t\t\t\treturn (\n\t\t\t\t\trealpathSync(run('jj', ['workspace', 'root', '--name', name])) ===\n\t\t\t\t\troot\n\t\t\t\t)\n\t\t\t} catch {\n\t\t\t\treturn false\n\t\t\t}\n\t\t})\n\n\t\tif (!branch) throw new Error('Could not identify the current jj workspace')\n\n\t\treturn { branch, worktree: basename(root) }\n\t} catch (error) {\n\t\tif (error instanceof Error && error.message.startsWith('Could not')) {\n\t\t\tthrow error\n\t\t}\n\t}\n\n\ttry {\n\t\tconst root = realpathSync(run('git', ['rev-parse', '--show-toplevel']))\n\t\tconst branch = run('git', ['branch', '--show-current']) || basename(root)\n\n\t\treturn { branch, worktree: basename(root) }\n\t} catch {\n\t\tthrow new Error('Run setup from inside a jj workspace or Git worktree')\n\t}\n}\n\nconst POSTGRES_USER = 'app_user'\n\nconst POSTGRES_PASSWORD = 'app_dev'\n\nconst AWS_ACCESS_KEY_ID = 'app_minio'\n\nconst AWS_SECRET_ACCESS_KEY = 'app_minio_secret'\n\nconst ENV_SPEC_HEADER = ['# ---', '# @defaultSensitive=false', '# ---', '']\n\nexport function updateEnvFile(\n\tpath: string,\n\tgroups: Record<string, string>[],\n\treplace = false,\n): void {\n\tconst updates: Record<string, string> = Object.assign({}, ...groups)\n\n\tconst lines =\n\t\t!replace && existsSync(path)\n\t\t\t? readFileSync(path, 'utf8').split(/\\r?\\n/)\n\t\t\t: []\n\n\tconst specIndex = lines.findIndex((line) =>\n\t\tline.includes('@defaultSensitive'),\n\t)\n\n\tif (specIndex === -1) {\n\t\tlines.unshift(...ENV_SPEC_HEADER)\n\t} else {\n\t\tif (lines[specIndex + 1]?.trim() !== '# ---') {\n\t\t\tlines.splice(specIndex + 1, 0, '# ---', '')\n\t\t}\n\n\t\tif (specIndex === 0 || lines[specIndex - 1]?.trim() !== '# ---') {\n\t\t\tlines.splice(specIndex, 0, '# ---')\n\t\t}\n\t}\n\n\tconst remaining = new Set(Object.keys(updates))\n\n\tfor (let index = 0; index < lines.length; index++) {\n\t\tconst key = lines[index]?.match(/^\\s*([^#=\\s]+)\\s*=/)?.[1]\n\n\t\tif (!key || !(key in updates)) continue\n\n\t\tlines[index] = `${key}=\"${updates[key]!}\"`\n\t\tremaining.delete(key)\n\t}\n\n\twhile (lines.at(-1) === '') lines.pop()\n\n\tfor (const group of groups) {\n\t\tconst pending = Object.keys(group).filter((key) => remaining.has(key))\n\n\t\tif (pending.length === 0) continue\n\n\t\tif (lines.length > 0) lines.push('')\n\n\t\tfor (const key of pending) lines.push(`${key}=\"${updates[key]!}\"`)\n\t}\n\n\twriteFileSync(path, `${lines.join('\\n')}\\n`)\n}\n\n// Vite/Nitro opens several sockets, so Pitchfork needs an explicit app port.\n// Workspace ports stay local to avoid conflicts across checkouts.\nfunction setDaemonPort(appPort: number): void {\n\tconst base = 'pitchfork.toml'\n\n\tif (!existsSync(base)) return\n\n\tconst contents = readFileSync(base, 'utf8').replace(/^port = \\d+\\n/m, '')\n\n\twriteFileSync(\n\t\t'pitchfork.local.toml',\n\t\t`${contents.trimEnd()}\\nport = ${appPort}\\n`,\n\t)\n}\n\nexport function readEnvFile(path: string): Record<string, string> {\n\tif (!existsSync(path)) return {}\n\n\treturn Object.fromEntries(\n\t\treadFileSync(path, 'utf8')\n\t\t\t.split(/\\r?\\n/)\n\t\t\t.flatMap((line) => {\n\t\t\t\tconst match = line.match(\n\t\t\t\t\t/^\\s*([^#=\\s]+)\\s*=\\s*(?:\"([^\"]*)\"|'([^']*)'|([^#]*?))\\s*(?:#.*)?$/,\n\t\t\t\t)\n\n\t\t\t\treturn match\n\t\t\t\t\t? [[match[1]!, match[2] ?? match[3] ?? match[4] ?? '']]\n\t\t\t\t\t: []\n\t\t\t}),\n\t)\n}\n\nfunction defaultWorkspaceRoot(): string {\n\ttry {\n\t\treturn realpathSync(run('jj', ['workspace', 'root', '--name', 'default']))\n\t} catch {\n\t\t// not a jj repo — the current directory is the root\n\t\treturn realpathSync('.')\n\t}\n}\n\nfunction pitchfork(args: string[]): string {\n\treturn execaSync('pitchfork', args, {\n\t\tstdio: ['ignore', 'pipe', 'ignore'],\n\t\ttimeout: 10_000,\n\t}).stdout.trim()\n}\n\nfunction pitchforkAvailable(): boolean {\n\ttry {\n\t\tpitchfork(['list'])\n\n\t\treturn true\n\t} catch {\n\t\treturn false\n\t}\n}\n\n// The proxy TLD (settings proxy.tld) decides where slugs live. A custom TLD\n// with a public suffix (e.g. lvh.example.com) makes slug URLs registrable as\n// OAuth redirect URIs; the default 'localhost' TLD is not registrable.\nfunction proxyTld(): string {\n\ttry {\n\t\treturn pitchfork(['settings', 'get', 'proxy.tld']) || 'localhost'\n\t} catch {\n\t\treturn 'localhost'\n\t}\n}\n\nexport function slugify(value: string): string {\n\tconst slug = value\n\t\t.toLowerCase()\n\t\t.replaceAll(/[^a-z0-9-]+/g, '-')\n\t\t.replaceAll(/-+/g, '-')\n\t\t.replace(/^-+|-+$/g, '')\n\n\tif (!slug) return 'workspace'\n\n\tif (slug.length <= 63) return slug\n\n\tconst suffix = worktrunkHash(value).toString(36).padStart(8, '0').slice(0, 8)\n\n\treturn `${slug.slice(0, 63 - suffix.length - 1).replace(/-+$/, '')}-${suffix}`\n}\n\n// Registers a stable https://<slug>.<tld> URL for the project. Only the\n// default workspace registers; other jj workspaces are reached via\n// https://<workspace>.<slug>.<tld> through `proxy.worktree` auto-discovery.\n// Best effort: pitchfork is a local convenience, never a bootstrap blocker.\n// `proxy trust` needs sudo, so it stays a one-time manual step.\nfunction registerProxySlug(mainRoot: string): string {\n\tconst slug = slugify(basename(mainRoot))\n\n\tif (realpathSync('.') === mainRoot) {\n\t\tregisterSlug(slug, mainRoot)\n\t}\n\n\treturn slug\n}\n\n// Worktree URLs must be single-level: the TLS edge serves a wildcard cert\n// covering only *.<tld>, so pitchfork's nested <workspace>.<slug>.<tld>\n// hostnames fail the TLS handshake behind it. Flattening to <slug>-<workspace>\n// keeps one label under the wildcard. Registered against this workspace's\n// directory so the proxy routes the slug to its own daemon.\nfunction registerWorktreeSlug(slug: string): void {\n\tregisterSlug(slug, realpathSync('.'))\n}\n\nfunction registerSlug(slug: string, dir: string): void {\n\ttry {\n\t\tpitchfork(['settings', 'set', 'proxy.enable', 'true', '--global'])\n\t\tpitchfork(['proxy', 'add', slug, '--daemon', 'dev', '--dir', dir])\n\t} catch {\n\t\t// pitchfork unavailable — skip registration\n\t}\n}\n\n// Ports are stable once assigned: only regenerate when the workspace file\n// is absent or belongs to another worktree, whose cloned copy must not keep\n// this workspace's ports. Re-running setup must not move this workspace's\n// existing database or registered OAuth redirect URIs out from under it.\n// The whole file is owned by setup and fully rewritten when foreign; human\n// values live in .env.development.local, where setup only adds a missing\n// BETTER_AUTH_SECRET.\nfunction existingPorts(worktree: string): Record<string, string> {\n\tconst entries = readEnvFile('.env.workspace.local')\n\n\tif (entries['WORKTREE_NAME'] !== worktree) return {}\n\n\treturn entries\n}\n\nexport function setupWorkspace() {\n\t// Never honor WORKTREE_NAME/WORKTREE_BRANCH from the environment:\n\t// nothing legitimate sets them (wt passes template vars, not env), and\n\t// inherited stale values from another workspace must not steer setup.\n\tconst { branch, worktree } = detectWorkspace()\n\tconst compose = sanitizeDatabaseName(worktree)\n\tconst existing = existingPorts(worktree)\n\n\tconst isForeign =\n\t\texistsSync('.env.workspace.local') &&\n\t\treadEnvFile('.env.workspace.local')['WORKTREE_NAME'] !== worktree\n\n\tconst database = existing['POSTGRES_DB'] ?? sanitizeDatabaseName(branch)\n\tconst mainRoot = defaultWorkspaceRoot()\n\t// Repo name in every hash input: branch names (notably the root\n\t// workspace) repeat across repos, which collided their derived ports.\n\tconst repo = basename(mainRoot)\n\tconst appPort = Number(existing['APP_PORT']) || hashPort(`${repo}:${branch}`)\n\n\tconst postgresPort =\n\t\tNumber(existing['POSTGRES_PORT']) || hashPort(`db-${repo}:${branch}`)\n\n\tconst minioPort =\n\t\tNumber(existing['MINIO_PORT']) || hashPort(`minio-${repo}:${branch}`)\n\n\tconst minioConsolePort =\n\t\tNumber(existing['MINIO_CONSOLE_PORT']) ||\n\t\thashPort(`minio-console-${repo}:${branch}`)\n\n\tconst tld = proxyTld()\n\tconst proxySlug = registerProxySlug(mainRoot)\n\tconst worktreeLabel = slugify(worktree)\n\tconst isRoot = realpathSync('.') === mainRoot\n\n\tconst slug =\n\t\tisRoot || worktreeLabel === proxySlug\n\t\t\t? proxySlug\n\t\t\t: slugify(`${proxySlug}-${worktreeLabel}`)\n\n\tif (!isRoot && slug !== proxySlug) {\n\t\tregisterWorktreeSlug(slug)\n\t}\n\n\tconst proxyHost = `${slug}.${tld}`\n\n\tconst proxyUp = pitchforkAvailable()\n\n\tupdateEnvFile(\n\t\t'.env.workspace.local',\n\t\t[\n\t\t\t{\n\t\t\t\tAPP_PORT: String(appPort),\n\t\t\t\tBASE_URL: proxyUp\n\t\t\t\t\t? `https://${proxyHost}`\n\t\t\t\t\t: `http://localhost:${appPort}`,\n\t\t\t},\n\t\t\t{\n\t\t\t\tPOSTGRES_PORT: String(postgresPort),\n\t\t\t\tPOSTGRES_DB: database,\n\t\t\t\tPOSTGRES_USER,\n\t\t\t\tPOSTGRES_PASSWORD,\n\t\t\t\tDATABASE_URL:\n\t\t\t\t\t'postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:${POSTGRES_PORT}/${POSTGRES_DB}',\n\t\t\t},\n\t\t\t{\n\t\t\t\tMINIO_PORT: String(minioPort),\n\t\t\t\tMINIO_CONSOLE_PORT: String(minioConsolePort),\n\t\t\t},\n\t\t\t{\n\t\t\t\tAWS_ENDPOINT_URL: 'http://localhost:${MINIO_PORT}',\n\t\t\t\tAWS_ACCESS_KEY_ID,\n\t\t\t\tAWS_SECRET_ACCESS_KEY,\n\t\t\t\tAWS_S3_BUCKET_NAME: 'app',\n\t\t\t},\n\t\t\t{\n\t\t\t\tWORKTREE_NAME: worktree,\n\t\t\t\tCOMPOSE_PROJECT_NAME: compose,\n\t\t\t},\n\t\t],\n\t\tisForeign,\n\t)\n\n\tsetDaemonPort(appPort)\n\n\tconst summary = [\n\t\t`app: http://localhost:${appPort}`,\n\t\t`postgres: localhost:${postgresPort}/${database}`,\n\t\t`minio: http://localhost:${minioPort}`,\n\t]\n\n\tif (proxyUp) {\n\t\tsummary.push(`proxy: https://${proxyHost}`)\n\t}\n\n\tif (seedDevLocalFile()) {\n\t\tsummary.push('Seeded a local BETTER_AUTH_SECRET.')\n\t}\n\n\treturn {\n\t\tbaseUrl: proxyUp ? `https://${proxyHost}` : `http://localhost:${appPort}`,\n\t\tsummary,\n\t}\n}\n\n// Adds a BETTER_AUTH_SECRET to .env.development.local unless a local file\n// already sets one.\nfunction seedDevLocalFile(): boolean {\n\tconst path = '.env.development.local'\n\n\tif (readEnvFile(path)['BETTER_AUTH_SECRET']) {\n\t\treturn false\n\t}\n\n\t// Drops an empty BETTER_AUTH_SECRET line: a quoted \"\" would hide the\n\t// .env.local value, and an unquoted one would duplicate the new value.\n\tconst existing = existsSync(path)\n\t\t? readFileSync(path, 'utf8')\n\t\t\t\t.split(/\\r?\\n/)\n\t\t\t\t.filter((line) => !/^\\s*BETTER_AUTH_SECRET\\s*=/.test(line))\n\t\t\t\t.join('\\n')\n\t\t\t\t.trimEnd()\n\t\t: ''\n\n\tif (readEnvFile('.env.local')['BETTER_AUTH_SECRET']) {\n\t\tif (existsSync(path)) {\n\t\t\twriteFileSync(path, existing ? `${existing}\\n` : '')\n\t\t}\n\n\t\treturn false\n\t}\n\n\twriteFileSync(\n\t\tpath,\n\t\t[\n\t\t\texisting ||\n\t\t\t\t'# Local overrides; setup adds missing defaults and leaves existing values alone.',\n\t\t\t`BETTER_AUTH_SECRET=\"${randomBytes(32).toString('base64url')}\"`,\n\t\t\t'',\n\t\t].join('\\n'),\n\t)\n\n\treturn true\n}\n","import { spawnSync } from 'node:child_process'\nimport { existsSync } from 'node:fs'\n\nimport { $ } from 'execa'\n\nimport type { BootstrapOptions } from './args.ts'\nimport { collectGarbage } from './gc.ts'\nimport { verifyApp } from './readiness.ts'\nimport { setupWorkspace } from './workspace.ts'\n\ntype Command = [string, ...string[]]\n\n/**\n * Configures the checkout in the current directory and starts the app. The\n * caller installs tools and packages and prints the banner first, because this\n * code ships in a package that a fresh clone does not have yet.\n */\nexport async function bootstrap({ verbose }: BootstrapOptions): Promise<void> {\n\t// Headless hosts lack a keyring/TPM; suppress Varlock backend probes.\n\tprocess.env._VARLOCK_FORCE_FILE_ENCRYPTION_FALLBACK = '1'\n\n\tconst gum = spawnSync('gum', ['--version'], { stdio: 'ignore' })\n\n\tif (gum.error || gum.status !== 0) {\n\t\tthrow new Error('gum is required; run mise install before bootstrap')\n\t}\n\n\tconst inherited = $({ stdio: 'inherit' })\n\n\tasync function complete(title: string): Promise<void> {\n\t\tawait inherited`gum style --foreground 82 ${` ✓ ${title}`}`\n\t}\n\n\tasync function step(title: string, command: Command): Promise<void> {\n\t\tif (verbose) {\n\t\t\tconsole.log(` ${title}`)\n\t\t\tawait inherited`${command}`\n\t\t} else {\n\t\t\tawait inherited`gum spin --show-error --title ${` ${title}...`} -- ${command}`\n\t\t}\n\n\t\tawait complete(title)\n\t}\n\n\tconst { baseUrl, summary } = setupWorkspace()\n\tawait complete('Configure workspace')\n\tconst removed = await collectGarbage()\n\tawait complete(`Remove orphaned compose stacks (${removed} removed)`)\n\n\t// Agent mode redacts secrets and rejects prompts in headless sessions.\n\tawait step('Validate env with varlock', [\n\t\t'vp',\n\t\t'exec',\n\t\t'varlock',\n\t\t'load',\n\t\t'--agent',\n\t\t'--format',\n\t\t'pretty',\n\t])\n\tawait step('Start Docker services', ['vp', 'run', 'compose:up'])\n\tawait step('Apply database migrations', ['vp', 'run', 'db:migrate'])\n\n\tconst pitchfork = await $({ reject: false })`pitchfork --version`\n\tconst hasDaemon = existsSync('pitchfork.toml') && pitchfork.exitCode === 0\n\n\tif (hasDaemon) await step('Start dev daemon', ['pitchfork', 'start', 'dev'])\n\n\tawait verifyApp(\n\t\tbaseUrl,\n\t\thasDaemon\n\t\t\t? async () => {\n\t\t\t\t\tawait inherited`gum style --foreground 220 ${' App not answering; force-restarting dev daemon'}`\n\t\t\t\t\tawait inherited`pitchfork restart dev --force`\n\t\t\t\t}\n\t\t\t: undefined,\n\t)\n\tawait complete('Verify app responds')\n\n\tconst heading =\n\t\tawait $`gum style --bold --foreground 82 ${'✓ Bootstrap complete'}`\n\n\tconst details = await Promise.all(\n\t\tsummary.map(async (line) => {\n\t\t\tconst result = await $`gum style --foreground 39 ${line}`\n\n\t\t\treturn result.stdout\n\t\t}),\n\t)\n\n\tawait inherited`gum style --border rounded --border-foreground 82 --padding ${'0 3'} --margin ${'1 0'} ${heading.stdout} ${details}`\n}\n","#!/usr/bin/env node\nimport { parseBootstrapArgs, USAGE } from './args.ts'\nimport { bootstrap } from './bootstrap.ts'\n\nconst [command, ...args] = process.argv.slice(2)\n\nif (command !== 'bootstrap') {\n\tconsole.error(USAGE)\n\tprocess.exitCode = 2\n} else {\n\tconst parsed = parseBootstrapArgs(args, process.env['usage_verbose'])\n\n\tif (parsed.kind === 'help') {\n\t\tconsole.log(USAGE)\n\t} else if (parsed.kind === 'error') {\n\t\tconsole.error(parsed.message)\n\t\tprocess.exitCode = 2\n\t} else {\n\t\ttry {\n\t\t\tawait bootstrap(parsed.options)\n\t\t} catch (error) {\n\t\t\tconsole.error(\n\t\t\t\t`✗ Bootstrap failed: ${error instanceof Error ? error.message : String(error)}`,\n\t\t\t)\n\t\t\tprocess.exitCode = 1\n\t\t}\n\t}\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { expect } from "vite-plus/test";
|
|
2
|
+
import { page, server } from "vite-plus/test/browser";
|
|
3
|
+
//#region src/vitest/browser.ts
|
|
4
|
+
const mobileWidth = 768;
|
|
5
|
+
/** Waits for fonts and two animation frames, so layout and paint settle. */
|
|
6
|
+
async function waitForPageIdle() {
|
|
7
|
+
await document.fonts.ready;
|
|
8
|
+
await new Promise((resolve) => requestAnimationFrame(() => resolve()));
|
|
9
|
+
await new Promise((resolve) => requestAnimationFrame(() => resolve()));
|
|
10
|
+
}
|
|
11
|
+
async function resizeViewport(width, height) {
|
|
12
|
+
await server.commands.resizeBrowserViewport(width, height);
|
|
13
|
+
await page.viewport(width, height);
|
|
14
|
+
}
|
|
15
|
+
function targetElement(target) {
|
|
16
|
+
return target instanceof HTMLElement ? target : target.element();
|
|
17
|
+
}
|
|
18
|
+
async function growToFullHeight(element) {
|
|
19
|
+
const width = window.innerWidth;
|
|
20
|
+
const minHeight = window.innerHeight;
|
|
21
|
+
let height = 0;
|
|
22
|
+
for (let attempt = 0; attempt < 5; attempt++) {
|
|
23
|
+
await resizeViewport(width, Math.max(minHeight, Math.ceil(element.scrollHeight)));
|
|
24
|
+
await waitForPageIdle();
|
|
25
|
+
const next = Math.ceil(element.scrollHeight);
|
|
26
|
+
if (next === height) break;
|
|
27
|
+
height = next;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
function viewportClip(element) {
|
|
31
|
+
const rect = element.getBoundingClientRect();
|
|
32
|
+
if (rect.top >= 0 && rect.left >= 0 && rect.bottom <= window.innerHeight && rect.right <= window.innerWidth) return;
|
|
33
|
+
const top = Math.max(rect.top, 0);
|
|
34
|
+
const left = Math.max(rect.left, 0);
|
|
35
|
+
const frame = document.createElement("div");
|
|
36
|
+
Object.assign(frame.style, {
|
|
37
|
+
position: "fixed",
|
|
38
|
+
top: `${top}px`,
|
|
39
|
+
left: `${left}px`,
|
|
40
|
+
width: `${Math.min(rect.right, window.innerWidth) - left}px`,
|
|
41
|
+
height: `${Math.min(rect.bottom, window.innerHeight) - top}px`,
|
|
42
|
+
pointerEvents: "none"
|
|
43
|
+
});
|
|
44
|
+
return document.body.appendChild(frame);
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Compares the target with its baseline at zero tolerance. The capture stops
|
|
48
|
+
* at the viewport unless `fullHeight` is set. Each browser instance pins its
|
|
49
|
+
* own viewport. Mobile baselines get a `-mobile` suffix.
|
|
50
|
+
*/
|
|
51
|
+
async function expectScreenshot(target, name, options = {}) {
|
|
52
|
+
const element = targetElement(target);
|
|
53
|
+
const suffix = window.innerWidth < mobileWidth ? "-mobile" : "";
|
|
54
|
+
const viewport = {
|
|
55
|
+
width: window.innerWidth,
|
|
56
|
+
height: window.innerHeight
|
|
57
|
+
};
|
|
58
|
+
let clip;
|
|
59
|
+
try {
|
|
60
|
+
if (options.fullHeight) await growToFullHeight(element);
|
|
61
|
+
await expect.poll(() => Array.from(element.querySelectorAll("img")).every((image) => image.complete)).toBe(true);
|
|
62
|
+
await server.commands.resetScreenshotPointer();
|
|
63
|
+
await waitForPageIdle();
|
|
64
|
+
if (!options.fullHeight) clip = viewportClip(element);
|
|
65
|
+
await expect.element(clip ?? element).toMatchScreenshot(`${name}${suffix}.png`, {
|
|
66
|
+
screenshotOptions: {
|
|
67
|
+
animations: "disabled",
|
|
68
|
+
caret: "hide",
|
|
69
|
+
scale: "css"
|
|
70
|
+
},
|
|
71
|
+
comparatorOptions: {
|
|
72
|
+
allowedMismatchedPixelRatio: 0,
|
|
73
|
+
allowedMismatchedPixels: 0
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
} finally {
|
|
77
|
+
clip?.remove();
|
|
78
|
+
if (options.fullHeight) await resizeViewport(viewport.width, viewport.height);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
//#endregion
|
|
82
|
+
export { waitForPageIdle as n, expectScreenshot as t };
|
|
83
|
+
|
|
84
|
+
//# sourceMappingURL=browser-CSDVICWx.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"browser-CSDVICWx.mjs","names":[],"sources":["../src/vitest/browser.ts"],"sourcesContent":["import { expect } from 'vite-plus/test'\nimport { page, server, type Locator } from 'vite-plus/test/browser'\n\ndeclare module 'vitest/browser' {\n\tinterface BrowserCommands {\n\t\tresetScreenshotPointer: () => Promise<void>\n\t\tresizeBrowserViewport: (width: number, height: number) => Promise<void>\n\t}\n}\n\nexport interface ScreenshotOptions {\n\t/** Grows the viewport to the full target height before the capture. */\n\tfullHeight?: boolean\n}\n\n// Matches Tailwind's `md` breakpoint, which the mobile instance falls below.\nconst mobileWidth = 768\n\n/** Waits for fonts and two animation frames, so layout and paint settle. */\nexport async function waitForPageIdle() {\n\tawait document.fonts.ready\n\tawait new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))\n\tawait new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))\n}\n\n// Vitest scales its iframe to fit the browser window. Resize both so tall\n// captures stay at one CSS pixel per image pixel.\nasync function resizeViewport(width: number, height: number) {\n\tawait server.commands.resizeBrowserViewport(width, height)\n\tawait page.viewport(width, height)\n}\n\nfunction targetElement(target: Locator | HTMLElement) {\n\treturn target instanceof HTMLElement ? target : target.element()\n}\n\nasync function growToFullHeight(element: Element) {\n\tconst width = window.innerWidth\n\tconst minHeight = window.innerHeight\n\tlet height = 0\n\n\t// Viewport-relative content can keep growing after each resize.\n\tfor (let attempt = 0; attempt < 5; attempt++) {\n\t\tawait resizeViewport(\n\t\t\twidth,\n\t\t\tMath.max(minHeight, Math.ceil(element.scrollHeight)),\n\t\t)\n\t\tawait waitForPageIdle()\n\n\t\tconst next = Math.ceil(element.scrollHeight)\n\n\t\tif (next === height) break\n\n\t\theight = next\n\t}\n}\n\n// Vitest renders tests in an iframe that paints only the viewport, so a\n// capture past it comes out white. Playwright captures the page pixels under\n// an element, so a transparent fixed frame clips the capture to the viewport.\nfunction viewportClip(element: Element) {\n\tconst rect = element.getBoundingClientRect()\n\n\tif (\n\t\trect.top >= 0 &&\n\t\trect.left >= 0 &&\n\t\trect.bottom <= window.innerHeight &&\n\t\trect.right <= window.innerWidth\n\t) {\n\t\treturn undefined\n\t}\n\n\tconst top = Math.max(rect.top, 0)\n\tconst left = Math.max(rect.left, 0)\n\tconst frame = document.createElement('div')\n\n\tObject.assign(frame.style, {\n\t\tposition: 'fixed',\n\t\ttop: `${top}px`,\n\t\tleft: `${left}px`,\n\t\twidth: `${Math.min(rect.right, window.innerWidth) - left}px`,\n\t\theight: `${Math.min(rect.bottom, window.innerHeight) - top}px`,\n\t\tpointerEvents: 'none',\n\t})\n\n\treturn document.body.appendChild(frame)\n}\n\n/**\n * Compares the target with its baseline at zero tolerance. The capture stops\n * at the viewport unless `fullHeight` is set. Each browser instance pins its\n * own viewport. Mobile baselines get a `-mobile` suffix.\n */\nexport async function expectScreenshot(\n\ttarget: Locator | HTMLElement,\n\tname: string,\n\toptions: ScreenshotOptions = {},\n) {\n\tconst element = targetElement(target)\n\tconst suffix = window.innerWidth < mobileWidth ? '-mobile' : ''\n\tconst viewport = { width: window.innerWidth, height: window.innerHeight }\n\tlet clip: HTMLElement | undefined\n\n\ttry {\n\t\tif (options.fullHeight) await growToFullHeight(element)\n\n\t\tawait expect\n\t\t\t.poll(() =>\n\t\t\t\tArray.from(element.querySelectorAll('img')).every(\n\t\t\t\t\t(image) => image.complete,\n\t\t\t\t),\n\t\t\t)\n\t\t\t.toBe(true)\n\t\tawait server.commands.resetScreenshotPointer()\n\t\tawait waitForPageIdle()\n\n\t\tif (!options.fullHeight) clip = viewportClip(element)\n\n\t\tawait expect\n\t\t\t.element(clip ?? element)\n\t\t\t.toMatchScreenshot(`${name}${suffix}.png`, {\n\t\t\t\tscreenshotOptions: {\n\t\t\t\t\tanimations: 'disabled',\n\t\t\t\t\tcaret: 'hide',\n\t\t\t\t\tscale: 'css',\n\t\t\t\t},\n\t\t\t\tcomparatorOptions: {\n\t\t\t\t\tallowedMismatchedPixelRatio: 0,\n\t\t\t\t\tallowedMismatchedPixels: 0,\n\t\t\t\t},\n\t\t\t})\n\t} finally {\n\t\tclip?.remove()\n\n\t\tif (options.fullHeight)\n\t\t\tawait resizeViewport(viewport.width, viewport.height)\n\t}\n}\n"],"mappings":";;;AAgBA,MAAM,cAAc;;AAGpB,eAAsB,kBAAkB;CACvC,MAAM,SAAS,MAAM;CACrB,MAAM,IAAI,SAAe,YAAY,4BAA4B,QAAQ,CAAC,CAAC;CAC3E,MAAM,IAAI,SAAe,YAAY,4BAA4B,QAAQ,CAAC,CAAC;AAC5E;AAIA,eAAe,eAAe,OAAe,QAAgB;CAC5D,MAAM,OAAO,SAAS,sBAAsB,OAAO,MAAM;CACzD,MAAM,KAAK,SAAS,OAAO,MAAM;AAClC;AAEA,SAAS,cAAc,QAA+B;CACrD,OAAO,kBAAkB,cAAc,SAAS,OAAO,QAAQ;AAChE;AAEA,eAAe,iBAAiB,SAAkB;CACjD,MAAM,QAAQ,OAAO;CACrB,MAAM,YAAY,OAAO;CACzB,IAAI,SAAS;CAGb,KAAK,IAAI,UAAU,GAAG,UAAU,GAAG,WAAW;EAC7C,MAAM,eACL,OACA,KAAK,IAAI,WAAW,KAAK,KAAK,QAAQ,YAAY,CAAC,CACpD;EACA,MAAM,gBAAgB;EAEtB,MAAM,OAAO,KAAK,KAAK,QAAQ,YAAY;EAE3C,IAAI,SAAS,QAAQ;EAErB,SAAS;CACV;AACD;AAKA,SAAS,aAAa,SAAkB;CACvC,MAAM,OAAO,QAAQ,sBAAsB;CAE3C,IACC,KAAK,OAAO,KACZ,KAAK,QAAQ,KACb,KAAK,UAAU,OAAO,eACtB,KAAK,SAAS,OAAO,YAErB;CAGD,MAAM,MAAM,KAAK,IAAI,KAAK,KAAK,CAAC;CAChC,MAAM,OAAO,KAAK,IAAI,KAAK,MAAM,CAAC;CAClC,MAAM,QAAQ,SAAS,cAAc,KAAK;CAE1C,OAAO,OAAO,MAAM,OAAO;EAC1B,UAAU;EACV,KAAK,GAAG,IAAI;EACZ,MAAM,GAAG,KAAK;EACd,OAAO,GAAG,KAAK,IAAI,KAAK,OAAO,OAAO,UAAU,IAAI,KAAK;EACzD,QAAQ,GAAG,KAAK,IAAI,KAAK,QAAQ,OAAO,WAAW,IAAI,IAAI;EAC3D,eAAe;CAChB,CAAC;CAED,OAAO,SAAS,KAAK,YAAY,KAAK;AACvC;;;;;;AAOA,eAAsB,iBACrB,QACA,MACA,UAA6B,CAAC,GAC7B;CACD,MAAM,UAAU,cAAc,MAAM;CACpC,MAAM,SAAS,OAAO,aAAa,cAAc,YAAY;CAC7D,MAAM,WAAW;EAAE,OAAO,OAAO;EAAY,QAAQ,OAAO;CAAY;CACxE,IAAI;CAEJ,IAAI;EACH,IAAI,QAAQ,YAAY,MAAM,iBAAiB,OAAO;EAEtD,MAAM,OACJ,WACA,MAAM,KAAK,QAAQ,iBAAiB,KAAK,CAAC,CAAC,CAAC,OAC1C,UAAU,MAAM,QAClB,CACD,CAAC,CACA,KAAK,IAAI;EACX,MAAM,OAAO,SAAS,uBAAuB;EAC7C,MAAM,gBAAgB;EAEtB,IAAI,CAAC,QAAQ,YAAY,OAAO,aAAa,OAAO;EAEpD,MAAM,OACJ,QAAQ,QAAQ,OAAO,CAAC,CACxB,kBAAkB,GAAG,OAAO,OAAO,OAAO;GAC1C,mBAAmB;IAClB,YAAY;IACZ,OAAO;IACP,OAAO;GACR;GACA,mBAAmB;IAClB,6BAA6B;IAC7B,yBAAyB;GAC1B;EACD,CAAC;CACH,UAAU;EACT,MAAM,OAAO;EAEb,IAAI,QAAQ,YACX,MAAM,eAAe,SAAS,OAAO,SAAS,MAAM;CACtD;AACD"}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { Locator } from "vite-plus/test/browser";
|
|
2
|
+
//#region src/vitest/browser.d.ts
|
|
3
|
+
declare module 'vitest/browser' {
|
|
4
|
+
interface BrowserCommands {
|
|
5
|
+
resetScreenshotPointer: () => Promise<void>;
|
|
6
|
+
resizeBrowserViewport: (width: number, height: number) => Promise<void>;
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
interface ScreenshotOptions {
|
|
10
|
+
/** Grows the viewport to the full target height before the capture. */
|
|
11
|
+
fullHeight?: boolean;
|
|
12
|
+
}
|
|
13
|
+
/** Waits for fonts and two animation frames, so layout and paint settle. */
|
|
14
|
+
declare function waitForPageIdle(): Promise<void>;
|
|
15
|
+
/**
|
|
16
|
+
* Compares the target with its baseline at zero tolerance. The capture stops
|
|
17
|
+
* at the viewport unless `fullHeight` is set. Each browser instance pins its
|
|
18
|
+
* own viewport. Mobile baselines get a `-mobile` suffix.
|
|
19
|
+
*/
|
|
20
|
+
declare function expectScreenshot(target: Locator | HTMLElement, name: string, options?: ScreenshotOptions): Promise<void>;
|
|
21
|
+
//#endregion
|
|
22
|
+
export { expectScreenshot as n, waitForPageIdle as r, ScreenshotOptions as t };
|
|
23
|
+
//# sourceMappingURL=browser-brqdbdWe.d.mts.map
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { Plugin, TestProjectInlineConfiguration } from "vite-plus";
|
|
2
|
+
//#region src/vitest/index.d.ts
|
|
3
|
+
type ProjectTest = NonNullable<TestProjectInlineConfiguration['test']>;
|
|
4
|
+
/** Vitest `test` options for one project. Arrays replace the toolkit values. */
|
|
5
|
+
type ProjectOptions = ProjectTest;
|
|
6
|
+
interface TestProject {
|
|
7
|
+
extends: true;
|
|
8
|
+
plugins?: Plugin[];
|
|
9
|
+
test: ProjectOptions;
|
|
10
|
+
}
|
|
11
|
+
declare const viewports: {
|
|
12
|
+
desktop: {
|
|
13
|
+
width: number;
|
|
14
|
+
height: number;
|
|
15
|
+
};
|
|
16
|
+
mobile: {
|
|
17
|
+
width: number;
|
|
18
|
+
height: number;
|
|
19
|
+
};
|
|
20
|
+
};
|
|
21
|
+
declare function unitProject(options?: ProjectOptions): TestProject;
|
|
22
|
+
declare function serverProject(options?: ProjectOptions): TestProject;
|
|
23
|
+
/**
|
|
24
|
+
* The browser project: Chromium through Playwright, one instance per
|
|
25
|
+
* viewport. The instance name is the baseline suffix for mobile captures.
|
|
26
|
+
*/
|
|
27
|
+
declare function browserProject(options?: ProjectOptions): Promise<TestProject>;
|
|
28
|
+
//#endregion
|
|
29
|
+
export { unitProject as a, serverProject as i, TestProject as n, viewports as o, browserProject as r, ProjectOptions as t };
|
|
30
|
+
//# sourceMappingURL=index-1oJRRJ46.d.mts.map
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { n as ToolkitConfig } from "../merge-BEomHgbw.mjs";
|
|
2
|
-
import { ProjectOptions } from "../
|
|
2
|
+
import { t as ProjectOptions } from "../index-1oJRRJ46.mjs";
|
|
3
3
|
import { UserConfig } from "vite-plus";
|
|
4
4
|
import tailwindcss from "@tailwindcss/vite";
|
|
5
5
|
import { devtools } from "@tanstack/devtools-vite";
|
|
@@ -1,22 +1,2 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
declare module 'vitest/browser' {
|
|
4
|
-
interface BrowserCommands {
|
|
5
|
-
resetScreenshotPointer: () => Promise<void>;
|
|
6
|
-
resizeBrowserViewport: (width: number, height: number) => Promise<void>;
|
|
7
|
-
}
|
|
8
|
-
}
|
|
9
|
-
export interface ScreenshotOptions {
|
|
10
|
-
/** Grows the viewport to the full target height before the capture. */
|
|
11
|
-
fullHeight?: boolean;
|
|
12
|
-
}
|
|
13
|
-
/** Waits for fonts and two animation frames, so layout and paint settle. */
|
|
14
|
-
export declare function waitForPageIdle(): Promise<void>;
|
|
15
|
-
/**
|
|
16
|
-
* Compares the target with its baseline at zero tolerance. The capture stops
|
|
17
|
-
* at the viewport unless `fullHeight` is set. Each browser instance pins its
|
|
18
|
-
* own viewport. Mobile baselines get a `-mobile` suffix.
|
|
19
|
-
*/
|
|
20
|
-
export declare function expectScreenshot(target: Locator | HTMLElement, name: string, options?: ScreenshotOptions): Promise<void>;
|
|
21
|
-
//#endregion
|
|
22
|
-
//# sourceMappingURL=browser.d.mts.map
|
|
1
|
+
import { n as expectScreenshot, r as waitForPageIdle, t as ScreenshotOptions } from "../browser-brqdbdWe.mjs";
|
|
2
|
+
export { ScreenshotOptions, expectScreenshot, waitForPageIdle };
|
package/dist/vitest/browser.mjs
CHANGED
|
@@ -1,84 +1,2 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { page, server } from "vite-plus/test/browser";
|
|
3
|
-
//#region src/vitest/browser.ts
|
|
4
|
-
const mobileWidth = 768;
|
|
5
|
-
/** Waits for fonts and two animation frames, so layout and paint settle. */
|
|
6
|
-
async function waitForPageIdle() {
|
|
7
|
-
await document.fonts.ready;
|
|
8
|
-
await new Promise((resolve) => requestAnimationFrame(() => resolve()));
|
|
9
|
-
await new Promise((resolve) => requestAnimationFrame(() => resolve()));
|
|
10
|
-
}
|
|
11
|
-
async function resizeViewport(width, height) {
|
|
12
|
-
await server.commands.resizeBrowserViewport(width, height);
|
|
13
|
-
await page.viewport(width, height);
|
|
14
|
-
}
|
|
15
|
-
function targetElement(target) {
|
|
16
|
-
return target instanceof HTMLElement ? target : target.element();
|
|
17
|
-
}
|
|
18
|
-
async function growToFullHeight(element) {
|
|
19
|
-
const width = window.innerWidth;
|
|
20
|
-
const minHeight = window.innerHeight;
|
|
21
|
-
let height = 0;
|
|
22
|
-
for (let attempt = 0; attempt < 5; attempt++) {
|
|
23
|
-
await resizeViewport(width, Math.max(minHeight, Math.ceil(element.scrollHeight)));
|
|
24
|
-
await waitForPageIdle();
|
|
25
|
-
const next = Math.ceil(element.scrollHeight);
|
|
26
|
-
if (next === height) break;
|
|
27
|
-
height = next;
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
function viewportClip(element) {
|
|
31
|
-
const rect = element.getBoundingClientRect();
|
|
32
|
-
if (rect.top >= 0 && rect.left >= 0 && rect.bottom <= window.innerHeight && rect.right <= window.innerWidth) return;
|
|
33
|
-
const top = Math.max(rect.top, 0);
|
|
34
|
-
const left = Math.max(rect.left, 0);
|
|
35
|
-
const frame = document.createElement("div");
|
|
36
|
-
Object.assign(frame.style, {
|
|
37
|
-
position: "fixed",
|
|
38
|
-
top: `${top}px`,
|
|
39
|
-
left: `${left}px`,
|
|
40
|
-
width: `${Math.min(rect.right, window.innerWidth) - left}px`,
|
|
41
|
-
height: `${Math.min(rect.bottom, window.innerHeight) - top}px`,
|
|
42
|
-
pointerEvents: "none"
|
|
43
|
-
});
|
|
44
|
-
return document.body.appendChild(frame);
|
|
45
|
-
}
|
|
46
|
-
/**
|
|
47
|
-
* Compares the target with its baseline at zero tolerance. The capture stops
|
|
48
|
-
* at the viewport unless `fullHeight` is set. Each browser instance pins its
|
|
49
|
-
* own viewport. Mobile baselines get a `-mobile` suffix.
|
|
50
|
-
*/
|
|
51
|
-
async function expectScreenshot(target, name, options = {}) {
|
|
52
|
-
const element = targetElement(target);
|
|
53
|
-
const suffix = window.innerWidth < mobileWidth ? "-mobile" : "";
|
|
54
|
-
const viewport = {
|
|
55
|
-
width: window.innerWidth,
|
|
56
|
-
height: window.innerHeight
|
|
57
|
-
};
|
|
58
|
-
let clip;
|
|
59
|
-
try {
|
|
60
|
-
if (options.fullHeight) await growToFullHeight(element);
|
|
61
|
-
await expect.poll(() => Array.from(element.querySelectorAll("img")).every((image) => image.complete)).toBe(true);
|
|
62
|
-
await server.commands.resetScreenshotPointer();
|
|
63
|
-
await waitForPageIdle();
|
|
64
|
-
if (!options.fullHeight) clip = viewportClip(element);
|
|
65
|
-
await expect.element(clip ?? element).toMatchScreenshot(`${name}${suffix}.png`, {
|
|
66
|
-
screenshotOptions: {
|
|
67
|
-
animations: "disabled",
|
|
68
|
-
caret: "hide",
|
|
69
|
-
scale: "css"
|
|
70
|
-
},
|
|
71
|
-
comparatorOptions: {
|
|
72
|
-
allowedMismatchedPixelRatio: 0,
|
|
73
|
-
allowedMismatchedPixels: 0
|
|
74
|
-
}
|
|
75
|
-
});
|
|
76
|
-
} finally {
|
|
77
|
-
clip?.remove();
|
|
78
|
-
if (options.fullHeight) await resizeViewport(viewport.width, viewport.height);
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
//#endregion
|
|
1
|
+
import { n as waitForPageIdle, t as expectScreenshot } from "../browser-CSDVICWx.mjs";
|
|
82
2
|
export { expectScreenshot, waitForPageIdle };
|
|
83
|
-
|
|
84
|
-
//# sourceMappingURL=browser.mjs.map
|
package/dist/vitest/index.d.mts
CHANGED
|
@@ -1,29 +1,2 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
type ProjectTest = NonNullable<TestProjectInlineConfiguration['test']>;
|
|
4
|
-
/** Vitest `test` options for one project. Arrays replace the toolkit values. */
|
|
5
|
-
export type ProjectOptions = ProjectTest;
|
|
6
|
-
export interface TestProject {
|
|
7
|
-
extends: true;
|
|
8
|
-
plugins?: Plugin[];
|
|
9
|
-
test: ProjectOptions;
|
|
10
|
-
}
|
|
11
|
-
export declare const viewports: {
|
|
12
|
-
desktop: {
|
|
13
|
-
width: number;
|
|
14
|
-
height: number;
|
|
15
|
-
};
|
|
16
|
-
mobile: {
|
|
17
|
-
width: number;
|
|
18
|
-
height: number;
|
|
19
|
-
};
|
|
20
|
-
};
|
|
21
|
-
export declare function unitProject(options?: ProjectOptions): TestProject;
|
|
22
|
-
export declare function serverProject(options?: ProjectOptions): TestProject;
|
|
23
|
-
/**
|
|
24
|
-
* The browser project: Chromium through Playwright, one instance per
|
|
25
|
-
* viewport. The instance name is the baseline suffix for mobile captures.
|
|
26
|
-
*/
|
|
27
|
-
export declare function browserProject(options?: ProjectOptions): Promise<TestProject>;
|
|
28
|
-
//#endregion
|
|
29
|
-
//# sourceMappingURL=index.d.mts.map
|
|
1
|
+
import { a as unitProject, i as serverProject, n as TestProject, o as viewports, r as browserProject, t as ProjectOptions } from "../index-1oJRRJ46.mjs";
|
|
2
|
+
export { ProjectOptions, TestProject, browserProject, serverProject, unitProject, viewports };
|
package/dist/vitest/react.d.mts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ScreenshotOptions } from "
|
|
1
|
+
import { t as ScreenshotOptions } from "../browser-brqdbdWe.mjs";
|
|
2
2
|
import { Locator } from "vite-plus/test/browser";
|
|
3
3
|
import { ComponentType, ReactNode } from "react";
|
|
4
4
|
import { RenderResult } from "vitest-browser-react";
|
package/dist/vitest/react.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { n as waitForPageIdle, t as expectScreenshot } from "../browser-CSDVICWx.mjs";
|
|
2
2
|
import { expect } from "vite-plus/test";
|
|
3
3
|
import { page, server } from "vite-plus/test/browser";
|
|
4
4
|
import { createElement } from "react";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aamini/config",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"description": "Shared Vite+, lint, format, type, and test config for aria-amini apps",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -14,6 +14,9 @@
|
|
|
14
14
|
"access": "public",
|
|
15
15
|
"provenance": true
|
|
16
16
|
},
|
|
17
|
+
"bin": {
|
|
18
|
+
"aamini": "./dist/bootstrap/cli.mjs"
|
|
19
|
+
},
|
|
17
20
|
"files": [
|
|
18
21
|
"dist",
|
|
19
22
|
"tsconfig"
|
|
@@ -58,6 +61,9 @@
|
|
|
58
61
|
"./tsconfig/*.json": "./tsconfig/*.json",
|
|
59
62
|
"./package.json": "./package.json"
|
|
60
63
|
},
|
|
64
|
+
"dependencies": {
|
|
65
|
+
"execa": "^10.0.1"
|
|
66
|
+
},
|
|
61
67
|
"peerDependencies": {
|
|
62
68
|
"@playwright/test": "^1.61.0",
|
|
63
69
|
"@rolldown/plugin-babel": "^0.2.4",
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"browser.mjs","names":[],"sources":["../../src/vitest/browser.ts"],"sourcesContent":["import { expect } from 'vite-plus/test'\nimport { page, server, type Locator } from 'vite-plus/test/browser'\n\ndeclare module 'vitest/browser' {\n\tinterface BrowserCommands {\n\t\tresetScreenshotPointer: () => Promise<void>\n\t\tresizeBrowserViewport: (width: number, height: number) => Promise<void>\n\t}\n}\n\nexport interface ScreenshotOptions {\n\t/** Grows the viewport to the full target height before the capture. */\n\tfullHeight?: boolean\n}\n\n// Matches Tailwind's `md` breakpoint, which the mobile instance falls below.\nconst mobileWidth = 768\n\n/** Waits for fonts and two animation frames, so layout and paint settle. */\nexport async function waitForPageIdle() {\n\tawait document.fonts.ready\n\tawait new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))\n\tawait new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))\n}\n\n// Vitest scales its iframe to fit the browser window. Resize both so tall\n// captures stay at one CSS pixel per image pixel.\nasync function resizeViewport(width: number, height: number) {\n\tawait server.commands.resizeBrowserViewport(width, height)\n\tawait page.viewport(width, height)\n}\n\nfunction targetElement(target: Locator | HTMLElement) {\n\treturn target instanceof HTMLElement ? target : target.element()\n}\n\nasync function growToFullHeight(element: Element) {\n\tconst width = window.innerWidth\n\tconst minHeight = window.innerHeight\n\tlet height = 0\n\n\t// Viewport-relative content can keep growing after each resize.\n\tfor (let attempt = 0; attempt < 5; attempt++) {\n\t\tawait resizeViewport(\n\t\t\twidth,\n\t\t\tMath.max(minHeight, Math.ceil(element.scrollHeight)),\n\t\t)\n\t\tawait waitForPageIdle()\n\n\t\tconst next = Math.ceil(element.scrollHeight)\n\n\t\tif (next === height) break\n\n\t\theight = next\n\t}\n}\n\n// Vitest renders tests in an iframe that paints only the viewport, so a\n// capture past it comes out white. Playwright captures the page pixels under\n// an element, so a transparent fixed frame clips the capture to the viewport.\nfunction viewportClip(element: Element) {\n\tconst rect = element.getBoundingClientRect()\n\n\tif (\n\t\trect.top >= 0 &&\n\t\trect.left >= 0 &&\n\t\trect.bottom <= window.innerHeight &&\n\t\trect.right <= window.innerWidth\n\t) {\n\t\treturn undefined\n\t}\n\n\tconst top = Math.max(rect.top, 0)\n\tconst left = Math.max(rect.left, 0)\n\tconst frame = document.createElement('div')\n\n\tObject.assign(frame.style, {\n\t\tposition: 'fixed',\n\t\ttop: `${top}px`,\n\t\tleft: `${left}px`,\n\t\twidth: `${Math.min(rect.right, window.innerWidth) - left}px`,\n\t\theight: `${Math.min(rect.bottom, window.innerHeight) - top}px`,\n\t\tpointerEvents: 'none',\n\t})\n\n\treturn document.body.appendChild(frame)\n}\n\n/**\n * Compares the target with its baseline at zero tolerance. The capture stops\n * at the viewport unless `fullHeight` is set. Each browser instance pins its\n * own viewport. Mobile baselines get a `-mobile` suffix.\n */\nexport async function expectScreenshot(\n\ttarget: Locator | HTMLElement,\n\tname: string,\n\toptions: ScreenshotOptions = {},\n) {\n\tconst element = targetElement(target)\n\tconst suffix = window.innerWidth < mobileWidth ? '-mobile' : ''\n\tconst viewport = { width: window.innerWidth, height: window.innerHeight }\n\tlet clip: HTMLElement | undefined\n\n\ttry {\n\t\tif (options.fullHeight) await growToFullHeight(element)\n\n\t\tawait expect\n\t\t\t.poll(() =>\n\t\t\t\tArray.from(element.querySelectorAll('img')).every(\n\t\t\t\t\t(image) => image.complete,\n\t\t\t\t),\n\t\t\t)\n\t\t\t.toBe(true)\n\t\tawait server.commands.resetScreenshotPointer()\n\t\tawait waitForPageIdle()\n\n\t\tif (!options.fullHeight) clip = viewportClip(element)\n\n\t\tawait expect\n\t\t\t.element(clip ?? element)\n\t\t\t.toMatchScreenshot(`${name}${suffix}.png`, {\n\t\t\t\tscreenshotOptions: {\n\t\t\t\t\tanimations: 'disabled',\n\t\t\t\t\tcaret: 'hide',\n\t\t\t\t\tscale: 'css',\n\t\t\t\t},\n\t\t\t\tcomparatorOptions: {\n\t\t\t\t\tallowedMismatchedPixelRatio: 0,\n\t\t\t\t\tallowedMismatchedPixels: 0,\n\t\t\t\t},\n\t\t\t})\n\t} finally {\n\t\tclip?.remove()\n\n\t\tif (options.fullHeight)\n\t\t\tawait resizeViewport(viewport.width, viewport.height)\n\t}\n}\n"],"mappings":";;;AAgBA,MAAM,cAAc;;AAGpB,eAAsB,kBAAkB;CACvC,MAAM,SAAS,MAAM;CACrB,MAAM,IAAI,SAAe,YAAY,4BAA4B,QAAQ,CAAC,CAAC;CAC3E,MAAM,IAAI,SAAe,YAAY,4BAA4B,QAAQ,CAAC,CAAC;AAC5E;AAIA,eAAe,eAAe,OAAe,QAAgB;CAC5D,MAAM,OAAO,SAAS,sBAAsB,OAAO,MAAM;CACzD,MAAM,KAAK,SAAS,OAAO,MAAM;AAClC;AAEA,SAAS,cAAc,QAA+B;CACrD,OAAO,kBAAkB,cAAc,SAAS,OAAO,QAAQ;AAChE;AAEA,eAAe,iBAAiB,SAAkB;CACjD,MAAM,QAAQ,OAAO;CACrB,MAAM,YAAY,OAAO;CACzB,IAAI,SAAS;CAGb,KAAK,IAAI,UAAU,GAAG,UAAU,GAAG,WAAW;EAC7C,MAAM,eACL,OACA,KAAK,IAAI,WAAW,KAAK,KAAK,QAAQ,YAAY,CAAC,CACpD;EACA,MAAM,gBAAgB;EAEtB,MAAM,OAAO,KAAK,KAAK,QAAQ,YAAY;EAE3C,IAAI,SAAS,QAAQ;EAErB,SAAS;CACV;AACD;AAKA,SAAS,aAAa,SAAkB;CACvC,MAAM,OAAO,QAAQ,sBAAsB;CAE3C,IACC,KAAK,OAAO,KACZ,KAAK,QAAQ,KACb,KAAK,UAAU,OAAO,eACtB,KAAK,SAAS,OAAO,YAErB;CAGD,MAAM,MAAM,KAAK,IAAI,KAAK,KAAK,CAAC;CAChC,MAAM,OAAO,KAAK,IAAI,KAAK,MAAM,CAAC;CAClC,MAAM,QAAQ,SAAS,cAAc,KAAK;CAE1C,OAAO,OAAO,MAAM,OAAO;EAC1B,UAAU;EACV,KAAK,GAAG,IAAI;EACZ,MAAM,GAAG,KAAK;EACd,OAAO,GAAG,KAAK,IAAI,KAAK,OAAO,OAAO,UAAU,IAAI,KAAK;EACzD,QAAQ,GAAG,KAAK,IAAI,KAAK,QAAQ,OAAO,WAAW,IAAI,IAAI;EAC3D,eAAe;CAChB,CAAC;CAED,OAAO,SAAS,KAAK,YAAY,KAAK;AACvC;;;;;;AAOA,eAAsB,iBACrB,QACA,MACA,UAA6B,CAAC,GAC7B;CACD,MAAM,UAAU,cAAc,MAAM;CACpC,MAAM,SAAS,OAAO,aAAa,cAAc,YAAY;CAC7D,MAAM,WAAW;EAAE,OAAO,OAAO;EAAY,QAAQ,OAAO;CAAY;CACxE,IAAI;CAEJ,IAAI;EACH,IAAI,QAAQ,YAAY,MAAM,iBAAiB,OAAO;EAEtD,MAAM,OACJ,WACA,MAAM,KAAK,QAAQ,iBAAiB,KAAK,CAAC,CAAC,CAAC,OAC1C,UAAU,MAAM,QAClB,CACD,CAAC,CACA,KAAK,IAAI;EACX,MAAM,OAAO,SAAS,uBAAuB;EAC7C,MAAM,gBAAgB;EAEtB,IAAI,CAAC,QAAQ,YAAY,OAAO,aAAa,OAAO;EAEpD,MAAM,OACJ,QAAQ,QAAQ,OAAO,CAAC,CACxB,kBAAkB,GAAG,OAAO,OAAO,OAAO;GAC1C,mBAAmB;IAClB,YAAY;IACZ,OAAO;IACP,OAAO;GACR;GACA,mBAAmB;IAClB,6BAA6B;IAC7B,yBAAyB;GAC1B;EACD,CAAC;CACH,UAAU;EACT,MAAM,OAAO;EAEb,IAAI,QAAQ,YACX,MAAM,eAAe,SAAS,OAAO,SAAS,MAAM;CACtD;AACD"}
|