@notis_ai/cli 0.2.4 → 0.2.6

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 (119) hide show
  1. package/README.md +47 -5
  2. package/dist/scaffolds/notis-database/CHANGELOG.md +5 -0
  3. package/dist/scaffolds/notis-database/metadata/screenshot-1.png +0 -0
  4. package/dist/scaffolds/notis-database/notis.config.ts +0 -1
  5. package/dist/scaffolds/notis-database/src/mock-runtime.ts +1 -0
  6. package/dist/scaffolds/notis-journal/CHANGELOG.md +25 -0
  7. package/dist/scaffolds/notis-journal/app/globals.css +37 -0
  8. package/dist/scaffolds/notis-journal/app/insights/page.tsx +513 -0
  9. package/dist/scaffolds/notis-journal/app/journal-core.tsx +362 -0
  10. package/dist/scaffolds/notis-journal/app/journal-ui.tsx +337 -0
  11. package/dist/scaffolds/notis-journal/app/layout.tsx +6 -0
  12. package/dist/scaffolds/notis-journal/app/page.tsx +485 -0
  13. package/dist/scaffolds/notis-journal/components/ui/badge.tsx +28 -0
  14. package/dist/scaffolds/notis-journal/components/ui/button.tsx +53 -0
  15. package/dist/scaffolds/notis-journal/components/ui/card.tsx +56 -0
  16. package/dist/scaffolds/notis-journal/components.json +20 -0
  17. package/dist/scaffolds/notis-journal/index.html +12 -0
  18. package/dist/scaffolds/notis-journal/lib/utils.ts +6 -0
  19. package/dist/scaffolds/notis-journal/metadata/screenshot-1.png +0 -0
  20. package/dist/scaffolds/notis-journal/metadata/screenshot-2.png +0 -0
  21. package/dist/scaffolds/notis-journal/metadata/screenshot-3.png +0 -0
  22. package/dist/scaffolds/notis-journal/metadata/screenshot-4.png +0 -0
  23. package/dist/scaffolds/notis-journal/metadata/screenshot-5.png +0 -0
  24. package/dist/scaffolds/notis-journal/metadata/screenshot-6.png +0 -0
  25. package/dist/scaffolds/notis-journal/metadata/screenshot-fixtures.json +132 -0
  26. package/dist/scaffolds/notis-journal/notis.config.ts +93 -0
  27. package/dist/scaffolds/notis-journal/package.json +34 -0
  28. package/dist/scaffolds/notis-journal/packages/sdk/package.json +36 -0
  29. package/dist/scaffolds/notis-journal/packages/sdk/src/components/DocumentEditor.tsx +93 -0
  30. package/dist/scaffolds/notis-journal/packages/sdk/src/components/Markdown.tsx +60 -0
  31. package/dist/scaffolds/notis-journal/packages/sdk/src/components/MultiSelectActionBar.tsx +278 -0
  32. package/dist/scaffolds/notis-journal/packages/sdk/src/components/MultiSelectCheckbox.tsx +91 -0
  33. package/dist/scaffolds/notis-journal/packages/sdk/src/components/MultiSelectDragOverlay.tsx +39 -0
  34. package/dist/scaffolds/notis-journal/packages/sdk/src/config.ts +145 -0
  35. package/dist/scaffolds/notis-journal/packages/sdk/src/documents.ts +229 -0
  36. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useBackend.ts +41 -0
  37. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useDatabaseSchema.ts +85 -0
  38. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useDocument.ts +78 -0
  39. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useDocuments.ts +121 -0
  40. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useMultiSelect.ts +539 -0
  41. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useNotis.ts +34 -0
  42. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useNotisNavigation.ts +49 -0
  43. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useTool.ts +64 -0
  44. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useTools.ts +56 -0
  45. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useTopBarSearch.ts +73 -0
  46. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useUpsertDocument.ts +95 -0
  47. package/dist/scaffolds/notis-journal/packages/sdk/src/index.ts +83 -0
  48. package/dist/scaffolds/notis-journal/packages/sdk/src/provider.tsx +43 -0
  49. package/dist/scaffolds/notis-journal/packages/sdk/src/runtime.ts +220 -0
  50. package/dist/scaffolds/notis-journal/packages/sdk/src/styles.css +186 -0
  51. package/dist/scaffolds/notis-journal/packages/sdk/src/ui.ts +15 -0
  52. package/dist/scaffolds/notis-journal/packages/sdk/src/vite.ts +56 -0
  53. package/dist/scaffolds/notis-journal/packages/sdk/tsconfig.json +15 -0
  54. package/dist/scaffolds/notis-journal/postcss.config.mjs +8 -0
  55. package/dist/scaffolds/notis-journal/skills/journal-onboarding/SKILL.md +120 -0
  56. package/dist/scaffolds/notis-journal/src/dev-main.tsx +58 -0
  57. package/dist/scaffolds/notis-journal/src/mock-runtime.ts +197 -0
  58. package/dist/scaffolds/notis-journal/tailwind.config.ts +58 -0
  59. package/dist/scaffolds/notis-journal/tsconfig.json +23 -0
  60. package/dist/scaffolds/notis-journal/vite.config.ts +10 -0
  61. package/dist/scaffolds/notis-notes/CHANGELOG.md +5 -0
  62. package/dist/scaffolds/notis-notes/app/page.tsx +325 -543
  63. package/dist/scaffolds/notis-notes/metadata/screenshot-1.png +0 -0
  64. package/dist/scaffolds/notis-notes/notis.config.ts +0 -1
  65. package/dist/scaffolds/notis-random/CHANGELOG.md +5 -0
  66. package/dist/scaffolds/notis-random/metadata/screenshot-1.png +0 -0
  67. package/dist/scaffolds/notis-random/metadata/screenshot-2.png +0 -0
  68. package/dist/scaffolds/notis-random/notis.config.ts +0 -1
  69. package/dist/scaffolds/notis-random/src/mock-runtime.ts +1 -0
  70. package/dist/scaffolds.json +11 -0
  71. package/package.json +3 -3
  72. package/skills/notis-apps/SKILL.md +64 -6
  73. package/skills/notis-apps/cli.md +38 -2
  74. package/skills/notis-cli/SKILL.md +20 -2
  75. package/skills/notis-query/cli.md +1 -1
  76. package/src/command-specs/apps.js +529 -16
  77. package/src/command-specs/index.js +1 -1
  78. package/src/command-specs/meta.js +8 -2
  79. package/src/command-specs/tools.js +50 -37
  80. package/src/runtime/agent-browser.js +273 -1
  81. package/src/runtime/app-changelog.js +79 -0
  82. package/src/runtime/app-dev-server.js +113 -6
  83. package/src/runtime/app-dev-sessions.js +176 -12
  84. package/src/runtime/app-platform.js +200 -39
  85. package/src/runtime/assets/store-screenshot-dark.png +0 -0
  86. package/src/runtime/desktop-auth.js +93 -0
  87. package/src/runtime/profiles.js +37 -15
  88. package/src/runtime/store-screenshot.js +138 -0
  89. package/src/runtime/transport.js +21 -82
  90. package/template/.harness/index.html.tmpl +128 -6
  91. package/template/CHANGELOG.md +5 -0
  92. package/template/app/page.tsx +11 -41
  93. package/template/notis.config.ts +0 -1
  94. package/template/package-lock.json +4137 -0
  95. package/template/package.json +1 -0
  96. package/template/packages/sdk/package.json +4 -0
  97. package/template/packages/sdk/src/components/DocumentEditor.tsx +93 -0
  98. package/template/packages/sdk/src/components/Markdown.tsx +60 -0
  99. package/template/packages/sdk/src/components/MultiSelectActionBar.tsx +7 -2
  100. package/template/packages/sdk/src/config.ts +74 -0
  101. package/template/packages/sdk/src/documents.ts +229 -0
  102. package/template/packages/sdk/src/hooks/useDatabaseSchema.ts +85 -0
  103. package/template/packages/sdk/src/hooks/useDocument.ts +78 -0
  104. package/template/packages/sdk/src/hooks/useDocuments.ts +121 -0
  105. package/template/packages/sdk/src/hooks/useMultiSelect.ts +38 -2
  106. package/template/packages/sdk/src/hooks/useUpsertDocument.ts +54 -9
  107. package/template/packages/sdk/src/index.ts +30 -1
  108. package/template/packages/sdk/src/runtime.ts +76 -17
  109. package/template/packages/sdk/src/styles.css +148 -0
  110. package/dist/scaffolds/notis-database/metadata/cover.png +0 -0
  111. package/dist/scaffolds/notis-database/metadata/screenshot-2.png +0 -0
  112. package/dist/scaffolds/notis-database/metadata/screenshot-3.png +0 -0
  113. package/dist/scaffolds/notis-notes/metadata/cover.png +0 -0
  114. package/dist/scaffolds/notis-notes/metadata/screenshot-2.png +0 -0
  115. package/dist/scaffolds/notis-notes/metadata/screenshot-3.png +0 -0
  116. package/dist/scaffolds/notis-random/metadata/cover.png +0 -0
  117. package/dist/scaffolds/notis-random/metadata/screenshot-3.png +0 -0
  118. package/template/metadata/cover.png +0 -0
  119. package/template/packages/sdk/src/hooks/useDatabase.ts +0 -76
@@ -2,15 +2,16 @@
2
2
  * Notis apps CLI commands.
3
3
  *
4
4
  * Canonical Notis app workflow:
5
- * init -> dev -> build -> deploy
5
+ * init -> dev -> build -> deploy -> publish (after explicit approval)
6
6
  *
7
- * Supporting commands: list, link, doctor.
7
+ * Supporting commands: list, link, doctor, pull.
8
8
  */
9
9
 
10
10
  import { randomUUID } from 'node:crypto';
11
11
  import { spawn } from 'node:child_process';
12
- import { existsSync, readdirSync } from 'node:fs';
13
- import { join, resolve } from 'node:path';
12
+ import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync } from 'node:fs';
13
+ import { tmpdir } from 'node:os';
14
+ import { basename, join, relative, resolve } from 'node:path';
14
15
 
15
16
  import { EXIT_CODES, usageError } from '../runtime/errors.js';
16
17
  import { formatTable } from '../runtime/output.js';
@@ -27,13 +28,16 @@ import {
27
28
  scaffoldProject,
28
29
  loadScaffoldCatalog,
29
30
  inspectListingReadiness,
31
+ resolveListingScreenshots,
30
32
  collectArtifactFiles,
31
33
  collectSourceFiles,
34
+ resolveConfiguredAppSkills,
32
35
  directDeploy,
33
36
  pullAppSource,
34
37
  } from '../runtime/app-platform.js';
35
38
  import { startAppDevServer } from '../runtime/app-dev-server.js';
36
39
  import {
40
+ captureHarnessScreenshot,
37
41
  closeAgentBrowserSession,
38
42
  isAgentBrowserAvailable,
39
43
  runHarnessRoute,
@@ -42,9 +46,12 @@ import {
42
46
  heartbeatAppDevSession,
43
47
  removeAppDevSession,
44
48
  upsertAppDevSessions,
49
+ waitForAppDevSessionMountAcknowledgements,
45
50
  } from '../runtime/app-dev-sessions.js';
46
51
  import { getAvailablePort } from '../runtime/ports.js';
47
52
  import { getCliMode } from '../runtime/cli-mode.js';
53
+ import { composeStoreScreenshot } from '../runtime/store-screenshot.js';
54
+ import { httpRequest } from '../runtime/transport.js';
48
55
  import {
49
56
  localNotisToolSlug,
50
57
  nextIdempotencyKey,
@@ -236,6 +243,15 @@ function parsePort(value) {
236
243
  return port;
237
244
  }
238
245
 
246
+ function parsePositiveInt(value) {
247
+ if (!value) return null;
248
+ const parsed = Number.parseInt(value, 10);
249
+ if (!Number.isInteger(parsed) || parsed <= 0) {
250
+ throw usageError('Expected a positive integer.');
251
+ }
252
+ return parsed;
253
+ }
254
+
239
255
  function parseRouteSlugs(value) {
240
256
  if (!value) return null;
241
257
  const slugs = String(value)
@@ -277,6 +293,36 @@ function routeSelection(manifest, rawRouteSlugs) {
277
293
  return selected;
278
294
  }
279
295
 
296
+ export function pruneStaleScreenshotFiles(outputDir, keepCount) {
297
+ for (const entry of readdirSync(outputDir, { withFileTypes: true })) {
298
+ if (!entry.isFile()) continue;
299
+ const match = /^screenshot-(\d+)\.png$/i.exec(entry.name);
300
+ if (match && Number.parseInt(match[1], 10) > keepCount) {
301
+ rmSync(join(outputDir, entry.name), { force: true });
302
+ }
303
+ }
304
+ }
305
+
306
+ export function shouldPruneStaleScreenshotFiles(selectedRouteSlugs, failedCount) {
307
+ return failedCount === 0 && !selectedRouteSlugs;
308
+ }
309
+
310
+ export function screenshotIndexByRouteSlug(manifest) {
311
+ const routes = Array.isArray(manifest?.routes) ? manifest.routes : [];
312
+ return new Map(routes.map((route, index) => [route.slug, index + 1]));
313
+ }
314
+
315
+ export function appRowFieldsFromManifest(manifest) {
316
+ const app = manifest?.app && typeof manifest.app === 'object' ? manifest.app : {};
317
+ return {
318
+ accent: app.accent ?? null,
319
+ };
320
+ }
321
+
322
+ export function screenshotExitCode(failedCount) {
323
+ return failedCount === 0 ? EXIT_CODES.ok : EXIT_CODES.unexpected;
324
+ }
325
+
280
326
  function declaredDatabaseSlugs(appConfig, manifest, route) {
281
327
  const slugs = new Set();
282
328
  for (const entry of appConfig?.databases || manifest?.databases || []) {
@@ -395,15 +441,23 @@ function buildManifestForDev(appConfig) {
395
441
  },
396
442
  databases: appConfig.databases || [],
397
443
  tools: appConfig.tools || [],
444
+ skills: (appConfig.skills || []).map((skill) => ({
445
+ key: skill.key,
446
+ path: String(skill.path || '').replace(/^\.\/+/, ''),
447
+ name: skill.name,
448
+ description: skill.description || null,
449
+ })),
450
+ onboarding: appConfig.onboarding || null,
398
451
  };
399
452
  }
400
453
 
401
- export function buildEnsureDevInstallArguments({ appConfig, manifest, linkedState }) {
454
+ export function buildEnsureDevInstallArguments({ appConfig, manifest, linkedState, skills }) {
402
455
  const devSlug = buildDevInstallSlug(appConfig.name);
403
456
  const arguments_ = {
404
457
  dev_slug: devSlug,
405
458
  name: appConfig.name,
406
459
  manifest,
460
+ skills,
407
461
  };
408
462
  if (linkedState?.dev_app_id) {
409
463
  arguments_.app_id = linkedState.dev_app_id;
@@ -427,8 +481,24 @@ export async function ensureDevInstall({
427
481
  }
428
482
 
429
483
  const manifest = buildManifestForDev(appConfig);
430
- const linkedState = readLinkedState(projectDir);
431
- const ensureArguments = buildEnsureDevInstallArguments({ appConfig, manifest, linkedState });
484
+ const skills = resolveConfiguredAppSkills(appConfig, projectDir);
485
+ let linkedState = readLinkedState(projectDir);
486
+ if (linkedState?.app_id) {
487
+ const linkedApp = await getAccessibleApp(ctx.runtime, linkedState.app_id, runTool);
488
+ if (!linkedApp || linkedApp.manifest?.is_dev === true) {
489
+ const { app_id: legacyDevAppId, linked_at: _linkedAt, deployed_at: _deployedAt, version: _version, ...rest } = linkedState;
490
+ const devAppId = linkedState.dev_app_id || (linkedApp ? legacyDevAppId : null);
491
+ linkedState = {
492
+ ...rest,
493
+ ...(devAppId ? { dev_app_id: devAppId } : {}),
494
+ ...(devAppId ? {
495
+ dev_linked_at: linkedState.dev_linked_at || linkedState.linked_at || new Date().toISOString(),
496
+ } : {}),
497
+ };
498
+ writeLinkedState(projectDir, linkedState);
499
+ }
500
+ }
501
+ const ensureArguments = buildEnsureDevInstallArguments({ appConfig, manifest, linkedState, skills });
432
502
  const ensureResult = await runTool({
433
503
  runtime: ctx.runtime,
434
504
  toolName: ENSURE_DEV_APP_INSTALLATION_TOOL,
@@ -440,6 +510,14 @@ export async function ensureDevInstall({
440
510
  if (!appId) {
441
511
  throw usageError('Dev installation tool did not return an app_id.');
442
512
  }
513
+ if (linkedState?.dev_app_id !== appId) {
514
+ const now = new Date().toISOString();
515
+ writeLinkedState(projectDir, {
516
+ ...(linkedState || {}),
517
+ dev_app_id: appId,
518
+ dev_linked_at: linkedState?.dev_linked_at || now,
519
+ });
520
+ }
443
521
  return {
444
522
  slug: ensureResult.payload.slug || devSlug,
445
523
  devSlug,
@@ -449,6 +527,7 @@ export async function ensureDevInstall({
449
527
  manifest,
450
528
  created: ensureResult.payload.created || false,
451
529
  linkedAppId: linkedState?.app_id || null,
530
+ targetAppId: linkedState?.app_id || null,
452
531
  databaseMaterialization: ensureResult.payload.database_materialization || { created: [], unresolved: [] },
453
532
  };
454
533
  }
@@ -468,16 +547,48 @@ function databaseMaterializationWarnings(apps) {
468
547
  return warnings;
469
548
  }
470
549
 
471
- async function assertDirectDeployAccess(runtime, appId) {
472
- const result = await runToolCommand({
550
+ async function getAccessibleApp(runtime, appId, runTool = runToolCommand) {
551
+ const result = await runTool({
473
552
  runtime,
474
- toolName: LIST_APPS_TOOL,
553
+ toolName: GET_APP_TOOL,
554
+ arguments_: { app_id: appId },
475
555
  });
476
- const apps = result.payload.apps || [];
477
- const hasAccess = apps.some((app) => (app.app_id || app.id) === appId);
478
- if (!hasAccess) {
556
+ if (result.payload?.app) {
557
+ return {
558
+ ...result.payload.app,
559
+ apps_access: result.payload.apps_access,
560
+ };
561
+ }
562
+ const message = typeof result.payload?.message === 'string' ? result.payload.message : '';
563
+ if (result.payload?.status === 'error' && /PGRST116|0 rows|no rows|not found/i.test(message)) {
564
+ return null;
565
+ }
566
+ throw usageError(`Could not verify access to app ${appId}${message ? `: ${message}` : '.'}`);
567
+ }
568
+
569
+ export async function assertDirectDeployAccess(runtime, appId, runTool = runToolCommand) {
570
+ const app = await getAccessibleApp(runtime, appId, runTool);
571
+ if (!app) {
479
572
  throw usageError(`Direct deploy requires access to app ${appId}.`);
480
573
  }
574
+ if (app.apps_access?.has_access !== true) {
575
+ throw usageError('Notis Apps require a PRO+ or ULTRA plan after your trial.');
576
+ }
577
+ return app;
578
+ }
579
+
580
+ export async function assertLinkTarget(runtime, appId, runTool = runToolCommand) {
581
+ const app = await getAccessibleApp(runtime, appId, runTool);
582
+ if (!app) {
583
+ throw usageError(`Cannot link to inaccessible app ${appId}.`);
584
+ }
585
+ if (app.manifest?.is_dev === true) {
586
+ throw usageError(
587
+ `Cannot link to development runtime app ${appId}. ` +
588
+ 'Link to an installed workspace app, or run `notis apps dev` without a target.',
589
+ );
590
+ }
591
+ return app;
481
592
  }
482
593
 
483
594
  // ---------------------------------------------------------------------------
@@ -540,6 +651,7 @@ async function appsCreateHandler(ctx) {
540
651
  name: ctx.args.name,
541
652
  description: appConfig?.description || undefined,
542
653
  icon: appConfig?.icon || undefined,
654
+ accent: appConfig?.accent ?? undefined,
543
655
  },
544
656
  mutating: true,
545
657
  idempotencyKey,
@@ -651,6 +763,7 @@ async function appsDevHandler(ctx) {
651
763
  return {
652
764
  ...app,
653
765
  bundleBaseUrl,
766
+ mountNonce: randomUUID(),
654
767
  appHref: buildAppHref({
655
768
  appSlug: app.slug,
656
769
  appId: app.appId,
@@ -669,6 +782,7 @@ async function appsDevHandler(ctx) {
669
782
  slug: app.devSlug,
670
783
  projectDir: app.projectDir,
671
784
  appId: app.appId,
785
+ targetAppId: app.targetAppId || null,
672
786
  userId: identity,
673
787
  })),
674
788
  port,
@@ -681,6 +795,8 @@ async function appsDevHandler(ctx) {
681
795
  userId: identity,
682
796
  apiBase,
683
797
  appId: app.appId,
798
+ targetAppId: app.targetAppId || undefined,
799
+ mountNonce: app.mountNonce,
684
800
  devSlug: app.devSlug,
685
801
  bundleBaseUrl: app.bundleBaseUrl,
686
802
  projectDir: app.projectDir,
@@ -705,6 +821,13 @@ async function appsDevHandler(ctx) {
705
821
  }
706
822
  }, DEV_HEARTBEAT_INTERVAL_MS);
707
823
 
824
+ const expectedMountAcknowledgements = apps.map((app) => ({
825
+ sessionId,
826
+ appId: app.appId,
827
+ devSlug: app.devSlug,
828
+ mountNonce: app.mountNonce,
829
+ }));
830
+
708
831
  ctx.output.emitSuccess({
709
832
  command: ctx.spec.command_path.join(' '),
710
833
  data: {
@@ -712,10 +835,12 @@ async function appsDevHandler(ctx) {
712
835
  api_base: apiBase,
713
836
  development_url: developmentTabUrl,
714
837
  session_id: sessionId,
838
+ mount_status: 'serving',
715
839
  identity,
716
840
  apps: apps.map((app) => ({
717
841
  slug: app.devSlug,
718
842
  app_id: app.appId,
843
+ target_app_id: app.targetAppId,
719
844
  name: app.name,
720
845
  project_dir: app.projectDir,
721
846
  bundle_base_url: app.bundleBaseUrl,
@@ -733,6 +858,8 @@ async function appsDevHandler(ctx) {
733
858
  '',
734
859
  ...apps.map((app) => ` ${app.name.padEnd(24)} ${app.bundleBaseUrl} -> ${app.appHref}`),
735
860
  '',
861
+ `Serving locally; waiting for Notis to mount ${apps.length === 1 ? 'the app' : `${apps.length} apps`}.`,
862
+ '',
736
863
  'Press Ctrl-C to stop.',
737
864
  ].join('\n'),
738
865
  });
@@ -741,6 +868,27 @@ async function appsDevHandler(ctx) {
741
868
  openInBrowser(developmentTabUrl);
742
869
  }
743
870
 
871
+ void (async () => {
872
+ let result = await waitForAppDevSessionMountAcknowledgements(expectedMountAcknowledgements);
873
+ if (!result.mounted) {
874
+ process.stderr.write(
875
+ `[notis apps dev] Serving locally, but Notis has not acknowledged ${result.missing.length === 1 ? 'the app' : `${result.missing.length} apps`} in its Local development sidebar yet. Keep this command running and open Notis.\n`,
876
+ );
877
+ }
878
+ while (!result.mounted) {
879
+ result = await waitForAppDevSessionMountAcknowledgements(expectedMountAcknowledgements, {
880
+ timeoutMs: 60_000,
881
+ pollIntervalMs: 250,
882
+ });
883
+ }
884
+ process.stderr.write(
885
+ `[notis apps dev] Mounted in Notis: ${apps.map((app) => app.name).join(', ')}.\n`,
886
+ );
887
+ })().catch((error) => {
888
+ const message = error instanceof Error ? error.message : String(error);
889
+ process.stderr.write(`[notis apps dev] mount acknowledgement check failed: ${message}\n`);
890
+ });
891
+
744
892
  let shuttingDown = false;
745
893
  const shutdown = async (signal) => {
746
894
  if (shuttingDown) return;
@@ -999,10 +1147,229 @@ async function appsVerifyHandler(ctx) {
999
1147
  }
1000
1148
  }
1001
1149
 
1150
+ async function appsScreenshotHandler(ctx) {
1151
+ const projectDir = resolveProjectDir(ctx.args.dir || '.');
1152
+ const problems = detectProjectProblems(projectDir);
1153
+ if (problems.length) {
1154
+ throw usageError(`Project has problems:\n${problems.map((p) => ` - ${p}`).join('\n')}`);
1155
+ }
1156
+
1157
+ if (!isAgentBrowserAvailable()) {
1158
+ throw usageError('agent-browser is not available on PATH. It ships with the Notis desktop app; open it once, then retry.');
1159
+ }
1160
+
1161
+ const mode = ctx.options.mode || 'stub';
1162
+ if (!['stub', 'live'].includes(mode)) {
1163
+ throw usageError('--mode must be either "stub" or "live".');
1164
+ }
1165
+
1166
+ let linkedState = null;
1167
+ if (mode === 'live') {
1168
+ if (!ctx.runtime.jwt) {
1169
+ throw usageError('Live mode requires CLI auth. Open the Notis desktop app, sign in, and retry.');
1170
+ }
1171
+ linkedState = readLinkedState(projectDir);
1172
+ if (!linkedState?.app_id) {
1173
+ throw usageError('Live mode requires a linked app. Run `notis apps link <app-id> .` first.');
1174
+ }
1175
+ }
1176
+
1177
+ if (!ctx.options.skipBuild) {
1178
+ await buildArtifact(projectDir);
1179
+ }
1180
+
1181
+ const manifest = readManifest(projectDir);
1182
+ const appConfig = await loadAppConfig(projectDir);
1183
+ const selectedRouteSlugs = parseRouteSlugs(ctx.options.routes);
1184
+ const routes = routeSelection(manifest, selectedRouteSlugs);
1185
+ const screenshotSlots = screenshotIndexByRouteSlug(manifest);
1186
+ if (!routes.length) {
1187
+ throw usageError('No routes to screenshot.');
1188
+ }
1189
+
1190
+ const width = parsePositiveInt(ctx.options.width) || 2000;
1191
+ const height = parsePositiveInt(ctx.options.height) || 1250;
1192
+ const outputDir = ctx.options.outputDir
1193
+ ? resolveProjectDir(ctx.options.outputDir)
1194
+ : join(projectDir, 'metadata');
1195
+
1196
+ const port = parsePort(ctx.options.port) || await getAvailablePort();
1197
+ const appSlug = slugify(appConfig.name || manifest.app?.name || 'app') || 'app';
1198
+ const baseUrl = `http://127.0.0.1:${port}/a/${appSlug}`;
1199
+ const browserSessionName = `notis-screenshot-${process.pid}`;
1200
+ const browserSessionNames = [];
1201
+ const configuredScreenshots = resolveListingScreenshots(projectDir, appConfig)
1202
+ .filter((screenshot) => screenshot.route)
1203
+ .filter((screenshot) => !selectedRouteSlugs || selectedRouteSlugs.includes(screenshot.route));
1204
+ const routeBySlug = new Map(routes.map((route) => [route.slug, route]));
1205
+ const captures = configuredScreenshots.length > 0
1206
+ ? configuredScreenshots.map((screenshot) => {
1207
+ const route = routeBySlug.get(screenshot.route);
1208
+ if (!route) {
1209
+ throw usageError(
1210
+ `Screenshot ${screenshot.path} references unavailable route slug "${screenshot.route}".`,
1211
+ { available_routes: routes.map((entry) => entry.slug) },
1212
+ );
1213
+ }
1214
+ return {
1215
+ route,
1216
+ scenario: screenshot.scenario,
1217
+ focus: screenshot.focus,
1218
+ theme: screenshot.theme || 'light',
1219
+ fileName: basename(screenshot.path),
1220
+ };
1221
+ })
1222
+ : routes.map((route, index) => ({
1223
+ route,
1224
+ scenario: null,
1225
+ focus: null,
1226
+ theme: 'light',
1227
+ fileName: `screenshot-${screenshotSlots.get(route.slug) || index + 1}.png`,
1228
+ }));
1229
+ const rawOutputDir = ctx.options.raw
1230
+ ? null
1231
+ : mkdtempSync(join(tmpdir(), 'notis-store-screenshots-'));
1232
+ let devServer = null;
1233
+ let browserTouched = false;
1234
+
1235
+ try {
1236
+ devServer = await startAppDevServer({
1237
+ apps: [{ slug: appSlug, projectDir, appId: linkedState?.app_id || 'harness-app' }],
1238
+ port,
1239
+ watch: false,
1240
+ harness: {
1241
+ mode,
1242
+ apiBase: ctx.runtime.apiBase,
1243
+ jwt: mode === 'live' ? ctx.runtime.jwt : null,
1244
+ },
1245
+ log: () => {},
1246
+ logError: (message) => process.stderr.write(`${message}\n`),
1247
+ });
1248
+ browserTouched = true;
1249
+
1250
+ mkdirSync(outputDir, { recursive: true });
1251
+ const results = [];
1252
+ for (const capture of captures) {
1253
+ const { route, scenario, focus, theme, fileName } = capture;
1254
+ const screenshotPath = join(outputDir, fileName);
1255
+ const browserScreenshotPath = rawOutputDir
1256
+ ? join(rawOutputDir, fileName)
1257
+ : screenshotPath;
1258
+ const scenarioParam = scenario ? `&scenario=${encodeURIComponent(scenario)}` : '';
1259
+ const themeParam = `&theme=${encodeURIComponent(theme || 'light')}`;
1260
+ // Keep scenario captures on independent pages. Chromium can otherwise
1261
+ // reuse stale compositor layers when the next screenshot changes the
1262
+ // same app route into a substantially different state.
1263
+ const captureSessionName = `${browserSessionName}-${results.length + 1}`;
1264
+ browserSessionNames.push(captureSessionName);
1265
+ let result = await captureHarnessScreenshot({
1266
+ url: `${baseUrl}/harness?route=${encodeURIComponent(route.slug)}${scenarioParam}${themeParam}`,
1267
+ sessionName: captureSessionName,
1268
+ screenshotPath: browserScreenshotPath,
1269
+ focusSelector: ctx.options.raw ? null : focus,
1270
+ width,
1271
+ height,
1272
+ timeoutMs: Number.parseInt(ctx.globalOptions.timeoutMs || '', 10) || 15_000,
1273
+ });
1274
+ let presentation = { mode: 'raw' };
1275
+ if (result.ok && rawOutputDir) {
1276
+ try {
1277
+ presentation = await composeStoreScreenshot({
1278
+ inputPath: browserScreenshotPath,
1279
+ outputPath: screenshotPath,
1280
+ width,
1281
+ height,
1282
+ accent: appConfig.accent,
1283
+ seed: appConfig.name || manifest.app?.name || appSlug,
1284
+ focused: Boolean(focus),
1285
+ theme: theme || 'light',
1286
+ });
1287
+ } catch (error) {
1288
+ result = {
1289
+ ...result,
1290
+ ok: false,
1291
+ screenshotPath: null,
1292
+ tool_error: {
1293
+ phase: 'compose',
1294
+ message: error instanceof Error ? error.message : String(error),
1295
+ },
1296
+ };
1297
+ }
1298
+ }
1299
+ results.push({
1300
+ route: route.slug,
1301
+ path: route.path,
1302
+ file: relative(projectDir, screenshotPath),
1303
+ ok: result.ok,
1304
+ errors: result.errors || [],
1305
+ timed_out: Boolean(result.timed_out),
1306
+ tool_error: result.tool_error,
1307
+ framing: result.framing || null,
1308
+ theme: theme || 'light',
1309
+ presentation,
1310
+ });
1311
+ }
1312
+
1313
+ const captured = results.filter((r) => r.ok);
1314
+ const failed = results.filter((r) => !r.ok);
1315
+ const warnings = [];
1316
+
1317
+ // Drop stale screenshots only after a full refresh. A selected-route
1318
+ // capture intentionally leaves other listing screenshots untouched.
1319
+ if (shouldPruneStaleScreenshotFiles(selectedRouteSlugs, failed.length)) {
1320
+ pruneStaleScreenshotFiles(outputDir, captures.length);
1321
+ }
1322
+ if (failed.length) {
1323
+ warnings.push(`${failed.length}/${results.length} routes failed to capture; see results.`);
1324
+ }
1325
+
1326
+ ctx.output.emitSuccess({
1327
+ ok: failed.length === 0,
1328
+ command: ctx.spec.command_path.join(' '),
1329
+ data: {
1330
+ project_dir: projectDir,
1331
+ output_dir: outputDir,
1332
+ mode,
1333
+ presentation: ctx.options.raw ? 'raw' : 'framed',
1334
+ viewport: { width, height },
1335
+ summary: { total: results.length, captured: captured.length, failed: failed.length },
1336
+ results,
1337
+ },
1338
+ humanSummary: failed.length === 0
1339
+ ? `Captured ${captured.length} screenshot(s) to ${relative(projectDir, outputDir) || 'metadata'}/.`
1340
+ : `Captured ${captured.length}/${results.length} screenshots; ${failed.length} failed.`,
1341
+ warnings,
1342
+ });
1343
+ return screenshotExitCode(failed.length);
1344
+ } finally {
1345
+ if (browserTouched) {
1346
+ await Promise.all(browserSessionNames.map(async (sessionName) => {
1347
+ try {
1348
+ await closeAgentBrowserSession(sessionName);
1349
+ } catch {
1350
+ // Ignore browser cleanup failures.
1351
+ }
1352
+ }));
1353
+ }
1354
+ if (devServer) {
1355
+ try {
1356
+ await devServer.close();
1357
+ } catch {
1358
+ // Ignore server cleanup failures.
1359
+ }
1360
+ }
1361
+ if (rawOutputDir) {
1362
+ rmSync(rawOutputDir, { recursive: true, force: true });
1363
+ }
1364
+ }
1365
+ }
1366
+
1002
1367
  async function appsLinkHandler(ctx) {
1003
1368
  const projectDir = resolveProjectDir(ctx.args.dir || '.');
1004
1369
  const appId = ctx.args.appId;
1005
1370
 
1371
+ await assertLinkTarget(ctx.runtime, appId);
1372
+
1006
1373
  writeLinkedState(projectDir, {
1007
1374
  app_id: appId,
1008
1375
  linked_at: new Date().toISOString(),
@@ -1106,6 +1473,7 @@ async function appsDeployHandler(ctx) {
1106
1473
  files,
1107
1474
  source_files: sourceFiles,
1108
1475
  manifest,
1476
+ ...appRowFieldsFromManifest(manifest),
1109
1477
  ...(baseVersion !== undefined ? { base_version: baseVersion } : {}),
1110
1478
  },
1111
1479
  mutating: true,
@@ -1165,6 +1533,95 @@ async function appsDeployHandler(ctx) {
1165
1533
  });
1166
1534
  }
1167
1535
 
1536
+ function deployedAppVersion(app) {
1537
+ const value = app?.current_version ?? app?.manifest?.version;
1538
+ const parsed = Number(value);
1539
+ return Number.isFinite(parsed) ? parsed : 0;
1540
+ }
1541
+
1542
+ async function appsPublishHandler(ctx) {
1543
+ if (ctx.options.confirmReady !== true) {
1544
+ throw usageError(
1545
+ 'Store submission requires explicit user confirmation that App Details is ready. ' +
1546
+ 'After confirmation, rerun with --confirm-ready.',
1547
+ );
1548
+ }
1549
+
1550
+ const projectDir = resolveProjectDir(ctx.args.dir || '.');
1551
+ const appId = requireLinkedAppId(projectDir, ctx.options.appId);
1552
+ const linkedState = readLinkedState(projectDir);
1553
+ const appConfig = await loadAppConfig(projectDir);
1554
+ const readiness = inspectListingReadiness(projectDir, appConfig);
1555
+ if (!readiness.ready) {
1556
+ throw usageError(
1557
+ `Store listing is not ready:\n${readiness.errors.map((error) => ` - ${error}`).join('\n')}`,
1558
+ );
1559
+ }
1560
+
1561
+ const detailResult = await runToolCommand({
1562
+ runtime: ctx.runtime,
1563
+ toolName: GET_APP_TOOL,
1564
+ arguments_: { app_id: appId },
1565
+ });
1566
+ const detail = detailResult.payload || {};
1567
+ const app = detail.app || {};
1568
+ if (!app.id) {
1569
+ throw usageError(`Could not load deployed app ${appId}.`);
1570
+ }
1571
+ if (!['team', 'public_store_hidden'].includes(app.visibility)) {
1572
+ throw usageError('Set the app visibility to Team or Public before Store submission.');
1573
+ }
1574
+
1575
+ const remoteVersion = deployedAppVersion(app);
1576
+ if (remoteVersion <= 0) {
1577
+ throw usageError('App has no deployed source. Run `notis apps deploy` first.');
1578
+ }
1579
+ if (
1580
+ linkedState?.app_id !== appId
1581
+ || !Number.isFinite(linkedState?.version)
1582
+ || linkedState.version !== remoteVersion
1583
+ ) {
1584
+ throw usageError(
1585
+ `Local project is not confirmed at deployed version ${remoteVersion}. ` +
1586
+ 'Run `notis apps deploy` from this project before submitting it.',
1587
+ );
1588
+ }
1589
+
1590
+ const activeSubmission = detail.active_submission || app.active_submission || null;
1591
+ if (activeSubmission?.status === 'pending_review') {
1592
+ throw usageError(
1593
+ `A Store submission is already in review${activeSubmission.github_pr_url ? `: ${activeSubmission.github_pr_url}` : '.'}`,
1594
+ );
1595
+ }
1596
+ if (activeSubmission?.status === 'removal_pending_review') {
1597
+ throw usageError('Store removal is currently in review. Wait for it to finish before submitting an update.');
1598
+ }
1599
+
1600
+ const result = await httpRequest({
1601
+ runtime: ctx.runtime,
1602
+ method: 'POST',
1603
+ path: '/portal_apps/publish',
1604
+ body: { app_id: appId },
1605
+ });
1606
+ const submission = result.payload.submission || result.payload;
1607
+ const reviewStatus = submission.status || 'pending_review';
1608
+ return ctx.output.emitSuccess({
1609
+ command: ctx.spec.command_path.join(' '),
1610
+ data: {
1611
+ app_id: appId,
1612
+ source_version: submission.source_version || remoteVersion,
1613
+ submission,
1614
+ },
1615
+ humanSummary: reviewStatus === 'merged'
1616
+ ? `Published app ${appId} to the Store at version ${submission.source_version || remoteVersion}`
1617
+ : `Submitted app ${appId} version ${submission.source_version || remoteVersion} for Store review`,
1618
+ hints: submission.github_pr_url
1619
+ ? [{ command: submission.github_pr_url, reason: 'Review the Store registry pull request' }]
1620
+ : [],
1621
+ meta: { mutating: true, request_id: result.requestId },
1622
+ });
1623
+ }
1624
+
1168
1625
  async function appsDoctorHandler(ctx) {
1169
1626
  const projectDir = resolveProjectDir(ctx.args.dir || '.');
1170
1627
  const problems = detectProjectProblems(projectDir);
@@ -1316,10 +1773,10 @@ export const appsCommandSpecs = [
1316
1773
  },
1317
1774
  {
1318
1775
  command_path: ['apps', 'verify'],
1319
- summary: 'Headless render smoke each route in a stub-runtime harness.',
1776
+ summary: 'Validate every route and the production Store listing contract.',
1320
1777
  when_to_use:
1321
- 'After notis apps build, before deploy. Catches render-time crashes ' +
1322
- 'and missing runtime calls that the build step cannot detect.',
1778
+ 'After notis apps screenshot, before deploy. Catches render-time crashes, ' +
1779
+ 'missing runtime calls, and incomplete listing media that the build step cannot detect.',
1323
1780
  args_schema: {
1324
1781
  arguments: [
1325
1782
  { token: '[dir]', key: 'dir', description: 'Project directory (default: current dir).' },
@@ -1345,6 +1802,41 @@ export const appsCommandSpecs = [
1345
1802
  backend_call: { type: 'local', name: 'verify_harness' },
1346
1803
  handler: appsVerifyHandler,
1347
1804
  },
1805
+ {
1806
+ command_path: ['apps', 'screenshot'],
1807
+ summary: 'Capture configured listing route/scenario states via the headless harness.',
1808
+ when_to_use:
1809
+ 'Generate the 3–6 declared metadata/screenshot-N.png files for the App Store listing. Apps are ' +
1810
+ 'icon-led (like Raycast) — there is no cover image, only these screenshots. ' +
1811
+ 'Each screenshot may set a focus selector to remove empty canvas and a light or dark theme that also controls its Store frame. ' +
1812
+ 'Run before notis apps verify / deploy / publish.',
1813
+ args_schema: {
1814
+ arguments: [
1815
+ { token: '[dir]', key: 'dir', description: 'Project directory (default: current dir).' },
1816
+ ],
1817
+ options: [
1818
+ { flags: '--routes <slugs>', description: 'Comma-separated route slugs. Default: every configured screenshot state.' },
1819
+ { flags: '--port <n>', description: 'Loopback port. Default: auto-pick.' },
1820
+ { flags: '--width <px>', description: 'Viewport width. Default: 2000.' },
1821
+ { flags: '--height <px>', description: 'Viewport height. Default: 1250 (16:10).' },
1822
+ { flags: '--output-dir <dir>', description: 'Where to write screenshot-N.png. Default: metadata/.' },
1823
+ { flags: '--mode <mode>', description: 'stub | live. Default stub. Live renders against real data via the CLI JWT (requires a linked app), so screenshots show actual content instead of empty states.' },
1824
+ { flags: '--raw', description: 'Write the unframed harness capture instead of the default Store presentation.' },
1825
+ { flags: '--skip-build', description: 'Skip notis apps build; reuse existing .notis/output/.' },
1826
+ ],
1827
+ },
1828
+ examples: [
1829
+ 'notis apps screenshot # honors notis.config.ts screenshot scenarios',
1830
+ 'notis apps screenshot --routes home,history',
1831
+ 'notis apps screenshot --mode live # populated screenshots from real data',
1832
+ 'notis apps screenshot --raw # diagnostic capture without Store framing',
1833
+ ],
1834
+ mutates: true,
1835
+ idempotent: true,
1836
+ require_auth: false,
1837
+ backend_call: { type: 'local', name: 'screenshot_routes' },
1838
+ handler: appsScreenshotHandler,
1839
+ },
1348
1840
  {
1349
1841
  command_path: ['apps', 'link'],
1350
1842
  summary: 'Link a local project to a remote Notis app.',
@@ -1406,6 +1898,27 @@ export const appsCommandSpecs = [
1406
1898
  backend_call: { type: 'tool', name: SAVE_APP_FILES_TOOL },
1407
1899
  handler: appsDeployHandler,
1408
1900
  },
1901
+ {
1902
+ command_path: ['apps', 'publish'],
1903
+ summary: 'Submit the deployed app for Store review.',
1904
+ when_to_use:
1905
+ 'After the user explicitly confirms the App Details page and Store listing are ready. Requires the current local project to match the latest deployed version.',
1906
+ args_schema: {
1907
+ arguments: [
1908
+ { token: '[dir]', key: 'dir', description: 'Project directory (default: current dir).' },
1909
+ ],
1910
+ options: [
1911
+ { flags: '--app-id <id>', description: 'Override linked app ID.' },
1912
+ { flags: '--confirm-ready', description: 'Confirm the user approved the current App Details page for Store submission.' },
1913
+ ],
1914
+ },
1915
+ examples: ['notis apps publish --confirm-ready', 'notis apps publish ./my-app --confirm-ready'],
1916
+ mutates: true,
1917
+ idempotent: false,
1918
+ require_auth: true,
1919
+ backend_call: { type: 'http', name: 'portal_apps/publish' },
1920
+ handler: appsPublishHandler,
1921
+ },
1409
1922
  {
1410
1923
  command_path: ['apps', 'doctor'],
1411
1924
  summary: 'Check project health and readiness.',