@notis_ai/cli 0.2.0-beta.21.1 → 0.2.0-beta.35.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 +74 -112
- package/dist/scaffolds/notes/app/globals.css +3 -0
- package/dist/scaffolds/notes/app/layout.tsx +6 -0
- package/dist/scaffolds/notes/app/page.tsx +1465 -0
- package/dist/scaffolds/notes/components/ui/badge.tsx +28 -0
- package/dist/scaffolds/notes/components/ui/button.tsx +53 -0
- package/dist/scaffolds/notes/components/ui/card.tsx +56 -0
- package/dist/scaffolds/notes/components.json +20 -0
- package/dist/scaffolds/notes/lib/utils.ts +6 -0
- package/dist/scaffolds/notes/notis.config.ts +31 -0
- package/dist/scaffolds/notes/package.json +31 -0
- package/dist/scaffolds/notes/postcss.config.mjs +8 -0
- package/dist/scaffolds/notes/tailwind.config.ts +58 -0
- package/dist/scaffolds/notes/tsconfig.json +22 -0
- package/dist/scaffolds/notes/vite.config.ts +10 -0
- package/dist/scaffolds.json +13 -0
- package/package.json +8 -3
- package/skills/notis-apps/SKILL.md +168 -0
- package/skills/notis-apps/cli.md +208 -0
- package/skills/notis-cli/SKILL.md +263 -0
- package/skills/notis-query/cli.md +40 -0
- package/src/cli.js +1 -1
- package/src/command-specs/apps.js +962 -59
- package/src/command-specs/helpers.js +6 -41
- package/src/command-specs/index.js +1 -7
- package/src/command-specs/meta.js +7 -6
- package/src/command-specs/tools.js +29 -34
- package/src/runtime/agent-browser.js +192 -0
- package/src/runtime/app-boundary-validator.js +132 -0
- package/src/runtime/app-dev-server.js +605 -0
- package/src/runtime/app-dev-sessions.js +87 -0
- package/src/runtime/app-platform.js +725 -38
- package/src/runtime/cli-mode.generated.js +4 -0
- package/src/runtime/cli-mode.js +29 -0
- package/src/runtime/output.js +2 -2
- package/src/runtime/ports.js +15 -0
- package/src/runtime/profiles.js +36 -5
- package/src/runtime/transport.js +131 -6
- package/template/.harness/index.html.tmpl +211 -0
- package/template/app/layout.tsx +1 -2
- package/template/app/page.tsx +41 -6
- package/template/metadata/cover.png +0 -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 +25 -6
- package/template/package.json +3 -4
- package/template/packages/sdk/src/components/MultiSelectActionBar.tsx +273 -0
- package/template/packages/sdk/src/components/MultiSelectCheckbox.tsx +91 -0
- package/template/packages/sdk/src/components/MultiSelectDragOverlay.tsx +39 -0
- package/template/packages/{notis-sdk → sdk}/src/config.ts +25 -2
- package/template/packages/sdk/src/hooks/useMultiSelect.ts +503 -0
- package/template/packages/{notis-sdk → sdk}/src/hooks/useNotis.ts +6 -7
- package/template/packages/{notis-sdk → sdk}/src/hooks/useNotisNavigation.ts +4 -4
- package/template/packages/{notis-sdk → sdk}/src/hooks/useTool.ts +22 -7
- package/template/packages/sdk/src/hooks/useTopBarSearch.ts +73 -0
- package/template/packages/sdk/src/index.ts +46 -0
- package/template/packages/sdk/src/provider.tsx +43 -0
- package/template/packages/sdk/src/runtime.ts +80 -0
- package/template/packages/sdk/src/styles.css +38 -0
- package/src/command-specs/auth.js +0 -178
- package/src/command-specs/db.js +0 -163
- package/src/runtime/app-preview-server.js +0 -336
- package/template/packages/notis-sdk/src/helpers.ts +0 -131
- package/template/packages/notis-sdk/src/hooks/useAppState.ts +0 -50
- package/template/packages/notis-sdk/src/hooks/useCollectionItem.ts +0 -58
- package/template/packages/notis-sdk/src/hooks/useDatabase.ts +0 -87
- package/template/packages/notis-sdk/src/hooks/useDocument.ts +0 -61
- package/template/packages/notis-sdk/src/hooks/useUpsertDocument.ts +0 -58
- package/template/packages/notis-sdk/src/index.ts +0 -47
- package/template/packages/notis-sdk/src/provider.tsx +0 -44
- package/template/packages/notis-sdk/src/runtime.ts +0 -165
- package/template/packages/notis-sdk/src/styles.css +0 -123
- /package/template/packages/{notis-sdk → sdk}/package.json +0 -0
- /package/template/packages/{notis-sdk → sdk}/src/hooks/useBackend.ts +0 -0
- /package/template/packages/{notis-sdk → sdk}/src/hooks/useTools.ts +0 -0
- /package/template/packages/{notis-sdk → sdk}/src/ui.ts +0 -0
- /package/template/packages/{notis-sdk → sdk}/src/vite.ts +0 -0
- /package/template/packages/{notis-sdk → sdk}/tsconfig.json +0 -0
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Notis apps CLI commands.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* init -> dev -> build ->
|
|
4
|
+
* Canonical Notis app workflow:
|
|
5
|
+
* init -> dev -> build -> deploy
|
|
6
6
|
*
|
|
7
7
|
* Supporting commands: list, link, doctor.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
import { randomUUID } from 'node:crypto';
|
|
11
|
+
import { spawn } from 'node:child_process';
|
|
12
|
+
import { existsSync, readdirSync } from 'node:fs';
|
|
13
|
+
import { join, resolve } from 'node:path';
|
|
14
|
+
|
|
10
15
|
import { EXIT_CODES, usageError } from '../runtime/errors.js';
|
|
11
16
|
import { formatTable } from '../runtime/output.js';
|
|
12
17
|
import {
|
|
@@ -20,17 +25,38 @@ import {
|
|
|
20
25
|
writeLinkedState,
|
|
21
26
|
requireLinkedAppId,
|
|
22
27
|
scaffoldProject,
|
|
28
|
+
loadScaffoldCatalog,
|
|
29
|
+
inspectListingReadiness,
|
|
23
30
|
collectArtifactFiles,
|
|
24
|
-
|
|
31
|
+
collectSourceFiles,
|
|
25
32
|
directDeploy,
|
|
33
|
+
pullAppSource,
|
|
26
34
|
} from '../runtime/app-platform.js';
|
|
27
|
-
import {
|
|
35
|
+
import { startAppDevServer } from '../runtime/app-dev-server.js';
|
|
36
|
+
import {
|
|
37
|
+
closeAgentBrowserSession,
|
|
38
|
+
isAgentBrowserAvailable,
|
|
39
|
+
runHarnessRoute,
|
|
40
|
+
} from '../runtime/agent-browser.js';
|
|
41
|
+
import {
|
|
42
|
+
heartbeatAppDevSession,
|
|
43
|
+
removeAppDevSession,
|
|
44
|
+
upsertAppDevSessions,
|
|
45
|
+
} from '../runtime/app-dev-sessions.js';
|
|
46
|
+
import { getAvailablePort } from '../runtime/ports.js';
|
|
47
|
+
import { getCliMode } from '../runtime/cli-mode.js';
|
|
28
48
|
import {
|
|
29
49
|
nextIdempotencyKey,
|
|
30
50
|
runToolCommand,
|
|
31
51
|
toolConflictToError,
|
|
32
52
|
} from './helpers.js';
|
|
33
53
|
|
|
54
|
+
const DEFAULT_DEV_PORT = 5173;
|
|
55
|
+
const DEV_HEARTBEAT_INTERVAL_MS = 10_000;
|
|
56
|
+
const CONFIG_FILENAMES = ['notis.config.ts', 'notis.config.js', 'notis.config.mjs'];
|
|
57
|
+
const ENSURE_DEV_APP_INSTALLATION_TOOL = 'notis-default-ensure_dev_app_installation';
|
|
58
|
+
const GET_APP_TOOL = 'notis-default-get_app';
|
|
59
|
+
|
|
34
60
|
// ---------------------------------------------------------------------------
|
|
35
61
|
// Formatters
|
|
36
62
|
// ---------------------------------------------------------------------------
|
|
@@ -44,6 +70,380 @@ function appsTable(apps) {
|
|
|
44
70
|
]);
|
|
45
71
|
}
|
|
46
72
|
|
|
73
|
+
function scaffoldsTable(scaffolds) {
|
|
74
|
+
return formatTable(scaffolds, [
|
|
75
|
+
{ label: 'Slug', value: (scaffold) => scaffold.slug || '' },
|
|
76
|
+
{ label: 'Name', value: (scaffold) => scaffold.name || scaffold.slug || '' },
|
|
77
|
+
{ label: 'Category', value: (scaffold) => (scaffold.categories || [])[0] || '' },
|
|
78
|
+
{ label: 'Tagline', value: (scaffold) => scaffold.tagline || scaffold.description || '' },
|
|
79
|
+
]);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function hasNotisConfig(dir) {
|
|
83
|
+
return CONFIG_FILENAMES.some((name) => existsSync(join(dir, name)));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function discoverAppDirs(projectDir) {
|
|
87
|
+
if (hasNotisConfig(projectDir)) {
|
|
88
|
+
return [projectDir];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const appsDir = join(projectDir, 'apps');
|
|
92
|
+
if (!existsSync(appsDir)) {
|
|
93
|
+
throw usageError(
|
|
94
|
+
`No notis.config.ts in ${projectDir}. Run from a Notis app project or a monorepo root with apps/<name>/notis.config.ts.`,
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const entries = readdirSync(appsDir, { withFileTypes: true });
|
|
99
|
+
const candidates = [];
|
|
100
|
+
for (const entry of entries) {
|
|
101
|
+
if (!entry.isDirectory()) continue;
|
|
102
|
+
if (entry.name.startsWith('.') || entry.name.startsWith('_')) continue;
|
|
103
|
+
const dir = join(appsDir, entry.name);
|
|
104
|
+
if (hasNotisConfig(dir)) {
|
|
105
|
+
candidates.push(dir);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (candidates.length === 0) {
|
|
110
|
+
throw usageError(
|
|
111
|
+
`Found apps/ in ${projectDir} but no apps/<name>/notis.config.ts inside it.`,
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return candidates;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function decodeJwtSub(jwt) {
|
|
119
|
+
if (!jwt) return null;
|
|
120
|
+
try {
|
|
121
|
+
const parts = jwt.split('.');
|
|
122
|
+
if (parts.length !== 3) return null;
|
|
123
|
+
const decoded = JSON.parse(Buffer.from(parts[1], 'base64url').toString());
|
|
124
|
+
return decoded.sub || decoded.email || null;
|
|
125
|
+
} catch {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function openInBrowser(url) {
|
|
131
|
+
const platform = process.platform;
|
|
132
|
+
let command;
|
|
133
|
+
let args;
|
|
134
|
+
if (platform === 'darwin') {
|
|
135
|
+
command = 'open';
|
|
136
|
+
args = [url];
|
|
137
|
+
} else if (platform === 'win32') {
|
|
138
|
+
command = 'cmd';
|
|
139
|
+
args = ['/c', 'start', '', url];
|
|
140
|
+
} else {
|
|
141
|
+
command = 'xdg-open';
|
|
142
|
+
args = [url];
|
|
143
|
+
}
|
|
144
|
+
try {
|
|
145
|
+
const child = spawn(command, args, { stdio: 'ignore', detached: true });
|
|
146
|
+
child.on('error', () => {});
|
|
147
|
+
child.unref();
|
|
148
|
+
} catch {
|
|
149
|
+
// Non-fatal. The URL is printed in the CLI output.
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function slugify(value) {
|
|
154
|
+
return String(value || '')
|
|
155
|
+
.trim()
|
|
156
|
+
.toLowerCase()
|
|
157
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
158
|
+
.replace(/(^-|-$)+/g, '');
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function buildDevInstallSlug(appName) {
|
|
162
|
+
const base = slugify(appName);
|
|
163
|
+
if (!base) {
|
|
164
|
+
return '';
|
|
165
|
+
}
|
|
166
|
+
return base.endsWith('-dev') ? base : `${base}-dev`;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function timingMs(startedAt) {
|
|
170
|
+
return Number(process.hrtime.bigint() - startedAt) / 1_000_000;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function logAppsTiming(label, details = {}) {
|
|
174
|
+
const suffix = Object.entries(details)
|
|
175
|
+
.map(([key, value]) => `${key}=${value}`)
|
|
176
|
+
.join(' ');
|
|
177
|
+
process.stderr.write(`[notis apps timing] ${label}${suffix ? ` ${suffix}` : ''}\n`);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function nextDevInstallIdempotencyKey(globalOptions = {}, devSlug) {
|
|
181
|
+
if (globalOptions.idempotencyKey) {
|
|
182
|
+
return `${globalOptions.idempotencyKey}:${devSlug}`;
|
|
183
|
+
}
|
|
184
|
+
return nextIdempotencyKey(globalOptions);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function pickDefaultRouteSlug(manifest) {
|
|
188
|
+
const routes = Array.isArray(manifest?.routes) ? manifest.routes : [];
|
|
189
|
+
const explicit = routes.find((route) => route && route.default && typeof route.slug === 'string');
|
|
190
|
+
if (explicit) return explicit.slug;
|
|
191
|
+
const firstWithSlug = routes.find((route) => route && typeof route.slug === 'string' && route.slug);
|
|
192
|
+
return firstWithSlug ? firstWithSlug.slug : null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function buildDevelopmentDesktopUrl(appHref = null) {
|
|
196
|
+
const route = String(appHref || '/store').replace(/^\/+/, '');
|
|
197
|
+
return `notis://${route || 'store'}`;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function buildAppHref({ appSlug, appId, manifest }) {
|
|
201
|
+
const originlessBase = `/apps/${appSlug}-${appId}`;
|
|
202
|
+
const routeSlug = pickDefaultRouteSlug(manifest);
|
|
203
|
+
return routeSlug ? `${originlessBase}/${routeSlug}` : originlessBase;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function parsePort(value) {
|
|
207
|
+
if (!value) return null;
|
|
208
|
+
const port = Number.parseInt(value, 10);
|
|
209
|
+
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
|
|
210
|
+
throw usageError('Port must be between 1 and 65535.');
|
|
211
|
+
}
|
|
212
|
+
return port;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function parseRouteSlugs(value) {
|
|
216
|
+
if (!value) return null;
|
|
217
|
+
const slugs = String(value)
|
|
218
|
+
.split(',')
|
|
219
|
+
.map((entry) => entry.trim())
|
|
220
|
+
.filter(Boolean);
|
|
221
|
+
if (slugs.length === 0) {
|
|
222
|
+
throw usageError('--routes must include at least one route slug.');
|
|
223
|
+
}
|
|
224
|
+
return slugs;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function routeSelection(manifest, rawRouteSlugs) {
|
|
228
|
+
const routes = Array.isArray(manifest?.routes) ? manifest.routes : [];
|
|
229
|
+
if (routes.length === 0) {
|
|
230
|
+
throw usageError('Manifest has no routes to verify.');
|
|
231
|
+
}
|
|
232
|
+
if (!rawRouteSlugs) {
|
|
233
|
+
return routes;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const bySlug = new Map(routes.map((route) => [route.slug, route]));
|
|
237
|
+
const selected = [];
|
|
238
|
+
const missing = [];
|
|
239
|
+
for (const slug of rawRouteSlugs) {
|
|
240
|
+
const route = bySlug.get(slug);
|
|
241
|
+
if (route) {
|
|
242
|
+
selected.push(route);
|
|
243
|
+
} else {
|
|
244
|
+
missing.push(slug);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
if (missing.length) {
|
|
248
|
+
throw usageError(
|
|
249
|
+
`Unknown route slug${missing.length === 1 ? '' : 's'}: ${missing.join(', ')}.`,
|
|
250
|
+
{ available_routes: routes.map((route) => route.slug) },
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
return selected;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function declaredDatabaseSlugs(appConfig, manifest, route) {
|
|
257
|
+
const slugs = new Set();
|
|
258
|
+
for (const entry of appConfig?.databases || manifest?.databases || []) {
|
|
259
|
+
if (typeof entry === 'string' && entry) {
|
|
260
|
+
slugs.add(entry);
|
|
261
|
+
} else if (entry && typeof entry === 'object' && typeof entry.slug === 'string') {
|
|
262
|
+
slugs.add(entry.slug);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
if (route?.collection?.database) {
|
|
266
|
+
slugs.add(route.collection.database);
|
|
267
|
+
}
|
|
268
|
+
return Array.from(slugs);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function harnessErrorMessage(error) {
|
|
272
|
+
if (!error || typeof error !== 'object') {
|
|
273
|
+
return String(error);
|
|
274
|
+
}
|
|
275
|
+
return error.message || error.reason || error.type || JSON.stringify(error);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function assertHarnessResult(result, route, databaseSlugs) {
|
|
279
|
+
const assertions = [];
|
|
280
|
+
if (result.tool_error) {
|
|
281
|
+
assertions.push({
|
|
282
|
+
ok: false,
|
|
283
|
+
code: 'tool_error',
|
|
284
|
+
message: `agent-browser ${result.tool_error.phase || 'command'} failed`,
|
|
285
|
+
details: result.tool_error,
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
if (result.mounted !== true) {
|
|
289
|
+
assertions.push({
|
|
290
|
+
ok: false,
|
|
291
|
+
code: 'not_mounted',
|
|
292
|
+
message: 'Harness did not report mounted === true.',
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
if (result.timed_out) {
|
|
296
|
+
assertions.push({
|
|
297
|
+
ok: false,
|
|
298
|
+
code: 'timeout',
|
|
299
|
+
message: 'Timed out waiting for window.__harness.mounted.',
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
for (const error of result.errors || []) {
|
|
303
|
+
assertions.push({
|
|
304
|
+
ok: false,
|
|
305
|
+
code: 'render_error',
|
|
306
|
+
message: harnessErrorMessage(error),
|
|
307
|
+
details: error,
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
for (const databaseSlug of databaseSlugs) {
|
|
311
|
+
const seen = (result.runtimeCalls || []).some(
|
|
312
|
+
(call) =>
|
|
313
|
+
call?.op === 'callTool' &&
|
|
314
|
+
call?.args?.name === 'notis-default-query' &&
|
|
315
|
+
call?.args?.arguments?.database_slug === databaseSlug,
|
|
316
|
+
);
|
|
317
|
+
if (!seen) {
|
|
318
|
+
assertions.push({
|
|
319
|
+
ok: false,
|
|
320
|
+
code: 'missing_database_query',
|
|
321
|
+
message: `Route "${route.slug}" did not call notis-default-query for "${databaseSlug}".`,
|
|
322
|
+
details: { databaseSlug },
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
return assertions;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function renderVerifyReport({ summary, results, noBrowser }) {
|
|
330
|
+
const lines = [
|
|
331
|
+
noBrowser
|
|
332
|
+
? `Harness URLs ready for ${summary.total} route${summary.total === 1 ? '' : 's'}.`
|
|
333
|
+
: `Verified ${summary.total} route${summary.total === 1 ? '' : 's'}: ${summary.passed} passed, ${summary.failed} failed.`,
|
|
334
|
+
];
|
|
335
|
+
for (const result of results) {
|
|
336
|
+
const marker = result.ok ? 'PASS' : result.status === 'manual' ? 'URL' : 'FAIL';
|
|
337
|
+
lines.push(`${marker.padEnd(4)} ${result.route.padEnd(18)} ${result.url}`);
|
|
338
|
+
for (const assertion of result.assertions || []) {
|
|
339
|
+
lines.push(` - ${assertion.message}`);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
if (noBrowser) {
|
|
343
|
+
lines.push('', 'Pass --keep-open to leave the harness process running while you inspect the URLs.');
|
|
344
|
+
}
|
|
345
|
+
return lines.join('\n');
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function buildManifestForDev(appConfig) {
|
|
349
|
+
const routes = Array.isArray(appConfig.routes) ? appConfig.routes : [];
|
|
350
|
+
return {
|
|
351
|
+
version: 1,
|
|
352
|
+
spec_version: 3,
|
|
353
|
+
app: {
|
|
354
|
+
name: appConfig.name,
|
|
355
|
+
description: appConfig.description || null,
|
|
356
|
+
icon: appConfig.icon || null,
|
|
357
|
+
},
|
|
358
|
+
routes: routes.map((route) => ({
|
|
359
|
+
path: route.path,
|
|
360
|
+
slug: route.slug,
|
|
361
|
+
name: route.name,
|
|
362
|
+
icon: route.icon || null,
|
|
363
|
+
parentSlug: route.parentSlug || null,
|
|
364
|
+
default: route.default || false,
|
|
365
|
+
export_name: route.exportName || route.export_name,
|
|
366
|
+
collection: route.collection || null,
|
|
367
|
+
})),
|
|
368
|
+
bundle: {
|
|
369
|
+
js: 'bundle/app.js',
|
|
370
|
+
css: 'bundle/app.css',
|
|
371
|
+
},
|
|
372
|
+
databases: appConfig.databases || [],
|
|
373
|
+
tools: appConfig.tools || [],
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
export function buildEnsureDevInstallArguments({ appConfig, manifest, linkedState }) {
|
|
378
|
+
const devSlug = buildDevInstallSlug(appConfig.name);
|
|
379
|
+
const arguments_ = {
|
|
380
|
+
dev_slug: devSlug,
|
|
381
|
+
name: appConfig.name,
|
|
382
|
+
manifest,
|
|
383
|
+
};
|
|
384
|
+
if (linkedState?.app_id) {
|
|
385
|
+
arguments_.app_id = linkedState.app_id;
|
|
386
|
+
}
|
|
387
|
+
return arguments_;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
export async function ensureDevInstall({
|
|
391
|
+
ctx,
|
|
392
|
+
appConfig,
|
|
393
|
+
projectDir,
|
|
394
|
+
idempotencyKey,
|
|
395
|
+
runTool = runToolCommand,
|
|
396
|
+
}) {
|
|
397
|
+
if (!appConfig.name) {
|
|
398
|
+
throw usageError(`notis.config.ts in ${projectDir} must define a non-empty name.`);
|
|
399
|
+
}
|
|
400
|
+
const devSlug = buildDevInstallSlug(appConfig.name);
|
|
401
|
+
if (!devSlug) {
|
|
402
|
+
throw usageError(`notis.config.ts name in ${projectDir} must slugify to a non-empty value.`);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const manifest = buildManifestForDev(appConfig);
|
|
406
|
+
const linkedState = readLinkedState(projectDir);
|
|
407
|
+
const ensureArguments = buildEnsureDevInstallArguments({ appConfig, manifest, linkedState });
|
|
408
|
+
const ensureResult = await runTool({
|
|
409
|
+
runtime: ctx.runtime,
|
|
410
|
+
toolName: ENSURE_DEV_APP_INSTALLATION_TOOL,
|
|
411
|
+
arguments_: ensureArguments,
|
|
412
|
+
mutating: true,
|
|
413
|
+
idempotencyKey,
|
|
414
|
+
});
|
|
415
|
+
const appId = ensureResult.payload.app_id;
|
|
416
|
+
if (!appId) {
|
|
417
|
+
throw usageError('Dev installation tool did not return an app_id.');
|
|
418
|
+
}
|
|
419
|
+
return {
|
|
420
|
+
slug: ensureResult.payload.slug || devSlug,
|
|
421
|
+
devSlug,
|
|
422
|
+
name: appConfig.name,
|
|
423
|
+
appId,
|
|
424
|
+
projectDir,
|
|
425
|
+
manifest,
|
|
426
|
+
created: ensureResult.payload.created || false,
|
|
427
|
+
linkedAppId: linkedState?.app_id || null,
|
|
428
|
+
databaseMaterialization: ensureResult.payload.database_materialization || { created: [], unresolved: [] },
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function databaseMaterializationWarnings(apps) {
|
|
433
|
+
const warnings = [];
|
|
434
|
+
for (const app of apps) {
|
|
435
|
+
const unresolved = app.databaseMaterialization?.unresolved || [];
|
|
436
|
+
if (!unresolved.length) {
|
|
437
|
+
continue;
|
|
438
|
+
}
|
|
439
|
+
warnings.push(
|
|
440
|
+
`${app.name}: database slug${unresolved.length === 1 ? '' : 's'} ${unresolved.join(', ')} ` +
|
|
441
|
+
'could not be created because no store snapshot schema exists. Create them manually or link to a store-installed app.',
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
return warnings;
|
|
445
|
+
}
|
|
446
|
+
|
|
47
447
|
async function assertDirectDeployAccess(runtime, appId) {
|
|
48
448
|
const result = await runToolCommand({
|
|
49
449
|
runtime,
|
|
@@ -76,33 +476,46 @@ async function appsListHandler(ctx) {
|
|
|
76
476
|
|
|
77
477
|
async function appsInitHandler(ctx) {
|
|
78
478
|
const projectDir = resolveProjectDir(ctx.args.dir || ctx.args.name.toLowerCase().replace(/[^a-z0-9]+/g, '-'));
|
|
479
|
+
const fromSlug = ctx.options.from || null;
|
|
79
480
|
|
|
80
|
-
scaffoldProject({ projectDir, appName: ctx.args.name });
|
|
481
|
+
scaffoldProject({ projectDir, appName: ctx.args.name, fromSlug });
|
|
81
482
|
|
|
82
483
|
return ctx.output.emitSuccess({
|
|
83
484
|
command: ctx.spec.command_path.join(' '),
|
|
84
|
-
data: { project_dir: projectDir, app_name: ctx.args.name },
|
|
85
|
-
humanSummary:
|
|
485
|
+
data: { project_dir: projectDir, app_name: ctx.args.name, scaffold: fromSlug },
|
|
486
|
+
humanSummary: fromSlug
|
|
487
|
+
? `Scaffolded "${ctx.args.name}" from ${fromSlug} in ${projectDir}`
|
|
488
|
+
: `Scaffolded "${ctx.args.name}" in ${projectDir}`,
|
|
86
489
|
hints: [
|
|
87
490
|
{ command: `cd ${projectDir} && npm install`, reason: 'Install dependencies' },
|
|
88
|
-
{ command:
|
|
491
|
+
{ command: `cd ${projectDir} && notis apps dev`, reason: 'Start the real-portal dev workflow' },
|
|
89
492
|
],
|
|
90
493
|
});
|
|
91
494
|
}
|
|
92
495
|
|
|
496
|
+
async function appsScaffoldsListHandler(ctx) {
|
|
497
|
+
const scaffolds = loadScaffoldCatalog();
|
|
498
|
+
return ctx.output.emitSuccess({
|
|
499
|
+
command: ctx.spec.command_path.join(' '),
|
|
500
|
+
data: { scaffolds },
|
|
501
|
+
humanSummary: scaffolds.length
|
|
502
|
+
? `Found ${scaffolds.length} bundled scaffolds`
|
|
503
|
+
: 'No bundled scaffolds found. Run the CLI build step to generate the scaffold catalog.',
|
|
504
|
+
renderHuman: () => (scaffolds.length ? scaffoldsTable(scaffolds) : 'No bundled scaffolds found.'),
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
|
|
93
508
|
async function appsCreateHandler(ctx) {
|
|
94
509
|
const projectDir = ctx.args.dir ? resolveProjectDir(ctx.args.dir) : null;
|
|
510
|
+
const appConfig = projectDir ? await loadAppConfig(projectDir) : null;
|
|
95
511
|
const idempotencyKey = nextIdempotencyKey(ctx.globalOptions);
|
|
96
|
-
const icon = ctx.options.icon
|
|
97
|
-
? (ctx.options.icon.startsWith('lucide:') ? ctx.options.icon : `lucide:${ctx.options.icon}`)
|
|
98
|
-
: undefined;
|
|
99
512
|
const result = await runToolCommand({
|
|
100
513
|
runtime: ctx.runtime,
|
|
101
514
|
toolName: 'notis_create_app',
|
|
102
515
|
arguments_: {
|
|
103
516
|
name: ctx.args.name,
|
|
104
|
-
description:
|
|
105
|
-
icon,
|
|
517
|
+
description: appConfig?.description || undefined,
|
|
518
|
+
icon: appConfig?.icon || undefined,
|
|
106
519
|
},
|
|
107
520
|
mutating: true,
|
|
108
521
|
idempotencyKey,
|
|
@@ -139,18 +552,199 @@ async function appsCreateHandler(ctx) {
|
|
|
139
552
|
}
|
|
140
553
|
|
|
141
554
|
async function appsDevHandler(ctx) {
|
|
142
|
-
const
|
|
143
|
-
const
|
|
144
|
-
|
|
145
|
-
|
|
555
|
+
const rootDir = resolveProjectDir(ctx.args.dir || '.');
|
|
556
|
+
const appDirs = discoverAppDirs(rootDir);
|
|
557
|
+
|
|
558
|
+
for (const projectDir of appDirs) {
|
|
559
|
+
const problems = detectProjectProblems(projectDir);
|
|
560
|
+
if (problems.length) {
|
|
561
|
+
throw usageError(`Project ${projectDir} has problems:\n${problems.map((p) => ` - ${p}`).join('\n')}`);
|
|
562
|
+
}
|
|
146
563
|
}
|
|
147
564
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
565
|
+
const port = ctx.options.port ? Number.parseInt(ctx.options.port, 10) : DEFAULT_DEV_PORT;
|
|
566
|
+
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
|
|
567
|
+
throw usageError('Port must be between 1 and 65535.');
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
const mode = getCliMode();
|
|
571
|
+
const identity = decodeJwtSub(ctx.runtime.jwt);
|
|
572
|
+
if (!identity) {
|
|
573
|
+
throw usageError('Could not determine the current user from the CLI auth token. Open the Notis desktop app, sign in, and retry.');
|
|
574
|
+
}
|
|
575
|
+
const apiBase = String(ctx.runtime.apiBase || '').replace(/\/$/, '');
|
|
576
|
+
const sessionId = randomUUID();
|
|
577
|
+
|
|
578
|
+
const candidates = [];
|
|
579
|
+
for (const projectDir of appDirs) {
|
|
580
|
+
const appConfig = await loadAppConfig(projectDir);
|
|
581
|
+
if (!appConfig.name) {
|
|
582
|
+
throw usageError(`notis.config.ts in ${projectDir} must define a non-empty name.`);
|
|
583
|
+
}
|
|
584
|
+
const devSlug = buildDevInstallSlug(appConfig.name);
|
|
585
|
+
if (!devSlug) {
|
|
586
|
+
throw usageError(`notis.config.ts name in ${projectDir} must slugify to a non-empty value.`);
|
|
587
|
+
}
|
|
588
|
+
candidates.push({ appConfig, devSlug, projectDir });
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
const seenSlugs = new Map();
|
|
592
|
+
for (const app of candidates) {
|
|
593
|
+
if (seenSlugs.has(app.devSlug)) {
|
|
594
|
+
throw usageError(
|
|
595
|
+
`Multiple apps slugify to "${app.devSlug}" (${seenSlugs.get(app.devSlug).projectDir} and ${app.projectDir}). Rename one in notis.config.ts.`,
|
|
596
|
+
);
|
|
597
|
+
}
|
|
598
|
+
seenSlugs.set(app.devSlug, app);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
const registrationStartedAt = process.hrtime.bigint();
|
|
602
|
+
const registered = await Promise.all(
|
|
603
|
+
candidates.map(async ({ appConfig, devSlug, projectDir }) => {
|
|
604
|
+
const appStartedAt = process.hrtime.bigint();
|
|
605
|
+
try {
|
|
606
|
+
return await ensureDevInstall({
|
|
607
|
+
ctx,
|
|
608
|
+
appConfig,
|
|
609
|
+
projectDir,
|
|
610
|
+
idempotencyKey: nextDevInstallIdempotencyKey(ctx.globalOptions, devSlug),
|
|
611
|
+
});
|
|
612
|
+
} finally {
|
|
613
|
+
logAppsTiming('ensure-dev-install', {
|
|
614
|
+
slug: devSlug,
|
|
615
|
+
ms: timingMs(appStartedAt).toFixed(1),
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
}),
|
|
619
|
+
);
|
|
620
|
+
logAppsTiming('ensure-dev-install:all', {
|
|
621
|
+
apps: registered.length,
|
|
622
|
+
ms: timingMs(registrationStartedAt).toFixed(1),
|
|
152
623
|
});
|
|
153
624
|
|
|
625
|
+
const apps = registered.map((app) => {
|
|
626
|
+
const bundleBaseUrl = `http://127.0.0.1:${port}/a/${app.devSlug}`;
|
|
627
|
+
return {
|
|
628
|
+
...app,
|
|
629
|
+
bundleBaseUrl,
|
|
630
|
+
appHref: buildAppHref({
|
|
631
|
+
appSlug: app.slug,
|
|
632
|
+
appId: app.appId,
|
|
633
|
+
manifest: app.manifest,
|
|
634
|
+
}),
|
|
635
|
+
};
|
|
636
|
+
});
|
|
637
|
+
const developmentTabUrl = buildDevelopmentDesktopUrl(apps[0]?.appHref);
|
|
638
|
+
const warnings = databaseMaterializationWarnings(apps);
|
|
639
|
+
|
|
640
|
+
const devServer = await startAppDevServer({
|
|
641
|
+
apps: apps.map((app) => ({
|
|
642
|
+
slug: app.devSlug,
|
|
643
|
+
projectDir: app.projectDir,
|
|
644
|
+
appId: app.appId,
|
|
645
|
+
userId: identity,
|
|
646
|
+
})),
|
|
647
|
+
port,
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
try {
|
|
651
|
+
const now = new Date().toISOString();
|
|
652
|
+
upsertAppDevSessions(apps.map((app) => ({
|
|
653
|
+
sessionId,
|
|
654
|
+
userId: identity,
|
|
655
|
+
apiBase,
|
|
656
|
+
appId: app.appId,
|
|
657
|
+
devSlug: app.devSlug,
|
|
658
|
+
bundleBaseUrl: app.bundleBaseUrl,
|
|
659
|
+
projectDir: app.projectDir,
|
|
660
|
+
startedAt: now,
|
|
661
|
+
lastHeartbeatAt: now,
|
|
662
|
+
})));
|
|
663
|
+
} catch (error) {
|
|
664
|
+
try {
|
|
665
|
+
await devServer.close();
|
|
666
|
+
} catch {
|
|
667
|
+
// ignore cleanup failure on registry errors
|
|
668
|
+
}
|
|
669
|
+
throw error;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
let heartbeatTimer = setInterval(() => {
|
|
673
|
+
try {
|
|
674
|
+
heartbeatAppDevSession(sessionId, new Date().toISOString());
|
|
675
|
+
} catch (error) {
|
|
676
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
677
|
+
process.stderr.write(`[notis apps dev] heartbeat failed: ${message}\n`);
|
|
678
|
+
}
|
|
679
|
+
}, DEV_HEARTBEAT_INTERVAL_MS);
|
|
680
|
+
|
|
681
|
+
ctx.output.emitSuccess({
|
|
682
|
+
command: ctx.spec.command_path.join(' '),
|
|
683
|
+
data: {
|
|
684
|
+
mode,
|
|
685
|
+
api_base: apiBase,
|
|
686
|
+
development_url: developmentTabUrl,
|
|
687
|
+
session_id: sessionId,
|
|
688
|
+
identity,
|
|
689
|
+
apps: apps.map((app) => ({
|
|
690
|
+
slug: app.devSlug,
|
|
691
|
+
app_id: app.appId,
|
|
692
|
+
name: app.name,
|
|
693
|
+
project_dir: app.projectDir,
|
|
694
|
+
bundle_base_url: app.bundleBaseUrl,
|
|
695
|
+
app_href: app.appHref,
|
|
696
|
+
created: app.created,
|
|
697
|
+
linked_app_id: app.linkedAppId,
|
|
698
|
+
database_materialization: app.databaseMaterialization,
|
|
699
|
+
})),
|
|
700
|
+
},
|
|
701
|
+
warnings,
|
|
702
|
+
humanSummary: [
|
|
703
|
+
`Running apps dev against ${apiBase} as ${identity} (mode: ${mode})`,
|
|
704
|
+
'',
|
|
705
|
+
`Open in desktop: ${developmentTabUrl}`,
|
|
706
|
+
'',
|
|
707
|
+
...apps.map((app) => ` ${app.name.padEnd(24)} ${app.bundleBaseUrl} -> ${app.appHref}`),
|
|
708
|
+
'',
|
|
709
|
+
'Press Ctrl-C to stop.',
|
|
710
|
+
].join('\n'),
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
if (!ctx.options.noOpen) {
|
|
714
|
+
openInBrowser(developmentTabUrl);
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
let shuttingDown = false;
|
|
718
|
+
const shutdown = async (signal) => {
|
|
719
|
+
if (shuttingDown) return;
|
|
720
|
+
shuttingDown = true;
|
|
721
|
+
process.stdout.write(`\n[notis apps dev] stopping (${signal})...\n`);
|
|
722
|
+
if (heartbeatTimer) {
|
|
723
|
+
clearInterval(heartbeatTimer);
|
|
724
|
+
heartbeatTimer = null;
|
|
725
|
+
}
|
|
726
|
+
try {
|
|
727
|
+
removeAppDevSession(sessionId);
|
|
728
|
+
} catch {
|
|
729
|
+
// ignore cleanup failures during shutdown
|
|
730
|
+
}
|
|
731
|
+
try {
|
|
732
|
+
await devServer.close();
|
|
733
|
+
} catch {
|
|
734
|
+
// ignore cleanup failures during shutdown
|
|
735
|
+
}
|
|
736
|
+
process.exit(EXIT_CODES.ok);
|
|
737
|
+
};
|
|
738
|
+
|
|
739
|
+
process.on('SIGINT', () => {
|
|
740
|
+
void shutdown('SIGINT');
|
|
741
|
+
});
|
|
742
|
+
process.on('SIGTERM', () => {
|
|
743
|
+
void shutdown('SIGTERM');
|
|
744
|
+
});
|
|
745
|
+
|
|
746
|
+
await new Promise(() => {});
|
|
747
|
+
|
|
154
748
|
return EXIT_CODES.ok;
|
|
155
749
|
}
|
|
156
750
|
|
|
@@ -170,25 +764,212 @@ async function appsBuildHandler(ctx) {
|
|
|
170
764
|
});
|
|
171
765
|
}
|
|
172
766
|
|
|
173
|
-
async function
|
|
767
|
+
async function appsVerifyHandler(ctx) {
|
|
174
768
|
const projectDir = resolveProjectDir(ctx.args.dir || '.');
|
|
175
|
-
const
|
|
176
|
-
if (
|
|
177
|
-
throw usageError(
|
|
769
|
+
const problems = detectProjectProblems(projectDir);
|
|
770
|
+
if (problems.length) {
|
|
771
|
+
throw usageError(`Project has problems:\n${problems.map((p) => ` - ${p}`).join('\n')}`);
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
const mode = ctx.options.mode || 'stub';
|
|
775
|
+
if (!['stub', 'live'].includes(mode)) {
|
|
776
|
+
throw usageError('--mode must be either "stub" or "live".');
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
let linkedState = null;
|
|
780
|
+
if (mode === 'live') {
|
|
781
|
+
if (!ctx.runtime.jwt) {
|
|
782
|
+
throw usageError('Live verify mode requires CLI auth. Open the Notis desktop app, sign in, and retry.');
|
|
783
|
+
}
|
|
784
|
+
linkedState = readLinkedState(projectDir);
|
|
785
|
+
if (!linkedState?.app_id) {
|
|
786
|
+
throw usageError('Live verify mode requires a linked app. Run `notis apps link <app-id> .` first.');
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
if (!ctx.options.skipBuild) {
|
|
791
|
+
await buildArtifact(projectDir);
|
|
178
792
|
}
|
|
179
793
|
|
|
180
794
|
const manifest = readManifest(projectDir);
|
|
181
|
-
const
|
|
182
|
-
const
|
|
795
|
+
const appConfig = await loadAppConfig(projectDir);
|
|
796
|
+
const listing = inspectListingReadiness(projectDir, appConfig);
|
|
797
|
+
if (listing.errors.length) {
|
|
798
|
+
throw usageError(`Listing metadata has problems:\n${listing.errors.map((error) => ` - ${error}`).join('\n')}`);
|
|
799
|
+
}
|
|
800
|
+
const listingWarnings = listing.warnings;
|
|
801
|
+
const routes = routeSelection(manifest, parseRouteSlugs(ctx.options.routes));
|
|
802
|
+
const port = parsePort(ctx.options.port) || await getAvailablePort();
|
|
803
|
+
const appSlug = slugify(appConfig.name || manifest.app?.name || 'app') || 'app';
|
|
804
|
+
const baseUrl = `http://127.0.0.1:${port}/a/${appSlug}`;
|
|
805
|
+
const browserSessionName = `notis-verify-${process.pid}`;
|
|
806
|
+
const noBrowser = ctx.options.browser === false;
|
|
807
|
+
const keepOpen = Boolean(ctx.options.keepOpen);
|
|
808
|
+
let devServer = null;
|
|
809
|
+
let browserTouched = false;
|
|
183
810
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
811
|
+
try {
|
|
812
|
+
devServer = await startAppDevServer({
|
|
813
|
+
apps: [{
|
|
814
|
+
slug: appSlug,
|
|
815
|
+
projectDir,
|
|
816
|
+
appId: linkedState?.app_id || 'harness-app',
|
|
817
|
+
}],
|
|
818
|
+
port,
|
|
819
|
+
watch: false,
|
|
820
|
+
harness: {
|
|
821
|
+
mode,
|
|
822
|
+
apiBase: ctx.runtime.apiBase,
|
|
823
|
+
jwt: mode === 'live' ? ctx.runtime.jwt : null,
|
|
824
|
+
},
|
|
825
|
+
log: () => {},
|
|
826
|
+
logError: (message) => process.stderr.write(`${message}\n`),
|
|
827
|
+
});
|
|
189
828
|
|
|
190
|
-
|
|
191
|
-
|
|
829
|
+
const urls = routes.map((route) => ({
|
|
830
|
+
route,
|
|
831
|
+
url: `${baseUrl}/harness?route=${encodeURIComponent(route.slug)}`,
|
|
832
|
+
}));
|
|
833
|
+
|
|
834
|
+
let results;
|
|
835
|
+
const warnings = [...listingWarnings];
|
|
836
|
+
if (noBrowser) {
|
|
837
|
+
results = urls.map(({ route, url }) => ({
|
|
838
|
+
route: route.slug,
|
|
839
|
+
path: route.path,
|
|
840
|
+
url,
|
|
841
|
+
ok: true,
|
|
842
|
+
status: 'manual',
|
|
843
|
+
mounted: null,
|
|
844
|
+
errors: [],
|
|
845
|
+
runtimeCalls: [],
|
|
846
|
+
assertions: [],
|
|
847
|
+
snapshot_path: null,
|
|
848
|
+
tool_error: null,
|
|
849
|
+
}));
|
|
850
|
+
} else if (!isAgentBrowserAvailable()) {
|
|
851
|
+
warnings.push('agent-browser is not available on PATH; rerun with --no-browser to inspect harness URLs manually.');
|
|
852
|
+
results = urls.map(({ route, url }) => {
|
|
853
|
+
const toolError = {
|
|
854
|
+
phase: 'available',
|
|
855
|
+
message: 'agent-browser is not available on PATH',
|
|
856
|
+
};
|
|
857
|
+
const result = {
|
|
858
|
+
route: route.slug,
|
|
859
|
+
path: route.path,
|
|
860
|
+
url,
|
|
861
|
+
mounted: false,
|
|
862
|
+
renderStarted: false,
|
|
863
|
+
errors: [],
|
|
864
|
+
runtimeCalls: [],
|
|
865
|
+
snapshotPath: null,
|
|
866
|
+
tool_error: toolError,
|
|
867
|
+
};
|
|
868
|
+
const assertions = assertHarnessResult(
|
|
869
|
+
result,
|
|
870
|
+
route,
|
|
871
|
+
declaredDatabaseSlugs(appConfig, manifest, route),
|
|
872
|
+
);
|
|
873
|
+
return {
|
|
874
|
+
...result,
|
|
875
|
+
ok: false,
|
|
876
|
+
status: 'failed',
|
|
877
|
+
assertions,
|
|
878
|
+
snapshot_path: null,
|
|
879
|
+
};
|
|
880
|
+
});
|
|
881
|
+
} else {
|
|
882
|
+
browserTouched = true;
|
|
883
|
+
results = [];
|
|
884
|
+
for (const { route, url } of urls) {
|
|
885
|
+
const snapshotPath = join(projectDir, '.notis', 'output', '.harness', `${route.slug}.snapshot.txt`);
|
|
886
|
+
const result = await runHarnessRoute({
|
|
887
|
+
url,
|
|
888
|
+
sessionName: browserSessionName,
|
|
889
|
+
timeoutMs: Number.parseInt(ctx.globalOptions.timeoutMs || '', 10) || 10_000,
|
|
890
|
+
snapshotPath,
|
|
891
|
+
});
|
|
892
|
+
const assertions = assertHarnessResult(
|
|
893
|
+
result,
|
|
894
|
+
route,
|
|
895
|
+
declaredDatabaseSlugs(appConfig, manifest, route),
|
|
896
|
+
);
|
|
897
|
+
results.push({
|
|
898
|
+
route: route.slug,
|
|
899
|
+
path: route.path,
|
|
900
|
+
url,
|
|
901
|
+
ok: assertions.length === 0,
|
|
902
|
+
status: assertions.length === 0 ? 'passed' : 'failed',
|
|
903
|
+
mounted: result.mounted,
|
|
904
|
+
renderStarted: result.renderStarted,
|
|
905
|
+
errors: result.errors,
|
|
906
|
+
runtimeCalls: result.runtimeCalls,
|
|
907
|
+
assertions,
|
|
908
|
+
snapshot_path: result.snapshotPath,
|
|
909
|
+
timed_out: Boolean(result.timed_out),
|
|
910
|
+
tool_error: result.tool_error,
|
|
911
|
+
});
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
const summary = {
|
|
916
|
+
total: results.length,
|
|
917
|
+
passed: results.filter((result) => result.status === 'passed').length,
|
|
918
|
+
failed: results.filter((result) => result.status === 'failed').length,
|
|
919
|
+
manual: results.filter((result) => result.status === 'manual').length,
|
|
920
|
+
};
|
|
921
|
+
const overallOk = summary.failed === 0;
|
|
922
|
+
const exitCode = overallOk ? EXIT_CODES.ok : EXIT_CODES.unexpected;
|
|
923
|
+
const data = {
|
|
924
|
+
status: overallOk ? (summary.manual ? 'manual' : 'passed') : 'failed',
|
|
925
|
+
project_dir: projectDir,
|
|
926
|
+
app_slug: appSlug,
|
|
927
|
+
mode,
|
|
928
|
+
browser_session: noBrowser ? null : browserSessionName,
|
|
929
|
+
server: {
|
|
930
|
+
port,
|
|
931
|
+
base_url: baseUrl,
|
|
932
|
+
urls: urls.map(({ route, url }) => ({ route: route.slug, url })),
|
|
933
|
+
},
|
|
934
|
+
summary,
|
|
935
|
+
results,
|
|
936
|
+
};
|
|
937
|
+
|
|
938
|
+
ctx.output.emitSuccess({
|
|
939
|
+
ok: overallOk,
|
|
940
|
+
command: ctx.spec.command_path.join(' '),
|
|
941
|
+
data,
|
|
942
|
+
humanSummary: overallOk
|
|
943
|
+
? (summary.manual ? `Harness URLs ready for ${summary.manual} routes.` : `Verified ${summary.passed} routes successfully.`)
|
|
944
|
+
: `Verification failed for ${summary.failed} routes.`,
|
|
945
|
+
warnings,
|
|
946
|
+
renderHuman: () => renderVerifyReport({ summary, results, noBrowser }),
|
|
947
|
+
});
|
|
948
|
+
|
|
949
|
+
if (keepOpen) {
|
|
950
|
+
process.stderr.write(`[notis apps verify] harness open at ${baseUrl}. Press Ctrl-C to stop.\n`);
|
|
951
|
+
await new Promise(() => {});
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
return exitCode;
|
|
955
|
+
} finally {
|
|
956
|
+
if (!keepOpen) {
|
|
957
|
+
if (browserTouched) {
|
|
958
|
+
try {
|
|
959
|
+
await closeAgentBrowserSession(browserSessionName);
|
|
960
|
+
} catch {
|
|
961
|
+
// Ignore browser cleanup failures.
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
if (devServer) {
|
|
965
|
+
try {
|
|
966
|
+
await devServer.close();
|
|
967
|
+
} catch {
|
|
968
|
+
// Ignore server cleanup failures.
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
}
|
|
192
973
|
}
|
|
193
974
|
|
|
194
975
|
async function appsLinkHandler(ctx) {
|
|
@@ -210,10 +991,60 @@ async function appsLinkHandler(ctx) {
|
|
|
210
991
|
});
|
|
211
992
|
}
|
|
212
993
|
|
|
994
|
+
async function appsPullHandler(ctx) {
|
|
995
|
+
const appId = ctx.args.appId;
|
|
996
|
+
const result = await runToolCommand({
|
|
997
|
+
runtime: ctx.runtime,
|
|
998
|
+
toolName: GET_APP_TOOL,
|
|
999
|
+
arguments_: { app_id: appId },
|
|
1000
|
+
});
|
|
1001
|
+
const app = result.payload?.app || {};
|
|
1002
|
+
const defaultDir = app.slug || slugify(app.name) || appId;
|
|
1003
|
+
const targetDir = resolveProjectDir(ctx.args.dir || defaultDir);
|
|
1004
|
+
const version = ctx.options.version || 'latest';
|
|
1005
|
+
|
|
1006
|
+
const pulled = await pullAppSource({
|
|
1007
|
+
apiBase: ctx.runtime.apiBase,
|
|
1008
|
+
jwt: ctx.runtime.jwt,
|
|
1009
|
+
appId,
|
|
1010
|
+
targetDir,
|
|
1011
|
+
version,
|
|
1012
|
+
force: Boolean(ctx.options.force),
|
|
1013
|
+
});
|
|
1014
|
+
|
|
1015
|
+
const versionLabel = pulled.version === 'latest' ? 'latest version' : `v${pulled.version}`;
|
|
1016
|
+
return ctx.output.emitSuccess({
|
|
1017
|
+
command: ctx.spec.command_path.join(' '),
|
|
1018
|
+
data: {
|
|
1019
|
+
app_id: appId,
|
|
1020
|
+
project_dir: pulled.projectDir,
|
|
1021
|
+
version: pulled.version,
|
|
1022
|
+
},
|
|
1023
|
+
humanSummary: `Pulled ${versionLabel} to ${pulled.projectDir}. Run \`cd ${pulled.projectDir} && npm install && notis apps dev\` to start editing.`,
|
|
1024
|
+
});
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
function updateLinkedDeployState(projectDir, linkedState, appId, version) {
|
|
1028
|
+
if (!linkedState || linkedState.app_id !== appId || !Number.isFinite(version)) {
|
|
1029
|
+
return;
|
|
1030
|
+
}
|
|
1031
|
+
writeLinkedState(projectDir, {
|
|
1032
|
+
...linkedState,
|
|
1033
|
+
app_id: appId,
|
|
1034
|
+
version,
|
|
1035
|
+
linked_at: linkedState.linked_at || new Date().toISOString(),
|
|
1036
|
+
deployed_at: new Date().toISOString(),
|
|
1037
|
+
});
|
|
1038
|
+
}
|
|
1039
|
+
|
|
213
1040
|
async function appsDeployHandler(ctx) {
|
|
214
1041
|
const projectDir = resolveProjectDir(ctx.args.dir || '.');
|
|
215
1042
|
const appId = requireLinkedAppId(projectDir, ctx.options.appId);
|
|
216
1043
|
const idempotencyKey = nextIdempotencyKey(ctx.globalOptions);
|
|
1044
|
+
const linkedState = readLinkedState(projectDir);
|
|
1045
|
+
const baseVersion = linkedState?.app_id === appId && Number.isFinite(linkedState?.version)
|
|
1046
|
+
? linkedState.version
|
|
1047
|
+
: undefined;
|
|
217
1048
|
|
|
218
1049
|
// Build if needed
|
|
219
1050
|
if (!ctx.options.skipBuild) {
|
|
@@ -224,6 +1055,7 @@ async function appsDeployHandler(ctx) {
|
|
|
224
1055
|
if (ctx.options.direct) {
|
|
225
1056
|
await assertDirectDeployAccess(ctx.runtime, appId);
|
|
226
1057
|
const { version } = await directDeploy(projectDir, appId);
|
|
1058
|
+
updateLinkedDeployState(projectDir, linkedState, appId, version);
|
|
227
1059
|
return ctx.output.emitSuccess({
|
|
228
1060
|
command: ctx.spec.command_path.join(' '),
|
|
229
1061
|
data: { app_id: appId, version, mode: 'direct' },
|
|
@@ -234,6 +1066,7 @@ async function appsDeployHandler(ctx) {
|
|
|
234
1066
|
|
|
235
1067
|
// Standard deploy via backend server, with auto-fallback to direct
|
|
236
1068
|
const files = collectArtifactFiles(projectDir);
|
|
1069
|
+
const sourceFiles = collectSourceFiles(projectDir);
|
|
237
1070
|
const manifest = readManifest(projectDir);
|
|
238
1071
|
|
|
239
1072
|
let result;
|
|
@@ -244,7 +1077,9 @@ async function appsDeployHandler(ctx) {
|
|
|
244
1077
|
arguments_: {
|
|
245
1078
|
app_id: appId,
|
|
246
1079
|
files,
|
|
1080
|
+
source_files: sourceFiles,
|
|
247
1081
|
manifest,
|
|
1082
|
+
...(baseVersion !== undefined ? { base_version: baseVersion } : {}),
|
|
248
1083
|
},
|
|
249
1084
|
mutating: true,
|
|
250
1085
|
idempotencyKey,
|
|
@@ -271,6 +1106,7 @@ async function appsDeployHandler(ctx) {
|
|
|
271
1106
|
|
|
272
1107
|
try {
|
|
273
1108
|
const { version } = await directDeploy(projectDir, appId);
|
|
1109
|
+
updateLinkedDeployState(projectDir, linkedState, appId, version);
|
|
274
1110
|
return ctx.output.emitSuccess({
|
|
275
1111
|
command: ctx.spec.command_path.join(' '),
|
|
276
1112
|
data: { app_id: appId, version, mode: 'direct-fallback' },
|
|
@@ -289,6 +1125,7 @@ async function appsDeployHandler(ctx) {
|
|
|
289
1125
|
throw error;
|
|
290
1126
|
}
|
|
291
1127
|
|
|
1128
|
+
updateLinkedDeployState(projectDir, linkedState, appId, Number(result.payload.version));
|
|
292
1129
|
return ctx.output.emitSuccess({
|
|
293
1130
|
command: ctx.spec.command_path.join(' '),
|
|
294
1131
|
data: {
|
|
@@ -312,13 +1149,21 @@ async function appsDoctorHandler(ctx) {
|
|
|
312
1149
|
problems.push('Failed to load notis.config.ts');
|
|
313
1150
|
}
|
|
314
1151
|
const warnings = detectProjectWarnings(projectDir, appConfig);
|
|
1152
|
+
let listing = null;
|
|
1153
|
+
if (appConfig) {
|
|
1154
|
+
try {
|
|
1155
|
+
listing = inspectListingReadiness(projectDir, appConfig);
|
|
1156
|
+
} catch (error) {
|
|
1157
|
+
warnings.push(error instanceof Error ? error.message : String(error));
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
315
1160
|
|
|
316
1161
|
const linkedState = readLinkedState(projectDir);
|
|
317
1162
|
const status = problems.length ? 'unhealthy' : warnings.length ? 'warnings' : 'healthy';
|
|
318
1163
|
|
|
319
1164
|
return ctx.output.emitSuccess({
|
|
320
1165
|
command: ctx.spec.command_path.join(' '),
|
|
321
|
-
data: { status, problems, warnings, linked: linkedState, config: appConfig },
|
|
1166
|
+
data: { status, problems, warnings, linked: linkedState, config: appConfig, listing },
|
|
322
1167
|
humanSummary: problems.length
|
|
323
1168
|
? `Found ${problems.length} problems:\n${problems.map((p) => ` - ${p}`).join('\n')}`
|
|
324
1169
|
: warnings.length
|
|
@@ -346,21 +1191,40 @@ export const appsCommandSpecs = [
|
|
|
346
1191
|
{
|
|
347
1192
|
command_path: ['apps', 'init'],
|
|
348
1193
|
summary: 'Scaffold a new Notis app project.',
|
|
349
|
-
when_to_use: 'Start a new Notis app.
|
|
1194
|
+
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.',
|
|
350
1195
|
args_schema: {
|
|
351
1196
|
arguments: [
|
|
352
1197
|
{ token: '<name>', description: 'Display name for the app.' },
|
|
353
1198
|
{ token: '[dir]', key: 'dir', description: 'Target directory (defaults to kebab-case of name).' },
|
|
354
1199
|
],
|
|
355
|
-
options: [
|
|
1200
|
+
options: [
|
|
1201
|
+
{ flags: '--from <slug>', description: 'Start from a bundled scaffold listed by `notis apps scaffolds list`.' },
|
|
1202
|
+
],
|
|
356
1203
|
},
|
|
357
|
-
examples: [
|
|
1204
|
+
examples: [
|
|
1205
|
+
'notis apps scaffolds list',
|
|
1206
|
+
'notis apps init "Mind the Flo"',
|
|
1207
|
+
'notis apps init "My CRM" --from notis-database',
|
|
1208
|
+
'notis apps init "My App" ./my-app',
|
|
1209
|
+
],
|
|
358
1210
|
mutates: true,
|
|
359
1211
|
idempotent: false,
|
|
360
1212
|
require_auth: false,
|
|
361
1213
|
backend_call: { type: 'local', name: 'scaffold_project' },
|
|
362
1214
|
handler: appsInitHandler,
|
|
363
1215
|
},
|
|
1216
|
+
{
|
|
1217
|
+
command_path: ['apps', 'scaffolds', 'list'],
|
|
1218
|
+
summary: 'List bundled Notis app scaffolds.',
|
|
1219
|
+
when_to_use: 'Discover the fixed scaffold catalog shipped with the CLI before starting a new app.',
|
|
1220
|
+
args_schema: { arguments: [], options: [] },
|
|
1221
|
+
examples: ['notis apps scaffolds list', 'notis apps init "My App" --from notis-database'],
|
|
1222
|
+
mutates: false,
|
|
1223
|
+
idempotent: true,
|
|
1224
|
+
require_auth: false,
|
|
1225
|
+
backend_call: { type: 'local', name: 'list_scaffolds' },
|
|
1226
|
+
handler: appsScaffoldsListHandler,
|
|
1227
|
+
},
|
|
364
1228
|
{
|
|
365
1229
|
command_path: ['apps', 'create'],
|
|
366
1230
|
summary: 'Create a new remote Notis app and optionally link a local project to it.',
|
|
@@ -370,14 +1234,11 @@ export const appsCommandSpecs = [
|
|
|
370
1234
|
{ token: '<name>', description: 'Display name for the remote app.' },
|
|
371
1235
|
{ token: '[dir]', key: 'dir', description: 'Project directory to link after creation (default: do not link).' },
|
|
372
1236
|
],
|
|
373
|
-
options: [
|
|
374
|
-
{ flags: '--description <text>', description: 'Optional app description.' },
|
|
375
|
-
{ flags: '--icon <lucide:icon>', description: 'Optional Lucide icon, for example lucide:dices.' },
|
|
376
|
-
],
|
|
1237
|
+
options: [],
|
|
377
1238
|
},
|
|
378
1239
|
examples: [
|
|
379
1240
|
'notis apps create "My App"',
|
|
380
|
-
'notis apps create "My App" .
|
|
1241
|
+
'notis apps create "My App" .',
|
|
381
1242
|
],
|
|
382
1243
|
mutates: true,
|
|
383
1244
|
idempotent: false,
|
|
@@ -386,25 +1247,33 @@ export const appsCommandSpecs = [
|
|
|
386
1247
|
},
|
|
387
1248
|
{
|
|
388
1249
|
command_path: ['apps', 'dev'],
|
|
389
|
-
summary: '
|
|
390
|
-
when_to_use:
|
|
1250
|
+
summary: 'Develop Notis apps inside the Electron desktop Portal with automatic local bundle reloads.',
|
|
1251
|
+
when_to_use:
|
|
1252
|
+
'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.',
|
|
391
1253
|
args_schema: {
|
|
392
1254
|
arguments: [
|
|
393
|
-
{ token: '[dir]', key: 'dir', description: 'Project directory (default: current dir).' },
|
|
1255
|
+
{ token: '[dir]', key: 'dir', description: 'Project directory or monorepo root (default: current dir).' },
|
|
1256
|
+
],
|
|
1257
|
+
options: [
|
|
1258
|
+
{ flags: '--port <number>', description: `Local bundle server port (default: ${DEFAULT_DEV_PORT}).` },
|
|
1259
|
+
{ flags: '--no-open', description: 'Do not auto-open the desktop Portal local development app.' },
|
|
394
1260
|
],
|
|
395
|
-
options: [],
|
|
396
1261
|
},
|
|
397
|
-
examples: [
|
|
398
|
-
|
|
1262
|
+
examples: [
|
|
1263
|
+
'notis apps dev',
|
|
1264
|
+
'notis apps dev ./my-app',
|
|
1265
|
+
'notis apps dev ./workspace --port 5200',
|
|
1266
|
+
],
|
|
1267
|
+
mutates: true,
|
|
399
1268
|
idempotent: true,
|
|
400
|
-
require_auth:
|
|
401
|
-
backend_call: { type: '
|
|
1269
|
+
require_auth: true,
|
|
1270
|
+
backend_call: { type: 'tool', name: ENSURE_DEV_APP_INSTALLATION_TOOL },
|
|
402
1271
|
handler: appsDevHandler,
|
|
403
1272
|
},
|
|
404
1273
|
{
|
|
405
1274
|
command_path: ['apps', 'build'],
|
|
406
1275
|
summary: 'Build and package the app into .notis/output/.',
|
|
407
|
-
when_to_use: 'Prepare the app for
|
|
1276
|
+
when_to_use: 'Prepare the app for verification or deployment.',
|
|
408
1277
|
args_schema: {
|
|
409
1278
|
arguments: [
|
|
410
1279
|
{ token: '[dir]', key: 'dir', description: 'Project directory (default: current dir).' },
|
|
@@ -419,23 +1288,35 @@ export const appsCommandSpecs = [
|
|
|
419
1288
|
handler: appsBuildHandler,
|
|
420
1289
|
},
|
|
421
1290
|
{
|
|
422
|
-
command_path: ['apps', '
|
|
423
|
-
summary: '
|
|
424
|
-
when_to_use:
|
|
1291
|
+
command_path: ['apps', 'verify'],
|
|
1292
|
+
summary: 'Headless render smoke each route in a stub-runtime harness.',
|
|
1293
|
+
when_to_use:
|
|
1294
|
+
'After notis apps build, before deploy. Catches render-time crashes ' +
|
|
1295
|
+
'and missing runtime calls that the build step cannot detect.',
|
|
425
1296
|
args_schema: {
|
|
426
1297
|
arguments: [
|
|
427
1298
|
{ token: '[dir]', key: 'dir', description: 'Project directory (default: current dir).' },
|
|
428
1299
|
],
|
|
429
1300
|
options: [
|
|
430
|
-
{ flags: '--
|
|
1301
|
+
{ flags: '--routes <slugs>', description: 'Comma-separated route slugs. Default: every route in manifest.' },
|
|
1302
|
+
{ flags: '--port <n>', description: 'Loopback port. Default: auto-pick.' },
|
|
1303
|
+
{ flags: '--skip-build', description: 'Skip notis apps build; reuse existing .notis/output/.' },
|
|
1304
|
+
{ flags: '--mode <mode>', description: 'stub | live. Default stub. Live posts to /portal_views/runtime_query with the CLI JWT.' },
|
|
1305
|
+
{ flags: '--no-browser', description: 'Start the harness server and print URLs; do not drive agent-browser.' },
|
|
1306
|
+
{ flags: '--keep-open', description: 'Leave server + browser session running after report (for manual triage).' },
|
|
431
1307
|
],
|
|
432
1308
|
},
|
|
433
|
-
examples: [
|
|
1309
|
+
examples: [
|
|
1310
|
+
'notis apps verify',
|
|
1311
|
+
'notis apps verify --routes notes',
|
|
1312
|
+
'notis apps verify --mode live',
|
|
1313
|
+
'notis apps verify --no-browser # start the harness, drive agent-browser yourself',
|
|
1314
|
+
],
|
|
434
1315
|
mutates: false,
|
|
435
1316
|
idempotent: true,
|
|
436
1317
|
require_auth: false,
|
|
437
|
-
backend_call: { type: 'local', name: '
|
|
438
|
-
handler:
|
|
1318
|
+
backend_call: { type: 'local', name: 'verify_harness' },
|
|
1319
|
+
handler: appsVerifyHandler,
|
|
439
1320
|
},
|
|
440
1321
|
{
|
|
441
1322
|
command_path: ['apps', 'link'],
|
|
@@ -455,6 +1336,28 @@ export const appsCommandSpecs = [
|
|
|
455
1336
|
backend_call: { type: 'local', name: 'write_link_state' },
|
|
456
1337
|
handler: appsLinkHandler,
|
|
457
1338
|
},
|
|
1339
|
+
{
|
|
1340
|
+
command_path: ['apps', 'pull'],
|
|
1341
|
+
summary: 'Download a Notis app source snapshot into a local project folder.',
|
|
1342
|
+
when_to_use:
|
|
1343
|
+
'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.',
|
|
1344
|
+
args_schema: {
|
|
1345
|
+
arguments: [
|
|
1346
|
+
{ token: '<app-id>', description: 'Remote app ID to pull.' },
|
|
1347
|
+
{ token: '[dir]', key: 'dir', description: 'Target directory (defaults to the app slug).' },
|
|
1348
|
+
],
|
|
1349
|
+
options: [
|
|
1350
|
+
{ flags: '--force', description: 'Overwrite a non-empty target directory.' },
|
|
1351
|
+
{ flags: '--version <n>', description: 'Pull a specific app source version (default: latest).' },
|
|
1352
|
+
],
|
|
1353
|
+
},
|
|
1354
|
+
examples: ['notis apps pull abc123', 'notis apps pull abc123 ./my-app --force'],
|
|
1355
|
+
mutates: true,
|
|
1356
|
+
idempotent: true,
|
|
1357
|
+
require_auth: true,
|
|
1358
|
+
backend_call: { type: 'http', name: 'portal_apps/source' },
|
|
1359
|
+
handler: appsPullHandler,
|
|
1360
|
+
},
|
|
458
1361
|
{
|
|
459
1362
|
command_path: ['apps', 'deploy'],
|
|
460
1363
|
summary: 'Build and upload the app to the linked Notis app.',
|