@notis_ai/cli 0.2.1 → 0.2.3

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 (117) hide show
  1. package/README.md +106 -170
  2. package/config/notis_app_boundary_rules.json +50 -0
  3. package/dist/scaffolds/notis-database/app/globals.css +44 -0
  4. package/dist/scaffolds/notis-database/app/layout.tsx +6 -0
  5. package/dist/scaffolds/notis-database/app/page.tsx +1091 -0
  6. package/dist/scaffolds/notis-database/components/ui/badge.tsx +28 -0
  7. package/dist/scaffolds/notis-database/components/ui/button.tsx +53 -0
  8. package/dist/scaffolds/notis-database/components/ui/card.tsx +56 -0
  9. package/dist/scaffolds/notis-database/components/ui/table.tsx +120 -0
  10. package/dist/scaffolds/notis-database/components.json +20 -0
  11. package/dist/scaffolds/notis-database/index.html +12 -0
  12. package/dist/scaffolds/notis-database/lib/types.ts +134 -0
  13. package/dist/scaffolds/notis-database/lib/utils.ts +6 -0
  14. package/dist/scaffolds/notis-database/metadata/cover.png +0 -0
  15. package/dist/scaffolds/notis-database/metadata/screenshot-1.png +0 -0
  16. package/dist/scaffolds/notis-database/metadata/screenshot-2.png +0 -0
  17. package/dist/scaffolds/notis-database/metadata/screenshot-3.png +0 -0
  18. package/dist/scaffolds/notis-database/notis.config.ts +27 -0
  19. package/dist/scaffolds/notis-database/package.json +31 -0
  20. package/dist/scaffolds/notis-database/postcss.config.mjs +8 -0
  21. package/dist/scaffolds/notis-database/src/dev-main.tsx +23 -0
  22. package/dist/scaffolds/notis-database/src/mock-runtime.ts +557 -0
  23. package/dist/scaffolds/notis-database/tailwind.config.ts +59 -0
  24. package/dist/scaffolds/notis-database/tsconfig.json +23 -0
  25. package/dist/scaffolds/notis-database/vite.config.ts +22 -0
  26. package/dist/scaffolds/notis-notes/app/globals.css +3 -0
  27. package/dist/scaffolds/notis-notes/app/layout.tsx +6 -0
  28. package/dist/scaffolds/notis-notes/app/page.tsx +2168 -0
  29. package/dist/scaffolds/notis-notes/components/ui/badge.tsx +28 -0
  30. package/dist/scaffolds/notis-notes/components/ui/button.tsx +53 -0
  31. package/dist/scaffolds/notis-notes/components/ui/card.tsx +56 -0
  32. package/dist/scaffolds/notis-notes/components.json +20 -0
  33. package/dist/scaffolds/notis-notes/lib/utils.ts +6 -0
  34. package/dist/scaffolds/notis-notes/metadata/cover.png +0 -0
  35. package/dist/scaffolds/notis-notes/metadata/screenshot-1.png +0 -0
  36. package/dist/scaffolds/notis-notes/metadata/screenshot-2.png +0 -0
  37. package/dist/scaffolds/notis-notes/metadata/screenshot-3.png +0 -0
  38. package/dist/scaffolds/notis-notes/notis.config.ts +36 -0
  39. package/dist/scaffolds/notis-notes/package.json +31 -0
  40. package/dist/scaffolds/notis-notes/postcss.config.mjs +8 -0
  41. package/dist/scaffolds/notis-notes/tailwind.config.ts +58 -0
  42. package/dist/scaffolds/notis-notes/tsconfig.json +23 -0
  43. package/dist/scaffolds/notis-notes/vite.config.ts +10 -0
  44. package/dist/scaffolds/notis-random/README.md +33 -0
  45. package/dist/scaffolds/notis-random/app/globals.css +11 -0
  46. package/dist/scaffolds/notis-random/app/history/page.tsx +66 -0
  47. package/dist/scaffolds/notis-random/app/layout.tsx +7 -0
  48. package/dist/scaffolds/notis-random/app/page.tsx +222 -0
  49. package/dist/scaffolds/notis-random/index.html +12 -0
  50. package/dist/scaffolds/notis-random/lib/notis-tools.ts +109 -0
  51. package/dist/scaffolds/notis-random/lib/rng.ts +42 -0
  52. package/dist/scaffolds/notis-random/lib/roll-record.ts +102 -0
  53. package/dist/scaffolds/notis-random/lib/utils.ts +25 -0
  54. package/dist/scaffolds/notis-random/metadata/cover.png +0 -0
  55. package/dist/scaffolds/notis-random/metadata/screenshot-1.png +0 -0
  56. package/dist/scaffolds/notis-random/metadata/screenshot-2.png +0 -0
  57. package/dist/scaffolds/notis-random/metadata/screenshot-3.png +0 -0
  58. package/dist/scaffolds/notis-random/notis.config.ts +43 -0
  59. package/dist/scaffolds/notis-random/package.json +32 -0
  60. package/dist/scaffolds/notis-random/postcss.config.mjs +6 -0
  61. package/dist/scaffolds/notis-random/src/dev-main.tsx +70 -0
  62. package/dist/scaffolds/notis-random/src/mock-runtime.ts +128 -0
  63. package/dist/scaffolds/notis-random/tailwind.config.ts +43 -0
  64. package/dist/scaffolds/notis-random/tsconfig.json +23 -0
  65. package/dist/scaffolds/notis-random/vite.config.ts +11 -0
  66. package/dist/scaffolds.json +36 -0
  67. package/package.json +8 -3
  68. package/skills/notis-apps/SKILL.md +162 -0
  69. package/skills/notis-apps/cli.md +208 -0
  70. package/skills/notis-cli/SKILL.md +260 -0
  71. package/skills/notis-query/cli.md +39 -0
  72. package/src/cli.js +31 -3
  73. package/src/command-specs/apps.js +248 -56
  74. package/src/command-specs/helpers.js +72 -104
  75. package/src/command-specs/index.js +0 -6
  76. package/src/command-specs/meta.js +14 -13
  77. package/src/command-specs/tools.js +196 -115
  78. package/src/runtime/app-boundary-validator.js +65 -14
  79. package/src/runtime/app-dev-server.js +32 -4
  80. package/src/runtime/app-platform.js +404 -24
  81. package/src/runtime/profiles.js +12 -6
  82. package/src/runtime/transport.js +124 -39
  83. package/template/.harness/index.html.tmpl +1 -50
  84. package/template/app/page.tsx +41 -6
  85. package/template/metadata/cover.png +0 -0
  86. package/template/metadata/screenshot-1.png +0 -0
  87. package/template/metadata/screenshot-2.png +0 -0
  88. package/template/metadata/screenshot-3.png +0 -0
  89. package/template/notis.config.ts +10 -6
  90. package/template/package.json +2 -2
  91. package/template/packages/{notis-sdk → sdk}/package.json +6 -0
  92. package/template/packages/{notis-sdk → sdk}/src/components/MultiSelectActionBar.tsx +2 -1
  93. package/template/packages/{notis-sdk → sdk}/src/components/MultiSelectCheckbox.tsx +2 -2
  94. package/template/packages/{notis-sdk → sdk}/src/components/MultiSelectDragOverlay.tsx +2 -2
  95. package/template/packages/{notis-sdk → sdk}/src/config.ts +1 -1
  96. package/template/packages/{notis-sdk → sdk}/src/hooks/useDatabase.ts +1 -13
  97. package/template/packages/{notis-sdk → sdk}/src/hooks/useMultiSelect.ts +4 -3
  98. package/template/packages/{notis-sdk → sdk}/src/hooks/useNotis.ts +4 -3
  99. package/template/packages/{notis-sdk → sdk}/src/hooks/useNotisNavigation.ts +3 -3
  100. package/template/packages/{notis-sdk → sdk}/src/hooks/useTool.ts +22 -7
  101. package/template/packages/{notis-sdk → sdk}/src/hooks/useUpsertDocument.ts +0 -8
  102. package/template/packages/{notis-sdk → sdk}/src/index.ts +2 -15
  103. package/template/packages/{notis-sdk → sdk}/src/provider.tsx +1 -1
  104. package/template/packages/{notis-sdk → sdk}/src/runtime.ts +5 -26
  105. package/src/command-specs/auth.js +0 -178
  106. package/src/command-specs/db.js +0 -163
  107. package/template/packages/notis-sdk/src/helpers.ts +0 -131
  108. package/template/packages/notis-sdk/src/hooks/useAppState.ts +0 -50
  109. package/template/packages/notis-sdk/src/hooks/useCollectionItem.ts +0 -58
  110. package/template/packages/notis-sdk/src/hooks/useDocument.ts +0 -61
  111. /package/template/packages/{notis-sdk → sdk}/src/hooks/useBackend.ts +0 -0
  112. /package/template/packages/{notis-sdk → sdk}/src/hooks/useTools.ts +0 -0
  113. /package/template/packages/{notis-sdk → sdk}/src/hooks/useTopBarSearch.ts +0 -0
  114. /package/template/packages/{notis-sdk → sdk}/src/styles.css +0 -0
  115. /package/template/packages/{notis-sdk → sdk}/src/ui.ts +0 -0
  116. /package/template/packages/{notis-sdk → sdk}/src/vite.ts +0 -0
  117. /package/template/packages/{notis-sdk → sdk}/tsconfig.json +0 -0
@@ -25,6 +25,8 @@ import {
25
25
  writeLinkedState,
26
26
  requireLinkedAppId,
27
27
  scaffoldProject,
28
+ loadScaffoldCatalog,
29
+ inspectListingReadiness,
28
30
  collectArtifactFiles,
29
31
  collectSourceFiles,
30
32
  directDeploy,
@@ -44,6 +46,7 @@ import {
44
46
  import { getAvailablePort } from '../runtime/ports.js';
45
47
  import { getCliMode } from '../runtime/cli-mode.js';
46
48
  import {
49
+ localNotisToolSlug,
47
50
  nextIdempotencyKey,
48
51
  runToolCommand,
49
52
  toolConflictToError,
@@ -52,8 +55,11 @@ import {
52
55
  const DEFAULT_DEV_PORT = 5173;
53
56
  const DEV_HEARTBEAT_INTERVAL_MS = 10_000;
54
57
  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';
58
+ const ENSURE_DEV_APP_INSTALLATION_TOOL = 'LOCAL_NOTIS_ENSURE_DEV_APP_INSTALLATION';
59
+ const GET_APP_TOOL = 'LOCAL_NOTIS_GET_APP';
60
+ const LIST_APPS_TOOL = 'LOCAL_NOTIS_LIST_APPS';
61
+ const CREATE_APP_TOOL = 'LOCAL_NOTIS_CREATE_APP';
62
+ const SAVE_APP_FILES_TOOL = 'LOCAL_NOTIS_SAVE_APP_FILES';
57
63
 
58
64
  // ---------------------------------------------------------------------------
59
65
  // Formatters
@@ -68,6 +74,15 @@ function appsTable(apps) {
68
74
  ]);
69
75
  }
70
76
 
77
+ function scaffoldsTable(scaffolds) {
78
+ return formatTable(scaffolds, [
79
+ { label: 'Slug', value: (scaffold) => scaffold.slug || '' },
80
+ { label: 'Name', value: (scaffold) => scaffold.name || scaffold.slug || '' },
81
+ { label: 'Category', value: (scaffold) => (scaffold.categories || [])[0] || '' },
82
+ { label: 'Tagline', value: (scaffold) => scaffold.tagline || scaffold.description || '' },
83
+ ]);
84
+ }
85
+
71
86
  function hasNotisConfig(dir) {
72
87
  return CONFIG_FILENAMES.some((name) => existsSync(join(dir, name)));
73
88
  }
@@ -155,6 +170,24 @@ function buildDevInstallSlug(appName) {
155
170
  return base.endsWith('-dev') ? base : `${base}-dev`;
156
171
  }
157
172
 
173
+ function timingMs(startedAt) {
174
+ return Number(process.hrtime.bigint() - startedAt) / 1_000_000;
175
+ }
176
+
177
+ function logAppsTiming(label, details = {}) {
178
+ const suffix = Object.entries(details)
179
+ .map(([key, value]) => `${key}=${value}`)
180
+ .join(' ');
181
+ process.stderr.write(`[notis apps timing] ${label}${suffix ? ` ${suffix}` : ''}\n`);
182
+ }
183
+
184
+ function nextDevInstallIdempotencyKey(globalOptions = {}, devSlug) {
185
+ if (globalOptions.idempotencyKey) {
186
+ return `${globalOptions.idempotencyKey}:${devSlug}`;
187
+ }
188
+ return nextIdempotencyKey(globalOptions);
189
+ }
190
+
158
191
  function pickDefaultRouteSlug(manifest) {
159
192
  const routes = Array.isArray(manifest?.routes) ? manifest.routes : [];
160
193
  const explicit = routes.find((route) => route && route.default && typeof route.slug === 'string');
@@ -163,8 +196,29 @@ function pickDefaultRouteSlug(manifest) {
163
196
  return firstWithSlug ? firstWithSlug.slug : null;
164
197
  }
165
198
 
166
- export function buildDevelopmentDesktopUrl() {
167
- return 'notis://apps?tab=development';
199
+ const DESKTOP_DEEP_LINK_SCHEME_PATTERN = /^[a-z][a-z0-9-]*$/;
200
+
201
+ export function resolveDevelopmentDesktopScheme(env = process.env) {
202
+ // Mirror the desktop app's own scheme resolution (electron main + forge config):
203
+ // local dev launches register `notis-dev`, while installed prod/beta builds claim
204
+ // `notis`. Hardcoding `notis` here is what made `apps dev` open the installed
205
+ // prod/beta app instead of the local dev app.
206
+ const scheme = (env.NOTIS_DESKTOP_DEEP_LINK_SCHEME || '').trim();
207
+ return DESKTOP_DEEP_LINK_SCHEME_PATTERN.test(scheme) ? scheme : 'notis';
208
+ }
209
+
210
+ export function buildDevelopmentDesktopUrl(appHref = null, scheme = 'notis') {
211
+ const route = String(appHref || '/store').replace(/^\/+/, '');
212
+ const normalizedScheme = DESKTOP_DEEP_LINK_SCHEME_PATTERN.test(scheme) ? scheme : 'notis';
213
+ return `${normalizedScheme}://${route || 'store'}`;
214
+ }
215
+
216
+ export function shouldOpenDevelopmentTab(options = {}) {
217
+ // Commander stores the negatable `--no-open` flag as `options.open === false`;
218
+ // it never sets `options.noOpen`. Reading the non-existent `noOpen` key meant
219
+ // `--no-open` was silently ignored and `apps dev` always ran `open notis://…`,
220
+ // which macOS routes to the installed prod/beta desktop app.
221
+ return options.open !== false;
168
222
  }
169
223
 
170
224
  function buildAppHref({ appSlug, appId, manifest }) {
@@ -279,13 +333,16 @@ function assertHarnessResult(result, route, databaseSlugs) {
279
333
  }
280
334
  for (const databaseSlug of databaseSlugs) {
281
335
  const seen = (result.runtimeCalls || []).some(
282
- (call) => call?.op === 'queryDatabase' && call?.args?.databaseSlug === databaseSlug,
336
+ (call) =>
337
+ call?.op === 'callTool' &&
338
+ localNotisToolSlug(call?.args?.name) === 'LOCAL_NOTIS_DATABASE_QUERY' &&
339
+ call?.args?.arguments?.database_slug === databaseSlug,
283
340
  );
284
341
  if (!seen) {
285
342
  assertions.push({
286
343
  ok: false,
287
344
  code: 'missing_database_query',
288
- message: `Route "${route.slug}" did not call queryDatabase for "${databaseSlug}".`,
345
+ message: `Route "${route.slug}" did not call LOCAL_NOTIS_DATABASE_QUERY for "${databaseSlug}".`,
289
346
  details: { databaseSlug },
290
347
  });
291
348
  }
@@ -341,7 +398,26 @@ function buildManifestForDev(appConfig) {
341
398
  };
342
399
  }
343
400
 
344
- async function ensureDevInstall({ ctx, appConfig, projectDir, idempotencyKey }) {
401
+ export function buildEnsureDevInstallArguments({ appConfig, manifest, linkedState }) {
402
+ const devSlug = buildDevInstallSlug(appConfig.name);
403
+ const arguments_ = {
404
+ dev_slug: devSlug,
405
+ name: appConfig.name,
406
+ manifest,
407
+ };
408
+ if (linkedState?.dev_app_id) {
409
+ arguments_.app_id = linkedState.dev_app_id;
410
+ }
411
+ return arguments_;
412
+ }
413
+
414
+ export async function ensureDevInstall({
415
+ ctx,
416
+ appConfig,
417
+ projectDir,
418
+ idempotencyKey,
419
+ runTool = runToolCommand,
420
+ }) {
345
421
  if (!appConfig.name) {
346
422
  throw usageError(`notis.config.ts in ${projectDir} must define a non-empty name.`);
347
423
  }
@@ -351,14 +427,12 @@ async function ensureDevInstall({ ctx, appConfig, projectDir, idempotencyKey })
351
427
  }
352
428
 
353
429
  const manifest = buildManifestForDev(appConfig);
354
- const ensureResult = await runToolCommand({
430
+ const linkedState = readLinkedState(projectDir);
431
+ const ensureArguments = buildEnsureDevInstallArguments({ appConfig, manifest, linkedState });
432
+ const ensureResult = await runTool({
355
433
  runtime: ctx.runtime,
356
434
  toolName: ENSURE_DEV_APP_INSTALLATION_TOOL,
357
- arguments_: {
358
- dev_slug: devSlug,
359
- name: appConfig.name,
360
- manifest,
361
- },
435
+ arguments_: ensureArguments,
362
436
  mutating: true,
363
437
  idempotencyKey,
364
438
  });
@@ -374,13 +448,30 @@ async function ensureDevInstall({ ctx, appConfig, projectDir, idempotencyKey })
374
448
  projectDir,
375
449
  manifest,
376
450
  created: ensureResult.payload.created || false,
451
+ linkedAppId: linkedState?.app_id || null,
452
+ databaseMaterialization: ensureResult.payload.database_materialization || { created: [], unresolved: [] },
377
453
  };
378
454
  }
379
455
 
456
+ function databaseMaterializationWarnings(apps) {
457
+ const warnings = [];
458
+ for (const app of apps) {
459
+ const unresolved = app.databaseMaterialization?.unresolved || [];
460
+ if (!unresolved.length) {
461
+ continue;
462
+ }
463
+ warnings.push(
464
+ `${app.name}: database slug${unresolved.length === 1 ? '' : 's'} ${unresolved.join(', ')} ` +
465
+ 'could not be created because no store snapshot schema exists. Create them manually or link to a store-installed app.',
466
+ );
467
+ }
468
+ return warnings;
469
+ }
470
+
380
471
  async function assertDirectDeployAccess(runtime, appId) {
381
472
  const result = await runToolCommand({
382
473
  runtime,
383
- toolName: 'notis_list_apps',
474
+ toolName: LIST_APPS_TOOL,
384
475
  });
385
476
  const apps = result.payload.apps || [];
386
477
  const hasAccess = apps.some((app) => (app.app_id || app.id) === appId);
@@ -396,7 +487,7 @@ async function assertDirectDeployAccess(runtime, appId) {
396
487
  async function appsListHandler(ctx) {
397
488
  const result = await runToolCommand({
398
489
  runtime: ctx.runtime,
399
- toolName: 'notis_list_apps',
490
+ toolName: LIST_APPS_TOOL,
400
491
  });
401
492
  const apps = result.payload.apps || [];
402
493
  return ctx.output.emitSuccess({
@@ -409,13 +500,16 @@ async function appsListHandler(ctx) {
409
500
 
410
501
  async function appsInitHandler(ctx) {
411
502
  const projectDir = resolveProjectDir(ctx.args.dir || ctx.args.name.toLowerCase().replace(/[^a-z0-9]+/g, '-'));
503
+ const fromSlug = ctx.options.from || null;
412
504
 
413
- scaffoldProject({ projectDir, appName: ctx.args.name });
505
+ scaffoldProject({ projectDir, appName: ctx.args.name, fromSlug });
414
506
 
415
507
  return ctx.output.emitSuccess({
416
508
  command: ctx.spec.command_path.join(' '),
417
- data: { project_dir: projectDir, app_name: ctx.args.name },
418
- humanSummary: `Scaffolded "${ctx.args.name}" in ${projectDir}`,
509
+ data: { project_dir: projectDir, app_name: ctx.args.name, scaffold: fromSlug },
510
+ humanSummary: fromSlug
511
+ ? `Scaffolded "${ctx.args.name}" from ${fromSlug} in ${projectDir}`
512
+ : `Scaffolded "${ctx.args.name}" in ${projectDir}`,
419
513
  hints: [
420
514
  { command: `cd ${projectDir} && npm install`, reason: 'Install dependencies' },
421
515
  { command: `cd ${projectDir} && notis apps dev`, reason: 'Start the real-portal dev workflow' },
@@ -423,19 +517,29 @@ async function appsInitHandler(ctx) {
423
517
  });
424
518
  }
425
519
 
520
+ async function appsScaffoldsListHandler(ctx) {
521
+ const scaffolds = loadScaffoldCatalog();
522
+ return ctx.output.emitSuccess({
523
+ command: ctx.spec.command_path.join(' '),
524
+ data: { scaffolds },
525
+ humanSummary: scaffolds.length
526
+ ? `Found ${scaffolds.length} bundled scaffolds`
527
+ : 'No bundled scaffolds found. Run the CLI build step to generate the scaffold catalog.',
528
+ renderHuman: () => (scaffolds.length ? scaffoldsTable(scaffolds) : 'No bundled scaffolds found.'),
529
+ });
530
+ }
531
+
426
532
  async function appsCreateHandler(ctx) {
427
533
  const projectDir = ctx.args.dir ? resolveProjectDir(ctx.args.dir) : null;
534
+ const appConfig = projectDir ? await loadAppConfig(projectDir) : null;
428
535
  const idempotencyKey = nextIdempotencyKey(ctx.globalOptions);
429
- const icon = ctx.options.icon
430
- ? (ctx.options.icon.startsWith('lucide:') ? ctx.options.icon : `lucide:${ctx.options.icon}`)
431
- : undefined;
432
536
  const result = await runToolCommand({
433
537
  runtime: ctx.runtime,
434
- toolName: 'notis_create_app',
538
+ toolName: CREATE_APP_TOOL,
435
539
  arguments_: {
436
540
  name: ctx.args.name,
437
- description: ctx.options.description || undefined,
438
- icon,
541
+ description: appConfig?.description || undefined,
542
+ icon: appConfig?.icon || undefined,
439
543
  },
440
544
  mutating: true,
441
545
  idempotencyKey,
@@ -490,25 +594,26 @@ async function appsDevHandler(ctx) {
490
594
  const mode = getCliMode();
491
595
  const identity = decodeJwtSub(ctx.runtime.jwt);
492
596
  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.');
597
+ throw usageError('Could not determine the current user from the CLI auth token. Open the Notis desktop app, sign in, and retry.');
494
598
  }
495
599
  const apiBase = String(ctx.runtime.apiBase || '').replace(/\/$/, '');
496
600
  const sessionId = randomUUID();
497
601
 
498
- const registered = [];
602
+ const candidates = [];
499
603
  for (const projectDir of appDirs) {
500
604
  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);
605
+ if (!appConfig.name) {
606
+ throw usageError(`notis.config.ts in ${projectDir} must define a non-empty name.`);
607
+ }
608
+ const devSlug = buildDevInstallSlug(appConfig.name);
609
+ if (!devSlug) {
610
+ throw usageError(`notis.config.ts name in ${projectDir} must slugify to a non-empty value.`);
611
+ }
612
+ candidates.push({ appConfig, devSlug, projectDir });
508
613
  }
509
614
 
510
615
  const seenSlugs = new Map();
511
- for (const app of registered) {
616
+ for (const app of candidates) {
512
617
  if (seenSlugs.has(app.devSlug)) {
513
618
  throw usageError(
514
619
  `Multiple apps slugify to "${app.devSlug}" (${seenSlugs.get(app.devSlug).projectDir} and ${app.projectDir}). Rename one in notis.config.ts.`,
@@ -517,6 +622,30 @@ async function appsDevHandler(ctx) {
517
622
  seenSlugs.set(app.devSlug, app);
518
623
  }
519
624
 
625
+ const registrationStartedAt = process.hrtime.bigint();
626
+ const registered = await Promise.all(
627
+ candidates.map(async ({ appConfig, devSlug, projectDir }) => {
628
+ const appStartedAt = process.hrtime.bigint();
629
+ try {
630
+ return await ensureDevInstall({
631
+ ctx,
632
+ appConfig,
633
+ projectDir,
634
+ idempotencyKey: nextDevInstallIdempotencyKey(ctx.globalOptions, devSlug),
635
+ });
636
+ } finally {
637
+ logAppsTiming('ensure-dev-install', {
638
+ slug: devSlug,
639
+ ms: timingMs(appStartedAt).toFixed(1),
640
+ });
641
+ }
642
+ }),
643
+ );
644
+ logAppsTiming('ensure-dev-install:all', {
645
+ apps: registered.length,
646
+ ms: timingMs(registrationStartedAt).toFixed(1),
647
+ });
648
+
520
649
  const apps = registered.map((app) => {
521
650
  const bundleBaseUrl = `http://127.0.0.1:${port}/a/${app.devSlug}`;
522
651
  return {
@@ -529,10 +658,19 @@ async function appsDevHandler(ctx) {
529
658
  }),
530
659
  };
531
660
  });
532
- const developmentTabUrl = buildDevelopmentDesktopUrl();
661
+ const developmentTabUrl = buildDevelopmentDesktopUrl(
662
+ apps[0]?.appHref,
663
+ resolveDevelopmentDesktopScheme(),
664
+ );
665
+ const warnings = databaseMaterializationWarnings(apps);
533
666
 
534
667
  const devServer = await startAppDevServer({
535
- apps: apps.map((app) => ({ slug: app.devSlug, projectDir: app.projectDir, appId: app.appId })),
668
+ apps: apps.map((app) => ({
669
+ slug: app.devSlug,
670
+ projectDir: app.projectDir,
671
+ appId: app.appId,
672
+ userId: identity,
673
+ })),
536
674
  port,
537
675
  });
538
676
 
@@ -583,12 +721,15 @@ async function appsDevHandler(ctx) {
583
721
  bundle_base_url: app.bundleBaseUrl,
584
722
  app_href: app.appHref,
585
723
  created: app.created,
724
+ linked_app_id: app.linkedAppId,
725
+ database_materialization: app.databaseMaterialization,
586
726
  })),
587
727
  },
728
+ warnings,
588
729
  humanSummary: [
589
730
  `Running apps dev against ${apiBase} as ${identity} (mode: ${mode})`,
590
731
  '',
591
- `Development tab: ${developmentTabUrl}`,
732
+ `Open in desktop: ${developmentTabUrl}`,
592
733
  '',
593
734
  ...apps.map((app) => ` ${app.name.padEnd(24)} ${app.bundleBaseUrl} -> ${app.appHref}`),
594
735
  '',
@@ -596,7 +737,7 @@ async function appsDevHandler(ctx) {
596
737
  ].join('\n'),
597
738
  });
598
739
 
599
- if (!ctx.options.noOpen) {
740
+ if (shouldOpenDevelopmentTab(ctx.options)) {
600
741
  openInBrowser(developmentTabUrl);
601
742
  }
602
743
 
@@ -665,7 +806,7 @@ async function appsVerifyHandler(ctx) {
665
806
  let linkedState = null;
666
807
  if (mode === 'live') {
667
808
  if (!ctx.runtime.jwt) {
668
- throw usageError('Live verify mode requires CLI auth. Run `notis auth login` and retry.');
809
+ throw usageError('Live verify mode requires CLI auth. Open the Notis desktop app, sign in, and retry.');
669
810
  }
670
811
  linkedState = readLinkedState(projectDir);
671
812
  if (!linkedState?.app_id) {
@@ -679,6 +820,11 @@ async function appsVerifyHandler(ctx) {
679
820
 
680
821
  const manifest = readManifest(projectDir);
681
822
  const appConfig = await loadAppConfig(projectDir);
823
+ const listing = inspectListingReadiness(projectDir, appConfig);
824
+ if (listing.errors.length) {
825
+ throw usageError(`Listing metadata has problems:\n${listing.errors.map((error) => ` - ${error}`).join('\n')}`);
826
+ }
827
+ const listingWarnings = listing.warnings;
682
828
  const routes = routeSelection(manifest, parseRouteSlugs(ctx.options.routes));
683
829
  const port = parsePort(ctx.options.port) || await getAvailablePort();
684
830
  const appSlug = slugify(appConfig.name || manifest.app?.name || 'app') || 'app';
@@ -713,7 +859,7 @@ async function appsVerifyHandler(ctx) {
713
859
  }));
714
860
 
715
861
  let results;
716
- const warnings = [];
862
+ const warnings = [...listingWarnings];
717
863
  if (noBrowser) {
718
864
  results = urls.map(({ route, url }) => ({
719
865
  route: route.slug,
@@ -905,10 +1051,27 @@ async function appsPullHandler(ctx) {
905
1051
  });
906
1052
  }
907
1053
 
1054
+ function updateLinkedDeployState(projectDir, linkedState, appId, version) {
1055
+ if (!linkedState || linkedState.app_id !== appId || !Number.isFinite(version)) {
1056
+ return;
1057
+ }
1058
+ writeLinkedState(projectDir, {
1059
+ ...linkedState,
1060
+ app_id: appId,
1061
+ version,
1062
+ linked_at: linkedState.linked_at || new Date().toISOString(),
1063
+ deployed_at: new Date().toISOString(),
1064
+ });
1065
+ }
1066
+
908
1067
  async function appsDeployHandler(ctx) {
909
1068
  const projectDir = resolveProjectDir(ctx.args.dir || '.');
910
1069
  const appId = requireLinkedAppId(projectDir, ctx.options.appId);
911
1070
  const idempotencyKey = nextIdempotencyKey(ctx.globalOptions);
1071
+ const linkedState = readLinkedState(projectDir);
1072
+ const baseVersion = linkedState?.app_id === appId && Number.isFinite(linkedState?.version)
1073
+ ? linkedState.version
1074
+ : undefined;
912
1075
 
913
1076
  // Build if needed
914
1077
  if (!ctx.options.skipBuild) {
@@ -919,6 +1082,7 @@ async function appsDeployHandler(ctx) {
919
1082
  if (ctx.options.direct) {
920
1083
  await assertDirectDeployAccess(ctx.runtime, appId);
921
1084
  const { version } = await directDeploy(projectDir, appId);
1085
+ updateLinkedDeployState(projectDir, linkedState, appId, version);
922
1086
  return ctx.output.emitSuccess({
923
1087
  command: ctx.spec.command_path.join(' '),
924
1088
  data: { app_id: appId, version, mode: 'direct' },
@@ -936,12 +1100,13 @@ async function appsDeployHandler(ctx) {
936
1100
  try {
937
1101
  result = await runToolCommand({
938
1102
  runtime: ctx.runtime,
939
- toolName: 'notis_save_app_files',
1103
+ toolName: SAVE_APP_FILES_TOOL,
940
1104
  arguments_: {
941
1105
  app_id: appId,
942
1106
  files,
943
1107
  source_files: sourceFiles,
944
1108
  manifest,
1109
+ ...(baseVersion !== undefined ? { base_version: baseVersion } : {}),
945
1110
  },
946
1111
  mutating: true,
947
1112
  idempotencyKey,
@@ -968,6 +1133,7 @@ async function appsDeployHandler(ctx) {
968
1133
 
969
1134
  try {
970
1135
  const { version } = await directDeploy(projectDir, appId);
1136
+ updateLinkedDeployState(projectDir, linkedState, appId, version);
971
1137
  return ctx.output.emitSuccess({
972
1138
  command: ctx.spec.command_path.join(' '),
973
1139
  data: { app_id: appId, version, mode: 'direct-fallback' },
@@ -986,6 +1152,7 @@ async function appsDeployHandler(ctx) {
986
1152
  throw error;
987
1153
  }
988
1154
 
1155
+ updateLinkedDeployState(projectDir, linkedState, appId, Number(result.payload.version));
989
1156
  return ctx.output.emitSuccess({
990
1157
  command: ctx.spec.command_path.join(' '),
991
1158
  data: {
@@ -1009,13 +1176,21 @@ async function appsDoctorHandler(ctx) {
1009
1176
  problems.push('Failed to load notis.config.ts');
1010
1177
  }
1011
1178
  const warnings = detectProjectWarnings(projectDir, appConfig);
1179
+ let listing = null;
1180
+ if (appConfig) {
1181
+ try {
1182
+ listing = inspectListingReadiness(projectDir, appConfig);
1183
+ } catch (error) {
1184
+ warnings.push(error instanceof Error ? error.message : String(error));
1185
+ }
1186
+ }
1012
1187
 
1013
1188
  const linkedState = readLinkedState(projectDir);
1014
1189
  const status = problems.length ? 'unhealthy' : warnings.length ? 'warnings' : 'healthy';
1015
1190
 
1016
1191
  return ctx.output.emitSuccess({
1017
1192
  command: ctx.spec.command_path.join(' '),
1018
- data: { status, problems, warnings, linked: linkedState, config: appConfig },
1193
+ data: { status, problems, warnings, linked: linkedState, config: appConfig, listing },
1019
1194
  humanSummary: problems.length
1020
1195
  ? `Found ${problems.length} problems:\n${problems.map((p) => ` - ${p}`).join('\n')}`
1021
1196
  : warnings.length
@@ -1037,27 +1212,46 @@ export const appsCommandSpecs = [
1037
1212
  examples: ['notis apps list', 'notis apps list --json'],
1038
1213
  mutates: false,
1039
1214
  idempotent: true,
1040
- backend_call: { type: 'tool', name: 'notis_list_apps' },
1215
+ backend_call: { type: 'tool', name: LIST_APPS_TOOL },
1041
1216
  handler: appsListHandler,
1042
1217
  },
1043
1218
  {
1044
1219
  command_path: ['apps', 'init'],
1045
1220
  summary: 'Scaffold a new Notis app project.',
1046
- when_to_use: 'Start a new Notis app. Creates a Vite + React project with @notis/sdk pre-configured.',
1221
+ 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.',
1047
1222
  args_schema: {
1048
1223
  arguments: [
1049
1224
  { token: '<name>', description: 'Display name for the app.' },
1050
1225
  { token: '[dir]', key: 'dir', description: 'Target directory (defaults to kebab-case of name).' },
1051
1226
  ],
1052
- options: [],
1227
+ options: [
1228
+ { flags: '--from <slug>', description: 'Start from a bundled scaffold listed by `notis apps scaffolds list`.' },
1229
+ ],
1053
1230
  },
1054
- examples: ['notis apps init "Mind the Flo"', 'notis apps init "My App" ./my-app'],
1231
+ examples: [
1232
+ 'notis apps scaffolds list',
1233
+ 'notis apps init "Mind the Flo"',
1234
+ 'notis apps init "My CRM" --from notis-database',
1235
+ 'notis apps init "My App" ./my-app',
1236
+ ],
1055
1237
  mutates: true,
1056
1238
  idempotent: false,
1057
1239
  require_auth: false,
1058
1240
  backend_call: { type: 'local', name: 'scaffold_project' },
1059
1241
  handler: appsInitHandler,
1060
1242
  },
1243
+ {
1244
+ command_path: ['apps', 'scaffolds', 'list'],
1245
+ summary: 'List bundled Notis app scaffolds.',
1246
+ when_to_use: 'Discover the fixed scaffold catalog shipped with the CLI before starting a new app.',
1247
+ args_schema: { arguments: [], options: [] },
1248
+ examples: ['notis apps scaffolds list', 'notis apps init "My App" --from notis-database'],
1249
+ mutates: false,
1250
+ idempotent: true,
1251
+ require_auth: false,
1252
+ backend_call: { type: 'local', name: 'list_scaffolds' },
1253
+ handler: appsScaffoldsListHandler,
1254
+ },
1061
1255
  {
1062
1256
  command_path: ['apps', 'create'],
1063
1257
  summary: 'Create a new remote Notis app and optionally link a local project to it.',
@@ -1067,32 +1261,29 @@ export const appsCommandSpecs = [
1067
1261
  { token: '<name>', description: 'Display name for the remote app.' },
1068
1262
  { token: '[dir]', key: 'dir', description: 'Project directory to link after creation (default: do not link).' },
1069
1263
  ],
1070
- options: [
1071
- { flags: '--description <text>', description: 'Optional app description.' },
1072
- { flags: '--icon <lucide:icon>', description: 'Optional Lucide icon, for example lucide:dices.' },
1073
- ],
1264
+ options: [],
1074
1265
  },
1075
1266
  examples: [
1076
1267
  'notis apps create "My App"',
1077
- 'notis apps create "My App" . --description "Internal tool" --icon lucide:layout-dashboard',
1268
+ 'notis apps create "My App" .',
1078
1269
  ],
1079
1270
  mutates: true,
1080
1271
  idempotent: false,
1081
- backend_call: { type: 'tool', name: 'notis_create_app' },
1272
+ backend_call: { type: 'tool', name: CREATE_APP_TOOL },
1082
1273
  handler: appsCreateHandler,
1083
1274
  },
1084
1275
  {
1085
1276
  command_path: ['apps', 'dev'],
1086
1277
  summary: 'Develop Notis apps inside the Electron desktop Portal with automatic local bundle reloads.',
1087
1278
  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.',
1279
+ '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.',
1089
1280
  args_schema: {
1090
1281
  arguments: [
1091
1282
  { token: '[dir]', key: 'dir', description: 'Project directory or monorepo root (default: current dir).' },
1092
1283
  ],
1093
1284
  options: [
1094
1285
  { 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.' },
1286
+ { flags: '--no-open', description: 'Do not auto-open the desktop Portal local development app.' },
1096
1287
  ],
1097
1288
  },
1098
1289
  examples: [
@@ -1175,7 +1366,8 @@ export const appsCommandSpecs = [
1175
1366
  {
1176
1367
  command_path: ['apps', 'pull'],
1177
1368
  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.',
1369
+ when_to_use:
1370
+ '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.',
1179
1371
  args_schema: {
1180
1372
  arguments: [
1181
1373
  { token: '<app-id>', description: 'Remote app ID to pull.' },
@@ -1211,7 +1403,7 @@ export const appsCommandSpecs = [
1211
1403
  examples: ['notis apps deploy', 'notis apps deploy --skip-build', 'notis apps deploy --app-id abc123', 'notis apps deploy --direct'],
1212
1404
  mutates: true,
1213
1405
  idempotent: true,
1214
- backend_call: { type: 'tool', name: 'notis_save_app_files' },
1406
+ backend_call: { type: 'tool', name: SAVE_APP_FILES_TOOL },
1215
1407
  handler: appsDeployHandler,
1216
1408
  },
1217
1409
  {