@notis_ai/cli 0.2.0-beta.19.1 → 0.2.0-beta.191.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (164) hide show
  1. package/README.md +421 -133
  2. package/bin/check-runtime.js +15 -0
  3. package/bin/notis.js +2 -0
  4. package/config/notis_app_boundary_rules.json +50 -0
  5. package/config/notis_app_design_rules.json +135 -0
  6. package/dist/agent-hooks/notis-agent-hook.mjs +19299 -0
  7. package/dist/base-skills/notis-apps/SKILL.md +72 -0
  8. package/dist/base-skills/notis-apps/references/architecture.md +167 -0
  9. package/dist/base-skills/notis-apps/references/context.md +81 -0
  10. package/dist/base-skills/notis-apps/references/design.md +178 -0
  11. package/dist/base-skills/notis-apps/references/reading.md +89 -0
  12. package/dist/base-skills/notis-apps/references/release.md +145 -0
  13. package/dist/base-skills/notis-apps/references/sdk.md +63 -0
  14. package/dist/base-skills/notis-apps/references/troubleshooting.md +23 -0
  15. package/dist/base-skills/notis-cli/SKILL.md +154 -0
  16. package/dist/base-skills/notis-cli/references/app-delivery.md +18 -0
  17. package/dist/base-skills/notis-cli/references/intelligence.md +94 -0
  18. package/dist/base-skills/notis-cli/references/native-databases.md +21 -0
  19. package/dist/base-skills/notis-cli/references/tool-examples.md +56 -0
  20. package/dist/base-skills/notis-cli/references/troubleshooting.md +39 -0
  21. package/dist/base-skills/notis-query/SKILL.md +67 -0
  22. package/dist/base-skills/notis-query/references/database-discovery.md +70 -0
  23. package/dist/base-skills/notis-query/references/documents.md +50 -0
  24. package/dist/base-skills/notis-query/references/query.md +543 -0
  25. package/{template/packages/notis-sdk → dist/sdk}/package.json +14 -4
  26. package/dist/sdk/src/agentContext.ts +36 -0
  27. package/dist/sdk/src/components/Dialog.tsx +46 -0
  28. package/dist/sdk/src/components/DocumentEditor.tsx +103 -0
  29. package/dist/sdk/src/components/Markdown.tsx +60 -0
  30. package/dist/sdk/src/components/MarkdownEditor.tsx +121 -0
  31. package/dist/sdk/src/components/MultiSelectActionBar.tsx +285 -0
  32. package/dist/sdk/src/components/MultiSelectCheckbox.tsx +97 -0
  33. package/dist/sdk/src/components/MultiSelectDragOverlay.tsx +39 -0
  34. package/dist/sdk/src/components/NotisCommentBoundary.tsx +172 -0
  35. package/dist/sdk/src/components/NotisSelectionBoundary.tsx +59 -0
  36. package/dist/sdk/src/components/ShortcutHints.tsx +56 -0
  37. package/dist/sdk/src/components/Skeleton.tsx +24 -0
  38. package/dist/sdk/src/config.ts +261 -0
  39. package/dist/sdk/src/documents.ts +256 -0
  40. package/dist/sdk/src/hooks/useActiveResource.ts +19 -0
  41. package/dist/sdk/src/hooks/useAgentContext.ts +23 -0
  42. package/dist/sdk/src/hooks/useCloudComputer.ts +64 -0
  43. package/dist/sdk/src/hooks/useCollectionInteractions.ts +838 -0
  44. package/dist/sdk/src/hooks/useDatabaseSchema.ts +49 -0
  45. package/dist/sdk/src/hooks/useDatabaseSubscription.ts +76 -0
  46. package/dist/sdk/src/hooks/useDocument.ts +43 -0
  47. package/dist/sdk/src/hooks/useDocuments.ts +84 -0
  48. package/dist/sdk/src/hooks/useHandover.ts +77 -0
  49. package/dist/sdk/src/hooks/useLongPressSelection.ts +79 -0
  50. package/dist/sdk/src/hooks/useMultiSelect.ts +95 -0
  51. package/{template/packages/notis-sdk → dist/sdk}/src/hooks/useNotis.ts +12 -4
  52. package/{template/packages/notis-sdk → dist/sdk}/src/hooks/useNotisNavigation.ts +11 -8
  53. package/dist/sdk/src/hooks/useQuery.ts +71 -0
  54. package/dist/sdk/src/hooks/useTool.ts +65 -0
  55. package/dist/sdk/src/hooks/useToolQuery.ts +12 -0
  56. package/dist/sdk/src/hooks/useTopBarSearch.ts +81 -0
  57. package/dist/sdk/src/hooks/useUpsertDocument.ts +95 -0
  58. package/dist/sdk/src/index.ts +164 -0
  59. package/dist/sdk/src/interactions/actions.ts +59 -0
  60. package/dist/sdk/src/interactions/shortcuts.tsx +726 -0
  61. package/dist/sdk/src/interactions/visibility.ts +13 -0
  62. package/dist/sdk/src/interactions.ts +45 -0
  63. package/dist/sdk/src/provider.tsx +44 -0
  64. package/dist/sdk/src/queryCache.ts +170 -0
  65. package/dist/sdk/src/runtime.ts +465 -0
  66. package/dist/sdk/src/styles.css +266 -0
  67. package/dist/sdk/src/tailwind.ts +66 -0
  68. package/dist/sdk/src/vite.ts +73 -0
  69. package/dist/skill-sync/index.js +1753 -0
  70. package/dist/skill-sync/index.js.map +7 -0
  71. package/dist/skill-sync-worker.mjs +3113 -0
  72. package/package.json +18 -7
  73. package/skills/notis-apps/cli.md +299 -0
  74. package/skills/notis-cli/AGENT_INSTRUCTIONS.md +39 -0
  75. package/skills/notis-onboarding/BRIEF.md +129 -0
  76. package/skills/notis-query/cli.md +39 -0
  77. package/src/agent-hook-entry.js +5 -0
  78. package/src/cli.js +294 -25
  79. package/src/command-specs/agents.js +392 -0
  80. package/src/command-specs/apps.js +1483 -203
  81. package/src/command-specs/auth.js +114 -137
  82. package/src/command-specs/diagnostics.js +730 -0
  83. package/src/command-specs/handover.js +374 -0
  84. package/src/command-specs/helpers.js +85 -82
  85. package/src/command-specs/index.js +25 -6
  86. package/src/command-specs/meta.js +150 -18
  87. package/src/command-specs/onboarding.js +290 -0
  88. package/src/command-specs/profile.js +358 -0
  89. package/src/command-specs/reports.js +97 -0
  90. package/src/command-specs/skills.js +75 -0
  91. package/src/command-specs/smoke.js +386 -0
  92. package/src/command-specs/tools.js +455 -139
  93. package/src/runtime/agent-browser.js +677 -0
  94. package/src/runtime/agent-memory-state.js +126 -0
  95. package/src/runtime/agent-setup.js +383 -0
  96. package/src/runtime/app-boundary-validator.js +404 -0
  97. package/src/runtime/app-changelog.js +79 -0
  98. package/src/runtime/app-platform.js +2652 -205
  99. package/src/runtime/app-registry-scaffolds.js +367 -0
  100. package/src/runtime/app-test-server.js +293 -0
  101. package/src/runtime/assets/store-screenshot-dark.png +0 -0
  102. package/src/runtime/auth-recovery.js +110 -0
  103. package/src/runtime/base-skills.d.ts +20 -0
  104. package/src/runtime/base-skills.js +167 -0
  105. package/src/runtime/channel.js +133 -0
  106. package/src/runtime/delegated-context.js +68 -0
  107. package/src/runtime/errors.js +1 -0
  108. package/src/runtime/git.js +233 -0
  109. package/src/runtime/login-listener.js +15 -0
  110. package/src/runtime/oauth.js +2622 -0
  111. package/src/runtime/output.js +37 -5
  112. package/src/runtime/ports.js +31 -0
  113. package/src/runtime/profiles.js +906 -55
  114. package/src/runtime/screenshot-framing.js +85 -0
  115. package/src/runtime/skill-sync/cloud-client.ts +99 -0
  116. package/src/runtime/skill-sync/index.ts +786 -0
  117. package/src/runtime/skill-sync/local-scanner.ts +1151 -0
  118. package/src/runtime/skill-sync/symlink-manager.ts +433 -0
  119. package/src/runtime/skill-sync/sync-plan.ts +47 -0
  120. package/src/runtime/skill-sync/types.ts +131 -0
  121. package/src/runtime/skill-sync/write-cloud-skill.ts +50 -0
  122. package/src/runtime/skill-sync-service.js +109 -0
  123. package/src/runtime/store-screenshot.js +146 -0
  124. package/src/runtime/sync-skills.d.ts +37 -0
  125. package/src/runtime/sync-skills.js +231 -0
  126. package/src/runtime/telemetry.js +92 -0
  127. package/src/runtime/transport.js +324 -45
  128. package/src/skill-sync-worker-entry.js +2 -0
  129. package/src/skill-sync-worker.js +50 -0
  130. package/template/.harness/index.html.tmpl +435 -0
  131. package/template/CHANGELOG.md +5 -0
  132. package/template/app/globals.css +28 -3
  133. package/template/app/layout.tsx +6 -3
  134. package/template/app/page.tsx +49 -42
  135. package/template/components/page-heading.tsx +23 -0
  136. package/template/components/ui/badge.tsx +7 -4
  137. package/template/components/ui/button.tsx +1 -1
  138. package/template/components/ui/card.tsx +24 -11
  139. package/template/components/ui/native-select.tsx +24 -0
  140. package/template/notis.config.ts +24 -6
  141. package/template/package-lock.json +3642 -0
  142. package/template/package.json +19 -16
  143. package/template/postcss.config.mjs +1 -1
  144. package/template/tailwind.config.ts +1 -6
  145. package/template/tsconfig.json +1 -0
  146. package/src/command-specs/db.js +0 -163
  147. package/src/runtime/app-preview-server.js +0 -312
  148. package/template/packages/notis-sdk/src/config.ts +0 -48
  149. package/template/packages/notis-sdk/src/helpers.ts +0 -131
  150. package/template/packages/notis-sdk/src/hooks/useAppState.ts +0 -50
  151. package/template/packages/notis-sdk/src/hooks/useCollectionItem.ts +0 -58
  152. package/template/packages/notis-sdk/src/hooks/useDatabase.ts +0 -87
  153. package/template/packages/notis-sdk/src/hooks/useDocument.ts +0 -61
  154. package/template/packages/notis-sdk/src/hooks/useTool.ts +0 -49
  155. package/template/packages/notis-sdk/src/hooks/useUpsertDocument.ts +0 -57
  156. package/template/packages/notis-sdk/src/index.ts +0 -47
  157. package/template/packages/notis-sdk/src/provider.tsx +0 -44
  158. package/template/packages/notis-sdk/src/runtime.ts +0 -159
  159. package/template/packages/notis-sdk/src/styles.css +0 -123
  160. package/template/packages/notis-sdk/src/vite.ts +0 -54
  161. package/template/packages/notis-sdk/tsconfig.json +0 -15
  162. /package/{template/packages/notis-sdk → dist/sdk}/src/hooks/useBackend.ts +0 -0
  163. /package/{template/packages/notis-sdk → dist/sdk}/src/hooks/useTools.ts +0 -0
  164. /package/{template/packages/notis-sdk → dist/sdk}/src/ui.ts +0 -0
@@ -1,36 +1,42 @@
1
- /**
2
- * Notis apps CLI commands.
3
- *
4
- * Clean command set for the Vercel-like Notis app workflow:
5
- * init -> dev -> build -> preview -> deploy
6
- *
7
- * Supporting commands: list, link, doctor.
8
- */
9
-
10
- import { EXIT_CODES, usageError } from '../runtime/errors.js';
1
+ import { mkdirSync, mkdtempSync, readdirSync, rmSync } from 'node:fs';
2
+ import { tmpdir } from 'node:os';
3
+ import { basename, join, relative } from 'node:path';
4
+
5
+ import { CliError, EXIT_CODES, usageError } from '../runtime/errors.js';
11
6
  import { formatTable } from '../runtime/output.js';
7
+ import { defaultAppProjectDir, resolveProjectDir, loadAppConfig, detectProjectProblems, detectProjectWarnings, buildArtifact, prepareAppRelease, beginAppCreateIntent, appLinkedStateProfileKey, readManifest, readLinkedState, writeLinkedState, requireLinkedAppId, scaffoldProject, findUnknownScreenshotScenarios, inspectListingReadiness, resolveListingScreenshots, collectArtifactFiles, collectSourceFiles, appRowFieldsFromManifest, pullAppSource, writeVerifyStamp } from '../runtime/app-platform.js';
8
+ import {
9
+ filterScaffoldCatalog,
10
+ loadScaffoldCatalog,
11
+ scaffoldRegistryLabel,
12
+ } from '../runtime/app-registry-scaffolds.js';
13
+ import { startAppTestServer } from '../runtime/app-test-server.js';
12
14
  import {
13
- resolveProjectDir,
14
- loadAppConfig,
15
- detectProjectProblems,
16
- detectProjectWarnings,
17
- buildArtifact,
18
- readManifest,
19
- readLinkedState,
20
- writeLinkedState,
21
- requireLinkedAppId,
22
- scaffoldProject,
23
- collectArtifactFiles,
24
- runProjectScript,
25
- directDeploy,
26
- } from '../runtime/app-platform.js';
27
- import { startPreviewServer } from '../runtime/app-preview-server.js';
15
+ captureHarnessScreenshot,
16
+ describeDesignFinding,
17
+ closeAgentBrowserSession,
18
+ isAgentBrowserAvailable,
19
+ runHarnessRoute,
20
+ } from '../runtime/agent-browser.js';
21
+ import { getAvailablePort } from '../runtime/ports.js';
22
+ import { composeStoreScreenshot } from '../runtime/store-screenshot.js';
23
+ import { httpRequest } from '../runtime/transport.js';
24
+ import { ensureFreshOAuthCredential } from '../runtime/oauth.js';
28
25
  import {
26
+ localNotisToolSlug,
29
27
  nextIdempotencyKey,
30
28
  runToolCommand,
31
29
  toolConflictToError,
32
30
  } from './helpers.js';
33
31
 
32
+ export { appRowFieldsFromManifest } from '../runtime/app-platform.js';
33
+ const GET_APP_TOOL = 'LOCAL_NOTIS_GET_APP';
34
+ const LIST_APPS_TOOL = 'LOCAL_NOTIS_LIST_APPS';
35
+ const CREATE_APP_TOOL = 'LOCAL_NOTIS_CREATE_APP';
36
+ const DUPLICATE_APP_TOOL = 'LOCAL_NOTIS_DUPLICATE_APP';
37
+ const SAVE_APP_FILES_TOOL = 'LOCAL_NOTIS_SAVE_APP_FILES';
38
+ export const APP_DEPLOY_TIMEOUT_MS = 600_000;
39
+
34
40
  // ---------------------------------------------------------------------------
35
41
  // Formatters
36
42
  // ---------------------------------------------------------------------------
@@ -44,16 +50,318 @@ function appsTable(apps) {
44
50
  ]);
45
51
  }
46
52
 
47
- async function assertDirectDeployAccess(runtime, appId) {
48
- const result = await runToolCommand({
53
+ function scaffoldsTable(scaffolds) {
54
+ return formatTable(scaffolds, [
55
+ { label: 'Slug', value: (scaffold) => scaffold.slug || '' },
56
+ { label: 'Name', value: (scaffold) => scaffold.name || scaffold.slug || '' },
57
+ { label: 'Category', value: (scaffold) => (scaffold.categories || [])[0] || '' },
58
+ { label: 'Tagline', value: (scaffold) => scaffold.tagline || scaffold.description || '' },
59
+ ]);
60
+ }
61
+
62
+ function decodeJwtSub(jwt) {
63
+ if (!jwt) return null;
64
+ try {
65
+ const parts = jwt.split('.');
66
+ if (parts.length !== 3) return null;
67
+ const decoded = JSON.parse(Buffer.from(parts[1], 'base64url').toString());
68
+ return decoded.sub || decoded.email || null;
69
+ } catch {
70
+ return null;
71
+ }
72
+ }
73
+
74
+ function linkedStateProfileKey(runtime) {
75
+ return appLinkedStateProfileKey({
76
+ apiBase: runtime?.apiBase,
77
+ userId: decodeJwtSub(runtime?.jwt),
78
+ });
79
+ }
80
+
81
+ function slugify(value) {
82
+ return String(value || '')
83
+ .trim()
84
+ .toLowerCase()
85
+ .replace(/[^a-z0-9]+/g, '-')
86
+ .replace(/(^-|-$)+/g, '');
87
+ }
88
+
89
+ function parsePort(value) {
90
+ if (!value) return null;
91
+ const port = Number.parseInt(value, 10);
92
+ if (!Number.isInteger(port) || port <= 0 || port > 65535) {
93
+ throw usageError('Port must be between 1 and 65535.');
94
+ }
95
+ return port;
96
+ }
97
+
98
+ function parsePositiveInt(value) {
99
+ if (!value) return null;
100
+ const parsed = Number.parseInt(value, 10);
101
+ if (!Number.isInteger(parsed) || parsed <= 0) {
102
+ throw usageError('Expected a positive integer.');
103
+ }
104
+ return parsed;
105
+ }
106
+
107
+ function parseRouteSlugs(value) {
108
+ if (!value) return null;
109
+ const slugs = String(value)
110
+ .split(',')
111
+ .map((entry) => entry.trim())
112
+ .filter(Boolean);
113
+ if (slugs.length === 0) {
114
+ throw usageError('--routes must include at least one route slug.');
115
+ }
116
+ return slugs;
117
+ }
118
+
119
+ function routeSelection(manifest, rawRouteSlugs) {
120
+ const routes = Array.isArray(manifest?.routes) ? manifest.routes : [];
121
+ if (routes.length === 0) {
122
+ throw usageError('Manifest has no routes to verify.');
123
+ }
124
+ if (!rawRouteSlugs) {
125
+ return routes;
126
+ }
127
+
128
+ const bySlug = new Map(routes.map((route) => [route.slug, route]));
129
+ const selected = [];
130
+ const missing = [];
131
+ for (const slug of rawRouteSlugs) {
132
+ const route = bySlug.get(slug);
133
+ if (route) {
134
+ selected.push(route);
135
+ } else {
136
+ missing.push(slug);
137
+ }
138
+ }
139
+ if (missing.length) {
140
+ throw usageError(
141
+ `Unknown route slug${missing.length === 1 ? '' : 's'}: ${missing.join(', ')}.`,
142
+ { available_routes: routes.map((route) => route.slug) },
143
+ );
144
+ }
145
+ return selected;
146
+ }
147
+
148
+ export function pruneStaleScreenshotFiles(outputDir, keepCount) {
149
+ for (const entry of readdirSync(outputDir, { withFileTypes: true })) {
150
+ if (!entry.isFile()) continue;
151
+ const match = /^screenshot-(\d+)\.png$/i.exec(entry.name);
152
+ if (match && Number.parseInt(match[1], 10) > keepCount) {
153
+ rmSync(join(outputDir, entry.name), { force: true });
154
+ }
155
+ }
156
+ }
157
+
158
+ export function shouldPruneStaleScreenshotFiles(selectedRouteSlugs, failedCount) {
159
+ return failedCount === 0 && !selectedRouteSlugs;
160
+ }
161
+
162
+ export function screenshotIndexByRouteSlug(manifest) {
163
+ const routes = Array.isArray(manifest?.routes) ? manifest.routes : [];
164
+ return new Map(routes.map((route, index) => [route.slug, index + 1]));
165
+ }
166
+
167
+ export function screenshotExitCode(failedCount) {
168
+ return failedCount === 0 ? EXIT_CODES.ok : EXIT_CODES.unexpected;
169
+ }
170
+
171
+ function declaredDatabaseSlugs(appConfig, manifest, route) {
172
+ const slugs = new Set();
173
+ for (const entry of appConfig?.databases || manifest?.databases || []) {
174
+ if (typeof entry === 'string' && entry) {
175
+ slugs.add(entry);
176
+ } else if (entry && typeof entry === 'object' && typeof entry.slug === 'string') {
177
+ slugs.add(entry.slug);
178
+ }
179
+ }
180
+ if (route?.collection?.database) {
181
+ slugs.add(route.collection.database);
182
+ }
183
+ return Array.from(slugs);
184
+ }
185
+
186
+ function harnessErrorMessage(error) {
187
+ if (!error || typeof error !== 'object') {
188
+ return String(error);
189
+ }
190
+ return error.message || error.reason || error.type || JSON.stringify(error);
191
+ }
192
+
193
+ function runtimeCallLabel(call) {
194
+ if (call?.op === 'callTool') {
195
+ return call?.args?.name || 'callTool';
196
+ }
197
+ if (call?.op === 'request') {
198
+ return `request ${call?.args?.path || ''}`.trim();
199
+ }
200
+ return call?.op || 'runtime call';
201
+ }
202
+
203
+ function assertHarnessResult(result, route, databaseSlugs, mode = 'stub', capabilities = {}) {
204
+ const assertions = [];
205
+ if (result.tool_error) {
206
+ assertions.push({
207
+ ok: false,
208
+ code: 'tool_error',
209
+ message: `agent-browser ${result.tool_error.phase || 'command'} failed`,
210
+ details: result.tool_error,
211
+ });
212
+ }
213
+ if (result.mounted !== true) {
214
+ assertions.push({
215
+ ok: false,
216
+ code: 'not_mounted',
217
+ message: 'Harness did not report mounted === true.',
218
+ });
219
+ }
220
+ if (result.timed_out) {
221
+ assertions.push({
222
+ ok: false,
223
+ code: 'timeout',
224
+ message: 'Timed out waiting for window.__harness.mounted.',
225
+ });
226
+ }
227
+ for (const error of result.errors || []) {
228
+ assertions.push({
229
+ ok: false,
230
+ code: 'render_error',
231
+ message: harnessErrorMessage(error),
232
+ details: error,
233
+ });
234
+ }
235
+ const runtimeCalls = result.runtimeCalls || [];
236
+ const declaredDatabaseSet = new Set(databaseSlugs);
237
+ const databaseQueries = runtimeCalls.filter(
238
+ (call) =>
239
+ call?.op === 'callTool'
240
+ && localNotisToolSlug(call?.args?.name) === 'LOCAL_NOTIS_DATABASE_QUERY',
241
+ );
242
+ for (const call of databaseQueries) {
243
+ const databaseSlug = call?.args?.arguments?.database_slug;
244
+ if (databaseSlug && !declaredDatabaseSet.has(databaseSlug) && capabilities.workspaceDatabases !== 'read') {
245
+ assertions.push({
246
+ ok: false,
247
+ code: 'undeclared_database_query',
248
+ message: `Route "${route.slug}" queried undeclared database "${databaseSlug}".`,
249
+ details: { databaseSlug },
250
+ });
251
+ }
252
+ }
253
+ const collectionDatabase = route?.collection?.database;
254
+ if (
255
+ collectionDatabase
256
+ && !databaseQueries.some((call) => call?.args?.arguments?.database_slug === collectionDatabase)
257
+ ) {
258
+ assertions.push({
259
+ ok: false,
260
+ code: 'missing_collection_database_query',
261
+ message: `Collection route "${route.slug}" did not query "${collectionDatabase}".`,
262
+ details: { databaseSlug: collectionDatabase },
263
+ });
264
+ }
265
+ if (result.design_tool_error) {
266
+ assertions.push({ ok: false, code: 'design_check_error',
267
+ message: `Route "${route.slug}" could not complete its automated design checks.`,
268
+ details: result.design_tool_error });
269
+ }
270
+ for (const finding of result.design || []) {
271
+ assertions.push({
272
+ ok: false,
273
+ code: 'design_rule_violation',
274
+ message: `Route "${route.slug}": ${describeDesignFinding(finding)}.`,
275
+ details: finding,
276
+ });
277
+ }
278
+ if (mode === 'live') {
279
+ if (runtimeCalls.some(call => call?.ok == null)) {
280
+ assertions.push({ ok: false, code: 'runtime_calls_pending', message: 'Live verification ended before every started runtime call settled.' });
281
+ }
282
+ // In live mode an app that catches every failed call and renders its error
283
+ // state still mounts cleanly, so the render assertions above all pass. Only
284
+ // the recorded outcomes reveal that nothing real came back.
285
+ if (runtimeCalls.length > 0 && runtimeCalls.every((call) => call?.ok === false)) {
286
+ assertions.push({
287
+ ok: false,
288
+ code: 'all_runtime_calls_failed',
289
+ message: `Route "${route.slug}" rendered without data: all ${runtimeCalls.length} runtime call(s) failed. First error: ${runtimeCalls[0]?.error || 'unknown'}.`,
290
+ details: {
291
+ failed: runtimeCalls.map((call) => ({ call: runtimeCallLabel(call), error: call?.error || null })),
292
+ },
293
+ });
294
+ }
295
+ for (const databaseSlug of databaseSlugs) {
296
+ const queries = databaseQueries.filter(
297
+ (call) => call?.args?.arguments?.database_slug === databaseSlug,
298
+ );
299
+ // A call still in flight when the harness was read has ok === null; only
300
+ // an explicit failure with no successful sibling is a real problem.
301
+ if (queries.some((call) => call?.ok === false) && !queries.some((call) => call?.ok === true)) {
302
+ assertions.push({
303
+ ok: false,
304
+ code: 'failed_database_query',
305
+ message: `Route "${route.slug}" never got a successful "${databaseSlug}" query. Last error: ${queries[queries.length - 1]?.error || 'unknown'}.`,
306
+ details: { databaseSlug },
307
+ });
308
+ }
309
+ }
310
+ }
311
+ return assertions;
312
+ }
313
+
314
+ function renderVerifyReport({ summary, results, noBrowser }) {
315
+ const lines = [
316
+ noBrowser
317
+ ? `Harness URLs ready for ${summary.total} route${summary.total === 1 ? '' : 's'}.`
318
+ : `Verified ${summary.total} route${summary.total === 1 ? '' : 's'}: ${summary.passed} passed, ${summary.failed} failed.`,
319
+ ];
320
+ for (const result of results) {
321
+ const marker = result.ok ? 'PASS' : result.status === 'manual' ? 'URL' : 'FAIL';
322
+ lines.push(`${marker.padEnd(4)} ${result.route.padEnd(18)} ${result.url}`);
323
+ for (const assertion of result.assertions || []) {
324
+ lines.push(` - ${assertion.message}`);
325
+ for (const failure of assertion.details?.failed || []) {
326
+ lines.push(` ${failure.call}: ${failure.error || 'unknown error'}`);
327
+ }
328
+ }
329
+ }
330
+ if (noBrowser) {
331
+ lines.push('', 'Pass --keep-open to leave the harness process running while you inspect the URLs.');
332
+ }
333
+ return lines.join('\n');
334
+ }
335
+
336
+ async function getAccessibleApp(runtime, appId, runTool = runToolCommand) {
337
+ const result = await runTool({
49
338
  runtime,
50
- toolName: 'notis_list_apps',
339
+ toolName: GET_APP_TOOL,
340
+ arguments_: { app_id: appId, include_documents: false },
51
341
  });
52
- const apps = result.payload.apps || [];
53
- const hasAccess = apps.some((app) => (app.app_id || app.id) === appId);
54
- if (!hasAccess) {
55
- throw usageError(`Direct deploy requires access to app ${appId}.`);
342
+ if (result.payload?.app) {
343
+ return {
344
+ ...result.payload.app,
345
+ apps_access: result.payload.apps_access,
346
+ };
56
347
  }
348
+ const message = typeof result.payload?.message === 'string' ? result.payload.message : '';
349
+ const errorCode = result.payload?.code || result.payload?.error?.code;
350
+ if (
351
+ result.payload?.status === 'error'
352
+ && (errorCode === 'app_not_found' || /^App not found\.?$/i.test(message.trim()))
353
+ ) {
354
+ return null;
355
+ }
356
+ throw usageError(`Could not verify access to app ${appId}${message ? `: ${message}` : '.'}`);
357
+ }
358
+
359
+ export async function assertLinkTarget(runtime, appId, runTool = runToolCommand) {
360
+ const result = await runTool({ runtime, toolName: LIST_APPS_TOOL });
361
+ const app = (result.payload?.apps || []).find(app => (app.app_id || app.id) === appId);
362
+ if (!app || app.can_edit !== true) throw usageError(`Cannot edit app ${appId} in this profile.`);
363
+
364
+ return app;
57
365
  }
58
366
 
59
367
  // ---------------------------------------------------------------------------
@@ -63,7 +371,7 @@ async function assertDirectDeployAccess(runtime, appId) {
63
371
  async function appsListHandler(ctx) {
64
372
  const result = await runToolCommand({
65
373
  runtime: ctx.runtime,
66
- toolName: 'notis_list_apps',
374
+ toolName: LIST_APPS_TOOL,
67
375
  });
68
376
  const apps = result.payload.apps || [];
69
377
  return ctx.output.emitSuccess({
@@ -74,63 +382,124 @@ async function appsListHandler(ctx) {
74
382
  });
75
383
  }
76
384
 
77
- async function appsInitHandler(ctx) {
78
- const projectDir = resolveProjectDir(ctx.args.dir || ctx.args.name.toLowerCase().replace(/[^a-z0-9]+/g, '-'));
385
+ export async function appsInitHandler(ctx) {
386
+ const projectDir = ctx.args.dir
387
+ ? resolveProjectDir(ctx.args.dir)
388
+ : defaultAppProjectDir(slugify(ctx.args.name));
389
+ const fromSlug = ctx.options.from || null;
79
390
 
80
- scaffoldProject({ projectDir, appName: ctx.args.name });
391
+ await scaffoldProject({ projectDir, appName: ctx.args.name, fromSlug });
81
392
 
82
393
  return ctx.output.emitSuccess({
83
394
  command: ctx.spec.command_path.join(' '),
84
- data: { project_dir: projectDir, app_name: ctx.args.name },
85
- humanSummary: `Scaffolded "${ctx.args.name}" in ${projectDir}`,
395
+ data: { project_dir: projectDir, app_name: ctx.args.name, scaffold: fromSlug },
396
+ humanSummary: fromSlug
397
+ ? `Scaffolded "${ctx.args.name}" from ${fromSlug} in ${projectDir}`
398
+ : `Scaffolded "${ctx.args.name}" in ${projectDir}`,
86
399
  hints: [
87
400
  { command: `cd ${projectDir} && npm install`, reason: 'Install dependencies' },
88
- { command: 'npm run dev', reason: 'Start the Vite dev server' },
401
+ { command: `cd ${projectDir} && notis apps build`, reason: 'Build and verify the app' },
89
402
  ],
90
403
  });
91
404
  }
92
405
 
93
- async function appsCreateHandler(ctx) {
94
- const projectDir = ctx.args.dir ? resolveProjectDir(ctx.args.dir) : null;
95
- const idempotencyKey = nextIdempotencyKey(ctx.globalOptions);
96
- const icon = ctx.options.icon
97
- ? (ctx.options.icon.startsWith('lucide:') ? ctx.options.icon : `lucide:${ctx.options.icon}`)
98
- : undefined;
99
- const result = await runToolCommand({
100
- runtime: ctx.runtime,
101
- toolName: 'notis_create_app',
102
- arguments_: {
103
- name: ctx.args.name,
104
- description: ctx.options.description || undefined,
105
- icon,
106
- },
107
- mutating: true,
108
- idempotencyKey,
406
+ async function appsScaffoldsListHandler(ctx) {
407
+ const searchTerm = ctx.options.search || null;
408
+ const catalog = await loadScaffoldCatalog();
409
+ const scaffolds = filterScaffoldCatalog(catalog, searchTerm);
410
+ const registry = scaffoldRegistryLabel();
411
+ const emptyMessage = searchTerm
412
+ ? `No published scaffolds match "${searchTerm}" (${catalog.length} available; run without --search to see all).`
413
+ : `No published scaffolds found in ${registry}.`;
414
+ return ctx.output.emitSuccess({
415
+ command: ctx.spec.command_path.join(' '),
416
+ data: { scaffolds, registry, search: searchTerm },
417
+ humanSummary: scaffolds.length
418
+ ? `Found ${scaffolds.length} published scaffolds in ${registry}`
419
+ : emptyMessage,
420
+ renderHuman: () => (scaffolds.length ? scaffoldsTable(scaffolds) : emptyMessage),
109
421
  });
422
+ }
110
423
 
111
- const app = result.payload.app || result.payload;
112
- if (!app?.id) {
113
- throw usageError('Create app did not return an app id.');
424
+ async function appsCreateHandler(ctx) {
425
+ const projectDir = ctx.args.dir ? resolveProjectDir(ctx.args.dir) : null;
426
+ const appConfig = projectDir ? await loadAppConfig(projectDir) : null;
427
+ const profileKey = linkedStateProfileKey(ctx.runtime);
428
+ const teamId = ctx.options.teamId || null;
429
+ const name = ctx.args.name.trim();
430
+ const slug = appConfig?.name || slugify(name);
431
+ if (appConfig && (appConfig.title || name) !== name) {
432
+ throw usageError('The app name must match the config display title. Keep the config machine name unchanged.');
114
433
  }
115
-
116
- if (projectDir) {
117
- writeLinkedState(projectDir, {
118
- app_id: app.id,
119
- linked_at: new Date().toISOString(),
434
+ // Capture the intent before listing: overlapping invocations share the same
435
+ // durable key even when neither can yet see the pending remote creation.
436
+ const intent = beginAppCreateIntent([profileKey, name, teamId, slug], ctx.globalOptions.idempotencyKey);
437
+ const idempotencyKey = intent.key;
438
+ const listed = await runToolCommand({ runtime: ctx.runtime, toolName: LIST_APPS_TOOL });
439
+ const validInventory = result => Array.isArray(result.payload?.apps)
440
+ && result.payload.status !== 'error' && result.payload.successful !== false;
441
+ if (!validInventory(listed)) throw usageError('App inventory is unavailable; absence is not proven. No app was created.');
442
+ const apps = listed.payload.apps;
443
+ const linked = projectDir ? readLinkedState(projectDir, profileKey) : null;
444
+ const matchesIdentity = app => app.name === name && app.slug === slug && (app.team_id || null) === teamId;
445
+ let app;
446
+ if (linked?.app_id) {
447
+ app = apps.find(app => (app.app_id || app.id) === linked.app_id);
448
+ if (!app || !matchesIdentity(app)) throw usageError('This directory is linked to a different app identity. Use its exact name, slug and scope.');
449
+ } else {
450
+ const candidates = apps.filter(app => (app.team_id || null) === teamId && (app.name === name || app.slug === slug));
451
+ if (candidates.length > 1 || (candidates.length === 1 && !matchesIdentity(candidates[0]))) {
452
+ throw usageError('Conflicting app name or slug in this scope. Inspect and link the exact intended identity.');
453
+ }
454
+ app = candidates[0];
455
+ }
456
+ if (app && app.can_edit !== true) throw usageError('The matching app is not editable in this profile.');
457
+ const reused = Boolean(app);
458
+ if (!app) {
459
+ const result = await runToolCommand({
460
+ runtime: ctx.runtime, toolName: CREATE_APP_TOOL,
461
+ arguments_: { name, slug, description: appConfig?.description || undefined,
462
+ icon: appConfig?.icon || undefined, accent: appConfig?.accent ?? undefined,
463
+ ...(teamId ? { team_id: teamId } : {}) },
464
+ mutating: true, idempotencyKey,
120
465
  });
466
+ if (result.payload?.status === 'error' && result.payload.outcome === 'rejected') {
467
+ // Only this typed, pre-insert rejection proves that the cached key is
468
+ // finished without side effects. Unknown outcomes retain their intent.
469
+ intent.complete();
470
+ throw usageError(result.payload.message || 'App creation was rejected before insertion. Correct the request before retrying.');
471
+ }
472
+ const created = result.payload.app || result.payload;
473
+ const appId = created?.id || created?.app_id;
474
+ const readback = await runToolCommand({ runtime: ctx.runtime, toolName: LIST_APPS_TOOL });
475
+ if (!validInventory(readback)) throw usageError('Creation readback is unavailable. Reconcile the pending creation before retrying.');
476
+ app = readback.payload.apps.find(row => (row.id || row.app_id) === appId);
477
+ if (!app || !matchesIdentity(app) || app.can_edit !== true) {
478
+ throw usageError('Creation outcome could not be reconciled to the exact editable identity. Do not retry blindly.');
479
+ }
480
+ }
481
+ app = { ...app, id: app.id || app.app_id };
482
+ if (projectDir) {
483
+ const state = buildLinkedAppState(linked, app.id);
484
+ writeLinkedState(projectDir, { ...state,
485
+ version: state.version ?? deployedAppVersion(app),
486
+ expected_updated_at: state.version === 0 && deployedAppVersion(app) === 0 ? app.updated_at : state.expected_updated_at ?? app.updated_at,
487
+ }, profileKey);
121
488
  }
122
489
 
490
+ intent.complete();
123
491
  return ctx.output.emitSuccess({
124
492
  command: ctx.spec.command_path.join(' '),
125
493
  data: {
126
494
  app,
127
495
  project_dir: projectDir,
128
496
  linked: Boolean(projectDir),
497
+ reused,
129
498
  idempotency_key: idempotencyKey,
130
499
  },
131
500
  humanSummary: projectDir
132
- ? `Created app ${app.name || ctx.args.name} (${app.id}) and linked ${projectDir}`
133
- : `Created app ${app.name || ctx.args.name} (${app.id})`,
501
+ ? `${reused ? 'Reused' : 'Created'} app ${app.name || ctx.args.name} (${app.id}) and linked ${projectDir}`
502
+ : `${reused ? 'Reused' : 'Created'} app ${app.name || ctx.args.name} (${app.id})`,
134
503
  hints: projectDir
135
504
  ? [{ command: `cd ${projectDir} && notis apps deploy .`, reason: 'Deploy the linked project' }]
136
505
  : [{ command: `notis apps link ${app.id} .`, reason: 'Link a local project before deploying' }],
@@ -138,71 +507,545 @@ async function appsCreateHandler(ctx) {
138
507
  });
139
508
  }
140
509
 
141
- async function appsDevHandler(ctx) {
510
+ export async function appsBuildHandler(ctx) {
142
511
  const projectDir = resolveProjectDir(ctx.args.dir || '.');
143
512
  const problems = detectProjectProblems(projectDir);
144
513
  if (problems.length) {
145
514
  throw usageError(`Project has problems:\n${problems.map((p) => ` - ${p}`).join('\n')}`);
146
515
  }
147
516
 
148
- await runProjectScript({
149
- projectDir,
150
- scriptName: 'dev',
151
- env: { NOTIS_DEV: '1' },
517
+ const { manifest } = await buildArtifact(projectDir, {
518
+ stdio: ctx.output.isMachineMode() ? 'pipe' : 'inherit',
519
+ });
520
+
521
+ return ctx.output.emitSuccess({
522
+ command: ctx.spec.command_path.join(' '),
523
+ data: { manifest },
524
+ humanSummary: `Built ${manifest.routes.length} routes into .notis/output/`,
152
525
  });
526
+ }
153
527
 
154
- return EXIT_CODES.ok;
528
+ function installHarnessSignalCleanup(cleanup) {
529
+ let signalOwned = false;
530
+ const handlers = new Map(['SIGINT', 'SIGTERM'].map((signal) => [signal, () => {
531
+ // The first signal owns cleanup and terminal reporting. Keep both listeners
532
+ // installed while it runs so repeated signals cannot bypass or duplicate it.
533
+ if (signalOwned) return;
534
+ signalOwned = true;
535
+ void cleanup().catch((error) => {
536
+ process.stderr.write(`[notis apps] ${error.message}\n`);
537
+ }).finally(() => {
538
+ removeHandlers();
539
+ process.exit(signal === 'SIGINT' ? 130 : 143);
540
+ });
541
+ }]));
542
+ const removeHandlers = () => {
543
+ for (const [signal, handler] of handlers) process.removeListener(signal, handler);
544
+ };
545
+ for (const [signal, handler] of handlers) process.on(signal, handler);
546
+ return () => { if (!signalOwned) removeHandlers(); };
155
547
  }
156
548
 
157
- async function appsBuildHandler(ctx) {
549
+ async function closeHarnessResources(sessionNames, testServer, rawOutputDir = null) {
550
+ const outcomes = await Promise.allSettled(sessionNames.map(async (name) => {
551
+ // Closing a named session is idempotent. Retry once, but never silently
552
+ // certify a release when its temporary browser could not be stopped.
553
+ for (let attempt = 0; attempt < 2; attempt += 1) {
554
+ if (await closeAgentBrowserSession(name).catch(() => false)) return;
555
+ }
556
+ throw new Error(`Browser session ${name} could not be closed`);
557
+ }));
558
+ if (testServer) outcomes.push(...await Promise.allSettled([testServer.close()]));
559
+ if (rawOutputDir) {
560
+ try { rmSync(rawOutputDir, { recursive: true, force: true }); }
561
+ catch (error) { outcomes.push({ status: 'rejected', reason: error }); }
562
+ }
563
+ const errors = outcomes.filter(result => result.status === 'rejected').map(result => result.reason);
564
+ if (errors.length) {
565
+ throw new AggregateError(errors, `Temporary app harness cleanup failed: ${errors.map(error => error.message).join('; ')}`);
566
+ }
567
+ }
568
+
569
+ export async function appsVerifyHandler(ctx) {
158
570
  const projectDir = resolveProjectDir(ctx.args.dir || '.');
159
571
  const problems = detectProjectProblems(projectDir);
160
572
  if (problems.length) {
161
573
  throw usageError(`Project has problems:\n${problems.map((p) => ` - ${p}`).join('\n')}`);
162
574
  }
163
575
 
164
- const { manifest } = await buildArtifact(projectDir);
576
+ const appConfig = await loadAppConfig(projectDir);
577
+ const isReport = appConfig.kind === 'report';
578
+ const mode = ctx.options.mode || 'stub';
579
+ if (!['stub', 'live'].includes(mode)) {
580
+ throw usageError('--mode must be either "stub" or "live".');
581
+ }
165
582
 
166
- return ctx.output.emitSuccess({
167
- command: ctx.spec.command_path.join(' '),
168
- data: { manifest },
169
- humanSummary: `Built ${manifest.routes.length} routes into .notis/output/`,
170
- });
583
+ let linkedState = null;
584
+ if (mode === 'live') {
585
+ if (
586
+ ctx.runtime.credentialKind === 'oauth'
587
+ && !await ensureFreshOAuthCredential(ctx.runtime)
588
+ ) {
589
+ throw usageError('Live verify mode requires a current OAuth grant. Run `notis login` and retry.');
590
+ }
591
+ if (!ctx.runtime.jwt) {
592
+ throw usageError('Live verify mode requires CLI auth. Run notis login and retry.');
593
+ }
594
+ if (isReport) {
595
+ if (!ctx.options.documentId || !/^\d+$/.test(String(ctx.options.expectedRevision ?? ''))) throw usageError('Live report verification requires --document-id and --expected-revision of the saved report.');
596
+ } else linkedState = readLinkedState(projectDir, linkedStateProfileKey(ctx.runtime));
597
+ if (!isReport && !linkedState?.app_id) {
598
+ throw usageError('Live verify mode requires a linked app. Run `notis apps link <app-id> .` first.');
599
+ }
600
+ }
601
+
602
+ if (!ctx.options.skipBuild) {
603
+ await buildArtifact(projectDir, {
604
+ stdio: ctx.output.isMachineMode() ? 'pipe' : 'inherit',
605
+ });
606
+ }
607
+
608
+ const manifest = readManifest(projectDir);
609
+ const listing = isReport ? { ready: true, errors: [], warnings: [] } : inspectListingReadiness(projectDir, appConfig);
610
+ // Store readiness is a publish concern, not a render concern. Verify reports
611
+ // it so the gaps stay visible while the app is still being built; only
612
+ // --listing (and `apps publish`) turn it back into a hard gate.
613
+ if (listing.errors.length && ctx.options.listing === true) {
614
+ throw usageError(`Listing metadata has problems:\n${listing.errors.map((error) => ` - ${error}`).join('\n')}`);
615
+ }
616
+ const listingWarnings = [
617
+ ...[...listing.errors, ...listing.warnings].map((message) => `Store readiness: ${message}`),
618
+ ...(isReport ? [] : findUnknownScreenshotScenarios(projectDir, resolveListingScreenshots(projectDir, appConfig))),
619
+ ];
620
+ const routes = routeSelection(manifest, parseRouteSlugs(ctx.options.routes));
621
+ const port = parsePort(ctx.options.port) || await getAvailablePort();
622
+ const appSlug = slugify(appConfig.name || manifest.app?.name || 'app') || 'app';
623
+ const baseUrl = `http://127.0.0.1:${port}/a/${appSlug}`;
624
+ const browserSessionName = `notis-verify-${process.pid}`;
625
+ const noBrowser = ctx.options.browser === false;
626
+ const keepOpen = Boolean(ctx.options.keepOpen);
627
+ let testServer = null;
628
+ let browserTouched = false;
629
+
630
+ let cleanupPromise;
631
+ const cleanup = () => (cleanupPromise ||= closeHarnessResources(
632
+ browserTouched ? [browserSessionName] : [], testServer,
633
+ ));
634
+ const removeSignalHandlers = ctx.registerSignalCleanup
635
+ ? ctx.registerSignalCleanup(cleanup)
636
+ : installHarnessSignalCleanup(cleanup);
637
+
638
+ try {
639
+ testServer = await startAppTestServer({
640
+ apps: [{
641
+ slug: appSlug,
642
+ projectDir,
643
+ appId: linkedState?.app_id || 'harness-app',
644
+ resource: isReport ? { kind: 'report', id: ctx.options.documentId || 'preview', revision: Number(ctx.options.expectedRevision || 0) } : undefined,
645
+ }],
646
+ port,
647
+ harness: {
648
+ mode,
649
+ apiBase: ctx.runtime.apiBase,
650
+ jwt: mode === 'live' ? ctx.runtime.jwt : null,
651
+ },
652
+ log: () => {},
653
+ logError: (message) => process.stderr.write(`${message}\n`),
654
+ });
655
+
656
+ const urls = routes.map((route) => ({
657
+ route,
658
+ url: `${baseUrl}/harness?route=${encodeURIComponent(route.slug)}`,
659
+ }));
660
+
661
+ let results;
662
+ const warnings = [...listingWarnings];
663
+ if (noBrowser) {
664
+ results = urls.map(({ route, url }) => ({
665
+ route: route.slug,
666
+ path: route.path,
667
+ url,
668
+ ok: true,
669
+ status: 'manual',
670
+ mounted: null,
671
+ errors: [],
672
+ runtimeCalls: [],
673
+ assertions: [],
674
+ snapshot_path: null,
675
+ tool_error: null,
676
+ }));
677
+ } else if (!isAgentBrowserAvailable()) {
678
+ warnings.push('agent-browser is not available on PATH; rerun with --no-browser to inspect harness URLs manually.');
679
+ results = urls.map(({ route, url }) => {
680
+ const toolError = {
681
+ phase: 'available',
682
+ message: 'agent-browser is not available on PATH',
683
+ };
684
+ const result = {
685
+ route: route.slug,
686
+ path: route.path,
687
+ url,
688
+ mounted: false,
689
+ renderStarted: false,
690
+ errors: [],
691
+ runtimeCalls: [],
692
+ snapshotPath: null,
693
+ tool_error: toolError,
694
+ };
695
+ const assertions = assertHarnessResult(
696
+ result,
697
+ route,
698
+ declaredDatabaseSlugs(appConfig, manifest, route),
699
+ mode,
700
+ manifest.capabilities || appConfig.capabilities || {},
701
+ );
702
+ return {
703
+ ...result,
704
+ ok: false,
705
+ status: 'failed',
706
+ assertions,
707
+ snapshot_path: null,
708
+ };
709
+ });
710
+ } else {
711
+ browserTouched = true;
712
+ results = [];
713
+ for (const { route, url } of urls) {
714
+ const snapshotPath = join(projectDir, '.notis', 'output', '.harness', `${route.slug}.snapshot.txt`);
715
+ const result = await runHarnessRoute({
716
+ url,
717
+ sessionName: browserSessionName,
718
+ timeoutMs: Number.parseInt(ctx.globalOptions.timeoutMs || '', 10) || (mode === 'live' ? 90_000 : 10_000),
719
+ waitForRuntime: mode === 'live',
720
+ snapshotPath,
721
+ });
722
+ const assertions = assertHarnessResult(
723
+ result,
724
+ route,
725
+ declaredDatabaseSlugs(appConfig, manifest, route),
726
+ mode,
727
+ manifest.capabilities || appConfig.capabilities || {},
728
+ );
729
+ results.push({
730
+ route: route.slug,
731
+ path: route.path,
732
+ url,
733
+ ok: assertions.length === 0,
734
+ status: assertions.length === 0 ? 'passed' : 'failed',
735
+ mounted: result.mounted,
736
+ renderStarted: result.renderStarted,
737
+ errors: result.errors,
738
+ runtimeCalls: result.runtimeCalls,
739
+ assertions,
740
+ snapshot_path: result.snapshotPath,
741
+ timed_out: Boolean(result.timed_out),
742
+ tool_error: result.tool_error,
743
+ });
744
+ }
745
+ }
746
+
747
+ const summary = {
748
+ total: results.length,
749
+ passed: results.filter((result) => result.status === 'passed').length,
750
+ failed: results.filter((result) => result.status === 'failed').length,
751
+ manual: results.filter((result) => result.status === 'manual').length,
752
+ };
753
+ const overallOk = summary.failed === 0;
754
+ const exitCode = overallOk ? EXIT_CODES.ok : EXIT_CODES.unexpected;
755
+ // Keep standalone verification diagnostics. Deploy always verifies its own
756
+ // frozen snapshot; this report is never authority to skip that check.
757
+ const verifyStamp = writeVerifyStamp(projectDir, {
758
+ ok: overallOk && summary.manual === 0 && summary.total > 0,
759
+ mode,
760
+ summary,
761
+ results,
762
+ });
763
+ const data = {
764
+ status: overallOk ? (summary.manual ? 'manual' : 'passed') : 'failed',
765
+ artifact_hash: verifyStamp.artifact_hash,
766
+ project_dir: projectDir,
767
+ app_slug: appSlug,
768
+ mode,
769
+ browser_session: noBrowser ? null : browserSessionName,
770
+ server: {
771
+ port,
772
+ base_url: baseUrl,
773
+ urls: urls.map(({ route, url }) => ({ route: route.slug, url })),
774
+ },
775
+ summary,
776
+ results,
777
+ ...(!isReport ? { listing: { ready: listing.ready, gated: ctx.options.listing === true, problems: listing.errors } } : {}),
778
+ };
779
+
780
+ if (!keepOpen) await cleanup();
781
+ ctx.output.emitSuccess({
782
+ ok: overallOk,
783
+ command: ctx.spec.command_path.join(' '),
784
+ data,
785
+ humanSummary: overallOk
786
+ ? (summary.manual ? `Harness URLs ready for ${summary.manual} routes.` : `Verified ${summary.passed} routes successfully.`)
787
+ : `Verification failed for ${summary.failed} routes.`,
788
+ warnings,
789
+ renderHuman: () => renderVerifyReport({ summary, results, noBrowser }),
790
+ });
791
+
792
+ if (keepOpen) {
793
+ process.stderr.write(`[notis apps verify] harness open at ${urls[0]?.url || baseUrl}. Press Ctrl-C to stop.\n`);
794
+ await new Promise(() => {});
795
+ }
796
+
797
+ return exitCode;
798
+ } finally {
799
+ try { await cleanup(); }
800
+ finally { removeSignalHandlers(); }
801
+ }
171
802
  }
172
803
 
173
- async function appsPreviewHandler(ctx) {
804
+ async function appsScreenshotHandler(ctx) {
174
805
  const projectDir = resolveProjectDir(ctx.args.dir || '.');
175
- const port = ctx.options.port ? Number.parseInt(ctx.options.port, 10) : 8787;
176
- if (!Number.isInteger(port) || port <= 0 || port > 65535) {
177
- throw usageError('Port must be between 1 and 65535.');
806
+ const problems = detectProjectProblems(projectDir);
807
+ if (problems.length) {
808
+ throw usageError(`Project has problems:\n${problems.map((p) => ` - ${p}`).join('\n')}`);
809
+ }
810
+
811
+ if (!isAgentBrowserAvailable()) {
812
+ throw usageError('agent-browser is not available on PATH. It ships with the Notis desktop app; open it once, then retry.');
813
+ }
814
+
815
+ const mode = ctx.options.mode || 'stub';
816
+ if (!['stub', 'live'].includes(mode)) {
817
+ throw usageError('--mode must be either "stub" or "live".');
818
+ }
819
+
820
+ let linkedState = null;
821
+ if (mode === 'live') {
822
+ if (
823
+ ctx.runtime.credentialKind === 'oauth'
824
+ && !await ensureFreshOAuthCredential(ctx.runtime)
825
+ ) {
826
+ throw usageError('Live mode requires a current OAuth grant. Run `notis login` and retry.');
827
+ }
828
+ if (!ctx.runtime.jwt) {
829
+ throw usageError('Live mode requires CLI auth. Run notis login and retry.');
830
+ }
831
+ linkedState = readLinkedState(projectDir, linkedStateProfileKey(ctx.runtime));
832
+ if (!linkedState?.app_id) {
833
+ throw usageError('Live mode requires a linked app. Run `notis apps link <app-id> .` first.');
834
+ }
835
+ }
836
+
837
+ if (!ctx.options.skipBuild) {
838
+ await buildArtifact(projectDir, {
839
+ stdio: ctx.output.isMachineMode() ? 'pipe' : 'inherit',
840
+ });
178
841
  }
179
842
 
180
843
  const manifest = readManifest(projectDir);
181
- const defaultRoute = (manifest.routes || []).find((r) => r.default) || manifest.routes?.[0];
182
- const url = `http://localhost:${port}${defaultRoute?.path || '/'}`;
844
+ const appConfig = await loadAppConfig(projectDir);
845
+ const selectedRouteSlugs = parseRouteSlugs(ctx.options.routes);
846
+ const routes = routeSelection(manifest, selectedRouteSlugs);
847
+ const screenshotSlots = screenshotIndexByRouteSlug(manifest);
848
+ if (!routes.length) {
849
+ throw usageError('No routes to screenshot.');
850
+ }
183
851
 
184
- ctx.output.emitSuccess({
185
- command: ctx.spec.command_path.join(' '),
186
- data: { port, url, routes: manifest.routes.length },
187
- humanSummary: `Preview at ${url} -- press Ctrl+C to stop.`,
188
- });
852
+ const width = parsePositiveInt(ctx.options.width) || 2000;
853
+ const height = parsePositiveInt(ctx.options.height) || 1250;
854
+ const outputDir = ctx.options.outputDir
855
+ ? resolveProjectDir(ctx.options.outputDir)
856
+ : join(projectDir, 'metadata');
189
857
 
190
- await startPreviewServer({ projectDir, port });
191
- return EXIT_CODES.ok;
858
+ const port = parsePort(ctx.options.port) || await getAvailablePort();
859
+ const appSlug = slugify(appConfig.name || manifest.app?.name || 'app') || 'app';
860
+ const baseUrl = `http://127.0.0.1:${port}/a/${appSlug}`;
861
+ const browserSessionName = `notis-screenshot-${process.pid}`;
862
+ const browserSessionNames = [];
863
+ const configuredScreenshots = resolveListingScreenshots(projectDir, appConfig)
864
+ .filter((screenshot) => screenshot.route)
865
+ .filter((screenshot) => !selectedRouteSlugs || selectedRouteSlugs.includes(screenshot.route));
866
+ const scenarioWarnings = findUnknownScreenshotScenarios(projectDir, configuredScreenshots);
867
+ const routeBySlug = new Map(routes.map((route) => [route.slug, route]));
868
+ const captures = configuredScreenshots.length > 0
869
+ ? configuredScreenshots.map((screenshot) => {
870
+ const route = routeBySlug.get(screenshot.route);
871
+ if (!route) {
872
+ throw usageError(
873
+ `Screenshot ${screenshot.path} references unavailable route slug "${screenshot.route}".`,
874
+ { available_routes: routes.map((entry) => entry.slug) },
875
+ );
876
+ }
877
+ return {
878
+ route,
879
+ scenario: screenshot.scenario,
880
+ focus: screenshot.focus,
881
+ theme: screenshot.theme || 'light',
882
+ fileName: basename(screenshot.path),
883
+ };
884
+ })
885
+ : routes.map((route, index) => ({
886
+ route,
887
+ scenario: null,
888
+ focus: null,
889
+ theme: 'light',
890
+ fileName: `screenshot-${screenshotSlots.get(route.slug) || index + 1}.png`,
891
+ }));
892
+ const rawOutputDir = ctx.options.raw
893
+ ? null
894
+ : mkdtempSync(join(tmpdir(), 'notis-store-screenshots-'));
895
+ let testServer = null;
896
+ let browserTouched = false;
897
+
898
+ let cleanupPromise;
899
+ const cleanup = () => (cleanupPromise ||= closeHarnessResources(
900
+ browserTouched ? browserSessionNames : [], testServer, rawOutputDir,
901
+ ));
902
+ const removeSignalHandlers = installHarnessSignalCleanup(cleanup);
903
+
904
+ try {
905
+ testServer = await startAppTestServer({
906
+ apps: [{ slug: appSlug, projectDir, appId: linkedState?.app_id || 'harness-app' }],
907
+ port,
908
+ harness: {
909
+ mode,
910
+ apiBase: ctx.runtime.apiBase,
911
+ jwt: mode === 'live' ? ctx.runtime.jwt : null,
912
+ },
913
+ log: () => {},
914
+ logError: (message) => process.stderr.write(`${message}\n`),
915
+ });
916
+ browserTouched = true;
917
+
918
+ mkdirSync(outputDir, { recursive: true });
919
+ const results = [];
920
+ for (const capture of captures) {
921
+ const { route, scenario, focus, theme, fileName } = capture;
922
+ const screenshotPath = join(outputDir, fileName);
923
+ const browserScreenshotPath = rawOutputDir
924
+ ? join(rawOutputDir, fileName)
925
+ : screenshotPath;
926
+ const scenarioParam = scenario ? `&scenario=${encodeURIComponent(scenario)}` : '';
927
+ const themeParam = `&theme=${encodeURIComponent(theme || 'light')}`;
928
+ // Keep scenario captures on independent pages. Chromium can otherwise
929
+ // reuse stale compositor layers when the next screenshot changes the
930
+ // same app route into a substantially different state.
931
+ const captureSessionName = `${browserSessionName}-${results.length + 1}`;
932
+ browserSessionNames.push(captureSessionName);
933
+ let result = await captureHarnessScreenshot({
934
+ url: `${baseUrl}/harness?route=${encodeURIComponent(route.slug)}${scenarioParam}${themeParam}`,
935
+ sessionName: captureSessionName,
936
+ screenshotPath: browserScreenshotPath,
937
+ focusSelector: ctx.options.raw ? null : focus,
938
+ frameContent: !ctx.options.raw,
939
+ width,
940
+ height,
941
+ timeoutMs: Number.parseInt(ctx.globalOptions.timeoutMs || '', 10) || 15_000,
942
+ });
943
+ let presentation = { mode: 'raw' };
944
+ if (result.ok && rawOutputDir) {
945
+ try {
946
+ presentation = await composeStoreScreenshot({
947
+ inputPath: browserScreenshotPath,
948
+ outputPath: screenshotPath,
949
+ width,
950
+ height,
951
+ accent: appConfig.accent,
952
+ seed: appConfig.name || manifest.app?.name || appSlug,
953
+ focused: Boolean(result.framing?.focus_selector),
954
+ theme: theme || 'light',
955
+ });
956
+ } catch (error) {
957
+ result = {
958
+ ...result,
959
+ ok: false,
960
+ screenshotPath: null,
961
+ tool_error: {
962
+ phase: 'compose',
963
+ message: error instanceof Error ? error.message : String(error),
964
+ },
965
+ };
966
+ }
967
+ }
968
+ results.push({
969
+ route: route.slug,
970
+ path: route.path,
971
+ file: relative(projectDir, screenshotPath),
972
+ ok: result.ok,
973
+ errors: result.errors || [],
974
+ timed_out: Boolean(result.timed_out),
975
+ tool_error: result.tool_error,
976
+ framing: result.framing || null,
977
+ theme: theme || 'light',
978
+ presentation,
979
+ });
980
+ }
981
+
982
+ const captured = results.filter((r) => r.ok);
983
+ const failed = results.filter((r) => !r.ok);
984
+ const warnings = [...scenarioWarnings];
985
+
986
+ // Drop stale screenshots only after a full refresh. A selected-route
987
+ // capture intentionally leaves other listing screenshots untouched.
988
+ if (shouldPruneStaleScreenshotFiles(selectedRouteSlugs, failed.length)) {
989
+ pruneStaleScreenshotFiles(outputDir, captures.length);
990
+ }
991
+ if (failed.length) {
992
+ warnings.push(`${failed.length}/${results.length} routes failed to capture; see results.`);
993
+ }
994
+
995
+ await cleanup();
996
+ ctx.output.emitSuccess({
997
+ ok: failed.length === 0,
998
+ command: ctx.spec.command_path.join(' '),
999
+ data: {
1000
+ project_dir: projectDir,
1001
+ output_dir: outputDir,
1002
+ mode,
1003
+ presentation: ctx.options.raw ? 'raw' : 'framed',
1004
+ viewport: { width, height },
1005
+ summary: { total: results.length, captured: captured.length, failed: failed.length },
1006
+ results,
1007
+ },
1008
+ humanSummary: failed.length === 0
1009
+ ? `Captured ${captured.length} screenshot(s) to ${relative(projectDir, outputDir) || 'metadata'}/.`
1010
+ : `Captured ${captured.length}/${results.length} screenshots; ${failed.length} failed.`,
1011
+ warnings,
1012
+ });
1013
+ return screenshotExitCode(failed.length);
1014
+ } finally {
1015
+ try { await cleanup(); }
1016
+ finally { removeSignalHandlers(); }
1017
+ }
1018
+ }
1019
+
1020
+ export function buildLinkedAppState(existingState, appId, linkedAt = new Date().toISOString()) {
1021
+ return { ...(existingState?.app_id === appId ? existingState : {}), app_id: appId, linked_at: linkedAt };
192
1022
  }
193
1023
 
194
1024
  async function appsLinkHandler(ctx) {
195
1025
  const projectDir = resolveProjectDir(ctx.args.dir || '.');
196
1026
  const appId = ctx.args.appId;
1027
+ const expectedVersion = ctx.options.expectedVersion === undefined ? null : Number(ctx.options.expectedVersion);
1028
+ if (expectedVersion !== null && (!/^\d+$/.test(String(ctx.options.expectedVersion)) || !Number.isSafeInteger(expectedVersion))) {
1029
+ throw usageError('--expected-version must be a non-negative integer.');
1030
+ }
197
1031
 
198
- writeLinkedState(projectDir, {
199
- app_id: appId,
200
- linked_at: new Date().toISOString(),
201
- });
1032
+ const app = await assertLinkTarget(ctx.runtime, appId);
1033
+
1034
+ const profileKey = linkedStateProfileKey(ctx.runtime);
1035
+ const state = buildLinkedAppState(readLinkedState(projectDir, profileKey), appId);
1036
+ const version = deployedAppVersion(app);
1037
+ if (expectedVersion !== null && version !== expectedVersion) {
1038
+ throw usageError('The app release changed before linking. Preserve local source, pull the current release into a fresh directory, and reapply changes before deploying.');
1039
+ }
1040
+ if (state.version !== undefined && state.version !== version) {
1041
+ throw usageError('A different release exists. Pull current source into a fresh directory and reapply local changes before deploying.');
1042
+ }
1043
+ if (!app.updated_at) throw usageError('App revision is unavailable; the directory was not relinked.');
1044
+ writeLinkedState(projectDir, { ...state, version, expected_updated_at: app.updated_at }, profileKey);
202
1045
 
203
1046
  return ctx.output.emitSuccess({
204
1047
  command: ctx.spec.command_path.join(' '),
205
- data: { app_id: appId, project_dir: projectDir },
1048
+ data: { app_id: appId, project_dir: projectDir, version, expected_updated_at: app.updated_at },
206
1049
  humanSummary: `Linked to app ${appId}`,
207
1050
  hints: [
208
1051
  { command: 'notis apps deploy .', reason: 'Deploy the app' },
@@ -210,94 +1053,383 @@ async function appsLinkHandler(ctx) {
210
1053
  });
211
1054
  }
212
1055
 
1056
+ async function appsPullHandler(ctx) {
1057
+ const appId = ctx.args.appId;
1058
+ const result = await runToolCommand({
1059
+ runtime: ctx.runtime,
1060
+ // Pull is source retrieval plus local link state. LIST_APPS is deliberately
1061
+ // non-materializing; GET_APP hydrates missing declared databases and would
1062
+ // turn a read-only pull into a remote mutation before build/verification.
1063
+ toolName: LIST_APPS_TOOL,
1064
+ });
1065
+ if (
1066
+ ctx.runtime.credentialKind === 'oauth'
1067
+ && !await ensureFreshOAuthCredential(ctx.runtime)
1068
+ ) {
1069
+ throw usageError('Pulling app source requires a current OAuth grant. Run `notis login` and retry.');
1070
+ }
1071
+ const apps = Array.isArray(result.payload?.apps) ? result.payload.apps : [];
1072
+ const app = apps.find((candidate) => (candidate?.app_id || candidate?.id) === appId);
1073
+ if (!app) {
1074
+ throw usageError(`App ${appId} is not accessible to the active profile.`);
1075
+ }
1076
+ const defaultDir = slugify(app.slug) || slugify(app.name) || slugify(appId);
1077
+ const targetDir = ctx.args.dir
1078
+ ? resolveProjectDir(ctx.args.dir)
1079
+ : defaultAppProjectDir(defaultDir);
1080
+ const version = ctx.options.sourceVersion || 'latest';
1081
+
1082
+ const pulled = await pullAppSource({
1083
+ apiBase: ctx.runtime.apiBase,
1084
+ jwt: ctx.runtime.jwt,
1085
+ appId,
1086
+ targetDir,
1087
+ version,
1088
+ force: Boolean(ctx.options.force),
1089
+ profileKey: linkedStateProfileKey(ctx.runtime),
1090
+ expectedUpdatedAt: app.updated_at,
1091
+ });
1092
+
1093
+ const versionLabel = pulled.version === 'latest' ? 'latest version' : `v${pulled.version}`;
1094
+ return ctx.output.emitSuccess({
1095
+ command: ctx.spec.command_path.join(' '),
1096
+ data: {
1097
+ app_id: appId,
1098
+ project_dir: pulled.projectDir,
1099
+ version: pulled.version,
1100
+ },
1101
+ humanSummary: `Pulled ${versionLabel} to ${pulled.projectDir}. Run npm install, edit the source, then build, verify and deploy the update.`,
1102
+ });
1103
+ }
1104
+
1105
+ function updateLinkedDeployState(projectDir, linkedState, appId, version, profileKey = null, updatedAt = null) {
1106
+ if (!linkedState || linkedState.app_id !== appId || !Number.isFinite(version)) {
1107
+ return;
1108
+ }
1109
+ writeLinkedState(projectDir, {
1110
+ ...linkedState,
1111
+ app_id: appId,
1112
+ version,
1113
+ linked_at: linkedState.linked_at || new Date().toISOString(),
1114
+ deployed_at: new Date().toISOString(),
1115
+ expected_updated_at: updatedAt,
1116
+ }, profileKey);
1117
+ }
1118
+
213
1119
  async function appsDeployHandler(ctx) {
214
1120
  const projectDir = resolveProjectDir(ctx.args.dir || '.');
215
- const appId = requireLinkedAppId(projectDir, ctx.options.appId);
1121
+ const profileKey = linkedStateProfileKey(ctx.runtime);
1122
+ const appId = requireLinkedAppId(projectDir, ctx.options.appId, profileKey);
216
1123
  const idempotencyKey = nextIdempotencyKey(ctx.globalOptions);
1124
+ const linkedState = readLinkedState(projectDir, profileKey);
1125
+ const baseVersion = linkedState?.app_id === appId && Number.isFinite(linkedState?.version)
1126
+ ? linkedState.version
1127
+ : undefined;
217
1128
 
218
- // Build if needed
219
- if (!ctx.options.skipBuild) {
220
- await buildArtifact(projectDir);
1129
+ if (!Number.isInteger(baseVersion) || baseVersion < 0 || !linkedState?.expected_updated_at) {
1130
+ throw usageError('Deploy requires a current profile-scoped app link and deployment base. Pull the current release, or link an unreleased app first.');
221
1131
  }
222
1132
 
223
- // Direct deploy mode: upload to Supabase storage directly
224
- if (ctx.options.direct) {
225
- await assertDirectDeployAccess(ctx.runtime, appId);
226
- const { version } = await directDeploy(projectDir, appId);
227
- return ctx.output.emitSuccess({
228
- command: ctx.spec.command_path.join(' '),
229
- data: { app_id: appId, version, mode: 'direct' },
230
- humanSummary: `Deployed to app ${appId} (version ${version}) via direct upload`,
231
- meta: { mutating: true },
1133
+ // Build if needed
1134
+ if (!ctx.options.skipBuild) {
1135
+ await buildArtifact(projectDir, {
1136
+ stdio: ctx.output.isMachineMode() ? 'pipe' : 'inherit',
232
1137
  });
233
1138
  }
234
1139
 
235
- // Standard deploy via backend server, with auto-fallback to direct
236
- const files = collectArtifactFiles(projectDir);
237
- const manifest = readManifest(projectDir);
238
-
239
- let result;
1140
+ const release = prepareAppRelease(projectDir);
1141
+ const { files, sourceFiles, manifest } = release;
1142
+ let cleanupVerification = async () => {};
1143
+ let uploadStarted = false;
1144
+ let cancelled = false;
1145
+ const removeDeploySignalHandlers = installHarnessSignalCleanup(async () => {
1146
+ cancelled = true;
1147
+ const cleanupErrors = [];
1148
+ try { await cleanupVerification(); }
1149
+ catch (error) { cleanupErrors.push(error.message); }
1150
+ finally {
1151
+ try { release.close(); } catch (error) { cleanupErrors.push(error.message); }
1152
+ }
1153
+ ctx.output.emitError({ command: 'apps deploy', error: new CliError({
1154
+ code: uploadStarted ? 'app_deploy_outcome_unknown' : 'app_deploy_cancelled',
1155
+ message: uploadStarted
1156
+ ? 'Deployment interrupted. Read back the exact app/version before retrying.'
1157
+ : 'Deployment interrupted before upload; no update was deployed.',
1158
+ retryable: false, exitCode: EXIT_CODES.network,
1159
+ details: { app_id: appId, base_version: baseVersion,
1160
+ target_version: baseVersion + 1, idempotency_key: idempotencyKey,
1161
+ activation_outcome: uploadStarted ? 'unknown' : 'not_started',
1162
+ ...(cleanupErrors.length ? { cleanup_errors: cleanupErrors } : {}) },
1163
+ hints: uploadStarted
1164
+ ? [{ command: 'notis apps list --json', reason: 'Reconcile the interrupted deployment' }]
1165
+ : [],
1166
+ }) });
1167
+ });
240
1168
  try {
241
- result = await runToolCommand({
242
- runtime: ctx.runtime,
243
- toolName: 'notis_save_app_files',
244
- arguments_: {
245
- app_id: appId,
246
- files,
247
- manifest,
1169
+ let verification;
1170
+ const verifyOutput = {
1171
+ ...ctx.output,
1172
+ emitSuccess: (result) => { verification = result; },
1173
+ isMachineMode: () => true,
1174
+ };
1175
+ const verified = await appsVerifyHandler({
1176
+ ...ctx, args: { dir: release.projectDir },
1177
+ options: { skipBuild: true, mode: 'stub' }, output: verifyOutput,
1178
+ registerSignalCleanup: (cleanup) => {
1179
+ cleanupVerification = cleanup;
1180
+ return () => { cleanupVerification = async () => {}; };
248
1181
  },
249
- mutating: true,
250
- idempotencyKey,
1182
+ }).catch(async (error) => {
1183
+ // The signal handler also awaits verification cleanup. If that shared
1184
+ // promise rejects, it still owns the single structured terminal outcome.
1185
+ if (cancelled) return await new Promise(() => {});
1186
+ throw error;
251
1187
  });
252
- } catch (error) {
253
- if (error.code === 'conflict') {
254
- throw toolConflictToError(error.details, 'Deploy conflict');
1188
+ if (verified !== EXIT_CODES.ok || verification?.data?.status !== 'passed' || verification?.data?.summary?.passed < 1) {
1189
+ throw usageError('App verification failed; no update was deployed. Run notis apps verify for details.');
255
1190
  }
256
1191
 
257
- // Auto-fallback to direct deploy on network errors
258
- const isNetworkError = error.code === 'network_error'
259
- || error.code === 'network_timeout'
260
- || (error.message && /fetch failed|ECONNREFUSED|network/i.test(error.message));
261
1192
 
262
- if (isNetworkError) {
263
- try {
264
- await assertDirectDeployAccess(ctx.runtime, appId);
265
- } catch (accessError) {
266
- throw usageError(
267
- `Backend deploy failed (${error.message}) and direct fallback was blocked because app access ` +
268
- `could not be verified (${accessError.message}).`,
269
- );
1193
+ // The signal handler owns terminal reporting and exit. A cancellation
1194
+ // during verification cleanup must never continue into the mutation.
1195
+ if (cancelled) return await new Promise(() => {});
1196
+
1197
+ // Upload uses captured bytes only. Fail closed on a staging identity swap
1198
+ // before any remote mutation, and finish local cleanup before activation.
1199
+ release.close();
1200
+
1201
+ let result;
1202
+ try {
1203
+ uploadStarted = true;
1204
+ result = await runToolCommand({
1205
+ // App deploys upload both the built artifact and the editable source
1206
+ // snapshot. The ordinary 30s CLI timeout is too short for larger apps,
1207
+ // and timing out a mutation is ambiguous: the backend may commit after
1208
+ // the client disconnects. Give this operation its real completion window.
1209
+ runtime: {
1210
+ ...ctx.runtime,
1211
+ timeoutMs: Math.max(ctx.runtime.timeoutMs || 0, APP_DEPLOY_TIMEOUT_MS),
1212
+ },
1213
+ toolName: SAVE_APP_FILES_TOOL,
1214
+ arguments_: {
1215
+ app_id: appId,
1216
+ files,
1217
+ source_files: sourceFiles,
1218
+ manifest,
1219
+ ...appRowFieldsFromManifest(manifest),
1220
+ base_version: baseVersion,
1221
+ expected_updated_at: linkedState.expected_updated_at,
1222
+ },
1223
+ mutating: true,
1224
+ idempotencyKey,
1225
+ });
1226
+ } catch (error) {
1227
+ if (error.code === 'conflict') {
1228
+ throw toolConflictToError(error.details, 'Deploy conflict');
270
1229
  }
271
1230
 
272
- try {
273
- const { version } = await directDeploy(projectDir, appId);
274
- return ctx.output.emitSuccess({
275
- command: ctx.spec.command_path.join(' '),
276
- data: { app_id: appId, version, mode: 'direct-fallback' },
277
- humanSummary: `Backend unavailable -- deployed to app ${appId} (version ${version}) via direct upload`,
278
- warnings: ['Backend server was unreachable. Used direct Supabase upload as fallback.'],
279
- meta: { mutating: true },
280
- });
281
- } catch (directError) {
282
- throw usageError(
283
- `Backend deploy failed (${error.message}) and direct fallback also failed (${directError.message}). ` +
284
- 'Check server/.env for Supabase credentials or start the backend server.',
285
- );
1231
+ // Transport failure may arrive after commit. Never replay an uncertain release.
1232
+ if (error.code === 'network_timeout' || error.code === 'network_error') {
1233
+ error.message = `${error.message}. Deployment outcome is unknown; read back the exact app/version before any retry.`;
1234
+ error.retryable = false;
1235
+ error.details = { ...error.details, app_id: appId, base_version: baseVersion,
1236
+ target_version: baseVersion + 1, idempotency_key: idempotencyKey };
1237
+ error.hints = [{ command: 'notis apps list --json', reason: `Read back app ${appId} and reconcile the deployment outcome` }];
286
1238
  }
1239
+ throw error;
287
1240
  }
288
1241
 
289
- throw error;
1242
+ const deployedVersion = Number(result?.payload?.version);
1243
+ if (!Number.isInteger(deployedVersion) || deployedVersion !== baseVersion + 1 || result.payload.app_id !== appId || !result.payload.updated_at) {
1244
+ throw new CliError({
1245
+ code: 'network_error',
1246
+ message: 'The backend returned an incomplete deploy response. The deploy may have committed; inspect the app version and pull before retrying.',
1247
+ exitCode: EXIT_CODES.network,
1248
+ retryable: false,
1249
+ details: { app_id: appId, base_version: baseVersion, target_version: baseVersion + 1, idempotency_key: idempotencyKey },
1250
+ hints: [{ command: 'notis apps list --json', reason: 'Reconcile the incomplete deployment response' }],
1251
+ });
1252
+ }
1253
+
1254
+ const warnings = [];
1255
+ try { updateLinkedDeployState(projectDir, linkedState, appId, deployedVersion, profileKey, result.payload.updated_at); }
1256
+ catch { warnings.push('The app was updated, but the local link could not be saved. Pull the installed version before editing again.'); }
1257
+ try { release.close(); }
1258
+ catch { warnings.push('The app was updated, but the temporary release directory needs local cleanup.'); }
1259
+
1260
+ // One review report per skill this release changed; share the link when the user should review it.
1261
+ const skillReviews = (Array.isArray(result.payload.skill_reviews) ? result.payload.skill_reviews : [])
1262
+ .filter((review) => review && typeof review.review_url === 'string');
1263
+ return ctx.output.emitSuccess({
1264
+ command: ctx.spec.command_path.join(' '),
1265
+ data: {
1266
+ app_id: appId,
1267
+ version: deployedVersion,
1268
+ idempotency_key: idempotencyKey,
1269
+ ...(skillReviews.length ? { skill_reviews: skillReviews } : {}),
1270
+ },
1271
+ warnings,
1272
+ humanSummary: [
1273
+ `Deployed to app ${appId} (version ${deployedVersion})`,
1274
+ ...skillReviews.map((review) => `Review ${review.skill} changes: ${review.review_url}`),
1275
+ ].join('\n'),
1276
+ meta: { mutating: true, idempotency_key: idempotencyKey },
1277
+ });
1278
+ } finally {
1279
+ removeDeploySignalHandlers();
1280
+ try { release.close(); } catch { /* Do not mask a committed or unknown release. */ }
1281
+ }
1282
+ }
1283
+
1284
+ function deployedAppVersion(app) {
1285
+ const value = app?.current_version ?? app?.manifest?.version;
1286
+ if (value === null || value === undefined) return 0;
1287
+ const parsed = Number(value);
1288
+ if (!Number.isInteger(parsed) || parsed < 0) throw usageError('The app returned an invalid deployment version.');
1289
+ return parsed;
1290
+ }
1291
+
1292
+ async function appsDuplicateHandler(ctx) {
1293
+ const projectDir = resolveProjectDir(ctx.args.dir || '.');
1294
+ // Either target an app explicitly, or duplicate whatever this project is
1295
+ // linked to, so `notis apps duplicate` works from inside a project.
1296
+ const appId = requireLinkedAppId(projectDir, ctx.options.appId, linkedStateProfileKey(ctx.runtime));
1297
+ const idempotencyKey = nextIdempotencyKey(ctx.globalOptions);
1298
+
1299
+ const copyDocuments = ctx.options.copyDocuments || 'declared';
1300
+ if (!['declared', 'all', 'none'].includes(copyDocuments)) {
1301
+ throw usageError("--copy-documents must be one of: declared, all, none.");
1302
+ }
1303
+
1304
+ const result = await runToolCommand({
1305
+ runtime: ctx.runtime,
1306
+ toolName: DUPLICATE_APP_TOOL,
1307
+ arguments_: {
1308
+ app_id: appId,
1309
+ ...(ctx.options.name ? { name: ctx.options.name } : {}),
1310
+ copy_documents: copyDocuments,
1311
+ },
1312
+ mutating: true,
1313
+ idempotencyKey,
1314
+ });
1315
+
1316
+ const payload = result.payload || {};
1317
+ if (payload.status === 'error') {
1318
+ throw usageError(`Could not duplicate app ${appId}: ${payload.message || 'unknown error'}`);
1319
+ }
1320
+
1321
+ const duplicated = payload.app || {};
1322
+ if (!duplicated.id) {
1323
+ throw usageError(`Could not duplicate app ${appId}: the backend did not return an app id.`);
1324
+ }
1325
+
1326
+ const data = {
1327
+ app_id: duplicated.id,
1328
+ name: duplicated.name,
1329
+ slug: duplicated.slug,
1330
+ duplicated_from_app_id: payload.duplicated_from_app_id || appId,
1331
+ copied_document_count: payload.copied_document_count ?? 0,
1332
+ portal_url: payload.portal_url,
1333
+ idempotency_key: idempotencyKey,
1334
+ // The duplicate owns brand new databases; nothing is shared with the source.
1335
+ databases: (payload.databases || []).map((database) => ({
1336
+ id: database.id,
1337
+ slug: database.slug,
1338
+ name: database.name,
1339
+ })),
1340
+ };
1341
+
1342
+ return ctx.output.emitSuccess({
1343
+ command: ctx.spec.command_path.join(' '),
1344
+ data,
1345
+ humanSummary: `Duplicated app ${appId} as ${duplicated.name || duplicated.id}`,
1346
+ hints: payload.portal_url
1347
+ ? [{ command: payload.portal_url, reason: 'Open the duplicated app in Portal' }]
1348
+ : [],
1349
+ meta: { mutating: true, idempotency_key: idempotencyKey },
1350
+ });
1351
+ }
1352
+
1353
+ async function appsPublishHandler(ctx) {
1354
+ if (ctx.options.confirmReady !== true) {
1355
+ throw usageError(
1356
+ 'Store submission requires explicit user confirmation that App Details is ready. ' +
1357
+ 'After confirmation, rerun with --confirm-ready.',
1358
+ );
1359
+ }
1360
+
1361
+ const projectDir = resolveProjectDir(ctx.args.dir || '.');
1362
+ const appId = requireLinkedAppId(projectDir, ctx.options.appId, linkedStateProfileKey(ctx.runtime));
1363
+ const linkedState = readLinkedState(projectDir, linkedStateProfileKey(ctx.runtime));
1364
+ const appConfig = await loadAppConfig(projectDir);
1365
+ const readiness = inspectListingReadiness(projectDir, appConfig);
1366
+ if (!readiness.ready) {
1367
+ throw usageError(
1368
+ `Store listing is not ready:\n${readiness.errors.map((error) => ` - ${error}`).join('\n')}`,
1369
+ );
1370
+ }
1371
+
1372
+ const detailResult = await runToolCommand({
1373
+ runtime: ctx.runtime,
1374
+ toolName: GET_APP_TOOL,
1375
+ arguments_: { app_id: appId },
1376
+ });
1377
+ const detail = detailResult.payload || {};
1378
+ const app = detail.app || {};
1379
+ if (!app.id) {
1380
+ throw usageError(`Could not load deployed app ${appId}.`);
1381
+ }
1382
+ if (!['team', 'public_store_hidden'].includes(app.visibility)) {
1383
+ throw usageError('Set the app visibility to Team or Public before Store submission.');
1384
+ }
1385
+
1386
+ const remoteVersion = deployedAppVersion(app);
1387
+ if (remoteVersion <= 0) {
1388
+ throw usageError('App has no deployed source. Run `notis apps deploy` first.');
1389
+ }
1390
+ if (
1391
+ linkedState?.app_id !== appId
1392
+ || !Number.isFinite(linkedState?.version)
1393
+ || linkedState.version !== remoteVersion
1394
+ ) {
1395
+ throw usageError(
1396
+ `Local project is not confirmed at deployed version ${remoteVersion}. ` +
1397
+ 'Run `notis apps deploy` from this project before submitting it.',
1398
+ );
290
1399
  }
291
1400
 
1401
+ const activeSubmission = detail.active_submission || app.active_submission || null;
1402
+ if (activeSubmission?.status === 'pending_review') {
1403
+ throw usageError(
1404
+ `A Store submission is already in review${activeSubmission.github_pr_url ? `: ${activeSubmission.github_pr_url}` : '.'}`,
1405
+ );
1406
+ }
1407
+ if (activeSubmission?.status === 'removal_pending_review') {
1408
+ throw usageError('Store removal is currently in review. Wait for it to finish before submitting an update.');
1409
+ }
1410
+
1411
+ const result = await httpRequest({
1412
+ runtime: ctx.runtime,
1413
+ method: 'POST',
1414
+ path: '/portal_apps/publish',
1415
+ body: { app_id: appId },
1416
+ });
1417
+ const submission = result.payload.submission || result.payload;
1418
+ const reviewStatus = submission.status || 'pending_review';
292
1419
  return ctx.output.emitSuccess({
293
1420
  command: ctx.spec.command_path.join(' '),
294
1421
  data: {
295
1422
  app_id: appId,
296
- version: result.payload.version,
297
- idempotency_key: idempotencyKey,
1423
+ source_version: submission.source_version || remoteVersion,
1424
+ submission,
298
1425
  },
299
- humanSummary: `Deployed to app ${appId} (version ${result.payload.version})`,
300
- meta: { mutating: true, idempotency_key: idempotencyKey },
1426
+ humanSummary: reviewStatus === 'merged'
1427
+ ? `Published app ${appId} to the Store at version ${submission.source_version || remoteVersion}`
1428
+ : `Submitted app ${appId} version ${submission.source_version || remoteVersion} for Store review`,
1429
+ hints: submission.github_pr_url
1430
+ ? [{ command: submission.github_pr_url, reason: 'Review the Store registry pull request' }]
1431
+ : [],
1432
+ meta: { mutating: true, request_id: result.requestId },
301
1433
  });
302
1434
  }
303
1435
 
@@ -312,21 +1444,36 @@ async function appsDoctorHandler(ctx) {
312
1444
  problems.push('Failed to load notis.config.ts');
313
1445
  }
314
1446
  const warnings = detectProjectWarnings(projectDir, appConfig);
1447
+ let listing = null;
1448
+ if (appConfig) {
1449
+ try {
1450
+ listing = inspectListingReadiness(projectDir, appConfig);
1451
+ } catch (error) {
1452
+ warnings.push(error instanceof Error ? error.message : String(error));
1453
+ }
1454
+ }
315
1455
 
316
- const linkedState = readLinkedState(projectDir);
1456
+ const linkedState = readLinkedState(projectDir, linkedStateProfileKey(ctx.runtime));
317
1457
  const status = problems.length ? 'unhealthy' : warnings.length ? 'warnings' : 'healthy';
318
1458
 
319
1459
  return ctx.output.emitSuccess({
320
1460
  command: ctx.spec.command_path.join(' '),
321
- data: { status, problems, warnings, linked: linkedState, config: appConfig },
1461
+ data: { status, problems, warnings, linked: linkedState, config: appConfig, listing },
322
1462
  humanSummary: problems.length
323
1463
  ? `Found ${problems.length} problems:\n${problems.map((p) => ` - ${p}`).join('\n')}`
324
1464
  : warnings.length
325
1465
  ? `Healthy with ${warnings.length} warnings:\n${warnings.map((w) => ` - ${w}`).join('\n')}`
326
- : `Project is healthy.${linkedState ? ` Linked to ${linkedState.app_id}.` : ' Not linked.'}`,
1466
+ : `Project is healthy.${doctorLinkSummary(linkedState)}`,
327
1467
  });
328
1468
  }
329
1469
 
1470
+ export function doctorLinkSummary(linkedState) {
1471
+ if (linkedState?.app_id) {
1472
+ return ` Linked to app ${linkedState.app_id}.`;
1473
+ }
1474
+ return ' Not linked.';
1475
+ }
1476
+
330
1477
  // ---------------------------------------------------------------------------
331
1478
  // Command specs
332
1479
  // ---------------------------------------------------------------------------
@@ -340,27 +1487,55 @@ export const appsCommandSpecs = [
340
1487
  examples: ['notis apps list', 'notis apps list --json'],
341
1488
  mutates: false,
342
1489
  idempotent: true,
343
- backend_call: { type: 'tool', name: 'notis_list_apps' },
1490
+ backend_call: { type: 'tool', name: LIST_APPS_TOOL },
344
1491
  handler: appsListHandler,
345
1492
  },
346
1493
  {
347
1494
  command_path: ['apps', 'init'],
348
1495
  summary: 'Scaffold a new Notis app project.',
349
- when_to_use: 'Start a new Notis app. Creates a Vite + React project with @notis/sdk pre-configured.',
1496
+ when_to_use: 'Start a new Notis app. Use --from with a published Store app when one is close to the desired app; otherwise creates the bare Vite + React project.',
350
1497
  args_schema: {
351
1498
  arguments: [
352
1499
  { token: '<name>', description: 'Display name for the app.' },
353
- { token: '[dir]', key: 'dir', description: 'Target directory (defaults to kebab-case of name).' },
1500
+ { token: '[dir]', key: 'dir', description: 'Target directory, resolved from the current directory. Defaults to ~/.notis/apps/<slug>; pass a path to place the project elsewhere, such as a tracked git repo or an existing monorepo.' },
1501
+ ],
1502
+ options: [
1503
+ { flags: '--from <slug>', description: 'Start from a published Store app listed by `notis apps scaffolds list`. Downloads its source from the public app registry.' },
354
1504
  ],
355
- options: [],
356
1505
  },
357
- examples: ['notis apps init "Mind the Flo"', 'notis apps init "My App" ./my-app'],
1506
+ examples: [
1507
+ 'notis apps scaffolds list',
1508
+ 'notis apps init "Mind the Flo"',
1509
+ 'notis apps init "My CRM" --from databases',
1510
+ 'notis apps init "My App" ~/code/my-app',
1511
+ ],
358
1512
  mutates: true,
359
1513
  idempotent: false,
360
1514
  require_auth: false,
361
1515
  backend_call: { type: 'local', name: 'scaffold_project' },
362
1516
  handler: appsInitHandler,
363
1517
  },
1518
+ {
1519
+ command_path: ['apps', 'scaffolds', 'list'],
1520
+ summary: 'List published Store apps available as scaffolds.',
1521
+ when_to_use: 'Discover published Store apps to start from before creating a new app. Every app published to the public Store is automatically a scaffold; use --search to narrow the catalog.',
1522
+ args_schema: {
1523
+ arguments: [],
1524
+ options: [
1525
+ { flags: '--search <term>', description: 'Filter scaffolds by name, tagline, description, or category.' },
1526
+ ],
1527
+ },
1528
+ examples: [
1529
+ 'notis apps scaffolds list',
1530
+ 'notis apps scaffolds list --search journal',
1531
+ 'notis apps init "My App" --from databases',
1532
+ ],
1533
+ mutates: false,
1534
+ idempotent: true,
1535
+ require_auth: false,
1536
+ backend_call: { type: 'local', name: 'list_scaffolds' },
1537
+ handler: appsScaffoldsListHandler,
1538
+ },
364
1539
  {
365
1540
  command_path: ['apps', 'create'],
366
1541
  summary: 'Create a new remote Notis app and optionally link a local project to it.',
@@ -370,72 +1545,102 @@ export const appsCommandSpecs = [
370
1545
  { token: '<name>', description: 'Display name for the remote app.' },
371
1546
  { token: '[dir]', key: 'dir', description: 'Project directory to link after creation (default: do not link).' },
372
1547
  ],
373
- options: [
374
- { flags: '--description <text>', description: 'Optional app description.' },
375
- { flags: '--icon <lucide:icon>', description: 'Optional Lucide icon, for example lucide:dices.' },
376
- ],
1548
+ options: [{ flags: '--team-id <id>', description: 'Create or reuse the exact team-scoped app (default: personal).' }],
377
1549
  },
378
1550
  examples: [
379
1551
  'notis apps create "My App"',
380
- 'notis apps create "My App" . --description "Internal tool" --icon lucide:layout-dashboard',
1552
+ 'notis apps create "My App" .',
381
1553
  ],
382
1554
  mutates: true,
383
- idempotent: false,
384
- backend_call: { type: 'tool', name: 'notis_create_app' },
1555
+ idempotent: true,
1556
+ backend_call: { type: 'tool', name: CREATE_APP_TOOL },
385
1557
  handler: appsCreateHandler,
386
1558
  },
387
1559
  {
388
- command_path: ['apps', 'dev'],
389
- summary: 'Run the Vite dev server for local development.',
390
- when_to_use: 'Iterate on app UI with hot reload. SDK hooks return mock data.',
1560
+ command_path: ['apps', 'build'],
1561
+ summary: 'Build and package the app into .notis/output/.',
1562
+ when_to_use: 'Prepare the app for verification or deployment.',
391
1563
  args_schema: {
392
1564
  arguments: [
393
1565
  { token: '[dir]', key: 'dir', description: 'Project directory (default: current dir).' },
394
1566
  ],
395
1567
  options: [],
396
1568
  },
397
- examples: ['notis apps dev', 'notis apps dev ./my-app'],
398
- mutates: false,
1569
+ examples: ['notis apps build', 'notis apps build ./my-app'],
1570
+ mutates: true,
399
1571
  idempotent: true,
400
1572
  require_auth: false,
401
- backend_call: { type: 'local', name: 'next_dev' },
402
- handler: appsDevHandler,
1573
+ backend_call: { type: 'local', name: 'next_build_and_package' },
1574
+ handler: appsBuildHandler,
403
1575
  },
404
1576
  {
405
- command_path: ['apps', 'build'],
406
- summary: 'Build and package the app into .notis/output/.',
407
- when_to_use: 'Prepare the app for preview or deployment.',
1577
+ command_path: ['apps', 'verify'],
1578
+ summary: 'Validate that every route renders and reports Store listing readiness.',
1579
+ when_to_use:
1580
+ 'Any time after notis apps build, and before deploy. Catches render-time crashes and ' +
1581
+ 'missing runtime calls. Incomplete listing media is reported as a warning; pass --listing ' +
1582
+ 'to fail on it instead.',
408
1583
  args_schema: {
409
1584
  arguments: [
410
1585
  { token: '[dir]', key: 'dir', description: 'Project directory (default: current dir).' },
411
1586
  ],
412
- options: [],
1587
+ options: [
1588
+ { flags: '--routes <slugs>', description: 'Comma-separated route slugs. Default: every route in manifest.' },
1589
+ { flags: '--port <n>', description: 'Loopback port. Default: auto-pick.' },
1590
+ { flags: '--skip-build', description: 'Skip notis apps build; reuse existing .notis/output/.' },
1591
+ { 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.' },
1592
+ { flags: '--listing', description: 'Fail instead of warn when the Store listing (tagline, categories, screenshots, changelog) is incomplete.' },
1593
+ { flags: '--no-browser', description: 'Start the harness server and print URLs; do not drive agent-browser.' },
1594
+ { flags: '--keep-open', description: 'Leave server + browser session running after report (for manual triage).' },
1595
+ ],
413
1596
  },
414
- examples: ['notis apps build', 'notis apps build ./my-app'],
415
- mutates: true,
1597
+ examples: [
1598
+ 'notis apps verify',
1599
+ 'notis apps verify --routes notes',
1600
+ 'notis apps verify --mode live',
1601
+ 'notis apps verify --listing # gate on Store listing readiness before publish',
1602
+ 'notis apps verify --no-browser # start the harness, drive agent-browser yourself',
1603
+ ],
1604
+ mutates: false,
416
1605
  idempotent: true,
417
1606
  require_auth: false,
418
- backend_call: { type: 'local', name: 'next_build_and_package' },
419
- handler: appsBuildHandler,
1607
+ backend_call: { type: 'local', name: 'verify_harness' },
1608
+ handler: appsVerifyHandler,
420
1609
  },
421
1610
  {
422
- command_path: ['apps', 'preview'],
423
- summary: 'Serve the built bundle locally for testing.',
424
- when_to_use: 'Smoke-test the exact bundle that will be deployed. Databases use seed data.',
1611
+ command_path: ['apps', 'screenshot'],
1612
+ summary: 'Capture configured listing route/scenario states via the headless harness.',
1613
+ when_to_use:
1614
+ 'Generate the 3–6 declared metadata/screenshot-N.png files for the App Store listing. Apps are ' +
1615
+ 'icon-led (like Raycast) — there is no cover image, only these screenshots. ' +
1616
+ 'Each screenshot may set a focus selector to remove empty canvas and a light or dark theme that also controls its Store frame. ' +
1617
+ 'Run before notis apps verify / deploy / publish.',
425
1618
  args_schema: {
426
1619
  arguments: [
427
1620
  { token: '[dir]', key: 'dir', description: 'Project directory (default: current dir).' },
428
1621
  ],
429
1622
  options: [
430
- { flags: '--port <number>', description: 'Server port (default: 8787).' },
1623
+ { flags: '--routes <slugs>', description: 'Comma-separated route slugs. Default: every configured screenshot state.' },
1624
+ { flags: '--port <n>', description: 'Loopback port. Default: auto-pick.' },
1625
+ { flags: '--width <px>', description: 'Viewport width. Default: 2000.' },
1626
+ { flags: '--height <px>', description: 'Viewport height. Default: 1250 (16:10).' },
1627
+ { flags: '--output-dir <dir>', description: 'Where to write screenshot-N.png. Default: metadata/.' },
1628
+ { 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.' },
1629
+ { flags: '--raw', description: 'Write the unframed harness capture instead of the default Store presentation.' },
1630
+ { flags: '--skip-build', description: 'Skip notis apps build; reuse existing .notis/output/.' },
431
1631
  ],
432
1632
  },
433
- examples: ['notis apps preview', 'notis apps preview --port 3000'],
434
- mutates: false,
1633
+ examples: [
1634
+ 'notis apps screenshot # honors notis.config.ts screenshot scenarios',
1635
+ 'notis apps screenshot --routes home,history',
1636
+ 'notis apps screenshot --mode live # populated screenshots from real data',
1637
+ 'notis apps screenshot --raw # diagnostic capture without Store framing',
1638
+ ],
1639
+ mutates: true,
435
1640
  idempotent: true,
436
1641
  require_auth: false,
437
- backend_call: { type: 'local', name: 'preview_server' },
438
- handler: appsPreviewHandler,
1642
+ backend_call: { type: 'local', name: 'screenshot_routes' },
1643
+ handler: appsScreenshotHandler,
439
1644
  },
440
1645
  {
441
1646
  command_path: ['apps', 'link'],
@@ -446,36 +1651,111 @@ export const appsCommandSpecs = [
446
1651
  { token: '<app-id>', description: 'Remote app ID to link to.' },
447
1652
  { token: '[dir]', key: 'dir', description: 'Project directory (default: current dir).' },
448
1653
  ],
449
- options: [],
1654
+ options: [
1655
+ { flags: '--expected-version <version>', description: 'Link only if the remote deployment version still matches this non-negative integer.' },
1656
+ ],
450
1657
  },
451
- examples: ['notis apps link abc123', 'notis apps link abc123 ./my-app'],
1658
+ examples: ['notis apps link abc123', 'notis apps link abc123 ./my-app', 'notis apps link abc123 ./recovered-app --expected-version 0'],
452
1659
  mutates: true,
453
1660
  idempotent: true,
454
1661
  require_auth: false,
455
1662
  backend_call: { type: 'local', name: 'write_link_state' },
456
1663
  handler: appsLinkHandler,
457
1664
  },
1665
+ {
1666
+ command_path: ['apps', 'pull'],
1667
+ summary: 'Download a Notis app source snapshot into a local project folder.',
1668
+ when_to_use:
1669
+ 'Edit an installed app. Preserve local edits, pull and link its persisted source, then build, verify and deploy.',
1670
+ args_schema: {
1671
+ arguments: [
1672
+ { token: '<app-id>', description: 'Remote app ID to pull.' },
1673
+ { token: '[dir]', key: 'dir', description: 'Target directory (defaults to ~/.notis/apps/<app-slug>).' },
1674
+ ],
1675
+ options: [
1676
+ { flags: '--force', description: 'Overwrite a non-empty target directory.' },
1677
+ { flags: '--source-version <n>', description: 'Pull a specific app source version (default: latest).' },
1678
+ ],
1679
+ },
1680
+ examples: [
1681
+ 'notis apps pull abc123',
1682
+ 'notis apps pull abc123 ./my-app --force --source-version 3',
1683
+ ],
1684
+ mutates: true,
1685
+ idempotent: true,
1686
+ require_auth: true,
1687
+ backend_call: { type: 'http', name: 'portal_apps/source' },
1688
+ handler: appsPullHandler,
1689
+ },
458
1690
  {
459
1691
  command_path: ['apps', 'deploy'],
460
- summary: 'Build and upload the app to the linked Notis app.',
1692
+ summary: 'Build, verify and release the linked Workspace app.',
461
1693
  when_to_use:
462
- 'Ship the installed app to production for the linked user/team app. Requires a linked app (notis apps link). This command does not publish to the app store.',
1694
+ 'Build, verify and release the linked personal or team Workspace app. This command does not publish to the Store.',
463
1695
  args_schema: {
464
1696
  arguments: [
465
1697
  { token: '[dir]', key: 'dir', description: 'Project directory (default: current dir).' },
466
1698
  ],
467
1699
  options: [
468
1700
  { flags: '--app-id <id>', description: 'Override linked app ID.' },
469
- { flags: '--skip-build', description: 'Skip the build step (use existing .notis/output/).' },
470
- { flags: '--direct', description: 'Upload directly to Supabase storage, bypassing the backend server. Auto-fallback on network errors.' },
1701
+ { flags: '--skip-build', description: 'Reuse unchanged build output; automated verification still runs.' },
471
1702
  ],
472
1703
  },
473
- examples: ['notis apps deploy', 'notis apps deploy --skip-build', 'notis apps deploy --app-id abc123', 'notis apps deploy --direct'],
474
1704
  mutates: true,
475
1705
  idempotent: true,
476
- backend_call: { type: 'tool', name: 'notis_save_app_files' },
1706
+ backend_call: { type: 'tool', name: SAVE_APP_FILES_TOOL },
477
1707
  handler: appsDeployHandler,
478
1708
  },
1709
+ {
1710
+ command_path: ['apps', 'publish'],
1711
+ summary: 'Submit the deployed app for Store review.',
1712
+ when_to_use:
1713
+ '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.',
1714
+ args_schema: {
1715
+ arguments: [
1716
+ { token: '[dir]', key: 'dir', description: 'Project directory (default: current dir).' },
1717
+ ],
1718
+ options: [
1719
+ { flags: '--app-id <id>', description: 'Override linked app ID.' },
1720
+ { flags: '--confirm-ready', description: 'Confirm the user approved the current App Details page for Store submission.' },
1721
+ ],
1722
+ },
1723
+ examples: ['notis apps publish --confirm-ready', 'notis apps publish ./my-app --confirm-ready'],
1724
+ mutates: true,
1725
+ idempotent: false,
1726
+ require_auth: true,
1727
+ backend_call: { type: 'http', name: 'portal_apps/publish' },
1728
+ handler: appsPublishHandler,
1729
+ },
1730
+ {
1731
+ command_path: ['apps', 'duplicate'],
1732
+ summary: 'Duplicate an app into an independent copy with its own databases.',
1733
+ when_to_use:
1734
+ 'When the same app should run for a second purpose - a notes app for blog drafts alongside one for bookmarks. The copy shares no data with the source.',
1735
+ args_schema: {
1736
+ arguments: [
1737
+ { token: '[dir]', key: 'dir', description: 'Project directory (default: current dir).' },
1738
+ ],
1739
+ options: [
1740
+ { flags: '--app-id <id>', description: 'App to duplicate. Defaults to the app this project is linked to.' },
1741
+ { flags: '--name <name>', description: 'Name for the duplicate (default: the source name followed by "copy").' },
1742
+ {
1743
+ flags: '--copy-documents <mode>',
1744
+ description:
1745
+ "Which rows to copy: 'declared' (default, the starter content a fresh install would have), 'all', or 'none'.",
1746
+ },
1747
+ ],
1748
+ },
1749
+ examples: [
1750
+ 'notis apps duplicate --name "Blog"',
1751
+ 'notis apps duplicate --app-id abc123 --name "Bookmarks" --copy-documents none',
1752
+ ],
1753
+ mutates: true,
1754
+ idempotent: false,
1755
+ require_auth: true,
1756
+ backend_call: { type: 'tool', name: DUPLICATE_APP_TOOL },
1757
+ handler: appsDuplicateHandler,
1758
+ },
479
1759
  {
480
1760
  command_path: ['apps', 'doctor'],
481
1761
  summary: 'Check project health and readiness.',