@notis_ai/cli 0.2.0-beta.102.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 +334 -0
- package/bin/notis.js +2 -0
- package/config/notis_app_boundary_rules.json +50 -0
- package/dist/scaffolds/notis-database/CHANGELOG.md +5 -0
- package/dist/scaffolds/notis-database/app/globals.css +44 -0
- package/dist/scaffolds/notis-database/app/layout.tsx +6 -0
- package/dist/scaffolds/notis-database/app/page.tsx +1091 -0
- package/dist/scaffolds/notis-database/components/ui/badge.tsx +28 -0
- package/dist/scaffolds/notis-database/components/ui/button.tsx +53 -0
- package/dist/scaffolds/notis-database/components/ui/card.tsx +56 -0
- package/dist/scaffolds/notis-database/components/ui/table.tsx +120 -0
- package/dist/scaffolds/notis-database/components.json +20 -0
- package/dist/scaffolds/notis-database/index.html +12 -0
- package/dist/scaffolds/notis-database/lib/types.ts +134 -0
- package/dist/scaffolds/notis-database/lib/utils.ts +6 -0
- package/dist/scaffolds/notis-database/metadata/screenshot-1.png +0 -0
- package/dist/scaffolds/notis-database/notis.config.ts +26 -0
- package/dist/scaffolds/notis-database/package.json +31 -0
- package/dist/scaffolds/notis-database/postcss.config.mjs +8 -0
- package/dist/scaffolds/notis-database/src/dev-main.tsx +23 -0
- package/dist/scaffolds/notis-database/src/mock-runtime.ts +558 -0
- package/dist/scaffolds/notis-database/tailwind.config.ts +59 -0
- package/dist/scaffolds/notis-database/tsconfig.json +23 -0
- package/dist/scaffolds/notis-database/vite.config.ts +22 -0
- package/dist/scaffolds/notis-journal/CHANGELOG.md +25 -0
- package/dist/scaffolds/notis-journal/app/globals.css +37 -0
- package/dist/scaffolds/notis-journal/app/insights/page.tsx +513 -0
- package/dist/scaffolds/notis-journal/app/journal-core.tsx +362 -0
- package/dist/scaffolds/notis-journal/app/journal-ui.tsx +337 -0
- package/dist/scaffolds/notis-journal/app/layout.tsx +6 -0
- package/dist/scaffolds/notis-journal/app/page.tsx +485 -0
- package/dist/scaffolds/notis-journal/components/ui/badge.tsx +28 -0
- package/dist/scaffolds/notis-journal/components/ui/button.tsx +53 -0
- package/dist/scaffolds/notis-journal/components/ui/card.tsx +56 -0
- package/dist/scaffolds/notis-journal/components.json +20 -0
- package/dist/scaffolds/notis-journal/index.html +12 -0
- package/dist/scaffolds/notis-journal/lib/utils.ts +6 -0
- package/dist/scaffolds/notis-journal/metadata/screenshot-1.png +0 -0
- package/dist/scaffolds/notis-journal/metadata/screenshot-2.png +0 -0
- package/dist/scaffolds/notis-journal/metadata/screenshot-3.png +0 -0
- package/dist/scaffolds/notis-journal/metadata/screenshot-4.png +0 -0
- package/dist/scaffolds/notis-journal/metadata/screenshot-5.png +0 -0
- package/dist/scaffolds/notis-journal/metadata/screenshot-6.png +0 -0
- package/dist/scaffolds/notis-journal/metadata/screenshot-fixtures.json +132 -0
- package/dist/scaffolds/notis-journal/notis.config.ts +93 -0
- package/dist/scaffolds/notis-journal/package.json +34 -0
- package/dist/scaffolds/notis-journal/packages/sdk/package.json +36 -0
- package/dist/scaffolds/notis-journal/packages/sdk/src/components/DocumentEditor.tsx +93 -0
- package/dist/scaffolds/notis-journal/packages/sdk/src/components/Markdown.tsx +60 -0
- package/dist/scaffolds/notis-journal/packages/sdk/src/components/MultiSelectActionBar.tsx +278 -0
- package/dist/scaffolds/notis-journal/packages/sdk/src/components/MultiSelectCheckbox.tsx +91 -0
- package/dist/scaffolds/notis-journal/packages/sdk/src/components/MultiSelectDragOverlay.tsx +39 -0
- package/dist/scaffolds/notis-journal/packages/sdk/src/config.ts +145 -0
- package/dist/scaffolds/notis-journal/packages/sdk/src/documents.ts +229 -0
- package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useBackend.ts +41 -0
- package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useDatabaseSchema.ts +85 -0
- package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useDocument.ts +78 -0
- package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useDocuments.ts +121 -0
- package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useMultiSelect.ts +539 -0
- package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useNotis.ts +34 -0
- package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useNotisNavigation.ts +49 -0
- package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useTool.ts +64 -0
- package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useTools.ts +56 -0
- package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useTopBarSearch.ts +73 -0
- package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useUpsertDocument.ts +95 -0
- package/dist/scaffolds/notis-journal/packages/sdk/src/index.ts +83 -0
- package/dist/scaffolds/notis-journal/packages/sdk/src/provider.tsx +43 -0
- package/dist/scaffolds/notis-journal/packages/sdk/src/runtime.ts +220 -0
- package/dist/scaffolds/notis-journal/packages/sdk/src/styles.css +186 -0
- package/dist/scaffolds/notis-journal/packages/sdk/src/ui.ts +15 -0
- package/dist/scaffolds/notis-journal/packages/sdk/src/vite.ts +56 -0
- package/dist/scaffolds/notis-journal/packages/sdk/tsconfig.json +15 -0
- package/dist/scaffolds/notis-journal/postcss.config.mjs +8 -0
- package/dist/scaffolds/notis-journal/skills/journal-onboarding/SKILL.md +120 -0
- package/dist/scaffolds/notis-journal/src/dev-main.tsx +58 -0
- package/dist/scaffolds/notis-journal/src/mock-runtime.ts +197 -0
- package/dist/scaffolds/notis-journal/tailwind.config.ts +58 -0
- package/dist/scaffolds/notis-journal/tsconfig.json +23 -0
- package/dist/scaffolds/notis-journal/vite.config.ts +10 -0
- package/dist/scaffolds/notis-notes/CHANGELOG.md +5 -0
- package/dist/scaffolds/notis-notes/app/globals.css +3 -0
- package/dist/scaffolds/notis-notes/app/layout.tsx +6 -0
- package/dist/scaffolds/notis-notes/app/page.tsx +1950 -0
- package/dist/scaffolds/notis-notes/components/ui/badge.tsx +28 -0
- package/dist/scaffolds/notis-notes/components/ui/button.tsx +53 -0
- package/dist/scaffolds/notis-notes/components/ui/card.tsx +56 -0
- package/dist/scaffolds/notis-notes/components.json +20 -0
- package/dist/scaffolds/notis-notes/lib/utils.ts +6 -0
- package/dist/scaffolds/notis-notes/metadata/screenshot-1.png +0 -0
- package/dist/scaffolds/notis-notes/notis.config.ts +35 -0
- package/dist/scaffolds/notis-notes/package.json +31 -0
- package/dist/scaffolds/notis-notes/postcss.config.mjs +8 -0
- package/dist/scaffolds/notis-notes/tailwind.config.ts +58 -0
- package/dist/scaffolds/notis-notes/tsconfig.json +23 -0
- package/dist/scaffolds/notis-notes/vite.config.ts +10 -0
- package/dist/scaffolds/notis-random/CHANGELOG.md +5 -0
- package/dist/scaffolds/notis-random/README.md +33 -0
- package/dist/scaffolds/notis-random/app/globals.css +11 -0
- package/dist/scaffolds/notis-random/app/history/page.tsx +66 -0
- package/dist/scaffolds/notis-random/app/layout.tsx +7 -0
- package/dist/scaffolds/notis-random/app/page.tsx +222 -0
- package/dist/scaffolds/notis-random/index.html +12 -0
- package/dist/scaffolds/notis-random/lib/notis-tools.ts +109 -0
- package/dist/scaffolds/notis-random/lib/rng.ts +42 -0
- package/dist/scaffolds/notis-random/lib/roll-record.ts +102 -0
- package/dist/scaffolds/notis-random/lib/utils.ts +25 -0
- package/dist/scaffolds/notis-random/metadata/screenshot-1.png +0 -0
- package/dist/scaffolds/notis-random/metadata/screenshot-2.png +0 -0
- package/dist/scaffolds/notis-random/notis.config.ts +42 -0
- package/dist/scaffolds/notis-random/package.json +32 -0
- package/dist/scaffolds/notis-random/postcss.config.mjs +6 -0
- package/dist/scaffolds/notis-random/src/dev-main.tsx +70 -0
- package/dist/scaffolds/notis-random/src/mock-runtime.ts +129 -0
- package/dist/scaffolds/notis-random/tailwind.config.ts +43 -0
- package/dist/scaffolds/notis-random/tsconfig.json +23 -0
- package/dist/scaffolds/notis-random/vite.config.ts +11 -0
- package/dist/scaffolds.json +47 -0
- package/package.json +45 -0
- package/skills/notis-apps/SKILL.md +219 -0
- package/skills/notis-apps/cli.md +244 -0
- package/skills/notis-cli/SKILL.md +269 -0
- package/skills/notis-query/cli.md +39 -0
- package/src/cli.js +175 -0
- package/src/command-specs/apps.js +1939 -0
- package/src/command-specs/helpers.js +186 -0
- package/src/command-specs/index.js +14 -0
- package/src/command-specs/meta.js +161 -0
- package/src/command-specs/tools.js +499 -0
- package/src/runtime/agent-browser.js +464 -0
- package/src/runtime/app-boundary-validator.js +183 -0
- package/src/runtime/app-changelog.js +79 -0
- package/src/runtime/app-dev-server.js +712 -0
- package/src/runtime/app-dev-sessions.js +251 -0
- package/src/runtime/app-platform.js +1500 -0
- package/src/runtime/assets/store-screenshot-dark.png +0 -0
- package/src/runtime/cli-mode.generated.js +4 -0
- package/src/runtime/cli-mode.js +29 -0
- package/src/runtime/desktop-auth.js +93 -0
- package/src/runtime/errors.js +55 -0
- package/src/runtime/help.js +60 -0
- package/src/runtime/output.js +180 -0
- package/src/runtime/ports.js +15 -0
- package/src/runtime/profiles.js +261 -0
- package/src/runtime/store-screenshot.js +138 -0
- package/src/runtime/transport.js +347 -0
- package/template/.harness/index.html.tmpl +333 -0
- package/template/CHANGELOG.md +5 -0
- package/template/app/globals.css +3 -0
- package/template/app/layout.tsx +6 -0
- package/template/app/page.tsx +60 -0
- package/template/components/ui/badge.tsx +28 -0
- package/template/components/ui/button.tsx +53 -0
- package/template/components/ui/card.tsx +56 -0
- package/template/components.json +20 -0
- package/template/lib/utils.ts +6 -0
- package/template/metadata/screenshot-1.png +0 -0
- package/template/metadata/screenshot-2.png +0 -0
- package/template/metadata/screenshot-3.png +0 -0
- package/template/notis.config.ts +36 -0
- package/template/package-lock.json +4137 -0
- package/template/package.json +32 -0
- package/template/packages/sdk/package.json +36 -0
- package/template/packages/sdk/src/components/DocumentEditor.tsx +93 -0
- package/template/packages/sdk/src/components/Markdown.tsx +60 -0
- package/template/packages/sdk/src/components/MultiSelectActionBar.tsx +278 -0
- package/template/packages/sdk/src/components/MultiSelectCheckbox.tsx +91 -0
- package/template/packages/sdk/src/components/MultiSelectDragOverlay.tsx +39 -0
- package/template/packages/sdk/src/config.ts +145 -0
- package/template/packages/sdk/src/documents.ts +229 -0
- package/template/packages/sdk/src/hooks/useBackend.ts +41 -0
- package/template/packages/sdk/src/hooks/useDatabaseSchema.ts +85 -0
- package/template/packages/sdk/src/hooks/useDocument.ts +78 -0
- package/template/packages/sdk/src/hooks/useDocuments.ts +121 -0
- package/template/packages/sdk/src/hooks/useMultiSelect.ts +539 -0
- package/template/packages/sdk/src/hooks/useNotis.ts +34 -0
- package/template/packages/sdk/src/hooks/useNotisNavigation.ts +49 -0
- package/template/packages/sdk/src/hooks/useTool.ts +64 -0
- package/template/packages/sdk/src/hooks/useTools.ts +56 -0
- package/template/packages/sdk/src/hooks/useTopBarSearch.ts +73 -0
- package/template/packages/sdk/src/hooks/useUpsertDocument.ts +95 -0
- package/template/packages/sdk/src/index.ts +83 -0
- package/template/packages/sdk/src/provider.tsx +43 -0
- package/template/packages/sdk/src/runtime.ts +220 -0
- package/template/packages/sdk/src/styles.css +186 -0
- package/template/packages/sdk/src/ui.ts +15 -0
- package/template/packages/sdk/src/vite.ts +56 -0
- package/template/packages/sdk/tsconfig.json +15 -0
- package/template/postcss.config.mjs +8 -0
- package/template/tailwind.config.ts +58 -0
- package/template/tsconfig.json +22 -0
- package/template/vite.config.ts +10 -0
|
@@ -0,0 +1,1939 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Notis apps CLI commands.
|
|
3
|
+
*
|
|
4
|
+
* Canonical Notis app workflow:
|
|
5
|
+
* init -> dev -> build -> deploy -> publish (after explicit approval)
|
|
6
|
+
*
|
|
7
|
+
* Supporting commands: list, link, doctor, pull.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { randomUUID } from 'node:crypto';
|
|
11
|
+
import { spawn } from 'node:child_process';
|
|
12
|
+
import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync } from 'node:fs';
|
|
13
|
+
import { tmpdir } from 'node:os';
|
|
14
|
+
import { basename, join, relative, resolve } from 'node:path';
|
|
15
|
+
|
|
16
|
+
import { EXIT_CODES, usageError } from '../runtime/errors.js';
|
|
17
|
+
import { formatTable } from '../runtime/output.js';
|
|
18
|
+
import {
|
|
19
|
+
resolveProjectDir,
|
|
20
|
+
loadAppConfig,
|
|
21
|
+
detectProjectProblems,
|
|
22
|
+
detectProjectWarnings,
|
|
23
|
+
buildArtifact,
|
|
24
|
+
readManifest,
|
|
25
|
+
readLinkedState,
|
|
26
|
+
writeLinkedState,
|
|
27
|
+
requireLinkedAppId,
|
|
28
|
+
scaffoldProject,
|
|
29
|
+
loadScaffoldCatalog,
|
|
30
|
+
inspectListingReadiness,
|
|
31
|
+
resolveListingScreenshots,
|
|
32
|
+
collectArtifactFiles,
|
|
33
|
+
collectSourceFiles,
|
|
34
|
+
resolveConfiguredAppSkills,
|
|
35
|
+
directDeploy,
|
|
36
|
+
pullAppSource,
|
|
37
|
+
} from '../runtime/app-platform.js';
|
|
38
|
+
import { startAppDevServer } from '../runtime/app-dev-server.js';
|
|
39
|
+
import {
|
|
40
|
+
captureHarnessScreenshot,
|
|
41
|
+
closeAgentBrowserSession,
|
|
42
|
+
isAgentBrowserAvailable,
|
|
43
|
+
runHarnessRoute,
|
|
44
|
+
} from '../runtime/agent-browser.js';
|
|
45
|
+
import {
|
|
46
|
+
heartbeatAppDevSession,
|
|
47
|
+
removeAppDevSession,
|
|
48
|
+
upsertAppDevSessions,
|
|
49
|
+
waitForAppDevSessionMountAcknowledgements,
|
|
50
|
+
} from '../runtime/app-dev-sessions.js';
|
|
51
|
+
import { getAvailablePort } from '../runtime/ports.js';
|
|
52
|
+
import { getCliMode } from '../runtime/cli-mode.js';
|
|
53
|
+
import { composeStoreScreenshot } from '../runtime/store-screenshot.js';
|
|
54
|
+
import { httpRequest } from '../runtime/transport.js';
|
|
55
|
+
import {
|
|
56
|
+
localNotisToolSlug,
|
|
57
|
+
nextIdempotencyKey,
|
|
58
|
+
runToolCommand,
|
|
59
|
+
toolConflictToError,
|
|
60
|
+
} from './helpers.js';
|
|
61
|
+
|
|
62
|
+
const DEFAULT_DEV_PORT = 5173;
|
|
63
|
+
const DEV_HEARTBEAT_INTERVAL_MS = 10_000;
|
|
64
|
+
const CONFIG_FILENAMES = ['notis.config.ts', 'notis.config.js', 'notis.config.mjs'];
|
|
65
|
+
const ENSURE_DEV_APP_INSTALLATION_TOOL = 'LOCAL_NOTIS_ENSURE_DEV_APP_INSTALLATION';
|
|
66
|
+
const GET_APP_TOOL = 'LOCAL_NOTIS_GET_APP';
|
|
67
|
+
const LIST_APPS_TOOL = 'LOCAL_NOTIS_LIST_APPS';
|
|
68
|
+
const CREATE_APP_TOOL = 'LOCAL_NOTIS_CREATE_APP';
|
|
69
|
+
const SAVE_APP_FILES_TOOL = 'LOCAL_NOTIS_SAVE_APP_FILES';
|
|
70
|
+
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
// Formatters
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
function appsTable(apps) {
|
|
76
|
+
return formatTable(apps, [
|
|
77
|
+
{ label: 'ID', value: (app) => app.app_id || app.id || '' },
|
|
78
|
+
{ label: 'Name', value: (app) => app.name || 'Untitled' },
|
|
79
|
+
{ label: 'Version', value: (app) => app.current_version || app.manifest?.version || 0 },
|
|
80
|
+
{ label: 'Status', value: (app) => app.status || '' },
|
|
81
|
+
]);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function scaffoldsTable(scaffolds) {
|
|
85
|
+
return formatTable(scaffolds, [
|
|
86
|
+
{ label: 'Slug', value: (scaffold) => scaffold.slug || '' },
|
|
87
|
+
{ label: 'Name', value: (scaffold) => scaffold.name || scaffold.slug || '' },
|
|
88
|
+
{ label: 'Category', value: (scaffold) => (scaffold.categories || [])[0] || '' },
|
|
89
|
+
{ label: 'Tagline', value: (scaffold) => scaffold.tagline || scaffold.description || '' },
|
|
90
|
+
]);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function hasNotisConfig(dir) {
|
|
94
|
+
return CONFIG_FILENAMES.some((name) => existsSync(join(dir, name)));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function discoverAppDirs(projectDir) {
|
|
98
|
+
if (hasNotisConfig(projectDir)) {
|
|
99
|
+
return [projectDir];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const appsDir = join(projectDir, 'apps');
|
|
103
|
+
if (!existsSync(appsDir)) {
|
|
104
|
+
throw usageError(
|
|
105
|
+
`No notis.config.ts in ${projectDir}. Run from a Notis app project or a monorepo root with apps/<name>/notis.config.ts.`,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const entries = readdirSync(appsDir, { withFileTypes: true });
|
|
110
|
+
const candidates = [];
|
|
111
|
+
for (const entry of entries) {
|
|
112
|
+
if (!entry.isDirectory()) continue;
|
|
113
|
+
if (entry.name.startsWith('.') || entry.name.startsWith('_')) continue;
|
|
114
|
+
const dir = join(appsDir, entry.name);
|
|
115
|
+
if (hasNotisConfig(dir)) {
|
|
116
|
+
candidates.push(dir);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (candidates.length === 0) {
|
|
121
|
+
throw usageError(
|
|
122
|
+
`Found apps/ in ${projectDir} but no apps/<name>/notis.config.ts inside it.`,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return candidates;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function decodeJwtSub(jwt) {
|
|
130
|
+
if (!jwt) return null;
|
|
131
|
+
try {
|
|
132
|
+
const parts = jwt.split('.');
|
|
133
|
+
if (parts.length !== 3) return null;
|
|
134
|
+
const decoded = JSON.parse(Buffer.from(parts[1], 'base64url').toString());
|
|
135
|
+
return decoded.sub || decoded.email || null;
|
|
136
|
+
} catch {
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function openInBrowser(url) {
|
|
142
|
+
const platform = process.platform;
|
|
143
|
+
let command;
|
|
144
|
+
let args;
|
|
145
|
+
if (platform === 'darwin') {
|
|
146
|
+
command = 'open';
|
|
147
|
+
args = [url];
|
|
148
|
+
} else if (platform === 'win32') {
|
|
149
|
+
command = 'cmd';
|
|
150
|
+
args = ['/c', 'start', '', url];
|
|
151
|
+
} else {
|
|
152
|
+
command = 'xdg-open';
|
|
153
|
+
args = [url];
|
|
154
|
+
}
|
|
155
|
+
try {
|
|
156
|
+
const child = spawn(command, args, { stdio: 'ignore', detached: true });
|
|
157
|
+
child.on('error', () => {});
|
|
158
|
+
child.unref();
|
|
159
|
+
} catch {
|
|
160
|
+
// Non-fatal. The URL is printed in the CLI output.
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function slugify(value) {
|
|
165
|
+
return String(value || '')
|
|
166
|
+
.trim()
|
|
167
|
+
.toLowerCase()
|
|
168
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
169
|
+
.replace(/(^-|-$)+/g, '');
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function buildDevInstallSlug(appName) {
|
|
173
|
+
const base = slugify(appName);
|
|
174
|
+
if (!base) {
|
|
175
|
+
return '';
|
|
176
|
+
}
|
|
177
|
+
return base.endsWith('-dev') ? base : `${base}-dev`;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function timingMs(startedAt) {
|
|
181
|
+
return Number(process.hrtime.bigint() - startedAt) / 1_000_000;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function logAppsTiming(label, details = {}) {
|
|
185
|
+
const suffix = Object.entries(details)
|
|
186
|
+
.map(([key, value]) => `${key}=${value}`)
|
|
187
|
+
.join(' ');
|
|
188
|
+
process.stderr.write(`[notis apps timing] ${label}${suffix ? ` ${suffix}` : ''}\n`);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function nextDevInstallIdempotencyKey(globalOptions = {}, devSlug) {
|
|
192
|
+
if (globalOptions.idempotencyKey) {
|
|
193
|
+
return `${globalOptions.idempotencyKey}:${devSlug}`;
|
|
194
|
+
}
|
|
195
|
+
return nextIdempotencyKey(globalOptions);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function pickDefaultRouteSlug(manifest) {
|
|
199
|
+
const routes = Array.isArray(manifest?.routes) ? manifest.routes : [];
|
|
200
|
+
const explicit = routes.find((route) => route && route.default && typeof route.slug === 'string');
|
|
201
|
+
if (explicit) return explicit.slug;
|
|
202
|
+
const firstWithSlug = routes.find((route) => route && typeof route.slug === 'string' && route.slug);
|
|
203
|
+
return firstWithSlug ? firstWithSlug.slug : null;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const DESKTOP_DEEP_LINK_SCHEME_PATTERN = /^[a-z][a-z0-9-]*$/;
|
|
207
|
+
|
|
208
|
+
export function resolveDevelopmentDesktopScheme(env = process.env) {
|
|
209
|
+
// Mirror the desktop app's own scheme resolution (electron main + forge config):
|
|
210
|
+
// local dev launches register `notis-dev`, while installed prod/beta builds claim
|
|
211
|
+
// `notis`. Hardcoding `notis` here is what made `apps dev` open the installed
|
|
212
|
+
// prod/beta app instead of the local dev app.
|
|
213
|
+
const scheme = (env.NOTIS_DESKTOP_DEEP_LINK_SCHEME || '').trim();
|
|
214
|
+
return DESKTOP_DEEP_LINK_SCHEME_PATTERN.test(scheme) ? scheme : 'notis';
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function buildDevelopmentDesktopUrl(appHref = null, scheme = 'notis') {
|
|
218
|
+
const route = String(appHref || '/store').replace(/^\/+/, '');
|
|
219
|
+
const normalizedScheme = DESKTOP_DEEP_LINK_SCHEME_PATTERN.test(scheme) ? scheme : 'notis';
|
|
220
|
+
return `${normalizedScheme}://${route || 'store'}`;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export function shouldOpenDevelopmentTab(options = {}) {
|
|
224
|
+
// Commander stores the negatable `--no-open` flag as `options.open === false`;
|
|
225
|
+
// it never sets `options.noOpen`. Reading the non-existent `noOpen` key meant
|
|
226
|
+
// `--no-open` was silently ignored and `apps dev` always ran `open notis://…`,
|
|
227
|
+
// which macOS routes to the installed prod/beta desktop app.
|
|
228
|
+
return options.open !== false;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function buildAppHref({ appSlug, appId, manifest }) {
|
|
232
|
+
const originlessBase = `/apps/${appSlug}-${appId}`;
|
|
233
|
+
const routeSlug = pickDefaultRouteSlug(manifest);
|
|
234
|
+
return routeSlug ? `${originlessBase}/${routeSlug}` : originlessBase;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function parsePort(value) {
|
|
238
|
+
if (!value) return null;
|
|
239
|
+
const port = Number.parseInt(value, 10);
|
|
240
|
+
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
|
|
241
|
+
throw usageError('Port must be between 1 and 65535.');
|
|
242
|
+
}
|
|
243
|
+
return port;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function parsePositiveInt(value) {
|
|
247
|
+
if (!value) return null;
|
|
248
|
+
const parsed = Number.parseInt(value, 10);
|
|
249
|
+
if (!Number.isInteger(parsed) || parsed <= 0) {
|
|
250
|
+
throw usageError('Expected a positive integer.');
|
|
251
|
+
}
|
|
252
|
+
return parsed;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function parseRouteSlugs(value) {
|
|
256
|
+
if (!value) return null;
|
|
257
|
+
const slugs = String(value)
|
|
258
|
+
.split(',')
|
|
259
|
+
.map((entry) => entry.trim())
|
|
260
|
+
.filter(Boolean);
|
|
261
|
+
if (slugs.length === 0) {
|
|
262
|
+
throw usageError('--routes must include at least one route slug.');
|
|
263
|
+
}
|
|
264
|
+
return slugs;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function routeSelection(manifest, rawRouteSlugs) {
|
|
268
|
+
const routes = Array.isArray(manifest?.routes) ? manifest.routes : [];
|
|
269
|
+
if (routes.length === 0) {
|
|
270
|
+
throw usageError('Manifest has no routes to verify.');
|
|
271
|
+
}
|
|
272
|
+
if (!rawRouteSlugs) {
|
|
273
|
+
return routes;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const bySlug = new Map(routes.map((route) => [route.slug, route]));
|
|
277
|
+
const selected = [];
|
|
278
|
+
const missing = [];
|
|
279
|
+
for (const slug of rawRouteSlugs) {
|
|
280
|
+
const route = bySlug.get(slug);
|
|
281
|
+
if (route) {
|
|
282
|
+
selected.push(route);
|
|
283
|
+
} else {
|
|
284
|
+
missing.push(slug);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
if (missing.length) {
|
|
288
|
+
throw usageError(
|
|
289
|
+
`Unknown route slug${missing.length === 1 ? '' : 's'}: ${missing.join(', ')}.`,
|
|
290
|
+
{ available_routes: routes.map((route) => route.slug) },
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
return selected;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export function pruneStaleScreenshotFiles(outputDir, keepCount) {
|
|
297
|
+
for (const entry of readdirSync(outputDir, { withFileTypes: true })) {
|
|
298
|
+
if (!entry.isFile()) continue;
|
|
299
|
+
const match = /^screenshot-(\d+)\.png$/i.exec(entry.name);
|
|
300
|
+
if (match && Number.parseInt(match[1], 10) > keepCount) {
|
|
301
|
+
rmSync(join(outputDir, entry.name), { force: true });
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export function shouldPruneStaleScreenshotFiles(selectedRouteSlugs, failedCount) {
|
|
307
|
+
return failedCount === 0 && !selectedRouteSlugs;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
export function screenshotIndexByRouteSlug(manifest) {
|
|
311
|
+
const routes = Array.isArray(manifest?.routes) ? manifest.routes : [];
|
|
312
|
+
return new Map(routes.map((route, index) => [route.slug, index + 1]));
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export function appRowFieldsFromManifest(manifest) {
|
|
316
|
+
const app = manifest?.app && typeof manifest.app === 'object' ? manifest.app : {};
|
|
317
|
+
return {
|
|
318
|
+
accent: app.accent ?? null,
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
export function screenshotExitCode(failedCount) {
|
|
323
|
+
return failedCount === 0 ? EXIT_CODES.ok : EXIT_CODES.unexpected;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function declaredDatabaseSlugs(appConfig, manifest, route) {
|
|
327
|
+
const slugs = new Set();
|
|
328
|
+
for (const entry of appConfig?.databases || manifest?.databases || []) {
|
|
329
|
+
if (typeof entry === 'string' && entry) {
|
|
330
|
+
slugs.add(entry);
|
|
331
|
+
} else if (entry && typeof entry === 'object' && typeof entry.slug === 'string') {
|
|
332
|
+
slugs.add(entry.slug);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
if (route?.collection?.database) {
|
|
336
|
+
slugs.add(route.collection.database);
|
|
337
|
+
}
|
|
338
|
+
return Array.from(slugs);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function harnessErrorMessage(error) {
|
|
342
|
+
if (!error || typeof error !== 'object') {
|
|
343
|
+
return String(error);
|
|
344
|
+
}
|
|
345
|
+
return error.message || error.reason || error.type || JSON.stringify(error);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function assertHarnessResult(result, route, databaseSlugs) {
|
|
349
|
+
const assertions = [];
|
|
350
|
+
if (result.tool_error) {
|
|
351
|
+
assertions.push({
|
|
352
|
+
ok: false,
|
|
353
|
+
code: 'tool_error',
|
|
354
|
+
message: `agent-browser ${result.tool_error.phase || 'command'} failed`,
|
|
355
|
+
details: result.tool_error,
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
if (result.mounted !== true) {
|
|
359
|
+
assertions.push({
|
|
360
|
+
ok: false,
|
|
361
|
+
code: 'not_mounted',
|
|
362
|
+
message: 'Harness did not report mounted === true.',
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
if (result.timed_out) {
|
|
366
|
+
assertions.push({
|
|
367
|
+
ok: false,
|
|
368
|
+
code: 'timeout',
|
|
369
|
+
message: 'Timed out waiting for window.__harness.mounted.',
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
for (const error of result.errors || []) {
|
|
373
|
+
assertions.push({
|
|
374
|
+
ok: false,
|
|
375
|
+
code: 'render_error',
|
|
376
|
+
message: harnessErrorMessage(error),
|
|
377
|
+
details: error,
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
for (const databaseSlug of databaseSlugs) {
|
|
381
|
+
const seen = (result.runtimeCalls || []).some(
|
|
382
|
+
(call) =>
|
|
383
|
+
call?.op === 'callTool' &&
|
|
384
|
+
localNotisToolSlug(call?.args?.name) === 'LOCAL_NOTIS_DATABASE_QUERY' &&
|
|
385
|
+
call?.args?.arguments?.database_slug === databaseSlug,
|
|
386
|
+
);
|
|
387
|
+
if (!seen) {
|
|
388
|
+
assertions.push({
|
|
389
|
+
ok: false,
|
|
390
|
+
code: 'missing_database_query',
|
|
391
|
+
message: `Route "${route.slug}" did not call LOCAL_NOTIS_DATABASE_QUERY for "${databaseSlug}".`,
|
|
392
|
+
details: { databaseSlug },
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
return assertions;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function renderVerifyReport({ summary, results, noBrowser }) {
|
|
400
|
+
const lines = [
|
|
401
|
+
noBrowser
|
|
402
|
+
? `Harness URLs ready for ${summary.total} route${summary.total === 1 ? '' : 's'}.`
|
|
403
|
+
: `Verified ${summary.total} route${summary.total === 1 ? '' : 's'}: ${summary.passed} passed, ${summary.failed} failed.`,
|
|
404
|
+
];
|
|
405
|
+
for (const result of results) {
|
|
406
|
+
const marker = result.ok ? 'PASS' : result.status === 'manual' ? 'URL' : 'FAIL';
|
|
407
|
+
lines.push(`${marker.padEnd(4)} ${result.route.padEnd(18)} ${result.url}`);
|
|
408
|
+
for (const assertion of result.assertions || []) {
|
|
409
|
+
lines.push(` - ${assertion.message}`);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
if (noBrowser) {
|
|
413
|
+
lines.push('', 'Pass --keep-open to leave the harness process running while you inspect the URLs.');
|
|
414
|
+
}
|
|
415
|
+
return lines.join('\n');
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function buildManifestForDev(appConfig) {
|
|
419
|
+
const routes = Array.isArray(appConfig.routes) ? appConfig.routes : [];
|
|
420
|
+
return {
|
|
421
|
+
version: 1,
|
|
422
|
+
spec_version: 3,
|
|
423
|
+
app: {
|
|
424
|
+
name: appConfig.name,
|
|
425
|
+
description: appConfig.description || null,
|
|
426
|
+
icon: appConfig.icon || null,
|
|
427
|
+
},
|
|
428
|
+
routes: routes.map((route) => ({
|
|
429
|
+
path: route.path,
|
|
430
|
+
slug: route.slug,
|
|
431
|
+
name: route.name,
|
|
432
|
+
icon: route.icon || null,
|
|
433
|
+
parentSlug: route.parentSlug || null,
|
|
434
|
+
default: route.default || false,
|
|
435
|
+
export_name: route.exportName || route.export_name,
|
|
436
|
+
collection: route.collection || null,
|
|
437
|
+
})),
|
|
438
|
+
bundle: {
|
|
439
|
+
js: 'bundle/app.js',
|
|
440
|
+
css: 'bundle/app.css',
|
|
441
|
+
},
|
|
442
|
+
databases: appConfig.databases || [],
|
|
443
|
+
tools: appConfig.tools || [],
|
|
444
|
+
skills: (appConfig.skills || []).map((skill) => ({
|
|
445
|
+
key: skill.key,
|
|
446
|
+
path: String(skill.path || '').replace(/^\.\/+/, ''),
|
|
447
|
+
name: skill.name,
|
|
448
|
+
description: skill.description || null,
|
|
449
|
+
})),
|
|
450
|
+
onboarding: appConfig.onboarding || null,
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
export function buildEnsureDevInstallArguments({ appConfig, manifest, linkedState, skills }) {
|
|
455
|
+
const devSlug = buildDevInstallSlug(appConfig.name);
|
|
456
|
+
const arguments_ = {
|
|
457
|
+
dev_slug: devSlug,
|
|
458
|
+
name: appConfig.name,
|
|
459
|
+
manifest,
|
|
460
|
+
skills,
|
|
461
|
+
};
|
|
462
|
+
if (linkedState?.dev_app_id) {
|
|
463
|
+
arguments_.app_id = linkedState.dev_app_id;
|
|
464
|
+
}
|
|
465
|
+
return arguments_;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
export async function ensureDevInstall({
|
|
469
|
+
ctx,
|
|
470
|
+
appConfig,
|
|
471
|
+
projectDir,
|
|
472
|
+
idempotencyKey,
|
|
473
|
+
runTool = runToolCommand,
|
|
474
|
+
}) {
|
|
475
|
+
if (!appConfig.name) {
|
|
476
|
+
throw usageError(`notis.config.ts in ${projectDir} must define a non-empty name.`);
|
|
477
|
+
}
|
|
478
|
+
const devSlug = buildDevInstallSlug(appConfig.name);
|
|
479
|
+
if (!devSlug) {
|
|
480
|
+
throw usageError(`notis.config.ts name in ${projectDir} must slugify to a non-empty value.`);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
const manifest = buildManifestForDev(appConfig);
|
|
484
|
+
const skills = resolveConfiguredAppSkills(appConfig, projectDir);
|
|
485
|
+
let linkedState = readLinkedState(projectDir);
|
|
486
|
+
if (linkedState?.app_id) {
|
|
487
|
+
const linkedApp = await getAccessibleApp(ctx.runtime, linkedState.app_id, runTool);
|
|
488
|
+
if (!linkedApp || linkedApp.manifest?.is_dev === true) {
|
|
489
|
+
const { app_id: legacyDevAppId, linked_at: _linkedAt, deployed_at: _deployedAt, version: _version, ...rest } = linkedState;
|
|
490
|
+
const devAppId = linkedState.dev_app_id || (linkedApp ? legacyDevAppId : null);
|
|
491
|
+
linkedState = {
|
|
492
|
+
...rest,
|
|
493
|
+
...(devAppId ? { dev_app_id: devAppId } : {}),
|
|
494
|
+
...(devAppId ? {
|
|
495
|
+
dev_linked_at: linkedState.dev_linked_at || linkedState.linked_at || new Date().toISOString(),
|
|
496
|
+
} : {}),
|
|
497
|
+
};
|
|
498
|
+
writeLinkedState(projectDir, linkedState);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
const ensureArguments = buildEnsureDevInstallArguments({ appConfig, manifest, linkedState, skills });
|
|
502
|
+
const ensureResult = await runTool({
|
|
503
|
+
runtime: ctx.runtime,
|
|
504
|
+
toolName: ENSURE_DEV_APP_INSTALLATION_TOOL,
|
|
505
|
+
arguments_: ensureArguments,
|
|
506
|
+
mutating: true,
|
|
507
|
+
idempotencyKey,
|
|
508
|
+
});
|
|
509
|
+
const appId = ensureResult.payload.app_id;
|
|
510
|
+
if (!appId) {
|
|
511
|
+
throw usageError('Dev installation tool did not return an app_id.');
|
|
512
|
+
}
|
|
513
|
+
if (linkedState?.dev_app_id !== appId) {
|
|
514
|
+
const now = new Date().toISOString();
|
|
515
|
+
writeLinkedState(projectDir, {
|
|
516
|
+
...(linkedState || {}),
|
|
517
|
+
dev_app_id: appId,
|
|
518
|
+
dev_linked_at: linkedState?.dev_linked_at || now,
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
return {
|
|
522
|
+
slug: ensureResult.payload.slug || devSlug,
|
|
523
|
+
devSlug,
|
|
524
|
+
name: appConfig.name,
|
|
525
|
+
appId,
|
|
526
|
+
projectDir,
|
|
527
|
+
manifest,
|
|
528
|
+
created: ensureResult.payload.created || false,
|
|
529
|
+
linkedAppId: linkedState?.app_id || null,
|
|
530
|
+
targetAppId: linkedState?.app_id || null,
|
|
531
|
+
databaseMaterialization: ensureResult.payload.database_materialization || { created: [], unresolved: [] },
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function databaseMaterializationWarnings(apps) {
|
|
536
|
+
const warnings = [];
|
|
537
|
+
for (const app of apps) {
|
|
538
|
+
const unresolved = app.databaseMaterialization?.unresolved || [];
|
|
539
|
+
if (!unresolved.length) {
|
|
540
|
+
continue;
|
|
541
|
+
}
|
|
542
|
+
warnings.push(
|
|
543
|
+
`${app.name}: database slug${unresolved.length === 1 ? '' : 's'} ${unresolved.join(', ')} ` +
|
|
544
|
+
'could not be created because no store snapshot schema exists. Create them manually or link to a store-installed app.',
|
|
545
|
+
);
|
|
546
|
+
}
|
|
547
|
+
return warnings;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
async function getAccessibleApp(runtime, appId, runTool = runToolCommand) {
|
|
551
|
+
const result = await runTool({
|
|
552
|
+
runtime,
|
|
553
|
+
toolName: GET_APP_TOOL,
|
|
554
|
+
arguments_: { app_id: appId },
|
|
555
|
+
});
|
|
556
|
+
if (result.payload?.app) {
|
|
557
|
+
return {
|
|
558
|
+
...result.payload.app,
|
|
559
|
+
apps_access: result.payload.apps_access,
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
const message = typeof result.payload?.message === 'string' ? result.payload.message : '';
|
|
563
|
+
if (result.payload?.status === 'error' && /PGRST116|0 rows|no rows|not found/i.test(message)) {
|
|
564
|
+
return null;
|
|
565
|
+
}
|
|
566
|
+
throw usageError(`Could not verify access to app ${appId}${message ? `: ${message}` : '.'}`);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
export async function assertDirectDeployAccess(runtime, appId, runTool = runToolCommand) {
|
|
570
|
+
const app = await getAccessibleApp(runtime, appId, runTool);
|
|
571
|
+
if (!app) {
|
|
572
|
+
throw usageError(`Direct deploy requires access to app ${appId}.`);
|
|
573
|
+
}
|
|
574
|
+
if (app.apps_access?.has_access !== true) {
|
|
575
|
+
throw usageError('Notis Apps require a PRO+ or ULTRA plan after your trial.');
|
|
576
|
+
}
|
|
577
|
+
return app;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
export async function assertLinkTarget(runtime, appId, runTool = runToolCommand) {
|
|
581
|
+
const app = await getAccessibleApp(runtime, appId, runTool);
|
|
582
|
+
if (!app) {
|
|
583
|
+
throw usageError(`Cannot link to inaccessible app ${appId}.`);
|
|
584
|
+
}
|
|
585
|
+
if (app.manifest?.is_dev === true) {
|
|
586
|
+
throw usageError(
|
|
587
|
+
`Cannot link to development runtime app ${appId}. ` +
|
|
588
|
+
'Link to an installed workspace app, or run `notis apps dev` without a target.',
|
|
589
|
+
);
|
|
590
|
+
}
|
|
591
|
+
return app;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// ---------------------------------------------------------------------------
|
|
595
|
+
// Handlers
|
|
596
|
+
// ---------------------------------------------------------------------------
|
|
597
|
+
|
|
598
|
+
async function appsListHandler(ctx) {
|
|
599
|
+
const result = await runToolCommand({
|
|
600
|
+
runtime: ctx.runtime,
|
|
601
|
+
toolName: LIST_APPS_TOOL,
|
|
602
|
+
});
|
|
603
|
+
const apps = result.payload.apps || [];
|
|
604
|
+
return ctx.output.emitSuccess({
|
|
605
|
+
command: ctx.spec.command_path.join(' '),
|
|
606
|
+
data: { apps },
|
|
607
|
+
humanSummary: apps.length ? `Found ${apps.length} apps` : 'No apps found.',
|
|
608
|
+
renderHuman: () => (apps.length ? appsTable(apps) : 'No apps found.'),
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
async function appsInitHandler(ctx) {
|
|
613
|
+
const projectDir = resolveProjectDir(ctx.args.dir || ctx.args.name.toLowerCase().replace(/[^a-z0-9]+/g, '-'));
|
|
614
|
+
const fromSlug = ctx.options.from || null;
|
|
615
|
+
|
|
616
|
+
scaffoldProject({ projectDir, appName: ctx.args.name, fromSlug });
|
|
617
|
+
|
|
618
|
+
return ctx.output.emitSuccess({
|
|
619
|
+
command: ctx.spec.command_path.join(' '),
|
|
620
|
+
data: { project_dir: projectDir, app_name: ctx.args.name, scaffold: fromSlug },
|
|
621
|
+
humanSummary: fromSlug
|
|
622
|
+
? `Scaffolded "${ctx.args.name}" from ${fromSlug} in ${projectDir}`
|
|
623
|
+
: `Scaffolded "${ctx.args.name}" in ${projectDir}`,
|
|
624
|
+
hints: [
|
|
625
|
+
{ command: `cd ${projectDir} && npm install`, reason: 'Install dependencies' },
|
|
626
|
+
{ command: `cd ${projectDir} && notis apps dev`, reason: 'Start the real-portal dev workflow' },
|
|
627
|
+
],
|
|
628
|
+
});
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
async function appsScaffoldsListHandler(ctx) {
|
|
632
|
+
const scaffolds = loadScaffoldCatalog();
|
|
633
|
+
return ctx.output.emitSuccess({
|
|
634
|
+
command: ctx.spec.command_path.join(' '),
|
|
635
|
+
data: { scaffolds },
|
|
636
|
+
humanSummary: scaffolds.length
|
|
637
|
+
? `Found ${scaffolds.length} bundled scaffolds`
|
|
638
|
+
: 'No bundled scaffolds found. Run the CLI build step to generate the scaffold catalog.',
|
|
639
|
+
renderHuman: () => (scaffolds.length ? scaffoldsTable(scaffolds) : 'No bundled scaffolds found.'),
|
|
640
|
+
});
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
async function appsCreateHandler(ctx) {
|
|
644
|
+
const projectDir = ctx.args.dir ? resolveProjectDir(ctx.args.dir) : null;
|
|
645
|
+
const appConfig = projectDir ? await loadAppConfig(projectDir) : null;
|
|
646
|
+
const idempotencyKey = nextIdempotencyKey(ctx.globalOptions);
|
|
647
|
+
const result = await runToolCommand({
|
|
648
|
+
runtime: ctx.runtime,
|
|
649
|
+
toolName: CREATE_APP_TOOL,
|
|
650
|
+
arguments_: {
|
|
651
|
+
name: ctx.args.name,
|
|
652
|
+
description: appConfig?.description || undefined,
|
|
653
|
+
icon: appConfig?.icon || undefined,
|
|
654
|
+
accent: appConfig?.accent ?? undefined,
|
|
655
|
+
},
|
|
656
|
+
mutating: true,
|
|
657
|
+
idempotencyKey,
|
|
658
|
+
});
|
|
659
|
+
|
|
660
|
+
const app = result.payload.app || result.payload;
|
|
661
|
+
if (!app?.id) {
|
|
662
|
+
throw usageError('Create app did not return an app id.');
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
if (projectDir) {
|
|
666
|
+
writeLinkedState(projectDir, {
|
|
667
|
+
app_id: app.id,
|
|
668
|
+
linked_at: new Date().toISOString(),
|
|
669
|
+
});
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
return ctx.output.emitSuccess({
|
|
673
|
+
command: ctx.spec.command_path.join(' '),
|
|
674
|
+
data: {
|
|
675
|
+
app,
|
|
676
|
+
project_dir: projectDir,
|
|
677
|
+
linked: Boolean(projectDir),
|
|
678
|
+
idempotency_key: idempotencyKey,
|
|
679
|
+
},
|
|
680
|
+
humanSummary: projectDir
|
|
681
|
+
? `Created app ${app.name || ctx.args.name} (${app.id}) and linked ${projectDir}`
|
|
682
|
+
: `Created app ${app.name || ctx.args.name} (${app.id})`,
|
|
683
|
+
hints: projectDir
|
|
684
|
+
? [{ command: `cd ${projectDir} && notis apps deploy .`, reason: 'Deploy the linked project' }]
|
|
685
|
+
: [{ command: `notis apps link ${app.id} .`, reason: 'Link a local project before deploying' }],
|
|
686
|
+
meta: { mutating: true, idempotency_key: idempotencyKey },
|
|
687
|
+
});
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
async function appsDevHandler(ctx) {
|
|
691
|
+
const rootDir = resolveProjectDir(ctx.args.dir || '.');
|
|
692
|
+
const appDirs = discoverAppDirs(rootDir);
|
|
693
|
+
|
|
694
|
+
for (const projectDir of appDirs) {
|
|
695
|
+
const problems = detectProjectProblems(projectDir);
|
|
696
|
+
if (problems.length) {
|
|
697
|
+
throw usageError(`Project ${projectDir} has problems:\n${problems.map((p) => ` - ${p}`).join('\n')}`);
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
const port = ctx.options.port ? Number.parseInt(ctx.options.port, 10) : DEFAULT_DEV_PORT;
|
|
702
|
+
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
|
|
703
|
+
throw usageError('Port must be between 1 and 65535.');
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
const mode = getCliMode();
|
|
707
|
+
const identity = decodeJwtSub(ctx.runtime.jwt);
|
|
708
|
+
if (!identity) {
|
|
709
|
+
throw usageError('Could not determine the current user from the CLI auth token. Open the Notis desktop app, sign in, and retry.');
|
|
710
|
+
}
|
|
711
|
+
const apiBase = String(ctx.runtime.apiBase || '').replace(/\/$/, '');
|
|
712
|
+
const sessionId = randomUUID();
|
|
713
|
+
|
|
714
|
+
const candidates = [];
|
|
715
|
+
for (const projectDir of appDirs) {
|
|
716
|
+
const appConfig = await loadAppConfig(projectDir);
|
|
717
|
+
if (!appConfig.name) {
|
|
718
|
+
throw usageError(`notis.config.ts in ${projectDir} must define a non-empty name.`);
|
|
719
|
+
}
|
|
720
|
+
const devSlug = buildDevInstallSlug(appConfig.name);
|
|
721
|
+
if (!devSlug) {
|
|
722
|
+
throw usageError(`notis.config.ts name in ${projectDir} must slugify to a non-empty value.`);
|
|
723
|
+
}
|
|
724
|
+
candidates.push({ appConfig, devSlug, projectDir });
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
const seenSlugs = new Map();
|
|
728
|
+
for (const app of candidates) {
|
|
729
|
+
if (seenSlugs.has(app.devSlug)) {
|
|
730
|
+
throw usageError(
|
|
731
|
+
`Multiple apps slugify to "${app.devSlug}" (${seenSlugs.get(app.devSlug).projectDir} and ${app.projectDir}). Rename one in notis.config.ts.`,
|
|
732
|
+
);
|
|
733
|
+
}
|
|
734
|
+
seenSlugs.set(app.devSlug, app);
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
const registrationStartedAt = process.hrtime.bigint();
|
|
738
|
+
const registered = await Promise.all(
|
|
739
|
+
candidates.map(async ({ appConfig, devSlug, projectDir }) => {
|
|
740
|
+
const appStartedAt = process.hrtime.bigint();
|
|
741
|
+
try {
|
|
742
|
+
return await ensureDevInstall({
|
|
743
|
+
ctx,
|
|
744
|
+
appConfig,
|
|
745
|
+
projectDir,
|
|
746
|
+
idempotencyKey: nextDevInstallIdempotencyKey(ctx.globalOptions, devSlug),
|
|
747
|
+
});
|
|
748
|
+
} finally {
|
|
749
|
+
logAppsTiming('ensure-dev-install', {
|
|
750
|
+
slug: devSlug,
|
|
751
|
+
ms: timingMs(appStartedAt).toFixed(1),
|
|
752
|
+
});
|
|
753
|
+
}
|
|
754
|
+
}),
|
|
755
|
+
);
|
|
756
|
+
logAppsTiming('ensure-dev-install:all', {
|
|
757
|
+
apps: registered.length,
|
|
758
|
+
ms: timingMs(registrationStartedAt).toFixed(1),
|
|
759
|
+
});
|
|
760
|
+
|
|
761
|
+
const apps = registered.map((app) => {
|
|
762
|
+
const bundleBaseUrl = `http://127.0.0.1:${port}/a/${app.devSlug}`;
|
|
763
|
+
return {
|
|
764
|
+
...app,
|
|
765
|
+
bundleBaseUrl,
|
|
766
|
+
mountNonce: randomUUID(),
|
|
767
|
+
appHref: buildAppHref({
|
|
768
|
+
appSlug: app.slug,
|
|
769
|
+
appId: app.appId,
|
|
770
|
+
manifest: app.manifest,
|
|
771
|
+
}),
|
|
772
|
+
};
|
|
773
|
+
});
|
|
774
|
+
const developmentTabUrl = buildDevelopmentDesktopUrl(
|
|
775
|
+
apps[0]?.appHref,
|
|
776
|
+
resolveDevelopmentDesktopScheme(),
|
|
777
|
+
);
|
|
778
|
+
const warnings = databaseMaterializationWarnings(apps);
|
|
779
|
+
|
|
780
|
+
const devServer = await startAppDevServer({
|
|
781
|
+
apps: apps.map((app) => ({
|
|
782
|
+
slug: app.devSlug,
|
|
783
|
+
projectDir: app.projectDir,
|
|
784
|
+
appId: app.appId,
|
|
785
|
+
targetAppId: app.targetAppId || null,
|
|
786
|
+
userId: identity,
|
|
787
|
+
})),
|
|
788
|
+
port,
|
|
789
|
+
});
|
|
790
|
+
|
|
791
|
+
try {
|
|
792
|
+
const now = new Date().toISOString();
|
|
793
|
+
upsertAppDevSessions(apps.map((app) => ({
|
|
794
|
+
sessionId,
|
|
795
|
+
userId: identity,
|
|
796
|
+
apiBase,
|
|
797
|
+
appId: app.appId,
|
|
798
|
+
targetAppId: app.targetAppId || undefined,
|
|
799
|
+
mountNonce: app.mountNonce,
|
|
800
|
+
devSlug: app.devSlug,
|
|
801
|
+
bundleBaseUrl: app.bundleBaseUrl,
|
|
802
|
+
projectDir: app.projectDir,
|
|
803
|
+
startedAt: now,
|
|
804
|
+
lastHeartbeatAt: now,
|
|
805
|
+
})));
|
|
806
|
+
} catch (error) {
|
|
807
|
+
try {
|
|
808
|
+
await devServer.close();
|
|
809
|
+
} catch {
|
|
810
|
+
// ignore cleanup failure on registry errors
|
|
811
|
+
}
|
|
812
|
+
throw error;
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
let heartbeatTimer = setInterval(() => {
|
|
816
|
+
try {
|
|
817
|
+
heartbeatAppDevSession(sessionId, new Date().toISOString());
|
|
818
|
+
} catch (error) {
|
|
819
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
820
|
+
process.stderr.write(`[notis apps dev] heartbeat failed: ${message}\n`);
|
|
821
|
+
}
|
|
822
|
+
}, DEV_HEARTBEAT_INTERVAL_MS);
|
|
823
|
+
|
|
824
|
+
const expectedMountAcknowledgements = apps.map((app) => ({
|
|
825
|
+
sessionId,
|
|
826
|
+
appId: app.appId,
|
|
827
|
+
devSlug: app.devSlug,
|
|
828
|
+
mountNonce: app.mountNonce,
|
|
829
|
+
}));
|
|
830
|
+
|
|
831
|
+
ctx.output.emitSuccess({
|
|
832
|
+
command: ctx.spec.command_path.join(' '),
|
|
833
|
+
data: {
|
|
834
|
+
mode,
|
|
835
|
+
api_base: apiBase,
|
|
836
|
+
development_url: developmentTabUrl,
|
|
837
|
+
session_id: sessionId,
|
|
838
|
+
mount_status: 'serving',
|
|
839
|
+
identity,
|
|
840
|
+
apps: apps.map((app) => ({
|
|
841
|
+
slug: app.devSlug,
|
|
842
|
+
app_id: app.appId,
|
|
843
|
+
target_app_id: app.targetAppId,
|
|
844
|
+
name: app.name,
|
|
845
|
+
project_dir: app.projectDir,
|
|
846
|
+
bundle_base_url: app.bundleBaseUrl,
|
|
847
|
+
app_href: app.appHref,
|
|
848
|
+
created: app.created,
|
|
849
|
+
linked_app_id: app.linkedAppId,
|
|
850
|
+
database_materialization: app.databaseMaterialization,
|
|
851
|
+
})),
|
|
852
|
+
},
|
|
853
|
+
warnings,
|
|
854
|
+
humanSummary: [
|
|
855
|
+
`Running apps dev against ${apiBase} as ${identity} (mode: ${mode})`,
|
|
856
|
+
'',
|
|
857
|
+
`Open in desktop: ${developmentTabUrl}`,
|
|
858
|
+
'',
|
|
859
|
+
...apps.map((app) => ` ${app.name.padEnd(24)} ${app.bundleBaseUrl} -> ${app.appHref}`),
|
|
860
|
+
'',
|
|
861
|
+
`Serving locally; waiting for Notis to mount ${apps.length === 1 ? 'the app' : `${apps.length} apps`}.`,
|
|
862
|
+
'',
|
|
863
|
+
'Press Ctrl-C to stop.',
|
|
864
|
+
].join('\n'),
|
|
865
|
+
});
|
|
866
|
+
|
|
867
|
+
if (shouldOpenDevelopmentTab(ctx.options)) {
|
|
868
|
+
openInBrowser(developmentTabUrl);
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
void (async () => {
|
|
872
|
+
let result = await waitForAppDevSessionMountAcknowledgements(expectedMountAcknowledgements);
|
|
873
|
+
if (!result.mounted) {
|
|
874
|
+
process.stderr.write(
|
|
875
|
+
`[notis apps dev] Serving locally, but Notis has not acknowledged ${result.missing.length === 1 ? 'the app' : `${result.missing.length} apps`} in its Local development sidebar yet. Keep this command running and open Notis.\n`,
|
|
876
|
+
);
|
|
877
|
+
}
|
|
878
|
+
while (!result.mounted) {
|
|
879
|
+
result = await waitForAppDevSessionMountAcknowledgements(expectedMountAcknowledgements, {
|
|
880
|
+
timeoutMs: 60_000,
|
|
881
|
+
pollIntervalMs: 250,
|
|
882
|
+
});
|
|
883
|
+
}
|
|
884
|
+
process.stderr.write(
|
|
885
|
+
`[notis apps dev] Mounted in Notis: ${apps.map((app) => app.name).join(', ')}.\n`,
|
|
886
|
+
);
|
|
887
|
+
})().catch((error) => {
|
|
888
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
889
|
+
process.stderr.write(`[notis apps dev] mount acknowledgement check failed: ${message}\n`);
|
|
890
|
+
});
|
|
891
|
+
|
|
892
|
+
let shuttingDown = false;
|
|
893
|
+
const shutdown = async (signal) => {
|
|
894
|
+
if (shuttingDown) return;
|
|
895
|
+
shuttingDown = true;
|
|
896
|
+
process.stdout.write(`\n[notis apps dev] stopping (${signal})...\n`);
|
|
897
|
+
if (heartbeatTimer) {
|
|
898
|
+
clearInterval(heartbeatTimer);
|
|
899
|
+
heartbeatTimer = null;
|
|
900
|
+
}
|
|
901
|
+
try {
|
|
902
|
+
removeAppDevSession(sessionId);
|
|
903
|
+
} catch {
|
|
904
|
+
// ignore cleanup failures during shutdown
|
|
905
|
+
}
|
|
906
|
+
try {
|
|
907
|
+
await devServer.close();
|
|
908
|
+
} catch {
|
|
909
|
+
// ignore cleanup failures during shutdown
|
|
910
|
+
}
|
|
911
|
+
process.exit(EXIT_CODES.ok);
|
|
912
|
+
};
|
|
913
|
+
|
|
914
|
+
process.on('SIGINT', () => {
|
|
915
|
+
void shutdown('SIGINT');
|
|
916
|
+
});
|
|
917
|
+
process.on('SIGTERM', () => {
|
|
918
|
+
void shutdown('SIGTERM');
|
|
919
|
+
});
|
|
920
|
+
|
|
921
|
+
await new Promise(() => {});
|
|
922
|
+
|
|
923
|
+
return EXIT_CODES.ok;
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
async function appsBuildHandler(ctx) {
|
|
927
|
+
const projectDir = resolveProjectDir(ctx.args.dir || '.');
|
|
928
|
+
const problems = detectProjectProblems(projectDir);
|
|
929
|
+
if (problems.length) {
|
|
930
|
+
throw usageError(`Project has problems:\n${problems.map((p) => ` - ${p}`).join('\n')}`);
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
const { manifest } = await buildArtifact(projectDir);
|
|
934
|
+
|
|
935
|
+
return ctx.output.emitSuccess({
|
|
936
|
+
command: ctx.spec.command_path.join(' '),
|
|
937
|
+
data: { manifest },
|
|
938
|
+
humanSummary: `Built ${manifest.routes.length} routes into .notis/output/`,
|
|
939
|
+
});
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
async function appsVerifyHandler(ctx) {
|
|
943
|
+
const projectDir = resolveProjectDir(ctx.args.dir || '.');
|
|
944
|
+
const problems = detectProjectProblems(projectDir);
|
|
945
|
+
if (problems.length) {
|
|
946
|
+
throw usageError(`Project has problems:\n${problems.map((p) => ` - ${p}`).join('\n')}`);
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
const mode = ctx.options.mode || 'stub';
|
|
950
|
+
if (!['stub', 'live'].includes(mode)) {
|
|
951
|
+
throw usageError('--mode must be either "stub" or "live".');
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
let linkedState = null;
|
|
955
|
+
if (mode === 'live') {
|
|
956
|
+
if (!ctx.runtime.jwt) {
|
|
957
|
+
throw usageError('Live verify mode requires CLI auth. Open the Notis desktop app, sign in, and retry.');
|
|
958
|
+
}
|
|
959
|
+
linkedState = readLinkedState(projectDir);
|
|
960
|
+
if (!linkedState?.app_id) {
|
|
961
|
+
throw usageError('Live verify mode requires a linked app. Run `notis apps link <app-id> .` first.');
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
if (!ctx.options.skipBuild) {
|
|
966
|
+
await buildArtifact(projectDir);
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
const manifest = readManifest(projectDir);
|
|
970
|
+
const appConfig = await loadAppConfig(projectDir);
|
|
971
|
+
const listing = inspectListingReadiness(projectDir, appConfig);
|
|
972
|
+
if (listing.errors.length) {
|
|
973
|
+
throw usageError(`Listing metadata has problems:\n${listing.errors.map((error) => ` - ${error}`).join('\n')}`);
|
|
974
|
+
}
|
|
975
|
+
const listingWarnings = listing.warnings;
|
|
976
|
+
const routes = routeSelection(manifest, parseRouteSlugs(ctx.options.routes));
|
|
977
|
+
const port = parsePort(ctx.options.port) || await getAvailablePort();
|
|
978
|
+
const appSlug = slugify(appConfig.name || manifest.app?.name || 'app') || 'app';
|
|
979
|
+
const baseUrl = `http://127.0.0.1:${port}/a/${appSlug}`;
|
|
980
|
+
const browserSessionName = `notis-verify-${process.pid}`;
|
|
981
|
+
const noBrowser = ctx.options.browser === false;
|
|
982
|
+
const keepOpen = Boolean(ctx.options.keepOpen);
|
|
983
|
+
let devServer = null;
|
|
984
|
+
let browserTouched = false;
|
|
985
|
+
|
|
986
|
+
try {
|
|
987
|
+
devServer = await startAppDevServer({
|
|
988
|
+
apps: [{
|
|
989
|
+
slug: appSlug,
|
|
990
|
+
projectDir,
|
|
991
|
+
appId: linkedState?.app_id || 'harness-app',
|
|
992
|
+
}],
|
|
993
|
+
port,
|
|
994
|
+
watch: false,
|
|
995
|
+
harness: {
|
|
996
|
+
mode,
|
|
997
|
+
apiBase: ctx.runtime.apiBase,
|
|
998
|
+
jwt: mode === 'live' ? ctx.runtime.jwt : null,
|
|
999
|
+
},
|
|
1000
|
+
log: () => {},
|
|
1001
|
+
logError: (message) => process.stderr.write(`${message}\n`),
|
|
1002
|
+
});
|
|
1003
|
+
|
|
1004
|
+
const urls = routes.map((route) => ({
|
|
1005
|
+
route,
|
|
1006
|
+
url: `${baseUrl}/harness?route=${encodeURIComponent(route.slug)}`,
|
|
1007
|
+
}));
|
|
1008
|
+
|
|
1009
|
+
let results;
|
|
1010
|
+
const warnings = [...listingWarnings];
|
|
1011
|
+
if (noBrowser) {
|
|
1012
|
+
results = urls.map(({ route, url }) => ({
|
|
1013
|
+
route: route.slug,
|
|
1014
|
+
path: route.path,
|
|
1015
|
+
url,
|
|
1016
|
+
ok: true,
|
|
1017
|
+
status: 'manual',
|
|
1018
|
+
mounted: null,
|
|
1019
|
+
errors: [],
|
|
1020
|
+
runtimeCalls: [],
|
|
1021
|
+
assertions: [],
|
|
1022
|
+
snapshot_path: null,
|
|
1023
|
+
tool_error: null,
|
|
1024
|
+
}));
|
|
1025
|
+
} else if (!isAgentBrowserAvailable()) {
|
|
1026
|
+
warnings.push('agent-browser is not available on PATH; rerun with --no-browser to inspect harness URLs manually.');
|
|
1027
|
+
results = urls.map(({ route, url }) => {
|
|
1028
|
+
const toolError = {
|
|
1029
|
+
phase: 'available',
|
|
1030
|
+
message: 'agent-browser is not available on PATH',
|
|
1031
|
+
};
|
|
1032
|
+
const result = {
|
|
1033
|
+
route: route.slug,
|
|
1034
|
+
path: route.path,
|
|
1035
|
+
url,
|
|
1036
|
+
mounted: false,
|
|
1037
|
+
renderStarted: false,
|
|
1038
|
+
errors: [],
|
|
1039
|
+
runtimeCalls: [],
|
|
1040
|
+
snapshotPath: null,
|
|
1041
|
+
tool_error: toolError,
|
|
1042
|
+
};
|
|
1043
|
+
const assertions = assertHarnessResult(
|
|
1044
|
+
result,
|
|
1045
|
+
route,
|
|
1046
|
+
declaredDatabaseSlugs(appConfig, manifest, route),
|
|
1047
|
+
);
|
|
1048
|
+
return {
|
|
1049
|
+
...result,
|
|
1050
|
+
ok: false,
|
|
1051
|
+
status: 'failed',
|
|
1052
|
+
assertions,
|
|
1053
|
+
snapshot_path: null,
|
|
1054
|
+
};
|
|
1055
|
+
});
|
|
1056
|
+
} else {
|
|
1057
|
+
browserTouched = true;
|
|
1058
|
+
results = [];
|
|
1059
|
+
for (const { route, url } of urls) {
|
|
1060
|
+
const snapshotPath = join(projectDir, '.notis', 'output', '.harness', `${route.slug}.snapshot.txt`);
|
|
1061
|
+
const result = await runHarnessRoute({
|
|
1062
|
+
url,
|
|
1063
|
+
sessionName: browserSessionName,
|
|
1064
|
+
timeoutMs: Number.parseInt(ctx.globalOptions.timeoutMs || '', 10) || 10_000,
|
|
1065
|
+
snapshotPath,
|
|
1066
|
+
});
|
|
1067
|
+
const assertions = assertHarnessResult(
|
|
1068
|
+
result,
|
|
1069
|
+
route,
|
|
1070
|
+
declaredDatabaseSlugs(appConfig, manifest, route),
|
|
1071
|
+
);
|
|
1072
|
+
results.push({
|
|
1073
|
+
route: route.slug,
|
|
1074
|
+
path: route.path,
|
|
1075
|
+
url,
|
|
1076
|
+
ok: assertions.length === 0,
|
|
1077
|
+
status: assertions.length === 0 ? 'passed' : 'failed',
|
|
1078
|
+
mounted: result.mounted,
|
|
1079
|
+
renderStarted: result.renderStarted,
|
|
1080
|
+
errors: result.errors,
|
|
1081
|
+
runtimeCalls: result.runtimeCalls,
|
|
1082
|
+
assertions,
|
|
1083
|
+
snapshot_path: result.snapshotPath,
|
|
1084
|
+
timed_out: Boolean(result.timed_out),
|
|
1085
|
+
tool_error: result.tool_error,
|
|
1086
|
+
});
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
const summary = {
|
|
1091
|
+
total: results.length,
|
|
1092
|
+
passed: results.filter((result) => result.status === 'passed').length,
|
|
1093
|
+
failed: results.filter((result) => result.status === 'failed').length,
|
|
1094
|
+
manual: results.filter((result) => result.status === 'manual').length,
|
|
1095
|
+
};
|
|
1096
|
+
const overallOk = summary.failed === 0;
|
|
1097
|
+
const exitCode = overallOk ? EXIT_CODES.ok : EXIT_CODES.unexpected;
|
|
1098
|
+
const data = {
|
|
1099
|
+
status: overallOk ? (summary.manual ? 'manual' : 'passed') : 'failed',
|
|
1100
|
+
project_dir: projectDir,
|
|
1101
|
+
app_slug: appSlug,
|
|
1102
|
+
mode,
|
|
1103
|
+
browser_session: noBrowser ? null : browserSessionName,
|
|
1104
|
+
server: {
|
|
1105
|
+
port,
|
|
1106
|
+
base_url: baseUrl,
|
|
1107
|
+
urls: urls.map(({ route, url }) => ({ route: route.slug, url })),
|
|
1108
|
+
},
|
|
1109
|
+
summary,
|
|
1110
|
+
results,
|
|
1111
|
+
};
|
|
1112
|
+
|
|
1113
|
+
ctx.output.emitSuccess({
|
|
1114
|
+
ok: overallOk,
|
|
1115
|
+
command: ctx.spec.command_path.join(' '),
|
|
1116
|
+
data,
|
|
1117
|
+
humanSummary: overallOk
|
|
1118
|
+
? (summary.manual ? `Harness URLs ready for ${summary.manual} routes.` : `Verified ${summary.passed} routes successfully.`)
|
|
1119
|
+
: `Verification failed for ${summary.failed} routes.`,
|
|
1120
|
+
warnings,
|
|
1121
|
+
renderHuman: () => renderVerifyReport({ summary, results, noBrowser }),
|
|
1122
|
+
});
|
|
1123
|
+
|
|
1124
|
+
if (keepOpen) {
|
|
1125
|
+
process.stderr.write(`[notis apps verify] harness open at ${baseUrl}. Press Ctrl-C to stop.\n`);
|
|
1126
|
+
await new Promise(() => {});
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
return exitCode;
|
|
1130
|
+
} finally {
|
|
1131
|
+
if (!keepOpen) {
|
|
1132
|
+
if (browserTouched) {
|
|
1133
|
+
try {
|
|
1134
|
+
await closeAgentBrowserSession(browserSessionName);
|
|
1135
|
+
} catch {
|
|
1136
|
+
// Ignore browser cleanup failures.
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
if (devServer) {
|
|
1140
|
+
try {
|
|
1141
|
+
await devServer.close();
|
|
1142
|
+
} catch {
|
|
1143
|
+
// Ignore server cleanup failures.
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
async function appsScreenshotHandler(ctx) {
|
|
1151
|
+
const projectDir = resolveProjectDir(ctx.args.dir || '.');
|
|
1152
|
+
const problems = detectProjectProblems(projectDir);
|
|
1153
|
+
if (problems.length) {
|
|
1154
|
+
throw usageError(`Project has problems:\n${problems.map((p) => ` - ${p}`).join('\n')}`);
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
if (!isAgentBrowserAvailable()) {
|
|
1158
|
+
throw usageError('agent-browser is not available on PATH. It ships with the Notis desktop app; open it once, then retry.');
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
const mode = ctx.options.mode || 'stub';
|
|
1162
|
+
if (!['stub', 'live'].includes(mode)) {
|
|
1163
|
+
throw usageError('--mode must be either "stub" or "live".');
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
let linkedState = null;
|
|
1167
|
+
if (mode === 'live') {
|
|
1168
|
+
if (!ctx.runtime.jwt) {
|
|
1169
|
+
throw usageError('Live mode requires CLI auth. Open the Notis desktop app, sign in, and retry.');
|
|
1170
|
+
}
|
|
1171
|
+
linkedState = readLinkedState(projectDir);
|
|
1172
|
+
if (!linkedState?.app_id) {
|
|
1173
|
+
throw usageError('Live mode requires a linked app. Run `notis apps link <app-id> .` first.');
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
if (!ctx.options.skipBuild) {
|
|
1178
|
+
await buildArtifact(projectDir);
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
const manifest = readManifest(projectDir);
|
|
1182
|
+
const appConfig = await loadAppConfig(projectDir);
|
|
1183
|
+
const selectedRouteSlugs = parseRouteSlugs(ctx.options.routes);
|
|
1184
|
+
const routes = routeSelection(manifest, selectedRouteSlugs);
|
|
1185
|
+
const screenshotSlots = screenshotIndexByRouteSlug(manifest);
|
|
1186
|
+
if (!routes.length) {
|
|
1187
|
+
throw usageError('No routes to screenshot.');
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
const width = parsePositiveInt(ctx.options.width) || 2000;
|
|
1191
|
+
const height = parsePositiveInt(ctx.options.height) || 1250;
|
|
1192
|
+
const outputDir = ctx.options.outputDir
|
|
1193
|
+
? resolveProjectDir(ctx.options.outputDir)
|
|
1194
|
+
: join(projectDir, 'metadata');
|
|
1195
|
+
|
|
1196
|
+
const port = parsePort(ctx.options.port) || await getAvailablePort();
|
|
1197
|
+
const appSlug = slugify(appConfig.name || manifest.app?.name || 'app') || 'app';
|
|
1198
|
+
const baseUrl = `http://127.0.0.1:${port}/a/${appSlug}`;
|
|
1199
|
+
const browserSessionName = `notis-screenshot-${process.pid}`;
|
|
1200
|
+
const browserSessionNames = [];
|
|
1201
|
+
const configuredScreenshots = resolveListingScreenshots(projectDir, appConfig)
|
|
1202
|
+
.filter((screenshot) => screenshot.route)
|
|
1203
|
+
.filter((screenshot) => !selectedRouteSlugs || selectedRouteSlugs.includes(screenshot.route));
|
|
1204
|
+
const routeBySlug = new Map(routes.map((route) => [route.slug, route]));
|
|
1205
|
+
const captures = configuredScreenshots.length > 0
|
|
1206
|
+
? configuredScreenshots.map((screenshot) => {
|
|
1207
|
+
const route = routeBySlug.get(screenshot.route);
|
|
1208
|
+
if (!route) {
|
|
1209
|
+
throw usageError(
|
|
1210
|
+
`Screenshot ${screenshot.path} references unavailable route slug "${screenshot.route}".`,
|
|
1211
|
+
{ available_routes: routes.map((entry) => entry.slug) },
|
|
1212
|
+
);
|
|
1213
|
+
}
|
|
1214
|
+
return {
|
|
1215
|
+
route,
|
|
1216
|
+
scenario: screenshot.scenario,
|
|
1217
|
+
focus: screenshot.focus,
|
|
1218
|
+
theme: screenshot.theme || 'light',
|
|
1219
|
+
fileName: basename(screenshot.path),
|
|
1220
|
+
};
|
|
1221
|
+
})
|
|
1222
|
+
: routes.map((route, index) => ({
|
|
1223
|
+
route,
|
|
1224
|
+
scenario: null,
|
|
1225
|
+
focus: null,
|
|
1226
|
+
theme: 'light',
|
|
1227
|
+
fileName: `screenshot-${screenshotSlots.get(route.slug) || index + 1}.png`,
|
|
1228
|
+
}));
|
|
1229
|
+
const rawOutputDir = ctx.options.raw
|
|
1230
|
+
? null
|
|
1231
|
+
: mkdtempSync(join(tmpdir(), 'notis-store-screenshots-'));
|
|
1232
|
+
let devServer = null;
|
|
1233
|
+
let browserTouched = false;
|
|
1234
|
+
|
|
1235
|
+
try {
|
|
1236
|
+
devServer = await startAppDevServer({
|
|
1237
|
+
apps: [{ slug: appSlug, projectDir, appId: linkedState?.app_id || 'harness-app' }],
|
|
1238
|
+
port,
|
|
1239
|
+
watch: false,
|
|
1240
|
+
harness: {
|
|
1241
|
+
mode,
|
|
1242
|
+
apiBase: ctx.runtime.apiBase,
|
|
1243
|
+
jwt: mode === 'live' ? ctx.runtime.jwt : null,
|
|
1244
|
+
},
|
|
1245
|
+
log: () => {},
|
|
1246
|
+
logError: (message) => process.stderr.write(`${message}\n`),
|
|
1247
|
+
});
|
|
1248
|
+
browserTouched = true;
|
|
1249
|
+
|
|
1250
|
+
mkdirSync(outputDir, { recursive: true });
|
|
1251
|
+
const results = [];
|
|
1252
|
+
for (const capture of captures) {
|
|
1253
|
+
const { route, scenario, focus, theme, fileName } = capture;
|
|
1254
|
+
const screenshotPath = join(outputDir, fileName);
|
|
1255
|
+
const browserScreenshotPath = rawOutputDir
|
|
1256
|
+
? join(rawOutputDir, fileName)
|
|
1257
|
+
: screenshotPath;
|
|
1258
|
+
const scenarioParam = scenario ? `&scenario=${encodeURIComponent(scenario)}` : '';
|
|
1259
|
+
const themeParam = `&theme=${encodeURIComponent(theme || 'light')}`;
|
|
1260
|
+
// Keep scenario captures on independent pages. Chromium can otherwise
|
|
1261
|
+
// reuse stale compositor layers when the next screenshot changes the
|
|
1262
|
+
// same app route into a substantially different state.
|
|
1263
|
+
const captureSessionName = `${browserSessionName}-${results.length + 1}`;
|
|
1264
|
+
browserSessionNames.push(captureSessionName);
|
|
1265
|
+
let result = await captureHarnessScreenshot({
|
|
1266
|
+
url: `${baseUrl}/harness?route=${encodeURIComponent(route.slug)}${scenarioParam}${themeParam}`,
|
|
1267
|
+
sessionName: captureSessionName,
|
|
1268
|
+
screenshotPath: browserScreenshotPath,
|
|
1269
|
+
focusSelector: ctx.options.raw ? null : focus,
|
|
1270
|
+
width,
|
|
1271
|
+
height,
|
|
1272
|
+
timeoutMs: Number.parseInt(ctx.globalOptions.timeoutMs || '', 10) || 15_000,
|
|
1273
|
+
});
|
|
1274
|
+
let presentation = { mode: 'raw' };
|
|
1275
|
+
if (result.ok && rawOutputDir) {
|
|
1276
|
+
try {
|
|
1277
|
+
presentation = await composeStoreScreenshot({
|
|
1278
|
+
inputPath: browserScreenshotPath,
|
|
1279
|
+
outputPath: screenshotPath,
|
|
1280
|
+
width,
|
|
1281
|
+
height,
|
|
1282
|
+
accent: appConfig.accent,
|
|
1283
|
+
seed: appConfig.name || manifest.app?.name || appSlug,
|
|
1284
|
+
focused: Boolean(focus),
|
|
1285
|
+
theme: theme || 'light',
|
|
1286
|
+
});
|
|
1287
|
+
} catch (error) {
|
|
1288
|
+
result = {
|
|
1289
|
+
...result,
|
|
1290
|
+
ok: false,
|
|
1291
|
+
screenshotPath: null,
|
|
1292
|
+
tool_error: {
|
|
1293
|
+
phase: 'compose',
|
|
1294
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1295
|
+
},
|
|
1296
|
+
};
|
|
1297
|
+
}
|
|
1298
|
+
}
|
|
1299
|
+
results.push({
|
|
1300
|
+
route: route.slug,
|
|
1301
|
+
path: route.path,
|
|
1302
|
+
file: relative(projectDir, screenshotPath),
|
|
1303
|
+
ok: result.ok,
|
|
1304
|
+
errors: result.errors || [],
|
|
1305
|
+
timed_out: Boolean(result.timed_out),
|
|
1306
|
+
tool_error: result.tool_error,
|
|
1307
|
+
framing: result.framing || null,
|
|
1308
|
+
theme: theme || 'light',
|
|
1309
|
+
presentation,
|
|
1310
|
+
});
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
const captured = results.filter((r) => r.ok);
|
|
1314
|
+
const failed = results.filter((r) => !r.ok);
|
|
1315
|
+
const warnings = [];
|
|
1316
|
+
|
|
1317
|
+
// Drop stale screenshots only after a full refresh. A selected-route
|
|
1318
|
+
// capture intentionally leaves other listing screenshots untouched.
|
|
1319
|
+
if (shouldPruneStaleScreenshotFiles(selectedRouteSlugs, failed.length)) {
|
|
1320
|
+
pruneStaleScreenshotFiles(outputDir, captures.length);
|
|
1321
|
+
}
|
|
1322
|
+
if (failed.length) {
|
|
1323
|
+
warnings.push(`${failed.length}/${results.length} routes failed to capture; see results.`);
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
ctx.output.emitSuccess({
|
|
1327
|
+
ok: failed.length === 0,
|
|
1328
|
+
command: ctx.spec.command_path.join(' '),
|
|
1329
|
+
data: {
|
|
1330
|
+
project_dir: projectDir,
|
|
1331
|
+
output_dir: outputDir,
|
|
1332
|
+
mode,
|
|
1333
|
+
presentation: ctx.options.raw ? 'raw' : 'framed',
|
|
1334
|
+
viewport: { width, height },
|
|
1335
|
+
summary: { total: results.length, captured: captured.length, failed: failed.length },
|
|
1336
|
+
results,
|
|
1337
|
+
},
|
|
1338
|
+
humanSummary: failed.length === 0
|
|
1339
|
+
? `Captured ${captured.length} screenshot(s) to ${relative(projectDir, outputDir) || 'metadata'}/.`
|
|
1340
|
+
: `Captured ${captured.length}/${results.length} screenshots; ${failed.length} failed.`,
|
|
1341
|
+
warnings,
|
|
1342
|
+
});
|
|
1343
|
+
return screenshotExitCode(failed.length);
|
|
1344
|
+
} finally {
|
|
1345
|
+
if (browserTouched) {
|
|
1346
|
+
await Promise.all(browserSessionNames.map(async (sessionName) => {
|
|
1347
|
+
try {
|
|
1348
|
+
await closeAgentBrowserSession(sessionName);
|
|
1349
|
+
} catch {
|
|
1350
|
+
// Ignore browser cleanup failures.
|
|
1351
|
+
}
|
|
1352
|
+
}));
|
|
1353
|
+
}
|
|
1354
|
+
if (devServer) {
|
|
1355
|
+
try {
|
|
1356
|
+
await devServer.close();
|
|
1357
|
+
} catch {
|
|
1358
|
+
// Ignore server cleanup failures.
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
if (rawOutputDir) {
|
|
1362
|
+
rmSync(rawOutputDir, { recursive: true, force: true });
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
async function appsLinkHandler(ctx) {
|
|
1368
|
+
const projectDir = resolveProjectDir(ctx.args.dir || '.');
|
|
1369
|
+
const appId = ctx.args.appId;
|
|
1370
|
+
|
|
1371
|
+
await assertLinkTarget(ctx.runtime, appId);
|
|
1372
|
+
|
|
1373
|
+
writeLinkedState(projectDir, {
|
|
1374
|
+
app_id: appId,
|
|
1375
|
+
linked_at: new Date().toISOString(),
|
|
1376
|
+
});
|
|
1377
|
+
|
|
1378
|
+
return ctx.output.emitSuccess({
|
|
1379
|
+
command: ctx.spec.command_path.join(' '),
|
|
1380
|
+
data: { app_id: appId, project_dir: projectDir },
|
|
1381
|
+
humanSummary: `Linked to app ${appId}`,
|
|
1382
|
+
hints: [
|
|
1383
|
+
{ command: 'notis apps deploy .', reason: 'Deploy the app' },
|
|
1384
|
+
],
|
|
1385
|
+
});
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
async function appsPullHandler(ctx) {
|
|
1389
|
+
const appId = ctx.args.appId;
|
|
1390
|
+
const result = await runToolCommand({
|
|
1391
|
+
runtime: ctx.runtime,
|
|
1392
|
+
toolName: GET_APP_TOOL,
|
|
1393
|
+
arguments_: { app_id: appId },
|
|
1394
|
+
});
|
|
1395
|
+
const app = result.payload?.app || {};
|
|
1396
|
+
const defaultDir = app.slug || slugify(app.name) || appId;
|
|
1397
|
+
const targetDir = resolveProjectDir(ctx.args.dir || defaultDir);
|
|
1398
|
+
const version = ctx.options.version || 'latest';
|
|
1399
|
+
|
|
1400
|
+
const pulled = await pullAppSource({
|
|
1401
|
+
apiBase: ctx.runtime.apiBase,
|
|
1402
|
+
jwt: ctx.runtime.jwt,
|
|
1403
|
+
appId,
|
|
1404
|
+
targetDir,
|
|
1405
|
+
version,
|
|
1406
|
+
force: Boolean(ctx.options.force),
|
|
1407
|
+
});
|
|
1408
|
+
|
|
1409
|
+
const versionLabel = pulled.version === 'latest' ? 'latest version' : `v${pulled.version}`;
|
|
1410
|
+
return ctx.output.emitSuccess({
|
|
1411
|
+
command: ctx.spec.command_path.join(' '),
|
|
1412
|
+
data: {
|
|
1413
|
+
app_id: appId,
|
|
1414
|
+
project_dir: pulled.projectDir,
|
|
1415
|
+
version: pulled.version,
|
|
1416
|
+
},
|
|
1417
|
+
humanSummary: `Pulled ${versionLabel} to ${pulled.projectDir}. Run \`cd ${pulled.projectDir} && npm install && notis apps dev\` to start editing.`,
|
|
1418
|
+
});
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
function updateLinkedDeployState(projectDir, linkedState, appId, version) {
|
|
1422
|
+
if (!linkedState || linkedState.app_id !== appId || !Number.isFinite(version)) {
|
|
1423
|
+
return;
|
|
1424
|
+
}
|
|
1425
|
+
writeLinkedState(projectDir, {
|
|
1426
|
+
...linkedState,
|
|
1427
|
+
app_id: appId,
|
|
1428
|
+
version,
|
|
1429
|
+
linked_at: linkedState.linked_at || new Date().toISOString(),
|
|
1430
|
+
deployed_at: new Date().toISOString(),
|
|
1431
|
+
});
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
async function appsDeployHandler(ctx) {
|
|
1435
|
+
const projectDir = resolveProjectDir(ctx.args.dir || '.');
|
|
1436
|
+
const appId = requireLinkedAppId(projectDir, ctx.options.appId);
|
|
1437
|
+
const idempotencyKey = nextIdempotencyKey(ctx.globalOptions);
|
|
1438
|
+
const linkedState = readLinkedState(projectDir);
|
|
1439
|
+
const baseVersion = linkedState?.app_id === appId && Number.isFinite(linkedState?.version)
|
|
1440
|
+
? linkedState.version
|
|
1441
|
+
: undefined;
|
|
1442
|
+
|
|
1443
|
+
// Build if needed
|
|
1444
|
+
if (!ctx.options.skipBuild) {
|
|
1445
|
+
await buildArtifact(projectDir);
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
// Direct deploy mode: upload to Supabase storage directly
|
|
1449
|
+
if (ctx.options.direct) {
|
|
1450
|
+
await assertDirectDeployAccess(ctx.runtime, appId);
|
|
1451
|
+
const { version } = await directDeploy(projectDir, appId);
|
|
1452
|
+
updateLinkedDeployState(projectDir, linkedState, appId, version);
|
|
1453
|
+
return ctx.output.emitSuccess({
|
|
1454
|
+
command: ctx.spec.command_path.join(' '),
|
|
1455
|
+
data: { app_id: appId, version, mode: 'direct' },
|
|
1456
|
+
humanSummary: `Deployed to app ${appId} (version ${version}) via direct upload`,
|
|
1457
|
+
meta: { mutating: true },
|
|
1458
|
+
});
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
// Standard deploy via backend server, with auto-fallback to direct
|
|
1462
|
+
const files = collectArtifactFiles(projectDir);
|
|
1463
|
+
const sourceFiles = collectSourceFiles(projectDir);
|
|
1464
|
+
const manifest = readManifest(projectDir);
|
|
1465
|
+
|
|
1466
|
+
let result;
|
|
1467
|
+
try {
|
|
1468
|
+
result = await runToolCommand({
|
|
1469
|
+
runtime: ctx.runtime,
|
|
1470
|
+
toolName: SAVE_APP_FILES_TOOL,
|
|
1471
|
+
arguments_: {
|
|
1472
|
+
app_id: appId,
|
|
1473
|
+
files,
|
|
1474
|
+
source_files: sourceFiles,
|
|
1475
|
+
manifest,
|
|
1476
|
+
...appRowFieldsFromManifest(manifest),
|
|
1477
|
+
...(baseVersion !== undefined ? { base_version: baseVersion } : {}),
|
|
1478
|
+
},
|
|
1479
|
+
mutating: true,
|
|
1480
|
+
idempotencyKey,
|
|
1481
|
+
});
|
|
1482
|
+
} catch (error) {
|
|
1483
|
+
if (error.code === 'conflict') {
|
|
1484
|
+
throw toolConflictToError(error.details, 'Deploy conflict');
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
// Auto-fallback to direct deploy on network errors
|
|
1488
|
+
const isNetworkError = error.code === 'network_error'
|
|
1489
|
+
|| error.code === 'network_timeout'
|
|
1490
|
+
|| (error.message && /fetch failed|ECONNREFUSED|network/i.test(error.message));
|
|
1491
|
+
|
|
1492
|
+
if (isNetworkError) {
|
|
1493
|
+
try {
|
|
1494
|
+
await assertDirectDeployAccess(ctx.runtime, appId);
|
|
1495
|
+
} catch (accessError) {
|
|
1496
|
+
throw usageError(
|
|
1497
|
+
`Backend deploy failed (${error.message}) and direct fallback was blocked because app access ` +
|
|
1498
|
+
`could not be verified (${accessError.message}).`,
|
|
1499
|
+
);
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
try {
|
|
1503
|
+
const { version } = await directDeploy(projectDir, appId);
|
|
1504
|
+
updateLinkedDeployState(projectDir, linkedState, appId, version);
|
|
1505
|
+
return ctx.output.emitSuccess({
|
|
1506
|
+
command: ctx.spec.command_path.join(' '),
|
|
1507
|
+
data: { app_id: appId, version, mode: 'direct-fallback' },
|
|
1508
|
+
humanSummary: `Backend unavailable -- deployed to app ${appId} (version ${version}) via direct upload`,
|
|
1509
|
+
warnings: ['Backend server was unreachable. Used direct Supabase upload as fallback.'],
|
|
1510
|
+
meta: { mutating: true },
|
|
1511
|
+
});
|
|
1512
|
+
} catch (directError) {
|
|
1513
|
+
throw usageError(
|
|
1514
|
+
`Backend deploy failed (${error.message}) and direct fallback also failed (${directError.message}). ` +
|
|
1515
|
+
'Check server/.env for Supabase credentials or start the backend server.',
|
|
1516
|
+
);
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
throw error;
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
updateLinkedDeployState(projectDir, linkedState, appId, Number(result.payload.version));
|
|
1524
|
+
return ctx.output.emitSuccess({
|
|
1525
|
+
command: ctx.spec.command_path.join(' '),
|
|
1526
|
+
data: {
|
|
1527
|
+
app_id: appId,
|
|
1528
|
+
version: result.payload.version,
|
|
1529
|
+
idempotency_key: idempotencyKey,
|
|
1530
|
+
},
|
|
1531
|
+
humanSummary: `Deployed to app ${appId} (version ${result.payload.version})`,
|
|
1532
|
+
meta: { mutating: true, idempotency_key: idempotencyKey },
|
|
1533
|
+
});
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
function deployedAppVersion(app) {
|
|
1537
|
+
const value = app?.current_version ?? app?.manifest?.version;
|
|
1538
|
+
const parsed = Number(value);
|
|
1539
|
+
return Number.isFinite(parsed) ? parsed : 0;
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
async function appsPublishHandler(ctx) {
|
|
1543
|
+
if (ctx.options.confirmReady !== true) {
|
|
1544
|
+
throw usageError(
|
|
1545
|
+
'Store submission requires explicit user confirmation that App Details is ready. ' +
|
|
1546
|
+
'After confirmation, rerun with --confirm-ready.',
|
|
1547
|
+
);
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
const projectDir = resolveProjectDir(ctx.args.dir || '.');
|
|
1551
|
+
const appId = requireLinkedAppId(projectDir, ctx.options.appId);
|
|
1552
|
+
const linkedState = readLinkedState(projectDir);
|
|
1553
|
+
const appConfig = await loadAppConfig(projectDir);
|
|
1554
|
+
const readiness = inspectListingReadiness(projectDir, appConfig);
|
|
1555
|
+
if (!readiness.ready) {
|
|
1556
|
+
throw usageError(
|
|
1557
|
+
`Store listing is not ready:\n${readiness.errors.map((error) => ` - ${error}`).join('\n')}`,
|
|
1558
|
+
);
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
const detailResult = await runToolCommand({
|
|
1562
|
+
runtime: ctx.runtime,
|
|
1563
|
+
toolName: GET_APP_TOOL,
|
|
1564
|
+
arguments_: { app_id: appId },
|
|
1565
|
+
});
|
|
1566
|
+
const detail = detailResult.payload || {};
|
|
1567
|
+
const app = detail.app || {};
|
|
1568
|
+
if (!app.id) {
|
|
1569
|
+
throw usageError(`Could not load deployed app ${appId}.`);
|
|
1570
|
+
}
|
|
1571
|
+
if (!['team', 'public_store_hidden'].includes(app.visibility)) {
|
|
1572
|
+
throw usageError('Set the app visibility to Team or Public before Store submission.');
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
const remoteVersion = deployedAppVersion(app);
|
|
1576
|
+
if (remoteVersion <= 0) {
|
|
1577
|
+
throw usageError('App has no deployed source. Run `notis apps deploy` first.');
|
|
1578
|
+
}
|
|
1579
|
+
if (
|
|
1580
|
+
linkedState?.app_id !== appId
|
|
1581
|
+
|| !Number.isFinite(linkedState?.version)
|
|
1582
|
+
|| linkedState.version !== remoteVersion
|
|
1583
|
+
) {
|
|
1584
|
+
throw usageError(
|
|
1585
|
+
`Local project is not confirmed at deployed version ${remoteVersion}. ` +
|
|
1586
|
+
'Run `notis apps deploy` from this project before submitting it.',
|
|
1587
|
+
);
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1590
|
+
const activeSubmission = detail.active_submission || app.active_submission || null;
|
|
1591
|
+
if (activeSubmission?.status === 'pending_review') {
|
|
1592
|
+
throw usageError(
|
|
1593
|
+
`A Store submission is already in review${activeSubmission.github_pr_url ? `: ${activeSubmission.github_pr_url}` : '.'}`,
|
|
1594
|
+
);
|
|
1595
|
+
}
|
|
1596
|
+
if (activeSubmission?.status === 'removal_pending_review') {
|
|
1597
|
+
throw usageError('Store removal is currently in review. Wait for it to finish before submitting an update.');
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1600
|
+
const result = await httpRequest({
|
|
1601
|
+
runtime: ctx.runtime,
|
|
1602
|
+
method: 'POST',
|
|
1603
|
+
path: '/portal_apps/publish',
|
|
1604
|
+
body: { app_id: appId },
|
|
1605
|
+
});
|
|
1606
|
+
const submission = result.payload.submission || result.payload;
|
|
1607
|
+
const reviewStatus = submission.status || 'pending_review';
|
|
1608
|
+
return ctx.output.emitSuccess({
|
|
1609
|
+
command: ctx.spec.command_path.join(' '),
|
|
1610
|
+
data: {
|
|
1611
|
+
app_id: appId,
|
|
1612
|
+
source_version: submission.source_version || remoteVersion,
|
|
1613
|
+
submission,
|
|
1614
|
+
},
|
|
1615
|
+
humanSummary: reviewStatus === 'merged'
|
|
1616
|
+
? `Published app ${appId} to the Store at version ${submission.source_version || remoteVersion}`
|
|
1617
|
+
: `Submitted app ${appId} version ${submission.source_version || remoteVersion} for Store review`,
|
|
1618
|
+
hints: submission.github_pr_url
|
|
1619
|
+
? [{ command: submission.github_pr_url, reason: 'Review the Store registry pull request' }]
|
|
1620
|
+
: [],
|
|
1621
|
+
meta: { mutating: true, request_id: result.requestId },
|
|
1622
|
+
});
|
|
1623
|
+
}
|
|
1624
|
+
|
|
1625
|
+
async function appsDoctorHandler(ctx) {
|
|
1626
|
+
const projectDir = resolveProjectDir(ctx.args.dir || '.');
|
|
1627
|
+
const problems = detectProjectProblems(projectDir);
|
|
1628
|
+
|
|
1629
|
+
let appConfig = null;
|
|
1630
|
+
try {
|
|
1631
|
+
appConfig = await loadAppConfig(projectDir);
|
|
1632
|
+
} catch {
|
|
1633
|
+
problems.push('Failed to load notis.config.ts');
|
|
1634
|
+
}
|
|
1635
|
+
const warnings = detectProjectWarnings(projectDir, appConfig);
|
|
1636
|
+
let listing = null;
|
|
1637
|
+
if (appConfig) {
|
|
1638
|
+
try {
|
|
1639
|
+
listing = inspectListingReadiness(projectDir, appConfig);
|
|
1640
|
+
} catch (error) {
|
|
1641
|
+
warnings.push(error instanceof Error ? error.message : String(error));
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
const linkedState = readLinkedState(projectDir);
|
|
1646
|
+
const status = problems.length ? 'unhealthy' : warnings.length ? 'warnings' : 'healthy';
|
|
1647
|
+
|
|
1648
|
+
return ctx.output.emitSuccess({
|
|
1649
|
+
command: ctx.spec.command_path.join(' '),
|
|
1650
|
+
data: { status, problems, warnings, linked: linkedState, config: appConfig, listing },
|
|
1651
|
+
humanSummary: problems.length
|
|
1652
|
+
? `Found ${problems.length} problems:\n${problems.map((p) => ` - ${p}`).join('\n')}`
|
|
1653
|
+
: warnings.length
|
|
1654
|
+
? `Healthy with ${warnings.length} warnings:\n${warnings.map((w) => ` - ${w}`).join('\n')}`
|
|
1655
|
+
: `Project is healthy.${linkedState ? ` Linked to ${linkedState.app_id}.` : ' Not linked.'}`,
|
|
1656
|
+
});
|
|
1657
|
+
}
|
|
1658
|
+
|
|
1659
|
+
// ---------------------------------------------------------------------------
|
|
1660
|
+
// Command specs
|
|
1661
|
+
// ---------------------------------------------------------------------------
|
|
1662
|
+
|
|
1663
|
+
export const appsCommandSpecs = [
|
|
1664
|
+
{
|
|
1665
|
+
command_path: ['apps', 'list'],
|
|
1666
|
+
summary: 'List apps the current profile can access.',
|
|
1667
|
+
when_to_use: 'Discover existing apps before linking or deploying.',
|
|
1668
|
+
args_schema: { arguments: [], options: [] },
|
|
1669
|
+
examples: ['notis apps list', 'notis apps list --json'],
|
|
1670
|
+
mutates: false,
|
|
1671
|
+
idempotent: true,
|
|
1672
|
+
backend_call: { type: 'tool', name: LIST_APPS_TOOL },
|
|
1673
|
+
handler: appsListHandler,
|
|
1674
|
+
},
|
|
1675
|
+
{
|
|
1676
|
+
command_path: ['apps', 'init'],
|
|
1677
|
+
summary: 'Scaffold a new Notis app project.',
|
|
1678
|
+
when_to_use: 'Start a new Notis app. Use --from with a bundled scaffold when one is close to the desired app; otherwise creates the bare Vite + React project.',
|
|
1679
|
+
args_schema: {
|
|
1680
|
+
arguments: [
|
|
1681
|
+
{ token: '<name>', description: 'Display name for the app.' },
|
|
1682
|
+
{ token: '[dir]', key: 'dir', description: 'Target directory (defaults to kebab-case of name).' },
|
|
1683
|
+
],
|
|
1684
|
+
options: [
|
|
1685
|
+
{ flags: '--from <slug>', description: 'Start from a bundled scaffold listed by `notis apps scaffolds list`.' },
|
|
1686
|
+
],
|
|
1687
|
+
},
|
|
1688
|
+
examples: [
|
|
1689
|
+
'notis apps scaffolds list',
|
|
1690
|
+
'notis apps init "Mind the Flo"',
|
|
1691
|
+
'notis apps init "My CRM" --from notis-database',
|
|
1692
|
+
'notis apps init "My App" ./my-app',
|
|
1693
|
+
],
|
|
1694
|
+
mutates: true,
|
|
1695
|
+
idempotent: false,
|
|
1696
|
+
require_auth: false,
|
|
1697
|
+
backend_call: { type: 'local', name: 'scaffold_project' },
|
|
1698
|
+
handler: appsInitHandler,
|
|
1699
|
+
},
|
|
1700
|
+
{
|
|
1701
|
+
command_path: ['apps', 'scaffolds', 'list'],
|
|
1702
|
+
summary: 'List bundled Notis app scaffolds.',
|
|
1703
|
+
when_to_use: 'Discover the fixed scaffold catalog shipped with the CLI before starting a new app.',
|
|
1704
|
+
args_schema: { arguments: [], options: [] },
|
|
1705
|
+
examples: ['notis apps scaffolds list', 'notis apps init "My App" --from notis-database'],
|
|
1706
|
+
mutates: false,
|
|
1707
|
+
idempotent: true,
|
|
1708
|
+
require_auth: false,
|
|
1709
|
+
backend_call: { type: 'local', name: 'list_scaffolds' },
|
|
1710
|
+
handler: appsScaffoldsListHandler,
|
|
1711
|
+
},
|
|
1712
|
+
{
|
|
1713
|
+
command_path: ['apps', 'create'],
|
|
1714
|
+
summary: 'Create a new remote Notis app and optionally link a local project to it.',
|
|
1715
|
+
when_to_use: 'Provision a fresh remote app before the first deploy. Pass a project directory to link it immediately.',
|
|
1716
|
+
args_schema: {
|
|
1717
|
+
arguments: [
|
|
1718
|
+
{ token: '<name>', description: 'Display name for the remote app.' },
|
|
1719
|
+
{ token: '[dir]', key: 'dir', description: 'Project directory to link after creation (default: do not link).' },
|
|
1720
|
+
],
|
|
1721
|
+
options: [],
|
|
1722
|
+
},
|
|
1723
|
+
examples: [
|
|
1724
|
+
'notis apps create "My App"',
|
|
1725
|
+
'notis apps create "My App" .',
|
|
1726
|
+
],
|
|
1727
|
+
mutates: true,
|
|
1728
|
+
idempotent: false,
|
|
1729
|
+
backend_call: { type: 'tool', name: CREATE_APP_TOOL },
|
|
1730
|
+
handler: appsCreateHandler,
|
|
1731
|
+
},
|
|
1732
|
+
{
|
|
1733
|
+
command_path: ['apps', 'dev'],
|
|
1734
|
+
summary: 'Develop Notis apps inside the Electron desktop Portal with automatic local bundle reloads.',
|
|
1735
|
+
when_to_use:
|
|
1736
|
+
'Run this inside a single app or a monorepo root with apps/<name>/notis.config.ts. It discovers every app, starts the local bundle server, registers desktop-local dev sessions, and opens the Electron Portal to the local development app.',
|
|
1737
|
+
args_schema: {
|
|
1738
|
+
arguments: [
|
|
1739
|
+
{ token: '[dir]', key: 'dir', description: 'Project directory or monorepo root (default: current dir).' },
|
|
1740
|
+
],
|
|
1741
|
+
options: [
|
|
1742
|
+
{ flags: '--port <number>', description: `Local bundle server port (default: ${DEFAULT_DEV_PORT}).` },
|
|
1743
|
+
{ flags: '--no-open', description: 'Do not auto-open the desktop Portal local development app.' },
|
|
1744
|
+
],
|
|
1745
|
+
},
|
|
1746
|
+
examples: [
|
|
1747
|
+
'notis apps dev',
|
|
1748
|
+
'notis apps dev ./my-app',
|
|
1749
|
+
'notis apps dev ./workspace --port 5200',
|
|
1750
|
+
],
|
|
1751
|
+
mutates: true,
|
|
1752
|
+
idempotent: true,
|
|
1753
|
+
require_auth: true,
|
|
1754
|
+
backend_call: { type: 'tool', name: ENSURE_DEV_APP_INSTALLATION_TOOL },
|
|
1755
|
+
handler: appsDevHandler,
|
|
1756
|
+
},
|
|
1757
|
+
{
|
|
1758
|
+
command_path: ['apps', 'build'],
|
|
1759
|
+
summary: 'Build and package the app into .notis/output/.',
|
|
1760
|
+
when_to_use: 'Prepare the app for verification or deployment.',
|
|
1761
|
+
args_schema: {
|
|
1762
|
+
arguments: [
|
|
1763
|
+
{ token: '[dir]', key: 'dir', description: 'Project directory (default: current dir).' },
|
|
1764
|
+
],
|
|
1765
|
+
options: [],
|
|
1766
|
+
},
|
|
1767
|
+
examples: ['notis apps build', 'notis apps build ./my-app'],
|
|
1768
|
+
mutates: true,
|
|
1769
|
+
idempotent: true,
|
|
1770
|
+
require_auth: false,
|
|
1771
|
+
backend_call: { type: 'local', name: 'next_build_and_package' },
|
|
1772
|
+
handler: appsBuildHandler,
|
|
1773
|
+
},
|
|
1774
|
+
{
|
|
1775
|
+
command_path: ['apps', 'verify'],
|
|
1776
|
+
summary: 'Validate every route and the production Store listing contract.',
|
|
1777
|
+
when_to_use:
|
|
1778
|
+
'After notis apps screenshot, before deploy. Catches render-time crashes, ' +
|
|
1779
|
+
'missing runtime calls, and incomplete listing media that the build step cannot detect.',
|
|
1780
|
+
args_schema: {
|
|
1781
|
+
arguments: [
|
|
1782
|
+
{ token: '[dir]', key: 'dir', description: 'Project directory (default: current dir).' },
|
|
1783
|
+
],
|
|
1784
|
+
options: [
|
|
1785
|
+
{ flags: '--routes <slugs>', description: 'Comma-separated route slugs. Default: every route in manifest.' },
|
|
1786
|
+
{ flags: '--port <n>', description: 'Loopback port. Default: auto-pick.' },
|
|
1787
|
+
{ flags: '--skip-build', description: 'Skip notis apps build; reuse existing .notis/output/.' },
|
|
1788
|
+
{ flags: '--mode <mode>', description: 'stub | live. Default stub. Live posts to /portal_views/runtime_query with the CLI JWT.' },
|
|
1789
|
+
{ flags: '--no-browser', description: 'Start the harness server and print URLs; do not drive agent-browser.' },
|
|
1790
|
+
{ flags: '--keep-open', description: 'Leave server + browser session running after report (for manual triage).' },
|
|
1791
|
+
],
|
|
1792
|
+
},
|
|
1793
|
+
examples: [
|
|
1794
|
+
'notis apps verify',
|
|
1795
|
+
'notis apps verify --routes notes',
|
|
1796
|
+
'notis apps verify --mode live',
|
|
1797
|
+
'notis apps verify --no-browser # start the harness, drive agent-browser yourself',
|
|
1798
|
+
],
|
|
1799
|
+
mutates: false,
|
|
1800
|
+
idempotent: true,
|
|
1801
|
+
require_auth: false,
|
|
1802
|
+
backend_call: { type: 'local', name: 'verify_harness' },
|
|
1803
|
+
handler: appsVerifyHandler,
|
|
1804
|
+
},
|
|
1805
|
+
{
|
|
1806
|
+
command_path: ['apps', 'screenshot'],
|
|
1807
|
+
summary: 'Capture configured listing route/scenario states via the headless harness.',
|
|
1808
|
+
when_to_use:
|
|
1809
|
+
'Generate the 3–6 declared metadata/screenshot-N.png files for the App Store listing. Apps are ' +
|
|
1810
|
+
'icon-led (like Raycast) — there is no cover image, only these screenshots. ' +
|
|
1811
|
+
'Each screenshot may set a focus selector to remove empty canvas and a light or dark theme that also controls its Store frame. ' +
|
|
1812
|
+
'Run before notis apps verify / deploy / publish.',
|
|
1813
|
+
args_schema: {
|
|
1814
|
+
arguments: [
|
|
1815
|
+
{ token: '[dir]', key: 'dir', description: 'Project directory (default: current dir).' },
|
|
1816
|
+
],
|
|
1817
|
+
options: [
|
|
1818
|
+
{ flags: '--routes <slugs>', description: 'Comma-separated route slugs. Default: every configured screenshot state.' },
|
|
1819
|
+
{ flags: '--port <n>', description: 'Loopback port. Default: auto-pick.' },
|
|
1820
|
+
{ flags: '--width <px>', description: 'Viewport width. Default: 2000.' },
|
|
1821
|
+
{ flags: '--height <px>', description: 'Viewport height. Default: 1250 (16:10).' },
|
|
1822
|
+
{ flags: '--output-dir <dir>', description: 'Where to write screenshot-N.png. Default: metadata/.' },
|
|
1823
|
+
{ flags: '--mode <mode>', description: 'stub | live. Default stub. Live renders against real data via the CLI JWT (requires a linked app), so screenshots show actual content instead of empty states.' },
|
|
1824
|
+
{ flags: '--raw', description: 'Write the unframed harness capture instead of the default Store presentation.' },
|
|
1825
|
+
{ flags: '--skip-build', description: 'Skip notis apps build; reuse existing .notis/output/.' },
|
|
1826
|
+
],
|
|
1827
|
+
},
|
|
1828
|
+
examples: [
|
|
1829
|
+
'notis apps screenshot # honors notis.config.ts screenshot scenarios',
|
|
1830
|
+
'notis apps screenshot --routes home,history',
|
|
1831
|
+
'notis apps screenshot --mode live # populated screenshots from real data',
|
|
1832
|
+
'notis apps screenshot --raw # diagnostic capture without Store framing',
|
|
1833
|
+
],
|
|
1834
|
+
mutates: true,
|
|
1835
|
+
idempotent: true,
|
|
1836
|
+
require_auth: false,
|
|
1837
|
+
backend_call: { type: 'local', name: 'screenshot_routes' },
|
|
1838
|
+
handler: appsScreenshotHandler,
|
|
1839
|
+
},
|
|
1840
|
+
{
|
|
1841
|
+
command_path: ['apps', 'link'],
|
|
1842
|
+
summary: 'Link a local project to a remote Notis app.',
|
|
1843
|
+
when_to_use: 'Connect a local project to an existing app for deployment.',
|
|
1844
|
+
args_schema: {
|
|
1845
|
+
arguments: [
|
|
1846
|
+
{ token: '<app-id>', description: 'Remote app ID to link to.' },
|
|
1847
|
+
{ token: '[dir]', key: 'dir', description: 'Project directory (default: current dir).' },
|
|
1848
|
+
],
|
|
1849
|
+
options: [],
|
|
1850
|
+
},
|
|
1851
|
+
examples: ['notis apps link abc123', 'notis apps link abc123 ./my-app'],
|
|
1852
|
+
mutates: true,
|
|
1853
|
+
idempotent: true,
|
|
1854
|
+
require_auth: false,
|
|
1855
|
+
backend_call: { type: 'local', name: 'write_link_state' },
|
|
1856
|
+
handler: appsLinkHandler,
|
|
1857
|
+
},
|
|
1858
|
+
{
|
|
1859
|
+
command_path: ['apps', 'pull'],
|
|
1860
|
+
summary: 'Download a Notis app source snapshot into a local project folder.',
|
|
1861
|
+
when_to_use:
|
|
1862
|
+
'Edit an installed app locally. Pulls the persisted source, links the directory to the app/version, then continue with npm install, notis apps dev, notis apps build, and notis apps deploy.',
|
|
1863
|
+
args_schema: {
|
|
1864
|
+
arguments: [
|
|
1865
|
+
{ token: '<app-id>', description: 'Remote app ID to pull.' },
|
|
1866
|
+
{ token: '[dir]', key: 'dir', description: 'Target directory (defaults to the app slug).' },
|
|
1867
|
+
],
|
|
1868
|
+
options: [
|
|
1869
|
+
{ flags: '--force', description: 'Overwrite a non-empty target directory.' },
|
|
1870
|
+
{ flags: '--version <n>', description: 'Pull a specific app source version (default: latest).' },
|
|
1871
|
+
],
|
|
1872
|
+
},
|
|
1873
|
+
examples: ['notis apps pull abc123', 'notis apps pull abc123 ./my-app --force'],
|
|
1874
|
+
mutates: true,
|
|
1875
|
+
idempotent: true,
|
|
1876
|
+
require_auth: true,
|
|
1877
|
+
backend_call: { type: 'http', name: 'portal_apps/source' },
|
|
1878
|
+
handler: appsPullHandler,
|
|
1879
|
+
},
|
|
1880
|
+
{
|
|
1881
|
+
command_path: ['apps', 'deploy'],
|
|
1882
|
+
summary: 'Build and upload the app to the linked Notis app.',
|
|
1883
|
+
when_to_use:
|
|
1884
|
+
'Ship the installed app to production for the linked user/team app. Requires a linked app (notis apps link). This command does not publish to the app store.',
|
|
1885
|
+
args_schema: {
|
|
1886
|
+
arguments: [
|
|
1887
|
+
{ token: '[dir]', key: 'dir', description: 'Project directory (default: current dir).' },
|
|
1888
|
+
],
|
|
1889
|
+
options: [
|
|
1890
|
+
{ flags: '--app-id <id>', description: 'Override linked app ID.' },
|
|
1891
|
+
{ flags: '--skip-build', description: 'Skip the build step (use existing .notis/output/).' },
|
|
1892
|
+
{ flags: '--direct', description: 'Upload directly to Supabase storage, bypassing the backend server. Auto-fallback on network errors.' },
|
|
1893
|
+
],
|
|
1894
|
+
},
|
|
1895
|
+
examples: ['notis apps deploy', 'notis apps deploy --skip-build', 'notis apps deploy --app-id abc123', 'notis apps deploy --direct'],
|
|
1896
|
+
mutates: true,
|
|
1897
|
+
idempotent: true,
|
|
1898
|
+
backend_call: { type: 'tool', name: SAVE_APP_FILES_TOOL },
|
|
1899
|
+
handler: appsDeployHandler,
|
|
1900
|
+
},
|
|
1901
|
+
{
|
|
1902
|
+
command_path: ['apps', 'publish'],
|
|
1903
|
+
summary: 'Submit the deployed app for Store review.',
|
|
1904
|
+
when_to_use:
|
|
1905
|
+
'After the user explicitly confirms the App Details page and Store listing are ready. Requires the current local project to match the latest deployed version.',
|
|
1906
|
+
args_schema: {
|
|
1907
|
+
arguments: [
|
|
1908
|
+
{ token: '[dir]', key: 'dir', description: 'Project directory (default: current dir).' },
|
|
1909
|
+
],
|
|
1910
|
+
options: [
|
|
1911
|
+
{ flags: '--app-id <id>', description: 'Override linked app ID.' },
|
|
1912
|
+
{ flags: '--confirm-ready', description: 'Confirm the user approved the current App Details page for Store submission.' },
|
|
1913
|
+
],
|
|
1914
|
+
},
|
|
1915
|
+
examples: ['notis apps publish --confirm-ready', 'notis apps publish ./my-app --confirm-ready'],
|
|
1916
|
+
mutates: true,
|
|
1917
|
+
idempotent: false,
|
|
1918
|
+
require_auth: true,
|
|
1919
|
+
backend_call: { type: 'http', name: 'portal_apps/publish' },
|
|
1920
|
+
handler: appsPublishHandler,
|
|
1921
|
+
},
|
|
1922
|
+
{
|
|
1923
|
+
command_path: ['apps', 'doctor'],
|
|
1924
|
+
summary: 'Check project health and readiness.',
|
|
1925
|
+
when_to_use: 'Diagnose issues with a Notis app project.',
|
|
1926
|
+
args_schema: {
|
|
1927
|
+
arguments: [
|
|
1928
|
+
{ token: '[dir]', key: 'dir', description: 'Project directory (default: current dir).' },
|
|
1929
|
+
],
|
|
1930
|
+
options: [],
|
|
1931
|
+
},
|
|
1932
|
+
examples: ['notis apps doctor', 'notis apps doctor ./my-app'],
|
|
1933
|
+
mutates: false,
|
|
1934
|
+
idempotent: true,
|
|
1935
|
+
require_auth: false,
|
|
1936
|
+
backend_call: { type: 'local', name: 'project_health_check' },
|
|
1937
|
+
handler: appsDoctorHandler,
|
|
1938
|
+
},
|
|
1939
|
+
];
|