@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/README.md +20 -0
- package/dist/scaffolds/notis-database/metadata/screenshot-1.png +0 -0
- package/dist/scaffolds/notis-notes/app/page.tsx +313 -175
- package/dist/scaffolds/notis-notes/metadata/screenshot-1.png +0 -0
- package/dist/scaffolds/notis-random/metadata/screenshot-1.png +0 -0
- package/dist/scaffolds/notis-random/metadata/screenshot-2.png +0 -0
- package/package.json +2 -1
- package/skills/notis-apps/SKILL.md +25 -5
- package/skills/notis-apps/cli.md +20 -0
- package/src/cli.js +20 -1
- package/src/command-specs/apps.js +249 -7
- package/src/runtime/agent-browser.js +89 -0
- package/src/runtime/app-boundary-validator.js +41 -10
- package/src/runtime/app-dev-server.js +92 -2
- package/src/runtime/app-dev-sessions.js +77 -11
- package/src/runtime/app-platform.js +5 -23
- package/dist/scaffolds/notis-database/metadata/cover.png +0 -0
- package/dist/scaffolds/notis-database/metadata/screenshot-2.png +0 -0
- package/dist/scaffolds/notis-database/metadata/screenshot-3.png +0 -0
- package/dist/scaffolds/notis-notes/metadata/cover.png +0 -0
- package/dist/scaffolds/notis-notes/metadata/screenshot-2.png +0 -0
- package/dist/scaffolds/notis-notes/metadata/screenshot-3.png +0 -0
- package/dist/scaffolds/notis-random/metadata/cover.png +0 -0
- package/dist/scaffolds/notis-random/metadata/screenshot-3.png +0 -0
- package/template/metadata/cover.png +0 -0
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@notis_ai/cli",
|
|
3
|
-
"version": "0.2.0-beta.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
{
|
package/skills/notis-apps/cli.md
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
478
|
-
if (!
|
|
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,
|