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