storyshot 0.1.0 → 0.1.3

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 CHANGED
@@ -108,11 +108,14 @@ so a change only pays for the lines that changed.
108
108
 
109
109
  When the flow is behind a login, the agent says so in its plan. Then:
110
110
 
111
- 1. A browser window opens (the `login` tool, or `storyshot login <url>`).
112
- 2. You log in there. 2FA and "Sign in with Google" work.
113
- 3. The session is saved to `videos/<site>-login.json` once you're logged in, or
114
- when you close the window. It includes cookies, localStorage and IndexedDB,
115
- and the file is added to `.gitignore` because it holds your session.
111
+ 1. A normal Chrome window opens (the `login` tool, or `storyshot login <url>`).
112
+ No script is attached to it while you log in, so "Sign in with Google" and
113
+ 2FA work as usual.
114
+ 2. You log in there. When you see the app logged in, tell the agent "done"
115
+ (or press Enter in the terminal). You can also just close the window.
116
+ 3. The session is saved to `videos/<site>-login.json`. It includes cookies,
117
+ localStorage, IndexedDB and sessionStorage, and the file is added to `.gitignore` because
118
+ it holds your session. The temporary browser profile is deleted.
116
119
  4. Every recording starts logged in, so the video never shows a login screen.
117
120
 
118
121
  If the session expires, Storyshot says so ("the saved login has expired, run
@@ -191,6 +194,7 @@ music: { bpm: 120, volume: 1 } # or { file: track.mp3 }, or fal
191
194
  sfx: true # click / whoosh / riser sounds
192
195
  voice: { provider: elevenlabs, voice: hpp4J3VqNfWAUOO0d1Us } # optional voiceover, see below
193
196
  storageState: auth.json # optional Playwright login state
197
+ ignoreHTTPSErrors: true # internal site with a self-signed HTTPS certificate (plain http:// needs nothing)
194
198
  captionPosition: bottom # or top; can also be set per step. A caption that would cover a step's click target or zoom subject moves to the other side automatically
195
199
  purpose: pitch # pitch | tutorial | course | docs (sets the defaults below)
196
200
  highlight: false # spotlight targets before click/type (per step or per action too)
package/bin/storyshot.mjs CHANGED
@@ -33,6 +33,8 @@ Options:
33
33
  --draft (render/compose) use the free local voice instead of a paid TTS provider and write
34
34
  <out>.draft.mp4; when it looks right, run \`compose\` without --draft (reuses the recording)
35
35
  --viewport 1600x900 (inspect, init)
36
+ --insecure (inspect, init) accept a self-signed HTTPS certificate (internal sites);
37
+ storyboards use \`ignoreHTTPSErrors: true\`
36
38
  `;
37
39
 
38
40
  const args = process.argv.slice(2);
@@ -80,11 +82,11 @@ try {
80
82
  } else if (cmd === 'login') {
81
83
  const { login, authFileFor } = await import('../src/auth.mjs');
82
84
  const r = await login(arg, { file: flag('save') ?? authFileFor(arg), viewport: viewport() });
83
- console.log(`✔ saved the login session to ${r.file} (${r.how}, ${r.cookies} cookies)${r.gitignore ? `\n added it to ${r.gitignore}` : ''}` +
85
+ console.log(`✔ saved the login session to ${r.file} (${r.how}, ${r.cookies} cookies, ${r.siteCookies} for this site${r.sessionStorage ? `, sessionStorage for ${r.sessionStorage} site(s)` : ''})${r.gitignore ? `\n added it to ${r.gitignore}` : ''}` +
84
86
  `\n use it: \`storageState: ${r.file.split(/[\\/]/).pop()}\` in the storyboard (path relative to the yaml), or \`--storage ${r.file}\` for inspect/init`);
85
87
  } else if (cmd === 'init') {
86
88
  const { initStoryboard } = await import('../src/init.mjs');
87
- const r = await initStoryboard(arg, { purpose: flag('purpose') ?? 'pitch', topic: flag('topic'), file: flag('file'), storageState: flag('storage'), viewport: viewport() });
89
+ const r = await initStoryboard(arg, { purpose: flag('purpose') ?? 'pitch', topic: flag('topic'), file: flag('file'), storageState: flag('storage'), ignoreHTTPSErrors: args.includes('--insecure'), viewport: viewport() });
88
90
  console.log(`✔ wrote ${r.file}\n screenshot: ${r.screenshot}\n next: edit it, then \`storyshot check ${r.file}\``);
89
91
  } else if (cmd === 'inspect') {
90
92
  await (await import('../src/doctor.mjs')).ensureBrowser();
@@ -94,7 +96,7 @@ try {
94
96
  const after = flag('after');
95
97
  console.log((await record(cfg, { log: () => {}, inspectAfter: after != null ? Number(after) : cfg.steps.length })).text);
96
98
  } else {
97
- console.log((await inspect(arg, { viewport: viewport(), storageState: flag('storage') })).text);
99
+ console.log((await inspect(arg, { viewport: viewport(), storageState: flag('storage'), ignoreHTTPSErrors: args.includes('--insecure') })).text);
98
100
  }
99
101
  } else if (cmd === 'check') {
100
102
  const r = check(arg, { draft: args.includes('--draft') });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "storyshot",
3
- "version": "0.1.0",
3
+ "version": "0.1.3",
4
4
  "description": "Turn a short YAML storyboard into a polished product demo video of your web app — smooth cursor, auto zoom, captions, music.",
5
5
  "author": "boheling <boheling@gmail.com>",
6
6
  "repository": {
@@ -82,9 +82,14 @@ everything.
82
82
  - Tell the user a browser window will open and that they should log in
83
83
  there, preferably with a demo account (the video shows whatever is on
84
84
  screen).
85
- - Call `login` with a URL behind the login. It saves the session to
86
- `videos/<site>-login.json` and adds it to `.gitignore`. If it returns
87
- "still waiting", call it again.
85
+ - Call `login` with a URL behind the login. It opens a normal Chrome
86
+ window and returns at once. Tell the user to log in there and, once the
87
+ app shows them logged in, to leave the window open and reply "done".
88
+ Nothing watches the window meanwhile (Google sign-in breaks if a script
89
+ probes it), so wait for their reply.
90
+ - Then call `login` again with the same URL and `done: true`. It saves the
91
+ session to `videos/<site>-login.json`, adds it to `.gitignore` and closes
92
+ the window. (CLI: `storyshot login <url>`, then press Enter.)
88
93
  - Pass that file as `storage_state` to `inspect_page` and `init_storyboard`,
89
94
  and add `storageState: <site>-login.json` to the storyboard.
90
95
  - Never ask for, store or type a password, and never write login steps.
@@ -163,6 +168,9 @@ Rules of thumb from real renders:
163
168
  `at (x, y)`.
164
169
  - Never put real credentials in a storyboard. For logged-in apps, use
165
170
  `storageState` (see step 1).
171
+ - Internal sites: `http://` URLs just work. If an `https://` internal site
172
+ fails with an untrusted certificate, add `ignoreHTTPSErrors: true` (inspect/init:
173
+ `ignore_https_errors`). Never turn it on for public sites.
166
174
 
167
175
  Run `check_storyboard` after every edit, and fix every ✖ before rendering. It
168
176
  checks structure, typos and leftover `TODO`s, but not whether targets exist on
package/src/auth.mjs CHANGED
@@ -1,7 +1,8 @@
1
1
  // Logged-in apps: detect login walls, and `storyshot login` — the user logs in once in a real
2
2
  // browser window and the session (cookies, localStorage, IndexedDB) is saved for recording.
3
3
  import { chromium } from 'playwright';
4
- import { execFileSync } from 'node:child_process';
4
+ import { execFileSync, spawn } from 'node:child_process';
5
+ import os from 'node:os';
5
6
  import fs from 'node:fs';
6
7
  import path from 'node:path';
7
8
  import { VIDEO_DIR, siteOf } from './config.mjs';
@@ -41,44 +42,121 @@ export function gitIgnored(file) {
41
42
  function ignore(file) {
42
43
  if (gitIgnored(file) === true) return null;
43
44
  let root = process.cwd();
44
- try { root = execFileSync('git', ['rev-parse', '--show-toplevel'], { cwd: path.dirname(path.resolve(file)) }).toString().trim(); } catch {}
45
+ try { root = execFileSync('git', ['rev-parse', '--show-toplevel'], { cwd: path.dirname(path.resolve(file)), stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim(); } catch {}
45
46
  const gi = path.join(root, '.gitignore');
46
47
  const rel = path.relative(root, path.resolve(file)).split(path.sep).join('/');
48
+ if (rel.startsWith('../') || path.isAbsolute(rel)) return null; // outside the project: nothing to ignore
47
49
  const cur = fs.existsSync(gi) ? fs.readFileSync(gi, 'utf8') : '';
48
50
  if (cur.split('\n').includes(rel)) return null;
49
51
  fs.writeFileSync(gi, `${cur}${cur && !cur.endsWith('\n') ? '\n' : ''}# Storyshot login session (contains your session cookies — never commit)\n${rel}\n`);
50
52
  return gi;
51
53
  }
52
54
 
53
- // Open a visible browser at `url`; save the session when the user has logged in (a login wall
54
- // appeared and then went away) or closes the window. Resolves with the saved file.
55
- export async function login(url, { file = authFileFor(url), viewport = { width: 1600, height: 900 }, timeoutMs = 10 * 60_000, log = console.log } = {}) {
56
- let browser;
57
- try { browser = await chromium.launch({ headless: false }); } catch (e) {
58
- throw new Error(`could not open a browser window (${e.message.split('\n')[0]}). Log in on a machine with a screen ` +
59
- `(storyshot login ${url} --save ${file}) and copy the file here.`);
55
+ // --- login ------------------------------------------------------------------------------------
56
+ // The user logs in in a plain Chrome window that NOTHING is attached to while they do it: Google
57
+ // (and other SSO providers) refuse automated browsers, polling the page from a script makes
58
+ // Google's sign-in bounce between the app and accounts.google.com, and even an open DevTools port
59
+ // gets "This browser or app may not be secure". So Chrome runs with only a temporary profile; when
60
+ // the user is done we quit it and read the session back from that profile.
61
+
62
+ function chromeExecutable() {
63
+ const home = os.homedir();
64
+ const candidates = process.platform === 'darwin'
65
+ ? ['/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', path.join(home, 'Applications/Google Chrome.app/Contents/MacOS/Google Chrome')]
66
+ : process.platform === 'win32'
67
+ ? [process.env.PROGRAMFILES, process.env['PROGRAMFILES(X86)'], process.env.LOCALAPPDATA].filter(Boolean).map((d) => path.join(d, 'Google', 'Chrome', 'Application', 'chrome.exe'))
68
+ : ['/usr/bin/google-chrome', '/usr/bin/google-chrome-stable', '/usr/bin/chromium', '/usr/bin/chromium-browser'];
69
+ return candidates.find((c) => fs.existsSync(c)) ?? chromium.executablePath();
70
+ }
71
+
72
+ // Opens the window and returns at once. Pass the result to finishLogin() when the user is done.
73
+ export function openLoginWindow(url, { viewport = { width: 1600, height: 900 }, headless = false } = {}) {
74
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'storyshot-login-'));
75
+ // "Continue where you left off" also keeps session cookies when the user closes the window.
76
+ fs.mkdirSync(path.join(dir, 'Default'), { recursive: true });
77
+ fs.writeFileSync(path.join(dir, 'Default', 'Preferences'), JSON.stringify({ session: { restore_on_startup: 1 } }));
78
+ const exe = chromeExecutable();
79
+ const args = [`--user-data-dir=${dir}`, '--no-first-run', '--no-default-browser-check',
80
+ `--window-size=${viewport.width},${viewport.height}`, ...(headless ? ['--headless=new'] : []), url];
81
+ let proc;
82
+ try { proc = spawn(exe, args, { stdio: 'ignore' }); } catch (e) {
83
+ throw new Error(`could not open a browser window (${e.message}). Log in on a machine with a screen (storyshot login ${url}) and copy the file here.`);
60
84
  }
61
- const context = await browser.newContext({ viewport });
62
- const page = await context.newPage();
63
- await page.goto(url, { waitUntil: 'domcontentloaded' }).catch(() => {});
64
- log(`A browser window is open at ${url}. Log in there (2FA and "Sign in with Google" work).`);
65
- log('The session is saved automatically once you are logged in — or close the window when done.');
66
- let state = null, sawWall = false, clearFor = 0, done = 'timeout';
67
- const end = Date.now() + timeoutMs;
68
- while (Date.now() < end) {
69
- if (!browser.isConnected() || page.isClosed()) { done = 'closed'; break; }
70
- try {
71
- state = await context.storageState({ indexedDB: true });
72
- const d = await detectLogin(page);
73
- if (d.wall) { sawWall = true; clearFor = 0; } else if (sawWall && ++clearFor >= 3) { done = 'logged in'; break; }
74
- } catch {}
85
+ const session = { url, dir, exe, proc, exited: false };
86
+ proc.on('exit', () => { session.exited = true; });
87
+ proc.on('error', () => { session.exited = true; });
88
+ return session;
89
+ }
90
+
91
+ // Quit the window (if the user hasn't), read the session back from its profile, save it and delete
92
+ // the temporary profile.
93
+ export async function finishLogin(session, { file = authFileFor(session.url), log = console.log } = {}) {
94
+ const { url, dir, exe, proc } = session;
95
+ const how = session.exited ? 'window closed' : 'logged in';
96
+ let state;
97
+ try {
98
+ if (!session.exited) {
99
+ // SIGTERM makes Chrome shut down cleanly and flush cookies; force it if it hangs
100
+ proc.kill('SIGTERM');
101
+ const t = setTimeout(() => proc.kill('SIGKILL'), 10_000);
102
+ await new Promise((r) => (session.exited ? r() : proc.once('exit', r)));
103
+ clearTimeout(t);
104
+ }
105
+ // --restore-last-session reopens the user's tabs, which brings back their sessionStorage (some
106
+ // apps, e.g. Microsoft MSAL, keep the login token there; storageState() doesn't capture it)
107
+ const context = await chromium.launchPersistentContext(dir, { executablePath: exe, headless: true, ignoreHTTPSErrors: true, args: ['--restore-last-session'] });
75
108
  await new Promise((r) => setTimeout(r, 1000));
109
+ const sessionStorage = {};
110
+ for (const p of context.pages()) {
111
+ if (!/^https?:/.test(p.url())) continue;
112
+ await p.waitForLoadState('load', { timeout: 15_000 }).catch(() => {});
113
+ const items = await p.evaluate(() => Object.entries(window.sessionStorage)).catch(() => []);
114
+ if (items.length) Object.assign(sessionStorage[new URL(p.url()).origin] ??= {}, Object.fromEntries(items));
115
+ }
116
+ // load the app once in a fresh tab, so its localStorage / IndexedDB are part of the snapshot
117
+ const page = await context.newPage();
118
+ await page.goto(url, { waitUntil: 'load', timeout: 30_000 }).catch(() => {});
119
+ state = await context.storageState({ indexedDB: true });
120
+ const extra = Object.entries(sessionStorage).map(([origin, items]) => ({ origin, items: Object.entries(items).map(([name, value]) => ({ name, value })) }));
121
+ if (extra.length) state.sessionStorage = extra;
122
+ await context.close().catch(() => {});
123
+ } finally {
124
+ if (!session.exited) proc.kill('SIGKILL');
125
+ fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 });
76
126
  }
77
- await browser.close().catch(() => {});
78
- if (!state) throw new Error('the browser was closed before anything could be saved — run login again');
79
- if (done === 'timeout') log('warning: timed out waiting for the login; saving the session as it is');
127
+ const host = new URL(url).hostname.replace(/^www\./, '');
128
+ const siteCookies = state.cookies.filter((c) => host.endsWith(c.domain.replace(/^\./, '')) || c.domain.replace(/^\./, '').endsWith(host)).length;
80
129
  fs.mkdirSync(path.dirname(path.resolve(file)), { recursive: true });
81
130
  fs.writeFileSync(file, JSON.stringify(state, null, 1));
82
131
  const gi = ignore(file);
83
- return { file: path.resolve(file), how: done, gitignore: gi, cookies: state.cookies.length, sawWall };
132
+ return { file: path.resolve(file), how, gitignore: gi, cookies: state.cookies.length, siteCookies, sessionStorage: state.sessionStorage?.length ?? 0 };
133
+ }
134
+
135
+ // A browser context with a saved login: storageState plus the sessionStorage that login() saved
136
+ // alongside it (Playwright has no sessionStorage in storageState, so it is restored by a script).
137
+ export async function sessionContext(browser, { storageState, ...options } = {}) {
138
+ let state = storageState;
139
+ if (typeof state === 'string') state = JSON.parse(fs.readFileSync(state, 'utf8'));
140
+ const { sessionStorage, ...rest } = state ?? {};
141
+ const context = await browser.newContext({ ...options, storageState: state ? rest : undefined });
142
+ if (sessionStorage?.length) {
143
+ await context.addInitScript((saved) => {
144
+ const s = saved.find((o) => o.origin === location.origin);
145
+ if (s) for (const { name, value } of s.items) if (window.sessionStorage.getItem(name) === null) window.sessionStorage.setItem(name, value);
146
+ }, sessionStorage);
147
+ }
148
+ return context;
149
+ }
150
+
151
+ // CLI: open the window, wait for Enter (or the window closing), then save.
152
+ export async function login(url, { file = authFileFor(url), viewport, log = console.log } = {}) {
153
+ const session = openLoginWindow(url, { viewport });
154
+ log(`A Chrome window is open at ${url}. Log in there (2FA and "Sign in with Google" work).`);
155
+ log('When you see the app logged in, come back here and press Enter (or just close the window).');
156
+ await new Promise((resolve) => {
157
+ const done = () => { process.stdin.pause(); resolve(); };
158
+ process.stdin.resume(); process.stdin.once('data', done);
159
+ session.proc.once('exit', done);
160
+ });
161
+ return finishLogin(session, { file, log });
84
162
  }
package/src/check.mjs CHANGED
@@ -4,7 +4,7 @@ import YAML from 'yaml';
4
4
  import { loadConfig } from './config.mjs';
5
5
  import { gitIgnored } from './auth.mjs';
6
6
 
7
- const TOP = ['name', 'url', 'out', 'purpose', 'viewport', 'output', 'theme', 'music', 'voice', 'sfx', 'storageState', 'colorScheme',
7
+ const TOP = ['name', 'url', 'out', 'purpose', 'viewport', 'output', 'theme', 'music', 'voice', 'sfx', 'storageState', 'ignoreHTTPSErrors', 'colorScheme',
8
8
  'scrollOffset', 'captionPosition', 'highlight', 'stepNumbers', 'subtitles', 'chapters', 'clips', 'maxWait', 'setup', 'steps', 'end_card'];
9
9
  const STEP = ['caption', 'say', 'sayAt', 'duration', 'zoom', 'actions', 'hidden', 'sfx', 'highlight', 'captionPosition'];
10
10
  const ZOOM = ['target', 'scale', 'start', 'end', 'ramp', 'offset'];
package/src/config.mjs CHANGED
@@ -29,7 +29,20 @@ export const PURPOSES = {
29
29
  // .mp4, so a promo, a tutorial and a lesson for the same app never collide or all read "demo".
30
30
  export const VIDEO_DIR = 'videos';
31
31
  export const KINDS = { pitch: 'promo', tutorial: 'tutorial', course: 'lesson', docs: 'docs' };
32
- export const siteOf = (url) => new URL(url).hostname.replace(/^www\./, '').split('.')[0];
32
+ // pxgenius.ai → pxgenius; an IP or a port is kept whole so two internal apps don't share a name
33
+ // (http://10.1.2.3:8080 → 10-1-2-3-8080, http://localhost:3000 → localhost-3000)
34
+ export const siteOf = (url) => {
35
+ const { hostname, port } = new URL(url);
36
+ const host = hostname.replace(/^\[|\]$/g, '');
37
+ const ip = /^[\d.]+$/.test(host) || host.includes(':');
38
+ return [ip ? host.replace(/[.:]+/g, '-').replace(/^-|-$/g, '') : host.replace(/^www\./, '').split('.')[0], port].filter(Boolean).join('-');
39
+ };
40
+
41
+ // A clear message for an internal site with a self-signed / untrusted HTTPS certificate.
42
+ export const certError = (e, url) => (/ERR_CERT|ERR_SSL/.test(e?.message ?? '')
43
+ ? new Error(`${url} has an untrusted HTTPS certificate (${e.message.match(/ERR_\w+/)[0]}). If it is your own internal site with a self-signed ` +
44
+ 'certificate, add `ignoreHTTPSErrors: true` to the storyboard (inspect/init: --insecure, MCP: ignore_https_errors: true).')
45
+ : null);
33
46
  const slug = (s) => String(s).toLowerCase().normalize('NFKD').replace(/[^\p{L}\p{N}]+/gu, '-').replace(/^-+|-+$/g, '').slice(0, 40);
34
47
 
35
48
  export function defaultStoryboard(url, purpose = 'pitch', topic) {
@@ -108,6 +121,7 @@ export function loadConfig(file, { draft = false } = {}) {
108
121
  output,
109
122
  scale,
110
123
  colorScheme: raw.colorScheme ?? 'light',
124
+ ignoreHTTPSErrors: raw.ignoreHTTPSErrors === true,
111
125
  storageState: raw.storageState ? path.resolve(dir, raw.storageState) : undefined,
112
126
  scrollOffset: raw.scrollOffset ?? 40,
113
127
  captionPosition: raw.captionPosition ?? 'bottom',
package/src/init.mjs CHANGED
@@ -9,11 +9,11 @@ import { loginAdvice } from './auth.mjs';
9
9
  const q = (s) => JSON.stringify(String(s));
10
10
  const tgt = (t) => (typeof t === 'string' ? q(t) : `{ ${Object.entries(t).map(([k, v]) => `${k}: ${typeof v === 'string' ? q(v) : v}`).join(', ')} }`);
11
11
 
12
- export async function initStoryboard(url, { purpose = 'pitch', topic, file, storageState, viewport = { width: 1600, height: 900 } } = {}) {
12
+ export async function initStoryboard(url, { purpose = 'pitch', topic, file, storageState, ignoreHTTPSErrors = false, viewport = { width: 1600, height: 900 } } = {}) {
13
13
  if (!PURPOSES[purpose]) throw new Error(`purpose must be one of ${Object.keys(PURPOSES).join(', ')}`);
14
14
  file ??= defaultStoryboard(url, purpose, topic);
15
15
  if (fs.existsSync(file)) throw new Error(`${file} already exists — edit it, or choose another path`);
16
- const page = await inspect(url, { viewport, storageState, outDir: path.join(path.dirname(path.resolve(file)), '.storyshot', 'inspect') });
16
+ const page = await inspect(url, { viewport, storageState, ignoreHTTPSErrors, outDir: path.join(path.dirname(path.resolve(file)), '.storyshot', 'inspect') });
17
17
  if (page.login?.wall) {
18
18
  throw new Error(`${url} asks to log in (${page.login.reason})${storageState ? ' even with the saved session — it has probably expired, log in again' : ''}. ` +
19
19
  `${loginAdvice(url)} Then run init again with that session (MCP: storage_state, CLI: --storage).`);
@@ -38,7 +38,7 @@ name: ${name}
38
38
  url: ${page.url}
39
39
  purpose: ${purpose}
40
40
  out: ${name}.mp4
41
- ${storageState ? `storageState: ${path.relative(path.dirname(path.resolve(file)), path.resolve(storageState)).split(path.sep).join('/')} # saved login (never commit it)\n` : ''}${narrated ? 'voice: { provider: kokoro } # free, local, English. For the final: { provider: elevenlabs, voice: <voice id> } + ELEVENLABS_API_KEY' : '# voice: { provider: kokoro } # optional voiceover; add `say:` lines to steps'}
41
+ ${storageState ? `storageState: ${path.relative(path.dirname(path.resolve(file)), path.resolve(storageState)).split(path.sep).join('/')} # saved login (never commit it)\n` : ''}${ignoreHTTPSErrors ? 'ignoreHTTPSErrors: true # internal site with a self-signed certificate\n' : ''}${narrated ? 'voice: { provider: kokoro } # free, local, English. For the final: { provider: elevenlabs, voice: <voice id> } + ELEVENLABS_API_KEY' : '# voice: { provider: kokoro } # optional voiceover; add `say:` lines to steps'}
42
42
 
43
43
  setup: # runs before recording; clean the frame here${dismiss ? `\n - dismiss: ${tgt(dismiss.target)}` : '\n # - dismiss: { role: button, name: Accept cookies }'}
44
44
 
package/src/inspect.mjs CHANGED
@@ -3,7 +3,8 @@
3
3
  import { chromium } from 'playwright';
4
4
  import fs from 'node:fs';
5
5
  import path from 'node:path';
6
- import { detectLogin, loginAdvice } from './auth.mjs';
6
+ import { detectLogin, loginAdvice, sessionContext } from './auth.mjs';
7
+ import { certError } from './config.mjs';
7
8
 
8
9
  // Runs in the page. Positions are relative to the top of the main scrolling content,
9
10
  // which may be the window or an inner scroll container (common in web apps).
@@ -161,11 +162,15 @@ export async function describePage(page, viewport, { maxElements = 80 } = {}) {
161
162
  return { text: lines.join('\n'), height: info.height, title: info.title, url, headings: info.headings, anchors: info.anchors, elements };
162
163
  }
163
164
 
164
- export async function inspect(url, { viewport = { width: 1600, height: 900 }, storageState, outDir = '.storyshot/inspect', maxElements = 80 } = {}) {
165
+ export async function inspect(url, { viewport = { width: 1600, height: 900 }, storageState, ignoreHTTPSErrors = false, outDir = '.storyshot/inspect', maxElements = 80 } = {}) {
165
166
  const browser = await chromium.launch();
166
- const context = await browser.newContext({ viewport, storageState });
167
+ const context = await sessionContext(browser, { viewport, storageState, ignoreHTTPSErrors });
167
168
  const page = await context.newPage();
168
- await page.goto(url, { waitUntil: 'networkidle', timeout: 45000 }).catch(() => page.waitForLoadState('domcontentloaded'));
169
+ await page.goto(url, { waitUntil: 'networkidle', timeout: 45000 }).catch(async (e) => {
170
+ const cert = certError(e, url);
171
+ if (cert) { await browser.close(); throw cert; }
172
+ await page.waitForLoadState('domcontentloaded');
173
+ });
169
174
  await page.waitForTimeout(1500); // let late toasts / banners appear so they get listed
170
175
 
171
176
  const desc = await describePage(page, viewport, { maxElements });
package/src/mcp.mjs CHANGED
@@ -14,7 +14,7 @@ import { record } from './record.mjs';
14
14
  import { guide } from './guide.mjs';
15
15
  import { check, formatCheck } from './check.mjs';
16
16
  import { initStoryboard } from './init.mjs';
17
- import { login, authFileFor } from './auth.mjs';
17
+ import { openLoginWindow, finishLogin, authFileFor } from './auth.mjs';
18
18
  import { ensureBrowser, ffmpegInstallCommand } from './doctor.mjs';
19
19
  import { execFileSync } from 'node:child_process';
20
20
 
@@ -54,11 +54,12 @@ server.registerTool('init_storyboard', {
54
54
  purpose: z.enum(['pitch', 'tutorial', 'course', 'docs']).optional().describe('pitch = 15–30s promo (default); tutorial = narrated how-to; course = narrated lesson; docs = silent per-step GIFs'),
55
55
  topic: z.string().optional().describe('Short topic for the file name, e.g. "export-pdf" → videos/<site>-tutorial-export-pdf.yaml. Use it for tutorials/lessons, since a site usually gets several'),
56
56
  storage_state: z.string().optional().describe('Absolute path to a saved login (from the login tool) for apps behind a login'),
57
+ ignore_https_errors: z.boolean().optional().describe('Accept a self-signed HTTPS certificate (internal sites only); also writes ignoreHTTPSErrors: true into the storyboard'),
57
58
  },
58
- }, async ({ url, path: file, purpose = 'pitch', topic, storage_state }) => {
59
+ }, async ({ url, path: file, purpose = 'pitch', topic, storage_state, ignore_https_errors = false }) => {
59
60
  try {
60
61
  await ensureBrowser(() => {});
61
- const r = await initStoryboard(url, { purpose, topic, file: file ? abs(file) : undefined, storageState: storage_state ? abs(storage_state) : undefined });
62
+ const r = await initStoryboard(url, { purpose, topic, file: file ? abs(file) : undefined, storageState: storage_state ? abs(storage_state) : undefined, ignoreHTTPSErrors: ignore_https_errors });
62
63
  return { content: [text(`${r.login?.hint ? `ℹ Login: ${r.login.hint}. If the flow needs it, call login first.\n\n` : ''}Wrote ${r.file}:\n\n${r.yaml}`), image(r.screenshot)] };
63
64
  } catch (e) { return fail(`init failed: ${e.message}`); }
64
65
  });
@@ -73,10 +74,11 @@ server.registerTool('inspect_page', {
73
74
  storyboard: z.string().optional().describe('Absolute path to a storyboard YAML (instead of url)'),
74
75
  after_step: z.number().int().min(0).optional().describe('With storyboard: run steps 1..N first (0 = only setup). Default: all steps.'),
75
76
  storage_state: z.string().optional().describe('With url: absolute path to a saved login (from the login tool)'),
77
+ ignore_https_errors: z.boolean().optional().describe('With url: accept a self-signed HTTPS certificate (internal sites only)'),
76
78
  viewport_width: z.number().optional(),
77
79
  viewport_height: z.number().optional(),
78
80
  },
79
- }, async ({ url, storyboard, after_step, storage_state, viewport_width = 1600, viewport_height = 900 }) => {
81
+ }, async ({ url, storyboard, after_step, storage_state, ignore_https_errors = false, viewport_width = 1600, viewport_height = 900 }) => {
80
82
  try {
81
83
  await ensureBrowser(() => {});
82
84
  if (storyboard) {
@@ -85,7 +87,7 @@ server.registerTool('inspect_page', {
85
87
  return { content: [text(r.text), image(r.screenshot)] };
86
88
  }
87
89
  if (!url) return fail('give either url or storyboard');
88
- const r = await inspect(url, { viewport: { width: viewport_width, height: viewport_height }, storageState: storage_state ? abs(storage_state) : undefined });
90
+ const r = await inspect(url, { viewport: { width: viewport_width, height: viewport_height }, storageState: storage_state ? abs(storage_state) : undefined, ignoreHTTPSErrors: ignore_https_errors });
89
91
  return { content: [text(r.text), image(r.screenshot)] };
90
92
  } catch (e) {
91
93
  const extra = [];
@@ -95,35 +97,41 @@ server.registerTool('inspect_page', {
95
97
  }
96
98
  });
97
99
 
98
- // --- login: the user logs in once in a visible browser window ---------------------------------
100
+ // --- login: the user logs in once in a plain Chrome window ---------------------------------------
101
+ // Two calls: the first opens the window and returns; after the user says they are logged in, the
102
+ // second (done: true) saves the session. Nothing watches the window in between, because Google
103
+ // sign-in breaks when a script probes it.
99
104
  const logins = new Map();
100
105
  server.registerTool('login', {
101
106
  title: 'Log in (user)',
102
- description: 'For apps behind a login. Opens a visible browser window at `url` where THE USER logs in (never type passwords yourself); ' +
103
- 'the session is saved to a file (added to .gitignore) once they are logged in or close the window. Tell the user to look for the window first. ' +
104
- `Waits up to ${Math.round(WAIT_MS / 1000)}s; if they are still logging in, call login again with the same arguments to keep waiting. ` +
107
+ description: 'For apps behind a login. Call 1 (`url`): opens a normal Chrome window at `url` and returns at once. ' +
108
+ 'Then tell the user: "A Chrome window opened. Log in there (Sign in with Google and 2FA work). When you see the app logged in, ' +
109
+ 'leave the window open and reply \'done\'." Never type passwords yourself, and do NOT make call 2 until the user replies. ' +
110
+ 'Call 2 (same `url`, `done: true`): saves the session to a file (added to .gitignore) and closes the window. ' +
105
111
  'Then use the file as storage_state in inspect_page/init_storyboard and as `storageState:` in the storyboard.',
106
112
  inputSchema: {
107
113
  url: z.string().describe('A page of the app that requires login (its login page or a page behind it)'),
114
+ done: z.boolean().optional().describe('true only after the user has said they are logged in'),
108
115
  path: z.string().optional().describe('Absolute path for the session file. Default: <cwd>/videos/<site>-login.json'),
109
116
  },
110
- }, async ({ url, path: file }) => {
117
+ }, async ({ url, done, path: file }) => {
111
118
  const out = abs(file ?? authFileFor(url));
112
- await ensureBrowser(() => {});
113
- let job = logins.get(out);
114
- if (!job || job.done) {
115
- job = { done: false, logs: [] };
116
- job.promise = login(url, { file: out, log: (m) => job.logs.push(m) }).then((r) => { job.result = r; }, (e) => { job.error = e; }).finally(() => { job.done = true; });
117
- logins.set(out, job);
119
+ if (!done) {
120
+ const old = logins.get(out);
121
+ if (old && !old.exited) return { content: [text(`The login window for ${url} is already open. Ask the user to log in there and reply when done, then call login with done: true.`)] };
122
+ await ensureBrowser(() => {});
123
+ try { logins.set(out, openLoginWindow(url)); } catch (e) { return fail(e.message); }
124
+ return { content: [text(`A Chrome window is open at ${url}. Tell the user: log in there (Sign in with Google and 2FA work); when the app shows them logged in, leave the window open and reply "done". ` +
125
+ 'Wait for their reply, then call login again with the same url and done: true.')] };
118
126
  }
119
- const deadline = Date.now() + WAIT_MS;
120
- while (!job.done && Date.now() < deadline) await new Promise((r) => setTimeout(r, 1000));
121
- if (!job.done) return { content: [text(`A browser window is open at ${url}. Still waiting for the user to log in — ask them to finish, then call login again with the same arguments.`)] };
122
- if (job.error) return fail(`login failed: ${job.error.message}`);
123
- const r = job.result;
124
- return { content: [text(`✔ Saved the login session to ${r.file} (${r.how}, ${r.cookies} cookies).${r.gitignore ? ` Added it to ${r.gitignore}.` : ''}\n` +
127
+ const session = logins.get(out);
128
+ if (!session) return fail('no login window is open for this url — call login without done first');
129
+ logins.delete(out);
130
+ let r;
131
+ try { r = await finishLogin(session, { file: out }); } catch (e) { return fail(`login failed: ${e.message}`); }
132
+ return { content: [text(`✔ Saved the login session to ${r.file} (${r.how}, ${r.cookies} cookies, ${r.siteCookies} for this site${r.sessionStorage ? `, sessionStorage for ${r.sessionStorage} site(s)` : ''}).${r.gitignore ? ` Added it to ${r.gitignore}.` : ''}\n` +
125
133
  `Next: inspect_page with storage_state "${r.file}" to confirm you see the logged-in app, and put \`storageState: ${path.basename(r.file)}\` (path relative to the yaml) in the storyboard.` +
126
- (r.sawWall ? '' : '\nNote: no login form was seen in that window — if the user did not log in, inspect will show the login wall again.'))] };
134
+ (r.siteCookies || r.sessionStorage ? '' : '\nNote: no cookies were saved for this site — the user may not have finished logging in; inspect will show the login wall again if so.'))] };
127
135
  });
128
136
 
129
137
  server.registerTool('check_storyboard', {
package/src/record.mjs CHANGED
@@ -4,7 +4,8 @@ import { chromium } from 'playwright';
4
4
  import fs from 'node:fs';
5
5
  import path from 'node:path';
6
6
  import { describePage } from './inspect.mjs';
7
- import { detectLogin, loginAdvice } from './auth.mjs';
7
+ import { detectLogin, loginAdvice, sessionContext } from './auth.mjs';
8
+ import { certError } from './config.mjs';
8
9
 
9
10
  // Injected into every page: fake cursor, click ripple, smooth in-page anchor scrolling.
10
11
  function pageScript({ scrollOffset }) {
@@ -154,8 +155,8 @@ export async function record(cfg, { log = console.log, inspectAfter = null } = {
154
155
  const { width: VW, height: VH } = cfg.viewport;
155
156
 
156
157
  const browser = await chromium.launch();
157
- const context = await browser.newContext({
158
- viewport: cfg.viewport, deviceScaleFactor: cfg.scale, colorScheme: cfg.colorScheme, storageState: cfg.storageState,
158
+ const context = await sessionContext(browser, {
159
+ viewport: cfg.viewport, deviceScaleFactor: cfg.scale, colorScheme: cfg.colorScheme, storageState: cfg.storageState, ignoreHTTPSErrors: cfg.ignoreHTTPSErrors,
159
160
  });
160
161
  await context.addInitScript(pageScript, { scrollOffset: cfg.scrollOffset });
161
162
  const page = await context.newPage();
@@ -354,7 +355,7 @@ export async function record(cfg, { log = console.log, inspectAfter = null } = {
354
355
  }
355
356
 
356
357
  log(`opening ${cfg.url}`);
357
- await page.goto(cfg.url, { waitUntil: 'networkidle' }).catch(() => page.goto(cfg.url));
358
+ await page.goto(cfg.url, { waitUntil: 'networkidle' }).catch(() => page.goto(cfg.url)).catch((e) => { throw certError(e, cfg.url) ?? e; });
358
359
  // sent to a login page instead of the start url → no (valid) session
359
360
  if (new URL(page.url()).pathname !== new URL(cfg.url).pathname || cfg.storageState) {
360
361
  const d = await detectLogin(page);