@notis_ai/cli 0.2.0-beta.145.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/components/DocumentEditor.tsx +13 -3
- package/template/packages/sdk/src/config.ts +6 -0
- package/template/packages/sdk/src/documents.ts +7 -1
- 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 +7 -1
|
@@ -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
|
});
|
|
@@ -51,7 +51,7 @@ function DocumentEditorFallback({ documentId, variant = 'full', className }: Not
|
|
|
51
51
|
);
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
-
const isMarkdown = document.contentType
|
|
54
|
+
const isMarkdown = document.contentType === 'markdown' || !document.contentType;
|
|
55
55
|
return (
|
|
56
56
|
<div className={className}>
|
|
57
57
|
{variant === 'full' ? <h1 style={fallbackTitleStyle}>{document.title}</h1> : null}
|
|
@@ -62,6 +62,15 @@ function DocumentEditorFallback({ documentId, variant = 'full', className }: Not
|
|
|
62
62
|
<p style={fallbackNoticeStyle}>Read-only preview — editing is available inside the Notis portal.</p>
|
|
63
63
|
)}
|
|
64
64
|
</>
|
|
65
|
+
) : document.contentType === 'view' ? (
|
|
66
|
+
<div style={fallbackFrameStyle}>
|
|
67
|
+
{document.viewType === 'report'
|
|
68
|
+
? 'Interactive report'
|
|
69
|
+
: document.viewType === 'html'
|
|
70
|
+
? 'Interactive HTML document'
|
|
71
|
+
: 'View document'} — open it in Notis Documents for
|
|
72
|
+
the full native experience.
|
|
73
|
+
</div>
|
|
65
74
|
) : (
|
|
66
75
|
<div style={fallbackFrameStyle}>
|
|
67
76
|
{document.fileType ? `${document.fileType.toUpperCase()} document` : 'File document'} — open it in
|
|
@@ -76,8 +85,9 @@ function DocumentEditorFallback({ documentId, variant = 'full', className }: Not
|
|
|
76
85
|
* Embeds the document editor for a document anywhere in an app view. The host
|
|
77
86
|
* (portal) provides the implementation through `runtime.ui.DocumentEditor` and
|
|
78
87
|
* dispatches on the document's content type (markdown -> rich text editor,
|
|
79
|
-
* files -> matching viewer
|
|
80
|
-
* preview
|
|
88
|
+
* files -> matching viewer, views -> their trusted native Documents surface).
|
|
89
|
+
* Outside the portal this falls back to a read-only preview or an explicit
|
|
90
|
+
* open-in-Documents notice for native views.
|
|
81
91
|
*
|
|
82
92
|
* ```tsx
|
|
83
93
|
* <DocumentEditor documentId={entry.id} variant="body" className="min-h-[320px]" />
|
|
@@ -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;
|
|
@@ -107,7 +107,7 @@ export function normalizePropertyValue(value: unknown): unknown {
|
|
|
107
107
|
}
|
|
108
108
|
|
|
109
109
|
function normalizeContentType(value: unknown): DocumentContentType | null {
|
|
110
|
-
return value === 'markdown' || value === 'file' ? value : null;
|
|
110
|
+
return value === 'markdown' || value === 'file' || value === 'view' ? value : null;
|
|
111
111
|
}
|
|
112
112
|
|
|
113
113
|
/**
|
|
@@ -140,6 +140,12 @@ export function normalizeDocumentRecord(value: unknown): DocumentRecord {
|
|
|
140
140
|
: null,
|
|
141
141
|
contentMarkdown: optionalString(record.contentMarkdown) ?? optionalString(record.content_markdown),
|
|
142
142
|
plainText: optionalString(record.plainText) ?? optionalString(record.plain_text),
|
|
143
|
+
viewType: optionalString(record.viewType) ?? optionalString(record.view_type),
|
|
144
|
+
viewState: asRecord(record.viewState) ?? asRecord(record.view_state),
|
|
145
|
+
viewRevision:
|
|
146
|
+
typeof (record.viewRevision ?? record.view_revision) === 'number'
|
|
147
|
+
? Number(record.viewRevision ?? record.view_revision)
|
|
148
|
+
: null,
|
|
143
149
|
createdAt:
|
|
144
150
|
optionalString(record.createdAt)
|
|
145
151
|
?? optionalString(record.created_at)
|
|
@@ -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
|
|
|
@@ -71,7 +71,7 @@ export interface DatabaseDescriptor {
|
|
|
71
71
|
* (pdf, xlsx, pptx, ...). Future content types (canvas, ...) extend this
|
|
72
72
|
* union without changing the component contracts.
|
|
73
73
|
*/
|
|
74
|
-
export type DocumentContentType = 'markdown' | 'file';
|
|
74
|
+
export type DocumentContentType = 'markdown' | 'file' | 'view';
|
|
75
75
|
|
|
76
76
|
export interface DocumentRecord {
|
|
77
77
|
id: string;
|
|
@@ -86,6 +86,9 @@ export interface DocumentRecord {
|
|
|
86
86
|
contentBlocknote?: Array<Record<string, unknown>> | null;
|
|
87
87
|
contentMarkdown?: string | null;
|
|
88
88
|
plainText?: string | null;
|
|
89
|
+
viewType?: string | null;
|
|
90
|
+
viewState?: Record<string, unknown> | null;
|
|
91
|
+
viewRevision?: number | null;
|
|
89
92
|
createdAt?: string | null;
|
|
90
93
|
lastEditedTime?: string | null;
|
|
91
94
|
}
|
|
@@ -121,6 +124,7 @@ export interface RouteDescriptor {
|
|
|
121
124
|
icon?: string | null;
|
|
122
125
|
parentSlug?: string | null;
|
|
123
126
|
default?: boolean;
|
|
127
|
+
resourceDeepLinks?: boolean;
|
|
124
128
|
collection?: {
|
|
125
129
|
database: string;
|
|
126
130
|
titleProperty: string;
|
|
@@ -169,6 +173,8 @@ export interface QueryFilter {
|
|
|
169
173
|
|
|
170
174
|
export interface NotisRuntimeContext {
|
|
171
175
|
collectionItem?: CollectionItemDetail | null;
|
|
176
|
+
/** App-owned resource requested through the route's `?resource=<id>` link. */
|
|
177
|
+
resourceId?: string | null;
|
|
172
178
|
/**
|
|
173
179
|
* Set when the app is being rendered by the screenshot harness (`notis apps
|
|
174
180
|
* screenshot`) for the named listing scenario. Lets apps and SDK components
|