@notis_ai/cli 0.2.0-beta.76.1 → 0.2.0-beta.95.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@notis_ai/cli",
3
- "version": "0.2.0-beta.76.1",
3
+ "version": "0.2.0-beta.95.1",
4
4
  "description": "Agent-first Notis CLI for apps and generic tool execution",
5
5
  "type": "module",
6
6
  "bin": {
@@ -22,6 +22,7 @@
22
22
  "prepack": "npm run build",
23
23
  "release:prepare": "npm run build && node ./scripts/prepare-publish.js --apply",
24
24
  "cli:set-mode": "node ./scripts/set-cli-mode.js",
25
+ "smoke": "node ./scripts/smoke-package.js",
25
26
  "test": "node --test"
26
27
  },
27
28
  "engines": {
@@ -26,6 +26,7 @@ Use the Notis CLI for the app lifecycle:
26
26
  npx --package @notis_ai/cli@latest -- notis apps init
27
27
  npx --package @notis_ai/cli@latest -- notis apps dev
28
28
  npx --package @notis_ai/cli@latest -- notis apps build
29
+ npx --package @notis_ai/cli@latest -- notis apps screenshot
29
30
  npx --package @notis_ai/cli@latest -- notis apps verify
30
31
  npx --package @notis_ai/cli@latest -- notis apps create
31
32
  npx --package @notis_ai/cli@latest -- notis apps link
@@ -50,6 +51,8 @@ npx --package @notis_ai/cli@latest -- notis apps ...
50
51
  - Use `@notis/sdk` hooks instead of direct runtime access.
51
52
  - Do not rely on `window.__NOTIS_RUNTIME__` or portal-owned DOM hooks.
52
53
  - `npx --package @notis_ai/cli@latest -- notis apps deploy` updates an installed app. It does not publish to the public App Store.
54
+ - **Local development first; deploy is user-gated.** Iterate with `apps dev` and hand off for the **user** to test in the desktop **Local development** sidebar group. Do not run `apps create` / `apps deploy` on your own — a clean build/verify is not permission to deploy. Deploy only when the user has tested the local build and explicitly asks. Finishing a build without deploying is the expected outcome.
55
+ - **Installed app links are explicit.** A mounted dev session updates an installed app only when the checkout is linked by app id in `.notis/state.json` and the active dev-session registry mirrors that id. Name or slug matches may be suggestions, never update targets.
53
56
  - Public or team App Store publishing is a Portal review flow.
54
57
  - Use Phosphor icon names with the `phosphor:` prefix. Do not use emoji icons.
55
58
  - Prefer scaffolded shadcn components and portal theme tokens over custom visual systems.
@@ -70,21 +73,30 @@ npx --package @notis_ai/cli@latest -- notis apps init "My App"
70
73
  npx --package @notis_ai/cli@latest -- notis apps pull <app-id> ./my-app
71
74
  ```
72
75
 
73
- 3. Install dependencies and develop:
76
+ 3. Install dependencies and develop. Keep `apps dev` running and **hand off to the user** — it is what surfaces the app in the desktop **Local development** sidebar group, and letting the user test there is the finish line for a build request:
74
77
 
75
78
  ```bash
76
79
  npm install
77
80
  npx --package @notis_ai/cli@latest -- notis apps dev
78
81
  ```
79
82
 
80
- 4. Build and verify:
83
+ The desktop lists a dev app only if its session is in the per-workspace registry it polls: `<workspace>/.context/app-dev-sessions.json`. A current CLI auto-resolves the nearest `.context` registry; on older CLIs (or when launched outside the workspace), pass `NOTIS_APP_DEV_SESSIONS_FILE="$CONDUCTOR_WORKSPACE_PATH/.context/app-dev-sessions.json"`. After starting, confirm your app's `devSlug` is in that file with a fresh `lastHeartbeatAt` — if it isn't, the session landed in the global `~/.notis` registry and the app won't appear in the sidebar.
84
+
85
+ 4. Build, capture listing screenshots, and verify (still local — this does not deploy):
81
86
 
82
87
  ```bash
83
88
  npx --package @notis_ai/cli@latest -- notis apps build
89
+ npx --package @notis_ai/cli@latest -- notis apps screenshot
84
90
  npx --package @notis_ai/cli@latest -- notis apps verify
85
91
  ```
86
92
 
87
- 5. Create or link the remote app, then deploy:
93
+ `notis apps screenshot` renders each route in a headless harness and writes
94
+ `metadata/screenshot-N.png` (2000x1250, 16:10). Apps are icon-led like Raycast:
95
+ the app icon represents the app everywhere, so there is no cover image — only
96
+ these screenshots. Re-run after changing routes or UI; it overwrites the set and
97
+ drops stale files. Use `--routes home,history` to capture a subset.
98
+
99
+ 5. **Only after the user has tested the local build and explicitly asks to deploy**, create or link the remote app, then deploy (this installs or updates the app on the user's account, where it appears under **Workspace** rather than Local development). After first install, keep `.notis/state.json` linked to that installed app id so future dev-session actions become **Update** instead of another **Install**:
88
100
 
89
101
  ```bash
90
102
  npx --package @notis_ai/cli@latest -- notis apps create "My App" .
@@ -107,17 +119,24 @@ notis.config.ts
107
119
  vite.config.ts
108
120
  app/
109
121
  components/
110
- metadata/
122
+ metadata/ # screenshot-N.png only — generated by `notis apps screenshot`
111
123
  .notis/output/
112
124
  ```
113
125
 
126
+ `metadata/` holds listing screenshots (`screenshot-1.png`, `screenshot-2.png`,
127
+ …). There is no `cover.png`: apps are icon-led like Raycast and the icon set in
128
+ `notis.config.ts` represents the app in the Store. Generate screenshots with
129
+ `notis apps screenshot` rather than authoring PNGs by hand.
130
+
114
131
  `notis.config.ts` owns:
115
132
 
116
- - app metadata: name, title, description, icon, categories, tagline
133
+ - app metadata: name, title, description, icon, accent, categories, tagline
117
134
  - database slug references
118
135
  - route definitions
119
136
  - tool access
120
137
 
138
+ **Icon & accent.** Set `icon` to a `phosphor:<name>` value (e.g. `phosphor:dice-five`) or `metadata/icon.png`. When `icon` is unset, the app shows its **two-letter initials** everywhere (store, sidebar, app details). `accent` optionally pins the avatar color to one of `blue | violet | emerald | amber | rose | sky | fuchsia | teal`; when unset a stable color is derived automatically from the app id. Both flow from `notis.config.ts` through deploy onto the app row and listing, and can also be changed later with the `update_app` tool (`{ "icon": "phosphor:...", "accent": "emerald" }`).
139
+
121
140
  Example:
122
141
 
123
142
  ```ts
@@ -128,6 +147,7 @@ export default defineNotisApp({
128
147
  title: 'My App',
129
148
  description: 'A focused Notis app.',
130
149
  icon: 'phosphor:squares-four',
150
+ accent: 'blue',
131
151
  databases: ['tasks'],
132
152
  routes: [
133
153
  {
@@ -155,6 +155,26 @@ Examples:
155
155
  - `npx --package @notis_ai/cli@latest -- notis apps verify --mode live`
156
156
  - `npx --package @notis_ai/cli@latest -- notis apps verify --no-browser # start the harness, drive agent-browser yourself`
157
157
 
158
+ ### `npx --package @notis_ai/cli@latest -- notis apps screenshot [dir]`
159
+
160
+ Capture listing screenshots of each route via the headless harness.
161
+
162
+ When to use: Generate metadata/screenshot-N.png for the App Store listing. Apps are icon-led (like Raycast) — there is no cover image, only these screenshots. Run before notis apps verify / deploy / publish.
163
+
164
+ Options:
165
+ - `--routes <slugs>` — Comma-separated route slugs. Default: every route in manifest.
166
+ - `--port <n>` — Loopback port. Default: auto-pick.
167
+ - `--width <px>` — Viewport width. Default: 2000.
168
+ - `--height <px>` — Viewport height. Default: 1250 (16:10).
169
+ - `--output-dir <dir>` — Where to write screenshot-N.png. Default: metadata/.
170
+ - `--mode <mode>` — stub | live. Default stub. Live renders against real data via the CLI JWT (requires a linked app), so screenshots show actual content instead of empty states.
171
+ - `--skip-build` — Skip notis apps build; reuse existing .notis/output/.
172
+
173
+ Examples:
174
+ - `npx --package @notis_ai/cli@latest -- notis apps screenshot`
175
+ - `npx --package @notis_ai/cli@latest -- notis apps screenshot --routes home,history`
176
+ - `npx --package @notis_ai/cli@latest -- notis apps screenshot --mode live # populated screenshots from real data`
177
+
158
178
  ### `npx --package @notis_ai/cli@latest -- notis apps link <app-id> [dir]`
159
179
 
160
180
  Link a local project to a remote Notis app.
package/src/cli.js CHANGED
@@ -1,10 +1,29 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
1
4
  import { Command } from 'commander';
2
5
  import { COMMAND_SPECS, GROUP_SUMMARIES } from './command-specs/index.js';
3
6
  import { OutputManager } from './runtime/output.js';
4
7
  import { asCliError } from './runtime/errors.js';
5
8
  import { resolveRuntimeProfile, workspacePath } from './runtime/profiles.js';
6
9
 
7
- const CLI_VERSION = '0.2.0';
10
+ // Read the version from package.json: the publish pipeline bumps the manifest
11
+ // (scripts/release-utils.js applyVersion), so a hardcoded string here goes
12
+ // stale on every release.
13
+ function readCliVersion() {
14
+ try {
15
+ const manifestPath = join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json');
16
+ const version = JSON.parse(readFileSync(manifestPath, 'utf-8')).version;
17
+ if (typeof version === 'string' && version.trim()) {
18
+ return version.trim();
19
+ }
20
+ } catch {
21
+ // Fall through to the placeholder below.
22
+ }
23
+ return '0.0.0';
24
+ }
25
+
26
+ const CLI_VERSION = readCliVersion();
8
27
 
9
28
  function buildHelpFooter(spec) {
10
29
  const lines = [
@@ -9,8 +9,8 @@
9
9
 
10
10
  import { randomUUID } from 'node:crypto';
11
11
  import { spawn } from 'node:child_process';
12
- import { existsSync, readdirSync } from 'node:fs';
13
- import { join, resolve } from 'node:path';
12
+ import { existsSync, mkdirSync, readdirSync, rmSync } from 'node:fs';
13
+ import { join, relative, resolve } from 'node:path';
14
14
 
15
15
  import { EXIT_CODES, usageError } from '../runtime/errors.js';
16
16
  import { formatTable } from '../runtime/output.js';
@@ -34,6 +34,7 @@ import {
34
34
  } from '../runtime/app-platform.js';
35
35
  import { startAppDevServer } from '../runtime/app-dev-server.js';
36
36
  import {
37
+ captureHarnessScreenshot,
37
38
  closeAgentBrowserSession,
38
39
  isAgentBrowserAvailable,
39
40
  runHarnessRoute,
@@ -236,6 +237,15 @@ function parsePort(value) {
236
237
  return port;
237
238
  }
238
239
 
240
+ function parsePositiveInt(value) {
241
+ if (!value) return null;
242
+ const parsed = Number.parseInt(value, 10);
243
+ if (!Number.isInteger(parsed) || parsed <= 0) {
244
+ throw usageError('Expected a positive integer.');
245
+ }
246
+ return parsed;
247
+ }
248
+
239
249
  function parseRouteSlugs(value) {
240
250
  if (!value) return null;
241
251
  const slugs = String(value)
@@ -277,6 +287,36 @@ function routeSelection(manifest, rawRouteSlugs) {
277
287
  return selected;
278
288
  }
279
289
 
290
+ export function pruneStaleScreenshotFiles(outputDir, keepCount) {
291
+ for (const entry of readdirSync(outputDir, { withFileTypes: true })) {
292
+ if (!entry.isFile()) continue;
293
+ const match = /^screenshot-(\d+)\.png$/i.exec(entry.name);
294
+ if (match && Number.parseInt(match[1], 10) > keepCount) {
295
+ rmSync(join(outputDir, entry.name), { force: true });
296
+ }
297
+ }
298
+ }
299
+
300
+ export function shouldPruneStaleScreenshotFiles(selectedRouteSlugs, failedCount) {
301
+ return failedCount === 0 && !selectedRouteSlugs;
302
+ }
303
+
304
+ export function screenshotIndexByRouteSlug(manifest) {
305
+ const routes = Array.isArray(manifest?.routes) ? manifest.routes : [];
306
+ return new Map(routes.map((route, index) => [route.slug, index + 1]));
307
+ }
308
+
309
+ export function appRowFieldsFromManifest(manifest) {
310
+ const app = manifest?.app && typeof manifest.app === 'object' ? manifest.app : {};
311
+ return {
312
+ accent: app.accent ?? null,
313
+ };
314
+ }
315
+
316
+ export function screenshotExitCode(failedCount) {
317
+ return failedCount === 0 ? EXIT_CODES.ok : EXIT_CODES.unexpected;
318
+ }
319
+
280
320
  function declaredDatabaseSlugs(appConfig, manifest, route) {
281
321
  const slugs = new Set();
282
322
  for (const entry of appConfig?.databases || manifest?.databases || []) {
@@ -427,7 +467,19 @@ export async function ensureDevInstall({
427
467
  }
428
468
 
429
469
  const manifest = buildManifestForDev(appConfig);
430
- const linkedState = readLinkedState(projectDir);
470
+ let linkedState = readLinkedState(projectDir);
471
+ if (linkedState?.app_id) {
472
+ const linkedApp = await assertDirectDeployAccess(ctx.runtime, linkedState.app_id, runTool);
473
+ if (!linkedState.dev_app_id && linkedApp?.manifest?.is_dev === true) {
474
+ const { app_id: legacyDevAppId, linked_at: _linkedAt, deployed_at: _deployedAt, version: _version, ...rest } = linkedState;
475
+ linkedState = {
476
+ ...rest,
477
+ dev_app_id: legacyDevAppId,
478
+ dev_linked_at: linkedState.dev_linked_at || linkedState.linked_at || new Date().toISOString(),
479
+ };
480
+ writeLinkedState(projectDir, linkedState);
481
+ }
482
+ }
431
483
  const ensureArguments = buildEnsureDevInstallArguments({ appConfig, manifest, linkedState });
432
484
  const ensureResult = await runTool({
433
485
  runtime: ctx.runtime,
@@ -440,6 +492,14 @@ export async function ensureDevInstall({
440
492
  if (!appId) {
441
493
  throw usageError('Dev installation tool did not return an app_id.');
442
494
  }
495
+ if (linkedState?.dev_app_id !== appId) {
496
+ const now = new Date().toISOString();
497
+ writeLinkedState(projectDir, {
498
+ ...(linkedState || {}),
499
+ dev_app_id: appId,
500
+ dev_linked_at: linkedState?.dev_linked_at || now,
501
+ });
502
+ }
443
503
  return {
444
504
  slug: ensureResult.payload.slug || devSlug,
445
505
  devSlug,
@@ -449,6 +509,7 @@ export async function ensureDevInstall({
449
509
  manifest,
450
510
  created: ensureResult.payload.created || false,
451
511
  linkedAppId: linkedState?.app_id || null,
512
+ targetAppId: linkedState?.app_id || null,
452
513
  databaseMaterialization: ensureResult.payload.database_materialization || { created: [], unresolved: [] },
453
514
  };
454
515
  }
@@ -468,16 +529,17 @@ function databaseMaterializationWarnings(apps) {
468
529
  return warnings;
469
530
  }
470
531
 
471
- async function assertDirectDeployAccess(runtime, appId) {
472
- const result = await runToolCommand({
532
+ async function assertDirectDeployAccess(runtime, appId, runTool = runToolCommand) {
533
+ const result = await runTool({
473
534
  runtime,
474
535
  toolName: LIST_APPS_TOOL,
475
536
  });
476
537
  const apps = result.payload.apps || [];
477
- const hasAccess = apps.some((app) => (app.app_id || app.id) === appId);
478
- if (!hasAccess) {
538
+ const app = apps.find((candidate) => (candidate.app_id || candidate.id) === appId) || null;
539
+ if (!app) {
479
540
  throw usageError(`Direct deploy requires access to app ${appId}.`);
480
541
  }
542
+ return app;
481
543
  }
482
544
 
483
545
  // ---------------------------------------------------------------------------
@@ -540,6 +602,7 @@ async function appsCreateHandler(ctx) {
540
602
  name: ctx.args.name,
541
603
  description: appConfig?.description || undefined,
542
604
  icon: appConfig?.icon || undefined,
605
+ accent: appConfig?.accent ?? undefined,
543
606
  },
544
607
  mutating: true,
545
608
  idempotencyKey,
@@ -669,6 +732,7 @@ async function appsDevHandler(ctx) {
669
732
  slug: app.devSlug,
670
733
  projectDir: app.projectDir,
671
734
  appId: app.appId,
735
+ targetAppId: app.targetAppId || null,
672
736
  userId: identity,
673
737
  })),
674
738
  port,
@@ -681,6 +745,7 @@ async function appsDevHandler(ctx) {
681
745
  userId: identity,
682
746
  apiBase,
683
747
  appId: app.appId,
748
+ targetAppId: app.targetAppId || undefined,
684
749
  devSlug: app.devSlug,
685
750
  bundleBaseUrl: app.bundleBaseUrl,
686
751
  projectDir: app.projectDir,
@@ -716,6 +781,7 @@ async function appsDevHandler(ctx) {
716
781
  apps: apps.map((app) => ({
717
782
  slug: app.devSlug,
718
783
  app_id: app.appId,
784
+ target_app_id: app.targetAppId,
719
785
  name: app.name,
720
786
  project_dir: app.projectDir,
721
787
  bundle_base_url: app.bundleBaseUrl,
@@ -999,6 +1065,149 @@ async function appsVerifyHandler(ctx) {
999
1065
  }
1000
1066
  }
1001
1067
 
1068
+ async function appsScreenshotHandler(ctx) {
1069
+ const projectDir = resolveProjectDir(ctx.args.dir || '.');
1070
+ const problems = detectProjectProblems(projectDir);
1071
+ if (problems.length) {
1072
+ throw usageError(`Project has problems:\n${problems.map((p) => ` - ${p}`).join('\n')}`);
1073
+ }
1074
+
1075
+ if (!isAgentBrowserAvailable()) {
1076
+ throw usageError('agent-browser is not available on PATH. It ships with the Notis desktop app; open it once, then retry.');
1077
+ }
1078
+
1079
+ const mode = ctx.options.mode || 'stub';
1080
+ if (!['stub', 'live'].includes(mode)) {
1081
+ throw usageError('--mode must be either "stub" or "live".');
1082
+ }
1083
+
1084
+ let linkedState = null;
1085
+ if (mode === 'live') {
1086
+ if (!ctx.runtime.jwt) {
1087
+ throw usageError('Live mode requires CLI auth. Open the Notis desktop app, sign in, and retry.');
1088
+ }
1089
+ linkedState = readLinkedState(projectDir);
1090
+ if (!linkedState?.app_id) {
1091
+ throw usageError('Live mode requires a linked app. Run `notis apps link <app-id> .` first.');
1092
+ }
1093
+ }
1094
+
1095
+ if (!ctx.options.skipBuild) {
1096
+ await buildArtifact(projectDir);
1097
+ }
1098
+
1099
+ const manifest = readManifest(projectDir);
1100
+ const appConfig = await loadAppConfig(projectDir);
1101
+ const selectedRouteSlugs = parseRouteSlugs(ctx.options.routes);
1102
+ const routes = routeSelection(manifest, selectedRouteSlugs);
1103
+ const screenshotSlots = screenshotIndexByRouteSlug(manifest);
1104
+ if (!routes.length) {
1105
+ throw usageError('No routes to screenshot.');
1106
+ }
1107
+
1108
+ const width = parsePositiveInt(ctx.options.width) || 2000;
1109
+ const height = parsePositiveInt(ctx.options.height) || 1250;
1110
+ const outputDir = ctx.options.outputDir
1111
+ ? resolveProjectDir(ctx.options.outputDir)
1112
+ : join(projectDir, 'metadata');
1113
+
1114
+ const port = parsePort(ctx.options.port) || await getAvailablePort();
1115
+ const appSlug = slugify(appConfig.name || manifest.app?.name || 'app') || 'app';
1116
+ const baseUrl = `http://127.0.0.1:${port}/a/${appSlug}`;
1117
+ const browserSessionName = `notis-screenshot-${process.pid}`;
1118
+ let devServer = null;
1119
+ let browserTouched = false;
1120
+
1121
+ try {
1122
+ devServer = await startAppDevServer({
1123
+ apps: [{ slug: appSlug, projectDir, appId: linkedState?.app_id || 'harness-app' }],
1124
+ port,
1125
+ watch: false,
1126
+ harness: {
1127
+ mode,
1128
+ apiBase: ctx.runtime.apiBase,
1129
+ jwt: mode === 'live' ? ctx.runtime.jwt : null,
1130
+ },
1131
+ log: () => {},
1132
+ logError: (message) => process.stderr.write(`${message}\n`),
1133
+ });
1134
+ browserTouched = true;
1135
+
1136
+ mkdirSync(outputDir, { recursive: true });
1137
+ const results = [];
1138
+ let index = 0;
1139
+ for (const route of routes) {
1140
+ index += 1;
1141
+ const slotIndex = screenshotSlots.get(route.slug) || index;
1142
+ const fileName = `screenshot-${slotIndex}.png`;
1143
+ const screenshotPath = join(outputDir, fileName);
1144
+ const result = await captureHarnessScreenshot({
1145
+ url: `${baseUrl}/harness?route=${encodeURIComponent(route.slug)}`,
1146
+ sessionName: browserSessionName,
1147
+ screenshotPath,
1148
+ width,
1149
+ height,
1150
+ timeoutMs: Number.parseInt(ctx.globalOptions.timeoutMs || '', 10) || 15_000,
1151
+ });
1152
+ results.push({
1153
+ route: route.slug,
1154
+ path: route.path,
1155
+ file: relative(projectDir, screenshotPath),
1156
+ ok: result.ok,
1157
+ errors: result.errors || [],
1158
+ timed_out: Boolean(result.timed_out),
1159
+ tool_error: result.tool_error,
1160
+ });
1161
+ }
1162
+
1163
+ const captured = results.filter((r) => r.ok);
1164
+ const failed = results.filter((r) => !r.ok);
1165
+ const warnings = [];
1166
+
1167
+ // Drop stale screenshots only after a full refresh. A selected-route
1168
+ // capture intentionally leaves other listing screenshots untouched.
1169
+ if (shouldPruneStaleScreenshotFiles(selectedRouteSlugs, failed.length)) {
1170
+ pruneStaleScreenshotFiles(outputDir, routes.length);
1171
+ }
1172
+ if (failed.length) {
1173
+ warnings.push(`${failed.length}/${results.length} routes failed to capture; see results.`);
1174
+ }
1175
+
1176
+ ctx.output.emitSuccess({
1177
+ ok: failed.length === 0,
1178
+ command: ctx.spec.command_path.join(' '),
1179
+ data: {
1180
+ project_dir: projectDir,
1181
+ output_dir: outputDir,
1182
+ mode,
1183
+ viewport: { width, height },
1184
+ summary: { total: results.length, captured: captured.length, failed: failed.length },
1185
+ results,
1186
+ },
1187
+ humanSummary: failed.length === 0
1188
+ ? `Captured ${captured.length} screenshot(s) to ${relative(projectDir, outputDir) || 'metadata'}/.`
1189
+ : `Captured ${captured.length}/${results.length} screenshots; ${failed.length} failed.`,
1190
+ warnings,
1191
+ });
1192
+ return screenshotExitCode(failed.length);
1193
+ } finally {
1194
+ if (browserTouched) {
1195
+ try {
1196
+ await closeAgentBrowserSession(browserSessionName);
1197
+ } catch {
1198
+ // Ignore browser cleanup failures.
1199
+ }
1200
+ }
1201
+ if (devServer) {
1202
+ try {
1203
+ await devServer.close();
1204
+ } catch {
1205
+ // Ignore server cleanup failures.
1206
+ }
1207
+ }
1208
+ }
1209
+ }
1210
+
1002
1211
  async function appsLinkHandler(ctx) {
1003
1212
  const projectDir = resolveProjectDir(ctx.args.dir || '.');
1004
1213
  const appId = ctx.args.appId;
@@ -1106,6 +1315,7 @@ async function appsDeployHandler(ctx) {
1106
1315
  files,
1107
1316
  source_files: sourceFiles,
1108
1317
  manifest,
1318
+ ...appRowFieldsFromManifest(manifest),
1109
1319
  ...(baseVersion !== undefined ? { base_version: baseVersion } : {}),
1110
1320
  },
1111
1321
  mutating: true,
@@ -1345,6 +1555,38 @@ export const appsCommandSpecs = [
1345
1555
  backend_call: { type: 'local', name: 'verify_harness' },
1346
1556
  handler: appsVerifyHandler,
1347
1557
  },
1558
+ {
1559
+ command_path: ['apps', 'screenshot'],
1560
+ summary: 'Capture listing screenshots of each route via the headless harness.',
1561
+ when_to_use:
1562
+ 'Generate metadata/screenshot-N.png for the App Store listing. Apps are ' +
1563
+ 'icon-led (like Raycast) — there is no cover image, only these screenshots. ' +
1564
+ 'Run before notis apps verify / deploy / publish.',
1565
+ args_schema: {
1566
+ arguments: [
1567
+ { token: '[dir]', key: 'dir', description: 'Project directory (default: current dir).' },
1568
+ ],
1569
+ options: [
1570
+ { flags: '--routes <slugs>', description: 'Comma-separated route slugs. Default: every route in manifest.' },
1571
+ { flags: '--port <n>', description: 'Loopback port. Default: auto-pick.' },
1572
+ { flags: '--width <px>', description: 'Viewport width. Default: 2000.' },
1573
+ { flags: '--height <px>', description: 'Viewport height. Default: 1250 (16:10).' },
1574
+ { flags: '--output-dir <dir>', description: 'Where to write screenshot-N.png. Default: metadata/.' },
1575
+ { flags: '--mode <mode>', description: 'stub | live. Default stub. Live renders against real data via the CLI JWT (requires a linked app), so screenshots show actual content instead of empty states.' },
1576
+ { flags: '--skip-build', description: 'Skip notis apps build; reuse existing .notis/output/.' },
1577
+ ],
1578
+ },
1579
+ examples: [
1580
+ 'notis apps screenshot',
1581
+ 'notis apps screenshot --routes home,history',
1582
+ 'notis apps screenshot --mode live # populated screenshots from real data',
1583
+ ],
1584
+ mutates: true,
1585
+ idempotent: true,
1586
+ require_auth: false,
1587
+ backend_call: { type: 'local', name: 'screenshot_routes' },
1588
+ handler: appsScreenshotHandler,
1589
+ },
1348
1590
  {
1349
1591
  command_path: ['apps', 'link'],
1350
1592
  summary: 'Link a local project to a remote Notis app.',
@@ -184,6 +184,95 @@ export async function runHarnessRoute({
184
184
  };
185
185
  }
186
186
 
187
+ /**
188
+ * 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`.
191
+ */
192
+ export async function captureHarnessScreenshot({
193
+ url,
194
+ sessionName,
195
+ screenshotPath,
196
+ width = 2000,
197
+ height = 1250,
198
+ timeoutMs = 15_000,
199
+ }) {
200
+ const opened = await runAgentBrowser(['--session', sessionName, 'open', url], {
201
+ timeoutMs: Math.min(Math.max(timeoutMs, 5000), 30_000),
202
+ });
203
+ if (opened.exitCode !== 0) {
204
+ return { ok: false, mounted: false, screenshotPath: null, tool_error: commandError('open', opened) };
205
+ }
206
+
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 },
212
+ );
213
+
214
+ const deadline = Date.now() + timeoutMs;
215
+ let lastErrors = [];
216
+ let lastReadError = null;
217
+ let mounted = false;
218
+ while (Date.now() < deadline) {
219
+ const read = await readHarness(sessionName, 5000);
220
+ if (read.ok) {
221
+ const harness = read.harness || {};
222
+ mounted = Boolean(harness.mounted);
223
+ lastErrors = Array.isArray(harness.errors) ? harness.errors : [];
224
+ if (mounted || lastErrors.length > 0) {
225
+ break;
226
+ }
227
+ } else {
228
+ lastReadError = read.toolError;
229
+ }
230
+ await delay(250);
231
+ }
232
+
233
+ if (lastErrors.length > 0) {
234
+ return { ok: false, mounted, screenshotPath: null, errors: lastErrors, tool_error: null };
235
+ }
236
+ if (!mounted) {
237
+ return {
238
+ ok: false,
239
+ mounted: false,
240
+ screenshotPath: null,
241
+ errors: [],
242
+ timed_out: true,
243
+ tool_error: lastReadError || {
244
+ phase: 'harness',
245
+ message: 'Timed out waiting for window.__harness.mounted.',
246
+ },
247
+ };
248
+ }
249
+
250
+ // Hide the harness debug banner so it never appears in the listing image.
251
+ 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
+ ],
258
+ { timeoutMs: 5000 },
259
+ );
260
+
261
+ // Let the route settle (fonts, async data) before the capture.
262
+ await delay(500);
263
+
264
+ mkdirSync(dirname(screenshotPath), { recursive: true });
265
+ const shot = await runAgentBrowser(
266
+ ['--session', sessionName, 'screenshot', screenshotPath, '--screenshot-format', 'png'],
267
+ { timeoutMs: 15_000 },
268
+ );
269
+ if (shot.exitCode !== 0) {
270
+ return { ok: false, mounted: true, screenshotPath: null, errors: lastErrors, timed_out: false, tool_error: commandError('screenshot', shot) };
271
+ }
272
+
273
+ return { ok: true, mounted: true, screenshotPath, errors: lastErrors, timed_out: false, tool_error: null };
274
+ }
275
+
187
276
  export async function closeAgentBrowserSession(sessionName) {
188
277
  const result = await runAgentBrowser(['--session', sessionName, 'close'], {
189
278
  timeoutMs: 5000,