@omochi-ui/cli 0.1.1 → 0.2.0
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 +47 -261
- package/bin/omochi-ui.cjs +34 -0
- package/package.json +23 -27
- package/bin/omochi-ui.js +0 -2
- package/dist/catalog-codegen.d.ts +0 -9
- package/dist/catalog-codegen.js +0 -185
- package/dist/cli.d.ts +0 -2
- package/dist/cli.js +0 -454
- package/dist/help.d.ts +0 -3
- package/dist/help.js +0 -187
- package/dist/hub/dashboards.d.ts +0 -17
- package/dist/hub/dashboards.js +0 -230
- package/dist/hub/gateway-supervisor.d.ts +0 -19
- package/dist/hub/gateway-supervisor.js +0 -75
- package/dist/hub/hub.d.ts +0 -41
- package/dist/hub/hub.js +0 -353
- package/dist/hub/ssh-gateway.d.ts +0 -16
- package/dist/hub/ssh-gateway.js +0 -110
- package/dist/hub/static-ui.d.ts +0 -2
- package/dist/hub/static-ui.js +0 -30
- package/dist/stdio-websocket-bridge.d.ts +0 -17
- package/dist/stdio-websocket-bridge.js +0 -78
- package/dist/up.d.ts +0 -21
- package/dist/up.js +0 -229
package/dist/hub/dashboards.js
DELETED
|
@@ -1,230 +0,0 @@
|
|
|
1
|
-
import { randomUUID } from 'node:crypto';
|
|
2
|
-
import { readFile, writeFile, rename, unlink } from 'node:fs/promises';
|
|
3
|
-
import path from 'node:path';
|
|
4
|
-
import { isValidNodeId, isValidServiceId } from '@omochi-ui/core';
|
|
5
|
-
import { ensureConfigRoot, omochiConfigRoot, withStateLock } from '@omochi-ui/discovery';
|
|
6
|
-
import { overlaps, referenceId } from '@omochi-ui/hub-ui/contracts';
|
|
7
|
-
class HttpError extends Error {
|
|
8
|
-
status;
|
|
9
|
-
constructor(status, message) {
|
|
10
|
-
super(message);
|
|
11
|
-
this.status = status;
|
|
12
|
-
}
|
|
13
|
-
}
|
|
14
|
-
const record = (value) => !!value && typeof value === 'object' && !Array.isArray(value);
|
|
15
|
-
const text = (value, max = 200) => typeof value === 'string' && value.trim().length > 0 && value.length <= max;
|
|
16
|
-
const integer = (value, min, max) => Number.isSafeInteger(value) && Number(value) >= min && Number(value) <= max;
|
|
17
|
-
export function validateDashboard(value) {
|
|
18
|
-
if (!record(value) || !text(value.name) || !Array.isArray(value.panels) || value.panels.length > 200) {
|
|
19
|
-
throw new HttpError(400, 'A dashboard needs a name and at most 200 panels');
|
|
20
|
-
}
|
|
21
|
-
const panels = [];
|
|
22
|
-
const ids = new Set();
|
|
23
|
-
const references = new Set();
|
|
24
|
-
for (const p of value.panels) {
|
|
25
|
-
if (!record(p) || !text(p.id, 100) || !text(p.title) || !record(p.reference)
|
|
26
|
-
|| typeof p.reference.nodeId !== 'string' || !isValidNodeId(p.reference.nodeId)
|
|
27
|
-
|| typeof p.reference.serviceId !== 'string' || !isValidServiceId(p.reference.serviceId)
|
|
28
|
-
|| !text(p.reference.surfaceId, 4096)
|
|
29
|
-
|| !integer(p.x, 0, 11) || !integer(p.y, 0, 100000) || !integer(p.w, 1, 12) || !integer(p.h, 1, 1000)
|
|
30
|
-
|| p.x + p.w > 12)
|
|
31
|
-
throw new HttpError(400, 'Invalid panel reference or grid position');
|
|
32
|
-
const panel = {
|
|
33
|
-
id: p.id, title: p.title, reference: { nodeId: p.reference.nodeId, serviceId: p.reference.serviceId, surfaceId: p.reference.surfaceId },
|
|
34
|
-
x: p.x, y: p.y, w: p.w, h: p.h,
|
|
35
|
-
};
|
|
36
|
-
const ref = referenceId(panel.reference);
|
|
37
|
-
if (ids.has(panel.id) || references.has(ref))
|
|
38
|
-
throw new HttpError(400, 'Duplicate panel or surface');
|
|
39
|
-
if (panels.some(other => overlaps(panel, other)))
|
|
40
|
-
throw new HttpError(400, 'Panels must not overlap');
|
|
41
|
-
ids.add(panel.id);
|
|
42
|
-
references.add(ref);
|
|
43
|
-
panels.push(panel);
|
|
44
|
-
}
|
|
45
|
-
return { name: value.name.trim(), panels };
|
|
46
|
-
}
|
|
47
|
-
/** Re-read under the mutation queue and cross-process lock; failed writes never replace the last good file. */
|
|
48
|
-
export class DashboardStore {
|
|
49
|
-
root;
|
|
50
|
-
queue = Promise.resolve();
|
|
51
|
-
file;
|
|
52
|
-
constructor(root = omochiConfigRoot()) {
|
|
53
|
-
this.root = root;
|
|
54
|
-
this.file = path.join(root, 'dashboards.json');
|
|
55
|
-
}
|
|
56
|
-
async read() {
|
|
57
|
-
let raw;
|
|
58
|
-
try {
|
|
59
|
-
raw = await readFile(this.file, 'utf8');
|
|
60
|
-
}
|
|
61
|
-
catch (error) {
|
|
62
|
-
if (error.code === 'ENOENT')
|
|
63
|
-
return { version: 1, dashboards: [] };
|
|
64
|
-
throw new HttpError(503, 'Could not read dashboard storage');
|
|
65
|
-
}
|
|
66
|
-
try {
|
|
67
|
-
const data = JSON.parse(raw);
|
|
68
|
-
if (!record(data) || data.version !== 1 || !Array.isArray(data.dashboards))
|
|
69
|
-
throw new Error();
|
|
70
|
-
const ids = new Set();
|
|
71
|
-
const dashboards = data.dashboards.map((item) => {
|
|
72
|
-
if (!record(item) || !text(item.id, 100) || !integer(item.revision, 1, Number.MAX_SAFE_INTEGER) || ids.has(item.id))
|
|
73
|
-
throw new Error();
|
|
74
|
-
ids.add(item.id);
|
|
75
|
-
return { ...validateDashboard(item), id: item.id, revision: item.revision };
|
|
76
|
-
});
|
|
77
|
-
return { version: 1, dashboards };
|
|
78
|
-
}
|
|
79
|
-
catch {
|
|
80
|
-
throw new HttpError(503, 'Dashboard storage is invalid; repair dashboards.json before saving');
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
async list() { await this.queue; return (await this.read()).dashboards; }
|
|
84
|
-
mutate(id, input, remove = false) {
|
|
85
|
-
const next = this.queue.then(async () => {
|
|
86
|
-
try {
|
|
87
|
-
await ensureConfigRoot(this.root);
|
|
88
|
-
return await withStateLock(this.file, async () => {
|
|
89
|
-
const file = await this.read();
|
|
90
|
-
const current = id === null ? undefined : file.dashboards.find(d => d.id === id);
|
|
91
|
-
if (id !== null && !current)
|
|
92
|
-
throw new HttpError(404, 'Dashboard not found');
|
|
93
|
-
if (current && (!record(input) || input.revision !== current.revision))
|
|
94
|
-
throw new HttpError(409, 'This dashboard changed elsewhere. Reload before saving.');
|
|
95
|
-
const dashboard = remove ? null : {
|
|
96
|
-
...validateDashboard(input), id: current?.id ?? randomUUID(), revision: (current?.revision ?? 0) + 1,
|
|
97
|
-
};
|
|
98
|
-
file.dashboards = file.dashboards.filter(d => d.id !== id);
|
|
99
|
-
if (dashboard)
|
|
100
|
-
file.dashboards.push(dashboard);
|
|
101
|
-
const temporary = path.join(this.root, `.dashboards-${randomUUID()}.tmp`);
|
|
102
|
-
try {
|
|
103
|
-
await writeFile(temporary, JSON.stringify(file, null, 2) + '\n', { mode: 0o600, flag: 'wx' });
|
|
104
|
-
await rename(temporary, this.file);
|
|
105
|
-
}
|
|
106
|
-
finally {
|
|
107
|
-
await unlink(temporary).catch(() => { });
|
|
108
|
-
}
|
|
109
|
-
return dashboard;
|
|
110
|
-
});
|
|
111
|
-
}
|
|
112
|
-
catch (error) {
|
|
113
|
-
if (error instanceof HttpError)
|
|
114
|
-
throw error;
|
|
115
|
-
throw new HttpError(503, 'Could not save dashboard storage');
|
|
116
|
-
}
|
|
117
|
-
});
|
|
118
|
-
this.queue = next.catch(() => { });
|
|
119
|
-
return next;
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
async function body(request) {
|
|
123
|
-
if (request.headers['content-type']?.split(';')[0]?.trim() !== 'application/json')
|
|
124
|
-
throw new HttpError(415, 'Use application/json');
|
|
125
|
-
const chunks = [];
|
|
126
|
-
let size = 0;
|
|
127
|
-
for await (const chunk of request) {
|
|
128
|
-
size += Buffer.byteLength(chunk);
|
|
129
|
-
if (size > 1024 * 1024)
|
|
130
|
-
throw new HttpError(413, 'Dashboard request is too large');
|
|
131
|
-
chunks.push(Buffer.from(chunk));
|
|
132
|
-
}
|
|
133
|
-
try {
|
|
134
|
-
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
135
|
-
}
|
|
136
|
-
catch {
|
|
137
|
-
throw new HttpError(400, 'Invalid JSON');
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
export function dashboardEndpoints(store, allowed) {
|
|
141
|
-
return (request, response) => {
|
|
142
|
-
const pathname = new URL(request.url ?? '/', 'http://localhost').pathname;
|
|
143
|
-
if (pathname !== '/api/dashboards' && !pathname.startsWith('/api/dashboards/'))
|
|
144
|
-
return false;
|
|
145
|
-
const send = (status, value) => {
|
|
146
|
-
response.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
|
|
147
|
-
response.end(JSON.stringify(value));
|
|
148
|
-
};
|
|
149
|
-
if (!allowed(request.headers.origin)) {
|
|
150
|
-
send(403, { error: 'Forbidden origin' });
|
|
151
|
-
return true;
|
|
152
|
-
}
|
|
153
|
-
response.setHeader('Vary', 'Origin');
|
|
154
|
-
if (request.headers.origin)
|
|
155
|
-
response.setHeader('Access-Control-Allow-Origin', request.headers.origin);
|
|
156
|
-
if (request.method === 'OPTIONS') {
|
|
157
|
-
response.writeHead(204, { 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type' });
|
|
158
|
-
response.end();
|
|
159
|
-
return true;
|
|
160
|
-
}
|
|
161
|
-
void (async () => {
|
|
162
|
-
const id = pathname === '/api/dashboards' ? null : decodeURIComponent(pathname.slice('/api/dashboards/'.length));
|
|
163
|
-
if (id !== null && (!id || id.includes('/')))
|
|
164
|
-
throw new HttpError(404, 'Dashboard not found');
|
|
165
|
-
if (request.method === 'GET') {
|
|
166
|
-
const dashboards = await store.list();
|
|
167
|
-
if (id === null)
|
|
168
|
-
send(200, dashboards);
|
|
169
|
-
else {
|
|
170
|
-
const dashboard = dashboards.find(d => d.id === id);
|
|
171
|
-
if (!dashboard)
|
|
172
|
-
throw new HttpError(404, 'Dashboard not found');
|
|
173
|
-
send(200, dashboard);
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
else if (request.method === 'POST' && id === null)
|
|
177
|
-
send(201, await store.mutate(null, await body(request)));
|
|
178
|
-
else if (request.method === 'PUT' && id !== null)
|
|
179
|
-
send(200, await store.mutate(id, await body(request)));
|
|
180
|
-
else if (request.method === 'DELETE' && id !== null) {
|
|
181
|
-
await store.mutate(id, await body(request), true);
|
|
182
|
-
send(200, { deleted: true });
|
|
183
|
-
}
|
|
184
|
-
else
|
|
185
|
-
throw new HttpError(405, 'Method not allowed');
|
|
186
|
-
})().catch(error => send(error instanceof HttpError ? error.status : error instanceof URIError ? 400 : 503, { error: error instanceof HttpError ? error.message : 'Could not save dashboard' }));
|
|
187
|
-
return true;
|
|
188
|
-
};
|
|
189
|
-
}
|
|
190
|
-
export function validatePresets(values) {
|
|
191
|
-
const ids = new Set();
|
|
192
|
-
return values.map((value) => {
|
|
193
|
-
if (!text(value.id, 100) || ids.has(value.id))
|
|
194
|
-
throw new Error(`Invalid or duplicate dashboard preset id: ${JSON.stringify(value.id)}`);
|
|
195
|
-
ids.add(value.id);
|
|
196
|
-
return { id: value.id, ...validateDashboard(value) };
|
|
197
|
-
});
|
|
198
|
-
}
|
|
199
|
-
/** Read-only presets live only for the lifetime of this hub process. */
|
|
200
|
-
export function presetEndpoints(presets, allowed) {
|
|
201
|
-
const validated = validatePresets(presets);
|
|
202
|
-
return (request, response) => {
|
|
203
|
-
const pathname = new URL(request.url ?? '/', 'http://localhost').pathname;
|
|
204
|
-
if (pathname !== '/api/presets')
|
|
205
|
-
return false;
|
|
206
|
-
const send = (status, value) => {
|
|
207
|
-
const headers = { 'Cache-Control': 'no-store' };
|
|
208
|
-
if (value !== undefined)
|
|
209
|
-
headers['Content-Type'] = 'application/json; charset=utf-8';
|
|
210
|
-
response.writeHead(status, headers);
|
|
211
|
-
response.end(value === undefined ? undefined : JSON.stringify(value));
|
|
212
|
-
};
|
|
213
|
-
if (!allowed(request.headers.origin)) {
|
|
214
|
-
send(403, { error: 'Forbidden origin' });
|
|
215
|
-
return true;
|
|
216
|
-
}
|
|
217
|
-
response.setHeader('Vary', 'Origin');
|
|
218
|
-
if (request.headers.origin)
|
|
219
|
-
response.setHeader('Access-Control-Allow-Origin', request.headers.origin);
|
|
220
|
-
if (request.method === 'OPTIONS') {
|
|
221
|
-
response.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
|
|
222
|
-
send(204);
|
|
223
|
-
}
|
|
224
|
-
else if (request.method === 'GET')
|
|
225
|
-
send(200, validated);
|
|
226
|
-
else
|
|
227
|
-
send(405, { error: 'Method not allowed' });
|
|
228
|
-
return true;
|
|
229
|
-
};
|
|
230
|
-
}
|
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
export interface GatewaySupervisor {
|
|
2
|
-
/** True when this hub started the gateway and is therefore responsible for it. */
|
|
3
|
-
readonly spawned: boolean;
|
|
4
|
-
close(): Promise<void>;
|
|
5
|
-
}
|
|
6
|
-
export interface SuperviseOptions {
|
|
7
|
-
/** Overridden in tests; defaults to this CLI's own `gateway` command. */
|
|
8
|
-
command?: string[];
|
|
9
|
-
startTimeoutMs?: number;
|
|
10
|
-
}
|
|
11
|
-
/**
|
|
12
|
-
* Makes sure a gateway is listening on this machine.
|
|
13
|
-
*
|
|
14
|
-
* A gateway already there is reused and left running, because it is not ours to stop and
|
|
15
|
-
* other hubs may be attached to it. Only a socket that refuses a connection is removed,
|
|
16
|
-
* and only then is a gateway started — a live socket is never unlinked, so two hubs
|
|
17
|
-
* racing to start end up sharing the winner's gateway instead of destroying it.
|
|
18
|
-
*/
|
|
19
|
-
export declare function superviseLocalGateway(socketPath: string, options?: SuperviseOptions): Promise<GatewaySupervisor>;
|
|
@@ -1,75 +0,0 @@
|
|
|
1
|
-
import { spawn } from 'node:child_process';
|
|
2
|
-
import { once } from 'node:events';
|
|
3
|
-
import { rm } from 'node:fs/promises';
|
|
4
|
-
import process from 'node:process';
|
|
5
|
-
import { fileURLToPath } from 'node:url';
|
|
6
|
-
import { probeGateway } from '@omochi-ui/discovery';
|
|
7
|
-
const START_TIMEOUT_MS = 10000;
|
|
8
|
-
const POLL_MS = 50;
|
|
9
|
-
/**
|
|
10
|
-
* How long to let a child report that it lost the race.
|
|
11
|
-
*
|
|
12
|
-
* The winner starts listening before the loser's bind fails, so a probe can succeed
|
|
13
|
-
* while our own child is still on its way out. Waiting for that exit is what makes
|
|
14
|
-
* `spawned` mean "this hub owns the gateway" rather than "this hub tried".
|
|
15
|
-
*/
|
|
16
|
-
const EXIT_GRACE_MS = 250;
|
|
17
|
-
const idle = { spawned: false, close: async () => { } };
|
|
18
|
-
function gatewayCommand() {
|
|
19
|
-
// `dist/hub/gateway-supervisor.js` sits one directory below `dist/cli.js`.
|
|
20
|
-
return [process.execPath, fileURLToPath(new URL('../cli.js', import.meta.url)), 'gateway'];
|
|
21
|
-
}
|
|
22
|
-
async function waitForGateway(socketPath, deadline, child) {
|
|
23
|
-
while (Date.now() < deadline) {
|
|
24
|
-
if (await probeGateway({ kind: 'unix', socketPath }))
|
|
25
|
-
return true;
|
|
26
|
-
// A child that exited lost the race for the socket, or failed outright; either way
|
|
27
|
-
// the next probe decides, because the winner is already listening.
|
|
28
|
-
if (child && child.exitCode !== null)
|
|
29
|
-
return (await probeGateway({ kind: 'unix', socketPath })) !== null;
|
|
30
|
-
await new Promise((resolveDelay) => setTimeout(resolveDelay, POLL_MS));
|
|
31
|
-
}
|
|
32
|
-
return false;
|
|
33
|
-
}
|
|
34
|
-
/**
|
|
35
|
-
* Makes sure a gateway is listening on this machine.
|
|
36
|
-
*
|
|
37
|
-
* A gateway already there is reused and left running, because it is not ours to stop and
|
|
38
|
-
* other hubs may be attached to it. Only a socket that refuses a connection is removed,
|
|
39
|
-
* and only then is a gateway started — a live socket is never unlinked, so two hubs
|
|
40
|
-
* racing to start end up sharing the winner's gateway instead of destroying it.
|
|
41
|
-
*/
|
|
42
|
-
export async function superviseLocalGateway(socketPath, options = {}) {
|
|
43
|
-
if (await probeGateway({ kind: 'unix', socketPath }))
|
|
44
|
-
return idle;
|
|
45
|
-
// Nothing answered, so any socket file here is left over from a gateway that died.
|
|
46
|
-
await rm(socketPath, { force: true });
|
|
47
|
-
const command = options.command ?? gatewayCommand();
|
|
48
|
-
const child = spawn(command[0], command.slice(1), { stdio: ['ignore', 'ignore', 'inherit'] });
|
|
49
|
-
child.unref();
|
|
50
|
-
const deadline = Date.now() + (options.startTimeoutMs ?? START_TIMEOUT_MS);
|
|
51
|
-
if (!(await waitForGateway(socketPath, deadline, child))) {
|
|
52
|
-
if (child.exitCode === null)
|
|
53
|
-
child.kill('SIGTERM');
|
|
54
|
-
throw new Error(`the gateway did not start on ${socketPath}`);
|
|
55
|
-
}
|
|
56
|
-
if (child.exitCode === null) {
|
|
57
|
-
await Promise.race([
|
|
58
|
-
once(child, 'exit'),
|
|
59
|
-
new Promise((resolveGrace) => setTimeout(resolveGrace, EXIT_GRACE_MS)),
|
|
60
|
-
]);
|
|
61
|
-
}
|
|
62
|
-
// Losing the race is normal: another hub bound the socket first and owns that process.
|
|
63
|
-
if (child.exitCode !== null)
|
|
64
|
-
return idle;
|
|
65
|
-
return {
|
|
66
|
-
// Reported live, so a gateway that dies later is never mistaken for ours to stop.
|
|
67
|
-
get spawned() {
|
|
68
|
-
return child.exitCode === null;
|
|
69
|
-
},
|
|
70
|
-
async close() {
|
|
71
|
-
if (child.exitCode === null)
|
|
72
|
-
child.kill('SIGTERM');
|
|
73
|
-
},
|
|
74
|
-
};
|
|
75
|
-
}
|
package/dist/hub/hub.d.ts
DELETED
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
import { type GatewayLocator, type NodeIdentity, type TrustStore } from '@omochi-ui/discovery';
|
|
2
|
-
import type { DashboardPreset } from '@omochi-ui/hub-ui/contracts';
|
|
3
|
-
export interface HubOptions {
|
|
4
|
-
host?: string;
|
|
5
|
-
port?: number;
|
|
6
|
-
allowOrigins?: string[];
|
|
7
|
-
/** Where to look for gateways. Defaults to the gateway on this machine. */
|
|
8
|
-
gateways?: GatewayLocator[];
|
|
9
|
-
/** The local gateway's socket; defaults to `<runtime root>/gateway.sock`. */
|
|
10
|
-
gatewaySocket?: string;
|
|
11
|
-
/** Start a local gateway when none is listening. Defaults to true. */
|
|
12
|
-
autoGateway?: boolean;
|
|
13
|
-
/** Where the persisted identity and pinned peers live; defaults to the config root. */
|
|
14
|
-
configRoot?: string;
|
|
15
|
-
/** Overrides this hub's identity and trust store; mainly useful in tests. */
|
|
16
|
-
identity?: NodeIdentity;
|
|
17
|
-
trust?: TrustStore;
|
|
18
|
-
/** Also look for gateways on the local link. Off by default. */
|
|
19
|
-
discoverLan?: boolean;
|
|
20
|
-
/** Read-only dashboard compositions exposed for this hub process only. */
|
|
21
|
-
presets?: DashboardPreset[];
|
|
22
|
-
}
|
|
23
|
-
export interface HubHandle {
|
|
24
|
-
readonly url: string;
|
|
25
|
-
readonly httpUrl: string;
|
|
26
|
-
/** How many gateways are currently connected. */
|
|
27
|
-
readonly nodeCount: number;
|
|
28
|
-
close(): Promise<void>;
|
|
29
|
-
}
|
|
30
|
-
/**
|
|
31
|
-
* Aggregates every reachable gateway into one A2UI stream.
|
|
32
|
-
*
|
|
33
|
-
* The hub knows nothing about manifests, runtime directories, or application sockets:
|
|
34
|
-
* a gateway owns all of that, and the hub only ever speaks the gateway contract. That is
|
|
35
|
-
* what lets a gateway on another machine join on exactly the same terms as the local one.
|
|
36
|
-
*
|
|
37
|
-
* Surfaces arrive namespaced by service id and are re-namespaced as
|
|
38
|
-
* `<nodeId>~<serviceId>/<surfaceId>`, so two machines running the same service stay
|
|
39
|
-
* distinct all the way out to the renderer.
|
|
40
|
-
*/
|
|
41
|
-
export declare function startHub(options?: HubOptions): Promise<HubHandle>;
|