@notis_ai/cli 0.2.0 → 0.2.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 +39 -10
- package/package.json +7 -1
- package/src/command-specs/apps.js +852 -47
- package/src/command-specs/helpers.js +28 -3
- package/src/command-specs/index.js +1 -1
- package/src/command-specs/tools.js +33 -6
- package/src/runtime/agent-browser.js +192 -0
- package/src/runtime/app-boundary-validator.js +132 -0
- package/src/runtime/app-dev-server.js +577 -0
- package/src/runtime/app-dev-sessions.js +87 -0
- package/src/runtime/app-platform.js +646 -71
- 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 +34 -3
- package/src/runtime/transport.js +129 -4
- package/template/.harness/index.html.tmpl +260 -0
- package/template/app/globals.css +3 -0
- package/template/app/layout.tsx +6 -0
- package/template/app/page.tsx +55 -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/notis.config.ts +33 -0
- package/template/package.json +31 -0
- package/template/packages/notis-sdk/package.json +26 -0
- package/template/packages/notis-sdk/src/components/MultiSelectActionBar.tsx +272 -0
- package/template/packages/notis-sdk/src/components/MultiSelectCheckbox.tsx +91 -0
- package/template/packages/notis-sdk/src/components/MultiSelectDragOverlay.tsx +39 -0
- package/template/packages/notis-sdk/src/config.ts +71 -0
- package/template/packages/notis-sdk/src/helpers.ts +131 -0
- package/template/packages/notis-sdk/src/hooks/useAppState.ts +50 -0
- package/template/packages/notis-sdk/src/hooks/useBackend.ts +41 -0
- package/template/packages/notis-sdk/src/hooks/useCollectionItem.ts +58 -0
- package/template/packages/notis-sdk/src/hooks/useDatabase.ts +88 -0
- package/template/packages/notis-sdk/src/hooks/useDocument.ts +61 -0
- package/template/packages/notis-sdk/src/hooks/useMultiSelect.ts +502 -0
- package/template/packages/notis-sdk/src/hooks/useNotis.ts +33 -0
- package/template/packages/notis-sdk/src/hooks/useNotisNavigation.ts +49 -0
- package/template/packages/notis-sdk/src/hooks/useTool.ts +49 -0
- package/template/packages/notis-sdk/src/hooks/useTools.ts +56 -0
- package/template/packages/notis-sdk/src/hooks/useTopBarSearch.ts +73 -0
- package/template/packages/notis-sdk/src/hooks/useUpsertDocument.ts +58 -0
- package/template/packages/notis-sdk/src/index.ts +67 -0
- package/template/packages/notis-sdk/src/provider.tsx +43 -0
- package/template/packages/notis-sdk/src/runtime.ts +182 -0
- package/template/packages/notis-sdk/src/styles.css +38 -0
- package/template/packages/notis-sdk/src/ui.ts +15 -0
- package/template/packages/notis-sdk/src/vite.ts +56 -0
- package/template/packages/notis-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
- package/src/runtime/app-preview-server.js +0 -272
|
@@ -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 {
|
|
@@ -21,15 +26,35 @@ import {
|
|
|
21
26
|
requireLinkedAppId,
|
|
22
27
|
scaffoldProject,
|
|
23
28
|
collectArtifactFiles,
|
|
24
|
-
|
|
29
|
+
collectSourceFiles,
|
|
30
|
+
directDeploy,
|
|
31
|
+
pullAppSource,
|
|
25
32
|
} from '../runtime/app-platform.js';
|
|
26
|
-
import {
|
|
33
|
+
import { startAppDevServer } from '../runtime/app-dev-server.js';
|
|
34
|
+
import {
|
|
35
|
+
closeAgentBrowserSession,
|
|
36
|
+
isAgentBrowserAvailable,
|
|
37
|
+
runHarnessRoute,
|
|
38
|
+
} from '../runtime/agent-browser.js';
|
|
39
|
+
import {
|
|
40
|
+
heartbeatAppDevSession,
|
|
41
|
+
removeAppDevSession,
|
|
42
|
+
upsertAppDevSessions,
|
|
43
|
+
} from '../runtime/app-dev-sessions.js';
|
|
44
|
+
import { getAvailablePort } from '../runtime/ports.js';
|
|
45
|
+
import { getCliMode } from '../runtime/cli-mode.js';
|
|
27
46
|
import {
|
|
28
47
|
nextIdempotencyKey,
|
|
29
48
|
runToolCommand,
|
|
30
49
|
toolConflictToError,
|
|
31
50
|
} from './helpers.js';
|
|
32
51
|
|
|
52
|
+
const DEFAULT_DEV_PORT = 5173;
|
|
53
|
+
const DEV_HEARTBEAT_INTERVAL_MS = 10_000;
|
|
54
|
+
const CONFIG_FILENAMES = ['notis.config.ts', 'notis.config.js', 'notis.config.mjs'];
|
|
55
|
+
const ENSURE_DEV_APP_INSTALLATION_TOOL = 'notis-default-ensure_dev_app_installation';
|
|
56
|
+
const GET_APP_TOOL = 'notis-default-get_app';
|
|
57
|
+
|
|
33
58
|
// ---------------------------------------------------------------------------
|
|
34
59
|
// Formatters
|
|
35
60
|
// ---------------------------------------------------------------------------
|
|
@@ -43,6 +68,327 @@ function appsTable(apps) {
|
|
|
43
68
|
]);
|
|
44
69
|
}
|
|
45
70
|
|
|
71
|
+
function hasNotisConfig(dir) {
|
|
72
|
+
return CONFIG_FILENAMES.some((name) => existsSync(join(dir, name)));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function discoverAppDirs(projectDir) {
|
|
76
|
+
if (hasNotisConfig(projectDir)) {
|
|
77
|
+
return [projectDir];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const appsDir = join(projectDir, 'apps');
|
|
81
|
+
if (!existsSync(appsDir)) {
|
|
82
|
+
throw usageError(
|
|
83
|
+
`No notis.config.ts in ${projectDir}. Run from a Notis app project or a monorepo root with apps/<name>/notis.config.ts.`,
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const entries = readdirSync(appsDir, { withFileTypes: true });
|
|
88
|
+
const candidates = [];
|
|
89
|
+
for (const entry of entries) {
|
|
90
|
+
if (!entry.isDirectory()) continue;
|
|
91
|
+
if (entry.name.startsWith('.') || entry.name.startsWith('_')) continue;
|
|
92
|
+
const dir = join(appsDir, entry.name);
|
|
93
|
+
if (hasNotisConfig(dir)) {
|
|
94
|
+
candidates.push(dir);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (candidates.length === 0) {
|
|
99
|
+
throw usageError(
|
|
100
|
+
`Found apps/ in ${projectDir} but no apps/<name>/notis.config.ts inside it.`,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return candidates;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function decodeJwtSub(jwt) {
|
|
108
|
+
if (!jwt) return null;
|
|
109
|
+
try {
|
|
110
|
+
const parts = jwt.split('.');
|
|
111
|
+
if (parts.length !== 3) return null;
|
|
112
|
+
const decoded = JSON.parse(Buffer.from(parts[1], 'base64url').toString());
|
|
113
|
+
return decoded.sub || decoded.email || null;
|
|
114
|
+
} catch {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function openInBrowser(url) {
|
|
120
|
+
const platform = process.platform;
|
|
121
|
+
let command;
|
|
122
|
+
let args;
|
|
123
|
+
if (platform === 'darwin') {
|
|
124
|
+
command = 'open';
|
|
125
|
+
args = [url];
|
|
126
|
+
} else if (platform === 'win32') {
|
|
127
|
+
command = 'cmd';
|
|
128
|
+
args = ['/c', 'start', '', url];
|
|
129
|
+
} else {
|
|
130
|
+
command = 'xdg-open';
|
|
131
|
+
args = [url];
|
|
132
|
+
}
|
|
133
|
+
try {
|
|
134
|
+
const child = spawn(command, args, { stdio: 'ignore', detached: true });
|
|
135
|
+
child.on('error', () => {});
|
|
136
|
+
child.unref();
|
|
137
|
+
} catch {
|
|
138
|
+
// Non-fatal. The URL is printed in the CLI output.
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function slugify(value) {
|
|
143
|
+
return String(value || '')
|
|
144
|
+
.trim()
|
|
145
|
+
.toLowerCase()
|
|
146
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
147
|
+
.replace(/(^-|-$)+/g, '');
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function buildDevInstallSlug(appName) {
|
|
151
|
+
const base = slugify(appName);
|
|
152
|
+
if (!base) {
|
|
153
|
+
return '';
|
|
154
|
+
}
|
|
155
|
+
return base.endsWith('-dev') ? base : `${base}-dev`;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function pickDefaultRouteSlug(manifest) {
|
|
159
|
+
const routes = Array.isArray(manifest?.routes) ? manifest.routes : [];
|
|
160
|
+
const explicit = routes.find((route) => route && route.default && typeof route.slug === 'string');
|
|
161
|
+
if (explicit) return explicit.slug;
|
|
162
|
+
const firstWithSlug = routes.find((route) => route && typeof route.slug === 'string' && route.slug);
|
|
163
|
+
return firstWithSlug ? firstWithSlug.slug : null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function buildDevelopmentDesktopUrl() {
|
|
167
|
+
return 'notis://apps?tab=development';
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function buildAppHref({ appSlug, appId, manifest }) {
|
|
171
|
+
const originlessBase = `/apps/${appSlug}-${appId}`;
|
|
172
|
+
const routeSlug = pickDefaultRouteSlug(manifest);
|
|
173
|
+
return routeSlug ? `${originlessBase}/${routeSlug}` : originlessBase;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function parsePort(value) {
|
|
177
|
+
if (!value) return null;
|
|
178
|
+
const port = Number.parseInt(value, 10);
|
|
179
|
+
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
|
|
180
|
+
throw usageError('Port must be between 1 and 65535.');
|
|
181
|
+
}
|
|
182
|
+
return port;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function parseRouteSlugs(value) {
|
|
186
|
+
if (!value) return null;
|
|
187
|
+
const slugs = String(value)
|
|
188
|
+
.split(',')
|
|
189
|
+
.map((entry) => entry.trim())
|
|
190
|
+
.filter(Boolean);
|
|
191
|
+
if (slugs.length === 0) {
|
|
192
|
+
throw usageError('--routes must include at least one route slug.');
|
|
193
|
+
}
|
|
194
|
+
return slugs;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function routeSelection(manifest, rawRouteSlugs) {
|
|
198
|
+
const routes = Array.isArray(manifest?.routes) ? manifest.routes : [];
|
|
199
|
+
if (routes.length === 0) {
|
|
200
|
+
throw usageError('Manifest has no routes to verify.');
|
|
201
|
+
}
|
|
202
|
+
if (!rawRouteSlugs) {
|
|
203
|
+
return routes;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const bySlug = new Map(routes.map((route) => [route.slug, route]));
|
|
207
|
+
const selected = [];
|
|
208
|
+
const missing = [];
|
|
209
|
+
for (const slug of rawRouteSlugs) {
|
|
210
|
+
const route = bySlug.get(slug);
|
|
211
|
+
if (route) {
|
|
212
|
+
selected.push(route);
|
|
213
|
+
} else {
|
|
214
|
+
missing.push(slug);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
if (missing.length) {
|
|
218
|
+
throw usageError(
|
|
219
|
+
`Unknown route slug${missing.length === 1 ? '' : 's'}: ${missing.join(', ')}.`,
|
|
220
|
+
{ available_routes: routes.map((route) => route.slug) },
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
return selected;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function declaredDatabaseSlugs(appConfig, manifest, route) {
|
|
227
|
+
const slugs = new Set();
|
|
228
|
+
for (const entry of appConfig?.databases || manifest?.databases || []) {
|
|
229
|
+
if (typeof entry === 'string' && entry) {
|
|
230
|
+
slugs.add(entry);
|
|
231
|
+
} else if (entry && typeof entry === 'object' && typeof entry.slug === 'string') {
|
|
232
|
+
slugs.add(entry.slug);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
if (route?.collection?.database) {
|
|
236
|
+
slugs.add(route.collection.database);
|
|
237
|
+
}
|
|
238
|
+
return Array.from(slugs);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function harnessErrorMessage(error) {
|
|
242
|
+
if (!error || typeof error !== 'object') {
|
|
243
|
+
return String(error);
|
|
244
|
+
}
|
|
245
|
+
return error.message || error.reason || error.type || JSON.stringify(error);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function assertHarnessResult(result, route, databaseSlugs) {
|
|
249
|
+
const assertions = [];
|
|
250
|
+
if (result.tool_error) {
|
|
251
|
+
assertions.push({
|
|
252
|
+
ok: false,
|
|
253
|
+
code: 'tool_error',
|
|
254
|
+
message: `agent-browser ${result.tool_error.phase || 'command'} failed`,
|
|
255
|
+
details: result.tool_error,
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
if (result.mounted !== true) {
|
|
259
|
+
assertions.push({
|
|
260
|
+
ok: false,
|
|
261
|
+
code: 'not_mounted',
|
|
262
|
+
message: 'Harness did not report mounted === true.',
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
if (result.timed_out) {
|
|
266
|
+
assertions.push({
|
|
267
|
+
ok: false,
|
|
268
|
+
code: 'timeout',
|
|
269
|
+
message: 'Timed out waiting for window.__harness.mounted.',
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
for (const error of result.errors || []) {
|
|
273
|
+
assertions.push({
|
|
274
|
+
ok: false,
|
|
275
|
+
code: 'render_error',
|
|
276
|
+
message: harnessErrorMessage(error),
|
|
277
|
+
details: error,
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
for (const databaseSlug of databaseSlugs) {
|
|
281
|
+
const seen = (result.runtimeCalls || []).some(
|
|
282
|
+
(call) => call?.op === 'queryDatabase' && call?.args?.databaseSlug === databaseSlug,
|
|
283
|
+
);
|
|
284
|
+
if (!seen) {
|
|
285
|
+
assertions.push({
|
|
286
|
+
ok: false,
|
|
287
|
+
code: 'missing_database_query',
|
|
288
|
+
message: `Route "${route.slug}" did not call queryDatabase for "${databaseSlug}".`,
|
|
289
|
+
details: { databaseSlug },
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
return assertions;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function renderVerifyReport({ summary, results, noBrowser }) {
|
|
297
|
+
const lines = [
|
|
298
|
+
noBrowser
|
|
299
|
+
? `Harness URLs ready for ${summary.total} route${summary.total === 1 ? '' : 's'}.`
|
|
300
|
+
: `Verified ${summary.total} route${summary.total === 1 ? '' : 's'}: ${summary.passed} passed, ${summary.failed} failed.`,
|
|
301
|
+
];
|
|
302
|
+
for (const result of results) {
|
|
303
|
+
const marker = result.ok ? 'PASS' : result.status === 'manual' ? 'URL' : 'FAIL';
|
|
304
|
+
lines.push(`${marker.padEnd(4)} ${result.route.padEnd(18)} ${result.url}`);
|
|
305
|
+
for (const assertion of result.assertions || []) {
|
|
306
|
+
lines.push(` - ${assertion.message}`);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
if (noBrowser) {
|
|
310
|
+
lines.push('', 'Pass --keep-open to leave the harness process running while you inspect the URLs.');
|
|
311
|
+
}
|
|
312
|
+
return lines.join('\n');
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function buildManifestForDev(appConfig) {
|
|
316
|
+
const routes = Array.isArray(appConfig.routes) ? appConfig.routes : [];
|
|
317
|
+
return {
|
|
318
|
+
version: 1,
|
|
319
|
+
spec_version: 3,
|
|
320
|
+
app: {
|
|
321
|
+
name: appConfig.name,
|
|
322
|
+
description: appConfig.description || null,
|
|
323
|
+
icon: appConfig.icon || null,
|
|
324
|
+
},
|
|
325
|
+
routes: routes.map((route) => ({
|
|
326
|
+
path: route.path,
|
|
327
|
+
slug: route.slug,
|
|
328
|
+
name: route.name,
|
|
329
|
+
icon: route.icon || null,
|
|
330
|
+
parentSlug: route.parentSlug || null,
|
|
331
|
+
default: route.default || false,
|
|
332
|
+
export_name: route.exportName || route.export_name,
|
|
333
|
+
collection: route.collection || null,
|
|
334
|
+
})),
|
|
335
|
+
bundle: {
|
|
336
|
+
js: 'bundle/app.js',
|
|
337
|
+
css: 'bundle/app.css',
|
|
338
|
+
},
|
|
339
|
+
databases: appConfig.databases || [],
|
|
340
|
+
tools: appConfig.tools || [],
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
async function ensureDevInstall({ ctx, appConfig, projectDir, idempotencyKey }) {
|
|
345
|
+
if (!appConfig.name) {
|
|
346
|
+
throw usageError(`notis.config.ts in ${projectDir} must define a non-empty name.`);
|
|
347
|
+
}
|
|
348
|
+
const devSlug = buildDevInstallSlug(appConfig.name);
|
|
349
|
+
if (!devSlug) {
|
|
350
|
+
throw usageError(`notis.config.ts name in ${projectDir} must slugify to a non-empty value.`);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const manifest = buildManifestForDev(appConfig);
|
|
354
|
+
const ensureResult = await runToolCommand({
|
|
355
|
+
runtime: ctx.runtime,
|
|
356
|
+
toolName: ENSURE_DEV_APP_INSTALLATION_TOOL,
|
|
357
|
+
arguments_: {
|
|
358
|
+
dev_slug: devSlug,
|
|
359
|
+
name: appConfig.name,
|
|
360
|
+
manifest,
|
|
361
|
+
},
|
|
362
|
+
mutating: true,
|
|
363
|
+
idempotencyKey,
|
|
364
|
+
});
|
|
365
|
+
const appId = ensureResult.payload.app_id;
|
|
366
|
+
if (!appId) {
|
|
367
|
+
throw usageError('Dev installation tool did not return an app_id.');
|
|
368
|
+
}
|
|
369
|
+
return {
|
|
370
|
+
slug: ensureResult.payload.slug || devSlug,
|
|
371
|
+
devSlug,
|
|
372
|
+
name: appConfig.name,
|
|
373
|
+
appId,
|
|
374
|
+
projectDir,
|
|
375
|
+
manifest,
|
|
376
|
+
created: ensureResult.payload.created || false,
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
async function assertDirectDeployAccess(runtime, appId) {
|
|
381
|
+
const result = await runToolCommand({
|
|
382
|
+
runtime,
|
|
383
|
+
toolName: 'notis_list_apps',
|
|
384
|
+
});
|
|
385
|
+
const apps = result.payload.apps || [];
|
|
386
|
+
const hasAccess = apps.some((app) => (app.app_id || app.id) === appId);
|
|
387
|
+
if (!hasAccess) {
|
|
388
|
+
throw usageError(`Direct deploy requires access to app ${appId}.`);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
46
392
|
// ---------------------------------------------------------------------------
|
|
47
393
|
// Handlers
|
|
48
394
|
// ---------------------------------------------------------------------------
|
|
@@ -72,7 +418,7 @@ async function appsInitHandler(ctx) {
|
|
|
72
418
|
humanSummary: `Scaffolded "${ctx.args.name}" in ${projectDir}`,
|
|
73
419
|
hints: [
|
|
74
420
|
{ command: `cd ${projectDir} && npm install`, reason: 'Install dependencies' },
|
|
75
|
-
{ command:
|
|
421
|
+
{ command: `cd ${projectDir} && notis apps dev`, reason: 'Start the real-portal dev workflow' },
|
|
76
422
|
],
|
|
77
423
|
});
|
|
78
424
|
}
|
|
@@ -80,13 +426,16 @@ async function appsInitHandler(ctx) {
|
|
|
80
426
|
async function appsCreateHandler(ctx) {
|
|
81
427
|
const projectDir = ctx.args.dir ? resolveProjectDir(ctx.args.dir) : null;
|
|
82
428
|
const idempotencyKey = nextIdempotencyKey(ctx.globalOptions);
|
|
429
|
+
const icon = ctx.options.icon
|
|
430
|
+
? (ctx.options.icon.startsWith('lucide:') ? ctx.options.icon : `lucide:${ctx.options.icon}`)
|
|
431
|
+
: undefined;
|
|
83
432
|
const result = await runToolCommand({
|
|
84
433
|
runtime: ctx.runtime,
|
|
85
434
|
toolName: 'notis_create_app',
|
|
86
435
|
arguments_: {
|
|
87
436
|
name: ctx.args.name,
|
|
88
437
|
description: ctx.options.description || undefined,
|
|
89
|
-
icon
|
|
438
|
+
icon,
|
|
90
439
|
},
|
|
91
440
|
mutating: true,
|
|
92
441
|
idempotencyKey,
|
|
@@ -123,18 +472,165 @@ async function appsCreateHandler(ctx) {
|
|
|
123
472
|
}
|
|
124
473
|
|
|
125
474
|
async function appsDevHandler(ctx) {
|
|
126
|
-
const
|
|
127
|
-
const
|
|
128
|
-
|
|
129
|
-
|
|
475
|
+
const rootDir = resolveProjectDir(ctx.args.dir || '.');
|
|
476
|
+
const appDirs = discoverAppDirs(rootDir);
|
|
477
|
+
|
|
478
|
+
for (const projectDir of appDirs) {
|
|
479
|
+
const problems = detectProjectProblems(projectDir);
|
|
480
|
+
if (problems.length) {
|
|
481
|
+
throw usageError(`Project ${projectDir} has problems:\n${problems.map((p) => ` - ${p}`).join('\n')}`);
|
|
482
|
+
}
|
|
130
483
|
}
|
|
131
484
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
485
|
+
const port = ctx.options.port ? Number.parseInt(ctx.options.port, 10) : DEFAULT_DEV_PORT;
|
|
486
|
+
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
|
|
487
|
+
throw usageError('Port must be between 1 and 65535.');
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
const mode = getCliMode();
|
|
491
|
+
const identity = decodeJwtSub(ctx.runtime.jwt);
|
|
492
|
+
if (!identity) {
|
|
493
|
+
throw usageError('Could not determine the current user from the CLI auth token. Run `notis auth login` from the desktop app and retry.');
|
|
494
|
+
}
|
|
495
|
+
const apiBase = String(ctx.runtime.apiBase || '').replace(/\/$/, '');
|
|
496
|
+
const sessionId = randomUUID();
|
|
497
|
+
|
|
498
|
+
const registered = [];
|
|
499
|
+
for (const projectDir of appDirs) {
|
|
500
|
+
const appConfig = await loadAppConfig(projectDir);
|
|
501
|
+
const info = await ensureDevInstall({
|
|
502
|
+
ctx,
|
|
503
|
+
appConfig,
|
|
504
|
+
projectDir,
|
|
505
|
+
idempotencyKey: nextIdempotencyKey(ctx.globalOptions),
|
|
506
|
+
});
|
|
507
|
+
registered.push(info);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
const seenSlugs = new Map();
|
|
511
|
+
for (const app of registered) {
|
|
512
|
+
if (seenSlugs.has(app.devSlug)) {
|
|
513
|
+
throw usageError(
|
|
514
|
+
`Multiple apps slugify to "${app.devSlug}" (${seenSlugs.get(app.devSlug).projectDir} and ${app.projectDir}). Rename one in notis.config.ts.`,
|
|
515
|
+
);
|
|
516
|
+
}
|
|
517
|
+
seenSlugs.set(app.devSlug, app);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
const apps = registered.map((app) => {
|
|
521
|
+
const bundleBaseUrl = `http://127.0.0.1:${port}/a/${app.devSlug}`;
|
|
522
|
+
return {
|
|
523
|
+
...app,
|
|
524
|
+
bundleBaseUrl,
|
|
525
|
+
appHref: buildAppHref({
|
|
526
|
+
appSlug: app.slug,
|
|
527
|
+
appId: app.appId,
|
|
528
|
+
manifest: app.manifest,
|
|
529
|
+
}),
|
|
530
|
+
};
|
|
531
|
+
});
|
|
532
|
+
const developmentTabUrl = buildDevelopmentDesktopUrl();
|
|
533
|
+
|
|
534
|
+
const devServer = await startAppDevServer({
|
|
535
|
+
apps: apps.map((app) => ({ slug: app.devSlug, projectDir: app.projectDir, appId: app.appId })),
|
|
536
|
+
port,
|
|
136
537
|
});
|
|
137
538
|
|
|
539
|
+
try {
|
|
540
|
+
const now = new Date().toISOString();
|
|
541
|
+
upsertAppDevSessions(apps.map((app) => ({
|
|
542
|
+
sessionId,
|
|
543
|
+
userId: identity,
|
|
544
|
+
apiBase,
|
|
545
|
+
appId: app.appId,
|
|
546
|
+
devSlug: app.devSlug,
|
|
547
|
+
bundleBaseUrl: app.bundleBaseUrl,
|
|
548
|
+
projectDir: app.projectDir,
|
|
549
|
+
startedAt: now,
|
|
550
|
+
lastHeartbeatAt: now,
|
|
551
|
+
})));
|
|
552
|
+
} catch (error) {
|
|
553
|
+
try {
|
|
554
|
+
await devServer.close();
|
|
555
|
+
} catch {
|
|
556
|
+
// ignore cleanup failure on registry errors
|
|
557
|
+
}
|
|
558
|
+
throw error;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
let heartbeatTimer = setInterval(() => {
|
|
562
|
+
try {
|
|
563
|
+
heartbeatAppDevSession(sessionId, new Date().toISOString());
|
|
564
|
+
} catch (error) {
|
|
565
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
566
|
+
process.stderr.write(`[notis apps dev] heartbeat failed: ${message}\n`);
|
|
567
|
+
}
|
|
568
|
+
}, DEV_HEARTBEAT_INTERVAL_MS);
|
|
569
|
+
|
|
570
|
+
ctx.output.emitSuccess({
|
|
571
|
+
command: ctx.spec.command_path.join(' '),
|
|
572
|
+
data: {
|
|
573
|
+
mode,
|
|
574
|
+
api_base: apiBase,
|
|
575
|
+
development_url: developmentTabUrl,
|
|
576
|
+
session_id: sessionId,
|
|
577
|
+
identity,
|
|
578
|
+
apps: apps.map((app) => ({
|
|
579
|
+
slug: app.devSlug,
|
|
580
|
+
app_id: app.appId,
|
|
581
|
+
name: app.name,
|
|
582
|
+
project_dir: app.projectDir,
|
|
583
|
+
bundle_base_url: app.bundleBaseUrl,
|
|
584
|
+
app_href: app.appHref,
|
|
585
|
+
created: app.created,
|
|
586
|
+
})),
|
|
587
|
+
},
|
|
588
|
+
humanSummary: [
|
|
589
|
+
`Running apps dev against ${apiBase} as ${identity} (mode: ${mode})`,
|
|
590
|
+
'',
|
|
591
|
+
`Development tab: ${developmentTabUrl}`,
|
|
592
|
+
'',
|
|
593
|
+
...apps.map((app) => ` ${app.name.padEnd(24)} ${app.bundleBaseUrl} -> ${app.appHref}`),
|
|
594
|
+
'',
|
|
595
|
+
'Press Ctrl-C to stop.',
|
|
596
|
+
].join('\n'),
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
if (!ctx.options.noOpen) {
|
|
600
|
+
openInBrowser(developmentTabUrl);
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
let shuttingDown = false;
|
|
604
|
+
const shutdown = async (signal) => {
|
|
605
|
+
if (shuttingDown) return;
|
|
606
|
+
shuttingDown = true;
|
|
607
|
+
process.stdout.write(`\n[notis apps dev] stopping (${signal})...\n`);
|
|
608
|
+
if (heartbeatTimer) {
|
|
609
|
+
clearInterval(heartbeatTimer);
|
|
610
|
+
heartbeatTimer = null;
|
|
611
|
+
}
|
|
612
|
+
try {
|
|
613
|
+
removeAppDevSession(sessionId);
|
|
614
|
+
} catch {
|
|
615
|
+
// ignore cleanup failures during shutdown
|
|
616
|
+
}
|
|
617
|
+
try {
|
|
618
|
+
await devServer.close();
|
|
619
|
+
} catch {
|
|
620
|
+
// ignore cleanup failures during shutdown
|
|
621
|
+
}
|
|
622
|
+
process.exit(EXIT_CODES.ok);
|
|
623
|
+
};
|
|
624
|
+
|
|
625
|
+
process.on('SIGINT', () => {
|
|
626
|
+
void shutdown('SIGINT');
|
|
627
|
+
});
|
|
628
|
+
process.on('SIGTERM', () => {
|
|
629
|
+
void shutdown('SIGTERM');
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
await new Promise(() => {});
|
|
633
|
+
|
|
138
634
|
return EXIT_CODES.ok;
|
|
139
635
|
}
|
|
140
636
|
|
|
@@ -154,25 +650,207 @@ async function appsBuildHandler(ctx) {
|
|
|
154
650
|
});
|
|
155
651
|
}
|
|
156
652
|
|
|
157
|
-
async function
|
|
653
|
+
async function appsVerifyHandler(ctx) {
|
|
158
654
|
const projectDir = resolveProjectDir(ctx.args.dir || '.');
|
|
159
|
-
const
|
|
160
|
-
if (
|
|
161
|
-
throw usageError(
|
|
655
|
+
const problems = detectProjectProblems(projectDir);
|
|
656
|
+
if (problems.length) {
|
|
657
|
+
throw usageError(`Project has problems:\n${problems.map((p) => ` - ${p}`).join('\n')}`);
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
const mode = ctx.options.mode || 'stub';
|
|
661
|
+
if (!['stub', 'live'].includes(mode)) {
|
|
662
|
+
throw usageError('--mode must be either "stub" or "live".');
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
let linkedState = null;
|
|
666
|
+
if (mode === 'live') {
|
|
667
|
+
if (!ctx.runtime.jwt) {
|
|
668
|
+
throw usageError('Live verify mode requires CLI auth. Run `notis auth login` and retry.');
|
|
669
|
+
}
|
|
670
|
+
linkedState = readLinkedState(projectDir);
|
|
671
|
+
if (!linkedState?.app_id) {
|
|
672
|
+
throw usageError('Live verify mode requires a linked app. Run `notis apps link <app-id> .` first.');
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
if (!ctx.options.skipBuild) {
|
|
677
|
+
await buildArtifact(projectDir);
|
|
162
678
|
}
|
|
163
679
|
|
|
164
680
|
const manifest = readManifest(projectDir);
|
|
165
|
-
const
|
|
166
|
-
const
|
|
681
|
+
const appConfig = await loadAppConfig(projectDir);
|
|
682
|
+
const routes = routeSelection(manifest, parseRouteSlugs(ctx.options.routes));
|
|
683
|
+
const port = parsePort(ctx.options.port) || await getAvailablePort();
|
|
684
|
+
const appSlug = slugify(appConfig.name || manifest.app?.name || 'app') || 'app';
|
|
685
|
+
const baseUrl = `http://127.0.0.1:${port}/a/${appSlug}`;
|
|
686
|
+
const browserSessionName = `notis-verify-${process.pid}`;
|
|
687
|
+
const noBrowser = ctx.options.browser === false;
|
|
688
|
+
const keepOpen = Boolean(ctx.options.keepOpen);
|
|
689
|
+
let devServer = null;
|
|
690
|
+
let browserTouched = false;
|
|
167
691
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
692
|
+
try {
|
|
693
|
+
devServer = await startAppDevServer({
|
|
694
|
+
apps: [{
|
|
695
|
+
slug: appSlug,
|
|
696
|
+
projectDir,
|
|
697
|
+
appId: linkedState?.app_id || 'harness-app',
|
|
698
|
+
}],
|
|
699
|
+
port,
|
|
700
|
+
watch: false,
|
|
701
|
+
harness: {
|
|
702
|
+
mode,
|
|
703
|
+
apiBase: ctx.runtime.apiBase,
|
|
704
|
+
jwt: mode === 'live' ? ctx.runtime.jwt : null,
|
|
705
|
+
},
|
|
706
|
+
log: () => {},
|
|
707
|
+
logError: (message) => process.stderr.write(`${message}\n`),
|
|
708
|
+
});
|
|
173
709
|
|
|
174
|
-
|
|
175
|
-
|
|
710
|
+
const urls = routes.map((route) => ({
|
|
711
|
+
route,
|
|
712
|
+
url: `${baseUrl}/harness?route=${encodeURIComponent(route.slug)}`,
|
|
713
|
+
}));
|
|
714
|
+
|
|
715
|
+
let results;
|
|
716
|
+
const warnings = [];
|
|
717
|
+
if (noBrowser) {
|
|
718
|
+
results = urls.map(({ route, url }) => ({
|
|
719
|
+
route: route.slug,
|
|
720
|
+
path: route.path,
|
|
721
|
+
url,
|
|
722
|
+
ok: true,
|
|
723
|
+
status: 'manual',
|
|
724
|
+
mounted: null,
|
|
725
|
+
errors: [],
|
|
726
|
+
runtimeCalls: [],
|
|
727
|
+
assertions: [],
|
|
728
|
+
snapshot_path: null,
|
|
729
|
+
tool_error: null,
|
|
730
|
+
}));
|
|
731
|
+
} else if (!isAgentBrowserAvailable()) {
|
|
732
|
+
warnings.push('agent-browser is not available on PATH; rerun with --no-browser to inspect harness URLs manually.');
|
|
733
|
+
results = urls.map(({ route, url }) => {
|
|
734
|
+
const toolError = {
|
|
735
|
+
phase: 'available',
|
|
736
|
+
message: 'agent-browser is not available on PATH',
|
|
737
|
+
};
|
|
738
|
+
const result = {
|
|
739
|
+
route: route.slug,
|
|
740
|
+
path: route.path,
|
|
741
|
+
url,
|
|
742
|
+
mounted: false,
|
|
743
|
+
renderStarted: false,
|
|
744
|
+
errors: [],
|
|
745
|
+
runtimeCalls: [],
|
|
746
|
+
snapshotPath: null,
|
|
747
|
+
tool_error: toolError,
|
|
748
|
+
};
|
|
749
|
+
const assertions = assertHarnessResult(
|
|
750
|
+
result,
|
|
751
|
+
route,
|
|
752
|
+
declaredDatabaseSlugs(appConfig, manifest, route),
|
|
753
|
+
);
|
|
754
|
+
return {
|
|
755
|
+
...result,
|
|
756
|
+
ok: false,
|
|
757
|
+
status: 'failed',
|
|
758
|
+
assertions,
|
|
759
|
+
snapshot_path: null,
|
|
760
|
+
};
|
|
761
|
+
});
|
|
762
|
+
} else {
|
|
763
|
+
browserTouched = true;
|
|
764
|
+
results = [];
|
|
765
|
+
for (const { route, url } of urls) {
|
|
766
|
+
const snapshotPath = join(projectDir, '.notis', 'output', '.harness', `${route.slug}.snapshot.txt`);
|
|
767
|
+
const result = await runHarnessRoute({
|
|
768
|
+
url,
|
|
769
|
+
sessionName: browserSessionName,
|
|
770
|
+
timeoutMs: Number.parseInt(ctx.globalOptions.timeoutMs || '', 10) || 10_000,
|
|
771
|
+
snapshotPath,
|
|
772
|
+
});
|
|
773
|
+
const assertions = assertHarnessResult(
|
|
774
|
+
result,
|
|
775
|
+
route,
|
|
776
|
+
declaredDatabaseSlugs(appConfig, manifest, route),
|
|
777
|
+
);
|
|
778
|
+
results.push({
|
|
779
|
+
route: route.slug,
|
|
780
|
+
path: route.path,
|
|
781
|
+
url,
|
|
782
|
+
ok: assertions.length === 0,
|
|
783
|
+
status: assertions.length === 0 ? 'passed' : 'failed',
|
|
784
|
+
mounted: result.mounted,
|
|
785
|
+
renderStarted: result.renderStarted,
|
|
786
|
+
errors: result.errors,
|
|
787
|
+
runtimeCalls: result.runtimeCalls,
|
|
788
|
+
assertions,
|
|
789
|
+
snapshot_path: result.snapshotPath,
|
|
790
|
+
timed_out: Boolean(result.timed_out),
|
|
791
|
+
tool_error: result.tool_error,
|
|
792
|
+
});
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
const summary = {
|
|
797
|
+
total: results.length,
|
|
798
|
+
passed: results.filter((result) => result.status === 'passed').length,
|
|
799
|
+
failed: results.filter((result) => result.status === 'failed').length,
|
|
800
|
+
manual: results.filter((result) => result.status === 'manual').length,
|
|
801
|
+
};
|
|
802
|
+
const overallOk = summary.failed === 0;
|
|
803
|
+
const exitCode = overallOk ? EXIT_CODES.ok : EXIT_CODES.unexpected;
|
|
804
|
+
const data = {
|
|
805
|
+
status: overallOk ? (summary.manual ? 'manual' : 'passed') : 'failed',
|
|
806
|
+
project_dir: projectDir,
|
|
807
|
+
app_slug: appSlug,
|
|
808
|
+
mode,
|
|
809
|
+
browser_session: noBrowser ? null : browserSessionName,
|
|
810
|
+
server: {
|
|
811
|
+
port,
|
|
812
|
+
base_url: baseUrl,
|
|
813
|
+
urls: urls.map(({ route, url }) => ({ route: route.slug, url })),
|
|
814
|
+
},
|
|
815
|
+
summary,
|
|
816
|
+
results,
|
|
817
|
+
};
|
|
818
|
+
|
|
819
|
+
ctx.output.emitSuccess({
|
|
820
|
+
ok: overallOk,
|
|
821
|
+
command: ctx.spec.command_path.join(' '),
|
|
822
|
+
data,
|
|
823
|
+
humanSummary: overallOk
|
|
824
|
+
? (summary.manual ? `Harness URLs ready for ${summary.manual} routes.` : `Verified ${summary.passed} routes successfully.`)
|
|
825
|
+
: `Verification failed for ${summary.failed} routes.`,
|
|
826
|
+
warnings,
|
|
827
|
+
renderHuman: () => renderVerifyReport({ summary, results, noBrowser }),
|
|
828
|
+
});
|
|
829
|
+
|
|
830
|
+
if (keepOpen) {
|
|
831
|
+
process.stderr.write(`[notis apps verify] harness open at ${baseUrl}. Press Ctrl-C to stop.\n`);
|
|
832
|
+
await new Promise(() => {});
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
return exitCode;
|
|
836
|
+
} finally {
|
|
837
|
+
if (!keepOpen) {
|
|
838
|
+
if (browserTouched) {
|
|
839
|
+
try {
|
|
840
|
+
await closeAgentBrowserSession(browserSessionName);
|
|
841
|
+
} catch {
|
|
842
|
+
// Ignore browser cleanup failures.
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
if (devServer) {
|
|
846
|
+
try {
|
|
847
|
+
await devServer.close();
|
|
848
|
+
} catch {
|
|
849
|
+
// Ignore server cleanup failures.
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
}
|
|
176
854
|
}
|
|
177
855
|
|
|
178
856
|
async function appsLinkHandler(ctx) {
|
|
@@ -194,6 +872,39 @@ async function appsLinkHandler(ctx) {
|
|
|
194
872
|
});
|
|
195
873
|
}
|
|
196
874
|
|
|
875
|
+
async function appsPullHandler(ctx) {
|
|
876
|
+
const appId = ctx.args.appId;
|
|
877
|
+
const result = await runToolCommand({
|
|
878
|
+
runtime: ctx.runtime,
|
|
879
|
+
toolName: GET_APP_TOOL,
|
|
880
|
+
arguments_: { app_id: appId },
|
|
881
|
+
});
|
|
882
|
+
const app = result.payload?.app || {};
|
|
883
|
+
const defaultDir = app.slug || slugify(app.name) || appId;
|
|
884
|
+
const targetDir = resolveProjectDir(ctx.args.dir || defaultDir);
|
|
885
|
+
const version = ctx.options.version || 'latest';
|
|
886
|
+
|
|
887
|
+
const pulled = await pullAppSource({
|
|
888
|
+
apiBase: ctx.runtime.apiBase,
|
|
889
|
+
jwt: ctx.runtime.jwt,
|
|
890
|
+
appId,
|
|
891
|
+
targetDir,
|
|
892
|
+
version,
|
|
893
|
+
force: Boolean(ctx.options.force),
|
|
894
|
+
});
|
|
895
|
+
|
|
896
|
+
const versionLabel = pulled.version === 'latest' ? 'latest version' : `v${pulled.version}`;
|
|
897
|
+
return ctx.output.emitSuccess({
|
|
898
|
+
command: ctx.spec.command_path.join(' '),
|
|
899
|
+
data: {
|
|
900
|
+
app_id: appId,
|
|
901
|
+
project_dir: pulled.projectDir,
|
|
902
|
+
version: pulled.version,
|
|
903
|
+
},
|
|
904
|
+
humanSummary: `Pulled ${versionLabel} to ${pulled.projectDir}. Run \`cd ${pulled.projectDir} && npm install && notis apps dev\` to start editing.`,
|
|
905
|
+
});
|
|
906
|
+
}
|
|
907
|
+
|
|
197
908
|
async function appsDeployHandler(ctx) {
|
|
198
909
|
const projectDir = resolveProjectDir(ctx.args.dir || '.');
|
|
199
910
|
const appId = requireLinkedAppId(projectDir, ctx.options.appId);
|
|
@@ -204,7 +915,21 @@ async function appsDeployHandler(ctx) {
|
|
|
204
915
|
await buildArtifact(projectDir);
|
|
205
916
|
}
|
|
206
917
|
|
|
918
|
+
// Direct deploy mode: upload to Supabase storage directly
|
|
919
|
+
if (ctx.options.direct) {
|
|
920
|
+
await assertDirectDeployAccess(ctx.runtime, appId);
|
|
921
|
+
const { version } = await directDeploy(projectDir, appId);
|
|
922
|
+
return ctx.output.emitSuccess({
|
|
923
|
+
command: ctx.spec.command_path.join(' '),
|
|
924
|
+
data: { app_id: appId, version, mode: 'direct' },
|
|
925
|
+
humanSummary: `Deployed to app ${appId} (version ${version}) via direct upload`,
|
|
926
|
+
meta: { mutating: true },
|
|
927
|
+
});
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
// Standard deploy via backend server, with auto-fallback to direct
|
|
207
931
|
const files = collectArtifactFiles(projectDir);
|
|
932
|
+
const sourceFiles = collectSourceFiles(projectDir);
|
|
208
933
|
const manifest = readManifest(projectDir);
|
|
209
934
|
|
|
210
935
|
let result;
|
|
@@ -215,6 +940,7 @@ async function appsDeployHandler(ctx) {
|
|
|
215
940
|
arguments_: {
|
|
216
941
|
app_id: appId,
|
|
217
942
|
files,
|
|
943
|
+
source_files: sourceFiles,
|
|
218
944
|
manifest,
|
|
219
945
|
},
|
|
220
946
|
mutating: true,
|
|
@@ -224,6 +950,39 @@ async function appsDeployHandler(ctx) {
|
|
|
224
950
|
if (error.code === 'conflict') {
|
|
225
951
|
throw toolConflictToError(error.details, 'Deploy conflict');
|
|
226
952
|
}
|
|
953
|
+
|
|
954
|
+
// Auto-fallback to direct deploy on network errors
|
|
955
|
+
const isNetworkError = error.code === 'network_error'
|
|
956
|
+
|| error.code === 'network_timeout'
|
|
957
|
+
|| (error.message && /fetch failed|ECONNREFUSED|network/i.test(error.message));
|
|
958
|
+
|
|
959
|
+
if (isNetworkError) {
|
|
960
|
+
try {
|
|
961
|
+
await assertDirectDeployAccess(ctx.runtime, appId);
|
|
962
|
+
} catch (accessError) {
|
|
963
|
+
throw usageError(
|
|
964
|
+
`Backend deploy failed (${error.message}) and direct fallback was blocked because app access ` +
|
|
965
|
+
`could not be verified (${accessError.message}).`,
|
|
966
|
+
);
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
try {
|
|
970
|
+
const { version } = await directDeploy(projectDir, appId);
|
|
971
|
+
return ctx.output.emitSuccess({
|
|
972
|
+
command: ctx.spec.command_path.join(' '),
|
|
973
|
+
data: { app_id: appId, version, mode: 'direct-fallback' },
|
|
974
|
+
humanSummary: `Backend unavailable -- deployed to app ${appId} (version ${version}) via direct upload`,
|
|
975
|
+
warnings: ['Backend server was unreachable. Used direct Supabase upload as fallback.'],
|
|
976
|
+
meta: { mutating: true },
|
|
977
|
+
});
|
|
978
|
+
} catch (directError) {
|
|
979
|
+
throw usageError(
|
|
980
|
+
`Backend deploy failed (${error.message}) and direct fallback also failed (${directError.message}). ` +
|
|
981
|
+
'Check server/.env for Supabase credentials or start the backend server.',
|
|
982
|
+
);
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
|
|
227
986
|
throw error;
|
|
228
987
|
}
|
|
229
988
|
|
|
@@ -242,16 +1001,14 @@ async function appsDeployHandler(ctx) {
|
|
|
242
1001
|
async function appsDoctorHandler(ctx) {
|
|
243
1002
|
const projectDir = resolveProjectDir(ctx.args.dir || '.');
|
|
244
1003
|
const problems = detectProjectProblems(projectDir);
|
|
245
|
-
const warnings = detectProjectWarnings(projectDir);
|
|
246
1004
|
|
|
247
1005
|
let appConfig = null;
|
|
248
1006
|
try {
|
|
249
1007
|
appConfig = await loadAppConfig(projectDir);
|
|
250
|
-
const configWarnings = detectProjectWarnings(projectDir, appConfig);
|
|
251
|
-
warnings.push(...configWarnings.filter((w) => !warnings.includes(w)));
|
|
252
1008
|
} catch {
|
|
253
1009
|
problems.push('Failed to load notis.config.ts');
|
|
254
1010
|
}
|
|
1011
|
+
const warnings = detectProjectWarnings(projectDir, appConfig);
|
|
255
1012
|
|
|
256
1013
|
const linkedState = readLinkedState(projectDir);
|
|
257
1014
|
const status = problems.length ? 'unhealthy' : warnings.length ? 'warnings' : 'healthy';
|
|
@@ -286,7 +1043,7 @@ export const appsCommandSpecs = [
|
|
|
286
1043
|
{
|
|
287
1044
|
command_path: ['apps', 'init'],
|
|
288
1045
|
summary: 'Scaffold a new Notis app project.',
|
|
289
|
-
when_to_use: 'Start a new Notis app. Creates a
|
|
1046
|
+
when_to_use: 'Start a new Notis app. Creates a Vite + React project with @notis/sdk pre-configured.',
|
|
290
1047
|
args_schema: {
|
|
291
1048
|
arguments: [
|
|
292
1049
|
{ token: '<name>', description: 'Display name for the app.' },
|
|
@@ -297,6 +1054,7 @@ export const appsCommandSpecs = [
|
|
|
297
1054
|
examples: ['notis apps init "Mind the Flo"', 'notis apps init "My App" ./my-app'],
|
|
298
1055
|
mutates: true,
|
|
299
1056
|
idempotent: false,
|
|
1057
|
+
require_auth: false,
|
|
300
1058
|
backend_call: { type: 'local', name: 'scaffold_project' },
|
|
301
1059
|
handler: appsInitHandler,
|
|
302
1060
|
},
|
|
@@ -325,24 +1083,33 @@ export const appsCommandSpecs = [
|
|
|
325
1083
|
},
|
|
326
1084
|
{
|
|
327
1085
|
command_path: ['apps', 'dev'],
|
|
328
|
-
summary: '
|
|
329
|
-
when_to_use:
|
|
1086
|
+
summary: 'Develop Notis apps inside the Electron desktop Portal with automatic local bundle reloads.',
|
|
1087
|
+
when_to_use:
|
|
1088
|
+
'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 Development tab.',
|
|
330
1089
|
args_schema: {
|
|
331
1090
|
arguments: [
|
|
332
|
-
{ token: '[dir]', key: 'dir', description: 'Project directory (default: current dir).' },
|
|
1091
|
+
{ token: '[dir]', key: 'dir', description: 'Project directory or monorepo root (default: current dir).' },
|
|
1092
|
+
],
|
|
1093
|
+
options: [
|
|
1094
|
+
{ flags: '--port <number>', description: `Local bundle server port (default: ${DEFAULT_DEV_PORT}).` },
|
|
1095
|
+
{ flags: '--no-open', description: 'Do not auto-open the desktop Portal Development tab.' },
|
|
333
1096
|
],
|
|
334
|
-
options: [],
|
|
335
1097
|
},
|
|
336
|
-
examples: [
|
|
337
|
-
|
|
1098
|
+
examples: [
|
|
1099
|
+
'notis apps dev',
|
|
1100
|
+
'notis apps dev ./my-app',
|
|
1101
|
+
'notis apps dev ./workspace --port 5200',
|
|
1102
|
+
],
|
|
1103
|
+
mutates: true,
|
|
338
1104
|
idempotent: true,
|
|
339
|
-
|
|
1105
|
+
require_auth: true,
|
|
1106
|
+
backend_call: { type: 'tool', name: ENSURE_DEV_APP_INSTALLATION_TOOL },
|
|
340
1107
|
handler: appsDevHandler,
|
|
341
1108
|
},
|
|
342
1109
|
{
|
|
343
1110
|
command_path: ['apps', 'build'],
|
|
344
1111
|
summary: 'Build and package the app into .notis/output/.',
|
|
345
|
-
when_to_use: 'Prepare the app for
|
|
1112
|
+
when_to_use: 'Prepare the app for verification or deployment.',
|
|
346
1113
|
args_schema: {
|
|
347
1114
|
arguments: [
|
|
348
1115
|
{ token: '[dir]', key: 'dir', description: 'Project directory (default: current dir).' },
|
|
@@ -352,26 +1119,40 @@ export const appsCommandSpecs = [
|
|
|
352
1119
|
examples: ['notis apps build', 'notis apps build ./my-app'],
|
|
353
1120
|
mutates: true,
|
|
354
1121
|
idempotent: true,
|
|
1122
|
+
require_auth: false,
|
|
355
1123
|
backend_call: { type: 'local', name: 'next_build_and_package' },
|
|
356
1124
|
handler: appsBuildHandler,
|
|
357
1125
|
},
|
|
358
1126
|
{
|
|
359
|
-
command_path: ['apps', '
|
|
360
|
-
summary: '
|
|
361
|
-
when_to_use:
|
|
1127
|
+
command_path: ['apps', 'verify'],
|
|
1128
|
+
summary: 'Headless render smoke each route in a stub-runtime harness.',
|
|
1129
|
+
when_to_use:
|
|
1130
|
+
'After notis apps build, before deploy. Catches render-time crashes ' +
|
|
1131
|
+
'and missing runtime calls that the build step cannot detect.',
|
|
362
1132
|
args_schema: {
|
|
363
1133
|
arguments: [
|
|
364
1134
|
{ token: '[dir]', key: 'dir', description: 'Project directory (default: current dir).' },
|
|
365
1135
|
],
|
|
366
1136
|
options: [
|
|
367
|
-
{ flags: '--
|
|
1137
|
+
{ flags: '--routes <slugs>', description: 'Comma-separated route slugs. Default: every route in manifest.' },
|
|
1138
|
+
{ flags: '--port <n>', description: 'Loopback port. Default: auto-pick.' },
|
|
1139
|
+
{ flags: '--skip-build', description: 'Skip notis apps build; reuse existing .notis/output/.' },
|
|
1140
|
+
{ flags: '--mode <mode>', description: 'stub | live. Default stub. Live posts to /portal_views/runtime_query with the CLI JWT.' },
|
|
1141
|
+
{ flags: '--no-browser', description: 'Start the harness server and print URLs; do not drive agent-browser.' },
|
|
1142
|
+
{ flags: '--keep-open', description: 'Leave server + browser session running after report (for manual triage).' },
|
|
368
1143
|
],
|
|
369
1144
|
},
|
|
370
|
-
examples: [
|
|
1145
|
+
examples: [
|
|
1146
|
+
'notis apps verify',
|
|
1147
|
+
'notis apps verify --routes notes',
|
|
1148
|
+
'notis apps verify --mode live',
|
|
1149
|
+
'notis apps verify --no-browser # start the harness, drive agent-browser yourself',
|
|
1150
|
+
],
|
|
371
1151
|
mutates: false,
|
|
372
1152
|
idempotent: true,
|
|
373
|
-
|
|
374
|
-
|
|
1153
|
+
require_auth: false,
|
|
1154
|
+
backend_call: { type: 'local', name: 'verify_harness' },
|
|
1155
|
+
handler: appsVerifyHandler,
|
|
375
1156
|
},
|
|
376
1157
|
{
|
|
377
1158
|
command_path: ['apps', 'link'],
|
|
@@ -387,9 +1168,31 @@ export const appsCommandSpecs = [
|
|
|
387
1168
|
examples: ['notis apps link abc123', 'notis apps link abc123 ./my-app'],
|
|
388
1169
|
mutates: true,
|
|
389
1170
|
idempotent: true,
|
|
1171
|
+
require_auth: false,
|
|
390
1172
|
backend_call: { type: 'local', name: 'write_link_state' },
|
|
391
1173
|
handler: appsLinkHandler,
|
|
392
1174
|
},
|
|
1175
|
+
{
|
|
1176
|
+
command_path: ['apps', 'pull'],
|
|
1177
|
+
summary: 'Download a Notis app source snapshot into a local project folder.',
|
|
1178
|
+
when_to_use: 'Edit an installed app locally. Pulls the persisted source and links the directory to the app.',
|
|
1179
|
+
args_schema: {
|
|
1180
|
+
arguments: [
|
|
1181
|
+
{ token: '<app-id>', description: 'Remote app ID to pull.' },
|
|
1182
|
+
{ token: '[dir]', key: 'dir', description: 'Target directory (defaults to the app slug).' },
|
|
1183
|
+
],
|
|
1184
|
+
options: [
|
|
1185
|
+
{ flags: '--force', description: 'Overwrite a non-empty target directory.' },
|
|
1186
|
+
{ flags: '--version <n>', description: 'Pull a specific app source version (default: latest).' },
|
|
1187
|
+
],
|
|
1188
|
+
},
|
|
1189
|
+
examples: ['notis apps pull abc123', 'notis apps pull abc123 ./my-app --force'],
|
|
1190
|
+
mutates: true,
|
|
1191
|
+
idempotent: true,
|
|
1192
|
+
require_auth: true,
|
|
1193
|
+
backend_call: { type: 'http', name: 'portal_apps/source' },
|
|
1194
|
+
handler: appsPullHandler,
|
|
1195
|
+
},
|
|
393
1196
|
{
|
|
394
1197
|
command_path: ['apps', 'deploy'],
|
|
395
1198
|
summary: 'Build and upload the app to the linked Notis app.',
|
|
@@ -402,9 +1205,10 @@ export const appsCommandSpecs = [
|
|
|
402
1205
|
options: [
|
|
403
1206
|
{ flags: '--app-id <id>', description: 'Override linked app ID.' },
|
|
404
1207
|
{ flags: '--skip-build', description: 'Skip the build step (use existing .notis/output/).' },
|
|
1208
|
+
{ flags: '--direct', description: 'Upload directly to Supabase storage, bypassing the backend server. Auto-fallback on network errors.' },
|
|
405
1209
|
],
|
|
406
1210
|
},
|
|
407
|
-
examples: ['notis apps deploy', 'notis apps deploy --skip-build', 'notis apps deploy --app-id abc123'],
|
|
1211
|
+
examples: ['notis apps deploy', 'notis apps deploy --skip-build', 'notis apps deploy --app-id abc123', 'notis apps deploy --direct'],
|
|
408
1212
|
mutates: true,
|
|
409
1213
|
idempotent: true,
|
|
410
1214
|
backend_call: { type: 'tool', name: 'notis_save_app_files' },
|
|
@@ -423,6 +1227,7 @@ export const appsCommandSpecs = [
|
|
|
423
1227
|
examples: ['notis apps doctor', 'notis apps doctor ./my-app'],
|
|
424
1228
|
mutates: false,
|
|
425
1229
|
idempotent: true,
|
|
1230
|
+
require_auth: false,
|
|
426
1231
|
backend_call: { type: 'local', name: 'project_health_check' },
|
|
427
1232
|
handler: appsDoctorHandler,
|
|
428
1233
|
},
|