@notis_ai/cli 0.2.0-beta.156.1 → 0.2.0-beta.158.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 +11 -45
- package/config/notis_app_design_rules.json +135 -0
- package/dist/agent-hooks/notis-agent-hook.mjs +8893 -10612
- package/dist/base-skills/notis-apps/SKILL.md +25 -573
- package/dist/base-skills/notis-apps/references/architecture.md +147 -0
- package/dist/base-skills/notis-apps/references/design.md +154 -0
- package/dist/base-skills/notis-apps/references/release.md +93 -0
- package/dist/base-skills/notis-apps/references/sdk.md +60 -0
- package/dist/base-skills/notis-apps/references/troubleshooting.md +26 -0
- package/dist/base-skills/notis-cli/SKILL.md +19 -267
- package/dist/base-skills/notis-cli/references/app-delivery.md +18 -0
- package/dist/base-skills/notis-cli/references/native-databases.md +20 -0
- package/dist/base-skills/notis-cli/references/tool-examples.md +56 -0
- package/dist/base-skills/notis-cli/references/troubleshooting.md +39 -0
- package/dist/base-skills/notis-query/SKILL.md +13 -651
- package/dist/base-skills/notis-query/references/database-discovery.md +59 -0
- package/dist/base-skills/notis-query/references/documents.md +50 -0
- package/dist/base-skills/notis-query/references/query.md +543 -0
- package/dist/skill-sync/index.js +24 -7
- package/dist/skill-sync/index.js.map +4 -4
- package/dist/skill-sync-worker.mjs +2989 -0
- package/package.json +1 -2
- package/skills/notis-apps/cli.md +34 -95
- package/skills/notis-cli/AGENT_INSTRUCTIONS.md +1 -1
- package/src/cli.js +4 -0
- package/src/command-specs/apps.js +322 -1560
- package/src/command-specs/diagnostics.js +37 -0
- package/src/command-specs/skills.js +23 -5
- package/src/runtime/agent-browser.js +169 -1
- package/src/runtime/app-boundary-validator.js +221 -0
- package/src/runtime/app-platform.js +359 -233
- package/src/runtime/app-test-server.js +292 -0
- package/src/runtime/profiles.js +5 -2
- package/src/runtime/skill-sync/cloud-client.ts +2 -1
- package/src/runtime/skill-sync/index.ts +24 -6
- package/src/runtime/skill-sync/types.ts +2 -0
- package/src/runtime/skill-sync-service.js +109 -0
- package/src/skill-sync-worker-entry.js +2 -0
- package/src/skill-sync-worker.js +50 -0
- package/template/app/page.tsx +45 -44
- package/template/components/page-heading.tsx +23 -0
- package/template/components/ui/badge.tsx +7 -4
- package/template/components/ui/card.tsx +24 -11
- package/template/components/ui/native-select.tsx +24 -0
- package/template/notis.config.ts +0 -1
- package/template/package.json +2 -2
- package/template/packages/sdk/package.json +1 -2
- package/template/packages/sdk/src/components/MultiSelectActionBar.tsx +55 -13
- package/template/packages/sdk/src/components/MultiSelectCheckbox.tsx +3 -1
- package/template/packages/sdk/src/config.ts +0 -2
- package/template/packages/sdk/src/hooks/useCollectionInteractions.ts +138 -28
- package/template/packages/sdk/src/hooks/useLongPressSelection.ts +79 -0
- package/template/packages/sdk/src/hooks/useMultiSelect.ts +2 -8
- package/template/packages/sdk/src/index.ts +3 -0
- package/template/packages/sdk/src/interactions/actions.ts +14 -1
- package/template/packages/sdk/src/interactions/shortcuts.tsx +79 -19
- package/template/packages/sdk/src/interactions/visibility.ts +13 -0
- package/template/packages/sdk/src/interactions.ts +5 -1
- package/template/packages/sdk/src/styles.css +28 -1
- package/src/runtime/app-dev-build-supervisor.js +0 -47
- package/src/runtime/app-dev-build.js +0 -41
- package/src/runtime/app-dev-consumers.js +0 -154
- package/src/runtime/app-dev-host-lock.js +0 -80
- package/src/runtime/app-dev-process-identity.js +0 -111
- package/src/runtime/app-dev-roots.js +0 -284
- package/src/runtime/app-dev-server.js +0 -1136
- package/src/runtime/app-dev-sessions.js +0 -185
- package/src/runtime/cli-mode.generated.js +0 -5
- package/src/runtime/cli-mode.js +0 -34
|
@@ -42,6 +42,8 @@ export interface UseShortcutsOptions {
|
|
|
42
42
|
sequenceTimeoutMs?: number;
|
|
43
43
|
/** Internal owner used to route collection shortcuts to the last interacted collection. */
|
|
44
44
|
collectionOwnerId?: string;
|
|
45
|
+
/** Evaluated at dispatch time, including for retained hidden app views. */
|
|
46
|
+
isAvailable?: () => boolean;
|
|
45
47
|
}
|
|
46
48
|
|
|
47
49
|
interface ShortcutRegistration {
|
|
@@ -52,6 +54,8 @@ interface ShortcutRegistration {
|
|
|
52
54
|
enabled: boolean;
|
|
53
55
|
sequenceTimeoutMs: number;
|
|
54
56
|
collectionOwnerId?: string;
|
|
57
|
+
/** Evaluated at dispatch time, including for retained hidden app views. */
|
|
58
|
+
isAvailable?: () => boolean;
|
|
55
59
|
getShortcuts: () => ShortcutDefinition[];
|
|
56
60
|
}
|
|
57
61
|
|
|
@@ -92,6 +96,11 @@ export function createShortcutCollectionOwner(): string {
|
|
|
92
96
|
return `collection-${state.nextOwnerId++}`;
|
|
93
97
|
}
|
|
94
98
|
|
|
99
|
+
export function releaseShortcutCollection(ownerId: string): void {
|
|
100
|
+
const state = getShortcutCollectionState();
|
|
101
|
+
if (state.activeOwnerId === ownerId) state.activeOwnerId = null;
|
|
102
|
+
}
|
|
103
|
+
|
|
95
104
|
export function activateShortcutCollection(ownerId: string): void {
|
|
96
105
|
getShortcutCollectionState().activeOwnerId = ownerId;
|
|
97
106
|
}
|
|
@@ -233,7 +242,7 @@ function applicableRegistrations(
|
|
|
233
242
|
registrations: Iterable<ShortcutRegistration>,
|
|
234
243
|
): ShortcutRegistration[] {
|
|
235
244
|
const ordered = Array.from(registrations)
|
|
236
|
-
.filter((registration) => registration.enabled)
|
|
245
|
+
.filter((registration) => registration.enabled && registration.isAvailable?.() !== false)
|
|
237
246
|
.sort((a, b) => b.priority - a.priority || b.order - a.order);
|
|
238
247
|
const activeCollectionOwner = getShortcutCollectionState().activeOwnerId;
|
|
239
248
|
const hasActiveCollection = Boolean(
|
|
@@ -248,6 +257,60 @@ function applicableRegistrations(
|
|
|
248
257
|
: ordered;
|
|
249
258
|
}
|
|
250
259
|
|
|
260
|
+
// Standalone app verification and legacy hosts may not install a provider.
|
|
261
|
+
// They must still arbitrate shortcuts together, not by effect/listener order.
|
|
262
|
+
const FALLBACK_REGISTRIES_SYMBOL = Symbol.for('notis.sdk.fallback_shortcut_registries');
|
|
263
|
+
interface FallbackShortcutRegistry {
|
|
264
|
+
registrations: Map<number, ShortcutRegistration>;
|
|
265
|
+
nextId: number;
|
|
266
|
+
dispatch: (event: KeyboardEvent) => void;
|
|
267
|
+
}
|
|
268
|
+
type FallbackShortcutGlobal = typeof globalThis & {
|
|
269
|
+
[FALLBACK_REGISTRIES_SYMBOL]?: WeakMap<Document, FallbackShortcutRegistry>;
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
function registerFallbackShortcut(input: Omit<ShortcutRegistration, 'id' | 'order'>): () => void {
|
|
273
|
+
const scope = globalThis as FallbackShortcutGlobal;
|
|
274
|
+
const registries = scope[FALLBACK_REGISTRIES_SYMBOL] ??= new WeakMap();
|
|
275
|
+
const ownerDocument = document;
|
|
276
|
+
let registry = registries.get(ownerDocument);
|
|
277
|
+
if (!registry) {
|
|
278
|
+
const registrations = new Map<number, ShortcutRegistration>();
|
|
279
|
+
registry = {
|
|
280
|
+
registrations,
|
|
281
|
+
nextId: 1,
|
|
282
|
+
dispatch: (event) => {
|
|
283
|
+
if (event.defaultPrevented) return;
|
|
284
|
+
const editable = isEditableShortcutEvent(event);
|
|
285
|
+
const token = chordToken(eventChord(event));
|
|
286
|
+
for (const registration of applicableRegistrations(registrations.values())) {
|
|
287
|
+
for (const shortcut of registration.getShortcuts()) {
|
|
288
|
+
if (shortcut.enabled === false || (editable && !shortcut.allowInEditable) || (event.repeat && !shortcut.allowRepeat)) continue;
|
|
289
|
+
const match = parseShortcut(shortcut).some(
|
|
290
|
+
(candidate) => candidate.sequence.length === 1 && chordToken(candidate.sequence[0]!) === token,
|
|
291
|
+
);
|
|
292
|
+
if (match) {
|
|
293
|
+
consumeShortcut(event, shortcut);
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
},
|
|
299
|
+
};
|
|
300
|
+
registries.set(ownerDocument, registry);
|
|
301
|
+
ownerDocument.addEventListener('keydown', registry.dispatch, true);
|
|
302
|
+
}
|
|
303
|
+
const id = registry.nextId++;
|
|
304
|
+
registry.registrations.set(id, { ...input, id, order: id });
|
|
305
|
+
return () => {
|
|
306
|
+
registry.registrations.delete(id);
|
|
307
|
+
if (registry.registrations.size === 0) {
|
|
308
|
+
ownerDocument.removeEventListener('keydown', registry.dispatch, true);
|
|
309
|
+
registries.delete(ownerDocument);
|
|
310
|
+
}
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
|
|
251
314
|
function shortcutToken(raw: string): string | null {
|
|
252
315
|
const definition: ShortcutDefinition = { id: '', keys: raw, onTrigger: () => undefined };
|
|
253
316
|
const parsed = parseShortcut(definition)[0];
|
|
@@ -575,7 +638,10 @@ export function useShortcuts(
|
|
|
575
638
|
priority = 0,
|
|
576
639
|
sequenceTimeoutMs = 1500,
|
|
577
640
|
collectionOwnerId,
|
|
641
|
+
isAvailable,
|
|
578
642
|
} = options;
|
|
643
|
+
const availableRef = useRef(isAvailable);
|
|
644
|
+
availableRef.current = isAvailable;
|
|
579
645
|
|
|
580
646
|
useEffect(() => {
|
|
581
647
|
if (!registry || !enabled) return;
|
|
@@ -585,30 +651,24 @@ export function useShortcuts(
|
|
|
585
651
|
priority: SHORTCUT_SCOPE_PRIORITY[scope] + priority,
|
|
586
652
|
sequenceTimeoutMs,
|
|
587
653
|
collectionOwnerId,
|
|
588
|
-
|
|
654
|
+
isAvailable: () => availableRef.current?.() !== false,
|
|
655
|
+
getShortcuts: () => availableRef.current?.() === false ? [] : shortcutsRef.current,
|
|
589
656
|
});
|
|
590
657
|
}, [collectionOwnerId, enabled, priority, registry, scope, sequenceTimeoutMs]);
|
|
591
658
|
|
|
592
659
|
// Compatibility fallback for SDK components rendered outside a provider.
|
|
593
660
|
useEffect(() => {
|
|
594
661
|
if (registry || !enabled) return;
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
return;
|
|
606
|
-
}
|
|
607
|
-
}
|
|
608
|
-
};
|
|
609
|
-
document.addEventListener('keydown', handleKeyDown, true);
|
|
610
|
-
return () => document.removeEventListener('keydown', handleKeyDown, true);
|
|
611
|
-
}, [enabled, registry]);
|
|
662
|
+
return registerFallbackShortcut({
|
|
663
|
+
enabled,
|
|
664
|
+
scope,
|
|
665
|
+
priority: SHORTCUT_SCOPE_PRIORITY[scope] + priority,
|
|
666
|
+
sequenceTimeoutMs,
|
|
667
|
+
collectionOwnerId,
|
|
668
|
+
isAvailable: () => availableRef.current?.() !== false,
|
|
669
|
+
getShortcuts: () => shortcutsRef.current,
|
|
670
|
+
});
|
|
671
|
+
}, [collectionOwnerId, enabled, priority, registry, scope, sequenceTimeoutMs]);
|
|
612
672
|
}
|
|
613
673
|
|
|
614
674
|
export function shortcutDisplay(raw: string): string {
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** Visibility across light and Shadow DOM, without relying on layout (virtual rows may have no rect). */
|
|
2
|
+
export function isInteractionElementVisible(element: HTMLElement | null): boolean {
|
|
3
|
+
if (!element?.isConnected) return false;
|
|
4
|
+
let current: Element | null = element;
|
|
5
|
+
while (current) {
|
|
6
|
+
if (current.hasAttribute('hidden') || current.hasAttribute('inert') || current.getAttribute('aria-hidden') === 'true') return false;
|
|
7
|
+
const style = current.ownerDocument.defaultView?.getComputedStyle(current);
|
|
8
|
+
if (style?.display === 'none' || style?.visibility === 'hidden') return false;
|
|
9
|
+
const root: Node = current.getRootNode();
|
|
10
|
+
current = current.parentElement ?? ('host' in root ? (root as ShadowRoot).host : null);
|
|
11
|
+
}
|
|
12
|
+
return true;
|
|
13
|
+
}
|
|
@@ -29,7 +29,8 @@ export type {
|
|
|
29
29
|
SelectionMarqueeRect,
|
|
30
30
|
UseCollectionInteractionsOptions,
|
|
31
31
|
} from './hooks/useCollectionInteractions';
|
|
32
|
-
export { MultiSelectActionBar } from './components/MultiSelectActionBar';
|
|
32
|
+
export { MultiSelectActionBar, BULK_ACTION_LAYOUT_EVENT } from './components/MultiSelectActionBar';
|
|
33
|
+
export type { BulkActionLayoutDetail } from './components/MultiSelectActionBar';
|
|
33
34
|
export { MultiSelectCheckbox, MultiSelectCheckbox as SelectionCheckbox } from './components/MultiSelectCheckbox';
|
|
34
35
|
export { MultiSelectDragOverlay, MultiSelectDragOverlay as SelectionMarquee } from './components/MultiSelectDragOverlay';
|
|
35
36
|
export { ShortcutHints } from './components/ShortcutHints';
|
|
@@ -39,3 +40,6 @@ export type { MultiSelectDragOverlayProps } from './components/MultiSelectDragOv
|
|
|
39
40
|
export type { MultiSelectCheckboxProps as SelectionCheckboxProps } from './components/MultiSelectCheckbox';
|
|
40
41
|
export type { MultiSelectDragOverlayProps as SelectionMarqueeProps } from './components/MultiSelectDragOverlay';
|
|
41
42
|
export type { ShortcutHint, ShortcutHintsProps } from './components/ShortcutHints';
|
|
43
|
+
|
|
44
|
+
export { useLongPressSelection } from './hooks/useLongPressSelection';
|
|
45
|
+
export { isInteractionElementVisible } from './interactions/visibility';
|
|
@@ -28,8 +28,35 @@
|
|
|
28
28
|
@apply mx-auto w-full max-w-[var(--portal-page-max-width)] px-4 py-6 md:px-4 md:py-8;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
/* Flat panel. No border, no shadow: the design bar allows a single hairline
|
|
32
|
+
* between sections and nothing around items. */
|
|
31
33
|
.notis-app-surface {
|
|
32
|
-
@apply rounded-2xl
|
|
34
|
+
@apply rounded-2xl bg-muted text-card-foreground;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/* List rows: hover and selection are carried by background tint only. Rows
|
|
38
|
+
* read as tinted panels on mobile and as transparent rows on desktop. */
|
|
39
|
+
.list-row {
|
|
40
|
+
@apply rounded-xl bg-muted px-4 py-3 transition-colors lg:bg-transparent lg:hover:bg-muted/60;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
.list-row-selected {
|
|
44
|
+
@apply bg-primary/[0.06] lg:bg-primary/[0.06] lg:hover:bg-primary/[0.06];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/* Split (list + detail) pages are full-bleed: use these instead of
|
|
48
|
+
* .notis-app-shell. The list pane is tinted and carries the one allowed
|
|
49
|
+
* hairline; the detail pane sits on the plain background. */
|
|
50
|
+
.notis-app-split {
|
|
51
|
+
@apply flex h-full min-h-0 w-full max-w-none flex-col lg:flex-row;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
.notis-app-pane-list {
|
|
55
|
+
@apply w-full shrink-0 bg-muted/40 lg:w-80 lg:border-r lg:border-border xl:w-96;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
.notis-app-pane-detail {
|
|
59
|
+
@apply min-w-0 flex-1 bg-background;
|
|
33
60
|
}
|
|
34
61
|
|
|
35
62
|
.notis-app-section {
|
|
@@ -1,47 +0,0 @@
|
|
|
1
|
-
// A separate process retains ownership of the build group if the CLI crashes.
|
|
2
|
-
// IPC disconnect is the lifetime signal; unlike polling a PID it cannot mistake
|
|
3
|
-
// a reused PID for the original owner.
|
|
4
|
-
import { spawn } from 'node:child_process';
|
|
5
|
-
|
|
6
|
-
if (!process.send) throw new Error('Build supervisor requires an IPC owner');
|
|
7
|
-
let build;
|
|
8
|
-
let stopping = false;
|
|
9
|
-
function stop(code = 0) {
|
|
10
|
-
if (stopping) return;
|
|
11
|
-
stopping = true;
|
|
12
|
-
if (!build?.pid) process.exit(code);
|
|
13
|
-
if (process.platform === 'win32') {
|
|
14
|
-
const killer = spawn('taskkill', ['/pid', String(build.pid), '/t', '/f']);
|
|
15
|
-
killer.once('error', () => process.exit(1));
|
|
16
|
-
killer.once('exit', () => process.exit(code));
|
|
17
|
-
return;
|
|
18
|
-
}
|
|
19
|
-
const signalGroup = (signal) => {
|
|
20
|
-
try { process.kill(-build.pid, signal); } catch (error) {
|
|
21
|
-
if (error.code !== 'ESRCH') throw error;
|
|
22
|
-
}
|
|
23
|
-
};
|
|
24
|
-
signalGroup('SIGTERM');
|
|
25
|
-
// npm can exit before Vite/esbuild. Keep the supervisor alive until the
|
|
26
|
-
// entire group has received the fallback signal.
|
|
27
|
-
setTimeout(() => {
|
|
28
|
-
signalGroup('SIGKILL');
|
|
29
|
-
process.exit(code);
|
|
30
|
-
}, 1000);
|
|
31
|
-
}
|
|
32
|
-
process.on('disconnect', () => stop());
|
|
33
|
-
process.on('SIGTERM', () => stop());
|
|
34
|
-
process.on('SIGINT', () => stop());
|
|
35
|
-
process.once('message', ({ command, args }) => {
|
|
36
|
-
if (stopping || !process.connected) return;
|
|
37
|
-
build = spawn(command, args, {
|
|
38
|
-
detached: process.platform !== 'win32',
|
|
39
|
-
stdio: 'inherit',
|
|
40
|
-
env: process.env,
|
|
41
|
-
});
|
|
42
|
-
build.once('spawn', () => {
|
|
43
|
-
if (process.connected) process.send({ pid: build.pid }, () => {});
|
|
44
|
-
});
|
|
45
|
-
build.once('error', () => stop(1));
|
|
46
|
-
build.once('exit', (code) => stop(code || 0));
|
|
47
|
-
});
|
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
import { spawn } from 'node:child_process';
|
|
2
|
-
import { EventEmitter } from 'node:events';
|
|
3
|
-
import { fileURLToPath } from 'node:url';
|
|
4
|
-
|
|
5
|
-
// Expose the actual npm group identity so Desktop recovery and diagnostics
|
|
6
|
-
// retain their existing ownership contract. The supervisor owns its lifetime.
|
|
7
|
-
export async function startAppDevBuild(cwd, command = 'npm', args = ['run', 'build', '--', '--watch']) {
|
|
8
|
-
const supervisor = spawn(process.execPath, [fileURLToPath(new URL('./app-dev-build-supervisor.js', import.meta.url))], {
|
|
9
|
-
cwd,
|
|
10
|
-
stdio: ['ignore', 'inherit', 'inherit', 'ipc'],
|
|
11
|
-
env: { ...process.env, ELECTRON_RUN_AS_NODE: '1', NOTIS_DEV: '1' },
|
|
12
|
-
});
|
|
13
|
-
const build = new EventEmitter();
|
|
14
|
-
build.pid = null;
|
|
15
|
-
build.exitCode = null;
|
|
16
|
-
build.signalCode = null;
|
|
17
|
-
build.kill = (signal = 'SIGTERM') => supervisor.kill(signal);
|
|
18
|
-
await new Promise((resolve, reject) => {
|
|
19
|
-
supervisor.once('error', reject);
|
|
20
|
-
supervisor.once('exit', (code, signal) => {
|
|
21
|
-
build.exitCode = code;
|
|
22
|
-
build.signalCode = signal;
|
|
23
|
-
reject(new Error(`Build supervisor exited before startup (${code ?? signal})`));
|
|
24
|
-
build.emit('exit', code, signal);
|
|
25
|
-
});
|
|
26
|
-
supervisor.once('message', ({ pid }) => {
|
|
27
|
-
build.pid = pid;
|
|
28
|
-
resolve();
|
|
29
|
-
});
|
|
30
|
-
supervisor.send({ command, args }, (error) => { if (error) reject(error); });
|
|
31
|
-
});
|
|
32
|
-
return build;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
export async function stopAppDevBuild(build) {
|
|
36
|
-
if (!build || build.exitCode !== null || build.signalCode !== null) return;
|
|
37
|
-
await new Promise((resolve) => {
|
|
38
|
-
build.once('exit', resolve);
|
|
39
|
-
build.kill('SIGTERM');
|
|
40
|
-
});
|
|
41
|
-
}
|
|
@@ -1,154 +0,0 @@
|
|
|
1
|
-
import { randomUUID } from 'node:crypto';
|
|
2
|
-
import {
|
|
3
|
-
existsSync,
|
|
4
|
-
lstatSync,
|
|
5
|
-
mkdirSync,
|
|
6
|
-
readFileSync,
|
|
7
|
-
renameSync,
|
|
8
|
-
rmSync,
|
|
9
|
-
writeFileSync,
|
|
10
|
-
} from 'node:fs';
|
|
11
|
-
import { homedir } from 'node:os';
|
|
12
|
-
import { dirname, join } from 'node:path';
|
|
13
|
-
|
|
14
|
-
export const DEFAULT_APP_DEV_CONSUMERS_FILE = join(homedir(), '.notis', 'app-dev-consumers.json');
|
|
15
|
-
export const APP_DEV_CONSUMER_EXPIRES_AFTER_MS = 10_000;
|
|
16
|
-
const waitArray = new Int32Array(new SharedArrayBuffer(4));
|
|
17
|
-
const CONSUMER_LOCK_STALE_AFTER_MS = 30_000;
|
|
18
|
-
|
|
19
|
-
function processIsRunning(pid) {
|
|
20
|
-
try {
|
|
21
|
-
process.kill(pid, 0);
|
|
22
|
-
return true;
|
|
23
|
-
} catch (error) {
|
|
24
|
-
return error?.code !== 'ESRCH';
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
function reclaimConsumerLock(lockPath) {
|
|
29
|
-
const stat = lstatSync(lockPath);
|
|
30
|
-
if (!stat.isDirectory() || stat.isSymbolicLink()) {
|
|
31
|
-
throw new Error(`Refusing unsafe app development consumer lock: ${lockPath}`);
|
|
32
|
-
}
|
|
33
|
-
let ownerPid = null;
|
|
34
|
-
try {
|
|
35
|
-
const owner = readFileSync(join(lockPath, 'owner'), 'utf8').trim();
|
|
36
|
-
const parsed = Number.parseInt(owner.split('.')[0] || '', 10);
|
|
37
|
-
ownerPid = Number.isSafeInteger(parsed) && parsed > 0 ? parsed : null;
|
|
38
|
-
} catch {
|
|
39
|
-
// An interrupted owner write is reclaimed after the bounded stale window.
|
|
40
|
-
}
|
|
41
|
-
if (ownerPid !== null) return !processIsRunning(ownerPid);
|
|
42
|
-
return Date.now() - stat.mtimeMs >= CONSUMER_LOCK_STALE_AFTER_MS;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
function withLock(filePath, callback) {
|
|
46
|
-
mkdirSync(dirname(filePath), { recursive: true, mode: 0o700 });
|
|
47
|
-
const lockPath = `${filePath}.lock`;
|
|
48
|
-
const owner = `${process.pid}.${randomUUID()}`;
|
|
49
|
-
const startedAt = Date.now();
|
|
50
|
-
while (true) {
|
|
51
|
-
try {
|
|
52
|
-
mkdirSync(lockPath, { mode: 0o700 });
|
|
53
|
-
writeFileSync(join(lockPath, 'owner'), owner, { mode: 0o600 });
|
|
54
|
-
break;
|
|
55
|
-
} catch (error) {
|
|
56
|
-
if (error?.code !== 'EEXIST') throw error;
|
|
57
|
-
try {
|
|
58
|
-
if (reclaimConsumerLock(lockPath)) {
|
|
59
|
-
rmSync(lockPath, { recursive: true, force: true });
|
|
60
|
-
continue;
|
|
61
|
-
}
|
|
62
|
-
} catch (lockError) {
|
|
63
|
-
if (lockError?.code === 'ENOENT') continue;
|
|
64
|
-
throw lockError;
|
|
65
|
-
}
|
|
66
|
-
if (Date.now() - startedAt > 2_000) {
|
|
67
|
-
throw new Error('App development consumer registry is busy.');
|
|
68
|
-
}
|
|
69
|
-
Atomics.wait(waitArray, 0, 0, 10);
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
try {
|
|
73
|
-
return callback();
|
|
74
|
-
} finally {
|
|
75
|
-
try {
|
|
76
|
-
if (readFileSync(join(lockPath, 'owner'), 'utf8').trim() === owner) {
|
|
77
|
-
rmSync(lockPath, { recursive: true, force: true });
|
|
78
|
-
}
|
|
79
|
-
} catch {
|
|
80
|
-
// A replaced lock belongs to another writer.
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
function normalize(value, now) {
|
|
86
|
-
const raw = value && typeof value === 'object' && Array.isArray(value.consumers)
|
|
87
|
-
? value.consumers
|
|
88
|
-
: [];
|
|
89
|
-
return raw.filter((lease) => {
|
|
90
|
-
const heartbeat = Date.parse(String(lease?.lastHeartbeatAt || ''));
|
|
91
|
-
return typeof lease?.instanceId === 'string'
|
|
92
|
-
&& typeof lease?.userId === 'string'
|
|
93
|
-
&& typeof lease?.apiBase === 'string'
|
|
94
|
-
&& Number.isSafeInteger(lease?.pid)
|
|
95
|
-
&& Number.isFinite(heartbeat)
|
|
96
|
-
&& now - heartbeat <= APP_DEV_CONSUMER_EXPIRES_AFTER_MS;
|
|
97
|
-
});
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
export function readAppDevConsumers(filePath = DEFAULT_APP_DEV_CONSUMERS_FILE, now = Date.now()) {
|
|
101
|
-
if (!existsSync(filePath)) return [];
|
|
102
|
-
try {
|
|
103
|
-
return normalize(JSON.parse(readFileSync(filePath, 'utf8')), now);
|
|
104
|
-
} catch {
|
|
105
|
-
return [];
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
function writeAppDevConsumers(filePath, consumers) {
|
|
110
|
-
mkdirSync(dirname(filePath), { recursive: true, mode: 0o700 });
|
|
111
|
-
const temporary = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
|
|
112
|
-
writeFileSync(temporary, JSON.stringify({ version: 1, consumers }, null, 2), { mode: 0o600 });
|
|
113
|
-
renameSync(temporary, filePath);
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
export function heartbeatAppDevConsumer(
|
|
117
|
-
lease,
|
|
118
|
-
filePath = DEFAULT_APP_DEV_CONSUMERS_FILE,
|
|
119
|
-
) {
|
|
120
|
-
return withLock(filePath, () => {
|
|
121
|
-
const now = Date.now();
|
|
122
|
-
const next = readAppDevConsumers(filePath, now)
|
|
123
|
-
.filter((entry) => entry.instanceId !== lease.instanceId);
|
|
124
|
-
next.push({
|
|
125
|
-
...lease,
|
|
126
|
-
apiBase: lease.apiBase.replace(/\/$/, ''),
|
|
127
|
-
lastHeartbeatAt: new Date(now).toISOString(),
|
|
128
|
-
});
|
|
129
|
-
writeAppDevConsumers(filePath, next);
|
|
130
|
-
return next;
|
|
131
|
-
});
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
export function removeAppDevConsumer(
|
|
135
|
-
instanceId,
|
|
136
|
-
filePath = DEFAULT_APP_DEV_CONSUMERS_FILE,
|
|
137
|
-
) {
|
|
138
|
-
return withLock(filePath, () => {
|
|
139
|
-
const next = readAppDevConsumers(filePath)
|
|
140
|
-
.filter((entry) => entry.instanceId !== instanceId);
|
|
141
|
-
writeAppDevConsumers(filePath, next);
|
|
142
|
-
return next;
|
|
143
|
-
});
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
export function hasAppDevConsumer(consumers, { mode, userId, apiBase }) {
|
|
147
|
-
if (mode === 'machine') return consumers.length > 0;
|
|
148
|
-
if (mode !== 'environment') return true;
|
|
149
|
-
const normalizedApiBase = String(apiBase || '').replace(/\/$/, '');
|
|
150
|
-
return consumers.some((consumer) => (
|
|
151
|
-
consumer.userId === userId
|
|
152
|
-
&& consumer.apiBase.replace(/\/$/, '') === normalizedApiBase
|
|
153
|
-
));
|
|
154
|
-
}
|
|
@@ -1,80 +0,0 @@
|
|
|
1
|
-
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
-
import { lstatSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
-
import { homedir } from 'node:os';
|
|
4
|
-
import { join } from 'node:path';
|
|
5
|
-
|
|
6
|
-
const DEFAULT_LOCK_ROOT = join(homedir(), '.notis', 'app-dev-host-locks');
|
|
7
|
-
const OWNER_FILE = 'owner.json';
|
|
8
|
-
|
|
9
|
-
function processIsRunning(pid) {
|
|
10
|
-
try {
|
|
11
|
-
process.kill(pid, 0);
|
|
12
|
-
return true;
|
|
13
|
-
} catch (error) {
|
|
14
|
-
return error?.code !== 'ESRCH';
|
|
15
|
-
}
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
function lockName(identity, apiBase, projectDir) {
|
|
19
|
-
return createHash('sha256').update(`${identity}\0${apiBase}\0${projectDir}`).digest('hex');
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
function readOwner(path) {
|
|
23
|
-
try {
|
|
24
|
-
const value = JSON.parse(readFileSync(join(path, OWNER_FILE), 'utf8'));
|
|
25
|
-
return Number.isSafeInteger(value?.pid) && typeof value?.ownerId === 'string'
|
|
26
|
-
? { pid: value.pid, ownerId: value.ownerId }
|
|
27
|
-
: null;
|
|
28
|
-
} catch {
|
|
29
|
-
return null;
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
function reclaimable(path, now, staleAfterMs) {
|
|
34
|
-
const stat = lstatSync(path);
|
|
35
|
-
if (!stat.isDirectory() || stat.isSymbolicLink()) {
|
|
36
|
-
throw new Error(`Refusing unsafe app development host lock: ${path}`);
|
|
37
|
-
}
|
|
38
|
-
const owner = readOwner(path);
|
|
39
|
-
if (owner) return !processIsRunning(owner.pid);
|
|
40
|
-
return now - stat.mtimeMs >= staleAfterMs;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
export function tryAcquireAppDevHostLock(options) {
|
|
44
|
-
const lockRoot = options.lockRoot || DEFAULT_LOCK_ROOT;
|
|
45
|
-
mkdirSync(lockRoot, { recursive: true, mode: 0o700 });
|
|
46
|
-
const path = join(lockRoot, lockName(options.identity, options.apiBase, options.projectDir));
|
|
47
|
-
const ownerId = `${process.pid}.${randomUUID()}`;
|
|
48
|
-
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
49
|
-
try {
|
|
50
|
-
mkdirSync(path, { mode: 0o700 });
|
|
51
|
-
try {
|
|
52
|
-
writeFileSync(join(path, OWNER_FILE), JSON.stringify({ pid: process.pid, ownerId }), {
|
|
53
|
-
mode: 0o600,
|
|
54
|
-
});
|
|
55
|
-
} catch (error) {
|
|
56
|
-
rmSync(path, { recursive: true, force: true });
|
|
57
|
-
throw error;
|
|
58
|
-
}
|
|
59
|
-
return { path, ownerId };
|
|
60
|
-
} catch (error) {
|
|
61
|
-
if (error?.code !== 'EEXIST') throw error;
|
|
62
|
-
if (reclaimable(path, options.now ?? Date.now(), options.staleAfterMs ?? 60_000)) {
|
|
63
|
-
rmSync(path, { recursive: true, force: true });
|
|
64
|
-
continue;
|
|
65
|
-
}
|
|
66
|
-
return null;
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
return null;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
export function releaseAppDevHostLock(lock) {
|
|
73
|
-
try {
|
|
74
|
-
if (readOwner(lock.path)?.ownerId === lock.ownerId) {
|
|
75
|
-
rmSync(lock.path, { recursive: true, force: true });
|
|
76
|
-
}
|
|
77
|
-
} catch {
|
|
78
|
-
// A replaced lock belongs to another process.
|
|
79
|
-
}
|
|
80
|
-
}
|
|
@@ -1,111 +0,0 @@
|
|
|
1
|
-
import { execFileSync } from 'node:child_process';
|
|
2
|
-
import { readlinkSync, realpathSync } from 'node:fs';
|
|
3
|
-
|
|
4
|
-
export const NOTIS_APP_BUILD_COMMAND_FINGERPRINT = 'npm:run-build:watch:v1';
|
|
5
|
-
export const NOTIS_APPS_DEV_HOST_COMMAND_FINGERPRINT = 'notis:apps-dev:v1';
|
|
6
|
-
|
|
7
|
-
function normalizePath(value) {
|
|
8
|
-
if (typeof value !== 'string' || !value.trim()) return null;
|
|
9
|
-
try {
|
|
10
|
-
return realpathSync(value.trim());
|
|
11
|
-
} catch {
|
|
12
|
-
return null;
|
|
13
|
-
}
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
export function isExpectedNotisBuildCommand(command) {
|
|
17
|
-
if (typeof command !== 'string') return false;
|
|
18
|
-
return /(?:^|[/\s])npm(?:\s|$)/.test(command)
|
|
19
|
-
&& /\brun\s+build\b/.test(command)
|
|
20
|
-
&& /(?:^|\s)--watch(?:\s|$)/.test(command);
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export function isExpectedNotisAppsDevHostCommand(command) {
|
|
24
|
-
return typeof command === 'string'
|
|
25
|
-
&& /(?:notis(?:\.js)?|@notis_ai[/\\]cli)/.test(command)
|
|
26
|
-
&& /\bapps\s+dev\b/.test(command);
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
function readDarwinProcessCwd(pid, execute) {
|
|
30
|
-
const output = execute('lsof', ['-a', '-p', String(pid), '-d', 'cwd', '-Fn'], {
|
|
31
|
-
encoding: 'utf8',
|
|
32
|
-
stdio: ['ignore', 'pipe', 'ignore'],
|
|
33
|
-
});
|
|
34
|
-
const line = output.split('\n').find((entry) => entry.startsWith('n'));
|
|
35
|
-
return line ? line.slice(1) : null;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export function inspectAppDevWatcherProcess(pid, {
|
|
39
|
-
platform = process.platform,
|
|
40
|
-
execute = execFileSync,
|
|
41
|
-
readLink = readlinkSync,
|
|
42
|
-
} = {}) {
|
|
43
|
-
if (!Number.isSafeInteger(pid) || pid <= 0 || platform === 'win32') return null;
|
|
44
|
-
try {
|
|
45
|
-
const ps = (field) => execute('ps', ['-o', `${field}=`, '-p', String(pid)], {
|
|
46
|
-
encoding: 'utf8',
|
|
47
|
-
stdio: ['ignore', 'pipe', 'ignore'],
|
|
48
|
-
}).trim();
|
|
49
|
-
const processGroupPid = Number.parseInt(ps('pgid'), 10);
|
|
50
|
-
const startIdentity = ps('lstart').replace(/\s+/g, ' ').trim();
|
|
51
|
-
const command = ps('command');
|
|
52
|
-
const cwd = platform === 'linux'
|
|
53
|
-
? readLink(`/proc/${pid}/cwd`)
|
|
54
|
-
: readDarwinProcessCwd(pid, execute);
|
|
55
|
-
const projectDir = normalizePath(cwd);
|
|
56
|
-
if (!Number.isSafeInteger(processGroupPid) || processGroupPid <= 0) return null;
|
|
57
|
-
if (!startIdentity || !command || !projectDir) return null;
|
|
58
|
-
return { pid, processGroupPid, startIdentity, command, projectDir };
|
|
59
|
-
} catch {
|
|
60
|
-
return null;
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
export function captureDesktopWatcherOwnership({
|
|
65
|
-
pid,
|
|
66
|
-
projectDir,
|
|
67
|
-
desktopOwnerId,
|
|
68
|
-
desktopOwnerScope,
|
|
69
|
-
inspect = inspectAppDevWatcherProcess,
|
|
70
|
-
} = {}) {
|
|
71
|
-
const owner = typeof desktopOwnerId === 'string' ? desktopOwnerId.trim() : '';
|
|
72
|
-
const ownerScope = typeof desktopOwnerScope === 'string' ? desktopOwnerScope.trim() : '';
|
|
73
|
-
const expectedProjectDir = normalizePath(projectDir);
|
|
74
|
-
if (!owner || !ownerScope || !expectedProjectDir) return null;
|
|
75
|
-
const identity = inspect(pid);
|
|
76
|
-
if (
|
|
77
|
-
!identity
|
|
78
|
-
|| identity.processGroupPid !== pid
|
|
79
|
-
|| identity.projectDir !== expectedProjectDir
|
|
80
|
-
|| !isExpectedNotisBuildCommand(identity.command)
|
|
81
|
-
) {
|
|
82
|
-
return null;
|
|
83
|
-
}
|
|
84
|
-
return {
|
|
85
|
-
desktopOwnerId: owner,
|
|
86
|
-
desktopOwnerScope: ownerScope,
|
|
87
|
-
watcherProcessGroupPid: identity.processGroupPid,
|
|
88
|
-
watcherStartIdentity: identity.startIdentity,
|
|
89
|
-
watcherProjectDir: identity.projectDir,
|
|
90
|
-
watcherCommandFingerprint: NOTIS_APP_BUILD_COMMAND_FINGERPRINT,
|
|
91
|
-
};
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
export function captureDesktopHostOwnership({
|
|
95
|
-
pid = process.pid,
|
|
96
|
-
desktopOwnerId,
|
|
97
|
-
desktopOwnerScope,
|
|
98
|
-
inspect = inspectAppDevWatcherProcess,
|
|
99
|
-
} = {}) {
|
|
100
|
-
const owner = typeof desktopOwnerId === 'string' ? desktopOwnerId.trim() : '';
|
|
101
|
-
const ownerScope = typeof desktopOwnerScope === 'string' ? desktopOwnerScope.trim() : '';
|
|
102
|
-
if (!owner || !ownerScope) return null;
|
|
103
|
-
const identity = inspect(pid);
|
|
104
|
-
if (!identity || !isExpectedNotisAppsDevHostCommand(identity.command)) return null;
|
|
105
|
-
return {
|
|
106
|
-
desktopOwnerId: owner,
|
|
107
|
-
desktopOwnerScope: ownerScope,
|
|
108
|
-
desktopHostStartIdentity: identity.startIdentity,
|
|
109
|
-
desktopHostCommandFingerprint: NOTIS_APPS_DEV_HOST_COMMAND_FINGERPRINT,
|
|
110
|
-
};
|
|
111
|
-
}
|