@notis_ai/cli 0.2.0-beta.146.1 → 0.2.0-beta.151.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 +2 -2
- package/dist/agent-hooks/notis-agent-hook.mjs +542 -146
- package/dist/base-skills/notis-apps/SKILL.md +12 -9
- package/package.json +1 -1
- package/skills/notis-apps/cli.md +2 -2
- package/src/command-specs/apps.js +118 -18
- package/src/runtime/app-dev-process-identity.js +111 -0
- package/src/runtime/app-dev-server.js +236 -8
- package/src/runtime/app-platform.js +23 -1
- package/template/packages/sdk/src/config.ts +6 -0
- package/template/packages/sdk/src/hooks/useNotis.ts +3 -0
- package/template/packages/sdk/src/hooks/useNotisNavigation.ts +7 -4
- package/template/packages/sdk/src/runtime.ts +3 -0
|
@@ -12,9 +12,20 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import { createServer } from 'node:http';
|
|
15
|
-
import { spawn } from 'node:child_process';
|
|
16
|
-
import {
|
|
15
|
+
import { execFileSync, spawn } from 'node:child_process';
|
|
16
|
+
import {
|
|
17
|
+
appendFileSync,
|
|
18
|
+
existsSync,
|
|
19
|
+
mkdirSync,
|
|
20
|
+
readFileSync,
|
|
21
|
+
renameSync,
|
|
22
|
+
rmSync,
|
|
23
|
+
statSync,
|
|
24
|
+
watch as fsWatch,
|
|
25
|
+
} from 'node:fs';
|
|
26
|
+
import { freemem, loadavg, totalmem } from 'node:os';
|
|
17
27
|
import { dirname, join, resolve } from 'node:path';
|
|
28
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
18
29
|
import { fileURLToPath } from 'node:url';
|
|
19
30
|
|
|
20
31
|
import {
|
|
@@ -32,6 +43,7 @@ import {
|
|
|
32
43
|
linkAppDevSessionTarget,
|
|
33
44
|
readAppDevSessions,
|
|
34
45
|
} from './app-dev-sessions.js';
|
|
46
|
+
import { captureDesktopWatcherOwnership } from './app-dev-process-identity.js';
|
|
35
47
|
|
|
36
48
|
const CONTENT_TYPES = {
|
|
37
49
|
'.js': 'application/javascript; charset=utf-8',
|
|
@@ -44,6 +56,115 @@ const CLI_ROOT = resolve(RUNTIME_DIR, '../..');
|
|
|
44
56
|
const REPO_ROOT = resolve(RUNTIME_DIR, '../../../..');
|
|
45
57
|
const HARNESS_TEMPLATE_PATH = join(CLI_ROOT, 'template', '.harness', 'index.html.tmpl');
|
|
46
58
|
const FALLBACK_REACT_VERSION = '19.0.0';
|
|
59
|
+
const BUILD_PROCESS_STOP_GRACE_MS = 1_000;
|
|
60
|
+
const DEV_DIAGNOSTIC_INTERVAL_MS = 30_000;
|
|
61
|
+
const DEV_DIAGNOSTIC_MAX_BYTES = 20 * 1024 * 1024;
|
|
62
|
+
|
|
63
|
+
function processGroupIsRunning(pid, signalProcess = process.kill) {
|
|
64
|
+
try {
|
|
65
|
+
signalProcess(-pid, 0);
|
|
66
|
+
return true;
|
|
67
|
+
} catch (error) {
|
|
68
|
+
return error?.code !== 'ESRCH';
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function readProcessGroupRssBytes(groupPids) {
|
|
73
|
+
if (process.platform === 'win32' || groupPids.size === 0) return new Map();
|
|
74
|
+
try {
|
|
75
|
+
const output = execFileSync('ps', ['-axo', 'pgid=,rss='], {
|
|
76
|
+
encoding: 'utf8',
|
|
77
|
+
maxBuffer: 1024 * 1024,
|
|
78
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
79
|
+
});
|
|
80
|
+
const rssByGroup = new Map();
|
|
81
|
+
for (const line of output.split('\n')) {
|
|
82
|
+
const [rawGroupPid, rawRssKiB] = line.trim().split(/\s+/, 2);
|
|
83
|
+
const groupPid = Number.parseInt(rawGroupPid, 10);
|
|
84
|
+
if (!groupPids.has(groupPid)) continue;
|
|
85
|
+
const rssKiB = Number.parseInt(rawRssKiB, 10);
|
|
86
|
+
if (!Number.isFinite(rssKiB)) continue;
|
|
87
|
+
rssByGroup.set(groupPid, (rssByGroup.get(groupPid) || 0) + (rssKiB * 1024));
|
|
88
|
+
}
|
|
89
|
+
return rssByGroup;
|
|
90
|
+
} catch {
|
|
91
|
+
return new Map();
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Stop the npm wrapper and every Vite/esbuild descendant it launched.
|
|
97
|
+
*
|
|
98
|
+
* Killing only the npm PID leaves its watch process alive after a Desktop host
|
|
99
|
+
* restart. Each watcher therefore owns a separate POSIX process group. Windows
|
|
100
|
+
* uses taskkill's tree mode for the equivalent cleanup.
|
|
101
|
+
*/
|
|
102
|
+
export async function terminateBuildProcessTree(child, {
|
|
103
|
+
platform = process.platform,
|
|
104
|
+
signalProcess = process.kill,
|
|
105
|
+
spawnProcess = spawn,
|
|
106
|
+
graceMs = BUILD_PROCESS_STOP_GRACE_MS,
|
|
107
|
+
} = {}) {
|
|
108
|
+
const pid = child?.pid;
|
|
109
|
+
if (!Number.isSafeInteger(pid) || pid <= 0) return;
|
|
110
|
+
|
|
111
|
+
if (platform === 'win32') {
|
|
112
|
+
await new Promise((resolvePromise) => {
|
|
113
|
+
let settled = false;
|
|
114
|
+
const finish = () => {
|
|
115
|
+
if (settled) return;
|
|
116
|
+
settled = true;
|
|
117
|
+
resolvePromise();
|
|
118
|
+
};
|
|
119
|
+
try {
|
|
120
|
+
const killer = spawnProcess('taskkill.exe', ['/pid', String(pid), '/t', '/f'], {
|
|
121
|
+
stdio: 'ignore',
|
|
122
|
+
windowsHide: true,
|
|
123
|
+
});
|
|
124
|
+
killer.once('error', () => {
|
|
125
|
+
try {
|
|
126
|
+
child.kill('SIGTERM');
|
|
127
|
+
} catch {
|
|
128
|
+
// The wrapper already exited.
|
|
129
|
+
}
|
|
130
|
+
finish();
|
|
131
|
+
});
|
|
132
|
+
killer.once('exit', finish);
|
|
133
|
+
setTimeout(finish, graceMs).unref?.();
|
|
134
|
+
} catch {
|
|
135
|
+
try {
|
|
136
|
+
child.kill('SIGTERM');
|
|
137
|
+
} catch {
|
|
138
|
+
// The wrapper already exited.
|
|
139
|
+
}
|
|
140
|
+
finish();
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
try {
|
|
147
|
+
signalProcess(-pid, 'SIGTERM');
|
|
148
|
+
} catch (error) {
|
|
149
|
+
if (error?.code === 'ESRCH') return;
|
|
150
|
+
try {
|
|
151
|
+
child.kill('SIGTERM');
|
|
152
|
+
} catch {
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const deadline = Date.now() + graceMs;
|
|
158
|
+
while (processGroupIsRunning(pid, signalProcess) && Date.now() < deadline) {
|
|
159
|
+
await delay(25);
|
|
160
|
+
}
|
|
161
|
+
if (!processGroupIsRunning(pid, signalProcess)) return;
|
|
162
|
+
try {
|
|
163
|
+
signalProcess(-pid, 'SIGKILL');
|
|
164
|
+
} catch (error) {
|
|
165
|
+
if (error?.code !== 'ESRCH') throw error;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
47
168
|
|
|
48
169
|
function extFor(pathname) {
|
|
49
170
|
const idx = pathname.lastIndexOf('.');
|
|
@@ -246,10 +367,11 @@ function buildHarnessDescriptor({ state, manifest, appConfig, route, scenario =
|
|
|
246
367
|
icon: route.icon || null,
|
|
247
368
|
parentSlug: route.parentSlug || null,
|
|
248
369
|
default: Boolean(route.default),
|
|
370
|
+
resourceDeepLinks: route.resourceDeepLinks === true,
|
|
249
371
|
collection: route.collection || null,
|
|
250
372
|
},
|
|
251
373
|
databases,
|
|
252
|
-
context: { collectionItem: null, screenshotScenario: scenario },
|
|
374
|
+
context: { collectionItem: null, resourceId: null, screenshotScenario: scenario },
|
|
253
375
|
tools,
|
|
254
376
|
};
|
|
255
377
|
}
|
|
@@ -300,7 +422,7 @@ function renderHarnessHtml({ state, manifest, appConfig, route, harnessOptions,
|
|
|
300
422
|
/**
|
|
301
423
|
* Start the dev server for one or more apps.
|
|
302
424
|
*
|
|
303
|
-
* @param {{apps: Array<{slug: string, projectDir: string, appId?: string, targetAppId?: string, userId?: string, profileKey?: string, sessionId?: string, mountNonce?: string}>, port: number, watch?: boolean, sessionsFilePath?: string, harness?: { mode?: string, apiBase?: string, jwt?: string }, log?: (m: string) => void, logError?: (m: string) => void}} options
|
|
425
|
+
* @param {{apps: Array<{slug: string, projectDir: string, appId?: string, targetAppId?: string, userId?: string, profileKey?: string, sessionId?: string, mountNonce?: string}>, port: number, watch?: boolean, sessionsFilePath?: string, harness?: { mode?: string, apiBase?: string, jwt?: string }, diagnosticsFile?: string | null, desktopOwnerId?: string | null, desktopOwnerScope?: string | null, terminateBuildProcess?: typeof terminateBuildProcessTree, log?: (m: string) => void, logError?: (m: string) => void}} options
|
|
304
426
|
*/
|
|
305
427
|
export async function startAppDevServer({
|
|
306
428
|
apps,
|
|
@@ -308,6 +430,10 @@ export async function startAppDevServer({
|
|
|
308
430
|
watch = true,
|
|
309
431
|
sessionsFilePath,
|
|
310
432
|
harness = {},
|
|
433
|
+
diagnosticsFile = process.env.NOTIS_DEV_DIAGNOSTICS_FILE || null,
|
|
434
|
+
desktopOwnerId = process.env.NOTIS_APPS_DEV_DESKTOP_OWNER_ID || null,
|
|
435
|
+
desktopOwnerScope = process.env.NOTIS_APPS_DEV_DESKTOP_OWNER_SCOPE || null,
|
|
436
|
+
terminateBuildProcess = terminateBuildProcessTree,
|
|
311
437
|
log = (msg) => process.stdout.write(`${msg}\n`),
|
|
312
438
|
logError = (msg) => process.stderr.write(`${msg}\n`),
|
|
313
439
|
}) {
|
|
@@ -316,6 +442,9 @@ export async function startAppDevServer({
|
|
|
316
442
|
}
|
|
317
443
|
|
|
318
444
|
const appState = new Map();
|
|
445
|
+
let diagnosticsTimer = null;
|
|
446
|
+
let diagnosticWriteFailed = false;
|
|
447
|
+
let serverClosing = false;
|
|
319
448
|
// Sidebar reconciliation needs one stream for the shared host, not one
|
|
320
449
|
// long-lived HTTP/1.1 connection per discovered app. Keeping a stream per
|
|
321
450
|
// app exhausts Chromium's per-origin connection pool and can indefinitely
|
|
@@ -344,17 +473,68 @@ export async function startAppDevServer({
|
|
|
344
473
|
prepareTimer: null,
|
|
345
474
|
reloadTimer: null,
|
|
346
475
|
buildProcess: null,
|
|
476
|
+
watcherOwnership: null,
|
|
347
477
|
lastMtimeMs: 0,
|
|
348
478
|
watchPollTimer: null,
|
|
349
479
|
bundleReady: false,
|
|
350
480
|
bundleReadyPromise,
|
|
351
481
|
resolveBundleReady,
|
|
482
|
+
buildProcessStopPromise: null,
|
|
352
483
|
};
|
|
353
484
|
};
|
|
354
485
|
for (const app of apps) {
|
|
355
486
|
appState.set(app.slug, createAppState(app));
|
|
356
487
|
}
|
|
357
488
|
|
|
489
|
+
function writeDevDiagnostic(event) {
|
|
490
|
+
if (!diagnosticsFile) return;
|
|
491
|
+
const memory = process.memoryUsage();
|
|
492
|
+
const watcherPids = new Set(
|
|
493
|
+
[...appState.values()]
|
|
494
|
+
.map((state) => state.buildProcess?.pid)
|
|
495
|
+
.filter((pid) => Number.isSafeInteger(pid) && pid > 0),
|
|
496
|
+
);
|
|
497
|
+
const watcherGroupRss = readProcessGroupRssBytes(watcherPids);
|
|
498
|
+
const record = {
|
|
499
|
+
at: new Date().toISOString(),
|
|
500
|
+
event,
|
|
501
|
+
host_pid: process.pid,
|
|
502
|
+
parent_pid: process.ppid,
|
|
503
|
+
rss_bytes: memory.rss,
|
|
504
|
+
heap_used_bytes: memory.heapUsed,
|
|
505
|
+
heap_total_bytes: memory.heapTotal,
|
|
506
|
+
external_bytes: memory.external,
|
|
507
|
+
system_free_bytes: freemem(),
|
|
508
|
+
system_total_bytes: totalmem(),
|
|
509
|
+
load_average_1m: loadavg()[0],
|
|
510
|
+
watcher_groups_rss_bytes: [...watcherGroupRss.values()].reduce((total, rss) => total + rss, 0),
|
|
511
|
+
apps: [...appState.values()].map((state) => ({
|
|
512
|
+
slug: state.slug,
|
|
513
|
+
project_dir: state.projectDir,
|
|
514
|
+
watcher_pid: state.buildProcess?.pid || null,
|
|
515
|
+
watcher_exit_code: state.buildProcess?.exitCode ?? null,
|
|
516
|
+
watcher_signal: state.buildProcess?.signalCode ?? null,
|
|
517
|
+
watcher_group_rss_bytes: watcherGroupRss.get(state.buildProcess?.pid) ?? null,
|
|
518
|
+
bundle_ready: state.bundleReady,
|
|
519
|
+
})),
|
|
520
|
+
};
|
|
521
|
+
try {
|
|
522
|
+
mkdirSync(dirname(diagnosticsFile), { recursive: true, mode: 0o700 });
|
|
523
|
+
if (existsSync(diagnosticsFile) && statSync(diagnosticsFile).size >= DEV_DIAGNOSTIC_MAX_BYTES) {
|
|
524
|
+
const previous = `${diagnosticsFile}.previous`;
|
|
525
|
+
rmSync(previous, { force: true });
|
|
526
|
+
renameSync(diagnosticsFile, previous);
|
|
527
|
+
}
|
|
528
|
+
appendFileSync(diagnosticsFile, `${JSON.stringify(record)}\n`, { mode: 0o600 });
|
|
529
|
+
diagnosticWriteFailed = false;
|
|
530
|
+
} catch (error) {
|
|
531
|
+
if (!diagnosticWriteFailed) {
|
|
532
|
+
diagnosticWriteFailed = true;
|
|
533
|
+
logError(`[notis apps dev] persistent diagnostics failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
358
538
|
function broadcastReload(slug) {
|
|
359
539
|
const state = appState.get(slug);
|
|
360
540
|
if (!state) return;
|
|
@@ -827,16 +1007,39 @@ export async function startAppDevServer({
|
|
|
827
1007
|
watchManifestInputs(state);
|
|
828
1008
|
pollForBundleAndWatch(state);
|
|
829
1009
|
|
|
830
|
-
|
|
1010
|
+
const buildProcess = spawn('npm', ['run', 'build', '--', '--watch'], {
|
|
831
1011
|
cwd: state.projectDir,
|
|
1012
|
+
detached: process.platform !== 'win32',
|
|
832
1013
|
stdio: 'inherit',
|
|
833
1014
|
env: { ...process.env, NOTIS_DEV: '1' },
|
|
834
1015
|
});
|
|
1016
|
+
state.buildProcess = buildProcess;
|
|
1017
|
+
for (let attempt = 0; attempt < 5 && !state.watcherOwnership; attempt += 1) {
|
|
1018
|
+
state.watcherOwnership = captureDesktopWatcherOwnership({
|
|
1019
|
+
pid: buildProcess.pid,
|
|
1020
|
+
projectDir: state.projectDir,
|
|
1021
|
+
desktopOwnerId,
|
|
1022
|
+
desktopOwnerScope,
|
|
1023
|
+
});
|
|
1024
|
+
if (!state.watcherOwnership && attempt < 4) await delay(10);
|
|
1025
|
+
}
|
|
835
1026
|
|
|
836
|
-
|
|
1027
|
+
buildProcess.on('exit', (code) => {
|
|
837
1028
|
if (code !== 0 && code !== null) {
|
|
838
1029
|
logError(`[notis apps dev] ${state.slug}: vite build --watch exited with code ${code}`);
|
|
839
1030
|
}
|
|
1031
|
+
if (!serverClosing) {
|
|
1032
|
+
if (state.buildProcess === buildProcess) state.buildProcess = null;
|
|
1033
|
+
const stopPromise = terminateBuildProcess(buildProcess).catch((error) => {
|
|
1034
|
+
logError(`[notis apps dev] ${state.slug}: watcher cleanup failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1035
|
+
});
|
|
1036
|
+
state.buildProcessStopPromise = stopPromise;
|
|
1037
|
+
void stopPromise.finally(() => {
|
|
1038
|
+
if (state.buildProcessStopPromise === stopPromise) {
|
|
1039
|
+
state.buildProcessStopPromise = null;
|
|
1040
|
+
}
|
|
1041
|
+
});
|
|
1042
|
+
}
|
|
840
1043
|
});
|
|
841
1044
|
} else {
|
|
842
1045
|
updateBundleDir(state, resolveBundleDir(state));
|
|
@@ -845,6 +1048,12 @@ export async function startAppDevServer({
|
|
|
845
1048
|
log(`[notis apps dev] ${state.slug}: serving bundle at http://127.0.0.1:${port}/a/${state.slug}/bundle/app.js`);
|
|
846
1049
|
}
|
|
847
1050
|
|
|
1051
|
+
writeDevDiagnostic('started');
|
|
1052
|
+
if (diagnosticsFile) {
|
|
1053
|
+
diagnosticsTimer = setInterval(() => writeDevDiagnostic('sample'), DEV_DIAGNOSTIC_INTERVAL_MS);
|
|
1054
|
+
diagnosticsTimer.unref?.();
|
|
1055
|
+
}
|
|
1056
|
+
|
|
848
1057
|
return {
|
|
849
1058
|
port,
|
|
850
1059
|
updateApp(slug, updates = {}) {
|
|
@@ -866,7 +1075,19 @@ export async function startAppDevServer({
|
|
|
866
1075
|
if (!state) return Promise.reject(new Error(`unknown app: ${slug}`));
|
|
867
1076
|
return state.bundleReadyPromise;
|
|
868
1077
|
},
|
|
1078
|
+
getWatcherOwnership(slug) {
|
|
1079
|
+
const state = appState.get(slug);
|
|
1080
|
+
if (!state) throw new Error(`unknown app: ${slug}`);
|
|
1081
|
+
return state.watcherOwnership ? { ...state.watcherOwnership } : null;
|
|
1082
|
+
},
|
|
869
1083
|
async close() {
|
|
1084
|
+
serverClosing = true;
|
|
1085
|
+
if (diagnosticsTimer) {
|
|
1086
|
+
clearInterval(diagnosticsTimer);
|
|
1087
|
+
diagnosticsTimer = null;
|
|
1088
|
+
}
|
|
1089
|
+
writeDevDiagnostic('stopping');
|
|
1090
|
+
const buildProcessStops = [];
|
|
870
1091
|
for (const state of appState.values()) {
|
|
871
1092
|
if (state.prepareTimer) clearTimeout(state.prepareTimer);
|
|
872
1093
|
if (state.reloadTimer) clearTimeout(state.reloadTimer);
|
|
@@ -894,10 +1115,16 @@ export async function startAppDevServer({
|
|
|
894
1115
|
}
|
|
895
1116
|
}
|
|
896
1117
|
state.sseClients.clear();
|
|
897
|
-
if (state.
|
|
898
|
-
state.
|
|
1118
|
+
if (state.buildProcessStopPromise) {
|
|
1119
|
+
buildProcessStops.push(state.buildProcessStopPromise);
|
|
1120
|
+
}
|
|
1121
|
+
if (state.buildProcess) {
|
|
1122
|
+
const buildProcess = state.buildProcess;
|
|
1123
|
+
state.buildProcess = null;
|
|
1124
|
+
buildProcessStops.push(terminateBuildProcess(buildProcess));
|
|
899
1125
|
}
|
|
900
1126
|
}
|
|
1127
|
+
await Promise.allSettled(buildProcessStops);
|
|
901
1128
|
for (const res of hostSseClients) {
|
|
902
1129
|
try {
|
|
903
1130
|
res.end();
|
|
@@ -907,6 +1134,7 @@ export async function startAppDevServer({
|
|
|
907
1134
|
}
|
|
908
1135
|
hostSseClients.clear();
|
|
909
1136
|
await new Promise((resolvePromise) => server.close(() => resolvePromise()));
|
|
1137
|
+
writeDevDiagnostic('stopped');
|
|
910
1138
|
},
|
|
911
1139
|
};
|
|
912
1140
|
}
|
|
@@ -680,6 +680,9 @@ function validateConfiguredRoutes(routes) {
|
|
|
680
680
|
if (route.default) {
|
|
681
681
|
defaultCount += 1;
|
|
682
682
|
}
|
|
683
|
+
if (route.resourceDeepLinks !== undefined && typeof route.resourceDeepLinks !== 'boolean') {
|
|
684
|
+
throw usageError(`Route "${route.slug}" resourceDeepLinks must be a boolean.`);
|
|
685
|
+
}
|
|
683
686
|
}
|
|
684
687
|
|
|
685
688
|
if (defaultCount !== 1) {
|
|
@@ -799,6 +802,7 @@ export function generateManifest(appConfig, projectDir) {
|
|
|
799
802
|
icon: route.icon || null,
|
|
800
803
|
parentSlug: route.parentSlug || null,
|
|
801
804
|
default: route.default || false,
|
|
805
|
+
resourceDeepLinks: route.resourceDeepLinks === true,
|
|
802
806
|
export_name: route.exportName || route.export_name || exportNameFromPath(route.path),
|
|
803
807
|
collection: route.collection || null,
|
|
804
808
|
};
|
|
@@ -902,6 +906,24 @@ export function generateManifest(appConfig, projectDir) {
|
|
|
902
906
|
};
|
|
903
907
|
}
|
|
904
908
|
|
|
909
|
+
/**
|
|
910
|
+
* Keep the persisted app row's presentation metadata aligned with the bundle
|
|
911
|
+
* manifest. The app slug remains the stable identity; title/name is only the
|
|
912
|
+
* user-facing label.
|
|
913
|
+
*/
|
|
914
|
+
export function appRowFieldsFromManifest(manifest) {
|
|
915
|
+
const app = manifest?.app && typeof manifest.app === 'object' ? manifest.app : {};
|
|
916
|
+
const displayName = typeof app.title === 'string' && app.title.trim()
|
|
917
|
+
? app.title.trim()
|
|
918
|
+
: typeof app.name === 'string' && app.name.trim()
|
|
919
|
+
? app.name.trim()
|
|
920
|
+
: null;
|
|
921
|
+
return {
|
|
922
|
+
...(displayName ? { name: displayName } : {}),
|
|
923
|
+
accent: app.accent ?? null,
|
|
924
|
+
};
|
|
925
|
+
}
|
|
926
|
+
|
|
905
927
|
export function normalizeAppToolBindings(bindings) {
|
|
906
928
|
return (Array.isArray(bindings) ? bindings : [])
|
|
907
929
|
.map((binding) => {
|
|
@@ -2818,7 +2840,7 @@ async function updateAppVersion(supabaseUrl, supabaseKey, appId, newVersion, man
|
|
|
2818
2840
|
},
|
|
2819
2841
|
body: JSON.stringify({
|
|
2820
2842
|
manifest: { ...manifest, version: newVersion },
|
|
2821
|
-
|
|
2843
|
+
...appRowFieldsFromManifest(manifest),
|
|
2822
2844
|
updated_at: new Date().toISOString(),
|
|
2823
2845
|
}),
|
|
2824
2846
|
});
|
|
@@ -40,6 +40,12 @@ export interface NotisRouteConfig {
|
|
|
40
40
|
icon?: string;
|
|
41
41
|
parentSlug?: string | null;
|
|
42
42
|
default?: boolean;
|
|
43
|
+
/**
|
|
44
|
+
* Allow the host to address an app-owned resource on this route through the
|
|
45
|
+
* canonical `?resource=<id>` deep link. Read the incoming id with
|
|
46
|
+
* `useNotis().resourceId`.
|
|
47
|
+
*/
|
|
48
|
+
resourceDeepLinks?: boolean;
|
|
43
49
|
exportName?: string;
|
|
44
50
|
collection?: {
|
|
45
51
|
database: string;
|
|
@@ -12,6 +12,8 @@ interface NotisContext {
|
|
|
12
12
|
databases: DatabaseDescriptor[];
|
|
13
13
|
/** Selected collection item for the current route, when applicable. */
|
|
14
14
|
collectionItem: CollectionItemDetail | null;
|
|
15
|
+
/** Resource requested through this route's canonical deep link. */
|
|
16
|
+
resourceId: string | null;
|
|
15
17
|
/** Whether the runtime is loaded and available. */
|
|
16
18
|
ready: boolean;
|
|
17
19
|
}
|
|
@@ -29,6 +31,7 @@ export function useNotis(): NotisContext {
|
|
|
29
31
|
route: runtime?.route ?? null,
|
|
30
32
|
databases: runtime?.databases ?? [],
|
|
31
33
|
collectionItem: runtime?.context?.collectionItem ?? null,
|
|
34
|
+
resourceId: runtime?.context?.resourceId ?? null,
|
|
32
35
|
ready: runtime !== null,
|
|
33
36
|
};
|
|
34
37
|
}
|
|
@@ -5,7 +5,7 @@ import { useNotisRuntime } from '../provider';
|
|
|
5
5
|
|
|
6
6
|
interface NavigationActions {
|
|
7
7
|
/** Navigate to a route within the app by its path. */
|
|
8
|
-
toRoute: (path: string) => void;
|
|
8
|
+
toRoute: (path: string, options?: { resourceId?: string | null }) => void;
|
|
9
9
|
/** Navigate to a document detail view. */
|
|
10
10
|
toDocument: (documentId: string, title?: string | null) => void;
|
|
11
11
|
/** Navigate to the app's default route. */
|
|
@@ -25,11 +25,14 @@ interface NavigationActions {
|
|
|
25
25
|
export function useNotisNavigation(): NavigationActions {
|
|
26
26
|
const runtime = useNotisRuntime();
|
|
27
27
|
|
|
28
|
-
const toRoute = useCallback((path: string) => {
|
|
28
|
+
const toRoute = useCallback((path: string, options?: { resourceId?: string | null }) => {
|
|
29
29
|
if (runtime?.navigate) {
|
|
30
|
-
runtime.navigate({ kind: 'route', path });
|
|
30
|
+
runtime.navigate({ kind: 'route', path, resourceId: options?.resourceId ?? null });
|
|
31
31
|
} else if (typeof window !== 'undefined') {
|
|
32
|
-
window.location.href
|
|
32
|
+
const url = new URL(path, window.location.href);
|
|
33
|
+
if (options?.resourceId) url.searchParams.set('resource', options.resourceId);
|
|
34
|
+
else url.searchParams.delete('resource');
|
|
35
|
+
window.location.href = url.toString();
|
|
33
36
|
}
|
|
34
37
|
}, [runtime]);
|
|
35
38
|
|
|
@@ -124,6 +124,7 @@ export interface RouteDescriptor {
|
|
|
124
124
|
icon?: string | null;
|
|
125
125
|
parentSlug?: string | null;
|
|
126
126
|
default?: boolean;
|
|
127
|
+
resourceDeepLinks?: boolean;
|
|
127
128
|
collection?: {
|
|
128
129
|
database: string;
|
|
129
130
|
titleProperty: string;
|
|
@@ -172,6 +173,8 @@ export interface QueryFilter {
|
|
|
172
173
|
|
|
173
174
|
export interface NotisRuntimeContext {
|
|
174
175
|
collectionItem?: CollectionItemDetail | null;
|
|
176
|
+
/** App-owned resource requested through the route's `?resource=<id>` link. */
|
|
177
|
+
resourceId?: string | null;
|
|
175
178
|
/**
|
|
176
179
|
* Set when the app is being rendered by the screenshot harness (`notis apps
|
|
177
180
|
* screenshot`) for the named listing scenario. Lets apps and SDK components
|