autokap 1.8.8 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -117,6 +117,8 @@ export type ArtifactUploadObjectId = "screenshotRaw" | "screenshot" | "clipGif"
117
117
  export interface ArtifactUploadMetadata {
118
118
  presetId: string;
119
119
  runId: string;
120
+ /** Per-invocation session id grouping every charge of one CLI run (migration 267). */
121
+ sessionId?: string | null;
120
122
  variantId: string;
121
123
  targetId?: string | null;
122
124
  targetLabel?: string | null;
@@ -49,6 +49,13 @@ export interface CLIRunnerOptions {
49
49
  * When `false`, skips the failed-run debug logs export (AUT-149).
50
50
  */
51
51
  exportDebugLogs?: boolean;
52
+ /**
53
+ * Shared invocation id for billing. A multi-preset invocation (`run --outdated`,
54
+ * `auto-recapture`) passes one sessionId for every preset so all their charges
55
+ * group into a single "CLI capture" entry (migration 267). Defaults to the
56
+ * run's own id for a single-preset invocation.
57
+ */
58
+ sessionId?: string;
52
59
  }
53
60
  export interface CLIRunResult {
54
61
  success: boolean;
@@ -58,3 +65,4 @@ export interface CLIRunResult {
58
65
  }
59
66
  export declare function runCapture(options: CLIRunnerOptions): Promise<CLIRunResult>;
60
67
  export declare function buildVideoClipMetadata(videoId: string, result: RunResult, program?: ExecutionProgram, runId?: string): VideoClipMetadata[];
68
+ export declare function resolveEffectiveCliArtifactPlan(artifactPlan: ExecutionProgram['artifactPlan'], deviceFrame?: string | null): ExecutionProgram['artifactPlan'];
@@ -25,7 +25,7 @@ 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, seedDeviceConfigs } from './mockup.js';
28
+ import { applyDeviceFrame, resolveVariantFrameOptions, seedDeviceConfigs } from './mockup.js';
29
29
  import { transformBrowserUrl } from './transform-browser-url.js';
30
30
  import { localizeStatusBar } from './status-bar-l10n.js';
31
31
  import { logger } from './logger.js';
@@ -156,6 +156,10 @@ export async function runCapture(options) {
156
156
  // here; we seed mockup.ts so the export pipeline can apply them locally.
157
157
  seedDeviceConfigs(program.deviceConfigs ?? null);
158
158
  const runId = randomUUID();
159
+ // A multi-preset invocation shares one sessionId so every preset's charges
160
+ // group into one "CLI capture" billing entry; a lone run groups under its own
161
+ // runId (migration 267).
162
+ const sessionId = options.sessionId ?? runId;
159
163
  let videoAudioAssets;
160
164
  let videoAudioAssetsByLocale;
161
165
  try {
@@ -164,8 +168,13 @@ export async function runCapture(options) {
164
168
  catch (error) {
165
169
  return { success: false, runId, error: error instanceof Error ? error.message : String(error) };
166
170
  }
167
- if (!options.program && program.mediaMode === 'video') {
168
- const prepareResult = await prepareVideoSpeechForRun(config, options.presetId, runId, options.regenerateTts ?? false);
171
+ // TTS synthesis is real, billed work — it belongs to a real run only. A dry run validates
172
+ // navigation/opcodes (capture opcodes + upload are skipped below) and must NOT synthesize speech:
173
+ // doing so would bill TTS credits while the run reports "0 credits charged". The rewritten SLEEP
174
+ // durations + audio assets are only consumed on the upload path (signalVideoComplete), which a dry
175
+ // run never reaches, so skipping prep here is side-effect-free for dry.
176
+ if (!options.dryRun && !options.program && program.mediaMode === 'video') {
177
+ const prepareResult = await prepareVideoSpeechForRun(config, options.presetId, runId, options.regenerateTts ?? false, sessionId);
169
178
  if (!prepareResult.success) {
170
179
  return { success: false, runId, error: prepareResult.error };
171
180
  }
@@ -303,7 +312,7 @@ export async function runCapture(options) {
303
312
  message: 'saving captures',
304
313
  });
305
314
  const provenance = buildRunProvenance(program, schemaVersionOrigin);
306
- const uploadOutcome = await uploadResults(config, program, runResult, runId, provenance);
315
+ const uploadOutcome = await uploadResults(config, program, runResult, runId, sessionId, provenance);
307
316
  if (program.mediaMode === 'video' && runResult.success) {
308
317
  await signalVideoComplete(config, program, runResult, uploadOutcome.runId, videoAudioAssets, videoAudioAssetsByLocale);
309
318
  }
@@ -351,7 +360,7 @@ export async function runCapture(options) {
351
360
  && (runResult ? !runResult.success : true);
352
361
  if (shouldExport) {
353
362
  logger.info('[debug-logs] Exporting debug logs to AutoKap…');
354
- await logCollector.flushTo(runId, program.presetId, config.apiBaseUrl, config.apiKey, options.env);
363
+ await logCollector.flushTo(runId, program.presetId, config.apiBaseUrl, config.apiKey, options.env, runResult?.failureKind);
355
364
  }
356
365
  logCollector.stop();
357
366
  }
@@ -425,7 +434,7 @@ async function fetchProgram(config, presetId, environmentName) {
425
434
  }
426
435
  return { success: false, error: 'failed to fetch program: retry attempts exhausted' };
427
436
  }
428
- async function prepareVideoSpeechForRun(config, videoId, runId, regenerateTts) {
437
+ async function prepareVideoSpeechForRun(config, videoId, runId, regenerateTts, sessionId) {
429
438
  if (regenerateTts) {
430
439
  logger.info('[capture] Forcing TTS regeneration — all cached segments will be re-synthesized and billed.');
431
440
  }
@@ -440,7 +449,9 @@ async function prepareVideoSpeechForRun(config, videoId, runId, regenerateTts) {
440
449
  'Content-Type': 'application/json',
441
450
  [CLI_VERSION_HEADER]: APP_VERSION,
442
451
  },
443
- body: JSON.stringify(regenerateTts ? { videoId, runId, regenerateTts: true } : { videoId, runId }),
452
+ body: JSON.stringify(regenerateTts
453
+ ? { videoId, runId, sessionId, regenerateTts: true }
454
+ : { videoId, runId, sessionId }),
444
455
  });
445
456
  }
446
457
  catch (err) {
@@ -681,7 +692,7 @@ async function postRunStart(config, runId, presetId, variantCount, env) {
681
692
  logger.warn(`[capture] Run registration error: ${message}`);
682
693
  }
683
694
  }
684
- async function uploadResults(config, program, result, runId, provenance) {
695
+ async function uploadResults(config, program, result, runId, sessionId, provenance) {
685
696
  const artifactJobs = result.variantResults.flatMap((variant) => {
686
697
  const variantSpec = program.variants.find((entry) => entry.id === variant.variantId);
687
698
  return variant.artifacts.map((artifact) => ({
@@ -696,7 +707,7 @@ async function uploadResults(config, program, result, runId, provenance) {
696
707
  logger.info(`[capture] Uploading ${totalArtifacts} capture artifacts with concurrency ${artifactUploadConcurrency}`);
697
708
  }
698
709
  await runWithConcurrency(artifactJobs, artifactUploadConcurrency, async (job, index) => {
699
- await uploadArtifact(config, program, runId, totalArtifacts, index + 1, job, provenance);
710
+ await uploadArtifact(config, program, runId, sessionId, totalArtifacts, index + 1, job, provenance);
700
711
  });
701
712
  // Strip binary buffers from artifacts before sending. The raw PNG/video
702
713
  // buffers were already uploaded via /api/cli/artifacts above, and the
@@ -857,18 +868,19 @@ function inferVariantLocale(variantId) {
857
868
  function inferVariantTheme(variantId) {
858
869
  return variantId.endsWith('-dark') ? 'dark' : 'light';
859
870
  }
860
- async function uploadArtifact(config, program, runId, totalArtifacts, uploadNumber, job, provenance) {
871
+ async function uploadArtifact(config, program, runId, sessionId, totalArtifacts, uploadNumber, job, provenance) {
861
872
  const { artifact, variant, variantSpec } = job;
862
873
  const filename = buildArtifactFilename(program.presetId, variant.variantId, artifact);
863
874
  const label = artifact.captureName ?? artifact.clipName ?? filename;
864
875
  logger.info(`[capture] Exporting capture ${uploadNumber}/${totalArtifacts}: ${label}`);
865
876
  if (process.env.AUTOKAP_USE_LEGACY_MULTIPART_UPLOADS === '1') {
866
- await uploadArtifactMultipart(config, program, runId, job, filename, provenance);
877
+ await uploadArtifactMultipart(config, program, runId, sessionId, job, filename, provenance);
867
878
  return;
868
879
  }
869
880
  const prepared = await prepareDirectArtifactUpload({
870
881
  program,
871
882
  runId,
883
+ sessionId,
872
884
  artifact,
873
885
  variant,
874
886
  variantSpec,
@@ -933,7 +945,7 @@ async function uploadArtifact(config, program, runId, totalArtifacts, uploadNumb
933
945
  throw new Error(`artifact completion failed for ${variant.variantId}: ${await formatServerError(completeResponse, completeUrl)}`);
934
946
  }
935
947
  }
936
- async function uploadArtifactMultipart(config, program, runId, job, filename, provenance) {
948
+ async function uploadArtifactMultipart(config, program, runId, sessionId, job, filename, provenance) {
937
949
  const { artifact, variant, variantSpec } = job;
938
950
  const formData = new FormData();
939
951
  formData.append('file', new Blob([new Uint8Array(artifact.buffer)], { type: artifact.mimeType }), filename);
@@ -947,6 +959,7 @@ async function uploadArtifactMultipart(config, program, runId, job, filename, pr
947
959
  formData.append('cliVersion', provenance.cliVersion);
948
960
  formData.append('programHash', provenance.programHash);
949
961
  formData.append('runId', runId);
962
+ formData.append('sessionId', sessionId);
950
963
  formData.append('variantId', variant.variantId);
951
964
  formData.append('targetId', variantSpec?.targetId ?? variant.variantId);
952
965
  formData.append('targetLabel', variantSpec?.targetLabel ?? variantSpec?.deviceFrame ?? variant.variantId);
@@ -962,6 +975,9 @@ async function uploadArtifactMultipart(config, program, runId, job, filename, pr
962
975
  if (variantSpec?.deviceFrame) {
963
976
  formData.append('deviceFrame', variantSpec.deviceFrame);
964
977
  }
978
+ if (variantSpec?.mockupOptions) {
979
+ formData.append('mockupOptions', JSON.stringify(variantSpec.mockupOptions));
980
+ }
965
981
  const requestedDeviceScaleFactor = variantSpec?.deviceScaleFactor ?? program.outputScale ?? 2;
966
982
  const isFrameCapture = artifact.mediaMode === 'clip' || artifact.mediaMode === 'video';
967
983
  const deviceScaleFactor = isFrameCapture && Number.isFinite(requestedDeviceScaleFactor)
@@ -1015,7 +1031,7 @@ async function uploadArtifactMultipart(config, program, runId, job, filename, pr
1015
1031
  }
1016
1032
  }
1017
1033
  async function prepareDirectArtifactUpload(params) {
1018
- const { program, runId, artifact, variant, variantSpec, provenance } = params;
1034
+ const { program, runId, sessionId, artifact, variant, variantSpec, provenance } = params;
1019
1035
  const requestedDeviceScaleFactor = variantSpec?.deviceScaleFactor ?? program.outputScale ?? 2;
1020
1036
  const isFrameCapture = artifact.mediaMode === 'clip' || artifact.mediaMode === 'video';
1021
1037
  const deviceScaleFactor = isFrameCapture && Number.isFinite(requestedDeviceScaleFactor)
@@ -1032,6 +1048,7 @@ async function prepareDirectArtifactUpload(params) {
1032
1048
  cliVersion: provenance.cliVersion,
1033
1049
  programHash: provenance.programHash,
1034
1050
  runId,
1051
+ sessionId,
1035
1052
  variantId: variant.variantId,
1036
1053
  targetId: variantSpec?.targetId ?? variant.variantId,
1037
1054
  targetLabel: variantSpec?.targetLabel ?? variantSpec?.deviceFrame ?? variant.variantId,
@@ -1103,22 +1120,24 @@ async function prepareDirectUploadParts(params) {
1103
1120
  async function prepareScreenshotBufferForDirectUpload(input, metadata, program, variantSpec, tabIcon) {
1104
1121
  let output = input;
1105
1122
  const artifactPlan = resolveEffectiveCliArtifactPlan(program.artifactPlan, variantSpec?.deviceFrame ?? null);
1106
- if (artifactPlan?.applyStatusBar && !artifactPlan?.applyMockup) {
1107
- throw new Error('applyStatusBar requires applyMockup with a device frame');
1108
- }
1109
- if (artifactPlan?.applyMockup) {
1110
- if (!variantSpec?.deviceFrame) {
1111
- throw new Error('applyMockup requires a deviceFrame on the variant');
1112
- }
1113
- output = await applyDeviceFrame(output, variantSpec.deviceFrame, {
1123
+ // The variant's deviceFrame is the sole gate. Its mockupOptions (orientation, status bar,
1124
+ // safe areas, …) defined by the user on the preset — drive the frame; the legacy
1125
+ // program-level applyStatusBar only survives as a fallback for old programs without options.
1126
+ if (variantSpec?.deviceFrame) {
1127
+ const mockup = variantSpec.mockupOptions;
1128
+ const frame = resolveVariantFrameOptions(mockup, {
1114
1129
  orientation: inferCliOrientation(metadata.viewport ?? null),
1130
+ showStatusBar: artifactPlan?.applyStatusBar,
1131
+ });
1132
+ output = await applyDeviceFrame(output, variantSpec.deviceFrame, {
1133
+ ...mockup,
1134
+ ...frame,
1115
1135
  viewport: metadata.viewport ?? undefined,
1116
1136
  colorScheme: metadata.theme,
1117
1137
  outputScale: normalizeCliDeviceScaleFactor(metadata.deviceScaleFactor)
1118
1138
  ?? normalizeCliDeviceScaleFactor(program.outputScale)
1119
1139
  ?? 2,
1120
- showStatusBar: artifactPlan.applyStatusBar ?? false,
1121
- statusBar: localizeStatusBar({}, metadata.lang),
1140
+ statusBar: localizeStatusBar(mockup?.statusBar ?? {}, metadata.lang),
1122
1141
  browserBar: buildCliBrowserBar(metadata.captureUrl, metadata.theme, tabIcon, { publicUrl: program.publicUrl, pageTitle: metadata.pageTitle ?? null }),
1123
1142
  });
1124
1143
  }
@@ -1181,10 +1200,11 @@ async function prepareClipBuffersForDirectUpload(artifact, metadata) {
1181
1200
  await fs.rm(tempDir, { recursive: true, force: true }).catch(() => undefined);
1182
1201
  }
1183
1202
  }
1184
- function resolveEffectiveCliArtifactPlan(artifactPlan, deviceFrame) {
1203
+ export function resolveEffectiveCliArtifactPlan(artifactPlan, deviceFrame) {
1204
+ // Device mockups are gated SOLELY by the variant's `deviceFrame` (user-defined, deterministic).
1205
+ // A present deviceFrame always renders its frame; `applyMockup` is a derived value, never a
1206
+ // kill-switch (the legacy `applyMockup: false` toggle is intentionally ignored here).
1185
1207
  if (deviceFrame) {
1186
- if (artifactPlan.applyMockup === false)
1187
- return artifactPlan;
1188
1208
  return artifactPlan.applyMockup ? artifactPlan : { ...artifactPlan, applyMockup: true };
1189
1209
  }
1190
1210
  if (!artifactPlan.applyMockup && !artifactPlan.applyStatusBar)
package/dist/cli.js CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command } from 'commander';
3
3
  import { createRequire } from 'node:module';
4
+ import { randomUUID } from 'node:crypto';
4
5
  import path from 'node:path';
5
6
  import fs from 'node:fs/promises';
6
7
  const require = createRequire(import.meta.url);
@@ -137,6 +138,9 @@ async function runOutdatedPresetsLocally(opts) {
137
138
  const { runCapture } = await import('./cli-runner.js');
138
139
  const failures = [];
139
140
  logger.info(`[capture] Running ${data.presets.length} outdated preset(s)`);
141
+ // One session id for the whole invocation so every preset's screenshot/clip/
142
+ // video charges group into a single "CLI capture" billing entry (migration 267).
143
+ const sessionId = randomUUID();
140
144
  for (const preset of data.presets) {
141
145
  const label = preset.name ? `${preset.name} (${preset.id})` : preset.id;
142
146
  logger.info(`[capture] Running outdated preset ${label}`);
@@ -147,6 +151,7 @@ async function runOutdatedPresetsLocally(opts) {
147
151
  allowUploadFailure: opts.allowUploadFailure,
148
152
  dryRun: opts.dry,
149
153
  regenerateTts: opts.regenerateTts,
154
+ sessionId,
150
155
  });
151
156
  if (!result.success) {
152
157
  failures.push({
@@ -397,6 +402,10 @@ program
397
402
  failedPresets: 0,
398
403
  message: `Runner started: ${data.presets.length} preset(s) to capture`,
399
404
  });
405
+ // One session id for the whole invocation so every preset's charges group
406
+ // into a single "CLI capture" billing entry (migration 267). For cloud runs
407
+ // child artifact billing is suppressed server-side, so this is a no-op there.
408
+ const sessionId = randomUUID();
400
409
  for (const [index, preset] of data.presets.entries()) {
401
410
  const presetDisplayName = displayPresetName(preset);
402
411
  const label = preset.name ? `${preset.name} (${preset.id})` : preset.id;
@@ -417,6 +426,7 @@ program
417
426
  headed: opts.headed,
418
427
  allowUploadFailure: opts.allowUploadFailure,
419
428
  regenerateTts: opts.regenerateTts,
429
+ sessionId,
420
430
  // Each preset runs under its own ephemeral runId, which is NOT a
421
431
  // capture_runs row in a cloud batch (the parent cloud run owns the row),
422
432
  // so the per-preset error-log export would 404. Failure telemetry for
@@ -457,6 +467,9 @@ program
457
467
  childRunId,
458
468
  status: 'failed',
459
469
  errorMessage: error,
470
+ ...(result.runResult?.failureKind
471
+ ? { failureKind: result.runResult.failureKind }
472
+ : {}),
460
473
  message: `Preset failed: ${presetDisplayName}`,
461
474
  });
462
475
  }
@@ -1053,7 +1053,7 @@ export declare const VariantSpecSchema: z.ZodObject<{
1053
1053
  light: "light";
1054
1054
  dark: "dark";
1055
1055
  }>>;
1056
- }, z.core.$strict>>;
1056
+ }, z.core.$strip>>;
1057
1057
  }, z.core.$strict>;
1058
1058
  export declare const PreconditionSpecSchema: z.ZodObject<{
1059
1059
  credentialsId: z.ZodOptional<z.ZodString>;
@@ -1205,7 +1205,7 @@ export declare const ExecutionProgramSchema: z.ZodObject<{
1205
1205
  light: "light";
1206
1206
  dark: "dark";
1207
1207
  }>>;
1208
- }, z.core.$strict>>;
1208
+ }, z.core.$strip>>;
1209
1209
  }, z.core.$strict>>;
1210
1210
  preconditions: z.ZodObject<{
1211
1211
  credentialsId: z.ZodOptional<z.ZodString>;
@@ -553,7 +553,13 @@ export const VariantSpecSchema = z.object({
553
553
  radius: z.number(),
554
554
  }).strict().optional(),
555
555
  colorScheme: z.enum(['light', 'dark']).optional(),
556
- }).strict().optional(),
556
+ // STRIP (not strict): per-variant mockupOptions are reconciled verbatim from the preset's
557
+ // CaptureTarget, whose UI-side MockupOptions carries render-irrelevant keys the engine never
558
+ // consumes (showDock, dockScale, dockMode, userAppIcon, autoBrowserBar, viewport). Strict would
559
+ // make parseProgram THROW on those — stranding every Mac/browser preset at CLI fetch time.
560
+ // Dropping unknown keys post-parse is safe: applyDeviceFrame ignores them, and the full set
561
+ // stays on config.targets for the UI/preview.
562
+ }).optional(),
557
563
  }).strict();
558
564
  const cookieSchema = z.object({
559
565
  name: z.string().min(1),
@@ -535,9 +535,16 @@ export interface ArtifactSpec {
535
535
  cursorTheme?: VideoCursorTheme;
536
536
  /** Max clip duration in seconds. Clips are trimmed if they exceed this. Default: 8. Ignored when `mediaMode='video'`. */
537
537
  maxClipDurationSec?: number;
538
- /** Whether to apply device frame mockup. Default: false */
538
+ /**
539
+ * @deprecated Device mockups are gated SOLELY by a variant's `deviceFrame` (user-defined,
540
+ * deterministic). The render paths derive mockup application from `deviceFrame` and ignore this
541
+ * flag as a gate. Kept optional for back-compat with stored programs; do not author it.
542
+ */
539
543
  applyMockup?: boolean;
540
- /** Whether to add status bar. Default: false */
544
+ /**
545
+ * @deprecated Per-variant `mockupOptions.showStatusBar` drives the status bar now. Retained only
546
+ * as a fallback for legacy programs that lack per-variant `mockupOptions`. Do not author it.
547
+ */
541
548
  applyStatusBar?: boolean;
542
549
  }
543
550
  export interface ExecutionProgram {
@@ -647,6 +654,13 @@ export interface OpcodeResult {
647
654
  /** Error message if failed */
648
655
  error?: string;
649
656
  }
657
+ /**
658
+ * Structured failure category, set on top of the free-text `error`. Lets the
659
+ * server surface a specific preset state instead of a generic "failed":
660
+ * `login_failed` = an opcode inside the login window (credential typing → first
661
+ * post-login assertion) failed, so the credentials are likely wrong.
662
+ */
663
+ export type RunFailureKind = 'login_failed';
650
664
  export interface VariantResult {
651
665
  variantId: string;
652
666
  success: boolean;
@@ -663,6 +677,8 @@ export interface VariantResult {
663
677
  */
664
678
  detectedAppVersion?: string | null;
665
679
  error?: string;
680
+ /** Set when the failure falls inside the login window — see RunFailureKind. */
681
+ failureKind?: RunFailureKind;
666
682
  }
667
683
  export interface ArtifactResult {
668
684
  mediaMode: MediaMode;
@@ -814,6 +830,8 @@ export interface RunResult {
814
830
  */
815
831
  warnings?: string[];
816
832
  error?: string;
833
+ /** First non-null variant `failureKind` — see RunFailureKind. */
834
+ failureKind?: RunFailureKind;
817
835
  }
818
836
  export interface WaitCondition {
819
837
  selector: string;
@@ -1,5 +1,6 @@
1
1
  import type { LogEntry } from './logger.js';
2
2
  import type { ProgressEvent } from './opcode-runner.js';
3
+ import type { RunFailureKind } from './execution-types.js';
3
4
  export interface OpcodeContext {
4
5
  index: number;
5
6
  kind: string;
@@ -24,6 +25,9 @@ export interface ErrorLogsPayload {
24
25
  entries: ExportedLogEntry[];
25
26
  envName?: string;
26
27
  endedAt: string;
28
+ /** Structured failure category (e.g. 'login_failed'). Lets the server tag the
29
+ * preset's capture_failed event with a specific kind. */
30
+ failureKind?: RunFailureKind;
27
31
  }
28
32
  export declare class LogCollector {
29
33
  private entries;
@@ -35,7 +39,7 @@ export declare class LogCollector {
35
39
  onProgress(event: ProgressEvent): void;
36
40
  snapshot(): ExportedLogEntry[];
37
41
  size(): number;
38
- flushTo(runId: string, presetId: string, apiBaseUrl: string, apiKey: string, envName?: string): Promise<{
42
+ flushTo(runId: string, presetId: string, apiBaseUrl: string, apiKey: string, envName?: string, failureKind?: RunFailureKind): Promise<{
39
43
  ok: boolean;
40
44
  status?: number;
41
45
  error?: string;
@@ -61,7 +61,7 @@ export class LogCollector {
61
61
  size() {
62
62
  return this.entries.length;
63
63
  }
64
- async flushTo(runId, presetId, apiBaseUrl, apiKey, envName) {
64
+ async flushTo(runId, presetId, apiBaseUrl, apiKey, envName, failureKind) {
65
65
  if (this.entries.length === 0) {
66
66
  return { ok: true, status: 204 };
67
67
  }
@@ -71,6 +71,7 @@ export class LogCollector {
71
71
  entries: this.entries,
72
72
  envName,
73
73
  endedAt: new Date().toISOString(),
74
+ ...(failureKind ? { failureKind } : {}),
74
75
  };
75
76
  const controller = new AbortController();
76
77
  const timeout = setTimeout(() => controller.abort(), FLUSH_TIMEOUT_MS);
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Login detection — pure, dependency-light helpers shared by the opcode runner
3
+ * (to classify a failure as login-related) and credential substitution
4
+ * (opcode-actions.ts owns the actual `{{token}}` replacement).
5
+ *
6
+ * A program "logs in" when it types credentials: a TYPE opcode whose text (or a
7
+ * locale override) contains the `{{email}}` / `{{password}}` placeholder. The
8
+ * server resolves these placeholders at capture time from the preset's linked
9
+ * credentials account; the stored program only ever holds the placeholders.
10
+ *
11
+ * This module imports ONLY types so it stays safe to reference from any layer.
12
+ */
13
+ import type { ExecutionOpcode } from './execution-types.js';
14
+ /** The credential placeholder tokens — the contract between the authored
15
+ * program and the server-side substitution. Single source of truth. */
16
+ export declare const CREDENTIAL_TOKEN_EMAIL = "{{email}}";
17
+ export declare const CREDENTIAL_TOKEN_PASSWORD = "{{password}}";
18
+ export declare const CREDENTIAL_TOKEN_LOGIN_URL = "{{loginUrl}}";
19
+ /**
20
+ * Which credential fields the program actually requires. Lets a caller compare
21
+ * against the linked account's available fields (has_email / has_password)
22
+ * without decrypting anything.
23
+ */
24
+ export declare function programRequiredCredentialFields(steps: ExecutionOpcode[]): {
25
+ email: boolean;
26
+ password: boolean;
27
+ };
28
+ /** True when the program logs in (types an email or password). */
29
+ export declare function programRequiresLogin(steps: ExecutionOpcode[]): boolean;
30
+ /**
31
+ * The contiguous index range that constitutes the login flow:
32
+ * - `start` = first credential-typing opcode,
33
+ * - `end` = first post-login assertion after the last credential opcode —
34
+ * EITHER a standalone assertion opcode (ASSERT_ROUTE /
35
+ * ASSERT_SURFACE / WAIT_FOR) OR, as the generator actually emits,
36
+ * the submit CLICK whose own postcondition asserts the post-login
37
+ * route/element (route_matches / element_visible) — else the last
38
+ * credential opcode itself.
39
+ *
40
+ * Scanning stops at that FIRST asserting opcode, so the window covers the
41
+ * credential typing and the submit (which proves login) but does NOT extend
42
+ * across the post-login navigation. A failure anywhere in `[start, end]` means
43
+ * the login did not go through: a TYPE failing (form gone/changed), the submit
44
+ * failing, or its post-login assertion failing (still on /login). Returns null
45
+ * when the program does not log in.
46
+ */
47
+ export declare function getLoginWindow(steps: ExecutionOpcode[]): {
48
+ start: number;
49
+ end: number;
50
+ } | null;
51
+ /** True when a failure at `failedIndex` falls inside the login window. */
52
+ export declare function isLoginFailureIndex(steps: ExecutionOpcode[], failedIndex: number): boolean;
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Login detection — pure, dependency-light helpers shared by the opcode runner
3
+ * (to classify a failure as login-related) and credential substitution
4
+ * (opcode-actions.ts owns the actual `{{token}}` replacement).
5
+ *
6
+ * A program "logs in" when it types credentials: a TYPE opcode whose text (or a
7
+ * locale override) contains the `{{email}}` / `{{password}}` placeholder. The
8
+ * server resolves these placeholders at capture time from the preset's linked
9
+ * credentials account; the stored program only ever holds the placeholders.
10
+ *
11
+ * This module imports ONLY types so it stays safe to reference from any layer.
12
+ */
13
+ /** The credential placeholder tokens — the contract between the authored
14
+ * program and the server-side substitution. Single source of truth. */
15
+ export const CREDENTIAL_TOKEN_EMAIL = '{{email}}';
16
+ export const CREDENTIAL_TOKEN_PASSWORD = '{{password}}';
17
+ export const CREDENTIAL_TOKEN_LOGIN_URL = '{{loginUrl}}';
18
+ /** Opcode kinds that prove a login advanced past the form: an explicit route /
19
+ * surface assertion, or a wait for a post-login element. The first such opcode
20
+ * after the credential typing closes the "login window". */
21
+ const POSTLOGIN_ASSERTION_KINDS = new Set([
22
+ 'ASSERT_ROUTE',
23
+ 'ASSERT_SURFACE',
24
+ 'WAIT_FOR',
25
+ ]);
26
+ /** Postcondition types that assert the login advanced past the form. The
27
+ * canonical generated login flow does NOT emit a standalone assertion opcode —
28
+ * it encodes the post-login check on the submit CLICK's own postcondition
29
+ * (route_matches the post-login route, or element_visible a post-login
30
+ * element). The first opcode after the credentials whose postcondition is one
31
+ * of these closes the window too, so the submit click is inside it. */
32
+ const POSTLOGIN_POSTCONDITION_TYPES = new Set([
33
+ 'route_matches',
34
+ 'element_visible',
35
+ ]);
36
+ /** Every text field of an opcode that may carry a credential placeholder. */
37
+ function credentialTexts(opcode) {
38
+ if (opcode.kind === 'TYPE') {
39
+ const texts = [opcode.text];
40
+ if (opcode.textByLocale)
41
+ texts.push(...Object.values(opcode.textByLocale));
42
+ return texts;
43
+ }
44
+ if (opcode.kind === 'NAVIGATE')
45
+ return [opcode.url];
46
+ return [];
47
+ }
48
+ /** True when the opcode types/uses email or password credentials. */
49
+ function isCredentialOpcode(opcode) {
50
+ return credentialTexts(opcode).some((text) => typeof text === 'string' &&
51
+ (text.includes(CREDENTIAL_TOKEN_EMAIL) || text.includes(CREDENTIAL_TOKEN_PASSWORD)));
52
+ }
53
+ /**
54
+ * Which credential fields the program actually requires. Lets a caller compare
55
+ * against the linked account's available fields (has_email / has_password)
56
+ * without decrypting anything.
57
+ */
58
+ export function programRequiredCredentialFields(steps) {
59
+ let email = false;
60
+ let password = false;
61
+ for (const opcode of steps) {
62
+ for (const text of credentialTexts(opcode)) {
63
+ if (typeof text !== 'string')
64
+ continue;
65
+ if (text.includes(CREDENTIAL_TOKEN_EMAIL))
66
+ email = true;
67
+ if (text.includes(CREDENTIAL_TOKEN_PASSWORD))
68
+ password = true;
69
+ }
70
+ if (email && password)
71
+ break;
72
+ }
73
+ return { email, password };
74
+ }
75
+ /** True when the program logs in (types an email or password). */
76
+ export function programRequiresLogin(steps) {
77
+ const { email, password } = programRequiredCredentialFields(steps);
78
+ return email || password;
79
+ }
80
+ /**
81
+ * The contiguous index range that constitutes the login flow:
82
+ * - `start` = first credential-typing opcode,
83
+ * - `end` = first post-login assertion after the last credential opcode —
84
+ * EITHER a standalone assertion opcode (ASSERT_ROUTE /
85
+ * ASSERT_SURFACE / WAIT_FOR) OR, as the generator actually emits,
86
+ * the submit CLICK whose own postcondition asserts the post-login
87
+ * route/element (route_matches / element_visible) — else the last
88
+ * credential opcode itself.
89
+ *
90
+ * Scanning stops at that FIRST asserting opcode, so the window covers the
91
+ * credential typing and the submit (which proves login) but does NOT extend
92
+ * across the post-login navigation. A failure anywhere in `[start, end]` means
93
+ * the login did not go through: a TYPE failing (form gone/changed), the submit
94
+ * failing, or its post-login assertion failing (still on /login). Returns null
95
+ * when the program does not log in.
96
+ */
97
+ export function getLoginWindow(steps) {
98
+ let start = -1;
99
+ let lastCred = -1;
100
+ for (let i = 0; i < steps.length; i++) {
101
+ if (isCredentialOpcode(steps[i])) {
102
+ if (start < 0)
103
+ start = i;
104
+ lastCred = i;
105
+ }
106
+ }
107
+ if (start < 0)
108
+ return null;
109
+ let end = lastCred;
110
+ for (let i = lastCred + 1; i < steps.length; i++) {
111
+ const step = steps[i];
112
+ if (POSTLOGIN_ASSERTION_KINDS.has(step.kind) ||
113
+ (step.postcondition != null &&
114
+ POSTLOGIN_POSTCONDITION_TYPES.has(step.postcondition.type))) {
115
+ end = i;
116
+ break;
117
+ }
118
+ }
119
+ return { start, end };
120
+ }
121
+ /** True when a failure at `failedIndex` falls inside the login window. */
122
+ export function isLoginFailureIndex(steps, failedIndex) {
123
+ const window = getLoginWindow(steps);
124
+ return window !== null && failedIndex >= window.start && failedIndex <= window.end;
125
+ }
126
+ //# sourceMappingURL=login-detection.js.map
package/dist/mockup.d.ts CHANGED
@@ -204,6 +204,19 @@ export interface MockupOptions {
204
204
  height: number;
205
205
  };
206
206
  }
207
+ /**
208
+ * Resolve the two per-variant frame decisions shared by both render paths (CLI direct-upload
209
+ * framing and the cloud legacy-multipart route): a variant's own `mockupOptions` wins, falling
210
+ * back to the viewport-inferred orientation and the deprecated program-level `applyStatusBar`.
211
+ * Pure + exported so the precedence is tested once instead of in two duplicated call sites.
212
+ */
213
+ export declare function resolveVariantFrameOptions(mockupOptions: MockupOptions | undefined, fallback: {
214
+ orientation?: MockupOrientation;
215
+ showStatusBar?: boolean;
216
+ }): {
217
+ orientation?: MockupOrientation;
218
+ showStatusBar: boolean;
219
+ };
207
220
  export interface ResolvedDeviceFrameDescriptor {
208
221
  id: string;
209
222
  name: string;
package/dist/mockup.js CHANGED
@@ -17,6 +17,18 @@ function getSupabaseMockupConfig() {
17
17
  serviceKey: process.env.SUPABASE_SERVICE_ROLE_KEY,
18
18
  };
19
19
  }
20
+ /**
21
+ * Resolve the two per-variant frame decisions shared by both render paths (CLI direct-upload
22
+ * framing and the cloud legacy-multipart route): a variant's own `mockupOptions` wins, falling
23
+ * back to the viewport-inferred orientation and the deprecated program-level `applyStatusBar`.
24
+ * Pure + exported so the precedence is tested once instead of in two duplicated call sites.
25
+ */
26
+ export function resolveVariantFrameOptions(mockupOptions, fallback) {
27
+ return {
28
+ orientation: mockupOptions?.orientation ?? fallback.orientation,
29
+ showStatusBar: mockupOptions?.showStatusBar ?? fallback.showStatusBar ?? false,
30
+ };
31
+ }
20
32
  const DEFAULT_MOCKUP_OPTIONS = {
21
33
  orientation: 'portrait',
22
34
  outputScale: 2,
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import { VARIANT_PLACEHOLDER } from './execution-types.js';
8
8
  import { dismissAllOverlays } from './overlay-engine.js';
9
+ import { CREDENTIAL_TOKEN_EMAIL, CREDENTIAL_TOKEN_PASSWORD, CREDENTIAL_TOKEN_LOGIN_URL, } from './login-detection.js';
9
10
  /**
10
11
  * Substitute credential placeholders inside opcode text fields.
11
12
  * Only the {{email}}, {{password}} and {{loginUrl}} tokens are replaced.
@@ -16,9 +17,9 @@ export function substituteCredentialPlaceholders(text, credentials) {
16
17
  return text;
17
18
  }
18
19
  return text
19
- .replaceAll('{{email}}', credentials?.email ?? '')
20
- .replaceAll('{{password}}', credentials?.password ?? '')
21
- .replaceAll('{{loginUrl}}', credentials?.loginUrl ?? '');
20
+ .replaceAll(CREDENTIAL_TOKEN_EMAIL, credentials?.email ?? '')
21
+ .replaceAll(CREDENTIAL_TOKEN_PASSWORD, credentials?.password ?? '')
22
+ .replaceAll(CREDENTIAL_TOKEN_LOGIN_URL, credentials?.loginUrl ?? '');
22
23
  }
23
24
  /**
24
25
  * Returns the list of credential placeholders (`{{email}}`, `{{password}}`,
@@ -32,14 +33,14 @@ export function findUnresolvedCredentialPlaceholders(text, credentials) {
32
33
  if (typeof text !== 'string' || !text.includes('{{'))
33
34
  return [];
34
35
  const missing = [];
35
- if (text.includes('{{email}}') && !credentials?.email?.trim()) {
36
- missing.push('{{email}}');
36
+ if (text.includes(CREDENTIAL_TOKEN_EMAIL) && !credentials?.email?.trim()) {
37
+ missing.push(CREDENTIAL_TOKEN_EMAIL);
37
38
  }
38
- if (text.includes('{{password}}') && !credentials?.password) {
39
- missing.push('{{password}}');
39
+ if (text.includes(CREDENTIAL_TOKEN_PASSWORD) && !credentials?.password) {
40
+ missing.push(CREDENTIAL_TOKEN_PASSWORD);
40
41
  }
41
- if (text.includes('{{loginUrl}}') && !credentials?.loginUrl?.trim()) {
42
- missing.push('{{loginUrl}}');
42
+ if (text.includes(CREDENTIAL_TOKEN_LOGIN_URL) && !credentials?.loginUrl?.trim()) {
43
+ missing.push(CREDENTIAL_TOKEN_LOGIN_URL);
43
44
  }
44
45
  return missing;
45
46
  }
@@ -14,6 +14,7 @@ import { smartWaitForStability } from './smart-wait.js';
14
14
  import { verifyCaptureQuality } from './capture-verification.js';
15
15
  import { generateAltText } from './alt-text.js';
16
16
  import { executeOpcodeCoreAction } from './opcode-actions.js';
17
+ import { isLoginFailureIndex } from './login-detection.js';
17
18
  import { logger } from './logger.js';
18
19
  function formatOpcodeDebug(opcode) {
19
20
  const fields = [];
@@ -172,6 +173,7 @@ export async function executeProgram(program, createAdapter, options = {}) {
172
173
  detectedAppVersion,
173
174
  warnings: aggregatedWarnings.length ? aggregatedWarnings : undefined,
174
175
  error: aborted ? 'aborted' : (success ? undefined : completedVariantResults.find(v => !v.success)?.error),
176
+ failureKind: success ? undefined : completedVariantResults.find(v => v.failureKind)?.failureKind,
175
177
  };
176
178
  }
177
179
  // ── Variant execution ───────────────────────────────────────────────
@@ -247,6 +249,12 @@ async function executeVariant(program, variant, createAdapter, recoveryChain, te
247
249
  durationMs: Date.now() - startTime,
248
250
  artifacts,
249
251
  error: `opcode ${i} (${opcode.kind}) failed: ${result.error}`,
252
+ // Tag failures inside the login window (credential typing → first
253
+ // post-login assertion) so the server can surface "login failed"
254
+ // instead of a generic capture failure.
255
+ ...(isLoginFailureIndex(program.steps, i)
256
+ ? { failureKind: 'login_failed' }
257
+ : {}),
250
258
  };
251
259
  }
252
260
  }
@@ -111,7 +111,7 @@ export declare const SignedExecutionProgramEnvelopeSchema: z.ZodObject<{
111
111
  light: "light";
112
112
  dark: "dark";
113
113
  }>>;
114
- }, z.core.$strict>>;
114
+ }, z.core.$strip>>;
115
115
  }, z.core.$strict>>;
116
116
  preconditions: z.ZodObject<{
117
117
  credentialsId: z.ZodOptional<z.ZodString>;
@@ -19,6 +19,7 @@ const PRESET_SKILL_SOURCE = {
19
19
  { relativePath: 'OPCODE-REFERENCE.md', title: 'Opcode Reference', anchor: 'reference-opcode-reference' },
20
20
  { relativePath: 'references/STANDARDS.md', title: 'Prompt Charter & Quality Standards', anchor: 'reference-prompt-standards' },
21
21
  { relativePath: 'references/mock-data.md', title: 'Mock Data Injection', anchor: 'reference-mock-data-injection' },
22
+ { relativePath: 'references/video-workflow.md', title: 'Demo Video Workflow', anchor: 'reference-demo-video-workflow' },
22
23
  { relativePath: 'references/examples.md', title: 'Complete Examples', anchor: 'reference-complete-examples' },
23
24
  ],
24
25
  };
@@ -130,7 +130,7 @@ export declare const VideoIngestPayloadSchema: z.ZodObject<{
130
130
  light: "light";
131
131
  dark: "dark";
132
132
  }>>;
133
- }, z.core.$strict>>;
133
+ }, z.core.$strip>>;
134
134
  }, z.core.$strict>>;
135
135
  preconditions: z.ZodObject<{
136
136
  credentialsId: z.ZodOptional<z.ZodString>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "autokap",
3
- "version": "1.8.8",
3
+ "version": "1.9.0",
4
4
  "description": "AI-powered CLI tool for capturing clean screenshots of websites",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",