@notis_ai/cli 0.2.0-beta.155.1 → 0.2.0-beta.157.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 +5180 -7281
- package/dist/base-skills/notis-apps/SKILL.md +141 -224
- package/dist/base-skills/notis-cli/SKILL.md +64 -131
- package/package.json +1 -2
- package/skills/notis-apps/cli.md +34 -95
- package/skills/notis-cli/AGENT_INSTRUCTIONS.md +1 -1
- package/src/command-specs/apps.js +326 -1562
- 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/template/app/page.tsx +47 -45
- 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 +62 -8
- package/template/packages/sdk/src/components/Skeleton.tsx +24 -0
- package/template/packages/sdk/src/config.ts +0 -2
- package/template/packages/sdk/src/hooks/useCloudComputer.ts +15 -48
- package/template/packages/sdk/src/hooks/useDatabaseSchema.ts +17 -53
- package/template/packages/sdk/src/hooks/useDatabaseSubscription.ts +2 -2
- package/template/packages/sdk/src/hooks/useDocument.ts +12 -47
- package/template/packages/sdk/src/hooks/useDocuments.ts +18 -58
- package/template/packages/sdk/src/hooks/useQuery.ts +71 -0
- package/template/packages/sdk/src/hooks/useToolQuery.ts +12 -0
- package/template/packages/sdk/src/hooks/useTopBarSearch.ts +15 -7
- package/template/packages/sdk/src/index.ts +8 -0
- package/template/packages/sdk/src/interactions.ts +2 -1
- package/template/packages/sdk/src/queryCache.ts +162 -0
- package/template/packages/sdk/src/runtime.ts +5 -0
- 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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { useCallback, useEffect } from 'react';
|
|
3
|
+
import { useCallback, useEffect, useLayoutEffect, useRef } from 'react';
|
|
4
4
|
import { useNotisRuntime } from '../provider';
|
|
5
5
|
|
|
6
6
|
interface TopBarSearchOptions {
|
|
@@ -24,8 +24,9 @@ interface TopBarSearchActions {
|
|
|
24
24
|
* While the component is mounted, typing in the top bar calls `onChange`,
|
|
25
25
|
* pressing Enter calls `onSubmit`, and the `value` prop drives the input.
|
|
26
26
|
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
27
|
+
* Use `setLoading(true)` only for an explicitly submitted search action, then
|
|
28
|
+
* restore the icon with `setLoading(false)`. Automatic reads use content
|
|
29
|
+
* skeletons initially and refresh populated content silently.
|
|
29
30
|
*
|
|
30
31
|
* Without a portal runtime, this hook is a safe no-op.
|
|
31
32
|
*
|
|
@@ -42,21 +43,28 @@ interface TopBarSearchActions {
|
|
|
42
43
|
* });
|
|
43
44
|
* ```
|
|
44
45
|
*
|
|
45
|
-
*
|
|
46
|
-
* on every render.
|
|
46
|
+
* Callback refs retain the latest committed handlers without re-registering
|
|
47
|
+
* search on every app render. Inline callbacks are safe.
|
|
47
48
|
*/
|
|
48
49
|
export function useTopBarSearch(opts: TopBarSearchOptions): TopBarSearchActions {
|
|
49
50
|
const runtime = useNotisRuntime();
|
|
50
51
|
const { value, onChange, placeholder, onSubmit } = opts;
|
|
52
|
+
const callbacks = useRef({ onChange, onSubmit });
|
|
53
|
+
useLayoutEffect(() => { callbacks.current = { onChange, onSubmit }; });
|
|
54
|
+
const hasSubmit = Boolean(onSubmit);
|
|
51
55
|
|
|
52
56
|
useEffect(() => {
|
|
53
57
|
const register = runtime?.registerTopBarSearch;
|
|
54
58
|
if (!register) return;
|
|
55
|
-
register({
|
|
59
|
+
register({
|
|
60
|
+
onChange: (next) => callbacks.current.onChange(next),
|
|
61
|
+
placeholder,
|
|
62
|
+
onSubmit: hasSubmit ? () => callbacks.current.onSubmit?.() : undefined,
|
|
63
|
+
});
|
|
56
64
|
return () => {
|
|
57
65
|
register(null);
|
|
58
66
|
};
|
|
59
|
-
}, [runtime,
|
|
67
|
+
}, [runtime, placeholder, hasSubmit]);
|
|
60
68
|
|
|
61
69
|
useEffect(() => {
|
|
62
70
|
runtime?.setTopBarSearchValue?.(value);
|
|
@@ -10,6 +10,10 @@
|
|
|
10
10
|
export { NotisProvider, useNotisRuntime } from './provider';
|
|
11
11
|
|
|
12
12
|
// Hooks
|
|
13
|
+
export { useQuery, useQueryClient } from './hooks/useQuery';
|
|
14
|
+
export type { UseQueryOptions, UseQueryResult } from './hooks/useQuery';
|
|
15
|
+
export { createQueryClient, createPrefetchQueue, queryKey } from './queryCache';
|
|
16
|
+
export type { NotisQueryClient, QuerySnapshot, QueryFetchOptions } from './queryCache';
|
|
13
17
|
export { useNotis } from './hooks/useNotis';
|
|
14
18
|
export { useDocuments } from './hooks/useDocuments';
|
|
15
19
|
export type { UseDocumentsOptions, UseDocumentsResult } from './hooks/useDocuments';
|
|
@@ -143,3 +147,7 @@ export type {
|
|
|
143
147
|
ToolCallOptions,
|
|
144
148
|
ToolInputSchema,
|
|
145
149
|
} from './runtime';
|
|
150
|
+
|
|
151
|
+
export { useToolQuery } from './hooks/useToolQuery';
|
|
152
|
+
|
|
153
|
+
export { Skeleton, ViewSkeleton } from './components/Skeleton';
|
|
@@ -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';
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/** In-memory read snapshots. The host owns the lifetime and authorization scope. */
|
|
2
|
+
export interface QuerySnapshot<T = unknown> {
|
|
3
|
+
data: T | undefined;
|
|
4
|
+
hasData: boolean;
|
|
5
|
+
isFetching: boolean;
|
|
6
|
+
error: Error | null;
|
|
7
|
+
updatedAt: number;
|
|
8
|
+
invalidation: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface QueryFetchOptions {
|
|
12
|
+
staleTimeMs?: number;
|
|
13
|
+
force?: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface NotisQueryClient {
|
|
17
|
+
getSnapshot<T>(key: string): QuerySnapshot<T>;
|
|
18
|
+
subscribe(key: string, listener: () => void): () => void;
|
|
19
|
+
fetch<T>(key: string, read: () => Promise<T>, options?: QueryFetchOptions): Promise<T>;
|
|
20
|
+
prefetch<T>(key: string, read: () => Promise<T>, options?: QueryFetchOptions): Promise<T>;
|
|
21
|
+
invalidate(key?: string): void;
|
|
22
|
+
clear(): void;
|
|
23
|
+
/** Host-only: permanently retire a revoked security scope. */
|
|
24
|
+
dispose?(): void;
|
|
25
|
+
getRevision?(): number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const EMPTY: QuerySnapshot = Object.freeze({
|
|
29
|
+
data: undefined, hasData: false, isFetching: false, error: null, updatedAt: 0, invalidation: 0,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
/** Stable keys distinguish filters, pagination, and resource identities. */
|
|
33
|
+
export function queryKey(value: unknown): string {
|
|
34
|
+
if (value instanceof Date) return JSON.stringify(value.toISOString());
|
|
35
|
+
if (Array.isArray(value)) return `[${value.map(queryKey).join(',')}]`;
|
|
36
|
+
if (value && typeof value === 'object') {
|
|
37
|
+
const record = value as Record<string, unknown>;
|
|
38
|
+
return `{${Object.keys(record).filter((key) => record[key] !== undefined).sort()
|
|
39
|
+
.map((key) => `${JSON.stringify(key)}:${queryKey(record[key])}`).join(',')}}`;
|
|
40
|
+
}
|
|
41
|
+
return JSON.stringify(value) ?? 'null';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** One shared queue can bound descriptor and data speculation together. */
|
|
45
|
+
export function createPrefetchQueue(concurrency = 2) {
|
|
46
|
+
let active = 0;
|
|
47
|
+
const waiting: Array<() => void> = [];
|
|
48
|
+
function drain() {
|
|
49
|
+
while (active < concurrency && waiting.length) waiting.shift()!();
|
|
50
|
+
}
|
|
51
|
+
return <T>(read: () => Promise<T>): Promise<T> => new Promise<T>((resolve, reject) => {
|
|
52
|
+
waiting.push(() => {
|
|
53
|
+
active += 1;
|
|
54
|
+
Promise.resolve().then(read).then(resolve, reject).finally(() => { active -= 1; drain(); });
|
|
55
|
+
});
|
|
56
|
+
drain();
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
type Entry = {
|
|
61
|
+
snapshot: QuerySnapshot;
|
|
62
|
+
listeners: Set<() => void>;
|
|
63
|
+
generation: number;
|
|
64
|
+
pending?: Promise<unknown>;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export function createQueryClient(options: {
|
|
68
|
+
maxEntries?: number;
|
|
69
|
+
now?: () => number;
|
|
70
|
+
schedule?: ReturnType<typeof createPrefetchQueue>;
|
|
71
|
+
} = {}): NotisQueryClient {
|
|
72
|
+
const entries = new Map<string, Entry>();
|
|
73
|
+
const now = options.now ?? Date.now;
|
|
74
|
+
const maxEntries = options.maxEntries ?? 100;
|
|
75
|
+
const schedule = options.schedule ?? createPrefetchQueue(2);
|
|
76
|
+
let epoch = 0;
|
|
77
|
+
let disposed = false;
|
|
78
|
+
let revision = 0;
|
|
79
|
+
|
|
80
|
+
function prune(keep?: string) {
|
|
81
|
+
for (const [oldKey, old] of entries) {
|
|
82
|
+
if (entries.size <= maxEntries) break;
|
|
83
|
+
if (oldKey !== keep && !old.listeners.size && !old.pending) entries.delete(oldKey);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
function entry(key: string): Entry {
|
|
87
|
+
let value = entries.get(key);
|
|
88
|
+
if (!value) {
|
|
89
|
+
value = { snapshot: EMPTY, listeners: new Set(), generation: 0 };
|
|
90
|
+
entries.set(key, value);
|
|
91
|
+
}
|
|
92
|
+
// Only operations, not getSnapshot during render, update the LRU.
|
|
93
|
+
entries.delete(key);
|
|
94
|
+
entries.set(key, value);
|
|
95
|
+
prune(key);
|
|
96
|
+
return value;
|
|
97
|
+
}
|
|
98
|
+
function publish(value: Entry, snapshot: QuerySnapshot) {
|
|
99
|
+
value.snapshot = snapshot;
|
|
100
|
+
for (const listener of value.listeners) listener();
|
|
101
|
+
}
|
|
102
|
+
const client: NotisQueryClient = {
|
|
103
|
+
getSnapshot<T>(key: string) { return (entries.get(key)?.snapshot ?? EMPTY) as QuerySnapshot<T>; },
|
|
104
|
+
subscribe(key, listener) {
|
|
105
|
+
const value = entry(key);
|
|
106
|
+
value.listeners.add(listener);
|
|
107
|
+
return () => { value.listeners.delete(listener); prune(); };
|
|
108
|
+
},
|
|
109
|
+
fetch<T>(key: string, read: () => Promise<T>, fetchOptions: QueryFetchOptions = {}) {
|
|
110
|
+
if (disposed) return Promise.reject(new Error('App query scope is no longer available.'));
|
|
111
|
+
const value = entry(key);
|
|
112
|
+
if (value.pending) return value.pending as Promise<T>;
|
|
113
|
+
if (!fetchOptions.force && value.snapshot.hasData && value.snapshot.updatedAt > 0
|
|
114
|
+
&& now() - value.snapshot.updatedAt < (fetchOptions.staleTimeMs ?? 30_000)) {
|
|
115
|
+
return Promise.resolve(value.snapshot.data as T);
|
|
116
|
+
}
|
|
117
|
+
const generation = ++value.generation;
|
|
118
|
+
const requestEpoch = epoch;
|
|
119
|
+
const canCommit = () => epoch === requestEpoch && value.generation === generation && entries.get(key) === value;
|
|
120
|
+
const request = Promise.resolve().then(read).then((data) => {
|
|
121
|
+
if (canCommit()) publish(value, { ...value.snapshot, data, hasData: true, isFetching: false, error: null, updatedAt: now() });
|
|
122
|
+
return data;
|
|
123
|
+
}, (reason) => {
|
|
124
|
+
const error = reason instanceof Error ? reason : new Error(String(reason));
|
|
125
|
+
if (canCommit()) publish(value, { ...value.snapshot, isFetching: false, error });
|
|
126
|
+
throw error;
|
|
127
|
+
}).finally(() => { if (value.pending === request) value.pending = undefined; prune(); });
|
|
128
|
+
value.pending = request;
|
|
129
|
+
publish(value, { ...value.snapshot, isFetching: true, error: null });
|
|
130
|
+
return request;
|
|
131
|
+
},
|
|
132
|
+
prefetch<T>(key: string, read: () => Promise<T>, fetchOptions?: QueryFetchOptions) {
|
|
133
|
+
const requestEpoch = epoch;
|
|
134
|
+
return schedule(() => {
|
|
135
|
+
if (epoch !== requestEpoch) throw new Error('App query scope was cleared.');
|
|
136
|
+
return client.fetch(key, read, fetchOptions);
|
|
137
|
+
});
|
|
138
|
+
},
|
|
139
|
+
getRevision: () => revision,
|
|
140
|
+
invalidate(key) {
|
|
141
|
+
revision += 1;
|
|
142
|
+
for (const [entryKey, value] of entries) {
|
|
143
|
+
if (key !== undefined && entryKey !== key) continue;
|
|
144
|
+
value.generation += 1;
|
|
145
|
+
value.pending = undefined;
|
|
146
|
+
publish(value, { ...value.snapshot, updatedAt: 0, isFetching: false, invalidation: value.snapshot.invalidation + 1 });
|
|
147
|
+
}
|
|
148
|
+
},
|
|
149
|
+
dispose() { disposed = true; client.clear(); },
|
|
150
|
+
clear() {
|
|
151
|
+
revision += 1;
|
|
152
|
+
epoch += 1;
|
|
153
|
+
for (const value of entries.values()) {
|
|
154
|
+
value.generation += 1;
|
|
155
|
+
value.pending = undefined;
|
|
156
|
+
publish(value, { ...EMPTY, invalidation: value.snapshot.invalidation + 1 });
|
|
157
|
+
}
|
|
158
|
+
for (const [key, value] of entries) if (!value.listeners.size) entries.delete(key);
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
return client;
|
|
162
|
+
}
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import type { ComponentType } from 'react';
|
|
11
|
+
import type { NotisQueryClient } from './queryCache';
|
|
11
12
|
|
|
12
13
|
// ---------------------------------------------------------------------------
|
|
13
14
|
// Database types
|
|
@@ -106,6 +107,8 @@ export interface ToolDescriptor {
|
|
|
106
107
|
}
|
|
107
108
|
|
|
108
109
|
export interface ToolCallOptions {
|
|
110
|
+
/** Explicitly identifies an idempotent read; never set on a mutation. */
|
|
111
|
+
readOnly?: boolean;
|
|
109
112
|
/**
|
|
110
113
|
* Coalesce an identical in-flight call. Only opt in for idempotent reads;
|
|
111
114
|
* mutations must execute once per invocation.
|
|
@@ -359,6 +362,8 @@ export interface CloudComputerFacts {
|
|
|
359
362
|
}
|
|
360
363
|
|
|
361
364
|
export interface NotisRuntime {
|
|
365
|
+
/** Optional host-scoped in-memory read cache. Older hosts remain supported. */
|
|
366
|
+
queryClient?: NotisQueryClient;
|
|
362
367
|
app: AppDescriptor;
|
|
363
368
|
route: RouteDescriptor;
|
|
364
369
|
databases: DatabaseDescriptor[];
|
|
@@ -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
|
-
}
|