retake-demos 0.2.2 → 0.4.0
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/.env.example +10 -0
- package/AGENTS.md +277 -0
- package/README.md +63 -22
- package/dist/captions.js +55 -0
- package/dist/cards.js +177 -0
- package/dist/cli.js +300 -15
- package/dist/describe.js +30 -10
- package/dist/dryrun.js +185 -2
- package/dist/env.js +76 -0
- package/dist/manifest.js +178 -10
- package/dist/operator/run.js +4 -0
- package/dist/operator/tools.js +111 -13
- package/dist/pace.js +99 -0
- package/dist/presets.js +7 -7
- package/dist/record.js +475 -46
- package/dist/render.js +427 -64
- package/dist/tidy.js +103 -0
- package/dist/ui/chat.html +301 -43
- package/dist/ui/guide.html +2 -1
- package/dist/ui/server.js +172 -36
- package/dist/voice.js +36 -0
- package/llms.txt +11 -0
- package/package.json +5 -3
- package/site/releases.json +45 -0
- package/skill/SKILL.md +272 -8
- package/demos/example.yaml +0 -65
- package/dist/ui/index.html +0 -724
- package/site/index.html +0 -158
package/dist/describe.js
CHANGED
|
@@ -25,16 +25,34 @@ import YAML from "yaml";
|
|
|
25
25
|
import { Manifest } from "./manifest.js";
|
|
26
26
|
import { digest } from "./digest.js";
|
|
27
27
|
export function loadDotenv(root, files = [".env"]) {
|
|
28
|
+
// One parser for every reader of .env (see env.ts), so a password written
|
|
29
|
+
// by the window or `retake secret` is typed back exactly as entered.
|
|
28
30
|
for (const name of files) {
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
+
for (const [k, v] of Object.entries(parseEnvText(root, name)))
|
|
32
|
+
if (process.env[k] === undefined)
|
|
33
|
+
process.env[k] = v;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
function parseEnvText(root, name) {
|
|
37
|
+
const f = path.join(root, name);
|
|
38
|
+
if (!fs.existsSync(f))
|
|
39
|
+
return {};
|
|
40
|
+
const out = {};
|
|
41
|
+
for (const raw of fs.readFileSync(f, "utf8").split(/\r?\n/)) {
|
|
42
|
+
const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(raw);
|
|
43
|
+
if (!m)
|
|
31
44
|
continue;
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
45
|
+
const v = m[2].trim();
|
|
46
|
+
if (v.startsWith('"') && v.endsWith('"')) {
|
|
47
|
+
try {
|
|
48
|
+
out[m[1]] = JSON.parse(v);
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
catch { /* literal */ }
|
|
36
52
|
}
|
|
53
|
+
out[m[1]] = v.replace(/^(["'])(.*)\1$/, "$2");
|
|
37
54
|
}
|
|
55
|
+
return out;
|
|
38
56
|
}
|
|
39
57
|
function cliPath(bin) {
|
|
40
58
|
const home = os.homedir();
|
|
@@ -293,11 +311,13 @@ export async function proposeEdits(input) {
|
|
|
293
311
|
// --- drafting ---------------------------------------------------------------
|
|
294
312
|
const SCHEMA_DOC = `
|
|
295
313
|
Manifest YAML fields:
|
|
296
|
-
name (kebab-case), title, url, preset: post-landscape, waitForSelector (selector present when page is ready),
|
|
297
|
-
camera:
|
|
298
|
-
|
|
314
|
+
name (kebab-case), title, url, mode: demo, preset: post-landscape, waitForSelector (selector present when page is ready),
|
|
315
|
+
camera: static, setup: [steps run before recording], steps: [...], outputs: {mp4: true, gif: true, thumbnail: {scene: <label>}}
|
|
316
|
+
NEVER set viewport. The preset decides the size and every take at a preset comes out identical; setting viewport makes this demo a different shape from every other one and players letterbox it. Same for cursor, chrome, background, fps, crf and captions — the preset handles all of it.
|
|
317
|
+
Always set mode: demo — this is a demo, so nothing goes in the frame but the product. Never emit intro or outro cards, music, callouts or voiceover: those belong to mode: launch, which only a person can ask for.
|
|
299
318
|
Steps (each may have pauseAfter ms, waitFor selector, timeout ms):
|
|
300
319
|
{action: scene, label, caption, camera?: static | {focus: selector, zoom: 1.25}} a named beat; caption is burned in until the next scene (under 60 chars). Set camera: static on every scene unless the thing being shown is genuinely too small to read at full frame — a calm, still demo reads as more real than a zooming one. If the person asked for zooms (or stillness), that wins.
|
|
320
|
+
{action: select, selector, value} choose in a <select> — value or the visible label, whichever the page uses
|
|
301
321
|
{action: wait, ms}
|
|
302
322
|
{action: click, selector, zoom: 1.3} zoom is optional
|
|
303
323
|
{action: type, selector, text, delay: 30}
|
|
@@ -307,7 +327,7 @@ Steps (each may have pauseAfter ms, waitFor selector, timeout ms):
|
|
|
307
327
|
{action: keyboard, key}
|
|
308
328
|
{action: waitFor, selector, timeout: 30000} wait for something to appear (use after actions that trigger async work)
|
|
309
329
|
{action: evaluate, script}
|
|
310
|
-
Rules: never invent field names or add "?" to keys; 15–35 seconds total; 3–6 scenes; open with a scene; every click uses a selector from the scouted list verbatim (selectors ending in \">> nth=N\" are already disambiguated — keep them exactly); after any action that loads or computes something, waitFor an element that only appears as a result (never one that is already on the page) — if you cannot know it from the scout, use {action: wait, ms: 3000} instead; end with a wait of ~2000ms on the payoff; do not use zoom steps — scenes carry the camera.
|
|
330
|
+
Rules: never invent field names or add "?" to keys; write mode: demo; no cards, music, callouts or voiceover; 15–35 seconds total; 3–6 scenes; open with a scene; every click uses a selector from the scouted list verbatim (selectors ending in \">> nth=N\" are already disambiguated — keep them exactly); after any action that loads or computes something, waitFor an element that only appears as a result (never one that is already on the page) — if you cannot know it from the scout, use {action: wait, ms: 3000} instead; end with a wait of ~2000ms on the payoff; do not use zoom steps — scenes carry the camera; write a caption ONLY where it says something the screen cannot (never narrate a visible click), and hold the payoff in ONE frame rather than moving between tabs or views to show its parts separately.
|
|
311
331
|
`;
|
|
312
332
|
/** The person's standing taste — demos/style.md, written once, read by every
|
|
313
333
|
draft. "No zooms, cursor visible, captions plain" should not need repeating. */
|
package/dist/dryrun.js
CHANGED
|
@@ -8,13 +8,74 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import fs from "node:fs";
|
|
10
10
|
import path from "node:path";
|
|
11
|
-
import {
|
|
11
|
+
import { applyPageSetup, authState, dragPoints, gateOnApp, resolvePoint, runEvaluateSeed, runFileOrCommandSeed } from "./record.js";
|
|
12
12
|
import { chromium } from "playwright";
|
|
13
13
|
import { expandEnv, resolve } from "./manifest.js";
|
|
14
|
+
async function elInfo(page, selector, timeout) {
|
|
15
|
+
try {
|
|
16
|
+
const h = await page.locator(selector).first().elementHandle({ timeout });
|
|
17
|
+
if (!h)
|
|
18
|
+
return null;
|
|
19
|
+
const info = await h.evaluate((el) => {
|
|
20
|
+
const e = el;
|
|
21
|
+
return {
|
|
22
|
+
tag: e.tagName.toLowerCase(),
|
|
23
|
+
type: (e.type ?? "").toLowerCase(),
|
|
24
|
+
disabled: !!e.disabled || e.getAttribute("aria-disabled") === "true",
|
|
25
|
+
readOnly: !!e.readOnly,
|
|
26
|
+
editable: e.isContentEditable,
|
|
27
|
+
};
|
|
28
|
+
});
|
|
29
|
+
await h.dispose();
|
|
30
|
+
return info;
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/** Does this action make sense on this element? Returns the complaint, or null.
|
|
37
|
+
*
|
|
38
|
+
* This is the check that pays for itself: an action that cannot work on the
|
|
39
|
+
* element it names is knowable before a single frame is recorded, and finding
|
|
40
|
+
* it out during a take costs the whole take. So the message names the fix, not
|
|
41
|
+
* just the symptom — a report that says "use `action: select`" ends the
|
|
42
|
+
* problem, one that says "element is not an <input>" starts an investigation. */
|
|
43
|
+
export function actionFits(action, el) {
|
|
44
|
+
const NOT_TEXT = new Set(["button", "submit", "reset", "checkbox", "radio", "file", "image", "range", "color"]);
|
|
45
|
+
const is = (t) => el.tag === t;
|
|
46
|
+
if (action === "type" || action === "fill") {
|
|
47
|
+
if (is("select"))
|
|
48
|
+
return `\`${action}\` cannot be used on a <select> — use \`action: select\` with \`value: "<the option>"\``;
|
|
49
|
+
if (is("input") && el.type === "file")
|
|
50
|
+
return `\`${action}\` cannot be used on a file input — use \`action: upload\` with \`file:\``;
|
|
51
|
+
if (is("input") && NOT_TEXT.has(el.type))
|
|
52
|
+
return `\`${action}\` cannot be used on <input type="${el.type}"> — it holds no text; \`action: click\` is probably what you mean`;
|
|
53
|
+
if (!is("input") && !is("textarea") && !el.editable)
|
|
54
|
+
return `\`${action}\` needs a text field, but this is a <${el.tag}> that is not editable — if it is a custom editor, target the inner element that accepts typing`;
|
|
55
|
+
if (el.disabled)
|
|
56
|
+
return `\`${action}\` cannot type into a disabled <${el.tag}> — something earlier has to enable it first`;
|
|
57
|
+
if (el.readOnly)
|
|
58
|
+
return `\`${action}\` cannot type into a read-only <${el.tag}>`;
|
|
59
|
+
}
|
|
60
|
+
if (action === "select" && !is("select")) {
|
|
61
|
+
return `\`select\` only works on a real <select>, and this is a <${el.tag}> — for a custom dropdown, \`click\` it and then \`click\` the option`;
|
|
62
|
+
}
|
|
63
|
+
if (action === "upload" && !(is("input") && el.type === "file")) {
|
|
64
|
+
return `\`upload\` needs <input type="file">, and this is a <${el.tag}>${el.type && el.type !== el.tag ? ` type="${el.type}"` : ""}`;
|
|
65
|
+
}
|
|
66
|
+
if (action === "click" && el.disabled) {
|
|
67
|
+
return `this <${el.tag}> is disabled right now — the click would do nothing; wait for whatever enables it (\`action: waitFor\`) before clicking`;
|
|
68
|
+
}
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
14
71
|
export async function dryRun(m, manifestDir, log, opts = {}) {
|
|
15
72
|
const q = resolve(m);
|
|
16
73
|
const lines = [];
|
|
17
74
|
let failures = 0;
|
|
75
|
+
/** What the page actually laid out at — the width its breakpoints saw. */
|
|
76
|
+
let layoutWidth = null;
|
|
77
|
+
// Failure pictures land beside the demo's other output, not in the cwd.
|
|
78
|
+
const shotDir = path.join(opts.outRoot ?? "outputs", m.name);
|
|
18
79
|
// Seeds run here too (file and command kinds — the cheap, out-of-page
|
|
19
80
|
// ones). Without them dry checks a different app state than run will,
|
|
20
81
|
// and a selector failure that is really a state difference looks like a
|
|
@@ -28,11 +89,23 @@ export async function dryRun(m, manifestDir, log, opts = {}) {
|
|
|
28
89
|
log("dry: seeds skipped (--no-seed) — app state may not match what run will see");
|
|
29
90
|
}
|
|
30
91
|
const browser = await chromium.launch({ headless: true });
|
|
92
|
+
// Start from the same session run would. Without this, a demo behind a
|
|
93
|
+
// login was dry-run logged OUT — every selector "missing", or worse, a
|
|
94
|
+
// green dry against a marketing page and a failed take against the app.
|
|
95
|
+
const auth = authState(m, manifestDir);
|
|
96
|
+
if (auth.path)
|
|
97
|
+
log(auth.fresh ? `dry: reusing session ${path.relative(process.cwd(), auth.path)}` : `dry: no fresh session — auth.setup will have to sign in`);
|
|
31
98
|
const context = await browser.newContext({
|
|
32
99
|
viewport: q.viewport,
|
|
33
100
|
colorScheme: m.colorScheme,
|
|
34
101
|
...(m.reducedMotion ? { reducedMotion: "reduce" } : {}),
|
|
102
|
+
...(auth.fresh && auth.path ? { storageState: auth.path } : {}),
|
|
35
103
|
});
|
|
104
|
+
// Whatever the recorder injects, dry injects — one function, so the two
|
|
105
|
+
// cannot drift apart again. (Measured, for the record: `html{zoom}` does
|
|
106
|
+
// NOT move an app's media queries, which is why the layout width reported
|
|
107
|
+
// below is asked of the page rather than computed as width÷scale.)
|
|
108
|
+
await applyPageSetup(context, m, q);
|
|
36
109
|
const stubs = new Map();
|
|
37
110
|
const arm = async (d) => {
|
|
38
111
|
const key = `${d.method ?? "ANY"} ${d.url}`;
|
|
@@ -56,6 +129,20 @@ export async function dryRun(m, manifestDir, log, opts = {}) {
|
|
|
56
129
|
// go network-idle, and the manifest's waitForSelector is the real gate.
|
|
57
130
|
await page.goto(expandEnv(m.url), { waitUntil: "domcontentloaded", timeout: 60000 });
|
|
58
131
|
await page.waitForLoadState("networkidle", { timeout: 8000 }).catch(() => { });
|
|
132
|
+
// `evaluate` seeds write into the page itself — the panel that only
|
|
133
|
+
// exists once some state is seeded. Dry skipped them, so dry and run were
|
|
134
|
+
// literally looking at different apps; that was one of three lost takes
|
|
135
|
+
// in the field report.
|
|
136
|
+
if (opts.seed !== false) {
|
|
137
|
+
for (const sd of m.seed)
|
|
138
|
+
if (sd.kind === "evaluate")
|
|
139
|
+
await runEvaluateSeed(page, sd, manifestDir, log);
|
|
140
|
+
}
|
|
141
|
+
// The manifest's own readiness gate, with run's wrong-app diagnosis. A dry
|
|
142
|
+
// run that starts stepping before the app has booted reports selector
|
|
143
|
+
// failures that are really a race.
|
|
144
|
+
await gateOnApp(page, m, shotDir);
|
|
145
|
+
layoutWidth = await page.evaluate(() => document.documentElement.clientWidth).catch(() => null);
|
|
59
146
|
for (const s of [...(m.auth?.setup ?? []), ...m.setup])
|
|
60
147
|
await run(s, true);
|
|
61
148
|
for (const [i, s] of m.steps.entries())
|
|
@@ -69,6 +156,16 @@ export async function dryRun(m, manifestDir, log, opts = {}) {
|
|
|
69
156
|
const tag = isSetup ? "setup" : String(index).padStart(3, "0");
|
|
70
157
|
const short = 8000; // long enough for a click that navigates, short enough to stay fast
|
|
71
158
|
try {
|
|
159
|
+
// Before doing anything: does this action fit the element it names? A
|
|
160
|
+
// mismatch is the expensive kind of failure — the selector resolves, dry
|
|
161
|
+
// passes, and the take dies on it minutes into a recording.
|
|
162
|
+
const sel = step.selector;
|
|
163
|
+
if (sel && ["type", "fill", "select", "upload", "click"].includes(step.action)) {
|
|
164
|
+
const el = await elInfo(page, sel, short);
|
|
165
|
+
const complaint = el && actionFits(step.action, el);
|
|
166
|
+
if (complaint)
|
|
167
|
+
throw new Error(complaint);
|
|
168
|
+
}
|
|
72
169
|
// A point step (click/hover `at`, or a drag) is verified by resolving
|
|
73
170
|
// its target's rect in the page — the same way the recorder will.
|
|
74
171
|
const point = async (t) => {
|
|
@@ -117,7 +214,7 @@ export async function dryRun(m, manifestDir, log, opts = {}) {
|
|
|
117
214
|
await page.waitForLoadState("networkidle", { timeout: 8000 }).catch(() => { });
|
|
118
215
|
break;
|
|
119
216
|
case "waitFor":
|
|
120
|
-
await page.waitForSelector(step.selector, { timeout: Math.min(step.timeout ?? 15000, 15000) });
|
|
217
|
+
await page.waitForSelector(step.selector, { state: step.gone ? "hidden" : "visible", timeout: Math.min(step.timeout ?? 15000, 15000) });
|
|
121
218
|
break;
|
|
122
219
|
// noWaitAfter: a click that submits a form starts a navigation, and
|
|
123
220
|
// waiting for the element to settle afterwards reports a false failure
|
|
@@ -138,6 +235,19 @@ export async function dryRun(m, manifestDir, log, opts = {}) {
|
|
|
138
235
|
case "hover":
|
|
139
236
|
await page.locator(step.selector).first().hover({ timeout: short });
|
|
140
237
|
break;
|
|
238
|
+
case "select": {
|
|
239
|
+
// Resolving is not enough: an option that does not exist is the
|
|
240
|
+
// whole failure mode, so dry proves the value is selectable.
|
|
241
|
+
const sel = page.locator(step.selector).first();
|
|
242
|
+
await sel.waitFor({ timeout: short });
|
|
243
|
+
try {
|
|
244
|
+
await sel.selectOption(step.value, { timeout: short });
|
|
245
|
+
}
|
|
246
|
+
catch {
|
|
247
|
+
await sel.selectOption({ label: step.value }, { timeout: short });
|
|
248
|
+
}
|
|
249
|
+
break;
|
|
250
|
+
}
|
|
141
251
|
case "scroll":
|
|
142
252
|
if (step.to)
|
|
143
253
|
await page.locator(step.to).first().boundingBox({ timeout: short });
|
|
@@ -160,6 +270,9 @@ export async function dryRun(m, manifestDir, log, opts = {}) {
|
|
|
160
270
|
case "wait":
|
|
161
271
|
await page.waitForTimeout(isSetup ? step.ms : Math.min(step.ms, 400));
|
|
162
272
|
break;
|
|
273
|
+
case "callout":
|
|
274
|
+
await point(step.selector ?? step.at);
|
|
275
|
+
break;
|
|
163
276
|
default: break; // scene, screenshot, zoom, download: nothing to verify
|
|
164
277
|
}
|
|
165
278
|
if (step.pauseAfter)
|
|
@@ -176,6 +289,16 @@ export async function dryRun(m, manifestDir, log, opts = {}) {
|
|
|
176
289
|
where = `\n on ${page.url()} — “${text}”`;
|
|
177
290
|
}
|
|
178
291
|
catch { /* page may be gone */ }
|
|
292
|
+
// Text alone cannot say "another element is covering the button" or
|
|
293
|
+
// "the layout is not what you think". A picture can, and dry is the
|
|
294
|
+
// cheap pass — it should hand over more than run's does, not less.
|
|
295
|
+
try {
|
|
296
|
+
const shot = path.join(shotDir, `dry-failed-${String(failures).padStart(2, "0")}.png`);
|
|
297
|
+
fs.mkdirSync(shotDir, { recursive: true });
|
|
298
|
+
await page.screenshot({ path: shot, fullPage: true });
|
|
299
|
+
where += `\n picture: ${path.relative(process.cwd(), shot)}`;
|
|
300
|
+
}
|
|
301
|
+
catch { /* not fatal */ }
|
|
179
302
|
const line = `✗ [${tag}] ${describe(step)} — ${why}${where}`;
|
|
180
303
|
lines.push(line);
|
|
181
304
|
log(line);
|
|
@@ -183,8 +306,68 @@ export async function dryRun(m, manifestDir, log, opts = {}) {
|
|
|
183
306
|
}
|
|
184
307
|
const ok = failures === 0;
|
|
185
308
|
log(ok ? `dry run: all ${m.steps.length} steps resolved` : `dry run: ${failures} step(s) would fail`);
|
|
309
|
+
// A preset's width is VIDEO pixels: at scale 2 a 1920 preset lays the page
|
|
310
|
+
// out at 960 CSS px, under most desktop breakpoints, so a responsive app
|
|
311
|
+
// quietly serves its MOBILE layout and the steps are looking for fields that
|
|
312
|
+
// are not there. It reads as a pile of broken selectors, and costs a whole
|
|
313
|
+
// take to work out. Only asked when something actually failed, and only
|
|
314
|
+
// reported when the wider page proves it.
|
|
315
|
+
if (!ok) {
|
|
316
|
+
// Measured, not computed: `zoom` does not move an app's media queries, so
|
|
317
|
+
// width÷scale is not the number the page's breakpoints saw. Ask the page.
|
|
318
|
+
const cssWidth = layoutWidth ?? q.viewport.width;
|
|
319
|
+
if (cssWidth < 1100) {
|
|
320
|
+
const missing = lines.map((l) => /✗ \[[^\]]+\] \w+ (.+?) —/.exec(l)?.[1]).filter((x) => !!x);
|
|
321
|
+
const found = await resolvesWiderThan(m, missing, cssWidth, manifestDir);
|
|
322
|
+
if (found.length) {
|
|
323
|
+
const note = `⚠ ${found.length} of these selector(s) DO exist when the page is 1280 CSS px wide, but not at ${cssWidth}px.\n` +
|
|
324
|
+
` This take lays the page out at ${cssWidth} CSS px, which is below this app's desktop breakpoint — you are filming its mobile layout.\n` +
|
|
325
|
+
` Fix: give the manifest a wider \`viewport\`, or write the steps against the layout you are actually filming.\n` +
|
|
326
|
+
` Resolved wider: ${found.slice(0, 4).join(", ")}${found.length > 4 ? ` … +${found.length - 4}` : ""}`;
|
|
327
|
+
lines.push(note);
|
|
328
|
+
log(note);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
186
332
|
return { ok, lines, failures };
|
|
187
333
|
}
|
|
334
|
+
/** Load the same page at a desktop width and report which of these selectors
|
|
335
|
+
resolve there. Proof, not a guess: it is only worth telling someone their
|
|
336
|
+
preset is filming the mobile layout if the wider page really does have the
|
|
337
|
+
thing they were looking for. */
|
|
338
|
+
async function resolvesWiderThan(m, selectors, narrowPx, manifestDir) {
|
|
339
|
+
const unique = [...new Set(selectors)].filter((s) => s && !s.startsWith("http"));
|
|
340
|
+
if (!unique.length || narrowPx >= 1280)
|
|
341
|
+
return [];
|
|
342
|
+
const browser = await chromium.launch({ headless: true });
|
|
343
|
+
try {
|
|
344
|
+
const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 }, colorScheme: m.colorScheme });
|
|
345
|
+
const page = await ctx.newPage();
|
|
346
|
+
for (const d of m.stub) {
|
|
347
|
+
await ctx.route(d.url, async (route) => {
|
|
348
|
+
const body = d.json !== undefined ? JSON.stringify(d.json) : d.from ? fs.readFileSync(path.resolve(manifestDir, expandEnv(d.from)), "utf8") : "{}";
|
|
349
|
+
await route.fulfill({ status: d.status ?? 200, contentType: d.contentType, body });
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
await page.goto(expandEnv(m.url), { waitUntil: "domcontentloaded", timeout: 30000 });
|
|
353
|
+
await page.waitForLoadState("networkidle", { timeout: 6000 }).catch(() => { });
|
|
354
|
+
const found = [];
|
|
355
|
+
for (const sel of unique) {
|
|
356
|
+
try {
|
|
357
|
+
if (await page.locator(sel).first().isVisible({ timeout: 1500 }))
|
|
358
|
+
found.push(sel);
|
|
359
|
+
}
|
|
360
|
+
catch { /* still missing */ }
|
|
361
|
+
}
|
|
362
|
+
return found;
|
|
363
|
+
}
|
|
364
|
+
catch {
|
|
365
|
+
return [];
|
|
366
|
+
}
|
|
367
|
+
finally {
|
|
368
|
+
await browser.close().catch(() => { });
|
|
369
|
+
}
|
|
370
|
+
}
|
|
188
371
|
function describe(s) {
|
|
189
372
|
if ("selector" in s && s.selector)
|
|
190
373
|
return `${s.action} ${s.selector}`;
|
package/dist/env.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The workspace `.env` — where an app's demo-account credentials live.
|
|
3
|
+
*
|
|
4
|
+
* Three things write it: the person (by hand, or `retake secret NAME`), the
|
|
5
|
+
* window (when an agent asks for a secret by name), and Settings. None of
|
|
6
|
+
* them ever hands a value to a model: the agent learns that `APP_PASSWORD`
|
|
7
|
+
* is *set*, and references it as `${APP_PASSWORD}` with `secret: true`.
|
|
8
|
+
*/
|
|
9
|
+
import fs from "node:fs";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
export const SECRET_NAME = /^[A-Z][A-Z0-9_]{1,63}$/;
|
|
12
|
+
/** Parse KEY=value lines (quotes stripped, comments and blanks ignored). */
|
|
13
|
+
export function readEnvFile(root) {
|
|
14
|
+
const file = path.join(root, ".env");
|
|
15
|
+
if (!fs.existsSync(file))
|
|
16
|
+
return {};
|
|
17
|
+
const out = {};
|
|
18
|
+
for (const raw of fs.readFileSync(file, "utf8").split(/\r?\n/)) {
|
|
19
|
+
const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(raw);
|
|
20
|
+
if (!m)
|
|
21
|
+
continue;
|
|
22
|
+
const v = m[2].trim();
|
|
23
|
+
// Double-quoted values are JSON strings (that is how writeEnvFile writes
|
|
24
|
+
// anything awkward); single-quoted are literal; bare is bare.
|
|
25
|
+
if (v.startsWith('"') && v.endsWith('"')) {
|
|
26
|
+
try {
|
|
27
|
+
out[m[1]] = JSON.parse(v);
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
catch { /* fall through */ }
|
|
31
|
+
}
|
|
32
|
+
out[m[1]] = v.replace(/^(["'])(.*)\1$/, "$2");
|
|
33
|
+
}
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
/** Set (or, with "", remove) keys in .env, leaving every other line alone.
|
|
37
|
+
Returns the keys actually written. Also updates process.env so the
|
|
38
|
+
calling process sees the new values without a restart. */
|
|
39
|
+
export function writeEnvFile(root, set) {
|
|
40
|
+
const file = path.join(root, ".env");
|
|
41
|
+
const lines = fs.existsSync(file) ? fs.readFileSync(file, "utf8").split("\n") : [];
|
|
42
|
+
const written = [];
|
|
43
|
+
for (const [k, v] of Object.entries(set)) {
|
|
44
|
+
if (v === undefined)
|
|
45
|
+
continue;
|
|
46
|
+
const i = lines.findIndex((l) => l.startsWith(k + "="));
|
|
47
|
+
// Quote when the value has anything a shell-ish parser would trip on.
|
|
48
|
+
const line = `${k}=${/[\s#"']/.test(v) ? JSON.stringify(v) : v}`;
|
|
49
|
+
if (v === "") {
|
|
50
|
+
if (i >= 0)
|
|
51
|
+
lines.splice(i, 1);
|
|
52
|
+
delete process.env[k];
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
if (i >= 0)
|
|
56
|
+
lines[i] = line;
|
|
57
|
+
else
|
|
58
|
+
lines.push(line);
|
|
59
|
+
process.env[k] = v;
|
|
60
|
+
}
|
|
61
|
+
written.push(k);
|
|
62
|
+
}
|
|
63
|
+
fs.writeFileSync(file, lines.filter((l, i, a) => l !== "" || i < a.length - 1).join("\n").replace(/\n*$/, "\n"), { mode: 0o600 });
|
|
64
|
+
// `mode` above only applies to a new file; an existing .env keeps whatever
|
|
65
|
+
// it had. Credentials went in, so: owner-only, every time.
|
|
66
|
+
try {
|
|
67
|
+
fs.chmodSync(file, 0o600);
|
|
68
|
+
}
|
|
69
|
+
catch { /* windows */ }
|
|
70
|
+
return written;
|
|
71
|
+
}
|
|
72
|
+
/** Which of these names are not set (missing or blank) in .env + the environment. */
|
|
73
|
+
export function missingSecrets(root, names) {
|
|
74
|
+
const file = readEnvFile(root);
|
|
75
|
+
return names.filter((n) => !(process.env[n]?.trim() || file[n]?.trim()));
|
|
76
|
+
}
|