@playdrop/playdrop-cli 0.6.5 → 0.6.7

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.
@@ -50,6 +50,9 @@ const SDK_INIT_PATTERNS = [
50
50
  /\bplaydrop\s*\.\s*init\s*\(/g,
51
51
  /\bsdk\s*\.\s*initialize\s*\(/g,
52
52
  ];
53
+ const SDK_READY_PATTERNS = [
54
+ /\bsdk\s*\??\.\s*host\s*\??\.\s*ready\s*\(/g,
55
+ ];
53
56
  const LOCAL_HTML_SCRIPT_PATTERN = /<script\b[^>]*\bsrc\s*=\s*["']([^"']+)["'][^>]*>/gi;
54
57
  const LOCAL_IMPORT_PATTERNS = [
55
58
  /(?:import|export)\s+(?:[^'"`]*?\s+from\s+)?["']([^"']+)["']/g,
@@ -124,6 +127,33 @@ function scanForLegacySdkSymbols(task) {
124
127
  function stripSpecifierDecorators(specifier) {
125
128
  return specifier.split(/[?#]/, 1)[0]?.trim() ?? '';
126
129
  }
130
+ function escapeRegexLiteral(value) {
131
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
132
+ }
133
+ function detectSdkInitInSource(source) {
134
+ if (SDK_INIT_PATTERNS.some((pattern) => {
135
+ pattern.lastIndex = 0;
136
+ return pattern.test(source);
137
+ })) {
138
+ return true;
139
+ }
140
+ const playdropAliases = new Set();
141
+ const aliasAssignmentPattern = /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*[^;\n]*\bplaydrop\b[^;\n]*/g;
142
+ let aliasMatch;
143
+ while ((aliasMatch = aliasAssignmentPattern.exec(source)) !== null) {
144
+ const alias = aliasMatch[1]?.trim();
145
+ if (alias) {
146
+ playdropAliases.add(alias);
147
+ }
148
+ }
149
+ for (const alias of playdropAliases) {
150
+ const aliasInitPattern = new RegExp(`\\b${escapeRegexLiteral(alias)}\\s*(?:\\.\\s*|\\?\\.\\s*)init\\s*\\(`, 'g');
151
+ if (aliasInitPattern.test(source)) {
152
+ return true;
153
+ }
154
+ }
155
+ return false;
156
+ }
127
157
  function safeReadFile(filePath) {
128
158
  try {
129
159
  return (0, node_fs_1.readFileSync)(filePath, 'utf8');
@@ -378,9 +408,10 @@ function collectSdkDetectionFiles(task, mode) {
378
408
  function detectSdkUsage(task, mode) {
379
409
  let hasSdkReference = false;
380
410
  let hasSdkInit = false;
411
+ let hasSdkReady = false;
381
412
  const files = collectSdkDetectionFiles(task, mode);
382
413
  for (const filePath of files) {
383
- if (hasSdkReference && hasSdkInit) {
414
+ if (hasSdkReference && hasSdkInit && hasSdkReady) {
384
415
  break;
385
416
  }
386
417
  const source = safeReadFile(filePath);
@@ -394,13 +425,16 @@ function detectSdkUsage(task, mode) {
394
425
  });
395
426
  }
396
427
  if (!hasSdkInit) {
397
- hasSdkInit = SDK_INIT_PATTERNS.some((pattern) => {
428
+ hasSdkInit = detectSdkInitInSource(source);
429
+ }
430
+ if (!hasSdkReady) {
431
+ hasSdkReady = SDK_READY_PATTERNS.some((pattern) => {
398
432
  pattern.lastIndex = 0;
399
433
  return pattern.test(source);
400
434
  });
401
435
  }
402
436
  }
403
- return { hasSdkReference, hasSdkInit };
437
+ return { hasSdkReference, hasSdkInit, hasSdkReady };
404
438
  }
405
439
  function collectOutdatedSdkVersionWarnings(task) {
406
440
  if (!task.packageJsonPath) {
@@ -451,6 +485,7 @@ function collectAppValidationWarnings(task, mode = 'source') {
451
485
  : primaryDetection;
452
486
  const hasSdkReference = primaryDetection.hasSdkReference || sourceDetection.hasSdkReference;
453
487
  const hasSdkInit = primaryDetection.hasSdkInit || sourceDetection.hasSdkInit;
488
+ const hasSdkReady = primaryDetection.hasSdkReady || sourceDetection.hasSdkReady;
454
489
  const warnings = [];
455
490
  if (!hasSdkReference) {
456
491
  warnings.push(`[apps][validate] Could not detect the Playdrop SDK loader or @playdrop/sdk import for ${task.name}. We could not find https://assets.playdrop.ai/sdk/playdrop.js or @playdrop/sdk in the app files, so it might not work once uploaded.`);
@@ -458,6 +493,9 @@ function collectAppValidationWarnings(task, mode = 'source') {
458
493
  if (!hasSdkInit) {
459
494
  warnings.push(`[apps][validate] Could not detect Playdrop SDK initialization for ${task.name}. We could not find playdrop.init() in the app files, so it might not work once uploaded.`);
460
495
  }
496
+ if (!hasSdkReady) {
497
+ warnings.push(`[apps][validate] Could not detect Playdrop host readiness for ${task.name}. We could not find sdk.host.ready() in the app files, so the app will fail to start correctly inside Playdrop.`);
498
+ }
461
499
  return [...warnings, ...collectOutdatedSdkVersionWarnings(task)];
462
500
  }
463
501
  async function runFormatScript(task) {
@@ -12,6 +12,7 @@ export type RunCaptureOptions = {
12
12
  targetUrl: string;
13
13
  expectedUrl?: string | null;
14
14
  timeoutMs: number;
15
+ settleAfterReadyMs?: number;
15
16
  minimumLogLevel: CaptureLogLevel;
16
17
  screenshotPath?: string | null;
17
18
  logPath?: string | null;
@@ -21,10 +22,27 @@ export type RunCaptureOptions = {
21
22
  login?: LoginOptions | null;
22
23
  savedSessionBootstrap?: boolean;
23
24
  enableCaptureBridge?: boolean;
25
+ requireHostedLaunchReady?: boolean;
26
+ expectedHostedLaunchState?: HostedLaunchExpectedState;
27
+ };
28
+ export type HostedLaunchExpectedState = 'ready' | 'login_required' | 'controller_required' | 'surface_unsupported';
29
+ export type CaptureHostedLaunchState = {
30
+ state: 'ready';
31
+ } | {
32
+ state: 'login_required';
33
+ } | {
34
+ state: 'controller_required';
35
+ } | {
36
+ state: 'surface_unsupported';
37
+ } | {
38
+ state: 'error';
39
+ errorCode: string | null;
40
+ message: string | null;
24
41
  };
25
42
  export type CaptureRunResult = {
26
43
  errorCount: number;
27
44
  finalUrl: string;
45
+ hostedLaunchState: CaptureHostedLaunchState | null;
28
46
  };
29
47
  export declare function resolveCaptureLogLevel(value: string | undefined): CaptureLogLevel;
30
48
  export declare function validateCaptureTimeout(value: number | undefined): number;
@@ -9,7 +9,6 @@ const node_path_1 = require("node:path");
9
9
  const playwright_1 = require("./playwright");
10
10
  const sessionCookie_1 = require("./sessionCookie");
11
11
  const FRAME_SELECTOR = 'iframe[title="Game"]';
12
- const LOGIN_BUTTON_NAME = 'Sign in to play';
13
12
  exports.CAPTURE_LOG_LEVEL_VALUES = ['debug', 'info', 'warn', 'error'];
14
13
  exports.MAX_CAPTURE_TIMEOUT_SECONDS = 600;
15
14
  function formatConsoleValue(value) {
@@ -80,6 +79,68 @@ function normalizeComparableUrl(rawUrl) {
80
79
  const pathname = parsed.pathname.replace(/\/+$/, '') || '/';
81
80
  return `${parsed.origin}${pathname}`;
82
81
  }
82
+ function normalizeHostedLaunchStatePayload(payload) {
83
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
84
+ return null;
85
+ }
86
+ const state = typeof payload.state === 'string'
87
+ ? payload.state.trim().toLowerCase()
88
+ : '';
89
+ if (state === 'ready') {
90
+ return { state: 'ready' };
91
+ }
92
+ if (state === 'login_required') {
93
+ return { state: 'login_required' };
94
+ }
95
+ if (state === 'controller_required') {
96
+ return { state: 'controller_required' };
97
+ }
98
+ if (state === 'surface_unsupported') {
99
+ return { state: 'surface_unsupported' };
100
+ }
101
+ if (state !== 'error') {
102
+ return null;
103
+ }
104
+ const errorCode = typeof payload.errorCode === 'string'
105
+ ? payload.errorCode.trim() || null
106
+ : null;
107
+ const message = typeof payload.message === 'string'
108
+ ? payload.message.trim() || null
109
+ : null;
110
+ return {
111
+ state: 'error',
112
+ errorCode,
113
+ message,
114
+ };
115
+ }
116
+ function formatHostedLaunchError(state) {
117
+ const parts = ['Hosted app failed to launch.'];
118
+ if (state.errorCode) {
119
+ parts.push(`errorCode=${state.errorCode}.`);
120
+ }
121
+ if (state.message) {
122
+ parts.push(state.message);
123
+ }
124
+ return parts.join(' ');
125
+ }
126
+ function formatHostedLaunchStateMismatch(actualState, expectedState) {
127
+ if (expectedState === "login_required") {
128
+ return `Hosted app reported ${actualState} instead of login_required.`;
129
+ }
130
+ if (expectedState === "controller_required") {
131
+ return `Hosted app reported ${actualState} instead of controller_required.`;
132
+ }
133
+ if (expectedState === "surface_unsupported") {
134
+ return `Hosted app reported ${actualState} instead of surface_unsupported.`;
135
+ }
136
+ return `Hosted app reported ${actualState} instead of ready.`;
137
+ }
138
+ function shouldSettleHostedLaunchWaiter(state, expectedState) {
139
+ if (state.state === "error" || !expectedState) {
140
+ return true;
141
+ }
142
+ return state.state === expectedState;
143
+ }
83
144
  async function writeLogFile(logPath, lines) {
84
145
  if (!logPath || logPath.trim().length === 0) {
85
146
  return;
@@ -113,11 +174,15 @@ async function runCapture(options) {
113
174
  const errors = [];
114
175
  const outputLines = [];
115
176
  const expectedUrl = options.expectedUrl ? normalizeComparableUrl(options.expectedUrl) : null;
177
+ const expectedHostedLaunchState = options.requireHostedLaunchReady
178
+ ? options.expectedHostedLaunchState ?? 'ready'
179
+ : null;
116
180
  const hasExplicitLogin = Boolean(options.login?.username);
117
181
  const shouldBootstrapSavedSession = options.savedSessionBootstrap !== false && !hasExplicitLogin;
118
182
  const bootstrapToken = shouldBootstrapSavedSession ? options.token?.trim() || null : null;
119
183
  const bootstrapUser = shouldBootstrapSavedSession ? options.user ?? null : null;
120
184
  let finalUrl = options.targetUrl;
185
+ let hostedLaunchState = null;
121
186
  const record = (level, message) => {
122
187
  const line = `[capture][${level}] ${message}`;
123
188
  outputLines.push(line);
@@ -142,6 +207,24 @@ async function runCapture(options) {
142
207
  try {
143
208
  await (0, playwright_1.withChromiumPage)(async ({ context, page }) => {
144
209
  const targetOrigin = new URL(options.targetUrl).origin;
210
+ let hostedLaunchWaiterSettled = false;
211
+ let resolveHostedLaunchWaiter = null;
212
+ const hostedLaunchWaiter = options.requireHostedLaunchReady
213
+ ? new Promise((resolve) => {
214
+ resolveHostedLaunchWaiter = resolve;
215
+ })
216
+ : null;
217
+ const settleHostedLaunchWaiter = (state) => {
218
+ hostedLaunchState = state;
219
+ if (!hostedLaunchWaiter || hostedLaunchWaiterSettled) {
220
+ return;
221
+ }
222
+ if (!shouldSettleHostedLaunchWaiter(state, expectedHostedLaunchState)) {
223
+ return;
224
+ }
225
+ hostedLaunchWaiterSettled = true;
226
+ resolveHostedLaunchWaiter?.(state);
227
+ };
145
228
  if (bootstrapToken) {
146
229
  await context.addCookies((0, sessionCookie_1.buildCaptureAccessTokenCookies)(options.targetUrl, bootstrapToken));
147
230
  }
@@ -173,6 +256,12 @@ async function runCapture(options) {
173
256
  }
174
257
  if (options.enableCaptureBridge) {
175
258
  await page.exposeBinding('__playdropCaptureLog', async (_source, type, payload) => {
259
+ if (type === 'hosted-launch-state') {
260
+ const normalizedHostedState = normalizeHostedLaunchStatePayload(payload);
261
+ if (normalizedHostedState) {
262
+ settleHostedLaunchWaiter(normalizedHostedState);
263
+ }
264
+ }
176
265
  const normalized = typeof type === 'string' ? type.toLowerCase() : 'info';
177
266
  const level = normalized === 'error' || normalized === 'fatal'
178
267
  ? 'error'
@@ -216,7 +305,11 @@ async function runCapture(options) {
216
305
  });
217
306
  const bridgeWindow = window;
218
307
  bridgeWindow.playdrop = bridgeWindow.playdrop || {};
308
+ bridgeWindow.__playdropCaptureHostedLaunchState = null;
219
309
  const bridge = (type, payload) => {
310
+ if (type === 'hosted-launch-state') {
311
+ bridgeWindow.__playdropCaptureHostedLaunchState = payload;
312
+ }
220
313
  try {
221
314
  const binding = bridgeWindow.__playdropCaptureLog;
222
315
  if (typeof binding === 'function') {
@@ -342,22 +435,20 @@ async function runCapture(options) {
342
435
  recordError(`[navigation] ${response.status()} ${response.statusText()} ${options.targetUrl}`);
343
436
  }
344
437
  const readinessTimeoutMs = Math.min(Math.max(options.timeoutMs, 1000), 30000);
345
- const readinessHandle = await page.waitForFunction(({ frameSelector, loginButtonName }) => {
438
+ const readinessHandle = await page.waitForFunction(({ frameSelector, waitForHostedLaunchState }) => {
346
439
  const frame = document.querySelector(frameSelector);
347
440
  if (frame) {
348
441
  return 'frame';
349
442
  }
350
- const buttons = Array.from(document.querySelectorAll('button'));
351
- const loginButton = buttons.find((button) => button.textContent?.trim() === loginButtonName);
352
- if (loginButton) {
353
- return 'login';
443
+ if (waitForHostedLaunchState) {
444
+ const state = window.__playdropCaptureHostedLaunchState;
445
+ if (state && typeof state.state === 'string' && state.state.trim().length > 0) {
446
+ return `hosted:${state.state.trim().toLowerCase()}`;
447
+ }
354
448
  }
355
449
  return null;
356
- }, { frameSelector: FRAME_SELECTOR, loginButtonName: LOGIN_BUTTON_NAME }, { timeout: readinessTimeoutMs });
357
- const readiness = await readinessHandle.jsonValue();
358
- if (readiness !== 'frame') {
359
- throw new Error('The Playdrop app frame did not load. The page is still showing the login wall.');
360
- }
450
+ }, { frameSelector: FRAME_SELECTOR, waitForHostedLaunchState: Boolean(expectedHostedLaunchState) }, { timeout: readinessTimeoutMs });
451
+ await readinessHandle.jsonValue();
361
452
  const assertPageState = async () => {
362
453
  const currentUrl = page.url();
363
454
  if (!currentUrl) {
@@ -371,22 +462,46 @@ async function runCapture(options) {
371
462
  if (finalPathname.startsWith('/login')) {
372
463
  throw new Error(`Capture landed on the login page (${currentUrl}).`);
373
464
  }
374
- const loginVisible = await page
375
- .getByRole('button', { name: LOGIN_BUTTON_NAME })
376
- .first()
377
- .isVisible()
378
- .catch(() => false);
379
- if (loginVisible) {
380
- throw new Error('The Playdrop login wall is still visible.');
381
- }
382
465
  const frameCount = await page.locator(FRAME_SELECTOR).count();
383
- if (frameCount === 0) {
466
+ if ((expectedHostedLaunchState ?? 'ready') === 'ready' && frameCount === 0) {
384
467
  throw new Error('The Playdrop app frame never appeared.');
385
468
  }
469
+ if (expectedHostedLaunchState === 'login_required' && frameCount > 0) {
470
+ throw new Error('The Playdrop auth gate booted the app frame unexpectedly.');
471
+ }
472
+ if (expectedHostedLaunchState === 'controller_required' && frameCount > 0) {
473
+ throw new Error('The Playdrop controller gate booted the app frame unexpectedly.');
474
+ }
475
+ if (expectedHostedLaunchState === 'surface_unsupported' && frameCount > 0) {
476
+ throw new Error('The Playdrop surface gate booted the app frame unexpectedly.');
477
+ }
386
478
  return currentUrl;
387
479
  };
480
+ if (hostedLaunchWaiter) {
481
+ const launchState = await Promise.race([
482
+ hostedLaunchWaiter,
483
+ page.waitForTimeout(options.timeoutMs).then(() => {
484
+ if (hostedLaunchState && expectedHostedLaunchState && hostedLaunchState.state !== expectedHostedLaunchState) {
485
+ throw new Error(formatHostedLaunchStateMismatch(hostedLaunchState.state, expectedHostedLaunchState));
486
+ }
487
+ throw new Error(expectedHostedLaunchState === 'login_required'
488
+ ? 'Hosted app did not stop at the PlayDrop sign-in gate before the launch-check timeout.'
489
+ : expectedHostedLaunchState === 'controller_required'
490
+ ? 'Hosted app did not stop at the PlayDrop controller gate before the launch-check timeout.'
491
+ : expectedHostedLaunchState === 'surface_unsupported'
492
+ ? 'Hosted app did not stop at the PlayDrop unsupported-surface gate before the launch-check timeout.'
493
+ : 'Hosted app did not reach the ready state before the launch-check timeout.');
494
+ }),
495
+ ]);
496
+ if (launchState.state === 'error') {
497
+ throw new Error(formatHostedLaunchError(launchState));
498
+ }
499
+ if (expectedHostedLaunchState && launchState.state !== expectedHostedLaunchState) {
500
+ throw new Error(formatHostedLaunchStateMismatch(launchState.state, expectedHostedLaunchState));
501
+ }
502
+ }
388
503
  finalUrl = await assertPageState();
389
- await page.waitForTimeout(options.timeoutMs);
504
+ await page.waitForTimeout(options.settleAfterReadyMs ?? options.timeoutMs);
390
505
  finalUrl = await assertPageState();
391
506
  if (options.screenshotPath) {
392
507
  const targetDir = (0, node_path_1.dirname)(options.screenshotPath);
@@ -404,5 +519,6 @@ async function runCapture(options) {
404
519
  return {
405
520
  errorCount: errors.length,
406
521
  finalUrl,
522
+ hostedLaunchState,
407
523
  };
408
524
  }
@@ -13,15 +13,44 @@ const path_1 = __importDefault(require("path"));
13
13
  const fs_1 = require("fs");
14
14
  const config_1 = require("@playdrop/config");
15
15
  let cachedCliPackageVersion = null;
16
+ function assertValidCliVersion(version) {
17
+ if (!/^\d+\.\d+\.\d+$/.test(version)) {
18
+ throw new Error(`invalid_cli_package_version:${version}`);
19
+ }
20
+ return version;
21
+ }
22
+ function readVersionFromJson(pathname, field) {
23
+ try {
24
+ const parsed = JSON.parse((0, fs_1.readFileSync)(pathname, 'utf8'));
25
+ const value = typeof parsed[field] === 'string' ? parsed[field].trim() : '';
26
+ if (!value) {
27
+ return null;
28
+ }
29
+ return assertValidCliVersion(value);
30
+ }
31
+ catch {
32
+ return null;
33
+ }
34
+ }
16
35
  function readCliPackageVersion() {
17
36
  if (cachedCliPackageVersion) {
18
37
  return cachedCliPackageVersion;
19
38
  }
39
+ const candidateMetaPaths = [
40
+ path_1.default.resolve(__dirname, '..', 'config', 'client-meta.json'),
41
+ path_1.default.resolve(__dirname, '..', '..', '..', 'config', 'client-meta.json'),
42
+ ];
43
+ for (const candidatePath of candidateMetaPaths) {
44
+ const clientMetaVersion = readVersionFromJson(candidatePath, 'version');
45
+ if (clientMetaVersion) {
46
+ cachedCliPackageVersion = clientMetaVersion;
47
+ return clientMetaVersion;
48
+ }
49
+ }
20
50
  const packageJsonPath = path_1.default.resolve(__dirname, '..', 'package.json');
21
- const packageJson = JSON.parse((0, fs_1.readFileSync)(packageJsonPath, 'utf8'));
22
- const version = typeof packageJson.version === 'string' ? packageJson.version.trim() : '';
23
- if (!/^\d+\.\d+\.\d+$/.test(version)) {
24
- throw new Error(`invalid_cli_package_version:${version}`);
51
+ const version = readVersionFromJson(packageJsonPath, 'version');
52
+ if (!version) {
53
+ throw new Error(`invalid_cli_package_version:${packageJsonPath}`);
25
54
  }
26
55
  cachedCliPackageVersion = version;
27
56
  return version;
@@ -283,42 +283,6 @@ async function capture(targetArg, options = {}) {
283
283
  process.exitCode = 1;
284
284
  return;
285
285
  }
286
- let registeredApp = null;
287
- try {
288
- registeredApp = await (0, devShared_1.assertAppRegistered)(client, currentUsername, appName);
289
- }
290
- catch (error) {
291
- if (error instanceof http_1.CLIUnsupportedClientError) {
292
- return;
293
- }
294
- if (error instanceof types_1.UnsupportedClientError) {
295
- (0, http_1.handleUnsupportedError)(error, 'Capture');
296
- process.exitCode = 1;
297
- return;
298
- }
299
- if (error instanceof types_1.ApiError) {
300
- if (error.status === 404) {
301
- (0, messages_1.printErrorWithHelp)(`App ${currentUsername}/${appName} is not registered on ${env}.`, [
302
- `Run "playdrop project create app ${appName}" to register the app before running capture.`,
303
- 'If you expected it to exist, ensure you are logged into the correct environment.',
304
- ], { command: 'project capture' });
305
- }
306
- else {
307
- (0, messages_1.printErrorWithHelp)(`Failed to verify app registration (status ${error.status}).`, [
308
- 'Retry in a moment.',
309
- 'If the issue persists, contact the Playdrop team.',
310
- ], { command: 'project capture' });
311
- }
312
- process.exitCode = 1;
313
- return;
314
- }
315
- if ((0, devShared_1.isNetworkError)(error)) {
316
- (0, messages_1.printNetworkIssue)('Could not reach the Playdrop API to verify the app registration.', 'project capture');
317
- process.exitCode = 1;
318
- return;
319
- }
320
- throw error;
321
- }
322
286
  const entryLabel = (0, node_path_1.relative)(process.cwd(), filePath) || filePath;
323
287
  console.log(`[capture] Preparing ${entryLabel} for ${env} (${appTypeSlug}).`);
324
288
  const serverAlreadyRunning = await (0, devServer_1.isDevServerAvailable)({
@@ -442,14 +406,61 @@ async function capture(targetArg, options = {}) {
442
406
  await new Promise(resolve => setTimeout(resolve, 1000));
443
407
  }
444
408
  const webBase = envConfig.webBase ?? 'https://www.playdrop.ai';
445
- const frameUrl = (0, devAuth_1.applyHostedDevAuthSelectionToUrl)(`${webBase}/creators/${encodeURIComponent(currentUsername)}/apps/${appTypeSlug}/${encodeURIComponent(appName)}/dev`, devOptions.selection);
446
- console.log(`[capture] Launching Playwright against ${frameUrl}`);
447
409
  const shouldResetBefore = devOptions.selection.devAuth === 'player'
448
410
  && (devOptions.resetMode === 'before' || devOptions.resetMode === 'before-and-after');
449
411
  const shouldResetAfter = devOptions.selection.devAuth === 'player'
450
412
  && (devOptions.resetMode === 'after' || devOptions.resetMode === 'before-and-after');
413
+ const requiresRegisteredApp = devOptions.selection.devAuth !== 'anonymous' || shouldResetBefore || shouldResetAfter;
414
+ let registeredApp = null;
415
+ if (requiresRegisteredApp) {
416
+ try {
417
+ registeredApp = await (0, devShared_1.assertAppRegistered)(client, currentUsername, appName);
418
+ }
419
+ catch (error) {
420
+ if (error instanceof http_1.CLIUnsupportedClientError) {
421
+ return;
422
+ }
423
+ if (error instanceof types_1.UnsupportedClientError) {
424
+ (0, http_1.handleUnsupportedError)(error, 'Capture');
425
+ process.exitCode = 1;
426
+ return;
427
+ }
428
+ if (error instanceof types_1.ApiError) {
429
+ if (error.status === 404) {
430
+ (0, messages_1.printErrorWithHelp)(`App ${currentUsername}/${appName} is not registered on ${env}.`, [
431
+ `Run "playdrop project create app ${appName}" to register the app before using viewer or player capture.`,
432
+ 'Use anonymous capture for local launch validation before upload.',
433
+ ], { command: 'project capture' });
434
+ }
435
+ else {
436
+ (0, messages_1.printErrorWithHelp)(`Failed to verify app registration (status ${error.status}).`, [
437
+ 'Retry in a moment.',
438
+ 'If the issue persists, contact the Playdrop team.',
439
+ ], { command: 'project capture' });
440
+ }
441
+ process.exitCode = 1;
442
+ return;
443
+ }
444
+ if ((0, devShared_1.isNetworkError)(error)) {
445
+ (0, messages_1.printNetworkIssue)('Could not reach the Playdrop API to verify the app registration.', 'project capture');
446
+ process.exitCode = 1;
447
+ return;
448
+ }
449
+ throw error;
450
+ }
451
+ }
452
+ const captureBaseUrl = (0, appUrls_1.buildPlatformDevUrl)(webBase, {
453
+ creatorUsername: currentUsername,
454
+ appName,
455
+ appType: appTypeSlug,
456
+ devAuth: devOptions.selection.devAuth,
457
+ player: devOptions.selection.player ? String(devOptions.selection.player) : null,
458
+ launchCheck: devOptions.selection.devAuth === 'anonymous',
459
+ });
460
+ const frameUrl = (0, devAuth_1.applyHostedDevAuthSelectionToUrl)(captureBaseUrl, devOptions.selection);
461
+ console.log(`[capture] Launching Playwright against ${frameUrl}`);
451
462
  const selectedPlayerSlot = devOptions.selection.player;
452
- const registeredAppId = typeof registeredApp.id === 'number' ? registeredApp.id : null;
463
+ const registeredAppId = typeof registeredApp?.id === 'number' ? registeredApp.id : null;
453
464
  if ((shouldResetBefore || shouldResetAfter) && registeredAppId === null) {
454
465
  (0, messages_1.printErrorWithHelp)('The registered app is missing a numeric id, so test-player reset is unavailable.', [], { command: 'project capture' });
455
466
  process.exitCode = 1;
@@ -463,6 +474,7 @@ async function capture(targetArg, options = {}) {
463
474
  targetUrl: frameUrl,
464
475
  expectedUrl: frameUrl,
465
476
  timeoutMs,
477
+ settleAfterReadyMs: timeoutMs,
466
478
  minimumLogLevel,
467
479
  screenshotPath,
468
480
  contextOptions: surfaceContextOptions,
@@ -472,6 +484,7 @@ async function capture(targetArg, options = {}) {
472
484
  } : undefined,
473
485
  savedSessionBootstrap: false,
474
486
  enableCaptureBridge: true,
487
+ requireHostedLaunchReady: true,
475
488
  });
476
489
  if (result.errorCount > 0) {
477
490
  console.error(`[capture] Completed with ${result.errorCount} error(s) after ${timeoutSeconds} seconds.`);
@@ -396,15 +396,23 @@ async function startDevServer(options) {
396
396
  };
397
397
  }
398
398
  async function isDevServerAvailable(input, timeoutMs = 1000) {
399
+ const port = input.port ?? exports.DEV_ROUTER_PORT;
400
+ const health = await fetchRouterHealth(port, Math.min(timeoutMs, 400));
401
+ if (!health || !isCompatibleRouterHealth(health)) {
402
+ return false;
403
+ }
399
404
  const controller = new AbortController();
400
405
  const timeout = setTimeout(() => controller.abort(), timeoutMs);
401
406
  timeout.unref?.();
402
407
  try {
403
- await fetch(buildLocalDevAppUrl(input), {
408
+ const response = await fetch(buildLocalDevAppUrl({
409
+ ...input,
410
+ port,
411
+ }), {
404
412
  method: 'GET',
405
413
  signal: controller.signal,
406
414
  });
407
- return true;
415
+ return response.ok;
408
416
  }
409
417
  catch {
410
418
  return false;
@@ -150,18 +150,19 @@ async function fetchCurrentUserInfo(client, apiBase) {
150
150
  return {
151
151
  username: data?.user?.username ?? null,
152
152
  role: data?.user?.role ?? null,
153
+ user: data?.user ?? null,
153
154
  };
154
155
  }
155
156
  catch (error) {
156
157
  if (error instanceof types_1.UnsupportedClientError) {
157
158
  (0, http_1.handleUnsupportedError)(error, 'Upload');
158
- return { username: null, role: null };
159
+ return { username: null, role: null, user: null };
159
160
  }
160
161
  if (error instanceof http_1.CLIUnsupportedClientError) {
161
162
  throw error;
162
163
  }
163
164
  if (error instanceof types_1.ApiError) {
164
- return { username: null, role: null };
165
+ return { username: null, role: null, user: null };
165
166
  }
166
167
  if (isConnectionRefusedError(error)) {
167
168
  throw new Error(buildApiUnavailableMessage(apiBase));
@@ -282,6 +283,13 @@ async function uploadAppTask(state, task, taskCreator, options) {
282
283
  skipReview: options?.skipReview,
283
284
  clearTags: options?.clearTags,
284
285
  creatorUsername: taskCreator,
286
+ apiBase: state.apiBase,
287
+ webBase: state.portalBase,
288
+ token: state.token,
289
+ user: state.currentUser,
290
+ runLocalLaunchCheck: true,
291
+ runStagedUploadLaunchCheck: true,
292
+ ensureRegisteredAppShell: true,
285
293
  });
286
294
  if (!upload.versionCreated || !upload.version) {
287
295
  throw new Error(`App "${task.name}" upload did not return a created version.`);
@@ -476,7 +484,7 @@ async function flushGraphState(client, graphState, results) {
476
484
  process.exitCode = process.exitCode || 1;
477
485
  }
478
486
  }
479
- async function processUploadTasks(client, tasks, owner, ownerUsername, currentUserRole, warnings, webBase, options) {
487
+ async function processUploadTasks(client, tasks, owner, ownerUsername, currentUserRole, currentUser, token, warnings, apiBase, webBase, options) {
480
488
  const portalBase = normalizePortalBase(webBase);
481
489
  const defaultCreator = normalizeCreatorUsername(ownerUsername) ?? owner;
482
490
  const results = [];
@@ -494,6 +502,9 @@ async function processUploadTasks(client, tasks, owner, ownerUsername, currentUs
494
502
  sortedTasks,
495
503
  defaultCreator,
496
504
  currentUserRole,
505
+ currentUser,
506
+ token,
507
+ apiBase,
497
508
  portalBase,
498
509
  uploadedAppsByName: new Map(),
499
510
  uploadedAssetsByKey: new Map(),
@@ -539,7 +550,7 @@ async function upload(pathOrName, options) {
539
550
  return;
540
551
  }
541
552
  const { client, env, envConfig } = ctx;
542
- let userInfo = { username: null, role: null };
553
+ let userInfo = { username: null, role: null, user: null };
543
554
  try {
544
555
  userInfo = await fetchCurrentUserInfo(client, envConfig.apiBase);
545
556
  }
@@ -589,7 +600,7 @@ async function upload(pathOrName, options) {
589
600
  taxonomyEntries.forEach((entry) => pushLoggedEntry(results, entry));
590
601
  }
591
602
  if (tasks.length > 0) {
592
- const uploadEntries = await processUploadTasks(client, tasks, owner, userInfo.username, userInfo.role, warnings, envConfig.webBase, options);
603
+ const uploadEntries = await processUploadTasks(client, tasks, owner, userInfo.username, userInfo.role, userInfo.user, ctx.token, warnings, envConfig.apiBase, envConfig.webBase, options);
593
604
  results.push(...uploadEntries);
594
605
  }
595
606
  (0, uploadLog_1.printTaskSummary)(results, warnings, { action: 'upload', environment: env });