@omochi-ui/cli 0.0.0-stage → 0.1.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/LICENSE +202 -0
- package/README.md +278 -2
- package/bin/omochi-ui.js +2 -0
- package/dist/catalog-codegen.d.ts +9 -0
- package/dist/catalog-codegen.js +185 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +454 -0
- package/dist/help.d.ts +3 -0
- package/dist/help.js +187 -0
- package/dist/hub/dashboards.d.ts +17 -0
- package/dist/hub/dashboards.js +230 -0
- package/dist/hub/gateway-supervisor.d.ts +19 -0
- package/dist/hub/gateway-supervisor.js +75 -0
- package/dist/hub/hub.d.ts +41 -0
- package/dist/hub/hub.js +353 -0
- package/dist/hub/ssh-gateway.d.ts +16 -0
- package/dist/hub/ssh-gateway.js +110 -0
- package/dist/hub/static-ui.d.ts +2 -0
- package/dist/hub/static-ui.js +30 -0
- package/dist/stdio-websocket-bridge.d.ts +17 -0
- package/dist/stdio-websocket-bridge.js +78 -0
- package/dist/up.d.ts +21 -0
- package/dist/up.js +229 -0
- package/package.json +37 -4
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { dirname, resolve } from 'node:path';
|
|
4
|
+
const identifier = (value) => value.replace(/[^A-Za-z0-9]+(.)/g, (_, next) => next.toUpperCase()).replace(/^[^A-Za-z_]+/, '');
|
|
5
|
+
const snake = (value) => value.replace(/([a-z0-9])([A-Z])/g, '$1_$2').replace(/[^A-Za-z0-9]+/g, '_').toLowerCase();
|
|
6
|
+
function referenceType(ref, language) {
|
|
7
|
+
const name = ref.split('/').at(-1);
|
|
8
|
+
const maps = {
|
|
9
|
+
ts: { ComponentId: 'string', ChildList: 'string[] | { componentId: string; path: string }', DataBinding: 'DataBinding', FunctionCall: 'FunctionCall', DynamicValue: 'DynamicValue', DynamicString: 'DynamicString', DynamicNumber: 'DynamicNumber', DynamicBoolean: 'DynamicBoolean', DynamicStringList: 'DynamicStringList', AccessibilityAttributes: 'Accessibility', CheckRule: 'CheckRule', Action: 'WireAction' },
|
|
10
|
+
py: { ComponentId: 'str', ChildList: 'list[str] | dict[str, str]', DataBinding: 'DataBinding', FunctionCall: 'FunctionCall', DynamicValue: 'Any', DynamicString: 'DynamicString', DynamicNumber: 'DynamicNumber', DynamicBoolean: 'DynamicBoolean', DynamicStringList: 'DynamicStringList', AccessibilityAttributes: 'dict[str, DynamicString]', CheckRule: 'dict[str, Any]', Action: 'dict[str, Any]' },
|
|
11
|
+
rs: { ComponentId: 'String', ChildList: 'Value', DataBinding: 'Value', FunctionCall: 'Value', DynamicValue: 'Value', DynamicString: 'Value', DynamicNumber: 'Value', DynamicBoolean: 'Value', DynamicStringList: 'Value', AccessibilityAttributes: 'Value', CheckRule: 'Value', Action: 'Value' },
|
|
12
|
+
};
|
|
13
|
+
const value = maps[language][name];
|
|
14
|
+
if (!value)
|
|
15
|
+
throw new Error(`unsupported schema reference: ${ref}`);
|
|
16
|
+
return value;
|
|
17
|
+
}
|
|
18
|
+
function schemaType(schema, language) {
|
|
19
|
+
if (typeof schema.$ref === 'string')
|
|
20
|
+
return referenceType(schema.$ref, language);
|
|
21
|
+
if (Array.isArray(schema.allOf)) {
|
|
22
|
+
const values = schema.allOf.filter((item) => !item.if && !item.then).map((item) => schemaType(item, language));
|
|
23
|
+
if (values.length === 1)
|
|
24
|
+
return values[0];
|
|
25
|
+
return language === 'rs' ? 'Value' : values.join(' & ');
|
|
26
|
+
}
|
|
27
|
+
const union = (schema.anyOf ?? schema.oneOf);
|
|
28
|
+
if (union) {
|
|
29
|
+
const values = union.map((item) => schemaType(item, language));
|
|
30
|
+
return language === 'rs' ? 'Value' : values.join(' | ');
|
|
31
|
+
}
|
|
32
|
+
if (Array.isArray(schema.enum)) {
|
|
33
|
+
if (language === 'ts')
|
|
34
|
+
return schema.enum.map((value) => JSON.stringify(value)).join(' | ');
|
|
35
|
+
if (language === 'py')
|
|
36
|
+
return `Literal[${schema.enum.map((value) => JSON.stringify(value)).join(', ')}]`;
|
|
37
|
+
return 'String';
|
|
38
|
+
}
|
|
39
|
+
if (schema.type === 'string')
|
|
40
|
+
return language === 'ts' ? 'string' : language === 'py' ? 'str' : 'String';
|
|
41
|
+
if (schema.type === 'number' || schema.type === 'integer')
|
|
42
|
+
return language === 'ts' ? 'number' : language === 'py' ? 'float' : 'f64';
|
|
43
|
+
if (schema.type === 'boolean')
|
|
44
|
+
return language === 'ts' ? 'boolean' : language === 'py' ? 'bool' : 'bool';
|
|
45
|
+
if (schema.type === 'array') {
|
|
46
|
+
const item = schemaType(schema.items ?? {}, language);
|
|
47
|
+
return language === 'ts' ? `${item}[]` : language === 'py' ? `list[${item}]` : `Vec<${item}>`;
|
|
48
|
+
}
|
|
49
|
+
if (schema.type === 'object' && schema.properties) {
|
|
50
|
+
const entries = Object.entries(schema.properties);
|
|
51
|
+
if (language === 'ts')
|
|
52
|
+
return `{ ${entries.map(([key, value]) => `${JSON.stringify(key)}: ${schemaType(value, language)}`).join('; ')} }`;
|
|
53
|
+
return language === 'py' ? 'dict[str, Any]' : 'Value';
|
|
54
|
+
}
|
|
55
|
+
throw new Error(`unsupported schema fragment: ${JSON.stringify(schema)}`);
|
|
56
|
+
}
|
|
57
|
+
function pointer(document, fragment) {
|
|
58
|
+
if (!fragment || fragment === '#')
|
|
59
|
+
return document;
|
|
60
|
+
if (!fragment.startsWith('#/'))
|
|
61
|
+
throw new Error(`unsupported JSON Schema fragment: ${fragment}`);
|
|
62
|
+
return fragment.slice(2).split('/').reduce((value, part) => {
|
|
63
|
+
if (!value || typeof value !== 'object')
|
|
64
|
+
throw new Error(`unresolved JSON Schema fragment: ${fragment}`);
|
|
65
|
+
return value[part.replaceAll('~1', '/').replaceAll('~0', '~')];
|
|
66
|
+
}, document);
|
|
67
|
+
}
|
|
68
|
+
async function resolveLocalRefs(value, document, base, stack = new Set()) {
|
|
69
|
+
if (Array.isArray(value))
|
|
70
|
+
return Promise.all(value.map((item) => resolveLocalRefs(item, document, base, stack)));
|
|
71
|
+
if (!value || typeof value !== 'object')
|
|
72
|
+
return value;
|
|
73
|
+
const object = value;
|
|
74
|
+
if (typeof object.$ref === 'string' && !object.$ref.startsWith('http:') && !object.$ref.startsWith('https:')) {
|
|
75
|
+
const [filename, rawFragment = ''] = object.$ref.split('#', 2);
|
|
76
|
+
const fragment = `#${rawFragment}`;
|
|
77
|
+
const referenceKey = `${base}:${object.$ref}`;
|
|
78
|
+
if (stack.has(referenceKey))
|
|
79
|
+
throw new Error(`circular schema reference: ${object.$ref}`);
|
|
80
|
+
const nextStack = new Set(stack).add(referenceKey);
|
|
81
|
+
if (filename) {
|
|
82
|
+
const path = resolve(base, filename);
|
|
83
|
+
const external = JSON.parse(await readFile(path, 'utf8'));
|
|
84
|
+
const resolved = pointer(external, fragment);
|
|
85
|
+
return resolveLocalRefs({ ...resolved, ...Object.fromEntries(Object.entries(object).filter(([key]) => key !== '$ref')) }, external, dirname(path), nextStack);
|
|
86
|
+
}
|
|
87
|
+
const resolved = pointer(document, fragment);
|
|
88
|
+
return resolveLocalRefs({ ...resolved, ...Object.fromEntries(Object.entries(object).filter(([key]) => key !== '$ref')) }, document, base, nextStack);
|
|
89
|
+
}
|
|
90
|
+
return Object.fromEntries(await Promise.all(Object.entries(object).map(async ([key, item]) => [key, await resolveLocalRefs(item, document, base, stack)])));
|
|
91
|
+
}
|
|
92
|
+
function normalize(raw, requestedName, sourceHash) {
|
|
93
|
+
const catalog = JSON.parse(raw);
|
|
94
|
+
if (!catalog.components || typeof catalog.components !== 'object')
|
|
95
|
+
throw new Error('catalog must contain a components object');
|
|
96
|
+
const title = requestedName ?? (typeof catalog.title === 'string' ? catalog.title : 'GeneratedCatalog');
|
|
97
|
+
const name = identifier(title);
|
|
98
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name))
|
|
99
|
+
throw new Error(`catalog name is not a valid identifier: ${title}`);
|
|
100
|
+
const components = Object.entries(catalog.components).map(([componentName, schema]) => {
|
|
101
|
+
const layers = Array.isArray(schema.allOf) ? schema.allOf : [schema];
|
|
102
|
+
const properties = new Map();
|
|
103
|
+
const required = new Set();
|
|
104
|
+
for (const layer of layers) {
|
|
105
|
+
if (typeof layer.$ref === 'string') {
|
|
106
|
+
if (layer.$ref.endsWith('/ComponentCommon'))
|
|
107
|
+
properties.set('accessibility', { $ref: 'https://a2ui.org/specification/v0_9/common_types.json#/$defs/AccessibilityAttributes' });
|
|
108
|
+
else if (layer.$ref.endsWith('/Checkable')) {
|
|
109
|
+
properties.set('checks', { type: 'array', items: { $ref: 'https://a2ui.org/specification/v0_9/common_types.json#/$defs/CheckRule' } });
|
|
110
|
+
properties.set('isValid', { type: 'boolean' });
|
|
111
|
+
properties.set('validationErrors', { type: 'array', items: { type: 'string' } });
|
|
112
|
+
}
|
|
113
|
+
else if (layer.$ref === '#/$defs/CatalogComponentCommon')
|
|
114
|
+
properties.set('weight', { type: 'number' });
|
|
115
|
+
else
|
|
116
|
+
throw new Error(`unsupported component mixin: ${layer.$ref}`);
|
|
117
|
+
}
|
|
118
|
+
for (const [key, value] of Object.entries(layer.properties ?? {}))
|
|
119
|
+
if (key !== 'component' && key !== 'id')
|
|
120
|
+
properties.set(key, value);
|
|
121
|
+
for (const key of layer.required ?? [])
|
|
122
|
+
if (key !== 'component' && key !== 'id')
|
|
123
|
+
required.add(key);
|
|
124
|
+
}
|
|
125
|
+
return { name: componentName, properties: [...properties].map(([propertyName, propertySchema]) => ({ name: propertyName, type: JSON.stringify(propertySchema), required: required.has(propertyName) })) };
|
|
126
|
+
});
|
|
127
|
+
return { id: String(catalog.catalogId ?? catalog.$id ?? ''), name, hash: sourceHash ?? createHash('sha256').update(raw).digest('hex'), components };
|
|
128
|
+
}
|
|
129
|
+
const propertyType = (property, language) => schemaType(JSON.parse(property.type), language);
|
|
130
|
+
const header = (model, comment) => `${comment} Generated by omochi-ui from catalog ${model.id}\n${comment} Source SHA-256: ${model.hash}\n`;
|
|
131
|
+
function typescript(model) {
|
|
132
|
+
const body = model.components.map((component) => `export interface ${component.name}Props {\n${component.properties.map((property) => ` ${JSON.stringify(property.name)}${property.required ? '' : '?'}: ${propertyType(property, 'ts')}`).join('\n')}\n}`).join('\n\n');
|
|
133
|
+
const map = model.components.map((component) => ` ${JSON.stringify(component.name)}: ${component.name}Props`).join('\n');
|
|
134
|
+
const knownTypes = ['Accessibility', 'CheckRule', 'DataBinding', 'DynamicBoolean', 'DynamicNumber', 'DynamicString', 'DynamicStringList', 'DynamicValue', 'FunctionCall', 'WireAction'];
|
|
135
|
+
const imports = ['CatalogDefinition', ...knownTypes.filter((name) => body.includes(name))];
|
|
136
|
+
return `${header(model, '//')}import type { ${imports.join(', ')} } from '@omochi-ui/sdk'\n\n${body}\n\nexport interface ${model.name}ComponentMap {\n${map}\n}\n\nexport const ${model.name.toUpperCase()}: CatalogDefinition<${model.name}ComponentMap> = { id: ${JSON.stringify(model.id)}, name: ${JSON.stringify(model.name)} }\n`;
|
|
137
|
+
}
|
|
138
|
+
function python(model) {
|
|
139
|
+
const body = model.components.map((component) => `class ${component.name}Props(TypedDict, total=False):\n${component.properties.map((property) => ` ${property.name}: ${property.required ? 'Required[' : ''}${propertyType(property, 'py')}${property.required ? ']' : ''}`).join('\n') || ' pass'}`).join('\n\n');
|
|
140
|
+
const overloads = model.components.map((component) => ` @overload\n def component(self, component: Literal[${JSON.stringify(component.name)}], *, key: str, **props: Unpack[${component.name}Props]) -> None: ...`).join('\n');
|
|
141
|
+
return `${header(model, '#')}from typing import Any, Literal, Required, TypedDict, Unpack, overload\nfrom omochi_ui import Catalog, UI\nfrom omochi_ui.catalog import DataBinding, DynamicBoolean, DynamicNumber, DynamicString, DynamicStringList, FunctionCall\n\n${body}\n\nclass ${model.name}UI(UI):\n${overloads}\n def component(self, component: str, *, key: str, **props: Any) -> None:\n super().component(component, key=key, **props)\n\n${model.name.toUpperCase()} = Catalog(id=${JSON.stringify(model.id)}, name=${JSON.stringify(model.name)}, ui_type=${model.name}UI)\n`;
|
|
142
|
+
}
|
|
143
|
+
function rust(model) {
|
|
144
|
+
const enumName = (component, property) => `${identifier(component.name)}${identifier(property.name[0].toUpperCase() + property.name.slice(1))}`;
|
|
145
|
+
const enums = model.components.flatMap((component) => component.properties.flatMap((property) => {
|
|
146
|
+
const values = JSON.parse(property.type).enum;
|
|
147
|
+
if (!Array.isArray(values))
|
|
148
|
+
return [];
|
|
149
|
+
const variants = values.map((value) => {
|
|
150
|
+
const raw = String(value);
|
|
151
|
+
const variant = identifier(raw[0].toUpperCase() + raw.slice(1)) || 'Value';
|
|
152
|
+
return ` #[serde(rename = ${JSON.stringify(raw)})]\n ${/^\d/.test(variant) ? `Value${variant}` : variant},`;
|
|
153
|
+
}).join('\n');
|
|
154
|
+
return [`#[derive(Clone, Debug, Serialize)]\npub enum ${enumName(component, property)} {\n${variants}\n}`];
|
|
155
|
+
})).join('\n\n');
|
|
156
|
+
const body = model.components.map((component) => `#[derive(Clone, Debug, Serialize)]\n#[serde(rename_all = "camelCase")]\npub struct ${component.name}Props {\n${component.properties.map((property) => {
|
|
157
|
+
const schema = JSON.parse(property.type);
|
|
158
|
+
const type = Array.isArray(schema.enum) ? enumName(component, property) : propertyType(property, 'rs');
|
|
159
|
+
return `${property.required ? '' : ' #[serde(skip_serializing_if = "Option::is_none")]\n'} pub ${snake(property.name)}: ${property.required ? type : `Option<${type}>`},`;
|
|
160
|
+
}).join('\n')}\n}\nimpl ComponentProps for ${component.name}Props {\n const NAME: &'static str = ${JSON.stringify(component.name)};\n fn into_props(self) -> JsonObject { serde_json::to_value(self).unwrap().as_object().unwrap().clone() }\n}`).join('\n\n');
|
|
161
|
+
return `${header(model, '//')}use omochi_ui::{ComponentProps, JsonObject};\nuse serde::Serialize;\nuse serde_json::Value;\n\npub const CATALOG_ID: &str = ${JSON.stringify(model.id)};\n\n${enums}${enums ? '\n\n' : ''}${body}\n`;
|
|
162
|
+
}
|
|
163
|
+
async function output(path, content, check) {
|
|
164
|
+
const current = await readFile(path, 'utf8').catch(() => null);
|
|
165
|
+
if (check) {
|
|
166
|
+
if (current !== content)
|
|
167
|
+
throw new Error(`generated catalog is stale: ${path}`);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
if (current !== content)
|
|
171
|
+
await writeFile(path, content);
|
|
172
|
+
}
|
|
173
|
+
export async function generateCatalog(options) {
|
|
174
|
+
if (!options.typescript && !options.python && !options.rust)
|
|
175
|
+
throw new Error('at least one output flag is required');
|
|
176
|
+
const raw = await readFile(options.catalog, 'utf8');
|
|
177
|
+
const parsed = JSON.parse(raw);
|
|
178
|
+
const resolved = await resolveLocalRefs(parsed, parsed, dirname(resolve(options.catalog)));
|
|
179
|
+
const model = normalize(JSON.stringify(resolved), options.name, createHash('sha256').update(raw).digest('hex'));
|
|
180
|
+
await Promise.all([
|
|
181
|
+
options.typescript ? output(options.typescript, typescript(model), Boolean(options.check)) : undefined,
|
|
182
|
+
options.python ? output(options.python, python(model), Boolean(options.check)) : undefined,
|
|
183
|
+
options.rust ? output(options.rust, rust(model), Boolean(options.check)) : undefined,
|
|
184
|
+
]);
|
|
185
|
+
}
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { hostname, networkInterfaces } from 'node:os';
|
|
3
|
+
import process from 'node:process';
|
|
4
|
+
import { createStaticLocator, gatewayEndpointKey, loadNodeIdentity, omochiConfigRoot, openTrustStore, pairWithGateway, parseGatewayTarget, startGateway, } from '@omochi-ui/discovery';
|
|
5
|
+
import { generateCatalog } from './catalog-codegen.js';
|
|
6
|
+
import { helpText, helpTopic } from './help.js';
|
|
7
|
+
import { startHub } from './hub/hub.js';
|
|
8
|
+
import { startSshGateway, validateSshTarget } from './hub/ssh-gateway.js';
|
|
9
|
+
import { startStdioWebSocketBridge } from './stdio-websocket-bridge.js';
|
|
10
|
+
import { loadComposition, openBrowser, startCompositionServices } from './up.js';
|
|
11
|
+
/**
|
|
12
|
+
* An address another machine could actually dial.
|
|
13
|
+
*
|
|
14
|
+
* A wildcard bind is what the gateway listens on, not somewhere anyone can connect to,
|
|
15
|
+
* so printing it back would send a person to `0.0.0.0`.
|
|
16
|
+
*/
|
|
17
|
+
function reachableHost(bound) {
|
|
18
|
+
if (bound !== '0.0.0.0' && bound !== '::' && bound !== '')
|
|
19
|
+
return bound;
|
|
20
|
+
for (const addresses of Object.values(networkInterfaces())) {
|
|
21
|
+
for (const address of addresses ?? []) {
|
|
22
|
+
if (address.family === 'IPv4' && !address.internal)
|
|
23
|
+
return address.address;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return hostname();
|
|
27
|
+
}
|
|
28
|
+
function usage(message = 'Invalid or incomplete arguments.') {
|
|
29
|
+
process.stderr.write(`[omochi-ui] ${message}\n\n${helpText(topic) ?? helpText()}`);
|
|
30
|
+
process.exit(2);
|
|
31
|
+
}
|
|
32
|
+
const args = process.argv.slice(2);
|
|
33
|
+
// Stop scanning at -- so e.g. `dev -- python app.py --help` reaches the app.
|
|
34
|
+
const separatorIndex = args.indexOf('--');
|
|
35
|
+
const ownArgs = separatorIndex < 0 ? args : args.slice(0, separatorIndex);
|
|
36
|
+
const explicitHelp = ownArgs[0] === 'help';
|
|
37
|
+
const helpArgs = explicitHelp ? ownArgs.slice(1) : ownArgs;
|
|
38
|
+
const topic = helpTopic(helpArgs.filter((arg) => arg !== '--help' && arg !== '-h'));
|
|
39
|
+
if (!args.length || explicitHelp || ownArgs.some((arg) => arg === '--help' || arg === '-h')) {
|
|
40
|
+
const text = helpText(topic);
|
|
41
|
+
if (text === undefined || (explicitHelp && helpArgs.length > (topic.includes(' ') ? 2 : 1))) {
|
|
42
|
+
usage(`Unknown help topic: ${helpArgs.join(' ')}`);
|
|
43
|
+
}
|
|
44
|
+
process.stdout.write(text);
|
|
45
|
+
process.exit(0);
|
|
46
|
+
}
|
|
47
|
+
const commandName = args.shift();
|
|
48
|
+
if (commandName === 'generate') {
|
|
49
|
+
if (args.shift() !== 'catalog')
|
|
50
|
+
usage();
|
|
51
|
+
const options = { catalog: '' };
|
|
52
|
+
while (args.length) {
|
|
53
|
+
const option = args.shift();
|
|
54
|
+
if (option === '--check') {
|
|
55
|
+
options.check = true;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
const value = args.shift();
|
|
59
|
+
if (!value)
|
|
60
|
+
usage();
|
|
61
|
+
if (option === '--catalog')
|
|
62
|
+
options.catalog = value;
|
|
63
|
+
else if (option === '--name')
|
|
64
|
+
options.name = value;
|
|
65
|
+
else if (option === '--typescript')
|
|
66
|
+
options.typescript = value;
|
|
67
|
+
else if (option === '--python')
|
|
68
|
+
options.python = value;
|
|
69
|
+
else if (option === '--rust')
|
|
70
|
+
options.rust = value;
|
|
71
|
+
else
|
|
72
|
+
usage();
|
|
73
|
+
}
|
|
74
|
+
if (!options.catalog)
|
|
75
|
+
usage();
|
|
76
|
+
await generateCatalog(options);
|
|
77
|
+
process.exit(0);
|
|
78
|
+
}
|
|
79
|
+
if (commandName === 'id') {
|
|
80
|
+
let nodeName;
|
|
81
|
+
while (args.length) {
|
|
82
|
+
const option = args.shift();
|
|
83
|
+
const value = args.shift();
|
|
84
|
+
if (!value || option !== '--node-name')
|
|
85
|
+
usage();
|
|
86
|
+
nodeName = value;
|
|
87
|
+
}
|
|
88
|
+
const identity = await loadNodeIdentity(nodeName === undefined ? {} : { name: nodeName });
|
|
89
|
+
process.stdout.write(`${identity.nodeId}\t${identity.name}\n`);
|
|
90
|
+
process.stderr.write(`[omochi-ui] identity stored in ${omochiConfigRoot()}\n`);
|
|
91
|
+
process.exit(0);
|
|
92
|
+
}
|
|
93
|
+
if (commandName === 'pair') {
|
|
94
|
+
const target = args.shift();
|
|
95
|
+
if (!target)
|
|
96
|
+
usage();
|
|
97
|
+
const parsed = parseGatewayTarget(target);
|
|
98
|
+
if (!parsed || parsed.endpoint.kind !== 'tcp')
|
|
99
|
+
usage();
|
|
100
|
+
let code;
|
|
101
|
+
while (args.length) {
|
|
102
|
+
const option = args.shift();
|
|
103
|
+
const value = args.shift();
|
|
104
|
+
if (!value || option !== '--code')
|
|
105
|
+
usage();
|
|
106
|
+
code = value;
|
|
107
|
+
}
|
|
108
|
+
// Reading the code from stdin keeps it out of shell history.
|
|
109
|
+
if (code === undefined) {
|
|
110
|
+
process.stderr.write('[omochi-ui] pairing code: ');
|
|
111
|
+
code = await new Promise((resolvePromise) => {
|
|
112
|
+
let typed = '';
|
|
113
|
+
process.stdin.setEncoding('utf8');
|
|
114
|
+
process.stdin.on('data', (chunk) => {
|
|
115
|
+
typed += chunk;
|
|
116
|
+
const line = typed.indexOf('\n');
|
|
117
|
+
if (line >= 0) {
|
|
118
|
+
process.stdin.pause();
|
|
119
|
+
resolvePromise(typed.slice(0, line));
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
process.stdin.on('end', () => resolvePromise(typed));
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
const identity = await loadNodeIdentity();
|
|
126
|
+
const trust = await openTrustStore();
|
|
127
|
+
try {
|
|
128
|
+
const peer = await pairWithGateway(parsed.endpoint, { identity, trust, code });
|
|
129
|
+
process.stderr.write(`[omochi-ui] paired with ${peer.nodeId} (${peer.name})\n`);
|
|
130
|
+
process.stderr.write(`[omochi-ui] reach it with: omochi-ui hub --gateway ${peer.nodeId}@${peer.addresses[0]}\n`);
|
|
131
|
+
process.exit(0);
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
process.stderr.write(`[omochi-ui] ${error instanceof Error ? error.message : String(error)}\n`);
|
|
135
|
+
process.exit(1);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
if (commandName === 'trust') {
|
|
139
|
+
const action = args.shift();
|
|
140
|
+
if (action !== 'list' && action !== 'forget')
|
|
141
|
+
usage('Expected trust list or trust forget <nodeId>.');
|
|
142
|
+
if (action === 'list' && args.length !== 0)
|
|
143
|
+
usage('trust list takes no arguments.');
|
|
144
|
+
if (action === 'forget' && args.length !== 1)
|
|
145
|
+
usage('trust forget requires exactly one node ID.');
|
|
146
|
+
const store = await openTrustStore();
|
|
147
|
+
if (action === 'list') {
|
|
148
|
+
const peers = store.list();
|
|
149
|
+
if (store.legacy)
|
|
150
|
+
process.stderr.write('[omochi-ui] old TLS-PSK pairings are unsupported; pair devices again\n');
|
|
151
|
+
if (!peers.length)
|
|
152
|
+
process.stderr.write(`[omochi-ui] no paired peers in ${store.file}\n`);
|
|
153
|
+
for (const peer of peers) {
|
|
154
|
+
const seen = peer.lastSeenAt ? ` last seen ${peer.lastSeenAt}` : '';
|
|
155
|
+
process.stdout.write(`${peer.nodeId}\t${peer.role}\t${peer.name}\tpaired ${peer.pairedAt}${seen}\n`);
|
|
156
|
+
}
|
|
157
|
+
process.exit(0);
|
|
158
|
+
}
|
|
159
|
+
if (action === 'forget') {
|
|
160
|
+
const nodeId = args.shift();
|
|
161
|
+
if (!nodeId)
|
|
162
|
+
usage();
|
|
163
|
+
const forgotten = await store.forget(nodeId);
|
|
164
|
+
process.stderr.write(forgotten
|
|
165
|
+
? `[omochi-ui] forgot ${nodeId}\n`
|
|
166
|
+
: `[omochi-ui] no paired peer ${nodeId}\n`);
|
|
167
|
+
process.exit(forgotten ? 0 : 1);
|
|
168
|
+
}
|
|
169
|
+
usage();
|
|
170
|
+
}
|
|
171
|
+
if (commandName === 'gateway') {
|
|
172
|
+
let root;
|
|
173
|
+
let socketPath;
|
|
174
|
+
let nodeName;
|
|
175
|
+
let listen;
|
|
176
|
+
let pairing;
|
|
177
|
+
let advertise = false;
|
|
178
|
+
while (args.length) {
|
|
179
|
+
const option = args.shift();
|
|
180
|
+
// `--lan` is sugar; accepting hubs and advertising stay separate switches.
|
|
181
|
+
if (option === '--lan') {
|
|
182
|
+
listen = { host: '0.0.0.0', port: 5556 };
|
|
183
|
+
advertise = true;
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
if (option === '--advertise') {
|
|
187
|
+
advertise = true;
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
if (option === '--pair') {
|
|
191
|
+
pairing = {};
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
const value = args.shift();
|
|
195
|
+
if (!value)
|
|
196
|
+
usage();
|
|
197
|
+
if (option === '--root')
|
|
198
|
+
root = value;
|
|
199
|
+
else if (option === '--socket')
|
|
200
|
+
socketPath = value;
|
|
201
|
+
else if (option === '--node-name')
|
|
202
|
+
nodeName = value;
|
|
203
|
+
else if (option === '--listen') {
|
|
204
|
+
const target = parseGatewayTarget(value);
|
|
205
|
+
if (!target || target.endpoint.kind !== 'tcp')
|
|
206
|
+
usage();
|
|
207
|
+
listen = { host: target.endpoint.host, port: target.endpoint.port };
|
|
208
|
+
}
|
|
209
|
+
else
|
|
210
|
+
usage();
|
|
211
|
+
}
|
|
212
|
+
if ((pairing || advertise) && !listen)
|
|
213
|
+
usage('--pair and --advertise require --listen HOST:PORT or --lan.');
|
|
214
|
+
const gateway = await startGateway({ root, socketPath, name: nodeName, listen, pairing, advertise })
|
|
215
|
+
.catch((error) => {
|
|
216
|
+
// Two gateways on one machine is the common mistake here, and a stack trace is a
|
|
217
|
+
// poor way to say so.
|
|
218
|
+
if (error.code === 'EADDRINUSE' && listen) {
|
|
219
|
+
process.stderr.write(`[omochi-ui] ${listen.host}:${listen.port} is already in use; another gateway may be running\n`);
|
|
220
|
+
process.exit(1);
|
|
221
|
+
}
|
|
222
|
+
if (error.code === 'EADDRINUSE') {
|
|
223
|
+
process.stderr.write('[omochi-ui] a gateway is already listening on this machine\n');
|
|
224
|
+
process.exit(1);
|
|
225
|
+
}
|
|
226
|
+
throw error;
|
|
227
|
+
});
|
|
228
|
+
process.stderr.write(`[omochi-ui] gateway ${gateway.nodeId} (${gateway.name})\n[omochi-ui] publishing services from ${gateway.root}\n[omochi-ui] listening on ${gateway.socketPath}\n`);
|
|
229
|
+
if (gateway.address) {
|
|
230
|
+
const peers = (await openTrustStore()).list().filter((peer) => peer.role === 'hub').length;
|
|
231
|
+
process.stderr.write(`[omochi-ui] reachable from the local network on ${gateway.address.host}:${gateway.address.port}; ${peers} hub(s) paired\n`);
|
|
232
|
+
if (advertise)
|
|
233
|
+
process.stderr.write(`[omochi-ui] advertising as _${'omochi-ui'}._tcp\n`);
|
|
234
|
+
}
|
|
235
|
+
if (gateway.pairing) {
|
|
236
|
+
const seconds = Math.round((gateway.pairing.expiresAt - Date.now()) / 1000);
|
|
237
|
+
process.stderr.write([
|
|
238
|
+
`[omochi-ui] pairing code: ${gateway.pairing.display} (valid ${seconds}s, one use)`,
|
|
239
|
+
`[omochi-ui] on the other machine: omochi-ui pair ${reachableHost(gateway.pairingAddress.host)}:${gateway.pairingAddress.port}`,
|
|
240
|
+
'',
|
|
241
|
+
].join('\n'));
|
|
242
|
+
void gateway.pairing.ended.then((reason) => {
|
|
243
|
+
// The gateway keeps running either way; only the window is over.
|
|
244
|
+
process.stderr.write(`[omochi-ui] pairing window closed: ${reason}\n`);
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
const stop = () => void gateway.close().then(() => process.exit(0));
|
|
248
|
+
process.on('SIGINT', stop);
|
|
249
|
+
process.on('SIGTERM', stop);
|
|
250
|
+
}
|
|
251
|
+
else if (commandName === 'up') {
|
|
252
|
+
let host = '127.0.0.1';
|
|
253
|
+
let port = 5555;
|
|
254
|
+
let shouldOpen = false;
|
|
255
|
+
const allowOrigins = [];
|
|
256
|
+
let file = 'omochi.toml';
|
|
257
|
+
let foundFile = false;
|
|
258
|
+
while (args.length) {
|
|
259
|
+
const option = args.shift();
|
|
260
|
+
if (option === '--open') {
|
|
261
|
+
shouldOpen = true;
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
if (!option.startsWith('-')) {
|
|
265
|
+
if (foundFile)
|
|
266
|
+
usage('up accepts at most one composition file.');
|
|
267
|
+
file = option;
|
|
268
|
+
foundFile = true;
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
const value = args.shift();
|
|
272
|
+
if (!value)
|
|
273
|
+
usage();
|
|
274
|
+
if (option === '--host')
|
|
275
|
+
host = value;
|
|
276
|
+
else if (option === '--port' && Number.isInteger(Number(value)))
|
|
277
|
+
port = Number(value);
|
|
278
|
+
else if (option === '--allow-origin')
|
|
279
|
+
allowOrigins.push(value);
|
|
280
|
+
else
|
|
281
|
+
usage();
|
|
282
|
+
}
|
|
283
|
+
if (port < 0 || port > 65535)
|
|
284
|
+
usage('--port must be an integer from 0 to 65535.');
|
|
285
|
+
let services;
|
|
286
|
+
let hub;
|
|
287
|
+
let shutdown;
|
|
288
|
+
const stop = (code = 0) => shutdown ??= (async () => {
|
|
289
|
+
await services?.close();
|
|
290
|
+
await hub?.close();
|
|
291
|
+
process.exit(code);
|
|
292
|
+
})();
|
|
293
|
+
process.on('SIGINT', () => { void stop(130); });
|
|
294
|
+
process.on('SIGTERM', () => { void stop(143); });
|
|
295
|
+
try {
|
|
296
|
+
const identity = await loadNodeIdentity();
|
|
297
|
+
const composition = await loadComposition(file, identity.nodeId);
|
|
298
|
+
services = startCompositionServices(composition.services);
|
|
299
|
+
hub = await startHub({ host, port, allowOrigins, presets: [composition.preset] });
|
|
300
|
+
const url = new URL(hub.httpUrl);
|
|
301
|
+
url.searchParams.set('preset', composition.id);
|
|
302
|
+
process.stderr.write(`[omochi-ui] composition ${composition.id}: ${composition.services.length} services\n[omochi-ui] ${url.toString()}\n`);
|
|
303
|
+
if (shouldOpen) {
|
|
304
|
+
if (['0.0.0.0', '::'].includes(url.hostname))
|
|
305
|
+
url.hostname = '127.0.0.1';
|
|
306
|
+
openBrowser(url.toString());
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
catch (error) {
|
|
310
|
+
process.stderr.write(`[omochi-ui] ${error instanceof Error ? error.message : String(error)}\n`);
|
|
311
|
+
await services?.close();
|
|
312
|
+
await hub?.close();
|
|
313
|
+
process.exit(1);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
else if (commandName === 'hub') {
|
|
317
|
+
let host = '127.0.0.1';
|
|
318
|
+
let port = 5555;
|
|
319
|
+
let autoGateway = true;
|
|
320
|
+
let discoverLan = false;
|
|
321
|
+
let sshHost;
|
|
322
|
+
let sshSocket;
|
|
323
|
+
const peers = [];
|
|
324
|
+
const peerIds = new Map();
|
|
325
|
+
const allowOrigins = [];
|
|
326
|
+
while (args.length) {
|
|
327
|
+
const option = args.shift();
|
|
328
|
+
if (option === '--no-auto-gateway') {
|
|
329
|
+
autoGateway = false;
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
if (option === '--discover-lan') {
|
|
333
|
+
discoverLan = true;
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
const value = args.shift();
|
|
337
|
+
if (!value)
|
|
338
|
+
usage();
|
|
339
|
+
if (option === '--host')
|
|
340
|
+
host = value;
|
|
341
|
+
else if (option === '--port' && Number.isInteger(Number(value)))
|
|
342
|
+
port = Number(value);
|
|
343
|
+
else if (option === '--ssh') {
|
|
344
|
+
if (sshHost !== undefined)
|
|
345
|
+
usage();
|
|
346
|
+
sshHost = value;
|
|
347
|
+
}
|
|
348
|
+
else if (option === '--ssh-socket') {
|
|
349
|
+
if (sshSocket !== undefined)
|
|
350
|
+
usage();
|
|
351
|
+
sshSocket = value;
|
|
352
|
+
}
|
|
353
|
+
else if (option === '--gateway') {
|
|
354
|
+
const target = parseGatewayTarget(value);
|
|
355
|
+
if (!target)
|
|
356
|
+
usage();
|
|
357
|
+
peers.push(target.endpoint);
|
|
358
|
+
if (target.nodeId)
|
|
359
|
+
peerIds.set(gatewayEndpointKey(target.endpoint), target.nodeId);
|
|
360
|
+
}
|
|
361
|
+
else if (option === '--allow-origin')
|
|
362
|
+
allowOrigins.push(value);
|
|
363
|
+
else
|
|
364
|
+
usage();
|
|
365
|
+
}
|
|
366
|
+
// Naming a gateway replaces the default, so `--gateway` can point at another machine
|
|
367
|
+
// without also picking up whatever happens to be running locally.
|
|
368
|
+
if (sshSocket !== undefined && sshHost === undefined)
|
|
369
|
+
usage();
|
|
370
|
+
if (port < 0 || port > 65535)
|
|
371
|
+
usage('--port must be an integer from 0 to 65535.');
|
|
372
|
+
let tunnel;
|
|
373
|
+
let hub;
|
|
374
|
+
const abort = new AbortController();
|
|
375
|
+
let stopping = false;
|
|
376
|
+
let pending;
|
|
377
|
+
let shutdown;
|
|
378
|
+
const stop = (code = 0) => shutdown ??= (async () => {
|
|
379
|
+
stopping = true;
|
|
380
|
+
abort.abort();
|
|
381
|
+
await pending?.catch(() => { });
|
|
382
|
+
await hub?.close();
|
|
383
|
+
await tunnel?.close();
|
|
384
|
+
process.exit(code);
|
|
385
|
+
})();
|
|
386
|
+
// Install these before SSH can prompt, so cancellation also cleans partial startup.
|
|
387
|
+
process.on('SIGINT', () => { void stop(130); });
|
|
388
|
+
process.on('SIGTERM', () => { void stop(143); });
|
|
389
|
+
try {
|
|
390
|
+
if (sshHost !== undefined) {
|
|
391
|
+
validateSshTarget(sshHost, sshSocket);
|
|
392
|
+
tunnel = await (pending = startSshGateway({ host: sshHost, remoteSocket: sshSocket, signal: abort.signal }));
|
|
393
|
+
peers.push({ kind: 'unix', socketPath: tunnel.socketPath });
|
|
394
|
+
}
|
|
395
|
+
const gateways = peers.length ? [createStaticLocator(peers, peerIds)] : undefined;
|
|
396
|
+
hub = await (pending = startHub({ host, port, allowOrigins, gateways, discoverLan, autoGateway: autoGateway && !peers.length }));
|
|
397
|
+
if (tunnel)
|
|
398
|
+
void tunnel.exited.then((error) => {
|
|
399
|
+
if (!stopping) {
|
|
400
|
+
process.stderr.write(`[omochi-ui] ${error.message}\n`);
|
|
401
|
+
void stop(1);
|
|
402
|
+
}
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
catch (error) {
|
|
406
|
+
process.stderr.write(`[omochi-ui] ${error instanceof Error ? error.message : error}\n`);
|
|
407
|
+
await stop(1);
|
|
408
|
+
process.exit(1);
|
|
409
|
+
}
|
|
410
|
+
// Connections are still being established, so name the targets rather than count them.
|
|
411
|
+
const named = peers.length
|
|
412
|
+
? peers.map((peer) => (peer.kind === 'unix' ? peer.socketPath : `${peer.host}:${peer.port}`)).join(', ')
|
|
413
|
+
: 'the local gateway';
|
|
414
|
+
const targets = discoverLan ? `${named} and any gateway on the local link` : named;
|
|
415
|
+
process.stderr.write(`[omochi-ui] merging ${targets}\n[omochi-ui] ${hub.httpUrl}\nA2UI endpoint: ${hub.url}\n`);
|
|
416
|
+
if (host !== '127.0.0.1' && host !== 'localhost' && host !== '::1') {
|
|
417
|
+
process.stderr.write('[omochi-ui] warning: hub has no authentication or TLS\n');
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
else {
|
|
421
|
+
if (commandName !== 'dev')
|
|
422
|
+
usage(`Unknown command: ${commandName}`);
|
|
423
|
+
const separator = args.indexOf('--');
|
|
424
|
+
if (separator < 0)
|
|
425
|
+
usage('dev requires -- followed by the application command.');
|
|
426
|
+
const optionArgs = args.slice(0, separator);
|
|
427
|
+
const childCommand = args.slice(separator + 1);
|
|
428
|
+
let host = '127.0.0.1';
|
|
429
|
+
let port = 5555;
|
|
430
|
+
for (let index = 0; index < optionArgs.length; index += 1) {
|
|
431
|
+
const option = optionArgs[index];
|
|
432
|
+
const value = optionArgs[index + 1];
|
|
433
|
+
if (option === '--host' && value)
|
|
434
|
+
host = value;
|
|
435
|
+
else if (option === '--port' && value && Number.isInteger(Number(value)))
|
|
436
|
+
port = Number(value);
|
|
437
|
+
else
|
|
438
|
+
usage();
|
|
439
|
+
index += 1;
|
|
440
|
+
}
|
|
441
|
+
if (!childCommand.length)
|
|
442
|
+
usage();
|
|
443
|
+
if (port < 0 || port > 65535)
|
|
444
|
+
usage('--port must be an integer from 0 to 65535.');
|
|
445
|
+
const handle = await startStdioWebSocketBridge({ command: childCommand, host, port });
|
|
446
|
+
process.stderr.write(`[omochi-ui] bridge: ${handle.url}\nStart the browser preview in another terminal: bun run --filter @omochi-ui/dev-server dev\nOpen: http://127.0.0.1:5173/?ws=${encodeURIComponent(handle.url)}\n`);
|
|
447
|
+
if (host !== '127.0.0.1' && host !== 'localhost' && host !== '::1')
|
|
448
|
+
process.stderr.write('[omochi-ui] warning: bridge has no authentication or TLS\n');
|
|
449
|
+
const forward = (signal) => { if (handle.child.exitCode === null)
|
|
450
|
+
handle.child.kill(signal); };
|
|
451
|
+
process.on('SIGINT', forward);
|
|
452
|
+
process.on('SIGTERM', forward);
|
|
453
|
+
process.exitCode = await handle.exited;
|
|
454
|
+
}
|
package/dist/help.d.ts
ADDED