@omochi-ui/utils 0.0.0-stage → 0.1.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/dist/cli.js ADDED
@@ -0,0 +1,239 @@
1
+ import path from "node:path";
2
+ import { CommandButton } from "./button.js";
3
+ import { HttpWatch } from "./http.js";
4
+ import { CommandProcess } from "./process.js";
5
+ import { Publisher } from "./publisher.js";
6
+ import { InputStream } from "./stream.js";
7
+ import { FileTail } from "./tail.js";
8
+ import { CommandWatch } from "./watch.js";
9
+ const HELP = `Usage:
10
+ omochi-utils button [--service-id ID] [--name NAME] [--cwd DIR] -- COMMAND [ARGS...]
11
+ omochi-utils watch [--interval 2s] [--timeout DURATION] [--diff] [--on-change] [--history N] [--cwd DIR] -- COMMAND [ARGS...]
12
+ omochi-utils process [--restart never|on-failure|always] [--restart-delay 1s] [--cwd DIR] -- COMMAND [ARGS...]
13
+ omochi-utils tail [--lines 100] [--service-id ID] [--name NAME] FILE
14
+ omochi-utils stream [--service-id ID] [--name NAME]
15
+ omochi-utils http [--interval 10s] [--timeout 10s] [--expect-status 200-299] [--expect-body TEXT] URL
16
+ omochi-utils mcp [--service-id ID] [--name NAME]
17
+
18
+ Publish one development utility surface through the local Omochi gateway.
19
+ Commands inherit the environment and run without an implicit shell.
20
+ Durations accept ms, s, or m suffixes. Start omochi-ui hub separately.
21
+ `;
22
+ function parse(args) {
23
+ if (!args.length || ["--help", "-h", "help"].includes(args[0]))
24
+ return null;
25
+ const mode = args[0];
26
+ const rest = args.slice(1);
27
+ if (!["button", "watch", "process", "tail", "stream", "http", "mcp"].includes(mode))
28
+ throw new Error(`unknown command: ${mode}`);
29
+ if (rest.includes("--help") || rest.includes("-h"))
30
+ return null;
31
+ const common = { name: defaultName(mode) };
32
+ let cwd;
33
+ let intervalMs = mode === "http" ? 10_000 : 2_000;
34
+ let timeoutMs = mode === "http" ? 10_000 : undefined;
35
+ let diff = false;
36
+ let onChange = false;
37
+ let history;
38
+ let restart = "never";
39
+ let restartDelayMs = 1000;
40
+ let lines = 100;
41
+ let expectStatus = [200, 299];
42
+ let expectBody;
43
+ const positional = [];
44
+ let command = [];
45
+ for (let i = 0; i < rest.length; i++) {
46
+ const arg = rest[i];
47
+ if (arg === "--") {
48
+ command = rest.slice(i + 1);
49
+ break;
50
+ }
51
+ const value = () => {
52
+ const found = rest[++i];
53
+ if (found === undefined)
54
+ throw new Error(`missing value for ${arg}`);
55
+ return found;
56
+ };
57
+ if (arg === "--service-id")
58
+ common.serviceId = value();
59
+ else if (arg === "--name")
60
+ common.name = value();
61
+ else if (arg === "--cwd" && ["button", "watch", "process"].includes(mode))
62
+ cwd = path.resolve(value());
63
+ else if (arg === "--interval" && ["watch", "http"].includes(mode))
64
+ intervalMs = parseDuration(value(), arg);
65
+ else if (arg === "--timeout" && ["watch", "http"].includes(mode))
66
+ timeoutMs = parseDuration(value(), arg);
67
+ else if (arg === "--diff" && mode === "watch")
68
+ diff = true;
69
+ else if (arg === "--on-change" && mode === "watch")
70
+ onChange = true;
71
+ else if (arg === "--history" && mode === "watch")
72
+ history = parseInteger(value(), arg, 1, 100);
73
+ else if (arg === "--restart" && mode === "process") {
74
+ const policy = value();
75
+ if (!["never", "on-failure", "always"].includes(policy))
76
+ throw new Error("--restart must be never, on-failure, or always");
77
+ restart = policy;
78
+ }
79
+ else if (arg === "--restart-delay" && mode === "process")
80
+ restartDelayMs = parseDuration(value(), arg);
81
+ else if (arg === "--lines" && mode === "tail")
82
+ lines = parseInteger(value(), arg, 1, Number.MAX_SAFE_INTEGER);
83
+ else if (arg === "--expect-status" && mode === "http")
84
+ expectStatus = parseStatus(value());
85
+ else if (arg === "--expect-body" && mode === "http")
86
+ expectBody = value();
87
+ else if (arg.startsWith("-"))
88
+ throw new Error(`unknown option: ${arg}`);
89
+ else
90
+ positional.push(arg);
91
+ }
92
+ if (["button", "watch", "process"].includes(mode)) {
93
+ if (!command.length)
94
+ throw new Error(`${mode} requires -- followed by a command`);
95
+ if (positional.length)
96
+ throw new Error(`unexpected argument: ${positional[0]}`);
97
+ }
98
+ else if (command.length || rest.includes("--"))
99
+ throw new Error(`${mode} does not accept a command after --`);
100
+ if (mode === "button")
101
+ return { ...common, mode, cwd, command };
102
+ if (mode === "watch")
103
+ return { ...common, mode, cwd, command, intervalMs, timeoutMs, diff, onChange, history };
104
+ if (mode === "process")
105
+ return { ...common, mode, cwd, command, restart, restartDelayMs };
106
+ if (mode === "tail") {
107
+ if (positional.length !== 1)
108
+ throw new Error("tail requires exactly one file path");
109
+ const filePath = path.resolve(positional[0]);
110
+ if (common.name === "Tail")
111
+ common.name = path.basename(filePath);
112
+ return { ...common, mode, path: filePath, lines };
113
+ }
114
+ if (mode === "stream") {
115
+ if (positional.length)
116
+ throw new Error(`unexpected argument: ${positional[0]}`);
117
+ return { ...common, mode };
118
+ }
119
+ if (mode === "http") {
120
+ if (positional.length !== 1)
121
+ throw new Error("http requires exactly one URL");
122
+ const url = new URL(positional[0]);
123
+ if (!["http:", "https:"].includes(url.protocol))
124
+ throw new Error("http URL must use http or https");
125
+ if (common.name === "HTTP")
126
+ common.name = url.host;
127
+ return { ...common, mode, url: url.toString(), intervalMs, timeoutMs: timeoutMs, expectStatus, expectBody };
128
+ }
129
+ if (positional.length)
130
+ throw new Error(`unexpected argument: ${positional[0]}`);
131
+ return { ...common, mode: "mcp" };
132
+ }
133
+ function defaultName(mode) {
134
+ return { button: "Command", watch: "Watch", process: "Process", tail: "Tail", stream: "Stream", http: "HTTP", mcp: "Agent" }[mode];
135
+ }
136
+ function parseDuration(value, option) {
137
+ const match = /^(\d+(?:\.\d+)?)(ms|s|m)$/.exec(value);
138
+ if (!match)
139
+ throw new Error(`${option} must be a positive duration such as 250ms, 2s, or 1m`);
140
+ const multiplier = match[2] === "ms" ? 1 : match[2] === "s" ? 1000 : 60_000;
141
+ const milliseconds = Number(match[1]) * multiplier;
142
+ if (!Number.isFinite(milliseconds) || milliseconds <= 0 || milliseconds > 2_147_483_647)
143
+ throw new Error(`${option} is out of range`);
144
+ const rounded = Math.round(milliseconds);
145
+ if (rounded <= 0)
146
+ throw new Error(`${option} is out of range`);
147
+ return rounded;
148
+ }
149
+ function parseInteger(value, option, minimum, maximum) {
150
+ const number = Number(value);
151
+ if (!Number.isSafeInteger(number) || number < minimum || number > maximum)
152
+ throw new Error(`${option} must be an integer between ${minimum} and ${maximum}`);
153
+ return number;
154
+ }
155
+ function parseStatus(value) {
156
+ const match = /^(\d{3})(?:-(\d{3}))?$/.exec(value);
157
+ if (!match)
158
+ throw new Error("--expect-status must be a status code or range such as 200-299");
159
+ const minimum = Number(match[1]);
160
+ const maximum = Number(match[2] ?? match[1]);
161
+ if (minimum < 100 || maximum > 599 || minimum > maximum)
162
+ throw new Error("--expect-status must be an ordered range between 100 and 599");
163
+ return [minimum, maximum];
164
+ }
165
+ async function main() {
166
+ let options;
167
+ try {
168
+ options = parse(process.argv.slice(2));
169
+ }
170
+ catch (error) {
171
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n\n${HELP}`);
172
+ process.exitCode = 2;
173
+ return;
174
+ }
175
+ if (!options) {
176
+ process.stdout.write(HELP);
177
+ return;
178
+ }
179
+ let publisher;
180
+ let utility;
181
+ let mcp;
182
+ let shutdown;
183
+ let stopped = false;
184
+ const close = () => {
185
+ stopped = true;
186
+ if (shutdown)
187
+ return shutdown;
188
+ shutdown = (async () => {
189
+ publisher?.events.close();
190
+ await utility?.close();
191
+ await publisher?.close();
192
+ await mcp?.close();
193
+ })();
194
+ return shutdown;
195
+ };
196
+ const stop = () => { void close().catch(reportError); };
197
+ process.once("SIGINT", stop);
198
+ process.once("SIGTERM", stop);
199
+ try {
200
+ publisher = await Publisher.open({ serviceId: options.serviceId, name: options.name });
201
+ if (stopped) {
202
+ await publisher.close();
203
+ return;
204
+ }
205
+ if (options.mode === "mcp") {
206
+ const { startMcp } = await import("./mcp.js");
207
+ process.stdin.once("end", stop);
208
+ mcp = await startMcp(publisher, stop);
209
+ if (stopped)
210
+ await mcp.close();
211
+ }
212
+ else {
213
+ if (options.mode === "button")
214
+ utility = new CommandButton(publisher, options);
215
+ if (options.mode === "watch")
216
+ utility = new CommandWatch(publisher, options);
217
+ if (options.mode === "process")
218
+ utility = new CommandProcess(publisher, options);
219
+ if (options.mode === "tail")
220
+ utility = new FileTail(publisher, options);
221
+ if (options.mode === "stream")
222
+ utility = new InputStream(publisher, options);
223
+ if (options.mode === "http")
224
+ utility = new HttpWatch(publisher, options);
225
+ await utility.start();
226
+ }
227
+ if (!stopped)
228
+ process.stderr.write(`Published service ${publisher.serviceId} (${options.name})\n`);
229
+ }
230
+ catch (error) {
231
+ reportError(error);
232
+ await close();
233
+ }
234
+ }
235
+ function reportError(error) {
236
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
237
+ process.exitCode = 1;
238
+ }
239
+ void main().catch(reportError);
@@ -0,0 +1,28 @@
1
+ export interface CommandOptions {
2
+ command: string[];
3
+ cwd?: string;
4
+ onData?: (chunk: string) => void;
5
+ }
6
+ export interface CommandResult {
7
+ code: number | null;
8
+ signal: NodeJS.Signals | null;
9
+ error?: Error;
10
+ timedOut: boolean;
11
+ durationMs: number;
12
+ }
13
+ export declare class ManagedCommand {
14
+ private readonly options;
15
+ private child?;
16
+ private readonly processGroups;
17
+ private running?;
18
+ private termination?;
19
+ private timedOut;
20
+ private timeout?;
21
+ constructor(options: CommandOptions);
22
+ get active(): boolean;
23
+ start(timeoutMs?: number): Promise<CommandResult>;
24
+ stop(): Promise<void>;
25
+ close(): Promise<void>;
26
+ private signalGroups;
27
+ }
28
+ export declare function describeResult(result: CommandResult): string;
@@ -0,0 +1,110 @@
1
+ import { spawn } from "node:child_process";
2
+ import { setTimeout as delay } from "node:timers/promises";
3
+ export class ManagedCommand {
4
+ options;
5
+ child;
6
+ processGroups = new Set();
7
+ running;
8
+ termination;
9
+ timedOut = false;
10
+ timeout;
11
+ constructor(options) {
12
+ this.options = options;
13
+ if (!options.command.length)
14
+ throw new Error("a command is required after --");
15
+ }
16
+ get active() { return this.running !== undefined; }
17
+ start(timeoutMs) {
18
+ if (this.running)
19
+ throw new Error("command is already running");
20
+ this.timedOut = false;
21
+ this.termination = undefined;
22
+ const [command, ...args] = this.options.command;
23
+ const startedAt = performance.now();
24
+ const child = spawn(command, args, {
25
+ cwd: this.options.cwd,
26
+ env: process.env,
27
+ shell: false,
28
+ detached: true,
29
+ stdio: ["ignore", "pipe", "pipe"],
30
+ });
31
+ this.child = child;
32
+ if (child.pid !== undefined)
33
+ this.processGroups.add(child.pid);
34
+ let spawnError;
35
+ child.once("error", (error) => { spawnError = error; });
36
+ for (const stream of [child.stdout, child.stderr]) {
37
+ stream.setEncoding("utf8");
38
+ stream.on("data", (chunk) => this.options.onData?.(chunk));
39
+ }
40
+ this.running = new Promise((resolve) => {
41
+ child.once("close", (code, signal) => {
42
+ clearTimeout(this.timeout);
43
+ if (child.pid !== undefined) {
44
+ try {
45
+ process.kill(-child.pid, 0);
46
+ }
47
+ catch (error) {
48
+ if (error.code === "ESRCH")
49
+ this.processGroups.delete(child.pid);
50
+ }
51
+ }
52
+ const result = { code, signal, error: spawnError, timedOut: this.timedOut, durationMs: performance.now() - startedAt };
53
+ this.child = undefined;
54
+ this.running = undefined;
55
+ resolve(result);
56
+ });
57
+ });
58
+ if (timeoutMs !== undefined) {
59
+ this.timeout = setTimeout(() => {
60
+ this.timedOut = true;
61
+ void this.stop();
62
+ }, timeoutMs);
63
+ }
64
+ return this.running;
65
+ }
66
+ stop() {
67
+ if (!this.running && !this.processGroups.size)
68
+ return Promise.resolve();
69
+ if (this.termination)
70
+ return this.termination;
71
+ const running = this.running;
72
+ this.termination = (async () => {
73
+ this.signalGroups("SIGTERM");
74
+ if (running)
75
+ await Promise.race([running, delay(1000)]);
76
+ else
77
+ await delay(1000);
78
+ this.signalGroups("SIGKILL");
79
+ if (running)
80
+ await running;
81
+ })();
82
+ return this.termination;
83
+ }
84
+ close() {
85
+ clearTimeout(this.timeout);
86
+ return this.stop();
87
+ }
88
+ signalGroups(signal) {
89
+ for (const group of this.processGroups) {
90
+ try {
91
+ process.kill(-group, signal);
92
+ }
93
+ catch (error) {
94
+ if (error.code === "ESRCH")
95
+ this.processGroups.delete(group);
96
+ else
97
+ this.child?.kill(signal);
98
+ }
99
+ }
100
+ }
101
+ }
102
+ export function describeResult(result) {
103
+ if (result.error)
104
+ return `Failed to start: ${result.error.message}`;
105
+ if (result.timedOut)
106
+ return "Timed out";
107
+ if (result.signal)
108
+ return `Stopped by ${result.signal}`;
109
+ return `Exited with code ${result.code}`;
110
+ }
@@ -0,0 +1,23 @@
1
+ import type { JsonObject } from "@omochi-ui/sdk";
2
+ export interface SurfaceEvent {
3
+ sequence: number;
4
+ receivedAt: string;
5
+ message: JsonObject;
6
+ }
7
+ /** A cursor is a sequence number, not an acknowledgement: reads are repeatable. */
8
+ export declare class EventBuffer {
9
+ private readonly capacity;
10
+ private readonly events;
11
+ private readonly waiters;
12
+ private sequence;
13
+ private closed;
14
+ constructor(capacity?: number);
15
+ push(message: JsonObject): void;
16
+ read(after?: number, timeoutMs?: number, signal?: AbortSignal): Promise<{
17
+ events: SurfaceEvent[];
18
+ nextCursor: number;
19
+ dropped: number;
20
+ closed: boolean;
21
+ }>;
22
+ close(): void;
23
+ }
package/dist/events.js ADDED
@@ -0,0 +1,54 @@
1
+ /** A cursor is a sequence number, not an acknowledgement: reads are repeatable. */
2
+ export class EventBuffer {
3
+ capacity;
4
+ events = [];
5
+ waiters = new Set();
6
+ sequence = 0;
7
+ closed = false;
8
+ constructor(capacity = 1000) {
9
+ this.capacity = capacity;
10
+ }
11
+ push(message) {
12
+ if (this.closed)
13
+ return;
14
+ this.events.push({ sequence: ++this.sequence, receivedAt: new Date().toISOString(), message: structuredClone(message) });
15
+ if (this.events.length > this.capacity)
16
+ this.events.shift();
17
+ for (const wake of this.waiters)
18
+ wake();
19
+ }
20
+ async read(after = 0, timeoutMs = 0, signal) {
21
+ if (!Number.isSafeInteger(after) || after < 0 || after > this.sequence)
22
+ throw new Error("after must be an existing cursor (or 0)");
23
+ if (!Number.isInteger(timeoutMs) || timeoutMs < 0 || timeoutMs > 30_000)
24
+ throw new Error("timeoutMs must be between 0 and 30000");
25
+ signal?.throwIfAborted();
26
+ if (!this.closed && this.sequence === after && timeoutMs > 0) {
27
+ await new Promise((resolve, reject) => {
28
+ const cleanup = () => {
29
+ clearTimeout(timer);
30
+ this.waiters.delete(wake);
31
+ signal?.removeEventListener("abort", abort);
32
+ };
33
+ const wake = () => { cleanup(); resolve(); };
34
+ const abort = () => { cleanup(); reject(signal?.reason ?? new Error("cancelled")); };
35
+ const timer = setTimeout(wake, timeoutMs);
36
+ this.waiters.add(wake);
37
+ signal?.addEventListener("abort", abort, { once: true });
38
+ });
39
+ }
40
+ signal?.throwIfAborted();
41
+ const oldest = this.events[0]?.sequence ?? this.sequence + 1;
42
+ return {
43
+ events: structuredClone(this.events.filter((event) => event.sequence > after)),
44
+ nextCursor: this.sequence,
45
+ dropped: Math.max(0, oldest - after - 1),
46
+ closed: this.closed,
47
+ };
48
+ }
49
+ close() {
50
+ this.closed = true;
51
+ for (const wake of this.waiters)
52
+ wake();
53
+ }
54
+ }
@@ -0,0 +1,16 @@
1
+ /** Internal streaming matcher; retains only the overlap needed by the next chunk. */
2
+ export declare class BodyMatcher {
3
+ private readonly expected?;
4
+ private readonly decoder;
5
+ private matchTail;
6
+ matched: boolean;
7
+ constructor(expected?: string | undefined);
8
+ write(value: Uint8Array): void;
9
+ finish(): void;
10
+ private match;
11
+ }
12
+ export declare function readText(response: Response, limit: number, expected?: string): Promise<{
13
+ text: string;
14
+ truncated: boolean;
15
+ expectedMatched: boolean;
16
+ }>;
@@ -0,0 +1,59 @@
1
+ import { StringDecoder } from "node:string_decoder";
2
+ /** Internal streaming matcher; retains only the overlap needed by the next chunk. */
3
+ export class BodyMatcher {
4
+ expected;
5
+ decoder = new TextDecoder();
6
+ matchTail = "";
7
+ matched;
8
+ constructor(expected) {
9
+ this.expected = expected;
10
+ this.matched = expected === undefined || expected === "";
11
+ }
12
+ write(value) {
13
+ if (!this.matched)
14
+ this.match(this.decoder.decode(value, { stream: true }));
15
+ }
16
+ finish() {
17
+ if (!this.matched)
18
+ this.match(this.decoder.decode());
19
+ }
20
+ match(decoded) {
21
+ const expected = this.expected;
22
+ const searchable = this.matchTail + decoded;
23
+ this.matched = searchable.includes(expected);
24
+ this.matchTail = !this.matched && expected.length > 1 ? searchable.slice(-(expected.length - 1)) : "";
25
+ }
26
+ }
27
+ export async function readText(response, limit, expected) {
28
+ if (!response.body)
29
+ return { text: "", truncated: false, expectedMatched: expected === undefined || expected === "" };
30
+ const reader = response.body.getReader();
31
+ const chunks = [];
32
+ const matcher = new BodyMatcher(expected);
33
+ let visibleLength = 0;
34
+ let totalLength = 0;
35
+ let truncated = false;
36
+ while (true) {
37
+ const { done, value } = await reader.read();
38
+ if (done) {
39
+ matcher.finish();
40
+ break;
41
+ }
42
+ totalLength += value.length;
43
+ if (visibleLength < limit) {
44
+ const visible = value.subarray(0, Math.min(value.length, limit - visibleLength));
45
+ chunks.push(visible);
46
+ visibleLength += visible.length;
47
+ }
48
+ matcher.write(value);
49
+ if (totalLength > limit) {
50
+ truncated = true;
51
+ if (expected === undefined || matcher.matched) {
52
+ await reader.cancel();
53
+ break;
54
+ }
55
+ }
56
+ }
57
+ const visible = Buffer.concat(chunks.map((chunk) => Buffer.from(chunk)), visibleLength);
58
+ return { text: new StringDecoder("utf8").write(visible), truncated, expectedMatched: matcher.matched };
59
+ }
package/dist/http.d.ts ADDED
@@ -0,0 +1,32 @@
1
+ import { type UI } from "@omochi-ui/sdk";
2
+ import { Publisher } from "./publisher.js";
3
+ import { UtilitySurface } from "./surface.js";
4
+ export interface HttpOptions {
5
+ url: string;
6
+ name: string;
7
+ intervalMs: number;
8
+ timeoutMs: number;
9
+ expectStatus: readonly [number, number];
10
+ expectBody?: string;
11
+ fetch?: typeof fetch;
12
+ }
13
+ export declare class HttpWatch extends UtilitySurface {
14
+ private readonly options;
15
+ private readonly fetcher;
16
+ private readonly body;
17
+ private interval?;
18
+ private controller?;
19
+ private running;
20
+ private paused;
21
+ private status;
22
+ private metadata;
23
+ private consecutiveFailures;
24
+ private lastHealthy?;
25
+ private transition;
26
+ constructor(publisher: Publisher, options: HttpOptions);
27
+ start(): Promise<void>;
28
+ protected build(ui: UI): void;
29
+ protected onAction(name: string): void;
30
+ close(): Promise<void>;
31
+ private run;
32
+ }