demobite 0.0.1

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.
@@ -0,0 +1,72 @@
1
+ #!/usr/bin/env node
2
+ // DemoBites ending — raw.webm -> clean.mp4. TRIM ONLY.
3
+ // Cuts everything before record_from (the stamped moment the page was fully
4
+ // loaded) and encodes a clean FULL FRAME 1920x1080 h264 take.
5
+ // NO backdrop, NO rounded corners, NO shadow — the DemoBites studio owns the
6
+ // look. fps=30 is load-bearing for the ingest pipeline; keep it.
7
+ //
8
+ // Usage: node trim.mjs <takeDir>
9
+ import fs from "node:fs";
10
+ import path from "node:path";
11
+ import { execFileSync } from "node:child_process";
12
+
13
+ const dir = process.argv[2];
14
+ if (!dir) {
15
+ console.error("Usage: node trim.mjs <takeDir>");
16
+ process.exit(2);
17
+ }
18
+ const requireTool = (tool) => {
19
+ try { execFileSync(tool, ["-version"], { stdio: "ignore" }); }
20
+ catch {
21
+ console.error(`${tool} is required on PATH. Install it (macOS: brew install ffmpeg) and rerun.`);
22
+ process.exit(1);
23
+ }
24
+ };
25
+ requireTool("ffmpeg");
26
+ requireTool("ffprobe");
27
+
28
+ const raw = path.join(dir, "raw.webm");
29
+ const manPath = path.join(dir, "manifest.json");
30
+ if (!fs.existsSync(raw)) { console.error(`${raw} not found. Run record.mjs first.`); process.exit(1); }
31
+ if (!fs.existsSync(manPath)) { console.error(`${manPath} not found. Run record.mjs first.`); process.exit(1); }
32
+ const man = JSON.parse(fs.readFileSync(manPath, "utf8"));
33
+ const clean = path.join(dir, "clean.mp4");
34
+
35
+ // LAW (timebase, verified by four-lane investigation 2026-08-09): the raw
36
+ // recording's timeline IS wall clock. Slope 1.000 to within 0.2%, confirmed
37
+ // two independent ways: 13 zero-latency visual anchors across a real take
38
+ // (rms 41ms, zero curvature), and a color-flip beacon experiment under heavy
39
+ // DOM churn (residuals within ±31ms except a 1-2 frame bend at churn onset).
40
+ // raw.webm merely EXTENDS past manifest.duration because frames keep arriving
41
+ // during context teardown — a tail, not a rate change.
42
+ //
43
+ // So the map is simply: video = wall - record_from. NEVER fit a rate. Every
44
+ // prior desync came from fitting one: the duration ratio folded the teardown
45
+ // tail into a fake 1.027x, and a scene-change fit anchored on a modal that
46
+ // took 0.49s to load tilted 0.897x. Both produced ramps of error that read as
47
+ // a broken cursor.
48
+ const r0 = man.record_from ?? 0;
49
+ man.timebase = {
50
+ a: 1,
51
+ b: -r0,
52
+ method: "identity (raw is wall-rate; see 2026-08-09 four-lane verification)",
53
+ k: 1,
54
+ videoRecordFrom: r0,
55
+ };
56
+ fs.writeFileSync(manPath, JSON.stringify(man, null, 2));
57
+ console.log(`timebase: identity, video = wall - ${r0.toFixed(3)}`);
58
+
59
+ execFileSync("ffmpeg", [
60
+ "-y", "-loglevel", "error",
61
+ "-i", raw,
62
+ "-filter_complex", `[0:v]trim=start=${r0},setpts=PTS-STARTPTS,fps=30,format=yuv420p[out]`,
63
+ "-map", "[out]",
64
+ "-c:v", "libx264", "-preset", "medium", "-crf", "19",
65
+ "-movflags", "+faststart",
66
+ clean,
67
+ ], { stdio: "inherit" });
68
+
69
+ const dur = execFileSync("ffprobe", [
70
+ "-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", clean,
71
+ ]).toString().trim();
72
+ console.log(`clean.mp4 ready in ${dir} (trimmed ${r0.toFixed ? r0.toFixed(2) : r0}s from the head), duration ${parseFloat(dur).toFixed(2)}s`);
package/skill/SKILL.md ADDED
@@ -0,0 +1,289 @@
1
+ ---
2
+ name: agentic-recorder
3
+ description: Film a product demo by driving a real browser from a storyboard and deliver it into DemoBites as an editable bite. Use when the user asks to record a demo, film a product walkthrough, capture a feature tour, or turn a flow in their app into a DemoBites bite. Requires a DemoBites account; the skill signs in via device link before anything films.
4
+ ---
5
+
6
+ # Agentic Recorder
7
+
8
+ You are the camera operator, the director, and the editor. You film a real browser doing a real flow, narrate it, and deliver a clean take into DemoBites, where everything — voice, camera, cursor, look — becomes editable. There is ONE delivery: a DemoBites bite. Never ask how the demo should be delivered.
9
+
10
+ All scripts live in `scripts/` beside this file. They are plain Node ESM. Requirements: Node 18+, `playwright` installed with Chromium (`npm i playwright && npx playwright install chromium`), and `ffmpeg` on PATH. Run every script from the project directory so `.recorder/` lands next to the project.
11
+
12
+ Follow the phases in order. Never skip the storyboard approval. Never ingest before the human's word — for DemoBites, Approve on the in-app preview page IS the word.
13
+
14
+ ## Phase 0: Auth gate, ALWAYS FIRST — with the human's word
15
+
16
+ This skill does not start unauthenticated — exactly like a CLI that requires
17
+ /login. THE VERY FIRST ACT, before any config question and before any
18
+ storyboard talk: check `.recorder/config.json` for `api_key`.
19
+
20
+ **The choreography when the key is missing (founder law, 2026-08-09 — never
21
+ surprise the human with a browser page):**
22
+
23
+ 1. TELL, don't act: "You're not connected to DemoBites yet. Signing in means
24
+ approving a link in your browser — say Go when you're ready." Then WAIT.
25
+ Nothing opens until the human gives the word.
26
+ 2. On their word, run `node scripts/login.mjs`. It prints the link and code,
27
+ opens the approval page, and polls. The human approves in their own
28
+ signed-in browser session — this script never sees credentials. The key
29
+ lands in config (chmod 600).
30
+ 3. If their DemoBites session is signed out, the browser shows the normal
31
+ sign-in page FIRST and the approval page follows with the same code — the
32
+ link survives the sign-in. Say this if the human mentions a login screen.
33
+ 4. Outcomes are ANSWERS, never obstacles:
34
+ - approved: confirm it plainly ("Connected — workspace X") and move on.
35
+ - denied: "The link was not approved, nothing was connected." FULL STOP.
36
+ NEVER re-run login after a denial — the human said no. Sign-in happens
37
+ again only when they ask.
38
+ - expired / unreachable: say what happened, offer a fresh link, and WAIT
39
+ for their word.
40
+
41
+ Gating first is deliberate: fail before minutes of filming and know the
42
+ target workspace up front. Bite-plan limits are NOT your concern and never
43
+ block you: staging always succeeds, takes wait in the product queue, and the
44
+ plan gate lives on the Approve button in DemoBites. Never mention quota in
45
+ the terminal — if the workspace is full, the product does the talking.
46
+ To sign out: `node scripts/login.mjs --logout` (revokes the key server-side
47
+ AND strips it locally). "Log me out of DemoBites" means exactly that command.
48
+
49
+ ## Phase 1: Config, once
50
+
51
+ Look for `.recorder/config.json` next to the project. If it exists, use it and ask nothing you already know. If it is missing or incomplete, ask the human once for:
52
+
53
+ - **app**: the product's name as it should appear in titles.
54
+ - **url**: the starting URL of the flow.
55
+ - **frame**: fixed at 1920x1080 for now, do not ask, just record it.
56
+ - **base**: defaults to `https://dev.demobites.com`, only ask if the human mentions a different environment.
57
+
58
+ Write the answers to `.recorder/config.json` and never ask again:
59
+
60
+ ```json
61
+ {
62
+ "app": "Acme",
63
+ "url": "https://app.acme.com",
64
+ "frame": { "width": 1920, "height": 1080 },
65
+ "base": "https://dev.demobites.com"
66
+ }
67
+ ```
68
+
69
+ `login.mjs` later merges `api_key` and `workspace` into this same file and chmods it 600. Treat the file as secret once a key is in it. Never print `api_key`.
70
+
71
+ ## Phase 2: Target-app sign-in, only when a login wall appears
72
+
73
+ The camera browser uses a persistent profile at `.recorder/profile`. Signed in sessions survive between takes.
74
+
75
+ When a page you need shows a login wall (login form, auth redirect, checkpoint page):
76
+
77
+ 1. Open a HEADED Playwright window on that profile at the login page and tell the human: "Sign in in the window I opened. I will wait." Poll for a signed in signal (URL leaves the login path, or a session cookie appears), then close the window. The session now lives in the profile.
78
+ 2. NEVER type, read, store, or ask for credentials. Not the password, not a 2FA code, nothing. The human signs in with their own hands.
79
+ 3. Do this only when a wall actually appears. Do not preemptively ask for logins.
80
+
81
+ ## Phase 3: Storyboard, written BEFORE filming
82
+
83
+ Write the storyboard as JSON before touching the camera.
84
+
85
+ ### LAW: the video is the metronome, not the script
86
+
87
+ **The narration is INTENT, never final copy.** In the DemoBites ending it is handed to the ingestion, which rescripts it and refits it to the video exactly as it does for a customer's own uploaded voice. So never stretch a shot to cover a sentence. A shot is as long as the ACTION needs, and the words get fitted to it afterwards.
88
+
89
+ Holding shots to cover estimated lines is what produced a 60 second take with the cursor parked for 12 seconds, which the founder rejected on 2026-08-08. Realism reads as: click the button, say a short sentence, move the cursor on. The camera goes with it.
90
+
91
+ **Budgets, hold yourself to them:**
92
+
93
+ - **30 to 45 seconds total.** Over 45 is a rewrite, not a trim.
94
+ - **6 to 10 beats.** More than that and nothing gets seen.
95
+ - **8 to 14 words per narration line.** Short beats one long one, every time.
96
+ - Trust the defaults in `record.mjs` (`DEFAULT_HOVER_DWELL` 3.2s, `DEFAULT_CLICK_AFTER` 2.0s). Only override when the app itself is slow.
97
+
98
+ **LAW: a line must FIT its beat, and a beat that NAVIGATES away cannot hold two lines** (founder drift analysis, 2026-08-09). Each narration line plays while its own beat is on screen. If a beat is a click that navigates to a new page, everything you want said ABOUT the old page has to fit BEFORE that click — a ~14-word line is ~5s of speech, so one line per pre-navigation beat, not two stacked. Cramming the intro plus a second observation before a fast navigation is what makes the words drift a beat behind the picture (a list-page sentence finishing over the product page). If you need to say two things about a page, either say them AFTER you have landed on it, or give the source beat a longer `dwell` so the line finishes before the click. The ingestion fits words to the video, but it cannot make 8 seconds of speech fit into a 5 second window — that is authoring, and it is yours.
99
+
100
+ Storyboard schema:
101
+
102
+ ```json
103
+ {
104
+ "app": "Acme",
105
+ "title": "Saved items in Acme",
106
+ "url": "https://app.acme.com",
107
+ "headless": true,
108
+ "hideCss": "[class*='chat-widget'] { display: none !important }",
109
+ "steps": [
110
+ { "action": "goto", "url": "https://app.acme.com", "label": "open the app" },
111
+ { "action": "settle", "on_screen": "the Acme dashboard, freshly loaded", "narration": "This is your Acme dashboard." },
112
+ { "action": "hover", "selector": "[data-test='plan-badge']", "label": "point at the plan badge", "on_screen": "the cursor rests on the plan badge beside the workspace name", "narration": "Your plan sits right beside the workspace name." },
113
+ { "action": "click", "selector": "button:has-text('Reports')", "minY": 150, "reveals": "[role='menu']", "label": "open Reports", "on_screen": "the Reports menu opens under the button", "narration": "Reports lives here." },
114
+ { "action": "scroll", "dy": 520, "ms": 1900, "narration": "Everything you exported, in one place." }
115
+ ]
116
+ }
117
+ ```
118
+
119
+ Step fields: `action` is one of `goto | settle | scroll | click | hover`. `goto` needs `url`. `settle` takes `ms` and an optional `focus` selector. `scroll` needs `dy` and takes `ms`. `click`/`hover` need `selector` and take `minY` (minimum Y for the visible instance pick), `dwell`, `after`, `waitLoad`. Every step takes `label` and `narration`.
120
+
121
+ Two fields carry the whole advantage of this lane, so fill them in:
122
+
123
+ - **`on_screen`** describes what the viewer is looking at during the beat. It rides into the ingestion's rescripting stage, so the model writes narration while KNOWING the cursor is on the degree badge and the menu just opened. A microphone can never supply this. Write it for every narrated beat.
124
+ - **`reveals`** (click steps) names what the click opens, a menu or a dialog. The camera cuts to it after the click. Without it the recorder auto detects top layer arrivals, which usually works; name it explicitly when the app is unusual. Pass `"reveals": false` for a click that opens nothing.
125
+
126
+ Use `hideCss` for chat widgets and cookie banners that would pollute the picture. The first `goto` opens the video, so the first narration goes on the settle right after it.
127
+
128
+ **Show the storyboard inline and get approval before filming.** Present it as a numbered shot list, not raw JSON. Say the target length out loud so the human can push back on pacing before you burn a take. Iterate until they say go.
129
+
130
+ ## LAW: bot walls — one human checkpoint, never a disguise
131
+
132
+ Some sites challenge automated browsers. The protocol, in order, no
133
+ improvisation:
134
+
135
+ 1. A silent JS challenge (page loads to a challenge URL, no checkbox):
136
+ retry HEADED once — the real browser usually passes on its own
137
+ (Reddit, Unsplash, GetYourGuide all film headed).
138
+ 2. An INTERACTIVE challenge ("Verify you are human" checkbox): hold ONE
139
+ headed window open and ask the human to click it themselves, then wait
140
+ for their word. One attempt. The persistent profile keeps the clearance.
141
+ 3. If it loops after the human's click, the site refuses automated filming.
142
+ Say exactly that, then offer the honest alternatives: film a different
143
+ subject, or point the human at the DemoBites native recorder / Chrome
144
+ extension — the human filming their own real browser needs no automation
145
+ at all and lands in the same studio. If the walled site is the CUSTOMER'S
146
+ OWN product, tell them to allowlist the recorder on their staging or demo
147
+ environment — their wall, their switch.
148
+ 4. NEVER: stealth plugins, fingerprint spoofing, user-agent forgery, hiding
149
+ webdriver flags, or retry-grinding a challenge. Disguising automation is
150
+ detection evasion — it is off the table no matter who asks.
151
+
152
+ The recorder films with the real Google Chrome binary by default
153
+ ("channel": "chrome" is implicit; "channel": "chromium" opts out) — real
154
+ product, real codecs, no signals faked. The camera profile also AGES with
155
+ use (cookies, history), which honestly raises its trust over time.
156
+
157
+ ## LAW: launch flags are fixed
158
+
159
+ Probes and takes launch the browser EXACTLY like record.mjs does: the
160
+ persistent `.recorder/profile`, the real Chrome channel, and record.mjs's own
161
+ args — nothing more. NEVER add `--no-sandbox`, `--disable-web-security`,
162
+ `--disable-gpu`, or any flag you saw in a CI tutorial: they weaken the
163
+ browser's security for zero benefit on a desktop, and `--no-sandbox`
164
+ specifically is a CI-farm fingerprint that makes bot walls MORE suspicious —
165
+ it sabotages the exact trust you are trying to earn. If a launch fails,
166
+ report the error; do not medicate it with flags.
167
+
168
+ ## Phase 4: Headless dry run
169
+
170
+ Before the real take, run the flow headless yourself (a throwaway script on the same profile, no video) and resolve every ambiguity on your own:
171
+
172
+ - Selectors matching multiple instances: find the right one with the visible instance rule (first visible match whose top clears `minY`, sticky header twins shadow the real control). Set `minY` in the storyboard accordingly.
173
+ - Popups, consent banners, overlay chats: extend `hideCss`.
174
+ - Timing: pages that need longer settles.
175
+
176
+ Only come back to the human when a PRODUCT question remains that you cannot decide, for example which of two similar buttons is the feature. When you do, bring annotated screenshot evidence: screenshot the state, mark the candidates, ask one crisp question. Never ask the human to debug selectors for you.
177
+
178
+ ## Phase 5: The take
179
+
180
+ ```bash
181
+ node scripts/record.mjs <takeDir> <storyboard.json>
182
+ ```
183
+
184
+ Outputs `raw.webm` and `manifest.json` (internal schema, absolute times) into `<takeDir>`. The recorder stamps `record_from`: the moment the first page was FULLY loaded (networkidle plus a beat). Everything before it gets trimmed in both endings, so the published cut always opens on a loaded page.
185
+
186
+ Filming laws baked into `record.mjs`, do not reimplement or weaken them:
187
+
188
+ - Trusted Types proof cursor: CSS data URI background on a bare div, no innerHTML anywhere.
189
+ - Top layer cursor via the Popover API, re shown on every move so it beats native dropdowns and later top layer arrivals.
190
+ - `record_from` stamped after networkidle plus a beat on the first goto.
191
+ - Visible instance picking with `minY` for click targets.
192
+ - Mouse coordinate clicks: the real mouse tracks the drawn cursor, hover states fire naturally.
193
+ - **The camera follows the subject, measured off the live page.** Every hover records the hovered element's rectangle. Every click records TWO shots: the control on approach, and then whatever the click opened. A click that opens a menu or a dialog moves the subject somewhere else on screen, so a camera left on the button shows a dimmed backdrop while the thing you just opened sits off frame.
194
+ - **Shots overlap on purpose.** The manifest's camera path is chained by the backend so the runtime travels from one subject to the next at zoom. Never "fix" this into a non overlapping sequence, that is the pull out to 1.0 between every shot.
195
+
196
+ If the take fails mid flow, the partial video and manifest are still saved. Diagnose, fix the storyboard, film again.
197
+
198
+ ## Phase 6: Deliver into DemoBites
199
+
200
+
201
+ Send the TRIMMED CLEAN take into DemoBites. The studio owns the look: NO backdrop, NO rounded corners, NO shadow on the uploaded file. Everything (voice, zooms, look, intro, outro) becomes editable there.
202
+
203
+ ```bash
204
+ node scripts/trim.mjs <takeDir> # raw.webm -> clean.mp4, trim from record_from ONLY
205
+ node scripts/calibrate.mjs <takeDir> # anchor-measure the clock against the footage
206
+ node scripts/manifest.mjs <takeDir> # internal manifest -> manifest.demobites.json (wire schema)
207
+ node scripts/upload.mjs <takeDir> # STAGE the take + open the in-app preview
208
+ ```
209
+
210
+ **The human word lives in the product now.** `upload.mjs` stages the take (the
211
+ ZIP for ingestion plus a playable MP4 for the player), opens the DemoBites
212
+ preview page in the human's browser, and polls while they decide THERE.
213
+ Approve on that page runs the ingest; Discard deletes the staged take and this
214
+ script reports it so you adjust and refilm. There is no local review.html for
215
+ this ending — the preview page is the review.
216
+
217
+ What the human approves on that page is the **picture and the coverage**, never the script. The page deliberately shows no quoted lines and no timestamps, because the ingestion rewrites the narration and refits it to the video. Presenting "this line at 0:05" promises something the system does not deliver. A retake is only for a wrong picture: private data on screen, or a missing step in the flow.
218
+
219
+ ### LAW: never hand over a studio link before the bite is ready
220
+
221
+ `ingest` only STARTS the pipeline. Transcode, rescript, fit, synthesize and finalize all happen after the call returns, so a link printed at that moment leads to a half built bite with grey silent rows, which is exactly what the founder walked into on 2026-08-08.
222
+
223
+ `upload.mjs` now polls `/api/recorder/status` until the bite reaches `completed` and prints what actually landed. **Read that line before you say anything to the human.** It reports `narrationReady/narrationTotal` segments with real audio behind them, and the camera shot count. If narration is 0, or ready is below total, or shots are 0, say so plainly and investigate. Do not pass on a link with a warning above it as though it were a success.
224
+
225
+ ## The wire manifest (fixed contract, version 2)
226
+
227
+ `manifest.mjs` produces exactly this shape. All times are relative to the UPLOADED file (record_from already subtracted, clamped at 0). `duration` is the duration of the uploaded clean.mp4.
228
+
229
+ ```
230
+ {
231
+ version: 2,
232
+ app: string,
233
+ title: string,
234
+ frame: { width: 1920, height: 1080 },
235
+ duration: number, // seconds of the UPLOADED file
236
+ steps: [{
237
+ n, action: 'goto'|'settle'|'click'|'scroll'|'hover', label,
238
+ t_start, t_end,
239
+ on_screen?: string, // what the viewer is looking at
240
+ click?: { x, y, t }, // frame px + seconds
241
+ narration?: { text, t, estimated_duration }
242
+ }],
243
+ camera: [{ t_start, t_end, x, y, w, h, label }] // focus rectangles, frame px
244
+ }
245
+ ```
246
+
247
+ `estimated_duration` is only ever an estimate and nothing downstream treats it as final.
248
+
249
+ ### What the two v2 fields buy
250
+
251
+ The recorder is a privileged upstream. It knows the words, the exact moment of every beat, and the exact rectangle that matters. Handing those over is the whole point of the lane.
252
+
253
+ - **`on_screen`** rides into the ingestion's rescripting stage inside a supplied transcript, so the narration is written against what is actually on screen. The recorder skips Whisper entirely and enters the SAME Stage 1 rescript, Stage 3 fit and Stage 4 synthesize that upload, the Chrome extension and the native recorder run. Nothing downstream is special cased.
254
+ - **`camera`** replaces the LLM auto zoom step. The backend derives each factor from the rectangle's size, so a degree badge lands near 3x and a dialog near 1.6x, and it deliberately OVERLAPS consecutive shots so the runtime travels between them. Without the overlap the camera pulls fully out to 1.0 between every shot, which reads as vertigo and hides the thing the click just opened.
255
+
256
+ ## Server contracts (fixed, coded verbatim in the scripts)
257
+
258
+ ```
259
+ POST <base>/api/recorder/device
260
+ -> { device_code, user_code, verification_url, expires_in, interval }
261
+
262
+ PUT <base>/api/recorder/device { device_code } (poll every `interval` seconds)
263
+ -> { status: 'pending' | 'approved' (+api_key+workspace) | 'denied' | 'expired' | 'consumed' }
264
+
265
+ DELETE <base>/api/recorder/key (Authorization: Bearer <api_key>)
266
+ -> { revoked: true } (logout)
267
+
268
+ PUT <base>/api/recorder/stage (Authorization: Bearer <api_key>)
269
+ { filename, sizeBytes, previewSizeBytes, manifest }
270
+ -> { stagingId, uploadUrl, previewUploadUrl, videoKey, previewUrl }
271
+
272
+ GET <base>/api/recorder/stage?id=<stagingId> (Authorization: Bearer <api_key>)
273
+ -> { status: 'pending'|'approving'|'approved'|'rejected', biteId, biteUKey, biteStatus, studioUrl }
274
+
275
+ GET <base>/api/recorder/status?biteId=<id> (Authorization: Bearer <api_key>)
276
+ -> { status, title, durationSec, narrationReady, narrationTotal, zooms } (STARTS the pipeline, not done)
277
+
278
+ GET <base>/api/recorder/status?biteId=<id> (Authorization: Bearer <api_key>)
279
+ -> { status, title, durationSec, zooms, narrationTotal, narrationReady, transcription }
280
+ ```
281
+
282
+ The upload zip contains exactly one file: `clean.mp4` stored as `recording.mp4`. Nothing else goes in the zip. Default base is `https://dev.demobites.com`, overridable via `config.base`.
283
+
284
+ ## Standing rules
285
+
286
+ - Anything the human sees (storyboard presentation, review page, questions) uses commas and periods only, no dashes, and real action words. Never orphan a single word on its own line in a heading.
287
+ - Never touch credentials. Never print the api_key. Config and key files are chmod 600.
288
+ - Never INGEST without the human's explicit word. For the DemoBites ending, staging for the in-app preview is HOW the word is asked — the take becomes a bite only when the human clicks Approve on that page.
289
+ - One take directory per take, keep failed takes for diagnosis, name them `take-<slug>`, `take-<slug>2`, and so on.
@@ -0,0 +1,150 @@
1
+ #!/usr/bin/env node
2
+ // DemoBites ending — device link. Mints a recorder API key through the
3
+ // human's OWN browser session; this script never sees credentials.
4
+ //
5
+ // Contracts (fixed, coded verbatim):
6
+ // POST <base>/api/recorder/device
7
+ // -> { device_code, user_code, verification_url, expires_in, interval }
8
+ // PUT <base>/api/recorder/device { device_code } (polled)
9
+ // -> { status: 'pending' | 'approved' (+api_key+workspace) | 'denied' | 'expired' | 'consumed' }
10
+ //
11
+ // On approval, saves { base, api_key, workspace } into .recorder/config.json
12
+ // (merged with existing keys, chmod 600).
13
+ //
14
+ // Usage: node login.mjs [base]
15
+ // node login.mjs --logout revoke the key server-side AND strip it
16
+ // from local config — a leaked config file
17
+ // dies with the logout.
18
+ // Base resolution: CLI arg, config.base, https://dev.demobites.com.
19
+ import fs from "node:fs";
20
+ import path from "node:path";
21
+ import { spawnSync } from "node:child_process";
22
+
23
+ const cfgDir = path.resolve(".recorder");
24
+ const cfgPath = path.join(cfgDir, "config.json");
25
+ let cfg = {};
26
+ let cfgParseFailed = false;
27
+ try { cfg = JSON.parse(fs.readFileSync(cfgPath, "utf8")); } catch (e) {
28
+ cfgParseFailed = fs.existsSync(cfgPath);
29
+ }
30
+ const argv = process.argv.slice(2).filter((a) => a !== "--logout");
31
+ const wantLogout = process.argv.includes("--logout");
32
+ // For LOGOUT the key's own home wins: the key was minted on cfg.base, so a
33
+ // CLI base argument must not point the revoke at a different host (review
34
+ // finding — the real key would stay live while we claim success).
35
+ const base = (wantLogout
36
+ ? (cfg.base || argv[0] || "https://dev.demobites.com")
37
+ : (argv[0] || cfg.base || "https://dev.demobites.com")
38
+ ).replace(/\/+$/, "");
39
+
40
+ if (wantLogout) {
41
+ if (cfgParseFailed) {
42
+ // The file exists but is not JSON — it may still CONTAIN the raw key.
43
+ // Destroy it rather than leaving secrets in a corrupted file.
44
+ fs.writeFileSync(cfgPath, "{}\n");
45
+ fs.chmodSync(cfgPath, 0o600);
46
+ console.error("Config was corrupted — file wiped locally. If a key was inside, revoke it from the DemoBites app.");
47
+ process.exit(1);
48
+ }
49
+ if (!cfg.api_key) {
50
+ console.log("Not signed in — nothing to log out.");
51
+ process.exit(0);
52
+ }
53
+ if (argv[0] && cfg.base && argv[0].replace(/\/+$/, "") !== cfg.base.replace(/\/+$/, "")) {
54
+ console.error(`Note: ignoring base argument ${argv[0]} — the key was minted on ${cfg.base}, revoking there.`);
55
+ }
56
+ // Server-side revoke first (best-effort — local strip happens regardless,
57
+ // and a revoked-but-cached key fails closed at the API anyway).
58
+ try {
59
+ const res = await fetch(`${base}/api/recorder/key`, {
60
+ method: "DELETE",
61
+ headers: { Authorization: `Bearer ${cfg.api_key}` },
62
+ });
63
+ if (res.ok) console.log("Recorder key revoked on the server.");
64
+ else console.error(`Server revoke returned ${res.status} — key stripped locally anyway.`);
65
+ } catch (e) {
66
+ console.error(`Could not reach ${base} (${e.message}) — key stripped locally anyway.`);
67
+ }
68
+ delete cfg.api_key;
69
+ delete cfg.workspace;
70
+ fs.writeFileSync(cfgPath, JSON.stringify(cfg, null, 2) + "\n");
71
+ fs.chmodSync(cfgPath, 0o600);
72
+ console.log("Logged out. Run login.mjs to sign in again.");
73
+ process.exit(0);
74
+ }
75
+
76
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
77
+
78
+ let start;
79
+ try {
80
+ start = await fetch(`${base}/api/recorder/device`, {
81
+ method: "POST",
82
+ headers: { "Content-Type": "application/json" },
83
+ body: "{}",
84
+ });
85
+ } catch (e) {
86
+ console.error(`Could not reach ${base}: ${e.message}`);
87
+ process.exit(1);
88
+ }
89
+ if (!start.ok) {
90
+ console.error(`Device link start failed: ${start.status} ${await start.text().catch(() => "")}`);
91
+ process.exit(1);
92
+ }
93
+ const { device_code, user_code, verification_url, expires_in, interval } = await start.json();
94
+ if (!device_code || !user_code || !verification_url) {
95
+ console.error("Device link response missing fields.");
96
+ process.exit(1);
97
+ }
98
+ const verifyUrl = new URL(verification_url, base).toString();
99
+ console.log("");
100
+ console.log(` Open: ${verifyUrl}`);
101
+ console.log(` Code: ${user_code}`);
102
+ console.log("");
103
+ console.log("Approve the link in your browser. If a sign-in page appears first,");
104
+ console.log("sign in — the approval page follows with the same code. Waiting...");
105
+ if (process.platform === "darwin") spawnSync("open", [verifyUrl], { stdio: "ignore" });
106
+
107
+ const deadline = Date.now() + (expires_in ?? 900) * 1000;
108
+ const waitMs = Math.max(2, interval ?? 5) * 1000;
109
+ while (Date.now() < deadline) {
110
+ await sleep(waitMs);
111
+ let poll;
112
+ try {
113
+ poll = await fetch(`${base}/api/recorder/device`, {
114
+ method: "PUT",
115
+ headers: { "Content-Type": "application/json" },
116
+ body: JSON.stringify({ device_code }),
117
+ });
118
+ } catch {
119
+ continue; // transient network blip, keep polling
120
+ }
121
+ if (!poll.ok) continue;
122
+ const data = await poll.json().catch(() => null);
123
+ if (!data) continue;
124
+ if (data.status === "pending") continue;
125
+ if (data.status === "approved") {
126
+ cfg.base = base;
127
+ cfg.api_key = data.api_key;
128
+ cfg.workspace = data.workspace;
129
+ fs.mkdirSync(cfgDir, { recursive: true });
130
+ fs.writeFileSync(cfgPath, JSON.stringify(cfg, null, 2) + "\n");
131
+ fs.chmodSync(cfgPath, 0o600);
132
+ const wsName =
133
+ data.workspace && typeof data.workspace === "object"
134
+ ? data.workspace.name ?? data.workspace.slug ?? JSON.stringify(data.workspace)
135
+ : data.workspace;
136
+ console.log(`Approved. Recorder key saved to .recorder/config.json (workspace: ${wsName}).`);
137
+ process.exit(0);
138
+ }
139
+ // denied / expired / consumed — terminal states. The wording matters:
140
+ // a denial is an ANSWER, not an obstacle. Agents reading this output must
141
+ // NOT re-run login — report the outcome and wait for the human to ask.
142
+ if (data.status === "denied") {
143
+ console.error("The link was NOT approved. Nothing was connected. Do not retry automatically — wait until the human asks to sign in again.");
144
+ } else {
145
+ console.error(`Device link ${data.status}. Do not retry automatically — offer the human a fresh link and wait for their word.`);
146
+ }
147
+ process.exit(1);
148
+ }
149
+ console.error("The link expired before approval. Do not retry automatically — offer the human a fresh link and wait for their word.");
150
+ process.exit(1);