@notis_ai/cli 0.2.15 → 0.2.17
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 +616 -198
- package/dist/base-skills/notis-apps/SKILL.md +101 -37
- package/dist/base-skills/notis-cli/SKILL.md +80 -22
- package/package.json +1 -1
- package/skills/notis-apps/cli.md +2 -2
- package/src/command-specs/apps.js +183 -70
- 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/src/runtime/profiles.js +19 -11
- package/src/runtime/sync-skills.js +20 -4
- package/template/packages/sdk/src/components/MultiSelectActionBar.tsx +7 -1
- package/template/packages/sdk/src/config.ts +6 -0
- package/template/packages/sdk/src/hooks/useCollectionInteractions.ts +23 -6
- 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/interactions/shortcuts.tsx +7 -1
- 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
|
});
|
package/src/runtime/profiles.js
CHANGED
|
@@ -485,10 +485,15 @@ export function updateConfig(updater) {
|
|
|
485
485
|
}
|
|
486
486
|
|
|
487
487
|
/**
|
|
488
|
-
* Remove
|
|
489
|
-
*
|
|
490
|
-
*
|
|
491
|
-
*
|
|
488
|
+
* Remove every profile owned by one worktree, plus stale localhost stubs that
|
|
489
|
+
* use one of that worktree's generated profile names. Older builds sometimes
|
|
490
|
+
* lost `dev_workspace_root`, so requiring the ownership marker alone leaves a
|
|
491
|
+
* dead local profile behind after archive.
|
|
492
|
+
*
|
|
493
|
+
* Do this without normalizing the rest of the shared file. Archive cleanup can
|
|
494
|
+
* run before a packaged Desktop upgrade has migrated its legacy `jwt`;
|
|
495
|
+
* preserving unknown/raw fields here keeps that migrate-then-strip handoff
|
|
496
|
+
* intact.
|
|
492
497
|
*/
|
|
493
498
|
export function removeOwnedDevProfiles(profileNames, workspaceRoot) {
|
|
494
499
|
return withConfigWriteLock((configFile) => {
|
|
@@ -502,16 +507,19 @@ export function removeOwnedDevProfiles(profileNames, workspaceRoot) {
|
|
|
502
507
|
return [];
|
|
503
508
|
}
|
|
504
509
|
|
|
510
|
+
const generatedNames = new Set(profileNames);
|
|
505
511
|
const removed = [];
|
|
506
|
-
for (const name of
|
|
507
|
-
|
|
508
|
-
if (
|
|
509
|
-
!profile
|
|
510
|
-
|| typeof profile !== 'object'
|
|
511
|
-
|| profile.dev_workspace_root !== workspaceRoot
|
|
512
|
-
) {
|
|
512
|
+
for (const [name, profile] of Object.entries(raw.profiles)) {
|
|
513
|
+
if (!profile || typeof profile !== 'object') {
|
|
513
514
|
continue;
|
|
514
515
|
}
|
|
516
|
+
|
|
517
|
+
const ownedByWorkspace = profile.dev_workspace_root === workspaceRoot;
|
|
518
|
+
const staleGeneratedLocalProfile = generatedNames.has(name)
|
|
519
|
+
&& !profile.dev_workspace_root
|
|
520
|
+
&& isLocalApiBase(profile.api_base);
|
|
521
|
+
if (!ownedByWorkspace && !staleGeneratedLocalProfile) continue;
|
|
522
|
+
|
|
515
523
|
delete raw.profiles[name];
|
|
516
524
|
removed.push(name);
|
|
517
525
|
if (raw.current_profile === name) raw.current_profile = DEFAULT_PROFILE;
|
|
@@ -84,6 +84,25 @@ async function writeLockOwnerAtomically(lockDirectory, owner) {
|
|
|
84
84
|
await rename(temporaryOwnerPath, ownerPath);
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
async function releaseOwnedLock(lockDirectory, ownerId) {
|
|
88
|
+
const owner = await lockSnapshot(lockDirectory);
|
|
89
|
+
if (owner?.id !== ownerId) return;
|
|
90
|
+
|
|
91
|
+
const quarantineRoot = join(dirname(lockDirectory), '.stale-operation-locks');
|
|
92
|
+
const releasedDirectory = join(quarantineRoot, `released.${ownerId}`);
|
|
93
|
+
await mkdir(quarantineRoot, { recursive: true, mode: 0o700 });
|
|
94
|
+
try {
|
|
95
|
+
// Moving the owned directory releases the shared pathname atomically.
|
|
96
|
+
// Delete only the private destination so a waiter can safely acquire a new
|
|
97
|
+
// lock without racing this owner's recursive cleanup.
|
|
98
|
+
await rename(lockDirectory, releasedDirectory);
|
|
99
|
+
} catch (error) {
|
|
100
|
+
if (error?.code === 'ENOENT') return;
|
|
101
|
+
throw error;
|
|
102
|
+
}
|
|
103
|
+
await rm(releasedDirectory, { recursive: true, force: true });
|
|
104
|
+
}
|
|
105
|
+
|
|
87
106
|
/** Serialize Desktop and terminal skill sync across processes on one Mac. */
|
|
88
107
|
export async function withSkillSyncLock(callback, {
|
|
89
108
|
home = homedir(),
|
|
@@ -174,10 +193,7 @@ export async function withSkillSyncLock(callback, {
|
|
|
174
193
|
heartbeatStopped = true;
|
|
175
194
|
clearInterval(heartbeat);
|
|
176
195
|
await heartbeatInFlight;
|
|
177
|
-
|
|
178
|
-
if (owner?.id === ownerId) {
|
|
179
|
-
await rm(lockDirectory, { recursive: true, force: true });
|
|
180
|
-
}
|
|
196
|
+
await releaseOwnedLock(lockDirectory, ownerId);
|
|
181
197
|
}
|
|
182
198
|
}
|
|
183
199
|
|
|
@@ -49,10 +49,12 @@ const containerBaseStyle: CSSProperties = {
|
|
|
49
49
|
};
|
|
50
50
|
|
|
51
51
|
const countStyle: CSSProperties = {
|
|
52
|
+
flexShrink: 0,
|
|
52
53
|
padding: '0 0.5rem',
|
|
53
54
|
fontSize: '12px',
|
|
54
55
|
fontWeight: 500,
|
|
55
56
|
fontVariantNumeric: 'tabular-nums',
|
|
57
|
+
whiteSpace: 'nowrap',
|
|
56
58
|
color: 'color-mix(in srgb, hsl(var(--background)) 70%, transparent)',
|
|
57
59
|
};
|
|
58
60
|
|
|
@@ -64,6 +66,7 @@ const dividerStyle: CSSProperties = {
|
|
|
64
66
|
};
|
|
65
67
|
|
|
66
68
|
const baseButtonStyle: CSSProperties = {
|
|
69
|
+
flexShrink: 0,
|
|
67
70
|
display: 'inline-flex',
|
|
68
71
|
alignItems: 'center',
|
|
69
72
|
gap: '0.375rem',
|
|
@@ -75,6 +78,7 @@ const baseButtonStyle: CSSProperties = {
|
|
|
75
78
|
cursor: 'pointer',
|
|
76
79
|
fontSize: '13px',
|
|
77
80
|
fontFamily: 'inherit',
|
|
81
|
+
whiteSpace: 'nowrap',
|
|
78
82
|
transition: 'background-color 120ms ease, color 120ms ease',
|
|
79
83
|
};
|
|
80
84
|
|
|
@@ -188,7 +192,9 @@ function ActionButton({ action }: { action: MultiSelectAction }) {
|
|
|
188
192
|
onBlur={() => setHover(false)}
|
|
189
193
|
style={buttonStyle}
|
|
190
194
|
>
|
|
191
|
-
{action.
|
|
195
|
+
{!action.shortcut && action.icon ? (
|
|
196
|
+
<span aria-hidden style={iconSlotStyle}>{action.icon}</span>
|
|
197
|
+
) : null}
|
|
192
198
|
{display ? <kbd aria-hidden style={keycapStyle}>{display}</kbd> : null}
|
|
193
199
|
<span>{action.pending ? `${action.label}…` : action.label}</span>
|
|
194
200
|
</button>
|
|
@@ -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;
|
|
@@ -64,8 +64,8 @@ export interface CollectionKeyboardShortcuts {
|
|
|
64
64
|
clear: string | false;
|
|
65
65
|
selectAll: string | false;
|
|
66
66
|
toggle: string | false;
|
|
67
|
-
extendNext: string | false;
|
|
68
|
-
extendPrevious: string | false;
|
|
67
|
+
extendNext: string | string[] | false;
|
|
68
|
+
extendPrevious: string | string[] | false;
|
|
69
69
|
next: string | string[] | false;
|
|
70
70
|
previous: string | string[] | false;
|
|
71
71
|
up: string | false;
|
|
@@ -158,8 +158,8 @@ const DEFAULT_SHORTCUTS: CollectionKeyboardShortcuts = {
|
|
|
158
158
|
clear: 'Escape',
|
|
159
159
|
selectAll: 'Mod+A',
|
|
160
160
|
toggle: 'X',
|
|
161
|
-
extendNext: 'Shift+ArrowDown',
|
|
162
|
-
extendPrevious: 'Shift+ArrowUp',
|
|
161
|
+
extendNext: ['Shift+ArrowDown', 'Shift+ArrowRight'],
|
|
162
|
+
extendPrevious: ['Shift+ArrowUp', 'Shift+ArrowLeft'],
|
|
163
163
|
next: 'J',
|
|
164
164
|
previous: 'K',
|
|
165
165
|
up: 'ArrowUp',
|
|
@@ -169,6 +169,16 @@ const DEFAULT_SHORTCUTS: CollectionKeyboardShortcuts = {
|
|
|
169
169
|
activate: 'Enter',
|
|
170
170
|
};
|
|
171
171
|
|
|
172
|
+
function filterShortcutKeys(
|
|
173
|
+
keys: string | string[] | false,
|
|
174
|
+
predicate: (key: string) => boolean,
|
|
175
|
+
): string | string[] | false {
|
|
176
|
+
if (!keys) return false;
|
|
177
|
+
const filtered = (Array.isArray(keys) ? keys : [keys]).filter(predicate);
|
|
178
|
+
if (filtered.length === 0) return false;
|
|
179
|
+
return filtered.length === 1 ? filtered[0] : filtered;
|
|
180
|
+
}
|
|
181
|
+
|
|
172
182
|
function setsEqual(a: ReadonlySet<string>, b: ReadonlySet<string>): boolean {
|
|
173
183
|
if (a.size !== b.size) return false;
|
|
174
184
|
for (const id of a) if (!b.has(id)) return false;
|
|
@@ -442,14 +452,20 @@ export function useCollectionInteractions<T>(
|
|
|
442
452
|
if (!keys) return;
|
|
443
453
|
definitions.push({ id, label, keys, onTrigger });
|
|
444
454
|
};
|
|
455
|
+
const extendRight = filterShortcutKeys(keyboard.extendNext, (key) => key.toLowerCase().endsWith('arrowright'));
|
|
456
|
+
const extendDown = filterShortcutKeys(keyboard.extendNext, (key) => !key.toLowerCase().endsWith('arrowright'));
|
|
457
|
+
const extendLeft = filterShortcutKeys(keyboard.extendPrevious, (key) => key.toLowerCase().endsWith('arrowleft'));
|
|
458
|
+
const extendUp = filterShortcutKeys(keyboard.extendPrevious, (key) => !key.toLowerCase().endsWith('arrowleft'));
|
|
445
459
|
add('collection.clear', 'Clear selection', keyboard.clear, clear);
|
|
446
460
|
add('collection.select-all', 'Select all visible items', selectionMode === 'none' ? false : keyboard.selectAll, selectAll);
|
|
447
461
|
add('collection.toggle', 'Toggle active item', selectionMode === 'none' ? false : keyboard.toggle, () => {
|
|
448
462
|
const id = activeIdRef.current ?? anchorIdRef.current;
|
|
449
463
|
if (id) toggle(id);
|
|
450
464
|
});
|
|
451
|
-
add('collection.extend-
|
|
452
|
-
add('collection.extend-
|
|
465
|
+
add('collection.extend-down', 'Extend selection down', selectionMode !== 'multiple' ? false : extendDown, () => moveActive('down', true));
|
|
466
|
+
add('collection.extend-right', 'Extend selection right', selectionMode !== 'multiple' ? false : extendRight, () => moveActive('right', true));
|
|
467
|
+
add('collection.extend-up', 'Extend selection up', selectionMode !== 'multiple' ? false : extendUp, () => moveActive('up', true));
|
|
468
|
+
add('collection.extend-left', 'Extend selection left', selectionMode !== 'multiple' ? false : extendLeft, () => moveActive('left', true));
|
|
453
469
|
add('collection.next', 'Next item', keyboard.next, () => moveActive('next'));
|
|
454
470
|
add('collection.previous', 'Previous item', keyboard.previous, () => moveActive('previous'));
|
|
455
471
|
add('collection.up', 'Move up', keyboard.up, () => moveActive('up'));
|
|
@@ -634,6 +650,7 @@ export function useCollectionInteractions<T>(
|
|
|
634
650
|
},
|
|
635
651
|
onKeyDown: (event: ReactKeyboardEvent) => {
|
|
636
652
|
if (event.key !== ' ' || event.metaKey || event.ctrlKey || event.altKey) return;
|
|
653
|
+
if (event.target !== event.currentTarget) return;
|
|
637
654
|
event.preventDefault();
|
|
638
655
|
if (selectionMode === 'none') activate(id);
|
|
639
656
|
else toggle(id);
|
|
@@ -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
|
|
|
@@ -182,7 +182,13 @@ export function isEditableShortcutEvent(event: Event): boolean {
|
|
|
182
182
|
if (!(target instanceof HTMLElement)) return false;
|
|
183
183
|
if (target.isContentEditable || target.getAttribute('contenteditable') === 'true') return true;
|
|
184
184
|
const tag = target.tagName;
|
|
185
|
-
|
|
185
|
+
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || tag === 'BUTTON' || tag === 'SUMMARY') {
|
|
186
|
+
return true;
|
|
187
|
+
}
|
|
188
|
+
if (tag === 'A' && target.hasAttribute('href')) return true;
|
|
189
|
+
return ['button', 'link', 'menuitem', 'switch', 'tab'].includes(
|
|
190
|
+
target.getAttribute('role') || '',
|
|
191
|
+
);
|
|
186
192
|
});
|
|
187
193
|
}
|
|
188
194
|
|