@notis_ai/cli 0.2.0-beta.177.1 → 0.2.0-beta.179.1

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.
@@ -1,6 +1,7 @@
1
1
  import { spawn, spawnSync } from 'node:child_process';
2
2
  import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
3
3
  import { dirname } from 'node:path';
4
+ import { contentCapturePlan, MEASURE_SCREENSHOT_CONTENT_SCRIPT, SAFE_VIEWPORT_HEIGHT_SCRIPT } from './screenshot-framing.js';
4
5
 
5
6
  export const HIDE_HARNESS_STATUS_SCRIPT =
6
7
  "(() => { const el = document.getElementById('harness-status'); if (el) el.style.opacity = '0'; return true; })()";
@@ -228,6 +229,7 @@ export async function runHarnessRoute({
228
229
  timeoutMs = 10_000,
229
230
  snapshotPath = null,
230
231
  designViewports = DEFAULT_DESIGN_VIEWPORTS,
232
+ waitForRuntime = false,
231
233
  }) {
232
234
  const opened = await runAgentBrowser(['--session', sessionName, 'open', url], {
233
235
  timeoutMs: Math.min(Math.max(timeoutMs, 5000), 30_000),
@@ -244,17 +246,21 @@ export async function runHarnessRoute({
244
246
  }
245
247
 
246
248
  const deadline = Date.now() + timeoutMs;
249
+ let readySince = null;
247
250
  while (Date.now() < deadline) {
248
251
  const lastRead = await readHarness(sessionName, 5000);
249
252
  if (!lastRead.ok) {
253
+ readySince = null;
250
254
  await delay(250);
251
255
  continue;
252
256
  }
253
257
  const harness = lastRead.harness;
254
- if (harness?.mounted || (Array.isArray(harness?.errors) && harness.errors.length > 0)) {
255
- await delay(250);
256
- break;
257
- }
258
+ if (Array.isArray(harness?.errors) && harness.errors.length > 0) break;
259
+ const pending = (harness?.runtimeCalls || []).some(call => call?.ok == null);
260
+ if (harness?.mounted && (!waitForRuntime || !pending)) {
261
+ readySince ??= Date.now();
262
+ if (Date.now() - readySince >= 250) break;
263
+ } else readySince = null;
258
264
  await delay(250);
259
265
  }
260
266
 
@@ -283,8 +289,8 @@ export async function runHarnessRoute({
283
289
  }
284
290
  }
285
291
 
286
- const timedOut = !harness.mounted && Date.now() >= deadline;
287
292
  const runtimeCalls = Array.isArray(harness.runtimeCalls) ? harness.runtimeCalls : [];
293
+ const timedOut = (!harness.mounted || (waitForRuntime && runtimeCalls.some(call => call?.ok == null))) && Date.now() >= deadline;
288
294
 
289
295
  // Design assertions only mean something on a mounted route. Loading
290
296
  // placeholders are excused while a runtime call is still in flight.
@@ -317,18 +323,18 @@ export async function runHarnessRoute({
317
323
 
318
324
  /**
319
325
  * Open a harness route, wait for it to mount, then capture a PNG screenshot of
320
- * the rendered app. Output is always exactly `width` x `height` physical
321
- * pixels (default 2000x1250, the 16:10 the listing validator expects), but the
322
- * CSS viewport is fitted to the rendered content height: short pages render
323
- * larger (no dead whitespace below the app), tall pages get more room before
324
- * cropping. The deviceScaleFactor maps the fitted CSS viewport back onto the
325
- * fixed output frame. Used by `notis apps screenshot`.
326
+ * the rendered app. Raw captures retain exact requested pixel dimensions.
327
+ * Framed captures can select the complete bounded app or a width-fitted first
328
+ * screen; the Store compositor normalizes them to the final listing size.
329
+ * Device scaling keeps text sharp at that size without changing app styles.
330
+ * Used by `notis apps screenshot`.
326
331
  */
327
332
  export async function captureHarnessScreenshot({
328
333
  url,
329
334
  sessionName,
330
335
  screenshotPath,
331
336
  focusSelector = null,
337
+ frameContent = false,
332
338
  width = 2000,
333
339
  height = 1250,
334
340
  timeoutMs = 15_000,
@@ -428,13 +434,50 @@ export async function captureHarnessScreenshot({
428
434
 
429
435
  // Let the route settle (async data, resize transitions) before the capture.
430
436
  await delay(500);
437
+
438
+ let contentBounds = null;
439
+ if (frameContent && !focusSelector) {
440
+ const measured = await runAgentBrowser(
441
+ ['--session', sessionName, '--json', 'eval', MEASURE_SCREENSHOT_CONTENT_SCRIPT],
442
+ { timeoutMs: 5000 },
443
+ );
444
+ if (measured.exitCode === 0) {
445
+ try {
446
+ const bounds = parseHarnessFromEval(measured.stdout);
447
+ if (bounds?.selector === '[data-notis-screenshot-content]'
448
+ && Number.isFinite(bounds.width) && bounds.width > 0
449
+ && Number.isFinite(bounds.height) && bounds.height > 0) {
450
+ contentBounds = bounds;
451
+ }
452
+ } catch { /* Keep the complete viewport if layout measurement is unavailable. */ }
453
+ }
454
+ }
455
+ let captureFocus = focusSelector || contentBounds?.selector || null;
456
+ if (contentBounds && scaledViewport) {
457
+ const plan = contentCapturePlan(contentBounds, framing || initial, width, height);
458
+ if (await setViewport(sessionName, plan.width, plan.height, plan.scale)) {
459
+ await delay(300);
460
+ if (!plan.focus) {
461
+ const safeHeight = await evalNumber(sessionName, SAFE_VIEWPORT_HEIGHT_SCRIPT, plan.height);
462
+ if (safeHeight < plan.height && await setViewport(sessionName, plan.width, safeHeight, plan.scale)) {
463
+ plan.height = safeHeight;
464
+ }
465
+ }
466
+ captureFocus = plan.focus ? contentBounds.selector : null;
467
+ framing = {
468
+ ...(framing || {}), ...plan, content_bounds: contentBounds,
469
+ capture_mode: plan.focus ? 'content' : 'width-fit-viewport',
470
+ };
471
+ await delay(300);
472
+ }
473
+ }
431
474
  const designResult = await evalDesignAssertions(sessionName, { viewport: 'capture' });
432
475
  const design = Array.isArray(designResult) ? designResult : [];
433
476
 
434
477
  mkdirSync(dirname(screenshotPath), { recursive: true });
435
478
  const screenshotArgs = ['--session', sessionName, 'screenshot'];
436
- if (focusSelector) {
437
- screenshotArgs.push(focusSelector);
479
+ if (captureFocus) {
480
+ screenshotArgs.push(captureFocus);
438
481
  }
439
482
  screenshotArgs.push(screenshotPath, '--screenshot-format', 'png');
440
483
  const shot = await runAgentBrowser(screenshotArgs, { timeoutMs: 15_000 });
@@ -446,7 +489,13 @@ export async function captureHarnessScreenshot({
446
489
  // capture came out wrong (e.g. the browser ignored the scale factor),
447
490
  // recapture once at a plain fixed viewport.
448
491
  const dimensions = pngDimensions(screenshotPath);
449
- if (!focusSelector && framing && (!dimensions || dimensions.width !== width || dimensions.height !== height)) {
492
+ const expectedDimensions = framing?.capture_mode === 'width-fit-viewport'
493
+ ? { width: Math.round(framing.width * framing.scale), height: Math.round(framing.height * framing.scale) }
494
+ : { width, height };
495
+ // Fractional browser scaling can round one pixel either way. Framed output
496
+ // is normalized by the compositor; raw captures still require exact pixels.
497
+ const tolerance = frameContent ? 1 : 0;
498
+ if (!captureFocus && framing && (!dimensions || Math.abs(dimensions.width - expectedDimensions.width) > tolerance || Math.abs(dimensions.height - expectedDimensions.height) > tolerance)) {
450
499
  await setViewport(sessionName, width, height);
451
500
  await delay(300);
452
501
  const retry = await runAgentBrowser(
@@ -459,10 +508,11 @@ export async function captureHarnessScreenshot({
459
508
  framing = null;
460
509
  }
461
510
 
462
- if (focusSelector) {
511
+ if (captureFocus) {
463
512
  framing = {
464
513
  ...(framing || {}),
465
- focus_selector: focusSelector,
514
+ focus_selector: captureFocus,
515
+ content_bounds: contentBounds,
466
516
  source_dimensions: dimensions,
467
517
  };
468
518
  }
@@ -286,13 +286,14 @@ export function detectProjectWarnings(projectDir, appConfig = null) {
286
286
  appConfig?.capabilities?.workspaceDatabases === 'read';
287
287
  if (
288
288
  appConfig
289
+ && appConfig.kind !== 'report'
289
290
  && !readsWorkspaceDatabases
290
291
  && (!Array.isArray(appConfig.databases) || appConfig.databases.length === 0)
291
292
  ) {
292
293
  warnings.push('No database references declared in notis.config.ts.');
293
294
  }
294
295
 
295
- if (appConfig) {
296
+ if (appConfig && appConfig.kind !== 'report') {
296
297
  try {
297
298
  const listing = inspectListingReadiness(projectDir, appConfig);
298
299
  warnings.push(...listing.errors, ...listing.warnings);
@@ -813,6 +814,29 @@ export function generateManifest(appConfig, projectDir) {
813
814
  return entry;
814
815
  });
815
816
 
817
+ if (appConfig.kind === 'report') {
818
+ if (routes.length !== 1 || routes[0].collection || routes[0].resourceDeepLinks) {
819
+ throw usageError('Reports require one standalone route, without app collections or resource deep links.');
820
+ }
821
+ if (['databases', 'skills', 'automations', 'onboarding', 'tagline', 'categories', 'author', 'screenshots'].some(key => {
822
+ const value = appConfig[key];
823
+ return Array.isArray(value) ? value.length > 0 : Boolean(value);
824
+ })) throw usageError('Reports cannot own databases, skills, automations or Store metadata.');
825
+ if (Object.values(appConfig.capabilities || {}).some(value => value !== 'read')) {
826
+ throw usageError('Reports support read capabilities only; shell grants require an installed app.');
827
+ }
828
+ const databaseAccess = appConfig.databaseAccess || [];
829
+ if (!Array.isArray(databaseAccess) || databaseAccess.some(entry =>
830
+ !entry || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(entry.id || '') ||
831
+ !['read', 'write'].includes(entry.access)
832
+ )) throw usageError('databaseAccess entries require an existing database UUID and read/write access.');
833
+ return { kind: 'report', version: 1, spec_version: 4,
834
+ routes: routes.map(route => ({ ...route, default: true, tool_access: { base: { tools: appConfig.tools || [] } } })),
835
+ bundle: { js: 'bundle/app.js', css: 'bundle/app.css' },
836
+ tools: appConfig.tools || [], tool_bindings: normalizeAppToolBindings(appConfig.toolBindings),
837
+ capabilities: normalizeAppCapabilities(appConfig.capabilities), database_access: databaseAccess };
838
+ }
839
+
816
840
  // Database entries may be a bare slug (structure only) or an object opting
817
841
  // into shipping rows to installers. Normalize to the manifest's snake_case.
818
842
  const databases = (appConfig.databases || []).map((entry) => {
@@ -164,6 +164,7 @@ function buildHarnessDescriptor({ state, manifest, appConfig, route, scenario =
164
164
  );
165
165
 
166
166
  return {
167
+ ...(state.resource ? { resource: state.resource } : {}),
167
168
  app: {
168
169
  id: state.appId || 'harness-app',
169
170
  slug: state.slug,
@@ -0,0 +1,85 @@
1
+ /** Runs only in the capture harness, never in an installed app. */
2
+ export function measureScreenshotContent() {
3
+ const root = document.getElementById('root');
4
+ if (!root) return null;
5
+ const viewportWidth = document.documentElement.clientWidth;
6
+ const visible = (element) => {
7
+ const rect = element.getBoundingClientRect();
8
+ const style = getComputedStyle(element);
9
+ return rect.width > 0 && rect.height > 0
10
+ && style.display !== 'none' && style.visibility !== 'hidden';
11
+ };
12
+
13
+ // Follow layout wrappers only while they contain the entire visible app.
14
+ // Never select one column, card, or control out of a multi-pane layout.
15
+ let node = root;
16
+ let content = null;
17
+ while (node) {
18
+ const hasOwnText = Array.from(node.childNodes).some(
19
+ (child) => child.nodeType === 3 && child.textContent.trim(),
20
+ );
21
+ const children = Array.from(node.children).filter(visible);
22
+ if (hasOwnText || children.length !== 1) break;
23
+ node = children[0];
24
+ const rect = node.getBoundingClientRect();
25
+ if (['DIV', 'MAIN', 'SECTION', 'ARTICLE', 'FORM'].includes(node.tagName)
26
+ && rect.width >= 320 && rect.height >= 160
27
+ && rect.width < viewportWidth * 0.9) {
28
+ content = node;
29
+ }
30
+ }
31
+ if (!content) return null;
32
+ content.setAttribute('data-notis-screenshot-content', '');
33
+ const rect = content.getBoundingClientRect();
34
+ return {
35
+ selector: '[data-notis-screenshot-content]',
36
+ width: rect.width,
37
+ height: rect.height,
38
+ viewport_width: viewportWidth,
39
+ };
40
+ }
41
+
42
+ export const MEASURE_SCREENSHOT_CONTENT_SCRIPT = `(${measureScreenshotContent.toString()})()`;
43
+
44
+ /** Keep long pages readable, and rasterize bounded views at their final width. */
45
+ export function contentCapturePlan(bounds, viewport, outputWidth, outputHeight) {
46
+ const aspect = outputWidth / outputHeight;
47
+ if (bounds.width / bounds.height < aspect * 0.75) {
48
+ const width = Math.max(768, Math.ceil((bounds.width + 32) / 8) * 8);
49
+ return { focus: false, width, height: Math.round(width / aspect), scale: outputWidth / width };
50
+ }
51
+ return {
52
+ focus: true,
53
+ width: viewport.width,
54
+ height: viewport.height,
55
+ scale: Math.min(4, outputWidth / bounds.width),
56
+ };
57
+ }
58
+
59
+ export function safeViewportHeight(rectangles, height) {
60
+ const crossing = rectangles.filter((rect) => rect.top < height && rect.bottom > height
61
+ && rect.top >= height * 0.85);
62
+ return crossing.length
63
+ ? Math.max(Math.ceil(height * 0.85), Math.floor(Math.min(...crossing.map((rect) => rect.top)) - 8))
64
+ : height;
65
+ }
66
+
67
+ // End a long-page viewport in whitespace rather than through a text line or
68
+ // control. Only geometry leaves the browser; no page text is returned.
69
+ export const SAFE_VIEWPORT_HEIGHT_SCRIPT = `(() => {
70
+ const root = document.getElementById('root');
71
+ if (!root) return innerHeight;
72
+ const rects = [];
73
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
74
+ let node;
75
+ while ((node = walker.nextNode())) {
76
+ if (!node.textContent.trim() || getComputedStyle(node.parentElement).visibility !== 'visible') continue;
77
+ const range = document.createRange(); range.selectNodeContents(node);
78
+ for (const rect of range.getClientRects()) rects.push({ top: rect.top, bottom: rect.bottom });
79
+ }
80
+ for (const control of root.querySelectorAll('button,input,select,textarea')) {
81
+ const rect = control.getBoundingClientRect();
82
+ rects.push({ top: rect.top, bottom: rect.bottom });
83
+ }
84
+ return (${safeViewportHeight.toString()})(rects, innerHeight);
85
+ })()`;
@@ -38,15 +38,17 @@ export function resolveStoreScreenshotAccent(accent, seed = 'notis-app') {
38
38
  return ACCENT_NAMES[stableHash(seed) % ACCENT_NAMES.length];
39
39
  }
40
40
 
41
- export function storeScreenshotLayout(width = 2000, height = 1250) {
41
+ export function storeScreenshotLayout(width = 2000, height = 1250, contentAspect = STORE_SCREENSHOT_ASPECT) {
42
+ const aspect = Number.isFinite(contentAspect) && contentAspect > 0
43
+ ? contentAspect : STORE_SCREENSHOT_ASPECT;
42
44
  const horizontalInset = Math.max(48, Math.round(width * 0.06));
43
45
  const verticalInset = Math.max(40, Math.round(height * 0.06));
44
46
  let cardWidth = width - horizontalInset * 2;
45
- let cardHeight = Math.round(cardWidth / STORE_SCREENSHOT_ASPECT);
47
+ let cardHeight = Math.round(cardWidth / aspect);
46
48
  const maximumCardHeight = height - verticalInset * 2;
47
49
  if (cardHeight > maximumCardHeight) {
48
50
  cardHeight = maximumCardHeight;
49
- cardWidth = Math.round(cardHeight * STORE_SCREENSHOT_ASPECT);
51
+ cardWidth = Math.round(cardHeight * aspect);
50
52
  }
51
53
  const cardLeft = Math.round((width - cardWidth) / 2);
52
54
  const cardTop = Math.round((height - cardHeight) / 2) - Math.round(height * 0.008);
@@ -80,7 +82,7 @@ function roundedRectSvg(width, height, radius, { fill = '#ffffff', stroke = null
80
82
 
81
83
  /**
82
84
  * Composite a truthful browser capture into the deterministic Store frame.
83
- * The source pixels are only resized/cropped; the app UI is never redrawn.
85
+ * Fit the complete source pixels; never crop controls or redraw the app UI.
84
86
  */
85
87
  export async function composeStoreScreenshot({
86
88
  inputPath,
@@ -94,13 +96,14 @@ export async function composeStoreScreenshot({
94
96
  }) {
95
97
  const sharp = await loadSharp();
96
98
  const resolvedTheme = theme === 'dark' ? 'dark' : 'light';
97
- const layout = storeScreenshotLayout(width, height);
99
+ const source = await sharp(inputPath).metadata();
100
+ const layout = storeScreenshotLayout(width, height, source.width / source.height);
98
101
  const resolvedAccent = resolveStoreScreenshotAccent(accent, seed);
99
102
 
100
103
  const roundedCapture = await sharp(inputPath)
101
104
  .resize(layout.cardWidth, layout.cardHeight, {
102
- fit: 'cover',
103
- position: focused ? 'top' : 'centre',
105
+ fit: 'contain',
106
+ background: resolvedTheme === 'dark' ? '#09090b' : '#ffffff',
104
107
  })
105
108
  .composite([{
106
109
  input: roundedRectSvg(layout.cardWidth, layout.cardHeight, layout.radius),
@@ -207,8 +207,8 @@
207
207
  }
208
208
 
209
209
  async function runtimeQuery(body) {
210
- if (!apiBase || !jwt || !descriptor.app || !descriptor.app.id) {
211
- throw new Error('live harness mode requires apiBase, jwt, and app id');
210
+ if (!apiBase || !jwt || !(descriptor.resource?.kind === 'report' ? descriptor.resource.id : descriptor.app?.id)) {
211
+ throw new Error('live harness mode requires apiBase, jwt, and runtime resource identity');
212
212
  }
213
213
  const response = await fetch(`${String(apiBase).replace(/\/$/, '')}/portal_views/runtime_query`, {
214
214
  method: 'POST',
@@ -217,7 +217,9 @@
217
217
  Authorization: `Bearer ${jwt}`,
218
218
  },
219
219
  body: JSON.stringify({
220
- app_id: descriptor.app.id,
220
+ ...(descriptor.resource?.kind === 'report'
221
+ ? { document_id: descriptor.resource.id, revision: descriptor.resource.revision }
222
+ : { app_id: descriptor.app.id }),
221
223
  route_slug: descriptor.route && descriptor.route.slug,
222
224
  ...body,
223
225
  }),
@@ -230,7 +232,10 @@
230
232
  if (!payload) {
231
233
  throw new Error(snippet ? `Runtime response was not JSON: ${snippet}` : 'Runtime response was empty');
232
234
  }
233
- if (payload.status === 'error' || payload.error) {
235
+ if (payload.status === 'error' || payload.error || (descriptor.resource?.kind === 'report' && (
236
+ ['error', 'failed', 'failure'].includes(String(payload.status || '').trim().toLowerCase())
237
+ || payload.ok === false || payload.success === false || payload.successful === false
238
+ ))) {
234
239
  throw new Error(payload.message || payload.error || 'Runtime request failed');
235
240
  }
236
241
  return payload;
@@ -238,6 +243,7 @@
238
243
 
239
244
  function stubRuntime() {
240
245
  return {
246
+ resource: descriptor.resource,
241
247
  app: descriptor.app,
242
248
  route: descriptor.route,
243
249
  context: descriptor.context || {},
@@ -287,6 +293,7 @@
287
293
  request: async (path, options) => tracked(
288
294
  record('request', { path, options }),
289
295
  async () => {
296
+ if (descriptor.resource?.kind === 'report') throw new Error('Reports access data through declared tools.');
290
297
  if (fixtures && fixtures.requests && Object.prototype.hasOwnProperty.call(fixtures.requests, path)) {
291
298
  return structuredClone(fixtures.requests[path]);
292
299
  }
@@ -349,6 +356,7 @@
349
356
  request: async (path, options) => tracked(
350
357
  record('request', { path, options }),
351
358
  async () => {
359
+ if (descriptor.resource?.kind === 'report') throw new Error('Reports access data through declared tools.');
352
360
  const response = await fetch(`${String(apiBase).replace(/\/$/, '')}${path}`, {
353
361
  method: (options && options.method) || 'GET',
354
362
  headers: {