@xano/cli 1.0.6-beta.7 → 1.0.6-beta.8
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/commands/hatch/index.d.ts +130 -0
- package/dist/commands/hatch/index.js +386 -0
- package/dist/utils/hatch-client.d.ts +74 -0
- package/dist/utils/hatch-client.js +163 -0
- package/dist/utils/hatch-contract.d.ts +92 -0
- package/dist/utils/hatch-contract.js +34 -0
- package/dist/utils/hatch-render.d.ts +128 -0
- package/dist/utils/hatch-render.js +183 -0
- package/dist/utils/sse.d.ts +78 -0
- package/dist/utils/sse.js +188 -0
- package/oclif.manifest.json +3372 -3305
- package/package.json +1 -1
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `xano hatch "<prompt>"` — start a Hatch build and watch it to its ending.
|
|
3
|
+
*
|
|
4
|
+
* Extends oclif's `Command` rather than `BaseCommand` (see the plan's KTD5):
|
|
5
|
+
* `BaseCommand` exists to resolve a Xano profile, and Hatch's API takes no
|
|
6
|
+
* credentials, so inheriting it would put a `-p/--profile` flag on the command
|
|
7
|
+
* that changes nothing — and would break the "works on a fresh install with no
|
|
8
|
+
* credentials file" property outright. What that gives up is `verboseFetch`
|
|
9
|
+
* (whose User-Agent is reproduced by {@link HatchClient}) and the update-check
|
|
10
|
+
* notice.
|
|
11
|
+
*
|
|
12
|
+
* Everything below the oclif class is a plain function over injected
|
|
13
|
+
* dependencies, so the whole run — the ordering, the exit codes, the interrupt,
|
|
14
|
+
* the snapshot backstop — is testable without a socket, a TTY, or oclif.
|
|
15
|
+
*/
|
|
16
|
+
import { Command } from '@oclif/core';
|
|
17
|
+
import type { Activity, CreateSessionResponse, SessionSnapshot, TerminalOutcome } from '../../utils/hatch-contract.js';
|
|
18
|
+
import type { SseEvent } from '../../utils/sse.js';
|
|
19
|
+
/** Hatch's own wall-clock budget for a single build. */
|
|
20
|
+
export declare const HATCH_BUILD_BUDGET_MS: number;
|
|
21
|
+
/**
|
|
22
|
+
* How far past that budget the client keeps watching.
|
|
23
|
+
*
|
|
24
|
+
* The server's expiry sweep is what produces the `expired` terminal activity,
|
|
25
|
+
* and it runs on its own schedule; giving it a minute of slack means the
|
|
26
|
+
* server's own answer normally wins the race and the cap stays what it is
|
|
27
|
+
* meant to be — a backstop against a connection that died in a way that looked
|
|
28
|
+
* like it was still alive, not the usual way a build ends.
|
|
29
|
+
*/
|
|
30
|
+
export declare const WATCH_CAP_MARGIN_MS = 60000;
|
|
31
|
+
/** The hard ceiling on a watch. Hitting it resolves the outcome from the snapshot. */
|
|
32
|
+
export declare const WATCH_CAP_MS: number;
|
|
33
|
+
/**
|
|
34
|
+
* What the run ended as. A superset of the contract's terminal outcomes:
|
|
35
|
+
* `started` is `--no-wait`, and `interrupted` is Ctrl-C — neither is a build
|
|
36
|
+
* ending, but a scripted caller still has to get one object with a verdict in
|
|
37
|
+
* it rather than an empty stream.
|
|
38
|
+
*/
|
|
39
|
+
export type HatchRunOutcome = 'interrupted' | 'started' | TerminalOutcome;
|
|
40
|
+
/** The single object `-o json` puts on stdout. */
|
|
41
|
+
export interface HatchRunSummary {
|
|
42
|
+
message?: string;
|
|
43
|
+
outcome: HatchRunOutcome;
|
|
44
|
+
sessionId: string;
|
|
45
|
+
siteUrl?: string;
|
|
46
|
+
watchUrl?: string;
|
|
47
|
+
}
|
|
48
|
+
export interface HatchRunResult {
|
|
49
|
+
exitCode: number;
|
|
50
|
+
summary: HatchRunSummary;
|
|
51
|
+
}
|
|
52
|
+
/** The slice of {@link HatchClient} the run needs, so tests can stub it. */
|
|
53
|
+
export interface HatchSessionApi {
|
|
54
|
+
createSession(): Promise<CreateSessionResponse>;
|
|
55
|
+
getSnapshot(sessionId: string): Promise<SessionSnapshot>;
|
|
56
|
+
openStream(sessionId: string, lastEventId?: number | string): Promise<Response>;
|
|
57
|
+
submitPrompt(sessionId: string, prompt: string): Promise<void>;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Two sinks, not one: stdout carries the result and stderr carries the
|
|
61
|
+
* progress. The run decides which is which — under `-o json` every human line
|
|
62
|
+
* goes to `err` so stdout holds exactly one object and the run pipes into `jq`.
|
|
63
|
+
*/
|
|
64
|
+
export interface HatchIo {
|
|
65
|
+
err(line: string): void;
|
|
66
|
+
out(line: string): void;
|
|
67
|
+
}
|
|
68
|
+
/** The spinner, abstracted so the run never touches `ux` (or a TTY) in tests. */
|
|
69
|
+
export interface HatchSpinner {
|
|
70
|
+
start(action: string, status?: string): void;
|
|
71
|
+
stop(message?: string): void;
|
|
72
|
+
update(status: string): void;
|
|
73
|
+
}
|
|
74
|
+
export interface RunHatchOptions {
|
|
75
|
+
/** Spinner on. Gated by the caller exactly as `BaseCommand.waitForBuild()` does. */
|
|
76
|
+
animate: boolean;
|
|
77
|
+
/** Overrides {@link WATCH_CAP_MS}; exists for tests. */
|
|
78
|
+
capMs?: number;
|
|
79
|
+
client: HatchSessionApi;
|
|
80
|
+
/** Aborted by the `SIGINT` handler. */
|
|
81
|
+
interrupt?: AbortSignal;
|
|
82
|
+
io: HatchIo;
|
|
83
|
+
/** `-o json`: one object on stdout, everything else on stderr. */
|
|
84
|
+
json: boolean;
|
|
85
|
+
prompt: string;
|
|
86
|
+
/** Passed through to {@link readSseWithReconnect}; exists for tests. */
|
|
87
|
+
reconnect?: {
|
|
88
|
+
backoffMs?: (attempt: number) => number;
|
|
89
|
+
maxAttempts?: number;
|
|
90
|
+
sleep?: (ms: number) => Promise<void>;
|
|
91
|
+
};
|
|
92
|
+
spinner?: HatchSpinner;
|
|
93
|
+
verbose: boolean;
|
|
94
|
+
/** False under `--no-wait`: print the link, do not open the stream. */
|
|
95
|
+
wait: boolean;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Rejects a prompt that is empty or only whitespace, before anything is sent.
|
|
99
|
+
*
|
|
100
|
+
* Note what is deliberately *not* here: an upper bound. Hatch truncates at 5000
|
|
101
|
+
* characters, and a second limit in the client is a second number to keep in
|
|
102
|
+
* step with the first. The prompt goes out as given.
|
|
103
|
+
*/
|
|
104
|
+
export declare function validatePrompt(prompt: string): string;
|
|
105
|
+
/** An activity off an SSE frame, or undefined if the frame carried no usable JSON. */
|
|
106
|
+
export declare function parseActivity(event: SseEvent): Activity | undefined;
|
|
107
|
+
/** Printed on Ctrl-C. The build is server-side; the user only needs telling. */
|
|
108
|
+
export declare function interruptBlock(watchUrl?: string): string[];
|
|
109
|
+
/**
|
|
110
|
+
* Run a build end to end.
|
|
111
|
+
*
|
|
112
|
+
* The ordering is load-bearing: the watch link is printed after session-create
|
|
113
|
+
* and *before* the stream is opened, so a stream that never opens still leaves
|
|
114
|
+
* the user with a link to a build that is running regardless.
|
|
115
|
+
*/
|
|
116
|
+
export declare function runHatch(options: RunHatchOptions): Promise<HatchRunResult>;
|
|
117
|
+
export default class Hatch extends Command {
|
|
118
|
+
static args: {
|
|
119
|
+
prompt: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
|
|
120
|
+
};
|
|
121
|
+
static description: string;
|
|
122
|
+
static examples: string[];
|
|
123
|
+
static flags: {
|
|
124
|
+
api: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
125
|
+
'no-wait': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
126
|
+
output: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
127
|
+
verbose: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
128
|
+
};
|
|
129
|
+
run(): Promise<void>;
|
|
130
|
+
}
|
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
/* eslint-disable n/no-unsupported-features/node-builtins -- Response/ReadableStream/AbortSignal are stable on the Node 20.12 floor this package targets; the rule's support data is stale, and src/utils/sse.ts and src/utils/hatch-client.ts hit the same false positives */
|
|
2
|
+
/**
|
|
3
|
+
* `xano hatch "<prompt>"` — start a Hatch build and watch it to its ending.
|
|
4
|
+
*
|
|
5
|
+
* Extends oclif's `Command` rather than `BaseCommand` (see the plan's KTD5):
|
|
6
|
+
* `BaseCommand` exists to resolve a Xano profile, and Hatch's API takes no
|
|
7
|
+
* credentials, so inheriting it would put a `-p/--profile` flag on the command
|
|
8
|
+
* that changes nothing — and would break the "works on a fresh install with no
|
|
9
|
+
* credentials file" property outright. What that gives up is `verboseFetch`
|
|
10
|
+
* (whose User-Agent is reproduced by {@link HatchClient}) and the update-check
|
|
11
|
+
* notice.
|
|
12
|
+
*
|
|
13
|
+
* Everything below the oclif class is a plain function over injected
|
|
14
|
+
* dependencies, so the whole run — the ordering, the exit codes, the interrupt,
|
|
15
|
+
* the snapshot backstop — is testable without a socket, a TTY, or oclif.
|
|
16
|
+
*/
|
|
17
|
+
import { Args, Command, Flags, ux } from '@oclif/core';
|
|
18
|
+
import { DEFAULT_HATCH_URL, HATCH_URL_ENV_VAR, HatchClient, outcomeFromSnapshot, resolveHatchUrl } from '../../utils/hatch-client.js';
|
|
19
|
+
import { exitCodeFor, initialRenderState, openingBlock, QUEUED_STATUS, reduceActivity, reduceReconnecting, renderLines, terminalBlock, } from '../../utils/hatch-render.js';
|
|
20
|
+
import { readSseWithReconnect } from '../../utils/sse.js';
|
|
21
|
+
/** Hatch's own wall-clock budget for a single build. */
|
|
22
|
+
export const HATCH_BUILD_BUDGET_MS = 15 * 60 * 1000;
|
|
23
|
+
/**
|
|
24
|
+
* How far past that budget the client keeps watching.
|
|
25
|
+
*
|
|
26
|
+
* The server's expiry sweep is what produces the `expired` terminal activity,
|
|
27
|
+
* and it runs on its own schedule; giving it a minute of slack means the
|
|
28
|
+
* server's own answer normally wins the race and the cap stays what it is
|
|
29
|
+
* meant to be — a backstop against a connection that died in a way that looked
|
|
30
|
+
* like it was still alive, not the usual way a build ends.
|
|
31
|
+
*/
|
|
32
|
+
export const WATCH_CAP_MARGIN_MS = 60_000;
|
|
33
|
+
/** The hard ceiling on a watch. Hitting it resolves the outcome from the snapshot. */
|
|
34
|
+
export const WATCH_CAP_MS = HATCH_BUILD_BUDGET_MS + WATCH_CAP_MARGIN_MS;
|
|
35
|
+
/**
|
|
36
|
+
* Rejects a prompt that is empty or only whitespace, before anything is sent.
|
|
37
|
+
*
|
|
38
|
+
* Note what is deliberately *not* here: an upper bound. Hatch truncates at 5000
|
|
39
|
+
* characters, and a second limit in the client is a second number to keep in
|
|
40
|
+
* step with the first. The prompt goes out as given.
|
|
41
|
+
*/
|
|
42
|
+
export function validatePrompt(prompt) {
|
|
43
|
+
if (prompt.trim() === '') {
|
|
44
|
+
throw new Error('a prompt is required: describe what you want built, e.g. xano hatch "a landing page for a bakery"');
|
|
45
|
+
}
|
|
46
|
+
return prompt;
|
|
47
|
+
}
|
|
48
|
+
/** An activity off an SSE frame, or undefined if the frame carried no usable JSON. */
|
|
49
|
+
export function parseActivity(event) {
|
|
50
|
+
if (!event.data)
|
|
51
|
+
return undefined;
|
|
52
|
+
try {
|
|
53
|
+
const parsed = JSON.parse(event.data);
|
|
54
|
+
if (!parsed || typeof parsed !== 'object')
|
|
55
|
+
return undefined;
|
|
56
|
+
return parsed;
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
// A frame this client cannot read is not a reason to fail a build that is
|
|
60
|
+
// still running — the same forward-compatibility posture as an unknown kind.
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/** Printed on Ctrl-C. The build is server-side; the user only needs telling. */
|
|
65
|
+
export function interruptBlock(watchUrl) {
|
|
66
|
+
if (!watchUrl)
|
|
67
|
+
return ['Stopped watching. The build keeps running.'];
|
|
68
|
+
return ['Stopped watching — the build keeps running. Follow it at:', ` ${watchUrl}`];
|
|
69
|
+
}
|
|
70
|
+
/** Never resolves; stands in for an absent interrupt so the race is uniform. */
|
|
71
|
+
function idleGate() {
|
|
72
|
+
return {
|
|
73
|
+
dispose() { },
|
|
74
|
+
promise: new Promise(() => { }),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
function deadlineGate(ms) {
|
|
78
|
+
let timer;
|
|
79
|
+
const promise = new Promise((resolve) => {
|
|
80
|
+
timer = setTimeout(() => resolve({ kind: 'capped' }), ms);
|
|
81
|
+
// The cap must never be the reason a finished command lingers.
|
|
82
|
+
timer.unref?.();
|
|
83
|
+
});
|
|
84
|
+
return {
|
|
85
|
+
dispose() {
|
|
86
|
+
if (timer)
|
|
87
|
+
clearTimeout(timer);
|
|
88
|
+
},
|
|
89
|
+
promise,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
function interruptGate(signal) {
|
|
93
|
+
if (!signal)
|
|
94
|
+
return idleGate();
|
|
95
|
+
let onAbort;
|
|
96
|
+
const promise = new Promise((resolve) => {
|
|
97
|
+
onAbort = () => resolve({ kind: 'interrupted' });
|
|
98
|
+
if (signal.aborted)
|
|
99
|
+
onAbort();
|
|
100
|
+
else
|
|
101
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
102
|
+
});
|
|
103
|
+
return {
|
|
104
|
+
dispose() {
|
|
105
|
+
if (onAbort)
|
|
106
|
+
signal.removeEventListener('abort', onAbort);
|
|
107
|
+
},
|
|
108
|
+
promise,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* The event stream, plus the two things that can end the watch before it does.
|
|
113
|
+
*
|
|
114
|
+
* A custom iterable rather than a `while` loop so the consumer is a plain
|
|
115
|
+
* `for await` — and so the three endings are one `Promise.race` in one place.
|
|
116
|
+
* When the cap or the interrupt wins, the pending `next()` is abandoned; the
|
|
117
|
+
* consumer closes the generator on its way out.
|
|
118
|
+
*/
|
|
119
|
+
function racingSteps(iterator, gates) {
|
|
120
|
+
return {
|
|
121
|
+
[Symbol.asyncIterator]() {
|
|
122
|
+
return {
|
|
123
|
+
async next() {
|
|
124
|
+
const step = await Promise.race([
|
|
125
|
+
iterator.next().then((result) => (result.done ? { kind: 'closed' } : { event: result.value, kind: 'event' })),
|
|
126
|
+
...gates.map((gate) => gate.promise),
|
|
127
|
+
]);
|
|
128
|
+
return { done: false, value: step };
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Run a build end to end.
|
|
136
|
+
*
|
|
137
|
+
* The ordering is load-bearing: the watch link is printed after session-create
|
|
138
|
+
* and *before* the stream is opened, so a stream that never opens still leaves
|
|
139
|
+
* the user with a link to a build that is running regardless.
|
|
140
|
+
*/
|
|
141
|
+
export async function runHatch(options) {
|
|
142
|
+
const { animate, client, interrupt, io, json, prompt, spinner, verbose, wait } = options;
|
|
143
|
+
validatePrompt(prompt);
|
|
144
|
+
// Progress is not result. Under `-o json` it moves to stderr rather than
|
|
145
|
+
// being buffered: a run killed by a proxy drop must not leave the session id
|
|
146
|
+
// and the link nowhere at all, and `-o json` is the mode most likely to be
|
|
147
|
+
// running unattended.
|
|
148
|
+
const progress = (line) => {
|
|
149
|
+
if (json)
|
|
150
|
+
io.err(line);
|
|
151
|
+
else
|
|
152
|
+
io.out(line);
|
|
153
|
+
};
|
|
154
|
+
const session = await client.createSession();
|
|
155
|
+
const { sessionId, watchUrl } = session;
|
|
156
|
+
for (const line of openingBlock(watchUrl))
|
|
157
|
+
progress(line);
|
|
158
|
+
await client.submitPrompt(sessionId, prompt);
|
|
159
|
+
const conclude = (outcome, exitCode, build) => {
|
|
160
|
+
const summary = {
|
|
161
|
+
...(build?.message === undefined ? {} : { message: build.message }),
|
|
162
|
+
outcome,
|
|
163
|
+
sessionId,
|
|
164
|
+
...(build?.siteUrl === undefined ? {} : { siteUrl: build.siteUrl }),
|
|
165
|
+
...(watchUrl === undefined ? {} : { watchUrl }),
|
|
166
|
+
};
|
|
167
|
+
if (json)
|
|
168
|
+
io.out(JSON.stringify(summary, null, 2));
|
|
169
|
+
return { exitCode, summary };
|
|
170
|
+
};
|
|
171
|
+
if (!wait)
|
|
172
|
+
return conclude('started', 0);
|
|
173
|
+
let state = initialRenderState();
|
|
174
|
+
const emit = (result) => {
|
|
175
|
+
for (const line of renderLines(result, { animate }))
|
|
176
|
+
progress(line);
|
|
177
|
+
if (animate)
|
|
178
|
+
spinner?.update(result.status);
|
|
179
|
+
state = result.state;
|
|
180
|
+
};
|
|
181
|
+
if (animate)
|
|
182
|
+
spinner?.start('Building', QUEUED_STATUS);
|
|
183
|
+
else
|
|
184
|
+
progress(QUEUED_STATUS);
|
|
185
|
+
const iterator = readSseWithReconnect({
|
|
186
|
+
...options.reconnect,
|
|
187
|
+
isTerminal: (event) => parseActivity(event)?.kind === 'terminal',
|
|
188
|
+
onReconnecting(attempt) {
|
|
189
|
+
emit(reduceReconnecting(state, attempt));
|
|
190
|
+
},
|
|
191
|
+
async open(lastEventId) {
|
|
192
|
+
const response = await client.openStream(sessionId, lastEventId);
|
|
193
|
+
if (!response.body)
|
|
194
|
+
throw new Error('the Hatch build stream returned an empty response body');
|
|
195
|
+
return response.body;
|
|
196
|
+
},
|
|
197
|
+
})[Symbol.asyncIterator]();
|
|
198
|
+
const gates = [deadlineGate(options.capMs ?? WATCH_CAP_MS), interruptGate(interrupt)];
|
|
199
|
+
const { done, ending, streamError } = await watchToEnding(iterator, gates, (event) => {
|
|
200
|
+
// A frame that carried no usable activity is not an ending — skip it.
|
|
201
|
+
const activity = parseActivity(event);
|
|
202
|
+
if (!activity)
|
|
203
|
+
return;
|
|
204
|
+
const result = reduceActivity(state, activity, { verbose, watchUrl });
|
|
205
|
+
if (result.done && animate)
|
|
206
|
+
spinner?.stop('');
|
|
207
|
+
emit(result);
|
|
208
|
+
return result.done;
|
|
209
|
+
});
|
|
210
|
+
if (animate && !done)
|
|
211
|
+
spinner?.stop('');
|
|
212
|
+
if (ending === 'interrupted') {
|
|
213
|
+
for (const line of interruptBlock(watchUrl))
|
|
214
|
+
progress(line);
|
|
215
|
+
return conclude('interrupted', 0);
|
|
216
|
+
}
|
|
217
|
+
// The stream closing is the fast path, not the guarantee. Anything that got
|
|
218
|
+
// here — the cap, a close with no terminal activity, a stream that could not
|
|
219
|
+
// be opened at all — asks the server what actually happened rather than
|
|
220
|
+
// assuming. One call on an exceptional path, not a poll loop.
|
|
221
|
+
let ended = done;
|
|
222
|
+
if (!ended) {
|
|
223
|
+
ended = await resolveFromSnapshot(client, sessionId, ending, streamError);
|
|
224
|
+
for (const line of terminalBlock(ended.outcome, watchUrl))
|
|
225
|
+
progress(line);
|
|
226
|
+
}
|
|
227
|
+
return conclude(ended.outcome.outcome, ended.exitCode, ended.outcome);
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Consume the stream until something ends it: a terminal activity, the
|
|
231
|
+
* wall-clock cap, an interrupt, a close, or a throw.
|
|
232
|
+
*
|
|
233
|
+
* `handle` folds each event and returns the build's ending if that event was
|
|
234
|
+
* the one that carried it — keeping the render side of the loop out of here.
|
|
235
|
+
*/
|
|
236
|
+
async function watchToEnding(iterator, gates, handle) {
|
|
237
|
+
const result = { ending: 'closed' };
|
|
238
|
+
try {
|
|
239
|
+
for await (const step of racingSteps(iterator, gates)) {
|
|
240
|
+
if (step.kind !== 'event') {
|
|
241
|
+
result.ending = step.kind;
|
|
242
|
+
break;
|
|
243
|
+
}
|
|
244
|
+
const done = handle(step.event);
|
|
245
|
+
if (done) {
|
|
246
|
+
result.done = done;
|
|
247
|
+
result.ending = 'closed';
|
|
248
|
+
break;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
catch (error) {
|
|
253
|
+
result.streamError = error;
|
|
254
|
+
}
|
|
255
|
+
finally {
|
|
256
|
+
for (const gate of gates)
|
|
257
|
+
gate.dispose();
|
|
258
|
+
// Deliberately not awaited. On the cap and interrupt paths the generator is
|
|
259
|
+
// parked on a read of a stream that is still open, and an async generator
|
|
260
|
+
// queues `return()` behind that pending `next()` — awaiting it would hang
|
|
261
|
+
// on exactly the two paths that exist to stop a hang.
|
|
262
|
+
const closing = iterator.return?.();
|
|
263
|
+
if (closing)
|
|
264
|
+
closing.catch(() => { });
|
|
265
|
+
}
|
|
266
|
+
return result;
|
|
267
|
+
}
|
|
268
|
+
/** Why the watch ended without a terminal activity, in the user's words. */
|
|
269
|
+
function unresolvedMessage(ending, streamError) {
|
|
270
|
+
const cause = streamError instanceof Error ? ` (${streamError.message})` : '';
|
|
271
|
+
if (ending === 'capped') {
|
|
272
|
+
return `Stopped watching after ${Math.round(WATCH_CAP_MS / 60_000)} minutes and the build had not reported an ending. It may still be running.`;
|
|
273
|
+
}
|
|
274
|
+
return `The build stream ended before the build did, and the server has not reported an ending yet${cause}.`;
|
|
275
|
+
}
|
|
276
|
+
async function resolveFromSnapshot(client, sessionId, ending, streamError) {
|
|
277
|
+
let snapshot;
|
|
278
|
+
let snapshotError;
|
|
279
|
+
try {
|
|
280
|
+
snapshot = await client.getSnapshot(sessionId);
|
|
281
|
+
}
|
|
282
|
+
catch (error) {
|
|
283
|
+
snapshotError = error;
|
|
284
|
+
}
|
|
285
|
+
const outcome = snapshot && outcomeFromSnapshot(snapshot);
|
|
286
|
+
if (outcome)
|
|
287
|
+
return { exitCode: exitCodeFor(outcome.outcome), outcome };
|
|
288
|
+
// Still in flight, or the snapshot itself failed. Either way this is not a
|
|
289
|
+
// success and silence is not an option: fail with what is known.
|
|
290
|
+
return {
|
|
291
|
+
exitCode: 1,
|
|
292
|
+
outcome: { message: unresolvedMessage(ending, snapshotError ?? streamError), outcome: 'failed' },
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
export default class Hatch extends Command {
|
|
296
|
+
static args = {
|
|
297
|
+
prompt: Args.string({
|
|
298
|
+
description: 'What you want built, in plain language',
|
|
299
|
+
required: true,
|
|
300
|
+
}),
|
|
301
|
+
};
|
|
302
|
+
static description = 'Build and deploy a full-stack app from a prompt, with Hatch';
|
|
303
|
+
static examples = [
|
|
304
|
+
`$ xano hatch "a landing page for a bakery, with an order form"
|
|
305
|
+
Your idea is being created, follow along for more details:
|
|
306
|
+
https://hatch.mesh0.ai/s/K7QM2XPA9RTV`,
|
|
307
|
+
`$ xano hatch "an inventory tracker" --no-wait
|
|
308
|
+
(prints the follow-along link and returns immediately)`,
|
|
309
|
+
`$ xano hatch "a recipe box" -o json | jq -r .siteUrl
|
|
310
|
+
(one JSON object on stdout; progress goes to stderr)`,
|
|
311
|
+
`$ xano hatch "a bakery site" --api http://localhost:8080
|
|
312
|
+
(point at a local Hatch API; XANO_HATCH_URL does the same)`,
|
|
313
|
+
];
|
|
314
|
+
static flags = {
|
|
315
|
+
// No `env:` here even though the variable is honoured. This repo's custom
|
|
316
|
+
// help class renders `[env: X]` for any flag that declares one, and oclif
|
|
317
|
+
// renders it too, so declaring it prints the hint twice. Precedence lives
|
|
318
|
+
// in `resolveHatchUrl`, which is where it was put to begin with.
|
|
319
|
+
api: Flags.string({
|
|
320
|
+
description: `Hatch base URL. Defaults to $${HATCH_URL_ENV_VAR}, then ${DEFAULT_HATCH_URL}`,
|
|
321
|
+
required: false,
|
|
322
|
+
}),
|
|
323
|
+
'no-wait': Flags.boolean({
|
|
324
|
+
default: false,
|
|
325
|
+
description: 'Print the follow-along link and exit without watching the build',
|
|
326
|
+
}),
|
|
327
|
+
output: Flags.string({
|
|
328
|
+
char: 'o',
|
|
329
|
+
default: 'summary',
|
|
330
|
+
description: 'Output format',
|
|
331
|
+
options: ['json', 'summary'],
|
|
332
|
+
}),
|
|
333
|
+
verbose: Flags.boolean({
|
|
334
|
+
default: false,
|
|
335
|
+
description: 'Print the build log lines as they arrive (disables the spinner)',
|
|
336
|
+
}),
|
|
337
|
+
};
|
|
338
|
+
async run() {
|
|
339
|
+
const { args, flags } = await this.parse(Hatch);
|
|
340
|
+
const json = flags.output === 'json';
|
|
341
|
+
const { verbose } = flags;
|
|
342
|
+
// Same gating as BaseCommand.waitForBuild(): a spinner belongs only on an
|
|
343
|
+
// interactive TTY that is not emitting JSON and not interleaving log lines.
|
|
344
|
+
const animate = Boolean(process.stdout.isTTY) && !json && !verbose;
|
|
345
|
+
const client = new HatchClient({
|
|
346
|
+
baseUrl: resolveHatchUrl({ env: process.env[HATCH_URL_ENV_VAR], flag: flags.api }),
|
|
347
|
+
version: this.config.version,
|
|
348
|
+
});
|
|
349
|
+
// The build is server-side and unaffected by this process leaving; the
|
|
350
|
+
// handler exists only so the user is told so rather than left guessing.
|
|
351
|
+
const controller = new AbortController();
|
|
352
|
+
const onInterrupt = () => controller.abort();
|
|
353
|
+
process.on('SIGINT', onInterrupt);
|
|
354
|
+
let result;
|
|
355
|
+
try {
|
|
356
|
+
result = await runHatch({
|
|
357
|
+
animate,
|
|
358
|
+
client,
|
|
359
|
+
interrupt: controller.signal,
|
|
360
|
+
io: {
|
|
361
|
+
err: (line) => this.logToStderr(line),
|
|
362
|
+
out: (line) => this.log(line),
|
|
363
|
+
},
|
|
364
|
+
json,
|
|
365
|
+
prompt: args.prompt,
|
|
366
|
+
spinner: {
|
|
367
|
+
start: (action, status) => ux.action.start(action, status),
|
|
368
|
+
stop: (message) => ux.action.stop(message ?? ''),
|
|
369
|
+
update(status) {
|
|
370
|
+
ux.action.status = status;
|
|
371
|
+
},
|
|
372
|
+
},
|
|
373
|
+
verbose,
|
|
374
|
+
wait: !flags['no-wait'],
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
catch (error) {
|
|
378
|
+
this.error(error.message);
|
|
379
|
+
}
|
|
380
|
+
finally {
|
|
381
|
+
process.off('SIGINT', onInterrupt);
|
|
382
|
+
}
|
|
383
|
+
if (result.exitCode !== 0)
|
|
384
|
+
this.exit(result.exitCode);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A minimal HTTP client for the Hatch API.
|
|
3
|
+
*
|
|
4
|
+
* Deliberately free of oclif: it takes its `fetch` and its version string as
|
|
5
|
+
* options so it is unit-testable without a network or a command instance.
|
|
6
|
+
* Hatch authenticates an anonymous session purely by the signed cookie handed
|
|
7
|
+
* back from session-create, so this client's only state is that cookie.
|
|
8
|
+
*/
|
|
9
|
+
import type { BuildOutcome, CreateSessionResponse, SessionSnapshot } from './hatch-contract.js';
|
|
10
|
+
/** Where `xano hatch` talks to when nothing else says otherwise. */
|
|
11
|
+
export declare const DEFAULT_HATCH_URL = "https://hatch.mesh0.ai";
|
|
12
|
+
/** The env var that points the command at a local API or a staging droplet. */
|
|
13
|
+
export declare const HATCH_URL_ENV_VAR = "XANO_HATCH_URL";
|
|
14
|
+
/**
|
|
15
|
+
* Resolve the Hatch origin: `--api` flag > `XANO_HATCH_URL` > the default.
|
|
16
|
+
*
|
|
17
|
+
* Trailing slashes are stripped here rather than at each call site, so no
|
|
18
|
+
* request ever ends up asking for `//api/sessions`.
|
|
19
|
+
*/
|
|
20
|
+
export declare function resolveHatchUrl(source: {
|
|
21
|
+
env?: string;
|
|
22
|
+
flag?: string;
|
|
23
|
+
}): string;
|
|
24
|
+
/**
|
|
25
|
+
* Session-create answered without a `Set-Cookie`. Named because every later
|
|
26
|
+
* call would otherwise 404 or 403 with no hint of the real cause.
|
|
27
|
+
*/
|
|
28
|
+
export declare class HatchSessionCookieError extends Error {
|
|
29
|
+
constructor(message: string);
|
|
30
|
+
}
|
|
31
|
+
export interface HatchClientOptions {
|
|
32
|
+
/** Origin only, no trailing slash — see `resolveHatchUrl`. */
|
|
33
|
+
baseUrl: string;
|
|
34
|
+
fetchImpl?: typeof fetch;
|
|
35
|
+
/** Reported as `xano-cli/<version>`; passed in so oclif stays out of here. */
|
|
36
|
+
version: string;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Derive how a build ended from its snapshot, in the same shape a `terminal`
|
|
40
|
+
* activity carries — so the command's stream path and its snapshot backstop
|
|
41
|
+
* agree on what "done" looks like. Undefined while the build is in flight.
|
|
42
|
+
*/
|
|
43
|
+
export declare function outcomeFromSnapshot(snapshot: SessionSnapshot): BuildOutcome | undefined;
|
|
44
|
+
export declare class HatchClient {
|
|
45
|
+
private readonly baseUrl;
|
|
46
|
+
private readonly fetchImpl;
|
|
47
|
+
/** The `name=value` pair captured from session-create. */
|
|
48
|
+
private sessionCookie;
|
|
49
|
+
private readonly userAgent;
|
|
50
|
+
constructor(options: HatchClientOptions);
|
|
51
|
+
/**
|
|
52
|
+
* Mint an anonymous session, asking for a watch link.
|
|
53
|
+
*
|
|
54
|
+
* `watchUrl` is optional on purpose: a Hatch deployed before the `?watch=1`
|
|
55
|
+
* affordance answers without one, and the command degrades to watching
|
|
56
|
+
* without a link rather than failing.
|
|
57
|
+
*/
|
|
58
|
+
createSession(): Promise<CreateSessionResponse>;
|
|
59
|
+
/** `GET /api/sessions/:id` — the backstop when the stream says nothing. */
|
|
60
|
+
getSnapshot(sessionId: string): Promise<SessionSnapshot>;
|
|
61
|
+
/**
|
|
62
|
+
* Open the session's SSE stream, returning the raw `Response` for a reader
|
|
63
|
+
* to frame.
|
|
64
|
+
*
|
|
65
|
+
* `lastEventId` goes in the `Last-Event-ID` **header**. Hatch's frontend also
|
|
66
|
+
* appends `?from=<n>`, but the server reads only the header — sending the
|
|
67
|
+
* query parameter alone silently replays the whole build on every reconnect.
|
|
68
|
+
*/
|
|
69
|
+
openStream(sessionId: string, lastEventId?: number | string): Promise<Response>;
|
|
70
|
+
/** `POST /api/sessions/:id/prompt` — the server answers 202. */
|
|
71
|
+
submitPrompt(sessionId: string, prompt: string): Promise<void>;
|
|
72
|
+
/** Every request carries the User-Agent, and the cookie once we hold one. */
|
|
73
|
+
private request;
|
|
74
|
+
}
|