@notis_ai/cli 0.2.0 → 0.2.2

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 (81) hide show
  1. package/README.md +122 -158
  2. package/dist/scaffolds/notes/app/globals.css +3 -0
  3. package/dist/scaffolds/notes/app/layout.tsx +6 -0
  4. package/dist/scaffolds/notes/app/page.tsx +1465 -0
  5. package/dist/scaffolds/notes/components/ui/badge.tsx +28 -0
  6. package/dist/scaffolds/notes/components/ui/button.tsx +53 -0
  7. package/dist/scaffolds/notes/components/ui/card.tsx +56 -0
  8. package/dist/scaffolds/notes/components.json +20 -0
  9. package/dist/scaffolds/notes/lib/utils.ts +6 -0
  10. package/dist/scaffolds/notes/notis.config.ts +31 -0
  11. package/dist/scaffolds/notes/package.json +31 -0
  12. package/dist/scaffolds/notes/postcss.config.mjs +8 -0
  13. package/dist/scaffolds/notes/tailwind.config.ts +58 -0
  14. package/dist/scaffolds/notes/tsconfig.json +22 -0
  15. package/dist/scaffolds/notes/vite.config.ts +10 -0
  16. package/dist/scaffolds.json +13 -0
  17. package/package.json +12 -2
  18. package/skills/notis-apps/SKILL.md +162 -0
  19. package/skills/notis-apps/cli.md +208 -0
  20. package/skills/notis-cli/SKILL.md +258 -0
  21. package/skills/notis-query/cli.md +40 -0
  22. package/src/cli.js +1 -1
  23. package/src/command-specs/apps.js +1029 -59
  24. package/src/command-specs/helpers.js +6 -41
  25. package/src/command-specs/index.js +1 -7
  26. package/src/command-specs/meta.js +7 -6
  27. package/src/command-specs/tools.js +29 -34
  28. package/src/runtime/agent-browser.js +192 -0
  29. package/src/runtime/app-boundary-validator.js +132 -0
  30. package/src/runtime/app-dev-server.js +605 -0
  31. package/src/runtime/app-dev-sessions.js +87 -0
  32. package/src/runtime/app-platform.js +1037 -82
  33. package/src/runtime/cli-mode.generated.js +4 -0
  34. package/src/runtime/cli-mode.js +29 -0
  35. package/src/runtime/output.js +2 -2
  36. package/src/runtime/ports.js +15 -0
  37. package/src/runtime/profiles.js +36 -5
  38. package/src/runtime/transport.js +131 -6
  39. package/template/.harness/index.html.tmpl +211 -0
  40. package/template/app/globals.css +3 -0
  41. package/template/app/layout.tsx +6 -0
  42. package/template/app/page.tsx +90 -0
  43. package/template/components/ui/badge.tsx +28 -0
  44. package/template/components/ui/button.tsx +53 -0
  45. package/template/components/ui/card.tsx +56 -0
  46. package/template/components.json +20 -0
  47. package/template/lib/utils.ts +6 -0
  48. package/template/metadata/cover.png +0 -0
  49. package/template/metadata/screenshot-1.png +0 -0
  50. package/template/metadata/screenshot-2.png +0 -0
  51. package/template/metadata/screenshot-3.png +0 -0
  52. package/template/notis.config.ts +37 -0
  53. package/template/package.json +31 -0
  54. package/template/packages/sdk/package.json +32 -0
  55. package/template/packages/sdk/src/components/MultiSelectActionBar.tsx +273 -0
  56. package/template/packages/sdk/src/components/MultiSelectCheckbox.tsx +91 -0
  57. package/template/packages/sdk/src/components/MultiSelectDragOverlay.tsx +39 -0
  58. package/template/packages/sdk/src/config.ts +71 -0
  59. package/template/packages/sdk/src/hooks/useBackend.ts +41 -0
  60. package/template/packages/sdk/src/hooks/useDatabase.ts +76 -0
  61. package/template/packages/sdk/src/hooks/useMultiSelect.ts +503 -0
  62. package/template/packages/sdk/src/hooks/useNotis.ts +34 -0
  63. package/template/packages/sdk/src/hooks/useNotisNavigation.ts +49 -0
  64. package/template/packages/sdk/src/hooks/useTool.ts +64 -0
  65. package/template/packages/sdk/src/hooks/useTools.ts +56 -0
  66. package/template/packages/sdk/src/hooks/useTopBarSearch.ts +73 -0
  67. package/template/packages/sdk/src/hooks/useUpsertDocument.ts +50 -0
  68. package/template/packages/sdk/src/index.ts +54 -0
  69. package/template/packages/sdk/src/provider.tsx +43 -0
  70. package/template/packages/sdk/src/runtime.ts +161 -0
  71. package/template/packages/sdk/src/styles.css +38 -0
  72. package/template/packages/sdk/src/ui.ts +15 -0
  73. package/template/packages/sdk/src/vite.ts +56 -0
  74. package/template/packages/sdk/tsconfig.json +15 -0
  75. package/template/postcss.config.mjs +8 -0
  76. package/template/tailwind.config.ts +58 -0
  77. package/template/tsconfig.json +22 -0
  78. package/template/vite.config.ts +10 -0
  79. package/src/command-specs/auth.js +0 -178
  80. package/src/command-specs/db.js +0 -163
  81. package/src/runtime/app-preview-server.js +0 -272
@@ -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 {
@@ -20,16 +25,38 @@ import {
20
25
  writeLinkedState,
21
26
  requireLinkedAppId,
22
27
  scaffoldProject,
28
+ loadScaffoldCatalog,
29
+ inspectListingReadiness,
23
30
  collectArtifactFiles,
24
- runProjectScript,
31
+ collectSourceFiles,
32
+ directDeploy,
33
+ pullAppSource,
25
34
  } from '../runtime/app-platform.js';
26
- import { startPreviewServer } from '../runtime/app-preview-server.js';
35
+ import { startAppDevServer } from '../runtime/app-dev-server.js';
36
+ import {
37
+ closeAgentBrowserSession,
38
+ isAgentBrowserAvailable,
39
+ runHarnessRoute,
40
+ } from '../runtime/agent-browser.js';
41
+ import {
42
+ heartbeatAppDevSession,
43
+ removeAppDevSession,
44
+ upsertAppDevSessions,
45
+ } from '../runtime/app-dev-sessions.js';
46
+ import { getAvailablePort } from '../runtime/ports.js';
47
+ import { getCliMode } from '../runtime/cli-mode.js';
27
48
  import {
28
49
  nextIdempotencyKey,
29
50
  runToolCommand,
30
51
  toolConflictToError,
31
52
  } from './helpers.js';
32
53
 
54
+ const DEFAULT_DEV_PORT = 5173;
55
+ const DEV_HEARTBEAT_INTERVAL_MS = 10_000;
56
+ const CONFIG_FILENAMES = ['notis.config.ts', 'notis.config.js', 'notis.config.mjs'];
57
+ const ENSURE_DEV_APP_INSTALLATION_TOOL = 'notis-default-ensure_dev_app_installation';
58
+ const GET_APP_TOOL = 'notis-default-get_app';
59
+
33
60
  // ---------------------------------------------------------------------------
34
61
  // Formatters
35
62
  // ---------------------------------------------------------------------------
@@ -43,6 +70,392 @@ function appsTable(apps) {
43
70
  ]);
44
71
  }
45
72
 
73
+ function scaffoldsTable(scaffolds) {
74
+ return formatTable(scaffolds, [
75
+ { label: 'Slug', value: (scaffold) => scaffold.slug || '' },
76
+ { label: 'Name', value: (scaffold) => scaffold.name || scaffold.slug || '' },
77
+ { label: 'Category', value: (scaffold) => (scaffold.categories || [])[0] || '' },
78
+ { label: 'Tagline', value: (scaffold) => scaffold.tagline || scaffold.description || '' },
79
+ ]);
80
+ }
81
+
82
+ function hasNotisConfig(dir) {
83
+ return CONFIG_FILENAMES.some((name) => existsSync(join(dir, name)));
84
+ }
85
+
86
+ function discoverAppDirs(projectDir) {
87
+ if (hasNotisConfig(projectDir)) {
88
+ return [projectDir];
89
+ }
90
+
91
+ const appsDir = join(projectDir, 'apps');
92
+ if (!existsSync(appsDir)) {
93
+ throw usageError(
94
+ `No notis.config.ts in ${projectDir}. Run from a Notis app project or a monorepo root with apps/<name>/notis.config.ts.`,
95
+ );
96
+ }
97
+
98
+ const entries = readdirSync(appsDir, { withFileTypes: true });
99
+ const candidates = [];
100
+ for (const entry of entries) {
101
+ if (!entry.isDirectory()) continue;
102
+ if (entry.name.startsWith('.') || entry.name.startsWith('_')) continue;
103
+ const dir = join(appsDir, entry.name);
104
+ if (hasNotisConfig(dir)) {
105
+ candidates.push(dir);
106
+ }
107
+ }
108
+
109
+ if (candidates.length === 0) {
110
+ throw usageError(
111
+ `Found apps/ in ${projectDir} but no apps/<name>/notis.config.ts inside it.`,
112
+ );
113
+ }
114
+
115
+ return candidates;
116
+ }
117
+
118
+ function decodeJwtSub(jwt) {
119
+ if (!jwt) return null;
120
+ try {
121
+ const parts = jwt.split('.');
122
+ if (parts.length !== 3) return null;
123
+ const decoded = JSON.parse(Buffer.from(parts[1], 'base64url').toString());
124
+ return decoded.sub || decoded.email || null;
125
+ } catch {
126
+ return null;
127
+ }
128
+ }
129
+
130
+ function openInBrowser(url) {
131
+ const platform = process.platform;
132
+ let command;
133
+ let args;
134
+ if (platform === 'darwin') {
135
+ command = 'open';
136
+ args = [url];
137
+ } else if (platform === 'win32') {
138
+ command = 'cmd';
139
+ args = ['/c', 'start', '', url];
140
+ } else {
141
+ command = 'xdg-open';
142
+ args = [url];
143
+ }
144
+ try {
145
+ const child = spawn(command, args, { stdio: 'ignore', detached: true });
146
+ child.on('error', () => {});
147
+ child.unref();
148
+ } catch {
149
+ // Non-fatal. The URL is printed in the CLI output.
150
+ }
151
+ }
152
+
153
+ function slugify(value) {
154
+ return String(value || '')
155
+ .trim()
156
+ .toLowerCase()
157
+ .replace(/[^a-z0-9]+/g, '-')
158
+ .replace(/(^-|-$)+/g, '');
159
+ }
160
+
161
+ function buildDevInstallSlug(appName) {
162
+ const base = slugify(appName);
163
+ if (!base) {
164
+ return '';
165
+ }
166
+ return base.endsWith('-dev') ? base : `${base}-dev`;
167
+ }
168
+
169
+ function timingMs(startedAt) {
170
+ return Number(process.hrtime.bigint() - startedAt) / 1_000_000;
171
+ }
172
+
173
+ function logAppsTiming(label, details = {}) {
174
+ const suffix = Object.entries(details)
175
+ .map(([key, value]) => `${key}=${value}`)
176
+ .join(' ');
177
+ process.stderr.write(`[notis apps timing] ${label}${suffix ? ` ${suffix}` : ''}\n`);
178
+ }
179
+
180
+ function nextDevInstallIdempotencyKey(globalOptions = {}, devSlug) {
181
+ if (globalOptions.idempotencyKey) {
182
+ return `${globalOptions.idempotencyKey}:${devSlug}`;
183
+ }
184
+ return nextIdempotencyKey(globalOptions);
185
+ }
186
+
187
+ function pickDefaultRouteSlug(manifest) {
188
+ const routes = Array.isArray(manifest?.routes) ? manifest.routes : [];
189
+ const explicit = routes.find((route) => route && route.default && typeof route.slug === 'string');
190
+ if (explicit) return explicit.slug;
191
+ const firstWithSlug = routes.find((route) => route && typeof route.slug === 'string' && route.slug);
192
+ return firstWithSlug ? firstWithSlug.slug : null;
193
+ }
194
+
195
+ export function buildDevelopmentDesktopUrl(appHref = null) {
196
+ const route = String(appHref || '/store').replace(/^\/+/, '');
197
+ return `notis://${route || 'store'}`;
198
+ }
199
+
200
+ function buildAppHref({ appSlug, appId, manifest }) {
201
+ const originlessBase = `/apps/${appSlug}-${appId}`;
202
+ const routeSlug = pickDefaultRouteSlug(manifest);
203
+ return routeSlug ? `${originlessBase}/${routeSlug}` : originlessBase;
204
+ }
205
+
206
+ function parsePort(value) {
207
+ if (!value) return null;
208
+ const port = Number.parseInt(value, 10);
209
+ if (!Number.isInteger(port) || port <= 0 || port > 65535) {
210
+ throw usageError('Port must be between 1 and 65535.');
211
+ }
212
+ return port;
213
+ }
214
+
215
+ function parseRouteSlugs(value) {
216
+ if (!value) return null;
217
+ const slugs = String(value)
218
+ .split(',')
219
+ .map((entry) => entry.trim())
220
+ .filter(Boolean);
221
+ if (slugs.length === 0) {
222
+ throw usageError('--routes must include at least one route slug.');
223
+ }
224
+ return slugs;
225
+ }
226
+
227
+ function routeSelection(manifest, rawRouteSlugs) {
228
+ const routes = Array.isArray(manifest?.routes) ? manifest.routes : [];
229
+ if (routes.length === 0) {
230
+ throw usageError('Manifest has no routes to verify.');
231
+ }
232
+ if (!rawRouteSlugs) {
233
+ return routes;
234
+ }
235
+
236
+ const bySlug = new Map(routes.map((route) => [route.slug, route]));
237
+ const selected = [];
238
+ const missing = [];
239
+ for (const slug of rawRouteSlugs) {
240
+ const route = bySlug.get(slug);
241
+ if (route) {
242
+ selected.push(route);
243
+ } else {
244
+ missing.push(slug);
245
+ }
246
+ }
247
+ if (missing.length) {
248
+ throw usageError(
249
+ `Unknown route slug${missing.length === 1 ? '' : 's'}: ${missing.join(', ')}.`,
250
+ { available_routes: routes.map((route) => route.slug) },
251
+ );
252
+ }
253
+ return selected;
254
+ }
255
+
256
+ function declaredDatabaseSlugs(appConfig, manifest, route) {
257
+ const slugs = new Set();
258
+ for (const entry of appConfig?.databases || manifest?.databases || []) {
259
+ if (typeof entry === 'string' && entry) {
260
+ slugs.add(entry);
261
+ } else if (entry && typeof entry === 'object' && typeof entry.slug === 'string') {
262
+ slugs.add(entry.slug);
263
+ }
264
+ }
265
+ if (route?.collection?.database) {
266
+ slugs.add(route.collection.database);
267
+ }
268
+ return Array.from(slugs);
269
+ }
270
+
271
+ function harnessErrorMessage(error) {
272
+ if (!error || typeof error !== 'object') {
273
+ return String(error);
274
+ }
275
+ return error.message || error.reason || error.type || JSON.stringify(error);
276
+ }
277
+
278
+ function assertHarnessResult(result, route, databaseSlugs) {
279
+ const assertions = [];
280
+ if (result.tool_error) {
281
+ assertions.push({
282
+ ok: false,
283
+ code: 'tool_error',
284
+ message: `agent-browser ${result.tool_error.phase || 'command'} failed`,
285
+ details: result.tool_error,
286
+ });
287
+ }
288
+ if (result.mounted !== true) {
289
+ assertions.push({
290
+ ok: false,
291
+ code: 'not_mounted',
292
+ message: 'Harness did not report mounted === true.',
293
+ });
294
+ }
295
+ if (result.timed_out) {
296
+ assertions.push({
297
+ ok: false,
298
+ code: 'timeout',
299
+ message: 'Timed out waiting for window.__harness.mounted.',
300
+ });
301
+ }
302
+ for (const error of result.errors || []) {
303
+ assertions.push({
304
+ ok: false,
305
+ code: 'render_error',
306
+ message: harnessErrorMessage(error),
307
+ details: error,
308
+ });
309
+ }
310
+ for (const databaseSlug of databaseSlugs) {
311
+ const seen = (result.runtimeCalls || []).some(
312
+ (call) =>
313
+ call?.op === 'callTool' &&
314
+ call?.args?.name === 'notis-default-query' &&
315
+ call?.args?.arguments?.database_slug === databaseSlug,
316
+ );
317
+ if (!seen) {
318
+ assertions.push({
319
+ ok: false,
320
+ code: 'missing_database_query',
321
+ message: `Route "${route.slug}" did not call notis-default-query for "${databaseSlug}".`,
322
+ details: { databaseSlug },
323
+ });
324
+ }
325
+ }
326
+ return assertions;
327
+ }
328
+
329
+ function renderVerifyReport({ summary, results, noBrowser }) {
330
+ const lines = [
331
+ noBrowser
332
+ ? `Harness URLs ready for ${summary.total} route${summary.total === 1 ? '' : 's'}.`
333
+ : `Verified ${summary.total} route${summary.total === 1 ? '' : 's'}: ${summary.passed} passed, ${summary.failed} failed.`,
334
+ ];
335
+ for (const result of results) {
336
+ const marker = result.ok ? 'PASS' : result.status === 'manual' ? 'URL' : 'FAIL';
337
+ lines.push(`${marker.padEnd(4)} ${result.route.padEnd(18)} ${result.url}`);
338
+ for (const assertion of result.assertions || []) {
339
+ lines.push(` - ${assertion.message}`);
340
+ }
341
+ }
342
+ if (noBrowser) {
343
+ lines.push('', 'Pass --keep-open to leave the harness process running while you inspect the URLs.');
344
+ }
345
+ return lines.join('\n');
346
+ }
347
+
348
+ function buildManifestForDev(appConfig) {
349
+ const routes = Array.isArray(appConfig.routes) ? appConfig.routes : [];
350
+ return {
351
+ version: 1,
352
+ spec_version: 3,
353
+ app: {
354
+ name: appConfig.name,
355
+ description: appConfig.description || null,
356
+ icon: appConfig.icon || null,
357
+ },
358
+ routes: routes.map((route) => ({
359
+ path: route.path,
360
+ slug: route.slug,
361
+ name: route.name,
362
+ icon: route.icon || null,
363
+ parentSlug: route.parentSlug || null,
364
+ default: route.default || false,
365
+ export_name: route.exportName || route.export_name,
366
+ collection: route.collection || null,
367
+ })),
368
+ bundle: {
369
+ js: 'bundle/app.js',
370
+ css: 'bundle/app.css',
371
+ },
372
+ databases: appConfig.databases || [],
373
+ tools: appConfig.tools || [],
374
+ };
375
+ }
376
+
377
+ export function buildEnsureDevInstallArguments({ appConfig, manifest, linkedState }) {
378
+ const devSlug = buildDevInstallSlug(appConfig.name);
379
+ const arguments_ = {
380
+ dev_slug: devSlug,
381
+ name: appConfig.name,
382
+ manifest,
383
+ };
384
+ if (linkedState?.dev_app_id) {
385
+ arguments_.app_id = linkedState.dev_app_id;
386
+ }
387
+ return arguments_;
388
+ }
389
+
390
+ export async function ensureDevInstall({
391
+ ctx,
392
+ appConfig,
393
+ projectDir,
394
+ idempotencyKey,
395
+ runTool = runToolCommand,
396
+ }) {
397
+ if (!appConfig.name) {
398
+ throw usageError(`notis.config.ts in ${projectDir} must define a non-empty name.`);
399
+ }
400
+ const devSlug = buildDevInstallSlug(appConfig.name);
401
+ if (!devSlug) {
402
+ throw usageError(`notis.config.ts name in ${projectDir} must slugify to a non-empty value.`);
403
+ }
404
+
405
+ const manifest = buildManifestForDev(appConfig);
406
+ const linkedState = readLinkedState(projectDir);
407
+ const ensureArguments = buildEnsureDevInstallArguments({ appConfig, manifest, linkedState });
408
+ const ensureResult = await runTool({
409
+ runtime: ctx.runtime,
410
+ toolName: ENSURE_DEV_APP_INSTALLATION_TOOL,
411
+ arguments_: ensureArguments,
412
+ mutating: true,
413
+ idempotencyKey,
414
+ });
415
+ const appId = ensureResult.payload.app_id;
416
+ if (!appId) {
417
+ throw usageError('Dev installation tool did not return an app_id.');
418
+ }
419
+ return {
420
+ slug: ensureResult.payload.slug || devSlug,
421
+ devSlug,
422
+ name: appConfig.name,
423
+ appId,
424
+ projectDir,
425
+ manifest,
426
+ created: ensureResult.payload.created || false,
427
+ linkedAppId: linkedState?.app_id || null,
428
+ databaseMaterialization: ensureResult.payload.database_materialization || { created: [], unresolved: [] },
429
+ };
430
+ }
431
+
432
+ function databaseMaterializationWarnings(apps) {
433
+ const warnings = [];
434
+ for (const app of apps) {
435
+ const unresolved = app.databaseMaterialization?.unresolved || [];
436
+ if (!unresolved.length) {
437
+ continue;
438
+ }
439
+ warnings.push(
440
+ `${app.name}: database slug${unresolved.length === 1 ? '' : 's'} ${unresolved.join(', ')} ` +
441
+ 'could not be created because no store snapshot schema exists. Create them manually or link to a store-installed app.',
442
+ );
443
+ }
444
+ return warnings;
445
+ }
446
+
447
+ async function assertDirectDeployAccess(runtime, appId) {
448
+ const result = await runToolCommand({
449
+ runtime,
450
+ toolName: 'notis_list_apps',
451
+ });
452
+ const apps = result.payload.apps || [];
453
+ const hasAccess = apps.some((app) => (app.app_id || app.id) === appId);
454
+ if (!hasAccess) {
455
+ throw usageError(`Direct deploy requires access to app ${appId}.`);
456
+ }
457
+ }
458
+
46
459
  // ---------------------------------------------------------------------------
47
460
  // Handlers
48
461
  // ---------------------------------------------------------------------------
@@ -63,30 +476,46 @@ async function appsListHandler(ctx) {
63
476
 
64
477
  async function appsInitHandler(ctx) {
65
478
  const projectDir = resolveProjectDir(ctx.args.dir || ctx.args.name.toLowerCase().replace(/[^a-z0-9]+/g, '-'));
479
+ const fromSlug = ctx.options.from || null;
66
480
 
67
- scaffoldProject({ projectDir, appName: ctx.args.name });
481
+ scaffoldProject({ projectDir, appName: ctx.args.name, fromSlug });
68
482
 
69
483
  return ctx.output.emitSuccess({
70
484
  command: ctx.spec.command_path.join(' '),
71
- data: { project_dir: projectDir, app_name: ctx.args.name },
72
- humanSummary: `Scaffolded "${ctx.args.name}" in ${projectDir}`,
485
+ data: { project_dir: projectDir, app_name: ctx.args.name, scaffold: fromSlug },
486
+ humanSummary: fromSlug
487
+ ? `Scaffolded "${ctx.args.name}" from ${fromSlug} in ${projectDir}`
488
+ : `Scaffolded "${ctx.args.name}" in ${projectDir}`,
73
489
  hints: [
74
490
  { command: `cd ${projectDir} && npm install`, reason: 'Install dependencies' },
75
- { command: 'npm run dev', reason: 'Start the Next.js dev server' },
491
+ { command: `cd ${projectDir} && notis apps dev`, reason: 'Start the real-portal dev workflow' },
76
492
  ],
77
493
  });
78
494
  }
79
495
 
496
+ async function appsScaffoldsListHandler(ctx) {
497
+ const scaffolds = loadScaffoldCatalog();
498
+ return ctx.output.emitSuccess({
499
+ command: ctx.spec.command_path.join(' '),
500
+ data: { scaffolds },
501
+ humanSummary: scaffolds.length
502
+ ? `Found ${scaffolds.length} bundled scaffolds`
503
+ : 'No bundled scaffolds found. Run the CLI build step to generate the scaffold catalog.',
504
+ renderHuman: () => (scaffolds.length ? scaffoldsTable(scaffolds) : 'No bundled scaffolds found.'),
505
+ });
506
+ }
507
+
80
508
  async function appsCreateHandler(ctx) {
81
509
  const projectDir = ctx.args.dir ? resolveProjectDir(ctx.args.dir) : null;
510
+ const appConfig = projectDir ? await loadAppConfig(projectDir) : null;
82
511
  const idempotencyKey = nextIdempotencyKey(ctx.globalOptions);
83
512
  const result = await runToolCommand({
84
513
  runtime: ctx.runtime,
85
514
  toolName: 'notis_create_app',
86
515
  arguments_: {
87
516
  name: ctx.args.name,
88
- description: ctx.options.description || undefined,
89
- icon: ctx.options.icon || undefined,
517
+ description: appConfig?.description || undefined,
518
+ icon: appConfig?.icon || undefined,
90
519
  },
91
520
  mutating: true,
92
521
  idempotencyKey,
@@ -123,17 +552,198 @@ async function appsCreateHandler(ctx) {
123
552
  }
124
553
 
125
554
  async function appsDevHandler(ctx) {
126
- const projectDir = resolveProjectDir(ctx.args.dir || '.');
127
- const problems = detectProjectProblems(projectDir);
128
- if (problems.length) {
129
- throw usageError(`Project has problems:\n${problems.map((p) => ` - ${p}`).join('\n')}`);
555
+ const rootDir = resolveProjectDir(ctx.args.dir || '.');
556
+ const appDirs = discoverAppDirs(rootDir);
557
+
558
+ for (const projectDir of appDirs) {
559
+ const problems = detectProjectProblems(projectDir);
560
+ if (problems.length) {
561
+ throw usageError(`Project ${projectDir} has problems:\n${problems.map((p) => ` - ${p}`).join('\n')}`);
562
+ }
130
563
  }
131
564
 
132
- await runProjectScript({
133
- projectDir,
134
- scriptName: 'dev',
135
- env: { NOTIS_DEV: '1' },
565
+ const port = ctx.options.port ? Number.parseInt(ctx.options.port, 10) : DEFAULT_DEV_PORT;
566
+ if (!Number.isInteger(port) || port <= 0 || port > 65535) {
567
+ throw usageError('Port must be between 1 and 65535.');
568
+ }
569
+
570
+ const mode = getCliMode();
571
+ const identity = decodeJwtSub(ctx.runtime.jwt);
572
+ if (!identity) {
573
+ throw usageError('Could not determine the current user from the CLI auth token. Open the Notis desktop app, sign in, and retry.');
574
+ }
575
+ const apiBase = String(ctx.runtime.apiBase || '').replace(/\/$/, '');
576
+ const sessionId = randomUUID();
577
+
578
+ const candidates = [];
579
+ for (const projectDir of appDirs) {
580
+ const appConfig = await loadAppConfig(projectDir);
581
+ if (!appConfig.name) {
582
+ throw usageError(`notis.config.ts in ${projectDir} must define a non-empty name.`);
583
+ }
584
+ const devSlug = buildDevInstallSlug(appConfig.name);
585
+ if (!devSlug) {
586
+ throw usageError(`notis.config.ts name in ${projectDir} must slugify to a non-empty value.`);
587
+ }
588
+ candidates.push({ appConfig, devSlug, projectDir });
589
+ }
590
+
591
+ const seenSlugs = new Map();
592
+ for (const app of candidates) {
593
+ if (seenSlugs.has(app.devSlug)) {
594
+ throw usageError(
595
+ `Multiple apps slugify to "${app.devSlug}" (${seenSlugs.get(app.devSlug).projectDir} and ${app.projectDir}). Rename one in notis.config.ts.`,
596
+ );
597
+ }
598
+ seenSlugs.set(app.devSlug, app);
599
+ }
600
+
601
+ const registrationStartedAt = process.hrtime.bigint();
602
+ const registered = await Promise.all(
603
+ candidates.map(async ({ appConfig, devSlug, projectDir }) => {
604
+ const appStartedAt = process.hrtime.bigint();
605
+ try {
606
+ return await ensureDevInstall({
607
+ ctx,
608
+ appConfig,
609
+ projectDir,
610
+ idempotencyKey: nextDevInstallIdempotencyKey(ctx.globalOptions, devSlug),
611
+ });
612
+ } finally {
613
+ logAppsTiming('ensure-dev-install', {
614
+ slug: devSlug,
615
+ ms: timingMs(appStartedAt).toFixed(1),
616
+ });
617
+ }
618
+ }),
619
+ );
620
+ logAppsTiming('ensure-dev-install:all', {
621
+ apps: registered.length,
622
+ ms: timingMs(registrationStartedAt).toFixed(1),
623
+ });
624
+
625
+ const apps = registered.map((app) => {
626
+ const bundleBaseUrl = `http://127.0.0.1:${port}/a/${app.devSlug}`;
627
+ return {
628
+ ...app,
629
+ bundleBaseUrl,
630
+ appHref: buildAppHref({
631
+ appSlug: app.slug,
632
+ appId: app.appId,
633
+ manifest: app.manifest,
634
+ }),
635
+ };
136
636
  });
637
+ const developmentTabUrl = buildDevelopmentDesktopUrl(apps[0]?.appHref);
638
+ const warnings = databaseMaterializationWarnings(apps);
639
+
640
+ const devServer = await startAppDevServer({
641
+ apps: apps.map((app) => ({
642
+ slug: app.devSlug,
643
+ projectDir: app.projectDir,
644
+ appId: app.appId,
645
+ userId: identity,
646
+ })),
647
+ port,
648
+ });
649
+
650
+ try {
651
+ const now = new Date().toISOString();
652
+ upsertAppDevSessions(apps.map((app) => ({
653
+ sessionId,
654
+ userId: identity,
655
+ apiBase,
656
+ appId: app.appId,
657
+ devSlug: app.devSlug,
658
+ bundleBaseUrl: app.bundleBaseUrl,
659
+ projectDir: app.projectDir,
660
+ startedAt: now,
661
+ lastHeartbeatAt: now,
662
+ })));
663
+ } catch (error) {
664
+ try {
665
+ await devServer.close();
666
+ } catch {
667
+ // ignore cleanup failure on registry errors
668
+ }
669
+ throw error;
670
+ }
671
+
672
+ let heartbeatTimer = setInterval(() => {
673
+ try {
674
+ heartbeatAppDevSession(sessionId, new Date().toISOString());
675
+ } catch (error) {
676
+ const message = error instanceof Error ? error.message : String(error);
677
+ process.stderr.write(`[notis apps dev] heartbeat failed: ${message}\n`);
678
+ }
679
+ }, DEV_HEARTBEAT_INTERVAL_MS);
680
+
681
+ ctx.output.emitSuccess({
682
+ command: ctx.spec.command_path.join(' '),
683
+ data: {
684
+ mode,
685
+ api_base: apiBase,
686
+ development_url: developmentTabUrl,
687
+ session_id: sessionId,
688
+ identity,
689
+ apps: apps.map((app) => ({
690
+ slug: app.devSlug,
691
+ app_id: app.appId,
692
+ name: app.name,
693
+ project_dir: app.projectDir,
694
+ bundle_base_url: app.bundleBaseUrl,
695
+ app_href: app.appHref,
696
+ created: app.created,
697
+ linked_app_id: app.linkedAppId,
698
+ database_materialization: app.databaseMaterialization,
699
+ })),
700
+ },
701
+ warnings,
702
+ humanSummary: [
703
+ `Running apps dev against ${apiBase} as ${identity} (mode: ${mode})`,
704
+ '',
705
+ `Open in desktop: ${developmentTabUrl}`,
706
+ '',
707
+ ...apps.map((app) => ` ${app.name.padEnd(24)} ${app.bundleBaseUrl} -> ${app.appHref}`),
708
+ '',
709
+ 'Press Ctrl-C to stop.',
710
+ ].join('\n'),
711
+ });
712
+
713
+ if (!ctx.options.noOpen) {
714
+ openInBrowser(developmentTabUrl);
715
+ }
716
+
717
+ let shuttingDown = false;
718
+ const shutdown = async (signal) => {
719
+ if (shuttingDown) return;
720
+ shuttingDown = true;
721
+ process.stdout.write(`\n[notis apps dev] stopping (${signal})...\n`);
722
+ if (heartbeatTimer) {
723
+ clearInterval(heartbeatTimer);
724
+ heartbeatTimer = null;
725
+ }
726
+ try {
727
+ removeAppDevSession(sessionId);
728
+ } catch {
729
+ // ignore cleanup failures during shutdown
730
+ }
731
+ try {
732
+ await devServer.close();
733
+ } catch {
734
+ // ignore cleanup failures during shutdown
735
+ }
736
+ process.exit(EXIT_CODES.ok);
737
+ };
738
+
739
+ process.on('SIGINT', () => {
740
+ void shutdown('SIGINT');
741
+ });
742
+ process.on('SIGTERM', () => {
743
+ void shutdown('SIGTERM');
744
+ });
745
+
746
+ await new Promise(() => {});
137
747
 
138
748
  return EXIT_CODES.ok;
139
749
  }
@@ -154,25 +764,212 @@ async function appsBuildHandler(ctx) {
154
764
  });
155
765
  }
156
766
 
157
- async function appsPreviewHandler(ctx) {
767
+ async function appsVerifyHandler(ctx) {
158
768
  const projectDir = resolveProjectDir(ctx.args.dir || '.');
159
- const port = ctx.options.port ? Number.parseInt(ctx.options.port, 10) : 8787;
160
- if (!Number.isInteger(port) || port <= 0 || port > 65535) {
161
- throw usageError('Port must be between 1 and 65535.');
769
+ const problems = detectProjectProblems(projectDir);
770
+ if (problems.length) {
771
+ throw usageError(`Project has problems:\n${problems.map((p) => ` - ${p}`).join('\n')}`);
772
+ }
773
+
774
+ const mode = ctx.options.mode || 'stub';
775
+ if (!['stub', 'live'].includes(mode)) {
776
+ throw usageError('--mode must be either "stub" or "live".');
777
+ }
778
+
779
+ let linkedState = null;
780
+ if (mode === 'live') {
781
+ if (!ctx.runtime.jwt) {
782
+ throw usageError('Live verify mode requires CLI auth. Open the Notis desktop app, sign in, and retry.');
783
+ }
784
+ linkedState = readLinkedState(projectDir);
785
+ if (!linkedState?.app_id) {
786
+ throw usageError('Live verify mode requires a linked app. Run `notis apps link <app-id> .` first.');
787
+ }
788
+ }
789
+
790
+ if (!ctx.options.skipBuild) {
791
+ await buildArtifact(projectDir);
162
792
  }
163
793
 
164
794
  const manifest = readManifest(projectDir);
165
- const defaultRoute = (manifest.routes || []).find((r) => r.default) || manifest.routes?.[0];
166
- const url = `http://localhost:${port}${defaultRoute?.path || '/'}`;
795
+ const appConfig = await loadAppConfig(projectDir);
796
+ const listing = inspectListingReadiness(projectDir, appConfig);
797
+ if (listing.errors.length) {
798
+ throw usageError(`Listing metadata has problems:\n${listing.errors.map((error) => ` - ${error}`).join('\n')}`);
799
+ }
800
+ const listingWarnings = listing.warnings;
801
+ const routes = routeSelection(manifest, parseRouteSlugs(ctx.options.routes));
802
+ const port = parsePort(ctx.options.port) || await getAvailablePort();
803
+ const appSlug = slugify(appConfig.name || manifest.app?.name || 'app') || 'app';
804
+ const baseUrl = `http://127.0.0.1:${port}/a/${appSlug}`;
805
+ const browserSessionName = `notis-verify-${process.pid}`;
806
+ const noBrowser = ctx.options.browser === false;
807
+ const keepOpen = Boolean(ctx.options.keepOpen);
808
+ let devServer = null;
809
+ let browserTouched = false;
167
810
 
168
- ctx.output.emitSuccess({
169
- command: ctx.spec.command_path.join(' '),
170
- data: { port, url, routes: manifest.routes.length },
171
- humanSummary: `Preview at ${url} -- press Ctrl+C to stop.`,
172
- });
811
+ try {
812
+ devServer = await startAppDevServer({
813
+ apps: [{
814
+ slug: appSlug,
815
+ projectDir,
816
+ appId: linkedState?.app_id || 'harness-app',
817
+ }],
818
+ port,
819
+ watch: false,
820
+ harness: {
821
+ mode,
822
+ apiBase: ctx.runtime.apiBase,
823
+ jwt: mode === 'live' ? ctx.runtime.jwt : null,
824
+ },
825
+ log: () => {},
826
+ logError: (message) => process.stderr.write(`${message}\n`),
827
+ });
173
828
 
174
- await startPreviewServer({ projectDir, port });
175
- return EXIT_CODES.ok;
829
+ const urls = routes.map((route) => ({
830
+ route,
831
+ url: `${baseUrl}/harness?route=${encodeURIComponent(route.slug)}`,
832
+ }));
833
+
834
+ let results;
835
+ const warnings = [...listingWarnings];
836
+ if (noBrowser) {
837
+ results = urls.map(({ route, url }) => ({
838
+ route: route.slug,
839
+ path: route.path,
840
+ url,
841
+ ok: true,
842
+ status: 'manual',
843
+ mounted: null,
844
+ errors: [],
845
+ runtimeCalls: [],
846
+ assertions: [],
847
+ snapshot_path: null,
848
+ tool_error: null,
849
+ }));
850
+ } else if (!isAgentBrowserAvailable()) {
851
+ warnings.push('agent-browser is not available on PATH; rerun with --no-browser to inspect harness URLs manually.');
852
+ results = urls.map(({ route, url }) => {
853
+ const toolError = {
854
+ phase: 'available',
855
+ message: 'agent-browser is not available on PATH',
856
+ };
857
+ const result = {
858
+ route: route.slug,
859
+ path: route.path,
860
+ url,
861
+ mounted: false,
862
+ renderStarted: false,
863
+ errors: [],
864
+ runtimeCalls: [],
865
+ snapshotPath: null,
866
+ tool_error: toolError,
867
+ };
868
+ const assertions = assertHarnessResult(
869
+ result,
870
+ route,
871
+ declaredDatabaseSlugs(appConfig, manifest, route),
872
+ );
873
+ return {
874
+ ...result,
875
+ ok: false,
876
+ status: 'failed',
877
+ assertions,
878
+ snapshot_path: null,
879
+ };
880
+ });
881
+ } else {
882
+ browserTouched = true;
883
+ results = [];
884
+ for (const { route, url } of urls) {
885
+ const snapshotPath = join(projectDir, '.notis', 'output', '.harness', `${route.slug}.snapshot.txt`);
886
+ const result = await runHarnessRoute({
887
+ url,
888
+ sessionName: browserSessionName,
889
+ timeoutMs: Number.parseInt(ctx.globalOptions.timeoutMs || '', 10) || 10_000,
890
+ snapshotPath,
891
+ });
892
+ const assertions = assertHarnessResult(
893
+ result,
894
+ route,
895
+ declaredDatabaseSlugs(appConfig, manifest, route),
896
+ );
897
+ results.push({
898
+ route: route.slug,
899
+ path: route.path,
900
+ url,
901
+ ok: assertions.length === 0,
902
+ status: assertions.length === 0 ? 'passed' : 'failed',
903
+ mounted: result.mounted,
904
+ renderStarted: result.renderStarted,
905
+ errors: result.errors,
906
+ runtimeCalls: result.runtimeCalls,
907
+ assertions,
908
+ snapshot_path: result.snapshotPath,
909
+ timed_out: Boolean(result.timed_out),
910
+ tool_error: result.tool_error,
911
+ });
912
+ }
913
+ }
914
+
915
+ const summary = {
916
+ total: results.length,
917
+ passed: results.filter((result) => result.status === 'passed').length,
918
+ failed: results.filter((result) => result.status === 'failed').length,
919
+ manual: results.filter((result) => result.status === 'manual').length,
920
+ };
921
+ const overallOk = summary.failed === 0;
922
+ const exitCode = overallOk ? EXIT_CODES.ok : EXIT_CODES.unexpected;
923
+ const data = {
924
+ status: overallOk ? (summary.manual ? 'manual' : 'passed') : 'failed',
925
+ project_dir: projectDir,
926
+ app_slug: appSlug,
927
+ mode,
928
+ browser_session: noBrowser ? null : browserSessionName,
929
+ server: {
930
+ port,
931
+ base_url: baseUrl,
932
+ urls: urls.map(({ route, url }) => ({ route: route.slug, url })),
933
+ },
934
+ summary,
935
+ results,
936
+ };
937
+
938
+ ctx.output.emitSuccess({
939
+ ok: overallOk,
940
+ command: ctx.spec.command_path.join(' '),
941
+ data,
942
+ humanSummary: overallOk
943
+ ? (summary.manual ? `Harness URLs ready for ${summary.manual} routes.` : `Verified ${summary.passed} routes successfully.`)
944
+ : `Verification failed for ${summary.failed} routes.`,
945
+ warnings,
946
+ renderHuman: () => renderVerifyReport({ summary, results, noBrowser }),
947
+ });
948
+
949
+ if (keepOpen) {
950
+ process.stderr.write(`[notis apps verify] harness open at ${baseUrl}. Press Ctrl-C to stop.\n`);
951
+ await new Promise(() => {});
952
+ }
953
+
954
+ return exitCode;
955
+ } finally {
956
+ if (!keepOpen) {
957
+ if (browserTouched) {
958
+ try {
959
+ await closeAgentBrowserSession(browserSessionName);
960
+ } catch {
961
+ // Ignore browser cleanup failures.
962
+ }
963
+ }
964
+ if (devServer) {
965
+ try {
966
+ await devServer.close();
967
+ } catch {
968
+ // Ignore server cleanup failures.
969
+ }
970
+ }
971
+ }
972
+ }
176
973
  }
177
974
 
178
975
  async function appsLinkHandler(ctx) {
@@ -194,17 +991,82 @@ async function appsLinkHandler(ctx) {
194
991
  });
195
992
  }
196
993
 
994
+ async function appsPullHandler(ctx) {
995
+ const appId = ctx.args.appId;
996
+ const result = await runToolCommand({
997
+ runtime: ctx.runtime,
998
+ toolName: GET_APP_TOOL,
999
+ arguments_: { app_id: appId },
1000
+ });
1001
+ const app = result.payload?.app || {};
1002
+ const defaultDir = app.slug || slugify(app.name) || appId;
1003
+ const targetDir = resolveProjectDir(ctx.args.dir || defaultDir);
1004
+ const version = ctx.options.version || 'latest';
1005
+
1006
+ const pulled = await pullAppSource({
1007
+ apiBase: ctx.runtime.apiBase,
1008
+ jwt: ctx.runtime.jwt,
1009
+ appId,
1010
+ targetDir,
1011
+ version,
1012
+ force: Boolean(ctx.options.force),
1013
+ });
1014
+
1015
+ const versionLabel = pulled.version === 'latest' ? 'latest version' : `v${pulled.version}`;
1016
+ return ctx.output.emitSuccess({
1017
+ command: ctx.spec.command_path.join(' '),
1018
+ data: {
1019
+ app_id: appId,
1020
+ project_dir: pulled.projectDir,
1021
+ version: pulled.version,
1022
+ },
1023
+ humanSummary: `Pulled ${versionLabel} to ${pulled.projectDir}. Run \`cd ${pulled.projectDir} && npm install && notis apps dev\` to start editing.`,
1024
+ });
1025
+ }
1026
+
1027
+ function updateLinkedDeployState(projectDir, linkedState, appId, version) {
1028
+ if (!linkedState || linkedState.app_id !== appId || !Number.isFinite(version)) {
1029
+ return;
1030
+ }
1031
+ writeLinkedState(projectDir, {
1032
+ ...linkedState,
1033
+ app_id: appId,
1034
+ version,
1035
+ linked_at: linkedState.linked_at || new Date().toISOString(),
1036
+ deployed_at: new Date().toISOString(),
1037
+ });
1038
+ }
1039
+
197
1040
  async function appsDeployHandler(ctx) {
198
1041
  const projectDir = resolveProjectDir(ctx.args.dir || '.');
199
1042
  const appId = requireLinkedAppId(projectDir, ctx.options.appId);
200
1043
  const idempotencyKey = nextIdempotencyKey(ctx.globalOptions);
1044
+ const linkedState = readLinkedState(projectDir);
1045
+ const baseVersion = linkedState?.app_id === appId && Number.isFinite(linkedState?.version)
1046
+ ? linkedState.version
1047
+ : undefined;
201
1048
 
202
1049
  // Build if needed
203
1050
  if (!ctx.options.skipBuild) {
204
1051
  await buildArtifact(projectDir);
205
1052
  }
206
1053
 
1054
+ // Direct deploy mode: upload to Supabase storage directly
1055
+ if (ctx.options.direct) {
1056
+ await assertDirectDeployAccess(ctx.runtime, appId);
1057
+ const { version } = await directDeploy(projectDir, appId);
1058
+ updateLinkedDeployState(projectDir, linkedState, appId, version);
1059
+ return ctx.output.emitSuccess({
1060
+ command: ctx.spec.command_path.join(' '),
1061
+ data: { app_id: appId, version, mode: 'direct' },
1062
+ humanSummary: `Deployed to app ${appId} (version ${version}) via direct upload`,
1063
+ meta: { mutating: true },
1064
+ });
1065
+ }
1066
+
1067
+ // Standard deploy via backend server, with auto-fallback to direct
207
1068
  const files = collectArtifactFiles(projectDir);
1069
+ const sourceFiles = collectSourceFiles(projectDir);
208
1070
  const manifest = readManifest(projectDir);
209
1071
 
210
1072
  let result;
@@ -215,7 +1077,9 @@ async function appsDeployHandler(ctx) {
215
1077
  arguments_: {
216
1078
  app_id: appId,
217
1079
  files,
1080
+ source_files: sourceFiles,
218
1081
  manifest,
1082
+ ...(baseVersion !== undefined ? { base_version: baseVersion } : {}),
219
1083
  },
220
1084
  mutating: true,
221
1085
  idempotencyKey,
@@ -224,9 +1088,44 @@ async function appsDeployHandler(ctx) {
224
1088
  if (error.code === 'conflict') {
225
1089
  throw toolConflictToError(error.details, 'Deploy conflict');
226
1090
  }
1091
+
1092
+ // Auto-fallback to direct deploy on network errors
1093
+ const isNetworkError = error.code === 'network_error'
1094
+ || error.code === 'network_timeout'
1095
+ || (error.message && /fetch failed|ECONNREFUSED|network/i.test(error.message));
1096
+
1097
+ if (isNetworkError) {
1098
+ try {
1099
+ await assertDirectDeployAccess(ctx.runtime, appId);
1100
+ } catch (accessError) {
1101
+ throw usageError(
1102
+ `Backend deploy failed (${error.message}) and direct fallback was blocked because app access ` +
1103
+ `could not be verified (${accessError.message}).`,
1104
+ );
1105
+ }
1106
+
1107
+ try {
1108
+ const { version } = await directDeploy(projectDir, appId);
1109
+ updateLinkedDeployState(projectDir, linkedState, appId, version);
1110
+ return ctx.output.emitSuccess({
1111
+ command: ctx.spec.command_path.join(' '),
1112
+ data: { app_id: appId, version, mode: 'direct-fallback' },
1113
+ humanSummary: `Backend unavailable -- deployed to app ${appId} (version ${version}) via direct upload`,
1114
+ warnings: ['Backend server was unreachable. Used direct Supabase upload as fallback.'],
1115
+ meta: { mutating: true },
1116
+ });
1117
+ } catch (directError) {
1118
+ throw usageError(
1119
+ `Backend deploy failed (${error.message}) and direct fallback also failed (${directError.message}). ` +
1120
+ 'Check server/.env for Supabase credentials or start the backend server.',
1121
+ );
1122
+ }
1123
+ }
1124
+
227
1125
  throw error;
228
1126
  }
229
1127
 
1128
+ updateLinkedDeployState(projectDir, linkedState, appId, Number(result.payload.version));
230
1129
  return ctx.output.emitSuccess({
231
1130
  command: ctx.spec.command_path.join(' '),
232
1131
  data: {
@@ -242,23 +1141,29 @@ async function appsDeployHandler(ctx) {
242
1141
  async function appsDoctorHandler(ctx) {
243
1142
  const projectDir = resolveProjectDir(ctx.args.dir || '.');
244
1143
  const problems = detectProjectProblems(projectDir);
245
- const warnings = detectProjectWarnings(projectDir);
246
1144
 
247
1145
  let appConfig = null;
248
1146
  try {
249
1147
  appConfig = await loadAppConfig(projectDir);
250
- const configWarnings = detectProjectWarnings(projectDir, appConfig);
251
- warnings.push(...configWarnings.filter((w) => !warnings.includes(w)));
252
1148
  } catch {
253
1149
  problems.push('Failed to load notis.config.ts');
254
1150
  }
1151
+ const warnings = detectProjectWarnings(projectDir, appConfig);
1152
+ let listing = null;
1153
+ if (appConfig) {
1154
+ try {
1155
+ listing = inspectListingReadiness(projectDir, appConfig);
1156
+ } catch (error) {
1157
+ warnings.push(error instanceof Error ? error.message : String(error));
1158
+ }
1159
+ }
255
1160
 
256
1161
  const linkedState = readLinkedState(projectDir);
257
1162
  const status = problems.length ? 'unhealthy' : warnings.length ? 'warnings' : 'healthy';
258
1163
 
259
1164
  return ctx.output.emitSuccess({
260
1165
  command: ctx.spec.command_path.join(' '),
261
- data: { status, problems, warnings, linked: linkedState, config: appConfig },
1166
+ data: { status, problems, warnings, linked: linkedState, config: appConfig, listing },
262
1167
  humanSummary: problems.length
263
1168
  ? `Found ${problems.length} problems:\n${problems.map((p) => ` - ${p}`).join('\n')}`
264
1169
  : warnings.length
@@ -286,20 +1191,40 @@ export const appsCommandSpecs = [
286
1191
  {
287
1192
  command_path: ['apps', 'init'],
288
1193
  summary: 'Scaffold a new Notis app project.',
289
- when_to_use: 'Start a new Notis app. Creates a Next.js project with @notis/sdk pre-configured.',
1194
+ when_to_use: 'Start a new Notis app. Use --from with a bundled scaffold when one is close to the desired app; otherwise creates the bare Vite + React project.',
290
1195
  args_schema: {
291
1196
  arguments: [
292
1197
  { token: '<name>', description: 'Display name for the app.' },
293
1198
  { token: '[dir]', key: 'dir', description: 'Target directory (defaults to kebab-case of name).' },
294
1199
  ],
295
- options: [],
1200
+ options: [
1201
+ { flags: '--from <slug>', description: 'Start from a bundled scaffold listed by `notis apps scaffolds list`.' },
1202
+ ],
296
1203
  },
297
- examples: ['notis apps init "Mind the Flo"', 'notis apps init "My App" ./my-app'],
1204
+ examples: [
1205
+ 'notis apps scaffolds list',
1206
+ 'notis apps init "Mind the Flo"',
1207
+ 'notis apps init "My CRM" --from notis-database',
1208
+ 'notis apps init "My App" ./my-app',
1209
+ ],
298
1210
  mutates: true,
299
1211
  idempotent: false,
1212
+ require_auth: false,
300
1213
  backend_call: { type: 'local', name: 'scaffold_project' },
301
1214
  handler: appsInitHandler,
302
1215
  },
1216
+ {
1217
+ command_path: ['apps', 'scaffolds', 'list'],
1218
+ summary: 'List bundled Notis app scaffolds.',
1219
+ when_to_use: 'Discover the fixed scaffold catalog shipped with the CLI before starting a new app.',
1220
+ args_schema: { arguments: [], options: [] },
1221
+ examples: ['notis apps scaffolds list', 'notis apps init "My App" --from notis-database'],
1222
+ mutates: false,
1223
+ idempotent: true,
1224
+ require_auth: false,
1225
+ backend_call: { type: 'local', name: 'list_scaffolds' },
1226
+ handler: appsScaffoldsListHandler,
1227
+ },
303
1228
  {
304
1229
  command_path: ['apps', 'create'],
305
1230
  summary: 'Create a new remote Notis app and optionally link a local project to it.',
@@ -309,14 +1234,11 @@ export const appsCommandSpecs = [
309
1234
  { token: '<name>', description: 'Display name for the remote app.' },
310
1235
  { token: '[dir]', key: 'dir', description: 'Project directory to link after creation (default: do not link).' },
311
1236
  ],
312
- options: [
313
- { flags: '--description <text>', description: 'Optional app description.' },
314
- { flags: '--icon <lucide:icon>', description: 'Optional Lucide icon, for example lucide:dices.' },
315
- ],
1237
+ options: [],
316
1238
  },
317
1239
  examples: [
318
1240
  'notis apps create "My App"',
319
- 'notis apps create "My App" . --description "Internal tool" --icon lucide:layout-dashboard',
1241
+ 'notis apps create "My App" .',
320
1242
  ],
321
1243
  mutates: true,
322
1244
  idempotent: false,
@@ -325,24 +1247,33 @@ export const appsCommandSpecs = [
325
1247
  },
326
1248
  {
327
1249
  command_path: ['apps', 'dev'],
328
- summary: 'Run the Next.js dev server for local development.',
329
- when_to_use: 'Iterate on app UI with hot reload. SDK hooks return mock data.',
1250
+ summary: 'Develop Notis apps inside the Electron desktop Portal with automatic local bundle reloads.',
1251
+ when_to_use:
1252
+ '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 to the local development app.',
330
1253
  args_schema: {
331
1254
  arguments: [
332
- { token: '[dir]', key: 'dir', description: 'Project directory (default: current dir).' },
1255
+ { token: '[dir]', key: 'dir', description: 'Project directory or monorepo root (default: current dir).' },
1256
+ ],
1257
+ options: [
1258
+ { flags: '--port <number>', description: `Local bundle server port (default: ${DEFAULT_DEV_PORT}).` },
1259
+ { flags: '--no-open', description: 'Do not auto-open the desktop Portal local development app.' },
333
1260
  ],
334
- options: [],
335
1261
  },
336
- examples: ['notis apps dev', 'notis apps dev ./my-app'],
337
- mutates: false,
1262
+ examples: [
1263
+ 'notis apps dev',
1264
+ 'notis apps dev ./my-app',
1265
+ 'notis apps dev ./workspace --port 5200',
1266
+ ],
1267
+ mutates: true,
338
1268
  idempotent: true,
339
- backend_call: { type: 'local', name: 'next_dev' },
1269
+ require_auth: true,
1270
+ backend_call: { type: 'tool', name: ENSURE_DEV_APP_INSTALLATION_TOOL },
340
1271
  handler: appsDevHandler,
341
1272
  },
342
1273
  {
343
1274
  command_path: ['apps', 'build'],
344
1275
  summary: 'Build and package the app into .notis/output/.',
345
- when_to_use: 'Prepare the app for preview or deployment.',
1276
+ when_to_use: 'Prepare the app for verification or deployment.',
346
1277
  args_schema: {
347
1278
  arguments: [
348
1279
  { token: '[dir]', key: 'dir', description: 'Project directory (default: current dir).' },
@@ -352,26 +1283,40 @@ export const appsCommandSpecs = [
352
1283
  examples: ['notis apps build', 'notis apps build ./my-app'],
353
1284
  mutates: true,
354
1285
  idempotent: true,
1286
+ require_auth: false,
355
1287
  backend_call: { type: 'local', name: 'next_build_and_package' },
356
1288
  handler: appsBuildHandler,
357
1289
  },
358
1290
  {
359
- command_path: ['apps', 'preview'],
360
- summary: 'Serve the built artifact locally for testing.',
361
- when_to_use: 'Smoke-test the exact artifact that will be deployed. Databases use seed data.',
1291
+ command_path: ['apps', 'verify'],
1292
+ summary: 'Headless render smoke each route in a stub-runtime harness.',
1293
+ when_to_use:
1294
+ 'After notis apps build, before deploy. Catches render-time crashes ' +
1295
+ 'and missing runtime calls that the build step cannot detect.',
362
1296
  args_schema: {
363
1297
  arguments: [
364
1298
  { token: '[dir]', key: 'dir', description: 'Project directory (default: current dir).' },
365
1299
  ],
366
1300
  options: [
367
- { flags: '--port <number>', description: 'Server port (default: 8787).' },
1301
+ { flags: '--routes <slugs>', description: 'Comma-separated route slugs. Default: every route in manifest.' },
1302
+ { flags: '--port <n>', description: 'Loopback port. Default: auto-pick.' },
1303
+ { flags: '--skip-build', description: 'Skip notis apps build; reuse existing .notis/output/.' },
1304
+ { flags: '--mode <mode>', description: 'stub | live. Default stub. Live posts to /portal_views/runtime_query with the CLI JWT.' },
1305
+ { flags: '--no-browser', description: 'Start the harness server and print URLs; do not drive agent-browser.' },
1306
+ { flags: '--keep-open', description: 'Leave server + browser session running after report (for manual triage).' },
368
1307
  ],
369
1308
  },
370
- examples: ['notis apps preview', 'notis apps preview --port 3000'],
1309
+ examples: [
1310
+ 'notis apps verify',
1311
+ 'notis apps verify --routes notes',
1312
+ 'notis apps verify --mode live',
1313
+ 'notis apps verify --no-browser # start the harness, drive agent-browser yourself',
1314
+ ],
371
1315
  mutates: false,
372
1316
  idempotent: true,
373
- backend_call: { type: 'local', name: 'preview_server' },
374
- handler: appsPreviewHandler,
1317
+ require_auth: false,
1318
+ backend_call: { type: 'local', name: 'verify_harness' },
1319
+ handler: appsVerifyHandler,
375
1320
  },
376
1321
  {
377
1322
  command_path: ['apps', 'link'],
@@ -387,9 +1332,32 @@ export const appsCommandSpecs = [
387
1332
  examples: ['notis apps link abc123', 'notis apps link abc123 ./my-app'],
388
1333
  mutates: true,
389
1334
  idempotent: true,
1335
+ require_auth: false,
390
1336
  backend_call: { type: 'local', name: 'write_link_state' },
391
1337
  handler: appsLinkHandler,
392
1338
  },
1339
+ {
1340
+ command_path: ['apps', 'pull'],
1341
+ summary: 'Download a Notis app source snapshot into a local project folder.',
1342
+ when_to_use:
1343
+ 'Edit an installed app locally. Pulls the persisted source, links the directory to the app/version, then continue with npm install, notis apps dev, notis apps build, and notis apps deploy.',
1344
+ args_schema: {
1345
+ arguments: [
1346
+ { token: '<app-id>', description: 'Remote app ID to pull.' },
1347
+ { token: '[dir]', key: 'dir', description: 'Target directory (defaults to the app slug).' },
1348
+ ],
1349
+ options: [
1350
+ { flags: '--force', description: 'Overwrite a non-empty target directory.' },
1351
+ { flags: '--version <n>', description: 'Pull a specific app source version (default: latest).' },
1352
+ ],
1353
+ },
1354
+ examples: ['notis apps pull abc123', 'notis apps pull abc123 ./my-app --force'],
1355
+ mutates: true,
1356
+ idempotent: true,
1357
+ require_auth: true,
1358
+ backend_call: { type: 'http', name: 'portal_apps/source' },
1359
+ handler: appsPullHandler,
1360
+ },
393
1361
  {
394
1362
  command_path: ['apps', 'deploy'],
395
1363
  summary: 'Build and upload the app to the linked Notis app.',
@@ -402,9 +1370,10 @@ export const appsCommandSpecs = [
402
1370
  options: [
403
1371
  { flags: '--app-id <id>', description: 'Override linked app ID.' },
404
1372
  { flags: '--skip-build', description: 'Skip the build step (use existing .notis/output/).' },
1373
+ { flags: '--direct', description: 'Upload directly to Supabase storage, bypassing the backend server. Auto-fallback on network errors.' },
405
1374
  ],
406
1375
  },
407
- examples: ['notis apps deploy', 'notis apps deploy --skip-build', 'notis apps deploy --app-id abc123'],
1376
+ examples: ['notis apps deploy', 'notis apps deploy --skip-build', 'notis apps deploy --app-id abc123', 'notis apps deploy --direct'],
408
1377
  mutates: true,
409
1378
  idempotent: true,
410
1379
  backend_call: { type: 'tool', name: 'notis_save_app_files' },
@@ -423,6 +1392,7 @@ export const appsCommandSpecs = [
423
1392
  examples: ['notis apps doctor', 'notis apps doctor ./my-app'],
424
1393
  mutates: false,
425
1394
  idempotent: true,
1395
+ require_auth: false,
426
1396
  backend_call: { type: 'local', name: 'project_health_check' },
427
1397
  handler: appsDoctorHandler,
428
1398
  },