@notis_ai/cli 0.2.5 → 0.2.6

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.
Files changed (106) hide show
  1. package/README.md +31 -9
  2. package/dist/scaffolds/notis-database/CHANGELOG.md +5 -0
  3. package/dist/scaffolds/notis-database/notis.config.ts +0 -1
  4. package/dist/scaffolds/notis-database/src/mock-runtime.ts +1 -0
  5. package/dist/scaffolds/notis-journal/CHANGELOG.md +25 -0
  6. package/dist/scaffolds/notis-journal/app/globals.css +37 -0
  7. package/dist/scaffolds/notis-journal/app/insights/page.tsx +513 -0
  8. package/dist/scaffolds/notis-journal/app/journal-core.tsx +362 -0
  9. package/dist/scaffolds/notis-journal/app/journal-ui.tsx +337 -0
  10. package/dist/scaffolds/notis-journal/app/layout.tsx +6 -0
  11. package/dist/scaffolds/notis-journal/app/page.tsx +485 -0
  12. package/dist/scaffolds/notis-journal/components/ui/badge.tsx +28 -0
  13. package/dist/scaffolds/notis-journal/components/ui/button.tsx +53 -0
  14. package/dist/scaffolds/notis-journal/components/ui/card.tsx +56 -0
  15. package/dist/scaffolds/notis-journal/components.json +20 -0
  16. package/dist/scaffolds/notis-journal/index.html +12 -0
  17. package/dist/scaffolds/notis-journal/lib/utils.ts +6 -0
  18. package/dist/scaffolds/notis-journal/metadata/screenshot-1.png +0 -0
  19. package/dist/scaffolds/notis-journal/metadata/screenshot-2.png +0 -0
  20. package/dist/scaffolds/notis-journal/metadata/screenshot-3.png +0 -0
  21. package/dist/scaffolds/notis-journal/metadata/screenshot-4.png +0 -0
  22. package/dist/scaffolds/notis-journal/metadata/screenshot-5.png +0 -0
  23. package/dist/scaffolds/notis-journal/metadata/screenshot-6.png +0 -0
  24. package/dist/scaffolds/notis-journal/metadata/screenshot-fixtures.json +132 -0
  25. package/dist/scaffolds/notis-journal/notis.config.ts +93 -0
  26. package/dist/scaffolds/notis-journal/package.json +34 -0
  27. package/dist/scaffolds/notis-journal/packages/sdk/package.json +36 -0
  28. package/dist/scaffolds/notis-journal/packages/sdk/src/components/DocumentEditor.tsx +93 -0
  29. package/dist/scaffolds/notis-journal/packages/sdk/src/components/Markdown.tsx +60 -0
  30. package/dist/scaffolds/notis-journal/packages/sdk/src/components/MultiSelectActionBar.tsx +278 -0
  31. package/dist/scaffolds/notis-journal/packages/sdk/src/components/MultiSelectCheckbox.tsx +91 -0
  32. package/dist/scaffolds/notis-journal/packages/sdk/src/components/MultiSelectDragOverlay.tsx +39 -0
  33. package/dist/scaffolds/notis-journal/packages/sdk/src/config.ts +145 -0
  34. package/dist/scaffolds/notis-journal/packages/sdk/src/documents.ts +229 -0
  35. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useBackend.ts +41 -0
  36. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useDatabaseSchema.ts +85 -0
  37. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useDocument.ts +78 -0
  38. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useDocuments.ts +121 -0
  39. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useMultiSelect.ts +539 -0
  40. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useNotis.ts +34 -0
  41. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useNotisNavigation.ts +49 -0
  42. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useTool.ts +64 -0
  43. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useTools.ts +56 -0
  44. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useTopBarSearch.ts +73 -0
  45. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useUpsertDocument.ts +95 -0
  46. package/dist/scaffolds/notis-journal/packages/sdk/src/index.ts +83 -0
  47. package/dist/scaffolds/notis-journal/packages/sdk/src/provider.tsx +43 -0
  48. package/dist/scaffolds/notis-journal/packages/sdk/src/runtime.ts +220 -0
  49. package/dist/scaffolds/notis-journal/packages/sdk/src/styles.css +186 -0
  50. package/dist/scaffolds/notis-journal/packages/sdk/src/ui.ts +15 -0
  51. package/dist/scaffolds/notis-journal/packages/sdk/src/vite.ts +56 -0
  52. package/dist/scaffolds/notis-journal/packages/sdk/tsconfig.json +15 -0
  53. package/dist/scaffolds/notis-journal/postcss.config.mjs +8 -0
  54. package/dist/scaffolds/notis-journal/skills/journal-onboarding/SKILL.md +120 -0
  55. package/dist/scaffolds/notis-journal/src/dev-main.tsx +58 -0
  56. package/dist/scaffolds/notis-journal/src/mock-runtime.ts +197 -0
  57. package/dist/scaffolds/notis-journal/tailwind.config.ts +58 -0
  58. package/dist/scaffolds/notis-journal/tsconfig.json +23 -0
  59. package/dist/scaffolds/notis-journal/vite.config.ts +10 -0
  60. package/dist/scaffolds/notis-notes/CHANGELOG.md +5 -0
  61. package/dist/scaffolds/notis-notes/app/page.tsx +17 -373
  62. package/dist/scaffolds/notis-notes/notis.config.ts +0 -1
  63. package/dist/scaffolds/notis-random/CHANGELOG.md +5 -0
  64. package/dist/scaffolds/notis-random/notis.config.ts +0 -1
  65. package/dist/scaffolds/notis-random/src/mock-runtime.ts +1 -0
  66. package/dist/scaffolds.json +11 -0
  67. package/package.json +3 -3
  68. package/skills/notis-apps/SKILL.md +43 -5
  69. package/skills/notis-apps/cli.md +22 -6
  70. package/skills/notis-cli/SKILL.md +20 -2
  71. package/skills/notis-query/cli.md +1 -1
  72. package/src/command-specs/apps.js +307 -36
  73. package/src/command-specs/index.js +1 -1
  74. package/src/command-specs/meta.js +8 -2
  75. package/src/command-specs/tools.js +50 -37
  76. package/src/runtime/agent-browser.js +204 -21
  77. package/src/runtime/app-changelog.js +79 -0
  78. package/src/runtime/app-dev-server.js +21 -4
  79. package/src/runtime/app-dev-sessions.js +99 -1
  80. package/src/runtime/app-platform.js +197 -18
  81. package/src/runtime/assets/store-screenshot-dark.png +0 -0
  82. package/src/runtime/desktop-auth.js +93 -0
  83. package/src/runtime/profiles.js +37 -15
  84. package/src/runtime/store-screenshot.js +138 -0
  85. package/src/runtime/transport.js +21 -82
  86. package/template/.harness/index.html.tmpl +128 -6
  87. package/template/CHANGELOG.md +5 -0
  88. package/template/app/page.tsx +11 -41
  89. package/template/notis.config.ts +0 -1
  90. package/template/package-lock.json +4137 -0
  91. package/template/package.json +1 -0
  92. package/template/packages/sdk/package.json +4 -0
  93. package/template/packages/sdk/src/components/DocumentEditor.tsx +93 -0
  94. package/template/packages/sdk/src/components/Markdown.tsx +60 -0
  95. package/template/packages/sdk/src/components/MultiSelectActionBar.tsx +7 -2
  96. package/template/packages/sdk/src/config.ts +74 -0
  97. package/template/packages/sdk/src/documents.ts +229 -0
  98. package/template/packages/sdk/src/hooks/useDatabaseSchema.ts +85 -0
  99. package/template/packages/sdk/src/hooks/useDocument.ts +78 -0
  100. package/template/packages/sdk/src/hooks/useDocuments.ts +121 -0
  101. package/template/packages/sdk/src/hooks/useMultiSelect.ts +38 -2
  102. package/template/packages/sdk/src/hooks/useUpsertDocument.ts +54 -9
  103. package/template/packages/sdk/src/index.ts +30 -1
  104. package/template/packages/sdk/src/runtime.ts +76 -17
  105. package/template/packages/sdk/src/styles.css +148 -0
  106. package/template/packages/sdk/src/hooks/useDatabase.ts +0 -76
@@ -342,43 +342,48 @@ async function toolsExecParallelHandler(ctx) {
342
342
  }
343
343
 
344
344
  async function toolsLinkHandler(ctx) {
345
- const apiBase = ctx.runtime.apiBase.replace(/\/+$/, '');
346
- let portalBase;
347
- try {
348
- const parsed = new URL(apiBase);
349
- if (parsed.hostname.startsWith('api.')) {
350
- parsed.hostname = parsed.hostname.replace(/^api\./, 'app.');
351
- portalBase = parsed.origin;
352
- } else if (parsed.hostname === 'localhost' || parsed.hostname === '127.0.0.1') {
353
- const apiPort = parseInt(parsed.port, 10) || 80;
354
- parsed.port = String(apiPort === 3001 ? 3000 : apiPort);
355
- portalBase = parsed.origin;
356
- } else {
357
- portalBase = apiBase;
358
- }
359
- } catch {
360
- portalBase = apiBase.replace('api.', 'app.');
345
+ const credentials = ctx.options.credentials
346
+ ? await resolveJsonInput(ctx.options.credentials, 'credentials')
347
+ : undefined;
348
+ if (credentials !== undefined && (typeof credentials !== 'object' || credentials === null || Array.isArray(credentials))) {
349
+ throw usageError('credentials must be a JSON object');
350
+ }
351
+
352
+ const arguments_ = {
353
+ toolkit: ctx.args.toolkit,
354
+ ...(ctx.options.reconnect ? { reconnect: true } : {}),
355
+ ...(ctx.options.connectionId ? { reconnect_connection_id: ctx.options.connectionId } : {}),
356
+ ...(ctx.options.label ? { label: ctx.options.label } : {}),
357
+ ...(credentials ? { credentials } : {}),
358
+ };
359
+ const idempotencyKey = nextIdempotencyKey(ctx.globalOptions);
360
+ const result = await runToolCommand({
361
+ runtime: ctx.runtime,
362
+ toolName: 'LOCAL_NOTIS_AUTHENTIFY',
363
+ arguments_,
364
+ mutating: true,
365
+ idempotencyKey,
366
+ });
367
+ const payload = result.payload || {};
368
+ if (payload.status === 'error') {
369
+ throw usageError(payload.message || `Failed to connect ${ctx.args.toolkit}`, payload);
361
370
  }
362
- const portalUrl = `${portalBase}/integrations?connect=${encodeURIComponent(ctx.args.toolkit)}`;
371
+ const authUrl = payload.redirect_url || payload.integrations_url || payload.url || null;
363
372
 
364
373
  return ctx.output.emitSuccess({
365
374
  command: ctx.spec.command_path.join(' '),
366
375
  data: {
367
- toolkit: ctx.args.toolkit,
368
- auth_url: portalUrl,
376
+ ...payload,
377
+ auth_url: authUrl,
369
378
  },
370
- humanSummary: `Open this URL to connect ${ctx.args.toolkit}`,
379
+ humanSummary: ctx.options.reconnect
380
+ ? `Reconnected ${ctx.args.toolkit}`
381
+ : `Started connection for ${ctx.args.toolkit}`,
371
382
  hints: [
372
383
  { command: 'notis tools toolkits', reason: 'Verify the toolkit is connected after setup' },
373
384
  ],
374
- renderHuman: () =>
375
- [
376
- `Connect ${ctx.args.toolkit}:`,
377
- ` ${portalUrl}`,
378
- '',
379
- 'After connecting, verify with:',
380
- ' notis tools toolkits',
381
- ].join('\n'),
385
+ meta: { mutating: true, idempotency_key: idempotencyKey },
386
+ renderHuman: () => JSON.stringify({ ...payload, auth_url: authUrl }, null, 2),
382
387
  });
383
388
  }
384
389
 
@@ -481,19 +486,27 @@ export const toolsCommandSpecs = [
481
486
  },
482
487
  {
483
488
  command_path: ['tools', 'link'],
484
- summary: 'Get the URL to connect an integration toolkit.',
485
- when_to_use: 'Use this when a tool requires authentication with an external service.',
489
+ summary: 'Connect or reconnect an integration toolkit.',
490
+ when_to_use: 'Use this when a tool requires authentication or an active connection must be replaced.',
486
491
  args_schema: {
487
492
  arguments: [{ token: '<toolkit>', description: 'Toolkit name to connect (e.g. github, gmail, slack).' }],
488
- options: [],
493
+ options: [
494
+ { flags: '--reconnect', description: 'Replace the existing account instead of adding another connection.' },
495
+ { flags: '--connection-id <id>', key: 'connectionId', description: 'Exact connection id to replace when multiple accounts exist.' },
496
+ { flags: '--label <label>', description: 'Account label for a new or replacement connection.' },
497
+ { flags: '--credentials <json>', description: 'Credential JSON object, @file path, or - for stdin. Prefer stdin so secrets do not enter shell history.' },
498
+ ],
489
499
  },
490
- examples: ['notis tools link github', 'notis tools link gmail --json'],
491
- output_schema: 'Returns the authentication URL for the specified toolkit.',
492
- mutates: false,
493
- idempotent: true,
494
- require_auth: false,
500
+ examples: [
501
+ 'notis tools link github',
502
+ 'notis tools link dataforseo --reconnect --credentials - < credentials.json',
503
+ ],
504
+ output_schema: 'Returns the connection result and an authentication URL when provider authorization is still required.',
505
+ mutates: true,
506
+ idempotent: false,
507
+ require_auth: true,
495
508
  related_commands: ['notis tools toolkits', 'notis tools search <query>'],
496
- backend_call: { type: 'local' },
509
+ backend_call: { type: 'tool', name: 'LOCAL_NOTIS_AUTHENTIFY' },
497
510
  handler: toolsLinkHandler,
498
511
  },
499
512
  ];
@@ -1,7 +1,118 @@
1
1
  import { spawn, spawnSync } from 'node:child_process';
2
- import { mkdirSync, writeFileSync } from 'node:fs';
2
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
3
3
  import { dirname } from 'node:path';
4
4
 
5
+ export const HIDE_HARNESS_STATUS_SCRIPT =
6
+ "(() => { const el = document.getElementById('harness-status'); if (el) el.style.opacity = '0'; return true; })()";
7
+
8
+ /**
9
+ * Prepare the mounted harness page for a listing capture: remove the debug
10
+ * banner and the layout the harness adds around the app (top padding for the
11
+ * banner, 100vh min-height) so content height can be measured and framed
12
+ * without artificial whitespace.
13
+ */
14
+ const PREPARE_CAPTURE_SCRIPT =
15
+ "(() => { const s = document.getElementById('harness-status'); if (s) s.style.display = 'none';" +
16
+ " const r = document.getElementById('root'); if (r) { r.style.paddingTop = '0'; r.style.minHeight = '0'; }" +
17
+ " document.body.style.minHeight = '0'; document.documentElement.style.minHeight = '0'; return true; })()";
18
+
19
+ const FONTS_STATUS_SCRIPT =
20
+ "(() => (document.fonts ? document.fonts.status : 'loaded'))()";
21
+
22
+ const CONTENT_HEIGHT_SCRIPT =
23
+ '(() => { const r = document.getElementById(\'root\');' +
24
+ ' if (!r) return Math.ceil(document.documentElement.scrollHeight);' +
25
+ ' return Math.ceil(Math.max(r.scrollHeight, r.getBoundingClientRect().height)); })()';
26
+
27
+ // CSS viewport bounds for content-aware framing. Content shorter than the
28
+ // minimum gets a little breathing room; content taller than the maximum is
29
+ // cropped at the frame rather than shrunk into unreadably small text.
30
+ const MIN_CSS_VIEWPORT_HEIGHT = 720;
31
+ const MAX_CSS_VIEWPORT_HEIGHT = 1500;
32
+
33
+ function pngDimensions(path) {
34
+ try {
35
+ const buffer = readFileSync(path);
36
+ if (buffer.length < 24 || buffer.readUInt32BE(12) !== 0x49484452) {
37
+ return null;
38
+ }
39
+ return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) };
40
+ } catch {
41
+ return null;
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Pick a CSS viewport that frames `contentHeight` at the output aspect ratio,
47
+ * with a deviceScaleFactor that maps it back to exactly outputWidth x
48
+ * outputHeight physical pixels. Width snaps to a multiple of 8 so the height
49
+ * stays integral for the common 16:10 target.
50
+ */
51
+ function fitCssViewport(contentHeight, outputWidth, outputHeight) {
52
+ const aspect = outputWidth / outputHeight;
53
+ const cssH = Math.min(
54
+ MAX_CSS_VIEWPORT_HEIGHT,
55
+ Math.max(MIN_CSS_VIEWPORT_HEIGHT, Math.round(contentHeight)),
56
+ );
57
+ const cssW = Math.round((cssH * aspect) / 8) * 8;
58
+ return {
59
+ width: cssW,
60
+ height: Math.round(cssW / aspect),
61
+ scale: outputWidth / cssW,
62
+ };
63
+ }
64
+
65
+ async function evalNumber(sessionName, script, fallback = null) {
66
+ const result = await runAgentBrowser(
67
+ ['--session', sessionName, '--json', 'eval', script],
68
+ { timeoutMs: 5000 },
69
+ );
70
+ if (result.exitCode !== 0) {
71
+ return fallback;
72
+ }
73
+ try {
74
+ const payload = parseAgentBrowserJson(result.stdout);
75
+ const raw = payload?.data?.result ?? payload?.result ?? payload?.data ?? null;
76
+ const value = typeof raw === 'string' ? JSON.parse(raw) : raw;
77
+ return Number.isFinite(value) ? value : fallback;
78
+ } catch {
79
+ return fallback;
80
+ }
81
+ }
82
+
83
+ async function waitForFonts(sessionName, timeoutMs = 3000) {
84
+ const deadline = Date.now() + timeoutMs;
85
+ while (Date.now() < deadline) {
86
+ const result = await runAgentBrowser(
87
+ ['--session', sessionName, '--json', 'eval', FONTS_STATUS_SCRIPT],
88
+ { timeoutMs: 5000 },
89
+ );
90
+ if (result.exitCode !== 0) {
91
+ return;
92
+ }
93
+ try {
94
+ const payload = parseAgentBrowserJson(result.stdout);
95
+ const raw = payload?.data?.result ?? payload?.result ?? payload?.data ?? null;
96
+ const status = typeof raw === 'string' && raw.startsWith('"') ? JSON.parse(raw) : raw;
97
+ if (status !== 'loading') {
98
+ return;
99
+ }
100
+ } catch {
101
+ return;
102
+ }
103
+ await delay(150);
104
+ }
105
+ }
106
+
107
+ async function setViewport(sessionName, width, height, scale = null) {
108
+ const args = ['--session', sessionName, 'set', 'viewport', String(width), String(height)];
109
+ if (scale != null) {
110
+ args.push(String(scale));
111
+ }
112
+ const result = await runAgentBrowser(args, { timeoutMs: 5000 });
113
+ return result.exitCode === 0;
114
+ }
115
+
5
116
  function delay(ms) {
6
117
  return new Promise((resolvePromise) => setTimeout(resolvePromise, ms));
7
118
  }
@@ -186,13 +297,18 @@ export async function runHarnessRoute({
186
297
 
187
298
  /**
188
299
  * Open a harness route, wait for it to mount, then capture a PNG screenshot of
189
- * the rendered app at a fixed viewport (default 2000x1250, the 16:10 the
190
- * listing validator expects). Used by `notis apps screenshot`.
300
+ * the rendered app. Output is always exactly `width` x `height` physical
301
+ * pixels (default 2000x1250, the 16:10 the listing validator expects), but the
302
+ * CSS viewport is fitted to the rendered content height: short pages render
303
+ * larger (no dead whitespace below the app), tall pages get more room before
304
+ * cropping. The deviceScaleFactor maps the fitted CSS viewport back onto the
305
+ * fixed output frame. Used by `notis apps screenshot`.
191
306
  */
192
307
  export async function captureHarnessScreenshot({
193
308
  url,
194
309
  sessionName,
195
310
  screenshotPath,
311
+ focusSelector = null,
196
312
  width = 2000,
197
313
  height = 1250,
198
314
  timeoutMs = 15_000,
@@ -204,12 +320,20 @@ export async function captureHarnessScreenshot({
204
320
  return { ok: false, mounted: false, screenshotPath: null, tool_error: commandError('open', opened) };
205
321
  }
206
322
 
207
- // Fixed viewport so every screenshot is the same frame, regardless of the
208
- // operator's screen. React re-layouts on the resize before we capture.
209
- await runAgentBrowser(
210
- ['--session', sessionName, 'set', 'viewport', String(width), String(height)],
211
- { timeoutMs: 5000 },
323
+ // Initial fixed frame so mount/layout happen at a deterministic size; the
324
+ // scale keeps physical output at width x height from the very first paint.
325
+ const initial = fitCssViewport(900, width, height);
326
+ let scaledViewport = await setViewport(
327
+ sessionName,
328
+ initial.width,
329
+ initial.height,
330
+ initial.scale,
212
331
  );
332
+ if (!scaledViewport) {
333
+ // Older agent-browser without deviceScaleFactor support: fall back to a
334
+ // plain fixed viewport at output size.
335
+ await setViewport(sessionName, width, height);
336
+ }
213
337
 
214
338
  const deadline = Date.now() + timeoutMs;
215
339
  let lastErrors = [];
@@ -247,30 +371,89 @@ export async function captureHarnessScreenshot({
247
371
  };
248
372
  }
249
373
 
250
- // Hide the harness debug banner so it never appears in the listing image.
374
+ // Strip harness chrome (debug banner, banner padding, 100vh min-height) so
375
+ // the frame contains only the app, then wait for webfonts before measuring.
251
376
  await runAgentBrowser(
252
- [
253
- '--session',
254
- sessionName,
255
- 'eval',
256
- "(() => { const el = document.getElementById('harness-status'); if (el) el.style.display = 'none'; return true; })()",
257
- ],
377
+ ['--session', sessionName, 'eval', PREPARE_CAPTURE_SCRIPT],
258
378
  { timeoutMs: 5000 },
259
379
  );
380
+ await waitForFonts(sessionName);
381
+
382
+ // Fit the CSS viewport to the rendered content. Content height depends on
383
+ // viewport width, so refine once after the first resize reflows the page.
384
+ let framing = null;
385
+ if (scaledViewport) {
386
+ let contentHeight = await evalNumber(sessionName, CONTENT_HEIGHT_SCRIPT);
387
+ for (let pass = 0; contentHeight != null && pass < 2; pass += 1) {
388
+ const fitted = fitCssViewport(contentHeight, width, height);
389
+ if (framing && fitted.width === framing.width) {
390
+ break;
391
+ }
392
+ scaledViewport = await setViewport(sessionName, fitted.width, fitted.height, fitted.scale);
393
+ if (!scaledViewport) {
394
+ framing = null;
395
+ await setViewport(sessionName, width, height);
396
+ break;
397
+ }
398
+ framing = { ...fitted, content_height: contentHeight };
399
+ await delay(150);
400
+ const remeasured = await evalNumber(sessionName, CONTENT_HEIGHT_SCRIPT);
401
+ if (remeasured == null || Math.abs(remeasured - contentHeight) <= 32) {
402
+ framing.content_height = remeasured ?? contentHeight;
403
+ break;
404
+ }
405
+ contentHeight = remeasured;
406
+ }
407
+ }
260
408
 
261
- // Let the route settle (fonts, async data) before the capture.
409
+ // Let the route settle (async data, resize transitions) before the capture.
262
410
  await delay(500);
263
411
 
264
412
  mkdirSync(dirname(screenshotPath), { recursive: true });
265
- const shot = await runAgentBrowser(
266
- ['--session', sessionName, 'screenshot', screenshotPath, '--screenshot-format', 'png'],
267
- { timeoutMs: 15_000 },
268
- );
413
+ const screenshotArgs = ['--session', sessionName, 'screenshot'];
414
+ if (focusSelector) {
415
+ screenshotArgs.push(focusSelector);
416
+ }
417
+ screenshotArgs.push(screenshotPath, '--screenshot-format', 'png');
418
+ const shot = await runAgentBrowser(screenshotArgs, { timeoutMs: 15_000 });
269
419
  if (shot.exitCode !== 0) {
270
420
  return { ok: false, mounted: true, screenshotPath: null, errors: lastErrors, timed_out: false, tool_error: commandError('screenshot', shot) };
271
421
  }
272
422
 
273
- return { ok: true, mounted: true, screenshotPath, errors: lastErrors, timed_out: false, tool_error: null };
423
+ // The listing validator requires exact output dimensions. If the fitted
424
+ // capture came out wrong (e.g. the browser ignored the scale factor),
425
+ // recapture once at a plain fixed viewport.
426
+ const dimensions = pngDimensions(screenshotPath);
427
+ if (!focusSelector && framing && (!dimensions || dimensions.width !== width || dimensions.height !== height)) {
428
+ await setViewport(sessionName, width, height);
429
+ await delay(300);
430
+ const retry = await runAgentBrowser(
431
+ ['--session', sessionName, 'screenshot', screenshotPath, '--screenshot-format', 'png'],
432
+ { timeoutMs: 15_000 },
433
+ );
434
+ if (retry.exitCode !== 0) {
435
+ return { ok: false, mounted: true, screenshotPath: null, errors: lastErrors, timed_out: false, tool_error: commandError('screenshot', retry) };
436
+ }
437
+ framing = null;
438
+ }
439
+
440
+ if (focusSelector) {
441
+ framing = {
442
+ ...(framing || {}),
443
+ focus_selector: focusSelector,
444
+ source_dimensions: dimensions,
445
+ };
446
+ }
447
+
448
+ return {
449
+ ok: true,
450
+ mounted: true,
451
+ screenshotPath,
452
+ errors: lastErrors,
453
+ timed_out: false,
454
+ tool_error: null,
455
+ framing,
456
+ };
274
457
  }
275
458
 
276
459
  export async function closeAgentBrowserSession(sessionName) {
@@ -0,0 +1,79 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ export const APP_CHANGELOG_FILE = 'CHANGELOG.md';
5
+ export const CHANGELOG_MERGE_DATE = '{PR_MERGE_DATE}';
6
+
7
+ const ENTRY_HEADING = /^##\s+\[([^\]]+)]\s+-\s+(\{PR_MERGE_DATE\}|\d{4}-\d{2}-\d{2})\s*$/;
8
+
9
+ function validCalendarDate(value) {
10
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
11
+ const date = new Date(`${value}T00:00:00Z`);
12
+ return !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === value;
13
+ }
14
+
15
+ export function parseAppChangelog(markdown, { sourcePath = APP_CHANGELOG_FILE } = {}) {
16
+ const lines = String(markdown || '').replace(/\r\n?/g, '\n').split('\n');
17
+ const entries = [];
18
+ const errors = [];
19
+ let current = null;
20
+
21
+ function finishCurrent() {
22
+ if (!current) return;
23
+ entries.push({
24
+ title: current.title,
25
+ date: current.date,
26
+ body: current.body.join('\n').trim(),
27
+ });
28
+ }
29
+
30
+ for (const [index, line] of lines.entries()) {
31
+ // Only a level-2 heading (`## ...`, not `###`/`####`) can start a release
32
+ // entry; deeper sub-section headings are legitimate release-body content.
33
+ if (!/^##(?!#)/.test(line)) {
34
+ if (current) current.body.push(line);
35
+ continue;
36
+ }
37
+
38
+ const match = line.match(ENTRY_HEADING);
39
+ if (!match) {
40
+ errors.push(
41
+ `${sourcePath}:${index + 1} must use \`## [Release title] - YYYY-MM-DD\` or \`## [Release title] - {PR_MERGE_DATE}\`.`,
42
+ );
43
+ continue;
44
+ }
45
+
46
+ finishCurrent();
47
+ const title = match[1].trim();
48
+ const date = match[2];
49
+ if (!title) {
50
+ errors.push(`${sourcePath}:${index + 1} must include a release title inside square brackets.`);
51
+ }
52
+ if (date !== CHANGELOG_MERGE_DATE && !validCalendarDate(date)) {
53
+ errors.push(`${sourcePath}:${index + 1} contains an invalid calendar date.`);
54
+ }
55
+ current = { title, date, body: [] };
56
+ }
57
+ finishCurrent();
58
+
59
+ if (entries.length === 0 && errors.length === 0) {
60
+ errors.push(
61
+ `${sourcePath} must include at least one \`## [Release title] - YYYY-MM-DD\` entry.`,
62
+ );
63
+ }
64
+
65
+ return { source_path: sourcePath, entries, errors, exists: true };
66
+ }
67
+
68
+ export function readAppChangelog(projectDir) {
69
+ const path = join(projectDir, APP_CHANGELOG_FILE);
70
+ if (!existsSync(path)) {
71
+ return {
72
+ source_path: APP_CHANGELOG_FILE,
73
+ entries: [],
74
+ errors: [`${APP_CHANGELOG_FILE} is required for Store publication.`],
75
+ exists: false,
76
+ };
77
+ }
78
+ return parseAppChangelog(readFileSync(path, 'utf-8'));
79
+ }
@@ -216,7 +216,7 @@ function findHarnessRoute(manifest, routeSlug) {
216
216
  return routes.find((route) => route?.slug === routeSlug) || null;
217
217
  }
218
218
 
219
- function buildHarnessDescriptor({ state, manifest, appConfig, route }) {
219
+ function buildHarnessDescriptor({ state, manifest, appConfig, route, scenario = null }) {
220
220
  const databases = normalizeDatabaseDescriptors(
221
221
  Array.isArray(appConfig?.databases) && appConfig.databases.length
222
222
  ? appConfig.databases
@@ -246,14 +246,28 @@ function buildHarnessDescriptor({ state, manifest, appConfig, route }) {
246
246
  collection: route.collection || null,
247
247
  },
248
248
  databases,
249
- context: { collectionItem: null },
249
+ context: { collectionItem: null, screenshotScenario: scenario },
250
250
  tools,
251
251
  };
252
252
  }
253
253
 
254
- function renderHarnessHtml({ state, manifest, appConfig, route, harnessOptions }) {
254
+ function harnessFixtures(projectDir, scenario) {
255
+ const fixtureConfig = readJsonFile(join(projectDir, 'metadata', 'screenshot-fixtures.json')) || {};
256
+ const scenarios = fixtureConfig.scenarios && typeof fixtureConfig.scenarios === 'object'
257
+ ? fixtureConfig.scenarios
258
+ : {};
259
+ return {
260
+ tools: fixtureConfig.tools && typeof fixtureConfig.tools === 'object' ? fixtureConfig.tools : {},
261
+ requests: fixtureConfig.requests && typeof fixtureConfig.requests === 'object' ? fixtureConfig.requests : {},
262
+ scenario: scenario && scenarios[scenario] && typeof scenarios[scenario] === 'object'
263
+ ? scenarios[scenario]
264
+ : null,
265
+ };
266
+ }
267
+
268
+ function renderHarnessHtml({ state, manifest, appConfig, route, harnessOptions, scenario = null }) {
255
269
  const template = readFileSync(HARNESS_TEMPLATE_PATH, 'utf-8');
256
- const descriptor = buildHarnessDescriptor({ state, manifest, appConfig, route });
270
+ const descriptor = buildHarnessDescriptor({ state, manifest, appConfig, route, scenario });
257
271
  const routeExport = route.export_name || route.exportName || exportNameFromPath(route.path || '/');
258
272
  const replacements = {
259
273
  '{{REACT_VERSION}}': resolveHarnessReactVersion(state.projectDir),
@@ -262,6 +276,7 @@ function renderHarnessHtml({ state, manifest, appConfig, route, harnessOptions }
262
276
  '{{MODE}}': scriptJson(harnessOptions.mode || 'stub'),
263
277
  '{{API_BASE}}': scriptJson(harnessOptions.apiBase || null),
264
278
  '{{JWT}}': scriptJson(harnessOptions.jwt || null),
279
+ '{{FIXTURES}}': scriptJson(harnessFixtures(state.projectDir, scenario)),
265
280
  };
266
281
  let html = template;
267
282
  for (const [token, value] of Object.entries(replacements)) {
@@ -370,6 +385,7 @@ export async function startAppDevServer({
370
385
  }
371
386
 
372
387
  const routeSlug = url.searchParams.get('route') || '';
388
+ const scenario = url.searchParams.get('scenario') || null;
373
389
  const route = findHarnessRoute(manifest, routeSlug);
374
390
  if (!route) {
375
391
  res.writeHead(404, {
@@ -386,6 +402,7 @@ export async function startAppDevServer({
386
402
  appConfig,
387
403
  route,
388
404
  harnessOptions: harness,
405
+ scenario,
389
406
  });
390
407
  res.writeHead(200, {
391
408
  ...headers,
@@ -7,6 +7,7 @@ import { CONFIG_DIR } from './profiles.js';
7
7
  export const DEFAULT_APP_DEV_SESSIONS_FILE = join(CONFIG_DIR, 'app-dev-sessions.json');
8
8
  export const APP_DEV_SESSIONS_FILE = DEFAULT_APP_DEV_SESSIONS_FILE;
9
9
  export const APP_DEV_SESSIONS_VERSION = 1;
10
+ export const APP_DEV_SESSION_MOUNT_ACKS_VERSION = 1;
10
11
 
11
12
  /**
12
13
  * Walk up from `startDir` looking for a workspace-scoped dev-sessions registry.
@@ -54,6 +55,12 @@ export function getAppDevSessionsFile(filePath) {
54
55
  return DEFAULT_APP_DEV_SESSIONS_FILE;
55
56
  }
56
57
 
58
+ export function getAppDevSessionMountAcksFile(sessionsFilePath) {
59
+ const resolvedSessionsFile = getAppDevSessionsFile(sessionsFilePath);
60
+ const parsed = parsePath(resolvedSessionsFile);
61
+ return join(parsed.dir, `${parsed.name}-mount-acks${parsed.ext || '.json'}`);
62
+ }
63
+
57
64
  function normalizeSessions(raw) {
58
65
  if (!raw || typeof raw !== 'object' || !Array.isArray(raw.sessions)) {
59
66
  return [];
@@ -149,5 +156,96 @@ export function linkAppDevSessionTarget({ appId, devSlug, targetAppId, lastHeart
149
156
  export function removeAppDevSession(sessionId, filePath) {
150
157
  const registry = readAppDevSessions(filePath);
151
158
  registry.sessions = registry.sessions.filter((session) => session.sessionId !== sessionId);
152
- return writeAppDevSessions(registry, filePath);
159
+ const result = writeAppDevSessions(registry, filePath);
160
+ removeAppDevSessionMountAcknowledgements(sessionId, filePath);
161
+ return result;
162
+ }
163
+
164
+ function normalizeMountAcknowledgements(raw) {
165
+ if (!raw || typeof raw !== 'object' || !Array.isArray(raw.acknowledgements)) {
166
+ return [];
167
+ }
168
+ return raw.acknowledgements.filter((acknowledgement) =>
169
+ acknowledgement &&
170
+ typeof acknowledgement === 'object' &&
171
+ typeof acknowledgement.sessionId === 'string' &&
172
+ typeof acknowledgement.appId === 'string' &&
173
+ typeof acknowledgement.devSlug === 'string' &&
174
+ typeof acknowledgement.mountNonce === 'string' &&
175
+ typeof acknowledgement.acknowledgedAt === 'string' &&
176
+ acknowledgement.stage === 'listed',
177
+ );
178
+ }
179
+
180
+ export function readAppDevSessionMountAcknowledgements(sessionsFilePath) {
181
+ const filePath = getAppDevSessionMountAcksFile(sessionsFilePath);
182
+ if (!existsSync(filePath)) {
183
+ return { version: APP_DEV_SESSION_MOUNT_ACKS_VERSION, acknowledgements: [] };
184
+ }
185
+ try {
186
+ const raw = JSON.parse(readFileSync(filePath, 'utf8'));
187
+ return {
188
+ version: APP_DEV_SESSION_MOUNT_ACKS_VERSION,
189
+ acknowledgements: normalizeMountAcknowledgements(raw),
190
+ };
191
+ } catch {
192
+ return { version: APP_DEV_SESSION_MOUNT_ACKS_VERSION, acknowledgements: [] };
193
+ }
194
+ }
195
+
196
+ function writeAppDevSessionMountAcknowledgements(registry, sessionsFilePath) {
197
+ const filePath = getAppDevSessionMountAcksFile(sessionsFilePath);
198
+ mkdirSync(dirname(filePath), { recursive: true, mode: 0o700 });
199
+ const normalized = {
200
+ version: APP_DEV_SESSION_MOUNT_ACKS_VERSION,
201
+ acknowledgements: normalizeMountAcknowledgements(registry),
202
+ };
203
+ const tempPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
204
+ writeFileSync(tempPath, JSON.stringify(normalized, null, 2), { mode: 0o600 });
205
+ renameSync(tempPath, filePath);
206
+ return normalized;
207
+ }
208
+
209
+ export function removeAppDevSessionMountAcknowledgements(sessionId, sessionsFilePath) {
210
+ const filePath = getAppDevSessionMountAcksFile(sessionsFilePath);
211
+ if (!existsSync(filePath)) {
212
+ return { version: APP_DEV_SESSION_MOUNT_ACKS_VERSION, acknowledgements: [] };
213
+ }
214
+ const registry = readAppDevSessionMountAcknowledgements(sessionsFilePath);
215
+ registry.acknowledgements = registry.acknowledgements.filter(
216
+ (acknowledgement) => acknowledgement.sessionId !== sessionId,
217
+ );
218
+ return writeAppDevSessionMountAcknowledgements(registry, sessionsFilePath);
219
+ }
220
+
221
+ function mountAcknowledgementKey(value) {
222
+ return `${value.sessionId}:${value.appId}:${value.devSlug}:${value.mountNonce}`;
223
+ }
224
+
225
+ export async function waitForAppDevSessionMountAcknowledgements(expectedSessions, options = {}) {
226
+ const expected = Array.isArray(expectedSessions) ? expectedSessions : [expectedSessions];
227
+ const timeoutMs = Number.isFinite(options.timeoutMs) ? Math.max(0, options.timeoutMs) : 15_000;
228
+ const pollIntervalMs = Number.isFinite(options.pollIntervalMs)
229
+ ? Math.max(1, options.pollIntervalMs)
230
+ : 100;
231
+ const now = typeof options.now === 'function' ? options.now : Date.now;
232
+ const sleep = typeof options.sleep === 'function'
233
+ ? options.sleep
234
+ : (delayMs) => new Promise((resolve) => setTimeout(resolve, delayMs));
235
+ const startedAt = now();
236
+
237
+ while (true) {
238
+ const registry = readAppDevSessionMountAcknowledgements(options.sessionsFilePath);
239
+ const acknowledgedKeys = new Set(registry.acknowledgements.map(mountAcknowledgementKey));
240
+ const missing = expected.filter((session) => !acknowledgedKeys.has(mountAcknowledgementKey(session)));
241
+ if (missing.length === 0) {
242
+ return { mounted: true, missing: [], acknowledgements: registry.acknowledgements };
243
+ }
244
+
245
+ const elapsedMs = now() - startedAt;
246
+ if (elapsedMs >= timeoutMs) {
247
+ return { mounted: false, missing, acknowledgements: registry.acknowledgements };
248
+ }
249
+ await sleep(Math.min(pollIntervalMs, timeoutMs - elapsedMs));
250
+ }
153
251
  }