autokap 1.6.1 → 1.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -19,6 +19,7 @@ declare class BrowserPool {
19
19
  lang?: string;
20
20
  colorScheme?: 'light' | 'dark';
21
21
  storageState?: BrowserStorageState;
22
+ extraHttpHeaders?: Record<string, string>;
22
23
  }): Promise<BrowserContext>;
23
24
  /**
24
25
  * Release a context back to the pool. Closes the context and unblocks
@@ -67,12 +67,14 @@ class BrowserPool {
67
67
  await new Promise((resolve) => this.queue.push(resolve));
68
68
  }
69
69
  await this.ensureBrowser();
70
+ const extra = options?.extraHttpHeaders;
70
71
  const context = await this.browser.newContext({
71
72
  viewport,
72
73
  deviceScaleFactor: normalizeDeviceScaleFactor(deviceScaleFactor),
73
74
  locale: options?.lang ? options.lang : 'en-US',
74
75
  colorScheme: options?.colorScheme ?? 'light',
75
76
  storageState: options?.storageState,
77
+ ...(extra && Object.keys(extra).length > 0 ? { extraHTTPHeaders: extra } : {}),
76
78
  });
77
79
  this.activeContexts++;
78
80
  this.captureCount++;
package/dist/browser.js CHANGED
@@ -911,6 +911,7 @@ export class Browser {
911
911
  lang: langToLocale(options.lang ?? 'en'),
912
912
  colorScheme: options.colorScheme ?? 'light',
913
913
  storageState: options.storageState,
914
+ extraHttpHeaders: options.extraHttpHeaders,
914
915
  });
915
916
  instance.page = await instance.context.newPage();
916
917
  instance.poolContext = true;
@@ -1030,6 +1031,9 @@ export class Browser {
1030
1031
  locale: langToLocale(options.lang ?? 'en'),
1031
1032
  colorScheme: options.colorScheme ?? 'light',
1032
1033
  storageState: options.storageState,
1034
+ ...(options.extraHttpHeaders && Object.keys(options.extraHttpHeaders).length > 0
1035
+ ? { extraHTTPHeaders: options.extraHttpHeaders }
1036
+ : {}),
1033
1037
  };
1034
1038
  // Dedicated browser process for clip capture. Not pooled because clip
1035
1039
  // capture installs context-level init scripts (cursor overlay). Cloud Run
@@ -5368,7 +5372,13 @@ export class Browser {
5368
5372
  async setLanguage(lang) {
5369
5373
  const context = this.ensureContext();
5370
5374
  const page = this.ensurePage();
5371
- await context.setExtraHTTPHeaders({ 'Accept-Language': lang });
5375
+ // `setExtraHTTPHeaders` REPLACES the header map — merge with the
5376
+ // environment-level auth headers so a SET_LOCALE opcode doesn't strip
5377
+ // them mid-run.
5378
+ await context.setExtraHTTPHeaders({
5379
+ ...(this.options.extraHttpHeaders ?? {}),
5380
+ 'Accept-Language': lang,
5381
+ });
5372
5382
  await page.addInitScript((locale) => {
5373
5383
  Object.defineProperty(navigator, 'language', { get: () => locale, configurable: true });
5374
5384
  Object.defineProperty(navigator, 'languages', { get: () => [locale], configurable: true });
@@ -5485,12 +5495,14 @@ export class Browser {
5485
5495
  return this.context;
5486
5496
  }
5487
5497
  buildContextOptions() {
5498
+ const extra = this.options.extraHttpHeaders;
5488
5499
  return {
5489
5500
  viewport: this.options.viewport,
5490
5501
  deviceScaleFactor: normalizeDeviceScaleFactor(this.options.deviceScaleFactor),
5491
5502
  locale: langToLocale(this.options.lang ?? 'en'),
5492
5503
  colorScheme: this.options.colorScheme ?? 'light',
5493
5504
  storageState: this.options.storageState,
5505
+ ...(extra && Object.keys(extra).length > 0 ? { extraHTTPHeaders: extra } : {}),
5494
5506
  };
5495
5507
  }
5496
5508
  }
@@ -114,6 +114,12 @@ export interface ArtifactUploadMetadata {
114
114
  mimeType: string;
115
115
  captureType: "fullpage" | "element";
116
116
  captureUrl: string;
117
+ /**
118
+ * Document title captured from the page at screenshot time (Playwright's
119
+ * `page.title()`). Server uses it as the tab title in browser mockups; falls
120
+ * back to `parsed.hostname` when absent (older CLIs do not send it).
121
+ */
122
+ pageTitle?: string | null;
117
123
  lang: string;
118
124
  theme: "light" | "dark";
119
125
  deviceFrame?: string | null;
@@ -1,4 +1,5 @@
1
1
  export declare function runDoctor(opts: {
2
2
  fix: boolean;
3
3
  agent?: string;
4
+ json?: boolean;
4
5
  }, currentVersion: string): Promise<void>;
@@ -148,8 +148,28 @@ async function detectAgent() {
148
148
  }
149
149
  return null;
150
150
  }
151
- async function checkSkill(agentOverride) {
151
+ async function parseSkillVersion(skillPath) {
152
+ try {
153
+ const raw = await fs.readFile(skillPath, 'utf8');
154
+ const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
155
+ if (!match)
156
+ return null;
157
+ const yamlText = match[1];
158
+ for (const line of yamlText.split(/\r?\n/)) {
159
+ const m = line.match(/^\s*version\s*:\s*(.+?)\s*$/);
160
+ if (m) {
161
+ return m[1].replace(/^["']|["']$/g, '').trim() || null;
162
+ }
163
+ }
164
+ return null;
165
+ }
166
+ catch {
167
+ return null;
168
+ }
169
+ }
170
+ async function checkSkill(extras, agentOverride) {
152
171
  const agent = agentOverride ?? (await detectAgent());
172
+ extras.agentDetected = agent;
153
173
  if (!agent) {
154
174
  return {
155
175
  name: 'AI agent skill',
@@ -160,12 +180,16 @@ async function checkSkill(agentOverride) {
160
180
  }
161
181
  const skillPath = path.join(process.cwd(), AGENT_PATHS[agent]);
162
182
  if (await pathExists(skillPath)) {
183
+ extras.skillPath = AGENT_PATHS[agent];
184
+ extras.skillVersion = await parseSkillVersion(skillPath);
185
+ const versionSuffix = extras.skillVersion ? ` (v${extras.skillVersion})` : '';
163
186
  return {
164
187
  name: 'AI agent skill',
165
188
  status: 'ok',
166
- message: `${agent}: ${AGENT_PATHS[agent]}`,
189
+ message: `${agent}: ${AGENT_PATHS[agent]}${versionSuffix}`,
167
190
  };
168
191
  }
192
+ extras.skillPath = AGENT_PATHS[agent];
169
193
  return {
170
194
  name: 'AI agent skill',
171
195
  status: 'warn',
@@ -181,6 +205,55 @@ async function checkSkill(agentOverride) {
181
205
  },
182
206
  };
183
207
  }
208
+ async function checkCliKeyValid() {
209
+ let config = null;
210
+ try {
211
+ config = await readConfig();
212
+ }
213
+ catch {
214
+ config = null;
215
+ }
216
+ if (!config || !config.apiKey) {
217
+ return {
218
+ name: 'CLI key valid',
219
+ status: 'warn',
220
+ message: 'No CLI key configured locally — skipping remote validation.',
221
+ fixCommand: 'autokap init --cli-key <key>',
222
+ };
223
+ }
224
+ try {
225
+ const res = await fetch(`${config.apiBaseUrl}/api/cli/validate`, {
226
+ headers: { Authorization: `Bearer ${config.apiKey}` },
227
+ });
228
+ if (res.ok) {
229
+ return {
230
+ name: 'CLI key valid',
231
+ status: 'ok',
232
+ message: `Key validated against ${config.apiBaseUrl}.`,
233
+ };
234
+ }
235
+ if (res.status === 401 || res.status === 403) {
236
+ return {
237
+ name: 'CLI key valid',
238
+ status: 'fail',
239
+ message: `Server rejected the CLI key (HTTP ${res.status}). It was likely revoked or rotated.`,
240
+ fixCommand: 'autokap init --cli-key <new-key>',
241
+ };
242
+ }
243
+ return {
244
+ name: 'CLI key valid',
245
+ status: 'warn',
246
+ message: `Validation endpoint returned HTTP ${res.status}.`,
247
+ };
248
+ }
249
+ catch (err) {
250
+ return {
251
+ name: 'CLI key valid',
252
+ status: 'warn',
253
+ message: `Cannot reach validation endpoint: ${err.message}`,
254
+ };
255
+ }
256
+ }
184
257
  async function checkCliVersion(currentVersion) {
185
258
  const latest = await getCachedOrFetchLatest();
186
259
  if (!latest) {
@@ -247,28 +320,68 @@ function printCheck(result) {
247
320
  }
248
321
  console.log('');
249
322
  }
323
+ function buildJsonReport(results, extras, currentVersion) {
324
+ const ok = results.filter(r => r.status === 'ok').length;
325
+ const warn = results.filter(r => r.status === 'warn').length;
326
+ const fail = results.filter(r => r.status === 'fail').length;
327
+ return {
328
+ cli_version: currentVersion,
329
+ agent_detected: extras.agentDetected,
330
+ skill_path: extras.skillPath,
331
+ skill_version: extras.skillVersion,
332
+ checks: results.map(r => ({
333
+ name: r.name,
334
+ status: r.status,
335
+ message: r.message,
336
+ fixable: Boolean(r.fixFn),
337
+ fix_command: r.fixCommand ?? null,
338
+ })),
339
+ summary: { ok, warn, fail, all_ok: fail === 0 && warn === 0 },
340
+ };
341
+ }
250
342
  export async function runDoctor(opts, currentVersion) {
251
343
  const agentOverride = opts.agent
252
344
  ? opts.agent.toLowerCase()
253
345
  : undefined;
346
+ const jsonMode = Boolean(opts.json);
254
347
  if (agentOverride && !(agentOverride in AGENT_PATHS)) {
255
- logger.error(`Unknown agent "${opts.agent}". Supported: ${Object.keys(AGENT_PATHS).join(', ')}`);
348
+ if (jsonMode) {
349
+ process.stdout.write(JSON.stringify({
350
+ error: `Unknown agent "${opts.agent}". Supported: ${Object.keys(AGENT_PATHS).join(', ')}`,
351
+ }) + '\n');
352
+ }
353
+ else {
354
+ logger.error(`Unknown agent "${opts.agent}". Supported: ${Object.keys(AGENT_PATHS).join(', ')}`);
355
+ }
256
356
  process.exit(1);
257
357
  }
258
- console.log(chalk.bold('\nautokap doctor'));
259
- console.log(chalk.gray(`Checking environment for autokap ${currentVersion}...\n`));
358
+ if (!jsonMode) {
359
+ console.log(chalk.bold('\nautokap doctor'));
360
+ console.log(chalk.gray(`Checking environment for autokap ${currentVersion}...\n`));
361
+ }
362
+ const extras = {
363
+ agentDetected: null,
364
+ skillPath: null,
365
+ skillVersion: null,
366
+ };
260
367
  const results = [];
261
368
  results.push(checkNodeVersion());
262
369
  results.push(await checkChromium());
263
370
  results.push(await checkFfmpeg());
264
371
  results.push(await checkConfig());
265
- results.push(await checkSkill(agentOverride));
372
+ results.push(await checkCliKeyValid());
373
+ results.push(await checkSkill(extras, agentOverride));
266
374
  results.push(await checkCliVersion(currentVersion));
375
+ const failures = results.filter(r => r.status === 'fail');
376
+ const warnings = results.filter(r => r.status === 'warn');
377
+ if (jsonMode) {
378
+ const report = buildJsonReport(results, extras, currentVersion);
379
+ process.stdout.write(JSON.stringify(report, null, 2) + '\n');
380
+ process.exit(failures.length > 0 ? 1 : 0);
381
+ }
267
382
  for (const r of results) {
268
383
  printCheck(r);
269
384
  }
270
- const failures = results.filter(r => r.status === 'fail');
271
- const warnings = results.filter(r => r.status === 'warn');
272
385
  if (opts.fix) {
273
386
  const fixable = results.filter(r => r.status !== 'ok' && r.fixFn);
274
387
  if (fixable.length === 0) {
@@ -25,7 +25,8 @@ import { parseProgram } from './execution-schema.js';
25
25
  import { buildCursorOverlayScript } from './cursor-overlay-script.js';
26
26
  import { CLI_VERSION_HEADER, } from './cli-contract.js';
27
27
  import { postProcessClipRecording } from './clip-postprocess.js';
28
- import { applyDeviceFrame } from './mockup.js';
28
+ import { applyDeviceFrame, seedDeviceConfigs } from './mockup.js';
29
+ import { transformBrowserUrl } from './transform-browser-url.js';
29
30
  import { localizeStatusBar } from './status-bar-l10n.js';
30
31
  import { logger } from './logger.js';
31
32
  import { callLLM } from './llm-provider.js';
@@ -166,6 +167,11 @@ export async function runCapture(options) {
166
167
  presetId: options.presetId,
167
168
  };
168
169
  }
170
+ // Custom device frames live in Supabase (table `device_mockups`) and the
171
+ // end-user CLI does not have the service role key. The server pre-fetches
172
+ // every device referenced by `variants[].deviceFrame` and embeds the rows
173
+ // here; we seed mockup.ts so the export pipeline can apply them locally.
174
+ seedDeviceConfigs(program.deviceConfigs ?? null);
169
175
  const runId = randomUUID();
170
176
  let videoAudioAssets;
171
177
  let videoAudioAssetsByLocale;
@@ -248,6 +254,7 @@ export async function runCapture(options) {
248
254
  lang: variant.locale,
249
255
  colorScheme: variant.theme,
250
256
  storageState: program.preconditions.storageState,
257
+ extraHttpHeaders: program.environmentHttpHeaders,
251
258
  };
252
259
  let recordingDir;
253
260
  let browser;
@@ -884,6 +891,9 @@ async function uploadArtifactMultipart(config, program, runId, job, filename) {
884
891
  formData.append('mimeType', artifact.mimeType);
885
892
  formData.append('captureType', artifact.captureType ?? 'fullpage');
886
893
  formData.append('captureUrl', artifact.captureUrl ?? program.baseUrl);
894
+ if (artifact.pageTitle) {
895
+ formData.append('pageTitle', artifact.pageTitle);
896
+ }
887
897
  formData.append('lang', variantSpec?.locale ?? 'en');
888
898
  formData.append('theme', variantSpec?.theme ?? 'light');
889
899
  if (variantSpec?.deviceFrame) {
@@ -961,6 +971,7 @@ async function prepareDirectArtifactUpload(params) {
961
971
  mimeType: artifact.mimeType,
962
972
  captureType: artifact.captureType ?? 'fullpage',
963
973
  captureUrl: artifact.captureUrl ?? program.baseUrl,
974
+ pageTitle: artifact.pageTitle ?? null,
964
975
  lang: variantSpec?.locale ?? 'en',
965
976
  theme: variantSpec?.theme ?? 'light',
966
977
  deviceFrame: variantSpec?.deviceFrame ?? null,
@@ -1039,7 +1050,7 @@ async function prepareScreenshotBufferForDirectUpload(input, metadata, program,
1039
1050
  ?? 2,
1040
1051
  showStatusBar: artifactPlan.applyStatusBar ?? false,
1041
1052
  statusBar: localizeStatusBar({}, metadata.lang),
1042
- browserBar: buildCliBrowserBar(metadata.captureUrl, metadata.theme, tabIcon),
1053
+ browserBar: buildCliBrowserBar(metadata.captureUrl, metadata.theme, tabIcon, { publicUrl: program.publicUrl, pageTitle: metadata.pageTitle ?? null }),
1043
1054
  });
1044
1055
  }
1045
1056
  if (artifactPlan?.format?.screenshotFormat === 'jpeg') {
@@ -1122,14 +1133,16 @@ function normalizeCliDeviceScaleFactor(value) {
1122
1133
  return null;
1123
1134
  return Math.max(0.5, Math.min(4, Number(value)));
1124
1135
  }
1125
- function buildCliBrowserBar(captureUrl, colorScheme, tabIcon) {
1136
+ function buildCliBrowserBar(captureUrl, colorScheme, tabIcon, options = {}) {
1126
1137
  if (!captureUrl)
1127
1138
  return undefined;
1139
+ const displayUrl = transformBrowserUrl(captureUrl, options.publicUrl);
1140
+ const explicitTitle = options.pageTitle?.trim() || null;
1128
1141
  try {
1129
- const parsed = new URL(captureUrl);
1142
+ const parsed = new URL(displayUrl);
1130
1143
  return {
1131
- url: captureUrl,
1132
- pageTitle: parsed.hostname,
1144
+ url: displayUrl,
1145
+ pageTitle: explicitTitle ?? parsed.hostname,
1133
1146
  colorScheme,
1134
1147
  tabIconUrl: tabIcon
1135
1148
  ? `data:${tabIcon.mimeType};base64,${tabIcon.buffer.toString('base64')}`
@@ -1138,8 +1151,8 @@ function buildCliBrowserBar(captureUrl, colorScheme, tabIcon) {
1138
1151
  }
1139
1152
  catch {
1140
1153
  return {
1141
- url: captureUrl,
1142
- pageTitle: captureUrl,
1154
+ url: displayUrl,
1155
+ pageTitle: explicitTitle ?? displayUrl,
1143
1156
  colorScheme,
1144
1157
  ...(tabIcon
1145
1158
  ? { tabIconUrl: `data:${tabIcon.mimeType};base64,${tabIcon.buffer.toString('base64')}` }
package/dist/cli.js CHANGED
@@ -1637,6 +1637,7 @@ program
1637
1637
  .description('Check environment and dependencies (Node, Chromium, ffmpeg, config, skill, version)')
1638
1638
  .option('--fix', 'Attempt to auto-fix detected issues (Chromium install, skill reinstall)', false)
1639
1639
  .option('--agent <name>', 'Override agent for skill check: claude, codex, cursor, windsurf, copilot')
1640
+ .option('--json', 'Output a structured JSON report to stdout (for AI assistants and tooling)', false)
1640
1641
  .action(async (opts) => {
1641
1642
  const { runDoctor } = await import('./cli-doctor.js');
1642
1643
  await runDoctor(opts, version);
@@ -2226,6 +2226,9 @@ export declare const ExecutionProgramSchema: z.ZodObject<{
2226
2226
  defaultValues: z.ZodArray<z.ZodRecord<z.ZodString, z.ZodString>>;
2227
2227
  replaceExisting: z.ZodOptional<z.ZodBoolean>;
2228
2228
  }, z.core.$strict>>>;
2229
+ deviceConfigs: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodRecord<z.ZodString, z.ZodUnknown>>>;
2230
+ publicUrl: z.ZodOptional<z.ZodString>;
2231
+ environmentHttpHeaders: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
2229
2232
  }, z.core.$strict>;
2230
2233
  export declare const HealerPatchSchema: z.ZodObject<{
2231
2234
  opcodeIndex: z.ZodNumber;
@@ -4923,4 +4926,7 @@ export declare function safeParseProgramResult(data: unknown): z.ZodSafeParseRes
4923
4926
  defaultValues: Record<string, string>[];
4924
4927
  replaceExisting?: boolean | undefined;
4925
4928
  }[] | undefined;
4929
+ deviceConfigs?: Record<string, Record<string, unknown>> | undefined;
4930
+ publicUrl?: string | undefined;
4931
+ environmentHttpHeaders?: Record<string, string> | undefined;
4926
4932
  }>;
@@ -652,6 +652,18 @@ export const ExecutionProgramSchema = z.object({
652
652
  compiledAt: z.string().datetime(),
653
653
  compiledWith: z.string().optional(),
654
654
  mockDataGroups: z.array(MockDataGroupSchema).optional(),
655
+ // Server-embedded device frame configs. Shape is governed by the
656
+ // `device_mockups.config` JSON column; we accept anything the server stores
657
+ // and let mockup.ts validate structurally at use time.
658
+ deviceConfigs: z.record(z.string().min(1), z.record(z.string(), z.unknown())).optional(),
659
+ // Project-level public URL used to rewrite the captured origin when
660
+ // decorating browser mockups. Validated only as a non-empty string here;
661
+ // the runtime parses it with `new URL()` and falls back gracefully.
662
+ publicUrl: z.string().min(1).optional(),
663
+ // Auth headers attached to the resolved project environment. Key/value
664
+ // pairs that Playwright will inject as `extraHTTPHeaders` on the
665
+ // BrowserContext so protected staging/preview URLs load successfully.
666
+ environmentHttpHeaders: z.record(z.string().min(1), z.string().min(1)).optional(),
655
667
  }).strict().superRefine((value, ctx) => {
656
668
  if (value.mediaMode !== value.artifactPlan.mediaMode) {
657
669
  ctx.addIssue({
@@ -5,7 +5,8 @@
5
5
  * preset (natural language) -> ExecutionProgram (typed IR) -> deterministic runtime
6
6
  */
7
7
  import type { AKTree, BrowserStorageState, BrowserSessionStorageState, OutscaleConfig, VideoCursorTheme, VideoPageSignals } from './types.js';
8
- import type { MockupOptions } from './mockup.js';
8
+ import type { DeviceConfig, MockupOptions } from './mockup.js';
9
+ export type { DeviceConfig };
9
10
  /** Sentinel value that resolves to the current variant's locale or theme at runtime */
10
11
  export declare const VARIANT_PLACEHOLDER: "$variant";
11
12
  export declare const OPCODE_KINDS: readonly ["NAVIGATE", "DISMISS_OVERLAYS", "ASSERT_ROUTE", "ASSERT_SURFACE", "CLICK", "TYPE", "PRESS_KEY", "WAIT_FOR", "SLEEP", "SET_LOCALE", "SET_THEME", "SCROLL", "CAPTURE_SCREENSHOT", "BEGIN_CLIP", "END_CLIP", "HOVER", "SELECT_OPTION", "CHECK", "DOUBLE_CLICK", "DRAG", "CLONE_ELEMENT", "INJECT_MOCK_DATA", "REMOVE_ELEMENT", "SET_ATTRIBUTE"];
@@ -557,6 +558,28 @@ export interface ExecutionProgram {
557
558
  * Compiled from PresetConfig.mockDataInjection.groups by the server.
558
559
  */
559
560
  mockDataGroups?: MockDataGroup[];
561
+ /**
562
+ * Server-embedded device frame configs for every `deviceFrame` referenced by
563
+ * `variants[]`. The CLI seeds its mockup engine with these so it does not
564
+ * need direct Supabase access (end-users do not have the service role key).
565
+ * Keyed by the exact deviceFrame string used in the variants.
566
+ */
567
+ deviceConfigs?: Record<string, DeviceConfig>;
568
+ /**
569
+ * Project-level public URL used to decorate browser mockups. The CLI
570
+ * substitutes the captured origin (typically a local dev server) with this
571
+ * value via `transformBrowserUrl` before baking it into the browser bar.
572
+ * Server-resolved from `projects.public_url`.
573
+ */
574
+ publicUrl?: string;
575
+ /**
576
+ * Auth headers attached to the resolved project environment (Bearer token,
577
+ * Vercel protection bypass, x-api-key, etc.). Injected into the Playwright
578
+ * BrowserContext so requests to protected staging/preview URLs go through.
579
+ * Decrypted server-side from `project_environments.auth_headers_encrypted`
580
+ * and embedded in the signed program envelope.
581
+ */
582
+ environmentHttpHeaders?: Record<string, string>;
560
583
  }
561
584
  export interface CircuitBreakerConfig {
562
585
  /** Max recovery attempts per opcode. Default: 3 */
@@ -629,6 +652,8 @@ export interface ArtifactResult {
629
652
  altText?: string;
630
653
  /** Final URL at the time the artifact was produced */
631
654
  captureUrl?: string;
655
+ /** Document title at the time the artifact was produced (page.title()) */
656
+ pageTitle?: string;
632
657
  /** Step index that produced the artifact */
633
658
  stepIndex?: number;
634
659
  /** Human-readable label for the artifact */
@@ -855,6 +880,13 @@ export interface RuntimeAdapter {
855
880
  buffer: Buffer;
856
881
  mimeType: string;
857
882
  } | null>;
883
+ /**
884
+ * Document title of the current page (Playwright's `page.title()`). Captured
885
+ * at screenshot time and stored on the artifact metadata so browser mockups
886
+ * can render the actual page title instead of just the hostname. Optional —
887
+ * adapters that cannot resolve a title should leave this method off.
888
+ */
889
+ getPageTitle?(): Promise<string | null>;
858
890
  /**
859
891
  * Read the captured app's version from the live page (meta tag, window
860
892
  * global, or data attribute). Mirrors `extractAppVersionFromHtml` server-side
@@ -958,4 +990,3 @@ export interface RuntimeAdapter {
958
990
  selector: string;
959
991
  }): Promise<void>;
960
992
  }
961
- export {};
package/dist/mockup.d.ts CHANGED
@@ -11,7 +11,8 @@ export interface DeviceFrameDefinition {
11
11
  height: number;
12
12
  };
13
13
  }
14
- interface OrientationConfigData {
14
+ export type DeviceOrientation = 'portrait' | 'landscape';
15
+ export interface OrientationConfigData {
15
16
  screen: {
16
17
  logicalWidth: number;
17
18
  logicalHeight: number;
@@ -103,6 +104,69 @@ interface OrientationConfigData {
103
104
  right?: string;
104
105
  };
105
106
  }
107
+ export interface DeviceConfig {
108
+ id: string;
109
+ name: string;
110
+ category: DeviceCategory;
111
+ platform: string;
112
+ frameOrientation?: 'portrait' | 'landscape';
113
+ supportedOrientations?: ('portrait' | 'landscape')[];
114
+ orientations?: {
115
+ portrait?: OrientationConfigData;
116
+ landscape?: OrientationConfigData;
117
+ };
118
+ /** Row-level frame_url (shared fallback for all orientations) */
119
+ _rowFrameUrl?: string;
120
+ screen: {
121
+ logicalWidth: number;
122
+ logicalHeight: number;
123
+ scale: number;
124
+ cornerRadius: number;
125
+ };
126
+ viewport: {
127
+ width: number;
128
+ height: number;
129
+ };
130
+ safeArea?: {
131
+ top: number;
132
+ bottom: number;
133
+ left?: number;
134
+ right?: number;
135
+ };
136
+ statusBar?: {
137
+ asset: string;
138
+ height: number;
139
+ width: number;
140
+ type?: StatusBarDeviceType;
141
+ layout?: StatusBarLayout;
142
+ };
143
+ homeIndicator?: {
144
+ width: number;
145
+ height: number;
146
+ cornerRadius: number;
147
+ bottomOffset: number;
148
+ };
149
+ frame: {
150
+ type: 'png' | 'svg';
151
+ asset: string;
152
+ width: number;
153
+ height: number;
154
+ screenRect: {
155
+ x: number;
156
+ y: number;
157
+ width: number;
158
+ height: number;
159
+ };
160
+ };
161
+ frameRotation?: number;
162
+ frameBehindContent?: boolean;
163
+ windowBorder?: OrientationConfigData['windowBorder'];
164
+ frameDarkUrl?: string;
165
+ browserBarZones?: OrientationConfigData['browserBarZones'];
166
+ browserStyle?: OrientationConfigData['browserStyle'];
167
+ adminShowStatusBar?: OrientationConfigData['adminShowStatusBar'];
168
+ adminForcedSafeAreaColors?: OrientationConfigData['adminForcedSafeAreaColors'];
169
+ }
106
170
  export type MockupOrientation = 'portrait' | 'landscape';
107
171
  export interface MockupOptions {
108
172
  orientation?: MockupOrientation;
@@ -184,6 +248,7 @@ export interface ResolvedDeviceFrameDescriptor {
184
248
  disableOverlays: boolean;
185
249
  }
186
250
  export declare function invalidateDeviceConfigCache(): void;
251
+ export declare function seedDeviceConfigs(configs: Record<string, DeviceConfig> | null | undefined): void;
187
252
  export declare function resolveDeviceFrameDescriptor(id: DeviceFrameId, options?: {
188
253
  orientation?: MockupOrientation;
189
254
  }): Promise<ResolvedDeviceFrameDescriptor | null>;
@@ -191,4 +256,3 @@ export declare function rasterizeDeviceFrame(descriptor: ResolvedDeviceFrameDesc
191
256
  export declare function getDeviceFrames(): Promise<DeviceFrameDefinition[]>;
192
257
  export declare function getDeviceFrame(id: DeviceFrameId): Promise<DeviceFrameDefinition | undefined>;
193
258
  export declare function applyDeviceFrame(screenshot: Buffer, deviceId: DeviceFrameId, options?: MockupOptions): Promise<Buffer>;
194
- export {};
package/dist/mockup.js CHANGED
@@ -5,6 +5,7 @@ import { fileURLToPath } from 'url';
5
5
  import { renderStatusBarBuffer } from './status-bar-render.js';
6
6
  import { generateBrowserBarSvg } from './browser-bar.js';
7
7
  import { computeMockupLayout } from './mockup-html.js';
8
+ import { logger } from './logger.js';
8
9
  const __filename = fileURLToPath(import.meta.url);
9
10
  const __dirname = path.dirname(__filename);
10
11
  const DEVICES_DIR = path.join(__dirname, '..', 'assets', 'devices');
@@ -144,7 +145,23 @@ function resolveOrientationConfig(config, requestedOrientation) {
144
145
  adminForcedSafeAreaColors: config.adminForcedSafeAreaColors,
145
146
  };
146
147
  }
148
+ // Server-provided device configs embedded in signed execution programs. When
149
+ // set, these short-circuit `loadDeviceConfigs` — the CLI does not need direct
150
+ // Supabase access (and end-users do not have the service role key) because the
151
+ // server already vetted every device referenced by the program's variants and
152
+ // inlined the rows.
153
+ let seededDeviceConfigs = null;
154
+ export function seedDeviceConfigs(configs) {
155
+ if (!configs || Object.keys(configs).length === 0) {
156
+ seededDeviceConfigs = null;
157
+ return;
158
+ }
159
+ seededDeviceConfigs = new Map(Object.entries(configs));
160
+ }
147
161
  async function loadDeviceConfigs() {
162
+ if (seededDeviceConfigs && seededDeviceConfigs.size > 0) {
163
+ return seededDeviceConfigs;
164
+ }
148
165
  if (configCache && configCache.expiresAt > Date.now()) {
149
166
  return configCache.configs;
150
167
  }
@@ -478,12 +495,12 @@ export async function applyDeviceFrame(screenshot, deviceId, options) {
478
495
  const isBrowserDevice = config.category === 'browser';
479
496
  const os = Math.max(0.5, Math.min(4, opts.outputScale));
480
497
  const geometry = computeResolvedFrameGeometry(resolved);
481
- console.log(`[mockup] applyDeviceFrame: id=${deviceId}, category=${config.category}, orientation=${requestedOrientation}`);
482
- console.log(`[mockup] hasOrientationConfig=${!!config.orientations?.[requestedOrientation]}, orientations=${JSON.stringify(Object.keys(config.orientations ?? {}))}`);
483
- console.log(`[mockup] resolved.screen:`, JSON.stringify(resolved.screen));
484
- console.log(`[mockup] resolved.frame: w=${resolved.frame.width} h=${resolved.frame.height} asset="${resolved.frame.asset}" url=${resolved.frameUrl ? 'yes' : 'no'}`);
485
- console.log(`[mockup] resolved.safeArea:`, JSON.stringify(resolved.safeArea));
486
- console.log(`[mockup] scale=${scale}, outputScale=${os}`);
498
+ logger.debug(`[mockup] applyDeviceFrame: id=${deviceId}, category=${config.category}, orientation=${requestedOrientation}`);
499
+ logger.debug(`[mockup] hasOrientationConfig=${!!config.orientations?.[requestedOrientation]}, orientations=${JSON.stringify(Object.keys(config.orientations ?? {}))}`);
500
+ logger.debug(`[mockup] resolved.screen: ${JSON.stringify(resolved.screen)}`);
501
+ logger.debug(`[mockup] resolved.frame: w=${resolved.frame.width} h=${resolved.frame.height} asset="${resolved.frame.asset}" url=${resolved.frameUrl ? 'yes' : 'no'}`);
502
+ logger.debug(`[mockup] resolved.safeArea: ${JSON.stringify(resolved.safeArea)}`);
503
+ logger.debug(`[mockup] scale=${scale}, outputScale=${os}`);
487
504
  // Browser devices can work without a frame image
488
505
  const hasFrame = !!(resolved.frameUrl || resolved.frame.asset);
489
506
  let frameData = null;
@@ -509,7 +526,7 @@ export async function applyDeviceFrame(screenshot, deviceId, options) {
509
526
  geo.frameWidth = logicalW;
510
527
  geo.frameHeight = logicalH;
511
528
  geo.screenRect = { x: 0, y: 0, width: logicalW, height: logicalH };
512
- console.log(`[mockup] frameless browser: logicalW=${logicalW}, logicalH=${logicalH}, os=${os}, geo=${geo.frameWidth}x${geo.frameHeight}`);
529
+ logger.debug(`[mockup] frameless browser: logicalW=${logicalW}, logicalH=${logicalH}, os=${os}, geo=${geo.frameWidth}x${geo.frameHeight}`);
513
530
  }
514
531
  else if (!hasFrame && geo.frameWidth === 0 && geo.frameHeight === 0) {
515
532
  // Non-browser frameless fallback
@@ -551,21 +568,21 @@ export async function applyDeviceFrame(screenshot, deviceId, options) {
551
568
  });
552
569
  const contentW = layout.contentArea.width;
553
570
  const contentH = layout.contentArea.height;
554
- console.log(`[mockup] hasFrame=${hasFrame}, geo: fw=${geo.frameWidth} fh=${geo.frameHeight} sr=${JSON.stringify(geo.screenRect)}`);
555
- console.log(`[mockup] layout: container=${layout.containerWidth}x${layout.containerHeight} content=${contentW}x${contentH} contentArea=${JSON.stringify(layout.contentArea)}`);
571
+ logger.debug(`[mockup] hasFrame=${hasFrame}, geo: fw=${geo.frameWidth} fh=${geo.frameHeight} sr=${JSON.stringify(geo.screenRect)}`);
572
+ logger.debug(`[mockup] layout: container=${layout.containerWidth}x${layout.containerHeight} content=${contentW}x${contentH} contentArea=${JSON.stringify(layout.contentArea)}`);
556
573
  // Get incoming screenshot dimensions for logging
557
574
  const screenshotMeta = await sharp(screenshot).metadata();
558
- console.log(`[mockup] input screenshot: ${screenshotMeta.width}x${screenshotMeta.height}`);
575
+ logger.debug(`[mockup] input screenshot: ${screenshotMeta.width}x${screenshotMeta.height}`);
559
576
  const physicalContentW = Math.round(contentW * os);
560
577
  const physicalContentH = Math.round(contentH * os);
561
- console.log(`[mockup] resize target: ${physicalContentW}x${physicalContentH}`);
578
+ logger.debug(`[mockup] resize target: ${physicalContentW}x${physicalContentH}`);
562
579
  if (screenshotMeta.width
563
580
  && screenshotMeta.height
564
581
  && (Math.abs(screenshotMeta.width - physicalContentW) > 1
565
582
  || Math.abs(screenshotMeta.height - physicalContentH) > 1)) {
566
583
  const ratioX = physicalContentW / screenshotMeta.width;
567
584
  const ratioY = physicalContentH / screenshotMeta.height;
568
- console.warn(`[mockup] screenshot will be resampled: ` +
585
+ logger.debug(`[mockup] screenshot will be resampled: ` +
569
586
  `${screenshotMeta.width}x${screenshotMeta.height} -> ` +
570
587
  `${physicalContentW}x${physicalContentH} ` +
571
588
  `(x=${ratioX.toFixed(4)}, y=${ratioY.toFixed(4)}). ` +
@@ -597,7 +614,7 @@ export async function applyDeviceFrame(screenshot, deviceId, options) {
597
614
  colors.leftColor = sanitizeCssColor(opts.safeAreaLeftColor);
598
615
  if (opts.safeAreaRightColor)
599
616
  colors.rightColor = sanitizeCssColor(opts.safeAreaRightColor);
600
- console.log(`[mockup] sampled colors: top=${colors.topColor} bottom=${colors.bottomColor} left=${colors.leftColor} right=${colors.rightColor}`);
617
+ logger.debug(`[mockup] sampled colors: top=${colors.topColor} bottom=${colors.bottomColor} left=${colors.leftColor} right=${colors.rightColor}`);
601
618
  // Determine color scheme: use explicit override if provided, otherwise auto-detect from edge colors.
602
619
  // Laptops (MacBook) always use dark menu bar (white text on black background).
603
620
  const isLaptop = config.category === 'laptop';
@@ -664,7 +681,7 @@ export async function applyDeviceFrame(screenshot, deviceId, options) {
664
681
  const renderW = Math.round(geo.frameWidth);
665
682
  const renderH = Math.round(geo.frameHeight);
666
683
  const cornerRadius = (resolved.screen?.cornerRadius ?? 0) * scale;
667
- console.log(`[mockup] composeMockup: ${renderW}x${renderH} @${os}x, showBrowserBar=${isBrowserDevice}, windowBorderWidth=${wbw}`);
684
+ logger.debug(`[mockup] composeMockup: ${renderW}x${renderH} @${os}x, showBrowserBar=${isBrowserDevice}, windowBorderWidth=${wbw}`);
668
685
  // ── Sharp compositing — layer-by-layer mockup assembly ──
669
686
  // All dimensions in physical pixels (logical * os).
670
687
  const pw = Math.round(renderW * os);
@@ -708,12 +708,21 @@ async function executeOpcodeAction(opcode, opcodeIndex, adapter, artifacts, tele
708
708
  tabIconMimeType = favicon.mimeType;
709
709
  }
710
710
  }
711
+ // Capture document title so browser mockups can render the real tab
712
+ // title instead of falling back to the hostname.
713
+ let pageTitle;
714
+ if (adapter.getPageTitle) {
715
+ const resolved = await adapter.getPageTitle();
716
+ if (resolved)
717
+ pageTitle = resolved;
718
+ }
711
719
  artifacts.push({
712
720
  mediaMode: 'screenshot',
713
721
  buffer,
714
722
  mimeType: 'image/png',
715
723
  captureType: opcode.elementSelector ? 'element' : 'fullpage',
716
724
  captureUrl,
725
+ pageTitle,
717
726
  dimensions: currentVariant?.viewport,
718
727
  captureId: opcode.captureId,
719
728
  captureName: opcode.captureName ?? opcode.description,
@@ -1126,6 +1126,9 @@ export declare const SignedExecutionProgramEnvelopeSchema: z.ZodObject<{
1126
1126
  defaultValues: z.ZodArray<z.ZodRecord<z.ZodString, z.ZodString>>;
1127
1127
  replaceExisting: z.ZodOptional<z.ZodBoolean>;
1128
1128
  }, z.core.$strict>>>;
1129
+ deviceConfigs: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodRecord<z.ZodString, z.ZodUnknown>>>;
1130
+ publicUrl: z.ZodOptional<z.ZodString>;
1131
+ environmentHttpHeaders: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
1129
1132
  }, z.core.$strict>;
1130
1133
  signature: z.ZodString;
1131
1134
  meta: z.ZodOptional<z.ZodObject<{
@@ -102,6 +102,10 @@ export function signExecutionProgramEnvelope(params) {
102
102
  };
103
103
  }
104
104
  export function verifySignedExecutionProgramEnvelope(params) {
105
+ // The Zod schema describes `deviceConfigs` as a loose record (the server is
106
+ // the source of truth for the rich DeviceConfig shape). Cast to the strongly
107
+ // typed envelope at this trust boundary — the signature verification below
108
+ // proves the server produced this payload.
105
109
  const envelope = SignedExecutionProgramEnvelopeSchema.parse(params.envelope);
106
110
  const payload = {
107
111
  signedAt: envelope.signedAt,
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Transform a captured URL for display in the browser bar mockup.
3
+ * 1. Replace the origin with `publicUrl` (if provided).
4
+ * 2. Strip UUID v4 path segments.
5
+ */
6
+ export declare function transformBrowserUrl(capturedUrl: string, publicUrl?: string | null): string;
@@ -0,0 +1,28 @@
1
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
2
+ /**
3
+ * Transform a captured URL for display in the browser bar mockup.
4
+ * 1. Replace the origin with `publicUrl` (if provided).
5
+ * 2. Strip UUID v4 path segments.
6
+ */
7
+ export function transformBrowserUrl(capturedUrl, publicUrl) {
8
+ let parsed;
9
+ try {
10
+ parsed = new URL(capturedUrl);
11
+ }
12
+ catch {
13
+ return capturedUrl;
14
+ }
15
+ if (publicUrl) {
16
+ try {
17
+ const pub = new URL(publicUrl);
18
+ parsed = new URL(`${pub.origin}${parsed.pathname}${parsed.search}${parsed.hash}`);
19
+ }
20
+ catch {
21
+ // invalid publicUrl — skip origin replacement
22
+ }
23
+ }
24
+ const segments = parsed.pathname.split("/").filter((s) => s && !UUID_RE.test(s));
25
+ parsed.pathname = "/" + segments.join("/");
26
+ return parsed.toString().replace(/\/$/, "") || parsed.origin;
27
+ }
28
+ //# sourceMappingURL=transform-browser-url.js.map
package/dist/types.d.ts CHANGED
@@ -246,6 +246,17 @@ export interface BrowserOptions {
246
246
  colorScheme?: 'light' | 'dark';
247
247
  /** Optional persisted cookies/localStorage captured during preparation. */
248
248
  storageState?: BrowserStorageState;
249
+ /**
250
+ * Extra HTTP headers injected on every navigation. Used to carry the
251
+ * environment-level auth (Bearer token, Vercel protection bypass, x-api-key,
252
+ * etc.) configured on the resolved `project_environments` row.
253
+ *
254
+ * These headers are sent to ALL requests the BrowserContext makes during
255
+ * capture, including cross-origin requests (third-party CDN, analytics).
256
+ * The dashboard surfaces a warning so users only put environment-scoped
257
+ * secrets here.
258
+ */
259
+ extraHttpHeaders?: Record<string, string>;
249
260
  }
250
261
  export interface OutscaleConfig {
251
262
  /** Uniform padding on all 4 sides (pixels). */
@@ -1152,6 +1152,9 @@ export declare const VideoIngestPayloadSchema: z.ZodObject<{
1152
1152
  defaultValues: z.ZodArray<z.ZodRecord<z.ZodString, z.ZodString>>;
1153
1153
  replaceExisting: z.ZodOptional<z.ZodBoolean>;
1154
1154
  }, z.core.$strict>>>;
1155
+ deviceConfigs: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodRecord<z.ZodString, z.ZodUnknown>>>;
1156
+ publicUrl: z.ZodOptional<z.ZodString>;
1157
+ environmentHttpHeaders: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
1155
1158
  }, z.core.$strict>;
1156
1159
  narration: z.ZodOptional<z.ZodObject<{
1157
1160
  voice: z.ZodString;
@@ -27,6 +27,7 @@ export declare class WebPlaywrightLocal implements RuntimeAdapter {
27
27
  constructor(browser: Browser, recordingDir?: string | undefined);
28
28
  navigate(url: string): Promise<void>;
29
29
  getCurrentUrl(): Promise<string>;
30
+ getPageTitle(): Promise<string | null>;
30
31
  detectAppVersion(): Promise<string | null>;
31
32
  getAKTree(): Promise<AKTree>;
32
33
  getPageSignals(): Promise<VideoPageSignals>;
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "autokap",
3
- "version": "1.6.1",
3
+ "version": "1.6.2",
4
4
  "description": "AI-powered CLI tool for capturing clean screenshots of websites",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -181,6 +181,10 @@
181
181
  "./program-signing": {
182
182
  "types": "./dist/program-signing.d.ts",
183
183
  "default": "./dist/program-signing.js"
184
+ },
185
+ "./transform-browser-url": {
186
+ "types": "./dist/transform-browser-url.d.ts",
187
+ "default": "./dist/transform-browser-url.js"
184
188
  }
185
189
  },
186
190
  "bin": {