@notis_ai/cli 0.2.0-beta.20.1 → 0.2.0-beta.32.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.
Files changed (35) hide show
  1. package/README.md +36 -9
  2. package/package.json +2 -1
  3. package/src/command-specs/apps.js +780 -42
  4. package/src/command-specs/helpers.js +28 -3
  5. package/src/command-specs/index.js +1 -1
  6. package/src/command-specs/tools.js +33 -6
  7. package/src/runtime/agent-browser.js +192 -0
  8. package/src/runtime/app-boundary-validator.js +132 -0
  9. package/src/runtime/app-dev-server.js +577 -0
  10. package/src/runtime/app-dev-sessions.js +87 -0
  11. package/src/runtime/app-platform.js +352 -17
  12. package/src/runtime/cli-mode.generated.js +4 -0
  13. package/src/runtime/cli-mode.js +29 -0
  14. package/src/runtime/output.js +2 -2
  15. package/src/runtime/ports.js +15 -0
  16. package/src/runtime/profiles.js +34 -3
  17. package/src/runtime/transport.js +129 -4
  18. package/template/.harness/index.html.tmpl +260 -0
  19. package/template/app/layout.tsx +1 -2
  20. package/template/notis.config.ts +16 -1
  21. package/template/package.json +1 -2
  22. package/template/packages/notis-sdk/src/components/MultiSelectActionBar.tsx +272 -0
  23. package/template/packages/notis-sdk/src/components/MultiSelectCheckbox.tsx +91 -0
  24. package/template/packages/notis-sdk/src/components/MultiSelectDragOverlay.tsx +39 -0
  25. package/template/packages/notis-sdk/src/config.ts +24 -1
  26. package/template/packages/notis-sdk/src/hooks/useDatabase.ts +2 -1
  27. package/template/packages/notis-sdk/src/hooks/useMultiSelect.ts +502 -0
  28. package/template/packages/notis-sdk/src/hooks/useNotis.ts +5 -3
  29. package/template/packages/notis-sdk/src/hooks/useNotisNavigation.ts +1 -1
  30. package/template/packages/notis-sdk/src/hooks/useTopBarSearch.ts +73 -0
  31. package/template/packages/notis-sdk/src/index.ts +20 -0
  32. package/template/packages/notis-sdk/src/provider.tsx +23 -24
  33. package/template/packages/notis-sdk/src/runtime.ts +43 -26
  34. package/template/packages/notis-sdk/src/styles.css +6 -91
  35. package/src/runtime/app-preview-server.js +0 -336
@@ -1,12 +1,17 @@
1
1
  /**
2
2
  * Notis apps CLI commands.
3
3
  *
4
- * Clean command set for the Vercel-like Notis app workflow:
5
- * init -> dev -> build -> preview -> deploy
4
+ * Canonical Notis app workflow:
5
+ * init -> dev -> build -> deploy
6
6
  *
7
7
  * Supporting commands: list, link, doctor.
8
8
  */
9
9
 
10
+ import { randomUUID } from 'node:crypto';
11
+ import { spawn } from 'node:child_process';
12
+ import { existsSync, readdirSync } from 'node:fs';
13
+ import { join, resolve } from 'node:path';
14
+
10
15
  import { EXIT_CODES, usageError } from '../runtime/errors.js';
11
16
  import { formatTable } from '../runtime/output.js';
12
17
  import {
@@ -21,16 +26,35 @@ import {
21
26
  requireLinkedAppId,
22
27
  scaffoldProject,
23
28
  collectArtifactFiles,
24
- runProjectScript,
29
+ collectSourceFiles,
25
30
  directDeploy,
31
+ pullAppSource,
26
32
  } from '../runtime/app-platform.js';
27
- import { startPreviewServer } from '../runtime/app-preview-server.js';
33
+ import { startAppDevServer } from '../runtime/app-dev-server.js';
34
+ import {
35
+ closeAgentBrowserSession,
36
+ isAgentBrowserAvailable,
37
+ runHarnessRoute,
38
+ } from '../runtime/agent-browser.js';
39
+ import {
40
+ heartbeatAppDevSession,
41
+ removeAppDevSession,
42
+ upsertAppDevSessions,
43
+ } from '../runtime/app-dev-sessions.js';
44
+ import { getAvailablePort } from '../runtime/ports.js';
45
+ import { getCliMode } from '../runtime/cli-mode.js';
28
46
  import {
29
47
  nextIdempotencyKey,
30
48
  runToolCommand,
31
49
  toolConflictToError,
32
50
  } from './helpers.js';
33
51
 
52
+ const DEFAULT_DEV_PORT = 5173;
53
+ const DEV_HEARTBEAT_INTERVAL_MS = 10_000;
54
+ const CONFIG_FILENAMES = ['notis.config.ts', 'notis.config.js', 'notis.config.mjs'];
55
+ const ENSURE_DEV_APP_INSTALLATION_TOOL = 'notis-default-ensure_dev_app_installation';
56
+ const GET_APP_TOOL = 'notis-default-get_app';
57
+
34
58
  // ---------------------------------------------------------------------------
35
59
  // Formatters
36
60
  // ---------------------------------------------------------------------------
@@ -44,6 +68,315 @@ function appsTable(apps) {
44
68
  ]);
45
69
  }
46
70
 
71
+ function hasNotisConfig(dir) {
72
+ return CONFIG_FILENAMES.some((name) => existsSync(join(dir, name)));
73
+ }
74
+
75
+ function discoverAppDirs(projectDir) {
76
+ if (hasNotisConfig(projectDir)) {
77
+ return [projectDir];
78
+ }
79
+
80
+ const appsDir = join(projectDir, 'apps');
81
+ if (!existsSync(appsDir)) {
82
+ throw usageError(
83
+ `No notis.config.ts in ${projectDir}. Run from a Notis app project or a monorepo root with apps/<name>/notis.config.ts.`,
84
+ );
85
+ }
86
+
87
+ const entries = readdirSync(appsDir, { withFileTypes: true });
88
+ const candidates = [];
89
+ for (const entry of entries) {
90
+ if (!entry.isDirectory()) continue;
91
+ if (entry.name.startsWith('.') || entry.name.startsWith('_')) continue;
92
+ const dir = join(appsDir, entry.name);
93
+ if (hasNotisConfig(dir)) {
94
+ candidates.push(dir);
95
+ }
96
+ }
97
+
98
+ if (candidates.length === 0) {
99
+ throw usageError(
100
+ `Found apps/ in ${projectDir} but no apps/<name>/notis.config.ts inside it.`,
101
+ );
102
+ }
103
+
104
+ return candidates;
105
+ }
106
+
107
+ function decodeJwtSub(jwt) {
108
+ if (!jwt) return null;
109
+ try {
110
+ const parts = jwt.split('.');
111
+ if (parts.length !== 3) return null;
112
+ const decoded = JSON.parse(Buffer.from(parts[1], 'base64url').toString());
113
+ return decoded.sub || decoded.email || null;
114
+ } catch {
115
+ return null;
116
+ }
117
+ }
118
+
119
+ function openInBrowser(url) {
120
+ const platform = process.platform;
121
+ let command;
122
+ let args;
123
+ if (platform === 'darwin') {
124
+ command = 'open';
125
+ args = [url];
126
+ } else if (platform === 'win32') {
127
+ command = 'cmd';
128
+ args = ['/c', 'start', '', url];
129
+ } else {
130
+ command = 'xdg-open';
131
+ args = [url];
132
+ }
133
+ try {
134
+ const child = spawn(command, args, { stdio: 'ignore', detached: true });
135
+ child.on('error', () => {});
136
+ child.unref();
137
+ } catch {
138
+ // Non-fatal. The URL is printed in the CLI output.
139
+ }
140
+ }
141
+
142
+ function slugify(value) {
143
+ return String(value || '')
144
+ .trim()
145
+ .toLowerCase()
146
+ .replace(/[^a-z0-9]+/g, '-')
147
+ .replace(/(^-|-$)+/g, '');
148
+ }
149
+
150
+ function buildDevInstallSlug(appName) {
151
+ const base = slugify(appName);
152
+ if (!base) {
153
+ return '';
154
+ }
155
+ return base.endsWith('-dev') ? base : `${base}-dev`;
156
+ }
157
+
158
+ function pickDefaultRouteSlug(manifest) {
159
+ const routes = Array.isArray(manifest?.routes) ? manifest.routes : [];
160
+ const explicit = routes.find((route) => route && route.default && typeof route.slug === 'string');
161
+ if (explicit) return explicit.slug;
162
+ const firstWithSlug = routes.find((route) => route && typeof route.slug === 'string' && route.slug);
163
+ return firstWithSlug ? firstWithSlug.slug : null;
164
+ }
165
+
166
+ export function buildDevelopmentDesktopUrl() {
167
+ return 'notis://apps?tab=development';
168
+ }
169
+
170
+ function buildAppHref({ appSlug, appId, manifest }) {
171
+ const originlessBase = `/apps/${appSlug}-${appId}`;
172
+ const routeSlug = pickDefaultRouteSlug(manifest);
173
+ return routeSlug ? `${originlessBase}/${routeSlug}` : originlessBase;
174
+ }
175
+
176
+ function parsePort(value) {
177
+ if (!value) return null;
178
+ const port = Number.parseInt(value, 10);
179
+ if (!Number.isInteger(port) || port <= 0 || port > 65535) {
180
+ throw usageError('Port must be between 1 and 65535.');
181
+ }
182
+ return port;
183
+ }
184
+
185
+ function parseRouteSlugs(value) {
186
+ if (!value) return null;
187
+ const slugs = String(value)
188
+ .split(',')
189
+ .map((entry) => entry.trim())
190
+ .filter(Boolean);
191
+ if (slugs.length === 0) {
192
+ throw usageError('--routes must include at least one route slug.');
193
+ }
194
+ return slugs;
195
+ }
196
+
197
+ function routeSelection(manifest, rawRouteSlugs) {
198
+ const routes = Array.isArray(manifest?.routes) ? manifest.routes : [];
199
+ if (routes.length === 0) {
200
+ throw usageError('Manifest has no routes to verify.');
201
+ }
202
+ if (!rawRouteSlugs) {
203
+ return routes;
204
+ }
205
+
206
+ const bySlug = new Map(routes.map((route) => [route.slug, route]));
207
+ const selected = [];
208
+ const missing = [];
209
+ for (const slug of rawRouteSlugs) {
210
+ const route = bySlug.get(slug);
211
+ if (route) {
212
+ selected.push(route);
213
+ } else {
214
+ missing.push(slug);
215
+ }
216
+ }
217
+ if (missing.length) {
218
+ throw usageError(
219
+ `Unknown route slug${missing.length === 1 ? '' : 's'}: ${missing.join(', ')}.`,
220
+ { available_routes: routes.map((route) => route.slug) },
221
+ );
222
+ }
223
+ return selected;
224
+ }
225
+
226
+ function declaredDatabaseSlugs(appConfig, manifest, route) {
227
+ const slugs = new Set();
228
+ for (const entry of appConfig?.databases || manifest?.databases || []) {
229
+ if (typeof entry === 'string' && entry) {
230
+ slugs.add(entry);
231
+ } else if (entry && typeof entry === 'object' && typeof entry.slug === 'string') {
232
+ slugs.add(entry.slug);
233
+ }
234
+ }
235
+ if (route?.collection?.database) {
236
+ slugs.add(route.collection.database);
237
+ }
238
+ return Array.from(slugs);
239
+ }
240
+
241
+ function harnessErrorMessage(error) {
242
+ if (!error || typeof error !== 'object') {
243
+ return String(error);
244
+ }
245
+ return error.message || error.reason || error.type || JSON.stringify(error);
246
+ }
247
+
248
+ function assertHarnessResult(result, route, databaseSlugs) {
249
+ const assertions = [];
250
+ if (result.tool_error) {
251
+ assertions.push({
252
+ ok: false,
253
+ code: 'tool_error',
254
+ message: `agent-browser ${result.tool_error.phase || 'command'} failed`,
255
+ details: result.tool_error,
256
+ });
257
+ }
258
+ if (result.mounted !== true) {
259
+ assertions.push({
260
+ ok: false,
261
+ code: 'not_mounted',
262
+ message: 'Harness did not report mounted === true.',
263
+ });
264
+ }
265
+ if (result.timed_out) {
266
+ assertions.push({
267
+ ok: false,
268
+ code: 'timeout',
269
+ message: 'Timed out waiting for window.__harness.mounted.',
270
+ });
271
+ }
272
+ for (const error of result.errors || []) {
273
+ assertions.push({
274
+ ok: false,
275
+ code: 'render_error',
276
+ message: harnessErrorMessage(error),
277
+ details: error,
278
+ });
279
+ }
280
+ for (const databaseSlug of databaseSlugs) {
281
+ const seen = (result.runtimeCalls || []).some(
282
+ (call) => call?.op === 'queryDatabase' && call?.args?.databaseSlug === databaseSlug,
283
+ );
284
+ if (!seen) {
285
+ assertions.push({
286
+ ok: false,
287
+ code: 'missing_database_query',
288
+ message: `Route "${route.slug}" did not call queryDatabase for "${databaseSlug}".`,
289
+ details: { databaseSlug },
290
+ });
291
+ }
292
+ }
293
+ return assertions;
294
+ }
295
+
296
+ function renderVerifyReport({ summary, results, noBrowser }) {
297
+ const lines = [
298
+ noBrowser
299
+ ? `Harness URLs ready for ${summary.total} route${summary.total === 1 ? '' : 's'}.`
300
+ : `Verified ${summary.total} route${summary.total === 1 ? '' : 's'}: ${summary.passed} passed, ${summary.failed} failed.`,
301
+ ];
302
+ for (const result of results) {
303
+ const marker = result.ok ? 'PASS' : result.status === 'manual' ? 'URL' : 'FAIL';
304
+ lines.push(`${marker.padEnd(4)} ${result.route.padEnd(18)} ${result.url}`);
305
+ for (const assertion of result.assertions || []) {
306
+ lines.push(` - ${assertion.message}`);
307
+ }
308
+ }
309
+ if (noBrowser) {
310
+ lines.push('', 'Pass --keep-open to leave the harness process running while you inspect the URLs.');
311
+ }
312
+ return lines.join('\n');
313
+ }
314
+
315
+ function buildManifestForDev(appConfig) {
316
+ const routes = Array.isArray(appConfig.routes) ? appConfig.routes : [];
317
+ return {
318
+ version: 1,
319
+ spec_version: 3,
320
+ app: {
321
+ name: appConfig.name,
322
+ description: appConfig.description || null,
323
+ icon: appConfig.icon || null,
324
+ },
325
+ routes: routes.map((route) => ({
326
+ path: route.path,
327
+ slug: route.slug,
328
+ name: route.name,
329
+ icon: route.icon || null,
330
+ parentSlug: route.parentSlug || null,
331
+ default: route.default || false,
332
+ export_name: route.exportName || route.export_name,
333
+ collection: route.collection || null,
334
+ })),
335
+ bundle: {
336
+ js: 'bundle/app.js',
337
+ css: 'bundle/app.css',
338
+ },
339
+ databases: appConfig.databases || [],
340
+ tools: appConfig.tools || [],
341
+ };
342
+ }
343
+
344
+ async function ensureDevInstall({ ctx, appConfig, projectDir, idempotencyKey }) {
345
+ if (!appConfig.name) {
346
+ throw usageError(`notis.config.ts in ${projectDir} must define a non-empty name.`);
347
+ }
348
+ const devSlug = buildDevInstallSlug(appConfig.name);
349
+ if (!devSlug) {
350
+ throw usageError(`notis.config.ts name in ${projectDir} must slugify to a non-empty value.`);
351
+ }
352
+
353
+ const manifest = buildManifestForDev(appConfig);
354
+ const ensureResult = await runToolCommand({
355
+ runtime: ctx.runtime,
356
+ toolName: ENSURE_DEV_APP_INSTALLATION_TOOL,
357
+ arguments_: {
358
+ dev_slug: devSlug,
359
+ name: appConfig.name,
360
+ manifest,
361
+ },
362
+ mutating: true,
363
+ idempotencyKey,
364
+ });
365
+ const appId = ensureResult.payload.app_id;
366
+ if (!appId) {
367
+ throw usageError('Dev installation tool did not return an app_id.');
368
+ }
369
+ return {
370
+ slug: ensureResult.payload.slug || devSlug,
371
+ devSlug,
372
+ name: appConfig.name,
373
+ appId,
374
+ projectDir,
375
+ manifest,
376
+ created: ensureResult.payload.created || false,
377
+ };
378
+ }
379
+
47
380
  async function assertDirectDeployAccess(runtime, appId) {
48
381
  const result = await runToolCommand({
49
382
  runtime,
@@ -85,7 +418,7 @@ async function appsInitHandler(ctx) {
85
418
  humanSummary: `Scaffolded "${ctx.args.name}" in ${projectDir}`,
86
419
  hints: [
87
420
  { command: `cd ${projectDir} && npm install`, reason: 'Install dependencies' },
88
- { command: 'npm run dev', reason: 'Start the Vite dev server' },
421
+ { command: `cd ${projectDir} && notis apps dev`, reason: 'Start the real-portal dev workflow' },
89
422
  ],
90
423
  });
91
424
  }
@@ -139,18 +472,165 @@ async function appsCreateHandler(ctx) {
139
472
  }
140
473
 
141
474
  async function appsDevHandler(ctx) {
142
- const projectDir = resolveProjectDir(ctx.args.dir || '.');
143
- const problems = detectProjectProblems(projectDir);
144
- if (problems.length) {
145
- throw usageError(`Project has problems:\n${problems.map((p) => ` - ${p}`).join('\n')}`);
475
+ const rootDir = resolveProjectDir(ctx.args.dir || '.');
476
+ const appDirs = discoverAppDirs(rootDir);
477
+
478
+ for (const projectDir of appDirs) {
479
+ const problems = detectProjectProblems(projectDir);
480
+ if (problems.length) {
481
+ throw usageError(`Project ${projectDir} has problems:\n${problems.map((p) => ` - ${p}`).join('\n')}`);
482
+ }
146
483
  }
147
484
 
148
- await runProjectScript({
149
- projectDir,
150
- scriptName: 'dev',
151
- env: { NOTIS_DEV: '1' },
485
+ const port = ctx.options.port ? Number.parseInt(ctx.options.port, 10) : DEFAULT_DEV_PORT;
486
+ if (!Number.isInteger(port) || port <= 0 || port > 65535) {
487
+ throw usageError('Port must be between 1 and 65535.');
488
+ }
489
+
490
+ const mode = getCliMode();
491
+ const identity = decodeJwtSub(ctx.runtime.jwt);
492
+ if (!identity) {
493
+ throw usageError('Could not determine the current user from the CLI auth token. Run `notis auth login` from the desktop app and retry.');
494
+ }
495
+ const apiBase = String(ctx.runtime.apiBase || '').replace(/\/$/, '');
496
+ const sessionId = randomUUID();
497
+
498
+ const registered = [];
499
+ for (const projectDir of appDirs) {
500
+ const appConfig = await loadAppConfig(projectDir);
501
+ const info = await ensureDevInstall({
502
+ ctx,
503
+ appConfig,
504
+ projectDir,
505
+ idempotencyKey: nextIdempotencyKey(ctx.globalOptions),
506
+ });
507
+ registered.push(info);
508
+ }
509
+
510
+ const seenSlugs = new Map();
511
+ for (const app of registered) {
512
+ if (seenSlugs.has(app.devSlug)) {
513
+ throw usageError(
514
+ `Multiple apps slugify to "${app.devSlug}" (${seenSlugs.get(app.devSlug).projectDir} and ${app.projectDir}). Rename one in notis.config.ts.`,
515
+ );
516
+ }
517
+ seenSlugs.set(app.devSlug, app);
518
+ }
519
+
520
+ const apps = registered.map((app) => {
521
+ const bundleBaseUrl = `http://127.0.0.1:${port}/a/${app.devSlug}`;
522
+ return {
523
+ ...app,
524
+ bundleBaseUrl,
525
+ appHref: buildAppHref({
526
+ appSlug: app.slug,
527
+ appId: app.appId,
528
+ manifest: app.manifest,
529
+ }),
530
+ };
531
+ });
532
+ const developmentTabUrl = buildDevelopmentDesktopUrl();
533
+
534
+ const devServer = await startAppDevServer({
535
+ apps: apps.map((app) => ({ slug: app.devSlug, projectDir: app.projectDir, appId: app.appId })),
536
+ port,
537
+ });
538
+
539
+ try {
540
+ const now = new Date().toISOString();
541
+ upsertAppDevSessions(apps.map((app) => ({
542
+ sessionId,
543
+ userId: identity,
544
+ apiBase,
545
+ appId: app.appId,
546
+ devSlug: app.devSlug,
547
+ bundleBaseUrl: app.bundleBaseUrl,
548
+ projectDir: app.projectDir,
549
+ startedAt: now,
550
+ lastHeartbeatAt: now,
551
+ })));
552
+ } catch (error) {
553
+ try {
554
+ await devServer.close();
555
+ } catch {
556
+ // ignore cleanup failure on registry errors
557
+ }
558
+ throw error;
559
+ }
560
+
561
+ let heartbeatTimer = setInterval(() => {
562
+ try {
563
+ heartbeatAppDevSession(sessionId, new Date().toISOString());
564
+ } catch (error) {
565
+ const message = error instanceof Error ? error.message : String(error);
566
+ process.stderr.write(`[notis apps dev] heartbeat failed: ${message}\n`);
567
+ }
568
+ }, DEV_HEARTBEAT_INTERVAL_MS);
569
+
570
+ ctx.output.emitSuccess({
571
+ command: ctx.spec.command_path.join(' '),
572
+ data: {
573
+ mode,
574
+ api_base: apiBase,
575
+ development_url: developmentTabUrl,
576
+ session_id: sessionId,
577
+ identity,
578
+ apps: apps.map((app) => ({
579
+ slug: app.devSlug,
580
+ app_id: app.appId,
581
+ name: app.name,
582
+ project_dir: app.projectDir,
583
+ bundle_base_url: app.bundleBaseUrl,
584
+ app_href: app.appHref,
585
+ created: app.created,
586
+ })),
587
+ },
588
+ humanSummary: [
589
+ `Running apps dev against ${apiBase} as ${identity} (mode: ${mode})`,
590
+ '',
591
+ `Development tab: ${developmentTabUrl}`,
592
+ '',
593
+ ...apps.map((app) => ` ${app.name.padEnd(24)} ${app.bundleBaseUrl} -> ${app.appHref}`),
594
+ '',
595
+ 'Press Ctrl-C to stop.',
596
+ ].join('\n'),
597
+ });
598
+
599
+ if (!ctx.options.noOpen) {
600
+ openInBrowser(developmentTabUrl);
601
+ }
602
+
603
+ let shuttingDown = false;
604
+ const shutdown = async (signal) => {
605
+ if (shuttingDown) return;
606
+ shuttingDown = true;
607
+ process.stdout.write(`\n[notis apps dev] stopping (${signal})...\n`);
608
+ if (heartbeatTimer) {
609
+ clearInterval(heartbeatTimer);
610
+ heartbeatTimer = null;
611
+ }
612
+ try {
613
+ removeAppDevSession(sessionId);
614
+ } catch {
615
+ // ignore cleanup failures during shutdown
616
+ }
617
+ try {
618
+ await devServer.close();
619
+ } catch {
620
+ // ignore cleanup failures during shutdown
621
+ }
622
+ process.exit(EXIT_CODES.ok);
623
+ };
624
+
625
+ process.on('SIGINT', () => {
626
+ void shutdown('SIGINT');
627
+ });
628
+ process.on('SIGTERM', () => {
629
+ void shutdown('SIGTERM');
152
630
  });
153
631
 
632
+ await new Promise(() => {});
633
+
154
634
  return EXIT_CODES.ok;
155
635
  }
156
636
 
@@ -170,25 +650,207 @@ async function appsBuildHandler(ctx) {
170
650
  });
171
651
  }
172
652
 
173
- async function appsPreviewHandler(ctx) {
653
+ async function appsVerifyHandler(ctx) {
174
654
  const projectDir = resolveProjectDir(ctx.args.dir || '.');
175
- const port = ctx.options.port ? Number.parseInt(ctx.options.port, 10) : 8787;
176
- if (!Number.isInteger(port) || port <= 0 || port > 65535) {
177
- throw usageError('Port must be between 1 and 65535.');
655
+ const problems = detectProjectProblems(projectDir);
656
+ if (problems.length) {
657
+ throw usageError(`Project has problems:\n${problems.map((p) => ` - ${p}`).join('\n')}`);
658
+ }
659
+
660
+ const mode = ctx.options.mode || 'stub';
661
+ if (!['stub', 'live'].includes(mode)) {
662
+ throw usageError('--mode must be either "stub" or "live".');
663
+ }
664
+
665
+ let linkedState = null;
666
+ if (mode === 'live') {
667
+ if (!ctx.runtime.jwt) {
668
+ throw usageError('Live verify mode requires CLI auth. Run `notis auth login` and retry.');
669
+ }
670
+ linkedState = readLinkedState(projectDir);
671
+ if (!linkedState?.app_id) {
672
+ throw usageError('Live verify mode requires a linked app. Run `notis apps link <app-id> .` first.');
673
+ }
674
+ }
675
+
676
+ if (!ctx.options.skipBuild) {
677
+ await buildArtifact(projectDir);
178
678
  }
179
679
 
180
680
  const manifest = readManifest(projectDir);
181
- const defaultRoute = (manifest.routes || []).find((r) => r.default) || manifest.routes?.[0];
182
- const url = `http://localhost:${port}${defaultRoute?.path || '/'}`;
681
+ const appConfig = await loadAppConfig(projectDir);
682
+ const routes = routeSelection(manifest, parseRouteSlugs(ctx.options.routes));
683
+ const port = parsePort(ctx.options.port) || await getAvailablePort();
684
+ const appSlug = slugify(appConfig.name || manifest.app?.name || 'app') || 'app';
685
+ const baseUrl = `http://127.0.0.1:${port}/a/${appSlug}`;
686
+ const browserSessionName = `notis-verify-${process.pid}`;
687
+ const noBrowser = ctx.options.browser === false;
688
+ const keepOpen = Boolean(ctx.options.keepOpen);
689
+ let devServer = null;
690
+ let browserTouched = false;
183
691
 
184
- ctx.output.emitSuccess({
185
- command: ctx.spec.command_path.join(' '),
186
- data: { port, url, routes: manifest.routes.length },
187
- humanSummary: `Preview at ${url} -- press Ctrl+C to stop.`,
188
- });
692
+ try {
693
+ devServer = await startAppDevServer({
694
+ apps: [{
695
+ slug: appSlug,
696
+ projectDir,
697
+ appId: linkedState?.app_id || 'harness-app',
698
+ }],
699
+ port,
700
+ watch: false,
701
+ harness: {
702
+ mode,
703
+ apiBase: ctx.runtime.apiBase,
704
+ jwt: mode === 'live' ? ctx.runtime.jwt : null,
705
+ },
706
+ log: () => {},
707
+ logError: (message) => process.stderr.write(`${message}\n`),
708
+ });
189
709
 
190
- await startPreviewServer({ projectDir, port });
191
- return EXIT_CODES.ok;
710
+ const urls = routes.map((route) => ({
711
+ route,
712
+ url: `${baseUrl}/harness?route=${encodeURIComponent(route.slug)}`,
713
+ }));
714
+
715
+ let results;
716
+ const warnings = [];
717
+ if (noBrowser) {
718
+ results = urls.map(({ route, url }) => ({
719
+ route: route.slug,
720
+ path: route.path,
721
+ url,
722
+ ok: true,
723
+ status: 'manual',
724
+ mounted: null,
725
+ errors: [],
726
+ runtimeCalls: [],
727
+ assertions: [],
728
+ snapshot_path: null,
729
+ tool_error: null,
730
+ }));
731
+ } else if (!isAgentBrowserAvailable()) {
732
+ warnings.push('agent-browser is not available on PATH; rerun with --no-browser to inspect harness URLs manually.');
733
+ results = urls.map(({ route, url }) => {
734
+ const toolError = {
735
+ phase: 'available',
736
+ message: 'agent-browser is not available on PATH',
737
+ };
738
+ const result = {
739
+ route: route.slug,
740
+ path: route.path,
741
+ url,
742
+ mounted: false,
743
+ renderStarted: false,
744
+ errors: [],
745
+ runtimeCalls: [],
746
+ snapshotPath: null,
747
+ tool_error: toolError,
748
+ };
749
+ const assertions = assertHarnessResult(
750
+ result,
751
+ route,
752
+ declaredDatabaseSlugs(appConfig, manifest, route),
753
+ );
754
+ return {
755
+ ...result,
756
+ ok: false,
757
+ status: 'failed',
758
+ assertions,
759
+ snapshot_path: null,
760
+ };
761
+ });
762
+ } else {
763
+ browserTouched = true;
764
+ results = [];
765
+ for (const { route, url } of urls) {
766
+ const snapshotPath = join(projectDir, '.notis', 'output', '.harness', `${route.slug}.snapshot.txt`);
767
+ const result = await runHarnessRoute({
768
+ url,
769
+ sessionName: browserSessionName,
770
+ timeoutMs: Number.parseInt(ctx.globalOptions.timeoutMs || '', 10) || 10_000,
771
+ snapshotPath,
772
+ });
773
+ const assertions = assertHarnessResult(
774
+ result,
775
+ route,
776
+ declaredDatabaseSlugs(appConfig, manifest, route),
777
+ );
778
+ results.push({
779
+ route: route.slug,
780
+ path: route.path,
781
+ url,
782
+ ok: assertions.length === 0,
783
+ status: assertions.length === 0 ? 'passed' : 'failed',
784
+ mounted: result.mounted,
785
+ renderStarted: result.renderStarted,
786
+ errors: result.errors,
787
+ runtimeCalls: result.runtimeCalls,
788
+ assertions,
789
+ snapshot_path: result.snapshotPath,
790
+ timed_out: Boolean(result.timed_out),
791
+ tool_error: result.tool_error,
792
+ });
793
+ }
794
+ }
795
+
796
+ const summary = {
797
+ total: results.length,
798
+ passed: results.filter((result) => result.status === 'passed').length,
799
+ failed: results.filter((result) => result.status === 'failed').length,
800
+ manual: results.filter((result) => result.status === 'manual').length,
801
+ };
802
+ const overallOk = summary.failed === 0;
803
+ const exitCode = overallOk ? EXIT_CODES.ok : EXIT_CODES.unexpected;
804
+ const data = {
805
+ status: overallOk ? (summary.manual ? 'manual' : 'passed') : 'failed',
806
+ project_dir: projectDir,
807
+ app_slug: appSlug,
808
+ mode,
809
+ browser_session: noBrowser ? null : browserSessionName,
810
+ server: {
811
+ port,
812
+ base_url: baseUrl,
813
+ urls: urls.map(({ route, url }) => ({ route: route.slug, url })),
814
+ },
815
+ summary,
816
+ results,
817
+ };
818
+
819
+ ctx.output.emitSuccess({
820
+ ok: overallOk,
821
+ command: ctx.spec.command_path.join(' '),
822
+ data,
823
+ humanSummary: overallOk
824
+ ? (summary.manual ? `Harness URLs ready for ${summary.manual} routes.` : `Verified ${summary.passed} routes successfully.`)
825
+ : `Verification failed for ${summary.failed} routes.`,
826
+ warnings,
827
+ renderHuman: () => renderVerifyReport({ summary, results, noBrowser }),
828
+ });
829
+
830
+ if (keepOpen) {
831
+ process.stderr.write(`[notis apps verify] harness open at ${baseUrl}. Press Ctrl-C to stop.\n`);
832
+ await new Promise(() => {});
833
+ }
834
+
835
+ return exitCode;
836
+ } finally {
837
+ if (!keepOpen) {
838
+ if (browserTouched) {
839
+ try {
840
+ await closeAgentBrowserSession(browserSessionName);
841
+ } catch {
842
+ // Ignore browser cleanup failures.
843
+ }
844
+ }
845
+ if (devServer) {
846
+ try {
847
+ await devServer.close();
848
+ } catch {
849
+ // Ignore server cleanup failures.
850
+ }
851
+ }
852
+ }
853
+ }
192
854
  }
193
855
 
194
856
  async function appsLinkHandler(ctx) {
@@ -210,6 +872,39 @@ async function appsLinkHandler(ctx) {
210
872
  });
211
873
  }
212
874
 
875
+ async function appsPullHandler(ctx) {
876
+ const appId = ctx.args.appId;
877
+ const result = await runToolCommand({
878
+ runtime: ctx.runtime,
879
+ toolName: GET_APP_TOOL,
880
+ arguments_: { app_id: appId },
881
+ });
882
+ const app = result.payload?.app || {};
883
+ const defaultDir = app.slug || slugify(app.name) || appId;
884
+ const targetDir = resolveProjectDir(ctx.args.dir || defaultDir);
885
+ const version = ctx.options.version || 'latest';
886
+
887
+ const pulled = await pullAppSource({
888
+ apiBase: ctx.runtime.apiBase,
889
+ jwt: ctx.runtime.jwt,
890
+ appId,
891
+ targetDir,
892
+ version,
893
+ force: Boolean(ctx.options.force),
894
+ });
895
+
896
+ const versionLabel = pulled.version === 'latest' ? 'latest version' : `v${pulled.version}`;
897
+ return ctx.output.emitSuccess({
898
+ command: ctx.spec.command_path.join(' '),
899
+ data: {
900
+ app_id: appId,
901
+ project_dir: pulled.projectDir,
902
+ version: pulled.version,
903
+ },
904
+ humanSummary: `Pulled ${versionLabel} to ${pulled.projectDir}. Run \`cd ${pulled.projectDir} && npm install && notis apps dev\` to start editing.`,
905
+ });
906
+ }
907
+
213
908
  async function appsDeployHandler(ctx) {
214
909
  const projectDir = resolveProjectDir(ctx.args.dir || '.');
215
910
  const appId = requireLinkedAppId(projectDir, ctx.options.appId);
@@ -234,6 +929,7 @@ async function appsDeployHandler(ctx) {
234
929
 
235
930
  // Standard deploy via backend server, with auto-fallback to direct
236
931
  const files = collectArtifactFiles(projectDir);
932
+ const sourceFiles = collectSourceFiles(projectDir);
237
933
  const manifest = readManifest(projectDir);
238
934
 
239
935
  let result;
@@ -244,6 +940,7 @@ async function appsDeployHandler(ctx) {
244
940
  arguments_: {
245
941
  app_id: appId,
246
942
  files,
943
+ source_files: sourceFiles,
247
944
  manifest,
248
945
  },
249
946
  mutating: true,
@@ -386,25 +1083,33 @@ export const appsCommandSpecs = [
386
1083
  },
387
1084
  {
388
1085
  command_path: ['apps', 'dev'],
389
- summary: 'Run the Vite dev server for local development.',
390
- when_to_use: 'Iterate on app UI with hot reload. SDK hooks return mock data.',
1086
+ summary: 'Develop Notis apps inside the Electron desktop Portal with automatic local bundle reloads.',
1087
+ when_to_use:
1088
+ 'Run this inside a single app or a monorepo root with apps/<name>/notis.config.ts. It discovers every app, starts the local bundle server, registers desktop-local dev sessions, and opens the Electron Portal Development tab.',
391
1089
  args_schema: {
392
1090
  arguments: [
393
- { token: '[dir]', key: 'dir', description: 'Project directory (default: current dir).' },
1091
+ { token: '[dir]', key: 'dir', description: 'Project directory or monorepo root (default: current dir).' },
1092
+ ],
1093
+ options: [
1094
+ { flags: '--port <number>', description: `Local bundle server port (default: ${DEFAULT_DEV_PORT}).` },
1095
+ { flags: '--no-open', description: 'Do not auto-open the desktop Portal Development tab.' },
394
1096
  ],
395
- options: [],
396
1097
  },
397
- examples: ['notis apps dev', 'notis apps dev ./my-app'],
398
- mutates: false,
1098
+ examples: [
1099
+ 'notis apps dev',
1100
+ 'notis apps dev ./my-app',
1101
+ 'notis apps dev ./workspace --port 5200',
1102
+ ],
1103
+ mutates: true,
399
1104
  idempotent: true,
400
- require_auth: false,
401
- backend_call: { type: 'local', name: 'next_dev' },
1105
+ require_auth: true,
1106
+ backend_call: { type: 'tool', name: ENSURE_DEV_APP_INSTALLATION_TOOL },
402
1107
  handler: appsDevHandler,
403
1108
  },
404
1109
  {
405
1110
  command_path: ['apps', 'build'],
406
1111
  summary: 'Build and package the app into .notis/output/.',
407
- when_to_use: 'Prepare the app for preview or deployment.',
1112
+ when_to_use: 'Prepare the app for verification or deployment.',
408
1113
  args_schema: {
409
1114
  arguments: [
410
1115
  { token: '[dir]', key: 'dir', description: 'Project directory (default: current dir).' },
@@ -419,23 +1124,35 @@ export const appsCommandSpecs = [
419
1124
  handler: appsBuildHandler,
420
1125
  },
421
1126
  {
422
- command_path: ['apps', 'preview'],
423
- summary: 'Serve the built bundle locally for testing.',
424
- when_to_use: 'Smoke-test the exact bundle that will be deployed. Databases use seed data.',
1127
+ command_path: ['apps', 'verify'],
1128
+ summary: 'Headless render smoke each route in a stub-runtime harness.',
1129
+ when_to_use:
1130
+ 'After notis apps build, before deploy. Catches render-time crashes ' +
1131
+ 'and missing runtime calls that the build step cannot detect.',
425
1132
  args_schema: {
426
1133
  arguments: [
427
1134
  { token: '[dir]', key: 'dir', description: 'Project directory (default: current dir).' },
428
1135
  ],
429
1136
  options: [
430
- { flags: '--port <number>', description: 'Server port (default: 8787).' },
1137
+ { flags: '--routes <slugs>', description: 'Comma-separated route slugs. Default: every route in manifest.' },
1138
+ { flags: '--port <n>', description: 'Loopback port. Default: auto-pick.' },
1139
+ { flags: '--skip-build', description: 'Skip notis apps build; reuse existing .notis/output/.' },
1140
+ { flags: '--mode <mode>', description: 'stub | live. Default stub. Live posts to /portal_views/runtime_query with the CLI JWT.' },
1141
+ { flags: '--no-browser', description: 'Start the harness server and print URLs; do not drive agent-browser.' },
1142
+ { flags: '--keep-open', description: 'Leave server + browser session running after report (for manual triage).' },
431
1143
  ],
432
1144
  },
433
- examples: ['notis apps preview', 'notis apps preview --port 3000'],
1145
+ examples: [
1146
+ 'notis apps verify',
1147
+ 'notis apps verify --routes notes',
1148
+ 'notis apps verify --mode live',
1149
+ 'notis apps verify --no-browser # start the harness, drive agent-browser yourself',
1150
+ ],
434
1151
  mutates: false,
435
1152
  idempotent: true,
436
1153
  require_auth: false,
437
- backend_call: { type: 'local', name: 'preview_server' },
438
- handler: appsPreviewHandler,
1154
+ backend_call: { type: 'local', name: 'verify_harness' },
1155
+ handler: appsVerifyHandler,
439
1156
  },
440
1157
  {
441
1158
  command_path: ['apps', 'link'],
@@ -455,6 +1172,27 @@ export const appsCommandSpecs = [
455
1172
  backend_call: { type: 'local', name: 'write_link_state' },
456
1173
  handler: appsLinkHandler,
457
1174
  },
1175
+ {
1176
+ command_path: ['apps', 'pull'],
1177
+ summary: 'Download a Notis app source snapshot into a local project folder.',
1178
+ when_to_use: 'Edit an installed app locally. Pulls the persisted source and links the directory to the app.',
1179
+ args_schema: {
1180
+ arguments: [
1181
+ { token: '<app-id>', description: 'Remote app ID to pull.' },
1182
+ { token: '[dir]', key: 'dir', description: 'Target directory (defaults to the app slug).' },
1183
+ ],
1184
+ options: [
1185
+ { flags: '--force', description: 'Overwrite a non-empty target directory.' },
1186
+ { flags: '--version <n>', description: 'Pull a specific app source version (default: latest).' },
1187
+ ],
1188
+ },
1189
+ examples: ['notis apps pull abc123', 'notis apps pull abc123 ./my-app --force'],
1190
+ mutates: true,
1191
+ idempotent: true,
1192
+ require_auth: true,
1193
+ backend_call: { type: 'http', name: 'portal_apps/source' },
1194
+ handler: appsPullHandler,
1195
+ },
458
1196
  {
459
1197
  command_path: ['apps', 'deploy'],
460
1198
  summary: 'Build and upload the app to the linked Notis app.',