@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/dist/up.js DELETED
@@ -1,229 +0,0 @@
1
- import { spawn } from 'node:child_process';
2
- import { readFile } from 'node:fs/promises';
3
- import path from 'node:path';
4
- import process from 'node:process';
5
- import { fileURLToPath } from 'node:url';
6
- import { isValidNodeId, isValidServiceId } from '@omochi-ui/core';
7
- import { parse } from 'smol-toml';
8
- const HEIGHTS = { small: 4, medium: 6, large: 9 };
9
- const UTILITY_MODES = new Set(['button', 'watch', 'process', 'tail', 'http']);
10
- function record(value, label) {
11
- if (!value || typeof value !== 'object' || Array.isArray(value))
12
- throw new Error(`${label} must be a table`);
13
- return value;
14
- }
15
- function keys(value, allowed, label) {
16
- const unexpected = Object.keys(value).find((key) => !allowed.includes(key));
17
- if (unexpected)
18
- throw new Error(`${label} has unknown field ${JSON.stringify(unexpected)}`);
19
- }
20
- function text(value, label, maximum = 200) {
21
- if (typeof value !== 'string' || !value.trim() || value.length > maximum)
22
- throw new Error(`${label} must be a non-empty string of at most ${maximum} characters`);
23
- return value.trim();
24
- }
25
- function argv(value, label) {
26
- if (!Array.isArray(value) || !value.length || value.some((item) => typeof item !== 'string' || !item)) {
27
- throw new Error(`${label} must be a non-empty array of strings`);
28
- }
29
- return [...value];
30
- }
31
- function utilityExecutable() {
32
- return fileURLToPath(import.meta.resolve('@omochi-ui/utils/bin/omochi-utils.js'));
33
- }
34
- function parseService(id, value, root) {
35
- if (!isValidServiceId(id))
36
- throw new Error(`invalid service id: ${JSON.stringify(id)}`);
37
- const service = record(value, `services.${id}`);
38
- keys(service, ['command', 'utility', 'cwd', 'env'], `services.${id}`);
39
- const hasCommand = Object.hasOwn(service, 'command');
40
- const hasUtility = Object.hasOwn(service, 'utility');
41
- if (hasCommand === hasUtility)
42
- throw new Error(`services.${id} must define exactly one of command or utility`);
43
- let command;
44
- if (hasUtility) {
45
- const utility = argv(service.utility, `services.${id}.utility`);
46
- if (!UTILITY_MODES.has(utility[0])) {
47
- throw new Error(`services.${id}.utility must start with button, watch, process, tail, or http`);
48
- }
49
- command = [process.execPath, utilityExecutable(), utility[0], '--service-id', id, ...utility.slice(1)];
50
- }
51
- else
52
- command = argv(service.command, `services.${id}.command`);
53
- const cwd = service.cwd === undefined ? root : path.resolve(root, text(service.cwd, `services.${id}.cwd`, 4096));
54
- const environment = {};
55
- if (service.env !== undefined) {
56
- const env = record(service.env, `services.${id}.env`);
57
- for (const [name, raw] of Object.entries(env)) {
58
- if (!name || typeof raw !== 'string')
59
- throw new Error(`services.${id}.env values must be strings`);
60
- environment[name] = raw;
61
- }
62
- }
63
- environment.OMOCHI_SERVICE = id;
64
- return { id, command, cwd, env: environment };
65
- }
66
- /** Parse a composition and resolve its semantic rows onto the hub's twelve-column grid. */
67
- export function parseComposition(source, file, nodeId) {
68
- if (!isValidNodeId(nodeId))
69
- throw new Error(`invalid local node id: ${JSON.stringify(nodeId)}`);
70
- let raw;
71
- try {
72
- raw = parse(source);
73
- }
74
- catch (error) {
75
- throw new Error(`could not parse ${file}: ${error instanceof Error ? error.message : String(error)}`);
76
- }
77
- const root = record(raw, 'composition');
78
- keys(root, ['version', 'id', 'services', 'dashboard'], 'composition');
79
- if (root.version !== 1)
80
- throw new Error('composition.version must be 1');
81
- const id = text(root.id, 'composition.id', 100);
82
- const directory = path.dirname(path.resolve(file));
83
- const servicesTable = record(root.services, 'services');
84
- if (!Object.keys(servicesTable).length)
85
- throw new Error('services must not be empty');
86
- const services = Object.entries(servicesTable).map(([serviceId, value]) => parseService(serviceId, value, directory));
87
- const serviceIds = new Set(services.map((service) => service.id));
88
- const dashboard = record(root.dashboard, 'dashboard');
89
- keys(dashboard, ['name', 'rows', 'panels'], 'dashboard');
90
- const name = text(dashboard.name, 'dashboard.name');
91
- if (!Array.isArray(dashboard.rows) || !dashboard.rows.length)
92
- throw new Error('dashboard.rows must be a non-empty array of tables');
93
- const panelsTable = record(dashboard.panels, 'dashboard.panels');
94
- const definitions = new Map();
95
- for (const [panelId, value] of Object.entries(panelsTable)) {
96
- text(panelId, 'dashboard panel id', 100);
97
- const panel = record(value, `dashboard.panels.${panelId}`);
98
- keys(panel, ['title', 'service', 'surface'], `dashboard.panels.${panelId}`);
99
- const service = text(panel.service, `dashboard.panels.${panelId}.service`, 64);
100
- if (!serviceIds.has(service))
101
- throw new Error(`dashboard.panels.${panelId} references undeclared service ${JSON.stringify(service)}`);
102
- definitions.set(panelId, {
103
- title: text(panel.title, `dashboard.panels.${panelId}.title`),
104
- service,
105
- surface: panel.surface === undefined ? 'main' : text(panel.surface, `dashboard.panels.${panelId}.surface`, 4096),
106
- });
107
- }
108
- if (!definitions.size)
109
- throw new Error('dashboard.panels must not be empty');
110
- const placed = new Set();
111
- const panels = [];
112
- let y = 0;
113
- for (const [rowIndex, rawRow] of dashboard.rows.entries()) {
114
- const row = record(rawRow, `dashboard.rows[${rowIndex}]`);
115
- keys(row, ['panels', 'height'], `dashboard.rows[${rowIndex}]`);
116
- if (!Array.isArray(row.panels) || !row.panels.length || row.panels.length > 12 || row.panels.some((panel) => typeof panel !== 'string')) {
117
- throw new Error(`dashboard.rows[${rowIndex}].panels must contain 1 to 12 panel ids`);
118
- }
119
- const height = row.height === undefined ? 'medium' : row.height;
120
- if (height !== 'small' && height !== 'medium' && height !== 'large') {
121
- throw new Error(`dashboard.rows[${rowIndex}].height must be small, medium, or large`);
122
- }
123
- const count = row.panels.length;
124
- const base = Math.floor(12 / count);
125
- const remainder = 12 % count;
126
- let x = 0;
127
- for (const [column, panelId] of row.panels.entries()) {
128
- const definition = definitions.get(panelId);
129
- if (!definition)
130
- throw new Error(`dashboard.rows[${rowIndex}] references unknown panel ${JSON.stringify(panelId)}`);
131
- if (placed.has(panelId))
132
- throw new Error(`dashboard panel ${JSON.stringify(panelId)} is placed more than once`);
133
- placed.add(panelId);
134
- const width = base + (column < remainder ? 1 : 0);
135
- panels.push({
136
- id: panelId,
137
- title: definition.title,
138
- reference: { nodeId, serviceId: definition.service, surfaceId: definition.surface },
139
- x, y, w: width, h: HEIGHTS[height],
140
- });
141
- x += width;
142
- }
143
- y += HEIGHTS[height];
144
- }
145
- const missing = [...definitions.keys()].find((panelId) => !placed.has(panelId));
146
- if (missing)
147
- throw new Error(`dashboard panel ${JSON.stringify(missing)} is not placed in a row`);
148
- return { id, services, preset: { id, name, panels } };
149
- }
150
- export async function loadComposition(file, nodeId) {
151
- const resolved = path.resolve(file);
152
- let source;
153
- try {
154
- source = await readFile(resolved, 'utf8');
155
- }
156
- catch (error) {
157
- throw new Error(`could not read ${resolved}: ${error instanceof Error ? error.message : String(error)}`);
158
- }
159
- return parseComposition(source, resolved, nodeId);
160
- }
161
- export function startCompositionServices(services) {
162
- const children = new Set();
163
- const groups = new Set();
164
- let closing = false;
165
- for (const service of services) {
166
- const [command, ...args] = service.command;
167
- const child = spawn(command, args, {
168
- cwd: service.cwd,
169
- env: { ...process.env, ...service.env },
170
- detached: true,
171
- shell: false,
172
- stdio: 'inherit',
173
- });
174
- children.add(child);
175
- if (child.pid !== undefined)
176
- groups.add(child.pid);
177
- child.once('error', (error) => {
178
- if (!closing)
179
- process.stderr.write(`[omochi-ui] service ${service.id} failed to start: ${error.message}\n`);
180
- });
181
- child.once('close', (code, signal) => {
182
- children.delete(child);
183
- if (child.pid !== undefined) {
184
- try {
185
- process.kill(-child.pid, 0);
186
- }
187
- catch (error) {
188
- if (error.code === 'ESRCH')
189
- groups.delete(child.pid);
190
- }
191
- }
192
- if (!closing)
193
- process.stderr.write(`[omochi-ui] service ${service.id} exited${signal ? ` from ${signal}` : ` with code ${code}`}\n`);
194
- });
195
- }
196
- const signal = (name) => {
197
- for (const group of groups) {
198
- try {
199
- process.kill(-group, name);
200
- }
201
- catch (error) {
202
- if (error.code === 'ESRCH')
203
- groups.delete(group);
204
- }
205
- }
206
- };
207
- return {
208
- async close() {
209
- if (closing)
210
- return;
211
- closing = true;
212
- signal('SIGTERM');
213
- await Promise.race([
214
- Promise.allSettled([...children].map((child) => new Promise((resolve) => child.once('close', () => resolve())))),
215
- new Promise((resolve) => setTimeout(resolve, 1000)),
216
- ]);
217
- signal('SIGKILL');
218
- },
219
- };
220
- }
221
- /** Best-effort desktop opener; the hub remains running when no desktop is available. */
222
- export function openBrowser(url) {
223
- const command = process.platform === 'darwin' ? ['open', url]
224
- : process.platform === 'win32' ? ['cmd', '/c', 'start', '', url]
225
- : ['xdg-open', url];
226
- const child = spawn(command[0], command.slice(1), { detached: true, stdio: 'ignore' });
227
- child.once('error', (error) => process.stderr.write(`[omochi-ui] could not open a browser: ${error.message}\n`));
228
- child.unref();
229
- }