@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.
@@ -0,0 +1,163 @@
1
+ /* eslint-disable n/no-unsupported-features/node-builtins, no-undef -- fetch/Response/Headers and the RequestInit type are stable on the Node 20.12 floor this package targets; the rule's support data is stale, and src/base-command.ts hits the same false positives */
2
+ import { isTerminalOutcome } from './hatch-contract.js';
3
+ /** Where `xano hatch` talks to when nothing else says otherwise. */
4
+ export const DEFAULT_HATCH_URL = 'https://hatch.mesh0.ai';
5
+ /** The env var that points the command at a local API or a staging droplet. */
6
+ export const HATCH_URL_ENV_VAR = 'XANO_HATCH_URL';
7
+ /**
8
+ * Resolve the Hatch origin: `--api` flag > `XANO_HATCH_URL` > the default.
9
+ *
10
+ * Trailing slashes are stripped here rather than at each call site, so no
11
+ * request ever ends up asking for `//api/sessions`.
12
+ */
13
+ export function resolveHatchUrl(source) {
14
+ const chosen = source.flag?.trim() || source.env?.trim() || DEFAULT_HATCH_URL;
15
+ return chosen.replace(/\/+$/, '');
16
+ }
17
+ /**
18
+ * Session-create answered without a `Set-Cookie`. Named because every later
19
+ * call would otherwise 404 or 403 with no hint of the real cause.
20
+ */
21
+ export class HatchSessionCookieError extends Error {
22
+ constructor(message) {
23
+ super(message);
24
+ this.name = 'HatchSessionCookieError';
25
+ }
26
+ }
27
+ /**
28
+ * Derive how a build ended from its snapshot, in the same shape a `terminal`
29
+ * activity carries — so the command's stream path and its snapshot backstop
30
+ * agree on what "done" looks like. Undefined while the build is in flight.
31
+ */
32
+ export function outcomeFromSnapshot(snapshot) {
33
+ if (!isTerminalOutcome(snapshot.state))
34
+ return undefined;
35
+ return {
36
+ ...(snapshot.failureMessage === undefined ? {} : { message: snapshot.failureMessage }),
37
+ outcome: snapshot.state,
38
+ ...(snapshot.siteUrl === undefined ? {} : { siteUrl: snapshot.siteUrl }),
39
+ };
40
+ }
41
+ export class HatchClient {
42
+ baseUrl;
43
+ fetchImpl;
44
+ /** The `name=value` pair captured from session-create. */
45
+ sessionCookie;
46
+ userAgent;
47
+ constructor(options) {
48
+ this.baseUrl = options.baseUrl.replace(/\/+$/, '');
49
+ this.fetchImpl = options.fetchImpl ?? fetch;
50
+ this.userAgent = `xano-cli/${options.version}`;
51
+ }
52
+ /**
53
+ * Mint an anonymous session, asking for a watch link.
54
+ *
55
+ * `watchUrl` is optional on purpose: a Hatch deployed before the `?watch=1`
56
+ * affordance answers without one, and the command degrades to watching
57
+ * without a link rather than failing.
58
+ */
59
+ async createSession() {
60
+ const response = await this.request(`${this.baseUrl}/api/sessions?watch=1`, { method: 'POST' });
61
+ if (!response.ok) {
62
+ throw new Error(await describeFailure(response, 'failed to start a Hatch session'));
63
+ }
64
+ const cookie = extractSessionCookie(response);
65
+ if (!cookie) {
66
+ throw new HatchSessionCookieError('failed to start a Hatch session: the server returned no session cookie, so the build could not be claimed');
67
+ }
68
+ this.sessionCookie = cookie;
69
+ return (await parseJson(response, 'failed to start a Hatch session'));
70
+ }
71
+ /** `GET /api/sessions/:id` — the backstop when the stream says nothing. */
72
+ async getSnapshot(sessionId) {
73
+ const response = await this.request(`${this.baseUrl}/api/sessions/${encodeURIComponent(sessionId)}`, {
74
+ headers: { accept: 'application/json' },
75
+ method: 'GET',
76
+ });
77
+ if (!response.ok) {
78
+ throw new Error(await describeFailure(response, 'failed to read the Hatch session'));
79
+ }
80
+ return (await parseJson(response, 'failed to read the Hatch session'));
81
+ }
82
+ /**
83
+ * Open the session's SSE stream, returning the raw `Response` for a reader
84
+ * to frame.
85
+ *
86
+ * `lastEventId` goes in the `Last-Event-ID` **header**. Hatch's frontend also
87
+ * appends `?from=<n>`, but the server reads only the header — sending the
88
+ * query parameter alone silently replays the whole build on every reconnect.
89
+ */
90
+ async openStream(sessionId, lastEventId) {
91
+ const headers = { accept: 'text/event-stream' };
92
+ if (lastEventId !== undefined) {
93
+ headers['Last-Event-ID'] = String(lastEventId);
94
+ }
95
+ const response = await this.request(`${this.baseUrl}/events/sessions/${encodeURIComponent(sessionId)}`, {
96
+ headers,
97
+ method: 'GET',
98
+ });
99
+ if (!response.ok) {
100
+ throw new Error(await describeFailure(response, 'failed to open the Hatch build stream'));
101
+ }
102
+ return response;
103
+ }
104
+ /** `POST /api/sessions/:id/prompt` — the server answers 202. */
105
+ async submitPrompt(sessionId, prompt) {
106
+ const response = await this.request(`${this.baseUrl}/api/sessions/${encodeURIComponent(sessionId)}/prompt`, {
107
+ body: JSON.stringify({ prompt }),
108
+ headers: { accept: 'application/json', 'content-type': 'application/json' },
109
+ method: 'POST',
110
+ });
111
+ if (!response.ok) {
112
+ throw new Error(await describeFailure(response, 'failed to submit the prompt'));
113
+ }
114
+ }
115
+ /** Every request carries the User-Agent, and the cookie once we hold one. */
116
+ async request(url, init) {
117
+ const headers = {
118
+ 'User-Agent': this.userAgent,
119
+ ...init.headers,
120
+ };
121
+ if (this.sessionCookie) {
122
+ headers.Cookie = this.sessionCookie;
123
+ }
124
+ try {
125
+ return await this.fetchImpl(url, { ...init, headers });
126
+ }
127
+ catch (error) {
128
+ throw new Error(`failed to reach Hatch at ${url}: ${error.message}`);
129
+ }
130
+ }
131
+ }
132
+ /**
133
+ * Unwrap Hatch's `{code, message}` error envelope. A non-JSON body (a proxy's
134
+ * HTML error page, say) still has to read as something, so it is appended
135
+ * under the status line rather than swallowed.
136
+ */
137
+ async function describeFailure(response, context) {
138
+ const body = await response.text().catch(() => '');
139
+ try {
140
+ const parsed = JSON.parse(body);
141
+ if (parsed?.message)
142
+ return `${context}: ${parsed.message}`;
143
+ }
144
+ catch {
145
+ // Not JSON — fall through to the status-plus-body form below.
146
+ }
147
+ return body ? `${context} (${response.status})\n${body}` : `${context} (${response.status})`;
148
+ }
149
+ /** Read a `Set-Cookie`, keeping only the `name=value` pair. */
150
+ function extractSessionCookie(response) {
151
+ const raw = response.headers.getSetCookie?.()[0] ?? response.headers.get('set-cookie');
152
+ const pair = raw?.split(';')[0]?.trim();
153
+ return pair || undefined;
154
+ }
155
+ async function parseJson(response, context) {
156
+ const body = await response.text();
157
+ try {
158
+ return JSON.parse(body);
159
+ }
160
+ catch (error) {
161
+ throw new Error(`${context}: the server returned a non-JSON response (${error.message})`);
162
+ }
163
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * A hand-written copy of the subset of Hatch's contract this CLI reads.
3
+ *
4
+ * Hatch's own `shared/contracts.ts` is an unpublished workspace file in a
5
+ * different repository; this package is released independently and cannot
6
+ * import it. Only what `xano hatch` consumes is copied here.
7
+ *
8
+ * It is a permissive superset on purpose: an activity `kind` or a phase this
9
+ * CLI has never heard of must degrade to "ignored", never to a throw, so a
10
+ * newer Hatch keeps working against an older CLI.
11
+ */
12
+ export declare const BUILD_PHASES: readonly ["queued", "scaffolding", "backend", "frontend", "deploying", "done"];
13
+ export type BuildPhase = (typeof BUILD_PHASES)[number];
14
+ /** Phase copy, mirroring Hatch's `frontend/src/lib/labels.ts`. */
15
+ export declare const PHASE_LABELS: Record<BuildPhase, string>;
16
+ /** Falls back to the raw value so an unknown phase renders as itself. */
17
+ export declare function phaseLabel(phase: string): string;
18
+ /** The states a build can end in. Everything else is in-flight. */
19
+ export declare const TERMINAL_OUTCOMES: readonly ["succeeded", "failed", "expired", "rejected"];
20
+ export type TerminalOutcome = (typeof TERMINAL_OUTCOMES)[number];
21
+ export type SessionState = 'building' | 'deploying' | 'draft' | 'provisioning' | 'queued' | TerminalOutcome;
22
+ export declare function isTerminalOutcome(state: string): state is TerminalOutcome;
23
+ export declare const ACTIVITY_KINDS: readonly ["phase", "narration", "artifact", "log", "heartbeat", "terminal"];
24
+ export type ActivityKind = (typeof ACTIVITY_KINDS)[number];
25
+ export interface ActivityBase {
26
+ /** Epoch milliseconds, server-assigned. */
27
+ at: number;
28
+ kind: string;
29
+ /** Monotonic per-session sequence; doubles as the SSE `Last-Event-ID`. */
30
+ seq: number;
31
+ }
32
+ export interface PhaseActivity extends ActivityBase {
33
+ kind: 'phase';
34
+ phase: BuildPhase;
35
+ }
36
+ export interface NarrationActivity extends ActivityBase {
37
+ kind: 'narration';
38
+ message: string;
39
+ }
40
+ export interface ArtifactActivity extends ActivityBase {
41
+ action: 'created' | 'deleted' | 'failed' | 'modified';
42
+ kind: 'artifact';
43
+ path: string;
44
+ }
45
+ export interface LogActivity extends ActivityBase {
46
+ kind: 'log';
47
+ line: string;
48
+ role?: 'input' | 'output';
49
+ stream: 'stderr' | 'stdout';
50
+ }
51
+ export interface HeartbeatActivity extends ActivityBase {
52
+ elapsedInPhaseMs: number;
53
+ filesWritten: number;
54
+ kind: 'heartbeat';
55
+ lastAction: string;
56
+ phase: BuildPhase;
57
+ }
58
+ export interface TerminalActivity extends ActivityBase {
59
+ kind: 'terminal';
60
+ /** User-facing prose on any non-success outcome. */
61
+ message?: string;
62
+ outcome: TerminalOutcome;
63
+ /** Present only when `outcome === 'succeeded'`. */
64
+ siteUrl?: string;
65
+ }
66
+ export type KnownActivity = ArtifactActivity | HeartbeatActivity | LogActivity | NarrationActivity | PhaseActivity | TerminalActivity;
67
+ /** An activity off the wire: known kinds are typed, anything else is inert. */
68
+ export type Activity = ActivityBase | KnownActivity;
69
+ export declare function isKnownActivity(activity: Activity): activity is KnownActivity;
70
+ /** How a build ended — the one shape both the stream and the snapshot yield. */
71
+ export interface BuildOutcome {
72
+ message?: string;
73
+ outcome: TerminalOutcome;
74
+ siteUrl?: string;
75
+ }
76
+ /** `POST /api/sessions` */
77
+ export interface CreateSessionResponse {
78
+ sessionId: string;
79
+ state: SessionState;
80
+ /** Absolute, server-built, and present only when `?watch=1` was asked for. */
81
+ watchUrl?: string;
82
+ }
83
+ /** `GET /api/sessions/:id` */
84
+ export interface SessionSnapshot {
85
+ createdAt: number;
86
+ failureMessage?: string;
87
+ phase: BuildPhase;
88
+ prompt: string;
89
+ sessionId: string;
90
+ siteUrl?: string;
91
+ state: SessionState;
92
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * A hand-written copy of the subset of Hatch's contract this CLI reads.
3
+ *
4
+ * Hatch's own `shared/contracts.ts` is an unpublished workspace file in a
5
+ * different repository; this package is released independently and cannot
6
+ * import it. Only what `xano hatch` consumes is copied here.
7
+ *
8
+ * It is a permissive superset on purpose: an activity `kind` or a phase this
9
+ * CLI has never heard of must degrade to "ignored", never to a throw, so a
10
+ * newer Hatch keeps working against an older CLI.
11
+ */
12
+ export const BUILD_PHASES = ['queued', 'scaffolding', 'backend', 'frontend', 'deploying', 'done'];
13
+ /** Phase copy, mirroring Hatch's `frontend/src/lib/labels.ts`. */
14
+ export const PHASE_LABELS = {
15
+ backend: 'Backend',
16
+ deploying: 'Deploying',
17
+ done: 'Done',
18
+ frontend: 'Frontend',
19
+ queued: 'Waiting',
20
+ scaffolding: 'Setting up',
21
+ };
22
+ /** Falls back to the raw value so an unknown phase renders as itself. */
23
+ export function phaseLabel(phase) {
24
+ return PHASE_LABELS[phase] ?? phase;
25
+ }
26
+ /** The states a build can end in. Everything else is in-flight. */
27
+ export const TERMINAL_OUTCOMES = ['succeeded', 'failed', 'expired', 'rejected'];
28
+ export function isTerminalOutcome(state) {
29
+ return TERMINAL_OUTCOMES.includes(state);
30
+ }
31
+ export const ACTIVITY_KINDS = ['phase', 'narration', 'artifact', 'log', 'heartbeat', 'terminal'];
32
+ export function isKnownActivity(activity) {
33
+ return ACTIVITY_KINDS.includes(activity.kind);
34
+ }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Turning a Hatch activity stream into what the terminal shows.
3
+ *
4
+ * Everything here is a pure function of `(state, activity)`, so the whole of
5
+ * `xano hatch`'s output can be tested with no socket, no TTY, and no clock.
6
+ *
7
+ * The fold emits two different things and the caller decides which to print:
8
+ *
9
+ * - `lines` — things that belong in the transcript no matter what (a completed
10
+ * phase, the final block, a `--verbose` log line).
11
+ * - `status` — the current "what is happening right now" label.
12
+ *
13
+ * When the command is animating, `status` is the spinner label and only `lines`
14
+ * are printed. When it is not — a non-TTY, `--verbose`, a redirect to a file —
15
+ * there is no spinner to carry the status, so a *changed* status has to be
16
+ * printed as a plain line; otherwise a piped build shows one line per phase and
17
+ * nothing at all for the six minutes in between. {@link renderLines} is the one
18
+ * place that difference lives; the fold itself is identical in both modes.
19
+ */
20
+ import type { Activity, BuildOutcome, TerminalOutcome } from './hatch-contract.js';
21
+ /**
22
+ * The status before any activity arrives. Not empty on purpose: the gap between
23
+ * submitting a prompt and the first `phase` event is not short — the agent pool
24
+ * is small and a build behind a full one waits — and an empty terminal in that
25
+ * window reads as a crash.
26
+ */
27
+ export declare const QUEUED_STATUS = "Queued";
28
+ /** The one outcome the user can act on immediately, and the only one not about their prompt. */
29
+ export declare const POOL_FULL_SENTENCE = "Hatch had no free build slot \u2014 try again shortly.";
30
+ export interface RenderOptions {
31
+ /** Whether `log` activities are part of the transcript. */
32
+ verbose?: boolean;
33
+ /** Printed by the terminal blocks so a user always has somewhere to look. */
34
+ watchUrl?: string;
35
+ }
36
+ /** How a build ended, plus the process exit code that ending implies. */
37
+ export interface RenderDone {
38
+ exitCode: number;
39
+ outcome: BuildOutcome;
40
+ }
41
+ export interface RenderState {
42
+ /** Artifacts seen so far. Counted, never printed inline. */
43
+ artifactCount: number;
44
+ /** Set once a `terminal` activity has been folded in; the fold stops there. */
45
+ done?: RenderDone;
46
+ /** Time in the current phase, per the last heartbeat. Decorates the status only. */
47
+ elapsedMs?: number;
48
+ /** Highest `seq` folded in, so a replayed prefix cannot double-count. */
49
+ lastSeq: number;
50
+ /** The raw phase value, kept so the next `phase` can flush it as completed. */
51
+ phase?: string;
52
+ /** The status without its elapsed decoration — this is what gets printed. */
53
+ statusText: string;
54
+ }
55
+ export interface RenderResult {
56
+ /** Present only on the activity that ended the build. */
57
+ done?: RenderDone;
58
+ /** Lines to print in either mode. */
59
+ lines: string[];
60
+ /** The state to fold the next activity into. */
61
+ state: RenderState;
62
+ /** The spinner label: {@link RenderState.statusText} plus any elapsed time. */
63
+ status: string;
64
+ /**
65
+ * Whether the status changed in a way worth printing. A heartbeat refreshing
66
+ * the elapsed time does not count — it would otherwise put a line in every
67
+ * piped build every few seconds.
68
+ */
69
+ statusChanged: boolean;
70
+ /** The undecorated status, which is what non-animated mode prints. */
71
+ statusText: string;
72
+ }
73
+ export declare function initialRenderState(): RenderState;
74
+ /** 0 on success; 1 on every other ending. */
75
+ export declare function exitCodeFor(outcome: TerminalOutcome): number;
76
+ /** `1m 05s`, or `42s` under a minute. Only ever decorates a spinner label. */
77
+ export declare function formatElapsed(ms: number): string;
78
+ /**
79
+ * Printed before the stream opens, so a user who never sees another byte still
80
+ * has the link. Degrades to a link-free sentence against a Hatch deployed
81
+ * before `?watch=1` existed, rather than printing `undefined`.
82
+ */
83
+ export declare function openingBlock(watchUrl?: string): string[];
84
+ /**
85
+ * The success block. The URL sits alone on an indented line so it survives a
86
+ * copy-paste and a `| tail -1`.
87
+ *
88
+ * `siteUrl` is promised on success and the client still must not crash without
89
+ * it, so the watch link stands in.
90
+ */
91
+ export declare function successBlock(outcome: BuildOutcome, watchUrl?: string): string[];
92
+ /**
93
+ * The failure block, shared by `failed`, `expired`, and `rejected`. The
94
+ * difference between "the agent could not do it" and "it ran out of time" is on
95
+ * the page the watch link opens; pointing there beats paraphrasing it.
96
+ */
97
+ export declare function failureBlock(outcome: BuildOutcome, watchUrl?: string): string[];
98
+ /** The terminal block for any outcome. */
99
+ export declare function terminalBlock(outcome: BuildOutcome, watchUrl?: string): string[];
100
+ /**
101
+ * What the caller actually prints.
102
+ *
103
+ * Animated: only `lines` — `status` is on the spinner. Not animated: `lines`
104
+ * first, then the changed status as its own line, so a completed phase is
105
+ * reported before the one that replaced it.
106
+ */
107
+ export declare function renderLines(result: RenderResult, options: {
108
+ animate: boolean;
109
+ }): string[];
110
+ /**
111
+ * A dropped connection is otherwise invisible — the elapsed counter is driven
112
+ * by server heartbeats, so during a backoff it simply freezes, which on a
113
+ * fifteen-minute command is indistinguishable from a hang.
114
+ *
115
+ * The attempt number is part of the status so each attempt reads as a change,
116
+ * and so non-animated mode prints once per attempt rather than once per drop.
117
+ */
118
+ export declare function reduceReconnecting(state: RenderState, attempt: number): RenderResult;
119
+ /**
120
+ * The fold. One activity in, the next state plus whatever it should print out.
121
+ *
122
+ * An unrecognized `kind` is ignored rather than thrown on: Hatch may add
123
+ * activity kinds, and an older CLI has to keep working against a newer server.
124
+ * A `seq` at or below the highest already folded in is ignored too — the caller
125
+ * drops replayed events by id, but a duplicate reaching here must not
126
+ * double-count an artifact or re-flush a phase.
127
+ */
128
+ export declare function reduceActivity(state: RenderState, activity: Activity, options?: RenderOptions): RenderResult;
@@ -0,0 +1,183 @@
1
+ /**
2
+ * Turning a Hatch activity stream into what the terminal shows.
3
+ *
4
+ * Everything here is a pure function of `(state, activity)`, so the whole of
5
+ * `xano hatch`'s output can be tested with no socket, no TTY, and no clock.
6
+ *
7
+ * The fold emits two different things and the caller decides which to print:
8
+ *
9
+ * - `lines` — things that belong in the transcript no matter what (a completed
10
+ * phase, the final block, a `--verbose` log line).
11
+ * - `status` — the current "what is happening right now" label.
12
+ *
13
+ * When the command is animating, `status` is the spinner label and only `lines`
14
+ * are printed. When it is not — a non-TTY, `--verbose`, a redirect to a file —
15
+ * there is no spinner to carry the status, so a *changed* status has to be
16
+ * printed as a plain line; otherwise a piped build shows one line per phase and
17
+ * nothing at all for the six minutes in between. {@link renderLines} is the one
18
+ * place that difference lives; the fold itself is identical in both modes.
19
+ */
20
+ import { isKnownActivity, phaseLabel } from './hatch-contract.js';
21
+ /**
22
+ * The status before any activity arrives. Not empty on purpose: the gap between
23
+ * submitting a prompt and the first `phase` event is not short — the agent pool
24
+ * is small and a build behind a full one waits — and an empty terminal in that
25
+ * window reads as a crash.
26
+ */
27
+ export const QUEUED_STATUS = 'Queued';
28
+ /** The one outcome the user can act on immediately, and the only one not about their prompt. */
29
+ export const POOL_FULL_SENTENCE = 'Hatch had no free build slot — try again shortly.';
30
+ /** Fallback prose per outcome, used when the server sent no `message`. */
31
+ const DEFAULT_FAILURE_MESSAGE = {
32
+ expired: 'The build ran out of time before it finished.',
33
+ failed: 'The build did not finish.',
34
+ rejected: 'The build could not be started.',
35
+ };
36
+ export function initialRenderState() {
37
+ return { artifactCount: 0, lastSeq: 0, statusText: QUEUED_STATUS };
38
+ }
39
+ /** 0 on success; 1 on every other ending. */
40
+ export function exitCodeFor(outcome) {
41
+ return outcome === 'succeeded' ? 0 : 1;
42
+ }
43
+ /** `1m 05s`, or `42s` under a minute. Only ever decorates a spinner label. */
44
+ export function formatElapsed(ms) {
45
+ const total = Math.max(0, Math.floor(ms / 1000));
46
+ const minutes = Math.floor(total / 60);
47
+ const seconds = total % 60;
48
+ return minutes > 0 ? `${minutes}m ${String(seconds).padStart(2, '0')}s` : `${seconds}s`;
49
+ }
50
+ /**
51
+ * Printed before the stream opens, so a user who never sees another byte still
52
+ * has the link. Degrades to a link-free sentence against a Hatch deployed
53
+ * before `?watch=1` existed, rather than printing `undefined`.
54
+ */
55
+ export function openingBlock(watchUrl) {
56
+ if (!watchUrl) {
57
+ return ['Your idea is being created. No follow-along link is available, so watch here.'];
58
+ }
59
+ return ['Your idea is being created, follow along for more details:', ` ${watchUrl}`];
60
+ }
61
+ /**
62
+ * The success block. The URL sits alone on an indented line so it survives a
63
+ * copy-paste and a `| tail -1`.
64
+ *
65
+ * `siteUrl` is promised on success and the client still must not crash without
66
+ * it, so the watch link stands in.
67
+ */
68
+ export function successBlock(outcome, watchUrl) {
69
+ const url = outcome.siteUrl ?? watchUrl;
70
+ if (!url)
71
+ return ['Your project is complete.'];
72
+ return ['Your project is complete, you can view it at:', ` ${url}`];
73
+ }
74
+ /**
75
+ * The failure block, shared by `failed`, `expired`, and `rejected`. The
76
+ * difference between "the agent could not do it" and "it ran out of time" is on
77
+ * the page the watch link opens; pointing there beats paraphrasing it.
78
+ */
79
+ export function failureBlock(outcome, watchUrl) {
80
+ const kind = outcome.outcome === 'succeeded' ? 'failed' : outcome.outcome;
81
+ const lines = [outcome.message?.trim() || DEFAULT_FAILURE_MESSAGE[kind]];
82
+ if (outcome.outcome === 'rejected')
83
+ lines.push(POOL_FULL_SENTENCE);
84
+ if (watchUrl)
85
+ lines.push('You can see what happened at:', ` ${watchUrl}`);
86
+ return lines;
87
+ }
88
+ /** The terminal block for any outcome. */
89
+ export function terminalBlock(outcome, watchUrl) {
90
+ return outcome.outcome === 'succeeded' ? successBlock(outcome, watchUrl) : failureBlock(outcome, watchUrl);
91
+ }
92
+ /**
93
+ * What the caller actually prints.
94
+ *
95
+ * Animated: only `lines` — `status` is on the spinner. Not animated: `lines`
96
+ * first, then the changed status as its own line, so a completed phase is
97
+ * reported before the one that replaced it.
98
+ */
99
+ export function renderLines(result, options) {
100
+ if (options.animate || !result.statusChanged)
101
+ return result.lines;
102
+ return [...result.lines, result.statusText];
103
+ }
104
+ /**
105
+ * A dropped connection is otherwise invisible — the elapsed counter is driven
106
+ * by server heartbeats, so during a backoff it simply freezes, which on a
107
+ * fifteen-minute command is indistinguishable from a hang.
108
+ *
109
+ * The attempt number is part of the status so each attempt reads as a change,
110
+ * and so non-animated mode prints once per attempt rather than once per drop.
111
+ */
112
+ export function reduceReconnecting(state, attempt) {
113
+ return build(state, { elapsedMs: undefined, statusText: `Reconnecting… (attempt ${attempt})` });
114
+ }
115
+ /**
116
+ * The fold. One activity in, the next state plus whatever it should print out.
117
+ *
118
+ * An unrecognized `kind` is ignored rather than thrown on: Hatch may add
119
+ * activity kinds, and an older CLI has to keep working against a newer server.
120
+ * A `seq` at or below the highest already folded in is ignored too — the caller
121
+ * drops replayed events by id, but a duplicate reaching here must not
122
+ * double-count an artifact or re-flush a phase.
123
+ */
124
+ export function reduceActivity(state, activity, options = {}) {
125
+ if (typeof activity.seq === 'number' && activity.seq <= state.lastSeq)
126
+ return build(state, {});
127
+ const seen = typeof activity.seq === 'number' ? { lastSeq: activity.seq } : {};
128
+ if (!isKnownActivity(activity))
129
+ return build(state, seen);
130
+ switch (activity.kind) {
131
+ case 'artifact': {
132
+ return build(state, { ...seen, artifactCount: state.artifactCount + 1 });
133
+ }
134
+ case 'heartbeat': {
135
+ // Refreshes the elapsed time in the status and nothing else. Notably it
136
+ // does not advance the phase, even though it carries one.
137
+ return build(state, { ...seen, elapsedMs: activity.elapsedInPhaseMs });
138
+ }
139
+ case 'log': {
140
+ return build(state, seen, options.verbose ? [activity.line] : []);
141
+ }
142
+ case 'narration': {
143
+ const label = state.phase === undefined ? undefined : phaseLabel(state.phase);
144
+ return build(state, {
145
+ ...seen,
146
+ statusText: label ? `${label} — ${activity.message}` : activity.message,
147
+ });
148
+ }
149
+ case 'phase': {
150
+ // The previous phase is only complete once another one starts, so this is
151
+ // where it gets flushed. The first phase has nothing to flush.
152
+ const previous = state.phase;
153
+ return build(state, { ...seen, elapsedMs: undefined, phase: activity.phase, statusText: phaseLabel(activity.phase) }, previous !== undefined && previous !== activity.phase ? [`✓ ${phaseLabel(previous)}`] : []);
154
+ }
155
+ case 'terminal': {
156
+ const outcome = {
157
+ ...(activity.message === undefined ? {} : { message: activity.message }),
158
+ outcome: activity.outcome,
159
+ ...(activity.siteUrl === undefined ? {} : { siteUrl: activity.siteUrl }),
160
+ };
161
+ const done = { exitCode: exitCodeFor(activity.outcome), outcome };
162
+ // The status is left alone: the final block says everything, and moving
163
+ // the status here would put a redundant line in a piped run.
164
+ return build(state, { ...seen, done, elapsedMs: undefined }, terminalBlock(outcome, options.watchUrl));
165
+ }
166
+ default: {
167
+ return build(state, seen);
168
+ }
169
+ }
170
+ }
171
+ /** Assembles a result, deriving `status` and `statusChanged` from the transition. */
172
+ function build(state, changes, lines = []) {
173
+ const next = { ...state, ...changes };
174
+ const statusChanged = next.statusText !== state.statusText;
175
+ return {
176
+ ...(next.done === undefined ? {} : { done: next.done }),
177
+ lines,
178
+ state: next,
179
+ status: next.elapsedMs === undefined ? next.statusText : `${next.statusText} (${formatElapsed(next.elapsedMs)})`,
180
+ statusChanged,
181
+ statusText: next.statusText,
182
+ };
183
+ }
@@ -92,25 +92,29 @@ export declare function collectAllPages<T>(fetchPage: (page: number) => Promise<
92
92
  onTruncate?: (collected: number) => void;
93
93
  }): Promise<T[]>;
94
94
  export interface PagingJson<T> {
95
- count: number;
95
+ curPage?: number;
96
96
  items: T[];
97
- next_page?: number;
98
- page?: number;
99
- per_page?: number;
100
- prev_page?: number;
101
- total?: number;
97
+ itemsTotal?: number;
98
+ nextPage?: number;
99
+ perPage?: number;
100
+ prevPage?: number;
102
101
  }
103
102
  /**
104
103
  * Build the `--output json` payload for a list command.
105
104
  *
105
+ * Field names mirror the Metadata API envelope exactly — `curPage`, `nextPage`,
106
+ * `prevPage`, `itemsTotal`, `items` — so a script can move between the CLI and
107
+ * the raw API without a translation layer. `perPage` is the one field the CLI
108
+ * injects: the API never echoes it back, and it is otherwise invisible when the
109
+ * user leaves the flag at its default.
110
+ *
106
111
  * Emits only fields the response actually proves, mirroring the footer's
107
- * honesty rule: `total` appears only when the server sent `itemsTotal`,
108
- * `next_page` only when the server computed it. `count` is always the number of
109
- * items in this payload and is the one figure that is unconditionally true.
112
+ * honesty rule: `itemsTotal` appears only when the server sent it, `nextPage`
113
+ * only when the server computed it.
110
114
  *
111
- * Without this, a script driving `--output json` sees a bare array and its only
112
- * available stop condition is `length < per_page` — precisely the page-fullness
113
- * inference this module refuses to make in the footer.
115
+ * Without this envelope, a script driving `--output json` sees a bare array and
116
+ * its only available stop condition is `length < perPage` — precisely the
117
+ * page-fullness inference this module refuses to make in the footer.
114
118
  */
115
119
  export declare function buildPagingJson<T>(list: NormalizedList<T>, options: {
116
120
  page?: number;