@notis_ai/cli 0.2.13 → 0.2.14

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 (53) hide show
  1. package/README.md +45 -3
  2. package/dist/scaffolds/notis-database/packages/sdk/src/config.ts +40 -2
  3. package/dist/scaffolds/notis-database/packages/sdk/src/documents.ts +21 -0
  4. package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useCloudComputer.ts +97 -0
  5. package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useDatabaseSubscription.ts +76 -0
  6. package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useHandover.ts +75 -0
  7. package/dist/scaffolds/notis-database/packages/sdk/src/index.ts +17 -0
  8. package/dist/scaffolds/notis-database/packages/sdk/src/runtime.ts +132 -1
  9. package/dist/scaffolds/notis-journal/packages/sdk/src/config.ts +40 -2
  10. package/dist/scaffolds/notis-journal/packages/sdk/src/documents.ts +21 -0
  11. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useCloudComputer.ts +97 -0
  12. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useDatabaseSubscription.ts +76 -0
  13. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useHandover.ts +75 -0
  14. package/dist/scaffolds/notis-journal/packages/sdk/src/index.ts +17 -0
  15. package/dist/scaffolds/notis-journal/packages/sdk/src/runtime.ts +132 -1
  16. package/dist/scaffolds/notis-journal/src/mock-runtime.ts +2 -0
  17. package/dist/scaffolds/notis-notes/packages/sdk/src/config.ts +40 -2
  18. package/dist/scaffolds/notis-notes/packages/sdk/src/documents.ts +21 -0
  19. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useCloudComputer.ts +97 -0
  20. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useDatabaseSubscription.ts +76 -0
  21. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useHandover.ts +75 -0
  22. package/dist/scaffolds/notis-notes/packages/sdk/src/index.ts +17 -0
  23. package/dist/scaffolds/notis-notes/packages/sdk/src/runtime.ts +132 -1
  24. package/dist/scaffolds/notis-random/packages/sdk/src/config.ts +40 -2
  25. package/dist/scaffolds/notis-random/packages/sdk/src/documents.ts +21 -0
  26. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useCloudComputer.ts +97 -0
  27. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useDatabaseSubscription.ts +76 -0
  28. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useHandover.ts +75 -0
  29. package/dist/scaffolds/notis-random/packages/sdk/src/index.ts +17 -0
  30. package/dist/scaffolds/notis-random/packages/sdk/src/runtime.ts +132 -1
  31. package/package.json +1 -1
  32. package/skills/notis-apps/SKILL.md +11 -7
  33. package/skills/notis-apps/cli.md +8 -3
  34. package/src/command-specs/apps.js +238 -50
  35. package/src/command-specs/handover.js +374 -0
  36. package/src/command-specs/index.js +3 -0
  37. package/src/command-specs/tools.js +6 -0
  38. package/src/runtime/app-dev-server.js +17 -8
  39. package/src/runtime/app-platform.js +218 -6
  40. package/src/runtime/delegated-context.js +68 -0
  41. package/src/runtime/git.js +233 -0
  42. package/src/runtime/transport.js +19 -2
  43. package/template/.harness/index.html.tmpl +116 -47
  44. package/template/packages/sdk/src/config.ts +52 -0
  45. package/template/packages/sdk/src/documents.ts +21 -0
  46. package/template/packages/sdk/src/hooks/useCloudComputer.ts +97 -0
  47. package/template/packages/sdk/src/hooks/useDatabaseSubscription.ts +76 -0
  48. package/template/packages/sdk/src/hooks/useHandover.ts +75 -0
  49. package/template/packages/sdk/src/index.ts +17 -0
  50. package/template/packages/sdk/src/runtime.ts +132 -1
  51. package/template/metadata/screenshot-1.png +0 -0
  52. package/template/metadata/screenshot-2.png +0 -0
  53. package/template/metadata/screenshot-3.png +0 -0
@@ -13,7 +13,7 @@ import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync } from 'node:fs
13
13
  import { tmpdir } from 'node:os';
14
14
  import { basename, join, relative, resolve } from 'node:path';
15
15
 
16
- import { EXIT_CODES, usageError } from '../runtime/errors.js';
16
+ import { CliError, EXIT_CODES, usageError } from '../runtime/errors.js';
17
17
  import { formatTable } from '../runtime/output.js';
18
18
  import {
19
19
  resolveProjectDir,
@@ -27,12 +27,14 @@ import {
27
27
  requireLinkedAppId,
28
28
  scaffoldProject,
29
29
  loadScaffoldCatalog,
30
+ findUnknownScreenshotScenarios,
30
31
  inspectListingReadiness,
31
32
  resolveListingScreenshots,
32
33
  collectArtifactFiles,
33
34
  collectSourceFiles,
34
35
  resolveConfiguredAppSkills,
35
36
  normalizeAppCapabilities,
37
+ normalizeAppSkillManifestPath,
36
38
  directDeploy,
37
39
  pullAppSource,
38
40
  } from '../runtime/app-platform.js';
@@ -72,6 +74,7 @@ const LIST_APPS_TOOL = 'LOCAL_NOTIS_LIST_APPS';
72
74
  const CREATE_APP_TOOL = 'LOCAL_NOTIS_CREATE_APP';
73
75
  const DUPLICATE_APP_TOOL = 'LOCAL_NOTIS_DUPLICATE_APP';
74
76
  const SAVE_APP_FILES_TOOL = 'LOCAL_NOTIS_SAVE_APP_FILES';
77
+ const APP_DEPLOY_TIMEOUT_MS = 120_000;
75
78
 
76
79
  // ---------------------------------------------------------------------------
77
80
  // Formatters
@@ -404,7 +407,17 @@ function harnessErrorMessage(error) {
404
407
  return error.message || error.reason || error.type || JSON.stringify(error);
405
408
  }
406
409
 
407
- function assertHarnessResult(result, route, databaseSlugs) {
410
+ function runtimeCallLabel(call) {
411
+ if (call?.op === 'callTool') {
412
+ return call?.args?.name || 'callTool';
413
+ }
414
+ if (call?.op === 'request') {
415
+ return `request ${call?.args?.path || ''}`.trim();
416
+ }
417
+ return call?.op || 'runtime call';
418
+ }
419
+
420
+ function assertHarnessResult(result, route, databaseSlugs, mode = 'stub') {
408
421
  const assertions = [];
409
422
  if (result.tool_error) {
410
423
  assertions.push({
@@ -436,8 +449,9 @@ function assertHarnessResult(result, route, databaseSlugs) {
436
449
  details: error,
437
450
  });
438
451
  }
452
+ const runtimeCalls = result.runtimeCalls || [];
439
453
  const declaredDatabaseSet = new Set(databaseSlugs);
440
- const databaseQueries = (result.runtimeCalls || []).filter(
454
+ const databaseQueries = runtimeCalls.filter(
441
455
  (call) =>
442
456
  call?.op === 'callTool'
443
457
  && localNotisToolSlug(call?.args?.name) === 'LOCAL_NOTIS_DATABASE_QUERY',
@@ -465,6 +479,36 @@ function assertHarnessResult(result, route, databaseSlugs) {
465
479
  details: { databaseSlug: collectionDatabase },
466
480
  });
467
481
  }
482
+ if (mode === 'live') {
483
+ // In live mode an app that catches every failed call and renders its error
484
+ // state still mounts cleanly, so the render assertions above all pass. Only
485
+ // the recorded outcomes reveal that nothing real came back.
486
+ if (runtimeCalls.length > 0 && runtimeCalls.every((call) => call?.ok === false)) {
487
+ assertions.push({
488
+ ok: false,
489
+ code: 'all_runtime_calls_failed',
490
+ message: `Route "${route.slug}" rendered without data: all ${runtimeCalls.length} runtime call(s) failed. First error: ${runtimeCalls[0]?.error || 'unknown'}.`,
491
+ details: {
492
+ failed: runtimeCalls.map((call) => ({ call: runtimeCallLabel(call), error: call?.error || null })),
493
+ },
494
+ });
495
+ }
496
+ for (const databaseSlug of databaseSlugs) {
497
+ const queries = databaseQueries.filter(
498
+ (call) => call?.args?.arguments?.database_slug === databaseSlug,
499
+ );
500
+ // A call still in flight when the harness was read has ok === null; only
501
+ // an explicit failure with no successful sibling is a real problem.
502
+ if (queries.some((call) => call?.ok === false) && !queries.some((call) => call?.ok === true)) {
503
+ assertions.push({
504
+ ok: false,
505
+ code: 'failed_database_query',
506
+ message: `Route "${route.slug}" never got a successful "${databaseSlug}" query. Last error: ${queries[queries.length - 1]?.error || 'unknown'}.`,
507
+ details: { databaseSlug },
508
+ });
509
+ }
510
+ }
511
+ }
468
512
  return assertions;
469
513
  }
470
514
 
@@ -479,6 +523,9 @@ function renderVerifyReport({ summary, results, noBrowser }) {
479
523
  lines.push(`${marker.padEnd(4)} ${result.route.padEnd(18)} ${result.url}`);
480
524
  for (const assertion of result.assertions || []) {
481
525
  lines.push(` - ${assertion.message}`);
526
+ for (const failure of assertion.details?.failed || []) {
527
+ lines.push(` ${failure.call}: ${failure.error || 'unknown error'}`);
528
+ }
482
529
  }
483
530
  }
484
531
  if (noBrowser) {
@@ -516,7 +563,7 @@ function buildManifestForDev(appConfig) {
516
563
  tools: appConfig.tools || [],
517
564
  skills: (appConfig.skills || []).map((skill) => ({
518
565
  key: skill.key,
519
- path: String(skill.path || '').replace(/^\.\/+/, ''),
566
+ path: normalizeAppSkillManifestPath(skill.path),
520
567
  name: skill.name,
521
568
  description: skill.description || null,
522
569
  })),
@@ -524,7 +571,14 @@ function buildManifestForDev(appConfig) {
524
571
  };
525
572
  }
526
573
 
527
- export function buildEnsureDevInstallArguments({ appConfig, manifest, linkedState, skills }) {
574
+ export function buildEnsureDevInstallArguments({
575
+ appConfig,
576
+ manifest,
577
+ linkedState,
578
+ skills,
579
+ useInstalledDatabases = false,
580
+ approvedCapabilities = null,
581
+ }) {
528
582
  const devSlug = buildDevInstallSlug(appConfig.name);
529
583
  const arguments_ = {
530
584
  dev_slug: devSlug,
@@ -535,14 +589,87 @@ export function buildEnsureDevInstallArguments({ appConfig, manifest, linkedStat
535
589
  if (linkedState?.dev_app_id) {
536
590
  arguments_.app_id = linkedState.dev_app_id;
537
591
  }
592
+ // Sent on every ensure call, including as `false`, so dropping `--live-data`
593
+ // puts the next session back on its own dev copies.
594
+ arguments_.use_installed_databases = Boolean(useInstalledDatabases);
595
+ if (Array.isArray(approvedCapabilities) && approvedCapabilities.length > 0) {
596
+ arguments_.approved_capabilities = approvedCapabilities;
597
+ }
538
598
  return arguments_;
539
599
  }
540
600
 
601
+ const CLOUD_SHELL_CONSENT_KEY = 'cloud_computer_shell_consent';
602
+
603
+ /**
604
+ * Collect the developer's explicit approval for `cloudComputer: 'shell'`.
605
+ *
606
+ * The server never grants shell from authorship alone — holding an app's source
607
+ * (a scaffold, a pulled repo) is not consent to full-authority commands on the
608
+ * cloud computer — so `apps dev` asks once, records the answer in the linked
609
+ * state, and passes the grant with the ensure call. Runs before the parallel
610
+ * ensure fan-out so the prompt cannot interleave.
611
+ */
612
+ export async function resolveCloudShellConsent({
613
+ appConfig,
614
+ projectDir,
615
+ grantCloudShell = false,
616
+ logger = console,
617
+ }) {
618
+ const capabilities = normalizeAppCapabilities(appConfig.capabilities);
619
+ if (capabilities.cloudComputer !== 'shell') {
620
+ return null;
621
+ }
622
+ const approved = ['cloud_computer_read', 'cloud_computer_shell'];
623
+ const linkedState = readLinkedState(projectDir) || {};
624
+ const recordDecision = (decision) => {
625
+ writeLinkedState(projectDir, { ...linkedState, [CLOUD_SHELL_CONSENT_KEY]: decision });
626
+ };
627
+
628
+ if (grantCloudShell) {
629
+ recordDecision('granted');
630
+ return approved;
631
+ }
632
+ const recorded = linkedState[CLOUD_SHELL_CONSENT_KEY];
633
+ if (recorded === 'granted') {
634
+ return approved;
635
+ }
636
+ const declineHint =
637
+ `${appConfig.name}: cloud computer shell stays denied for this dev session. `
638
+ + 'Re-run with --grant-cloud-shell to approve it.';
639
+ if (recorded === 'declined') {
640
+ logger.warn(declineHint);
641
+ return null;
642
+ }
643
+ if (!process.stdin.isTTY || !process.stderr.isTTY) {
644
+ logger.warn(declineHint);
645
+ return null;
646
+ }
647
+ const { createInterface } = await import('node:readline/promises');
648
+ const prompt = createInterface({ input: process.stdin, output: process.stderr });
649
+ try {
650
+ const answer = (await prompt.question(
651
+ `${appConfig.name} declares cloudComputer: 'shell' - its views will run commands and `
652
+ + 'read or write files on your cloud computer with the same authority as your own '
653
+ + 'agent. Allow for this dev app? [y/N] ',
654
+ )).trim().toLowerCase();
655
+ const granted = answer === 'y' || answer === 'yes';
656
+ recordDecision(granted ? 'granted' : 'declined');
657
+ if (!granted) {
658
+ logger.warn(declineHint);
659
+ }
660
+ return granted ? approved : null;
661
+ } finally {
662
+ prompt.close();
663
+ }
664
+ }
665
+
541
666
  export async function ensureDevInstall({
542
667
  ctx,
543
668
  appConfig,
544
669
  projectDir,
545
670
  idempotencyKey,
671
+ useInstalledDatabases = false,
672
+ approvedCapabilities = null,
546
673
  runTool = runToolCommand,
547
674
  }) {
548
675
  if (!appConfig.name) {
@@ -581,7 +708,14 @@ export async function ensureDevInstall({
581
708
  linkedApp = null;
582
709
  }
583
710
  }
584
- const ensureArguments = buildEnsureDevInstallArguments({ appConfig, manifest, linkedState, skills });
711
+ const ensureArguments = buildEnsureDevInstallArguments({
712
+ appConfig,
713
+ manifest,
714
+ linkedState,
715
+ skills,
716
+ useInstalledDatabases,
717
+ approvedCapabilities,
718
+ });
585
719
  const ensureResult = await runTool({
586
720
  runtime: ctx.runtime,
587
721
  toolName: ENSURE_DEV_APP_INSTALLATION_TOOL,
@@ -613,6 +747,7 @@ export async function ensureDevInstall({
613
747
  targetAppId: linkedState?.app_id || null,
614
748
  targetAppSlug: linkedApp?.slug || null,
615
749
  databaseMaterialization: ensureResult.payload.database_materialization || { created: [], unresolved: [] },
750
+ liveData: ensureResult.payload.live_data || null,
616
751
  };
617
752
  }
618
753
 
@@ -631,6 +766,12 @@ function databaseMaterializationWarnings(apps) {
631
766
  return warnings;
632
767
  }
633
768
 
769
+ function liveDataWarnings(apps) {
770
+ return apps
771
+ .filter((app) => app.liveData?.warning)
772
+ .map((app) => `${app.name}: ${app.liveData.warning}`);
773
+ }
774
+
634
775
  async function getAccessibleApp(runtime, appId, runTool = runToolCommand) {
635
776
  const result = await runTool({
636
777
  runtime,
@@ -807,6 +948,7 @@ async function appsDevHandler(ctx) {
807
948
  const desktopAppName = resolveDevelopmentDesktopAppName(ctx.runtime);
808
949
  const desktopBundleId = resolveDevelopmentDesktopBundleId(ctx.runtime);
809
950
  const sessionId = randomUUID();
951
+ const useInstalledDatabases = Boolean(ctx.options?.liveData);
810
952
 
811
953
  const candidates = [];
812
954
  for (const projectDir of appDirs) {
@@ -818,7 +960,14 @@ async function appsDevHandler(ctx) {
818
960
  if (!devSlug) {
819
961
  throw usageError(`notis.config.ts name in ${projectDir} must slugify to a non-empty value.`);
820
962
  }
821
- candidates.push({ appConfig, devSlug, projectDir });
963
+ // Sequential on purpose: the shell-consent prompt must never interleave
964
+ // with another app's, and the ensure fan-out below runs in parallel.
965
+ const approvedCapabilities = await resolveCloudShellConsent({
966
+ appConfig,
967
+ projectDir,
968
+ grantCloudShell: Boolean(ctx.options?.grantCloudShell),
969
+ });
970
+ candidates.push({ appConfig, devSlug, projectDir, approvedCapabilities });
822
971
  }
823
972
 
824
973
  const seenSlugs = new Map();
@@ -833,7 +982,7 @@ async function appsDevHandler(ctx) {
833
982
 
834
983
  const registrationStartedAt = process.hrtime.bigint();
835
984
  const registered = await Promise.all(
836
- candidates.map(async ({ appConfig, devSlug, projectDir }) => {
985
+ candidates.map(async ({ appConfig, devSlug, projectDir, approvedCapabilities }) => {
837
986
  const appStartedAt = process.hrtime.bigint();
838
987
  try {
839
988
  return await ensureDevInstall({
@@ -841,6 +990,8 @@ async function appsDevHandler(ctx) {
841
990
  appConfig,
842
991
  projectDir,
843
992
  idempotencyKey: nextDevInstallIdempotencyKey(ctx.globalOptions, devSlug),
993
+ useInstalledDatabases,
994
+ approvedCapabilities,
844
995
  });
845
996
  } finally {
846
997
  logAppsTiming('ensure-dev-install', {
@@ -881,7 +1032,7 @@ async function appsDevHandler(ctx) {
881
1032
  desktopScheme,
882
1033
  sessionId,
883
1034
  );
884
- const warnings = databaseMaterializationWarnings(apps);
1035
+ const warnings = [...databaseMaterializationWarnings(apps), ...liveDataWarnings(apps)];
885
1036
 
886
1037
  const devServer = await startAppDevServer({
887
1038
  apps: apps.map((app) => ({
@@ -958,12 +1109,16 @@ async function appsDevHandler(ctx) {
958
1109
  created: app.created,
959
1110
  linked_app_id: app.linkedAppId,
960
1111
  database_materialization: app.databaseMaterialization,
1112
+ live_data: app.liveData,
961
1113
  })),
962
1114
  },
963
1115
  warnings,
964
1116
  humanSummary: [
965
1117
  `Running apps dev against ${apiBase} as ${identity} (mode: ${mode})`,
966
1118
  `Target desktop: ${desktopAppName}`,
1119
+ ...(useInstalledDatabases
1120
+ ? [`Databases: ${apps.filter((app) => app.liveData?.enabled).length}/${apps.length} app(s) reading the installed app's live rows`]
1121
+ : []),
967
1122
  '',
968
1123
  `Open in desktop: ${developmentTabUrl}`,
969
1124
  '',
@@ -1120,10 +1275,16 @@ async function appsVerifyHandler(ctx) {
1120
1275
  const manifest = readManifest(projectDir);
1121
1276
  const appConfig = await loadAppConfig(projectDir);
1122
1277
  const listing = inspectListingReadiness(projectDir, appConfig);
1123
- if (listing.errors.length) {
1278
+ // Store readiness is a publish concern, not a render concern. Verify reports
1279
+ // it so the gaps stay visible while the app is still being built; only
1280
+ // --listing (and `apps publish`) turn it back into a hard gate.
1281
+ if (listing.errors.length && ctx.options.listing === true) {
1124
1282
  throw usageError(`Listing metadata has problems:\n${listing.errors.map((error) => ` - ${error}`).join('\n')}`);
1125
1283
  }
1126
- const listingWarnings = listing.warnings;
1284
+ const listingWarnings = [
1285
+ ...[...listing.errors, ...listing.warnings].map((message) => `Store readiness: ${message}`),
1286
+ ...findUnknownScreenshotScenarios(projectDir, resolveListingScreenshots(projectDir, appConfig)),
1287
+ ];
1127
1288
  const routes = routeSelection(manifest, parseRouteSlugs(ctx.options.routes));
1128
1289
  const port = parsePort(ctx.options.port) || await getAvailablePort();
1129
1290
  const appSlug = slugify(appConfig.name || manifest.app?.name || 'app') || 'app';
@@ -1195,6 +1356,7 @@ async function appsVerifyHandler(ctx) {
1195
1356
  result,
1196
1357
  route,
1197
1358
  declaredDatabaseSlugs(appConfig, manifest, route),
1359
+ mode,
1198
1360
  );
1199
1361
  return {
1200
1362
  ...result,
@@ -1219,6 +1381,7 @@ async function appsVerifyHandler(ctx) {
1219
1381
  result,
1220
1382
  route,
1221
1383
  declaredDatabaseSlugs(appConfig, manifest, route),
1384
+ mode,
1222
1385
  );
1223
1386
  results.push({
1224
1387
  route: route.slug,
@@ -1259,6 +1422,11 @@ async function appsVerifyHandler(ctx) {
1259
1422
  },
1260
1423
  summary,
1261
1424
  results,
1425
+ listing: {
1426
+ ready: listing.ready,
1427
+ gated: ctx.options.listing === true,
1428
+ problems: listing.errors,
1429
+ },
1262
1430
  };
1263
1431
 
1264
1432
  ctx.output.emitSuccess({
@@ -1360,6 +1528,7 @@ async function appsScreenshotHandler(ctx) {
1360
1528
  const configuredScreenshots = resolveListingScreenshots(projectDir, appConfig)
1361
1529
  .filter((screenshot) => screenshot.route)
1362
1530
  .filter((screenshot) => !selectedRouteSlugs || selectedRouteSlugs.includes(screenshot.route));
1531
+ const scenarioWarnings = findUnknownScreenshotScenarios(projectDir, configuredScreenshots);
1363
1532
  const routeBySlug = new Map(routes.map((route) => [route.slug, route]));
1364
1533
  const captures = configuredScreenshots.length > 0
1365
1534
  ? configuredScreenshots.map((screenshot) => {
@@ -1471,7 +1640,7 @@ async function appsScreenshotHandler(ctx) {
1471
1640
 
1472
1641
  const captured = results.filter((r) => r.ok);
1473
1642
  const failed = results.filter((r) => !r.ok);
1474
- const warnings = [];
1643
+ const warnings = [...scenarioWarnings];
1475
1644
 
1476
1645
  // Drop stale screenshots only after a full refresh. A selected-route
1477
1646
  // capture intentionally leaves other listing screenshots untouched.
@@ -1642,7 +1811,14 @@ async function appsDeployHandler(ctx) {
1642
1811
  let result;
1643
1812
  try {
1644
1813
  result = await runToolCommand({
1645
- runtime: ctx.runtime,
1814
+ // App deploys upload both the built artifact and the editable source
1815
+ // snapshot. The ordinary 30s CLI timeout is too short for larger apps,
1816
+ // and timing out a mutation is ambiguous: the backend may commit after
1817
+ // the client disconnects. Give this operation its real completion window.
1818
+ runtime: {
1819
+ ...ctx.runtime,
1820
+ timeoutMs: Math.max(ctx.runtime.timeoutMs || 0, APP_DEPLOY_TIMEOUT_MS),
1821
+ },
1646
1822
  toolName: SAVE_APP_FILES_TOOL,
1647
1823
  arguments_: {
1648
1824
  app_id: appId,
@@ -1660,51 +1836,47 @@ async function appsDeployHandler(ctx) {
1660
1836
  throw toolConflictToError(error.details, 'Deploy conflict');
1661
1837
  }
1662
1838
 
1663
- // Auto-fallback to direct deploy on network errors
1664
- const isNetworkError = error.code === 'network_error'
1665
- || error.code === 'network_timeout'
1666
- || (error.message && /fetch failed|ECONNREFUSED|network/i.test(error.message));
1667
-
1668
- if (isNetworkError) {
1669
- try {
1670
- await assertDirectDeployAccess(ctx.runtime, appId);
1671
- } catch (accessError) {
1672
- throw usageError(
1673
- `Backend deploy failed (${error.message}) and direct fallback was blocked because app access ` +
1674
- `could not be verified (${accessError.message}).`,
1675
- );
1676
- }
1839
+ // A timed-out mutation may already have committed on the backend. A direct
1840
+ // upload here would create a second revision, so fail closed and let the
1841
+ // caller inspect/pull the app before deciding whether to retry.
1842
+ if (error.code === 'network_timeout') {
1843
+ error.message = `${error.message}. The backend may still complete this deploy; direct fallback was not attempted to avoid creating a duplicate revision. Inspect the app version, then pull before retrying if needed.`;
1844
+ throw error;
1845
+ }
1677
1846
 
1678
- try {
1679
- const { version } = await directDeploy(projectDir, appId);
1680
- updateLinkedDeployState(projectDir, linkedState, appId, version);
1681
- return ctx.output.emitSuccess({
1682
- command: ctx.spec.command_path.join(' '),
1683
- data: { app_id: appId, version, mode: 'direct-fallback' },
1684
- humanSummary: `Backend unavailable -- deployed to app ${appId} (version ${version}) via direct upload`,
1685
- warnings: ['Backend server was unreachable. Used direct Supabase upload as fallback.'],
1686
- meta: { mutating: true },
1687
- });
1688
- } catch (directError) {
1689
- throw usageError(
1690
- `Backend deploy failed (${error.message}) and direct fallback also failed (${directError.message}). ` +
1691
- 'Check server/.env for Supabase credentials or start the backend server.',
1692
- );
1693
- }
1847
+ // A network failure is ambiguous for a mutation. It can happen after the
1848
+ // backend committed but before undici finished reading the response (for
1849
+ // example ECONNRESET / UND_ERR_SOCKET). Never infer pre-dispatch from a
1850
+ // generic network_error or its message: an automatic direct upload could
1851
+ // create a second revision. Operators who have proved the backend is down
1852
+ // can still choose the explicit --direct mode.
1853
+ if (error.code === 'network_error') {
1854
+ error.message = `${error.message}. The backend may have committed this deploy; direct fallback was not attempted to avoid creating a duplicate revision. Inspect the app version, then pull before retrying, or use --direct only after proving the backend mutation did not land.`;
1855
+ throw error;
1694
1856
  }
1695
1857
 
1696
1858
  throw error;
1697
1859
  }
1698
1860
 
1699
- updateLinkedDeployState(projectDir, linkedState, appId, Number(result.payload.version));
1861
+ const deployedVersion = Number(result?.payload?.version);
1862
+ if (!Number.isFinite(deployedVersion) || deployedVersion <= 0) {
1863
+ throw new CliError({
1864
+ code: 'network_error',
1865
+ message: 'The backend returned an incomplete deploy response. The deploy may have committed; inspect the app version and pull before retrying. Direct fallback was not attempted to avoid creating a duplicate revision.',
1866
+ exitCode: EXIT_CODES.network,
1867
+ retryable: true,
1868
+ });
1869
+ }
1870
+
1871
+ updateLinkedDeployState(projectDir, linkedState, appId, deployedVersion);
1700
1872
  return ctx.output.emitSuccess({
1701
1873
  command: ctx.spec.command_path.join(' '),
1702
1874
  data: {
1703
1875
  app_id: appId,
1704
- version: result.payload.version,
1876
+ version: deployedVersion,
1705
1877
  idempotency_key: idempotencyKey,
1706
1878
  },
1707
- humanSummary: `Deployed to app ${appId} (version ${result.payload.version})`,
1879
+ humanSummary: `Deployed to app ${appId} (version ${deployedVersion})`,
1708
1880
  meta: { mutating: true, idempotency_key: idempotencyKey },
1709
1881
  });
1710
1882
  }
@@ -1988,12 +2160,25 @@ export const appsCommandSpecs = [
1988
2160
  options: [
1989
2161
  { flags: '--port <number>', description: `Local bundle server port (default: ${DEFAULT_DEV_PORT}).` },
1990
2162
  { flags: '--no-open', description: 'Do not auto-open the desktop Portal local development app.' },
2163
+ {
2164
+ flags: '--live-data',
2165
+ description:
2166
+ "Read and write the installed app's real databases instead of empty dev copies. "
2167
+ + 'Applies to this session only; warns and falls back when the app is not installed yet.',
2168
+ },
2169
+ {
2170
+ flags: '--grant-cloud-shell',
2171
+ description:
2172
+ "Approve a cloudComputer: 'shell' declaration without the interactive prompt. "
2173
+ + 'The grant persists for this dev app; authorship alone never grants it.',
2174
+ },
1991
2175
  ],
1992
2176
  },
1993
2177
  examples: [
1994
2178
  'notis apps dev',
1995
2179
  'notis apps dev ./my-app',
1996
2180
  'notis apps dev ./workspace --port 5200',
2181
+ 'notis apps dev --live-data # iterate on a view over the installed app\'s real rows',
1997
2182
  ],
1998
2183
  mutates: true,
1999
2184
  idempotent: true,
@@ -2020,10 +2205,11 @@ export const appsCommandSpecs = [
2020
2205
  },
2021
2206
  {
2022
2207
  command_path: ['apps', 'verify'],
2023
- summary: 'Validate every route and the production Store listing contract.',
2208
+ summary: 'Validate that every route renders and reports Store listing readiness.',
2024
2209
  when_to_use:
2025
- 'After notis apps screenshot, before deploy. Catches render-time crashes, ' +
2026
- 'missing runtime calls, and incomplete listing media that the build step cannot detect.',
2210
+ 'Any time after notis apps build, and before deploy. Catches render-time crashes and ' +
2211
+ 'missing runtime calls. Incomplete listing media is reported as a warning; pass --listing ' +
2212
+ 'to fail on it instead.',
2027
2213
  args_schema: {
2028
2214
  arguments: [
2029
2215
  { token: '[dir]', key: 'dir', description: 'Project directory (default: current dir).' },
@@ -2032,7 +2218,8 @@ export const appsCommandSpecs = [
2032
2218
  { flags: '--routes <slugs>', description: 'Comma-separated route slugs. Default: every route in manifest.' },
2033
2219
  { flags: '--port <n>', description: 'Loopback port. Default: auto-pick.' },
2034
2220
  { flags: '--skip-build', description: 'Skip notis apps build; reuse existing .notis/output/.' },
2035
- { flags: '--mode <mode>', description: 'stub | live. Default stub. Live posts to /portal_views/runtime_query with the CLI JWT.' },
2221
+ { flags: '--mode <mode>', description: 'stub | live. Default stub. Live posts to /portal_views/runtime_query with the CLI JWT and fails routes whose runtime calls all errored.' },
2222
+ { flags: '--listing', description: 'Fail instead of warn when the Store listing (tagline, categories, screenshots, changelog) is incomplete.' },
2036
2223
  { flags: '--no-browser', description: 'Start the harness server and print URLs; do not drive agent-browser.' },
2037
2224
  { flags: '--keep-open', description: 'Leave server + browser session running after report (for manual triage).' },
2038
2225
  ],
@@ -2041,6 +2228,7 @@ export const appsCommandSpecs = [
2041
2228
  'notis apps verify',
2042
2229
  'notis apps verify --routes notes',
2043
2230
  'notis apps verify --mode live',
2231
+ 'notis apps verify --listing # gate on Store listing readiness before publish',
2044
2232
  'notis apps verify --no-browser # start the harness, drive agent-browser yourself',
2045
2233
  ],
2046
2234
  mutates: false,