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