@xano/cli 1.0.6-beta.6 → 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/README.md CHANGED
@@ -56,20 +56,20 @@ Notes:
56
56
  - **`--per_page` is only offered where the endpoint accepts it.** Static-host endpoints hardcode 100 items per page server-side, so those commands take `--page` alone. `xano static_host list --per_page 10` previously parsed but did nothing; it is now rejected rather than silently ignored.
57
57
  - **All paged commands default `--per_page` to 50.** The test-list commands previously requested 10000 internally so nothing was ever cut off; now that the footer and the JSON envelope both report position, a page that stops at 50 is visible rather than silent. Raise it (up to 10000) when you want everything in one call.
58
58
  - **The last group has no CLI paging.** Those endpoints page server-side but return a plain array with no page or total metadata, so the CLI cannot tell you where you are. Rather than show a page number with no context — or infer "more available" from a full page, which is wrong exactly when the result count is a multiple of the page size — these commands are left as-is. **They return at most 25–50 items** (50 for tenants and ephemerals, 25 for releases, platforms, and tenant backups). If you have more than that, query the Metadata API directly until those endpoints return paging metadata.
59
- - **`--output json` returns an envelope, not a bare array.** Every list command emits `{count, items, ...}`, with `page`, `per_page`, `next_page`, `prev_page`, and `total` included only when the endpoint actually reports them. This is a **breaking change** for scripts that parsed the previous bare array — read `.items` instead.
59
+ - **`--output json` returns an envelope, not a bare array.** Every list command emits `{items, ...}` using the Metadata API's own field names — `curPage`, `nextPage`, `prevPage`, `itemsTotal` — each included only when the endpoint actually reports it. `perPage` is the one field the CLI adds, since the API never echoes it back and it is otherwise invisible at its default. This is a **breaking change** for scripts that parsed the previous bare array — read `.items` instead.
60
60
 
61
61
  ```jsonc
62
62
  // xano function list -o json
63
63
  {
64
- "count": 50,
65
- "page": 2,
66
- "per_page": 50,
67
- "next_page": 3, // absent on the last page — never inferred
64
+ "curPage": 2,
65
+ "perPage": 50, // injected by the CLI; everything else mirrors the API
66
+ "nextPage": 3, // absent on the last page — never inferred
67
+ "prevPage": 1,
68
68
  "items": [ /* ... */ ]
69
69
  }
70
70
  ```
71
71
 
72
- The point of the envelope is that a script gets the same honest stop condition a human gets from the footer. Without it the only signal available is `items.length < per_page`, which is wrong exactly when the result count is a multiple of the page size. Absence of `next_page` means the server said there is no next page; it is never omitted as a guess.
72
+ The point of the envelope is that a script gets the same honest stop condition a human gets from the footer, in the same shape the raw Metadata API returns. Without it the only signal available is `items.length < perPage`, which is wrong exactly when the result count is a multiple of the page size. Absence of `nextPage` means the server said there is no next page; it is never omitted as a guess.
73
73
 
74
74
  ### Authentication
75
75
 
@@ -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
+ }