@tgcloud/cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +53 -0
  3. package/bin/tgcloud.js +2 -0
  4. package/package.json +36 -0
  5. package/src/api/client.js +136 -0
  6. package/src/api/endpoints.js +124 -0
  7. package/src/cli.js +51 -0
  8. package/src/commands/add.js +133 -0
  9. package/src/commands/completion.js +298 -0
  10. package/src/commands/deploy.js +319 -0
  11. package/src/commands/diff.js +57 -0
  12. package/src/commands/fetch.js +23 -0
  13. package/src/commands/init.js +226 -0
  14. package/src/commands/login.js +56 -0
  15. package/src/commands/migrate.js +142 -0
  16. package/src/commands/pull.js +156 -0
  17. package/src/commands/reset.js +95 -0
  18. package/src/commands/run.js +145 -0
  19. package/src/commands/status.js +72 -0
  20. package/src/commands/webhook.js +139 -0
  21. package/src/core/capabilities.js +83 -0
  22. package/src/core/credentials.js +229 -0
  23. package/src/core/db-changes.js +221 -0
  24. package/src/core/file-diff.js +11 -0
  25. package/src/core/hasher.js +11 -0
  26. package/src/core/local-diff.js +57 -0
  27. package/src/core/migration-flow.js +356 -0
  28. package/src/core/project-layout.js +294 -0
  29. package/src/core/project-root.js +47 -0
  30. package/src/core/run-output.js +134 -0
  31. package/src/core/scanner.js +115 -0
  32. package/src/core/snapshot.js +132 -0
  33. package/src/core/state.js +92 -0
  34. package/src/core/sync.js +50 -0
  35. package/src/templates/AGENTS.md +95 -0
  36. package/src/templates/docs/tgcloud-sdk.md +347 -0
  37. package/src/templates/gitignore +2 -0
  38. package/src/templates/handlers/_default_.js +6 -0
  39. package/src/templates/handlers/message.js +13 -0
  40. package/src/templates/lib/_default_.js +2 -0
  41. package/src/templates/schema.js +10 -0
  42. package/src/utils/logger.js +54 -0
  43. package/src/utils/pager.js +76 -0
  44. package/src/utils/prompt.js +220 -0
  45. package/src/utils/spinner.js +5 -0
@@ -0,0 +1,347 @@
1
+ # tgcloud SDK reference
2
+
3
+ Everything your bot's modules can import from `sdk`: the database (`db`), the
4
+ Telegram Bot API (`api`), HTTP (`fetch`), and `console` logging. The database is
5
+ the largest surface, so it comes first; `api`, `fetch`, and `console` are at the
6
+ end.
7
+
8
+ > Orientation and project rules live in [../AGENTS.md](../AGENTS.md). This file is
9
+ > the API reference.
10
+
11
+ ## `sdk` at a glance
12
+
13
+ ```js
14
+ import { db, api, fetch, BotApiError } from 'sdk'
15
+ // or from submodules:
16
+ import { table, integer, text, eq, sql } from 'sdk/db'
17
+ import { api } from 'sdk/api'
18
+ import { fetch } from 'sdk/fetch'
19
+ ```
20
+
21
+ - **`db`** — SQLite query builder + schema DSL. → most of this file.
22
+ - **`api`** — Telegram Bot API (`api.sendMessage(...)`). → *Telegram Bot API* below.
23
+ - **`fetch`** — outbound HTTP. → *HTTP* below.
24
+ - **`console`** — logging that surfaces in `npx tgcloud run` output. → *Logging* below.
25
+
26
+ ## Rules that bite
27
+
28
+ The runtime is not stock Node — these defaults don't hold. Detail is in the
29
+ sections below; project rules are in [../AGENTS.md](../AGENTS.md).
30
+
31
+ - **Import by bare module name** — `from 'schema'`, `from 'lib/cart'`, `from 'sdk/db'`. Never a relative path or a `.js` extension.
32
+ - **Every DB call is async — always `await`.** `.all()`/`.get()`/`.values()`/`.run()`, `db.$count()`, and raw `db.run/all/get` all return Promises. A forgotten `await` returns the builder, not rows.
33
+ - **No foreign keys.** `.references()` and table-level `foreignKey()` **throw when declared** — a schema using them won't deploy. Enforce integrity in app code.
34
+ - **Drops happen only via `.deprecated('reason')`** on a column/table/index. Deleting the declaration does *not* drop it; type changes are manual (`db.run(...)`).
35
+ - **A handler's `export default (input, ctx)`** gets the update's *payload* as `input` — `handlers/message` receives the `Message` (i.e. `update.message`), `handlers/callback_query` the `CallbackQuery`. The full `Update` (with `update_id`) is `ctx.update`.
36
+
37
+ # Database (`db`)
38
+
39
+ ## Importing
40
+
41
+ `db` (the default export of `sdk/db`) holds the whole API; the same functions are
42
+ also named exports.
43
+
44
+ ```js
45
+ import { table, integer, text, eq, sql } from 'sdk/db' // named imports
46
+ import db, { sql } from 'sdk/db' // or namespace + names
47
+ ```
48
+
49
+ Import your own modules by **bare name** — `from 'schema'`, `from 'lib/cart'` —
50
+ never a relative path or `.js` extension.
51
+
52
+ ## Defining the schema (`schema.js`)
53
+
54
+ Tables are **named exports**. `table()` builds a descriptor at runtime (no DB call);
55
+ the platform discovers the exported tables and runs migrations when you deploy
56
+ `schema.js`.
57
+
58
+ ```js
59
+ import { table, integer, text, boolean, json, index, check, sql } from 'sdk/db'
60
+
61
+ export const users = table('users', {
62
+ id: integer('id').primaryKey({ autoIncrement: true }),
63
+ tgId: integer('tg_id').unique(),
64
+ name: text('name').notNull(),
65
+ lang: text('lang').default('en'),
66
+ isAdmin: boolean('is_admin').default(false),
67
+ prefs: json('prefs'),
68
+ created: integer('created_at', { mode: 'timestamp' }).default(sql`(unixepoch())`),
69
+ }, (t) => ({
70
+ createdIdx: index('idx_users_created').on(t.created),
71
+ }))
72
+
73
+ export const todos = table('todos', {
74
+ id: integer('id').primaryKey({ autoIncrement: true }),
75
+ userId: integer('user_id').notNull(), // logical link to users.id — FKs are NOT enforced
76
+ text: text('text').notNull(),
77
+ done: boolean('done').default(false),
78
+ priority: integer('priority').default(0),
79
+ }, (t) => ({
80
+ userDoneIdx: index('idx_todos_user_done').on(t.userId, t.done),
81
+ priorityCheck: check('priority_check', sql`${t.priority} >= 0`),
82
+ }))
83
+ ```
84
+
85
+ The third argument to `table()` is a callback `(t) => ({...})` where `t` exposes the
86
+ columns (`t.userId` → a column ref); declare indexes and table-level constraints there.
87
+
88
+ ## Column types
89
+
90
+ | factory | SQLite | notes |
91
+ |-------------|--------|------------------------------------------------|
92
+ | `text()` | TEXT | |
93
+ | `integer()` | INTEGER| |
94
+ | `real()` | REAL | alias `float()` |
95
+ | `numeric()` | NUMERIC| |
96
+ | `blob()` | BLOB | takes/returns `Uint8Array` |
97
+ | `boolean()` | INTEGER| stored 0/1, read as `true`/`false` |
98
+ | `json()` | TEXT | auto `JSON.stringify` / `JSON.parse` |
99
+
100
+ Signature: `integer(name?, opts?)`. The name is optional — if omitted it's taken
101
+ from the JS key. `opts.mode` sets runtime conversion:
102
+
103
+ | mode | stored as | JS value |
104
+ |----------------|--------------------|-----------------------|
105
+ | `boolean` | INTEGER 0/1 | `boolean` |
106
+ | `json` | TEXT (JSON) | any object/array |
107
+ | `timestamp` | INTEGER (unix sec) | `Date` |
108
+ | `timestamp_ms` | INTEGER (unix ms) | `Date` |
109
+ | `bytes` | BLOB | `Uint8Array` |
110
+
111
+ `boolean()` and `json()` are sugar over `integer(name, { mode: 'boolean' })` and
112
+ `text(name, { mode: 'json' })`. A `blob()` column takes and returns a `Uint8Array`
113
+ automatically — that's handled at the wire level, so it needs no mode.
114
+
115
+ ## Column modifiers (chainable)
116
+
117
+ ```js
118
+ integer('id').primaryKey()
119
+ integer('id').primaryKey({ autoIncrement: true })
120
+ text('name').notNull()
121
+ text('tg').unique()
122
+ text('lang').default('en')
123
+ integer('created_at', { mode: 'timestamp' }).default(sql`(unixepoch())`)
124
+ text('slug').generatedAlwaysAs(sql`lower(name)`, { mode: 'stored' }) // 'virtual' | 'stored'
125
+ text('name').constraint('COLLATE NOCASE') // arbitrary column-level DDL
126
+ text('email').deprecated('replaced by login') // marks for drop; invisible at runtime
127
+ ```
128
+
129
+ - `.default()` on a `json()` column encodes the value automatically; a `` sql`...` ``
130
+ default is passed through verbatim.
131
+ - `.deprecated()` is terminal — don't chain methods after it.
132
+
133
+ ## No foreign keys
134
+
135
+ The runtime runs with `PRAGMA foreign_keys` **off**, so a declared FK would be
136
+ silently inert (no cascades, no orphan protection). To make that impossible,
137
+ `.references()` and table-level `foreignKey()` **throw when declared** — a schema
138
+ using them won't deploy. `REFERENCES`/`FOREIGN KEY` smuggled in via `.constraint()`
139
+ or a raw `db.run('CREATE TABLE …')` stay inert too. Enforce integrity in app code:
140
+ delete children before parents, insert parents before children, sweep orphans with
141
+ `LEFT JOIN … WHERE parent.id IS NULL`.
142
+
143
+ ## Table-level constraints & indexes (in the callback)
144
+
145
+ ```js
146
+ table('t', { ... }, (t) => ({
147
+ pk: primaryKey({ columns: [t.a, t.b] }),
148
+ uq: unique('uq_email').on(t.email),
149
+ chk: check('chk_done', sql`${t.done} in (0, 1)`),
150
+ idx: index('idx_name').on(t.col),
151
+ uidx: uniqueIndex('uidx_email').on(t.email),
152
+ lower: index('idx_lower').on(sql`lower(${t.email})`), // expression index
153
+ active:index('idx_active').on(t.userId).where(sql`done = 0`), // partial index
154
+ // foreignKey(...) is NOT supported — it throws (see "No foreign keys")
155
+ }))
156
+ ```
157
+
158
+ Table modifiers (chained after `table(...)`): `.strict()`, `.withoutRowid()`,
159
+ `.constraint('CHECK (x > 0)', 'chk_x')`, `.deprecated('reason')`.
160
+
161
+ ## Query builder
162
+
163
+ ### select
164
+
165
+ `select(projection?)` → `.from(table)` → builder. No projection = `SELECT *`.
166
+
167
+ ```js
168
+ await db.select().from(todos).all() // all rows
169
+ await db.select().from(todos).where(eq(todos.id, 1)).get() // first row or null
170
+ await db.$count(todos) // COUNT(*)
171
+
172
+ await db.select().from(todos)
173
+ .where(and(eq(todos.userId, uid), eq(todos.done, false)))
174
+ .orderBy(desc(todos.priority), asc(todos.id))
175
+ .limit(10).offset(20)
176
+ .all()
177
+
178
+ // projection: { alias: colRef | sqlExpr }
179
+ await db.select({ id: todos.id, title: todos.text, n: sql`count(*)` })
180
+ .from(todos).groupBy(todos.userId).having(sql`count(*) > ${1}`).all()
181
+ ```
182
+
183
+ Chain: `.where()` `.orderBy()` `.limit()` `.offset()` `.groupBy()` `.having()`
184
+ `.distinct()`; terminals `.all()` / `.get()` / `.values()`.
185
+
186
+ The builder is **awaitable** — `await db.select().from(todos)` (with `.where(…)`
187
+ etc. as needed) runs `.all()` and resolves to the row array, so `.all()` is
188
+ optional. Use `.get()`/`.values()` for the other shapes; count rows with
189
+ `db.$count(table, where?)`.
190
+
191
+ ### insert / update / delete
192
+
193
+ ```js
194
+ await db.insert(todos).values({ userId: 1, text: 'Buy milk' }).run()
195
+ await db.insert(todos).values([{ text: 'A' }, { text: 'B' }]).run() // batch
196
+ await db.insert(todos).values({ text: 'X' }).returning().run() // RETURNING *
197
+
198
+ await db.insert(users).values({ tgId: 42, name: 'Ann' })
199
+ .onConflictDoNothing({ target: users.tgId }).run()
200
+ await db.insert(users).values({ tgId: 42, name: 'Ann' })
201
+ .onConflictDoUpdate({ target: users.tgId, set: { name: 'Ann' } }).run()
202
+
203
+ await db.update(todos).set({ done: true }).where(eq(todos.id, 1)).run() // .set() required
204
+ await db.delete(todos).where(eq(todos.id, 1)).run()
205
+ ```
206
+
207
+ A plain insert/update/delete resolves to `[]` — there's no insert id or row count.
208
+ Add `.returning()` (→ `RETURNING *`) or `.returning({ id: todos.id })` to get the
209
+ affected rows back (converted, since they're bound to the table). Like select,
210
+ these builders are **awaitable**: `await db.insert(todos).values({ … })` runs
211
+ without an explicit `.run()`.
212
+
213
+ ### Raw SQL — `db.run` / `db.all` / `db.get`
214
+
215
+ Mode is by method, not auto-detected: `run` = write/exec, `all` = all rows,
216
+ `get`/`one` = first row (or `null`). Each takes a `` sql`…` `` object or a
217
+ `(queryString, params)` pair.
218
+
219
+ ```js
220
+ await db.run('UPDATE todos SET done = 1 WHERE id = :id', { ':id': 5 })
221
+ await db.all(sql`SELECT * FROM todos WHERE done = ${false}`)
222
+ await db.get(sql`SELECT count(*) AS c FROM todos`)
223
+ ```
224
+
225
+ > Raw methods are **not bound to a table**, so rows come back without mode
226
+ > conversion (boolean/json/timestamp arrive as 0/1, a JSON string, unix seconds).
227
+ > Only the table-bound builder converts.
228
+
229
+ `db.raw.read(sql, params)` / `db.raw.write(sql, params)` are the low-level escape
230
+ hatch (params default `{}`); prefer `run`/`all`/`get` above.
231
+
232
+ ## `sql` tagged template
233
+
234
+ `` sql`...` `` interpolates values as named parameters, column refs as identifiers,
235
+ and splices nested `sql`.
236
+
237
+ ```js
238
+ sql`count = ${n}` // count = :p1
239
+ sql`${todos.priority} > ${min}` // priority > :p2 (column as identifier)
240
+ sql`WHERE ${cond}` // nested sql spliced in
241
+ sql.raw('datetime("now")') // literal, no parameters
242
+ ```
243
+
244
+ In DDL contexts (DEFAULT / CHECK / GENERATED) parameters don't work — use literal SQL.
245
+
246
+ ## Operators
247
+
248
+ Import from `sdk/db`:
249
+
250
+ ```js
251
+ import {
252
+ eq, ne, gt, gte, lt, lte,
253
+ like, notLike,
254
+ isNull, isNotNull, and, or, not,
255
+ between, notBetween, inArray, notInArray,
256
+ count, sum, avg, min, max,
257
+ asc, desc,
258
+ } from 'sdk/db'
259
+ ```
260
+
261
+ `.where(e1, e2)` with multiple args is equivalent to `and(e1, e2)`.
262
+
263
+ ## Migrations (summary)
264
+
265
+ - **Adding** columns/tables/indexes — applied automatically on `npx tgcloud migrate`
266
+ after you deploy `schema.js`.
267
+ - **Dropping** — only via `.deprecated()`; the CLI shows what will be removed.
268
+ - **Changing a column's type** — not automatic; do it manually with `db.run`.
269
+
270
+ `npx tgcloud push` reports pending DB changes but never applies them. Run
271
+ `npx tgcloud migrate` to apply.
272
+
273
+ # Telegram Bot API (`api`)
274
+
275
+ `import { api, BotApiError } from 'sdk'`. Call any Bot API method as
276
+ `api.<method>(params)` — a Proxy dispatches the name, so every current (and
277
+ future) method works with no SDK update.
278
+
279
+ ```js
280
+ const me = await api.getMe() // → the unwrapped `result`
281
+ await api.sendMessage({ chat_id: id, text: 'Hello!' })
282
+ await api.editMessageText({ chat_id, message_id, text: 'Updated' })
283
+ await api.answerCallbackQuery({ callback_query_id, text: 'Done' })
284
+ ```
285
+
286
+ - **The envelope is unwrapped.** On `{ ok: true }` the call resolves to `result`
287
+ directly — no `.ok`/`.result` wrapper. Params are one object using the Bot API's
288
+ snake_case names (`chat_id`, `message_id`, …).
289
+ - **Failures throw `BotApiError`.** On `{ ok: false }` it throws; the error carries
290
+ `.code` (Bot API `error_code` — 400/403/429/…), `.description`, `.method`, and
291
+ `.parameters` (e.g. `retry_after` on 429, `migrate_to_chat_id`). Catch and inspect
292
+ `.code` to handle an expected failure:
293
+
294
+ ```js
295
+ try {
296
+ await api.deleteMessage({ chat_id, message_id });
297
+ } catch (e) {
298
+ if (e.code !== 400) throw e; // 400 = already gone; anything else is a real error
299
+ }
300
+ ```
301
+
302
+ # HTTP (`fetch`)
303
+
304
+ `import { fetch } from 'sdk'`. A `fetch`-like client for outbound HTTP.
305
+
306
+ ```js
307
+ const res = await fetch('https://api.example.com/users', {
308
+ method: 'POST',
309
+ headers: { 'Content-Type': 'application/json' },
310
+ body: JSON.stringify({ name: 'Alice' }),
311
+ });
312
+ if (!res.ok) throw new Error(res.statusText);
313
+ const data = await res.json();
314
+ ```
315
+
316
+ The response: `res.status`, `res.statusText`, `res.ok` (true for 200–299),
317
+ `res.url`, `res.headers` (`.get()` / `.has()` / `.keys()` / `.entries()`), and body
318
+ readers `await res.json()` / `await res.text()`. Or stream:
319
+ `for await (const chunk of res.body)`.
320
+
321
+ Body helpers set the matching `Content-Type` for you:
322
+
323
+ ```js
324
+ await fetch(url, { method: 'POST', body: fetch.body.json({ a: 1 }) }); // application/json
325
+ await fetch(url, { method: 'POST', body: fetch.body.form({ a: 1 }) }); // x-www-form-urlencoded
326
+ await fetch(url, { method: 'POST', body: fetch.body.text('hi') }); // text/plain
327
+ ```
328
+
329
+ Notes: a body can be read **once** — a second `.json()`/`.text()`/stream throws
330
+ `TypeError: body used already` (check `res.bodyUsed`). Redirects are followed
331
+ automatically (web-parity) — `res.url` is the final URL after any hops. A 404 (or
332
+ any HTTP status) resolves normally with `res.ok === false`; only real network
333
+ errors (bad host, invalid URL) reject.
334
+
335
+ # Logging (`console`)
336
+
337
+ Plain `console` works, and its output shows up in `npx tgcloud run`:
338
+
339
+ ```js
340
+ console.log('processing', { chatId: id }); // log / debug — plain
341
+ console.info('started'); // info — blue
342
+ console.warn('rate limited'); // warn — yellow
343
+ console.error(err); // error — red, includes a stack
344
+ ```
345
+
346
+ Each line is tagged with its `[file:line]`. `console.error` and `console.trace`
347
+ append a full stack trace; `console.warn` does not.
@@ -0,0 +1,2 @@
1
+ .tgcloud/
2
+ node_modules/
@@ -0,0 +1,6 @@
1
+ // handlers/$$BASENAME$$ — runs on each `$$BASENAME$$` update.
2
+ import { api } from 'sdk';
3
+
4
+ export default async function ($$BASENAME$$, ctx) {
5
+ // your logic here
6
+ }
@@ -0,0 +1,13 @@
1
+ // handlers/message.js — runs on each `message` update. The file name is the
2
+ // Telegram update type; the platform calls the default export with the matching
3
+ // payload (here, a Message). The full update is available as `ctx.update`.
4
+
5
+ import { api } from 'sdk';
6
+
7
+ export default async function (message, ctx) {
8
+ // Echo the text back — replace with your own logic.
9
+ await api.sendMessage({
10
+ chat_id: message.chat.id,
11
+ text: `You said: ${message.text ?? '(no text)'}`,
12
+ });
13
+ }
@@ -0,0 +1,2 @@
1
+ // $$MODULE$$ — shared module. Export functions or values below, then import
2
+ // them elsewhere by module name: import { thing } from '$$MODULE$$';
@@ -0,0 +1,10 @@
1
+ import { table, integer, text } from 'sdk/db';
2
+
3
+ // Your database tables go here as named exports. `npx tgcloud push` deploys this
4
+ // file; `npx tgcloud migrate` applies the changes to the database.
5
+
6
+ // Uncomment to define your first table:
7
+ // export const users = table('users', {
8
+ // id: integer('id').primaryKey({ autoIncrement: true }),
9
+ // name: text('name').notNull(),
10
+ // });
@@ -0,0 +1,54 @@
1
+ import chalk from 'chalk';
2
+
3
+ export function success(msg) {
4
+ console.log(chalk.green(' ✓ ') + msg);
5
+ }
6
+
7
+ // Non-fatal error line. Convention: only the "Error:" word is colored, the
8
+ // message stays default — matches fatal(). Glyphs (✓/✗) are reserved for
9
+ // formatted lists (created files, deploy status), not message-level output.
10
+ export function error(msg) {
11
+ // Errors go to stderr (like fatal()), so a script capturing stdout doesn't mix
12
+ // error text into normal output.
13
+ console.error(chalk.red('Error: ') + msg);
14
+ }
15
+
16
+ export function warn(msg) {
17
+ console.log(chalk.yellow('Warning: ') + msg);
18
+ }
19
+
20
+ export function info(msg) {
21
+ console.log(msg);
22
+ }
23
+
24
+ export function modified(file) {
25
+ console.log(chalk.yellow(' ↑ ') + file + chalk.dim(' — modified'));
26
+ }
27
+
28
+ export function newFile(file) {
29
+ console.log(chalk.green(' + ') + file + chalk.dim(' — new'));
30
+ }
31
+
32
+ export function unchanged(file) {
33
+ console.log(chalk.green(' ✓ ') + file + chalk.dim(' — unchanged'));
34
+ }
35
+
36
+ export function deleted(file) {
37
+ console.log(chalk.red(' - ') + file + chalk.dim(' — deleted'));
38
+ }
39
+
40
+ export function skipped(file, reason) {
41
+ console.log(chalk.red(' ✗ ') + file + chalk.dim(` — ${reason}`));
42
+ }
43
+
44
+ // A completed per-file action: green ✓, the path, then a dim "— status" suffix.
45
+ // Pairs with skipped() (red ✗). Use for deploy/pull/reset result lines so the
46
+ // status reads as dim metadata, consistent with modified()/newFile()/deleted().
47
+ export function done(file, status) {
48
+ console.log(chalk.green(' ✓ ') + file + (status ? chalk.dim(` — ${status}`) : ''));
49
+ }
50
+
51
+ export function fatal(msg) {
52
+ console.error(chalk.red('Error: ') + msg);
53
+ process.exit(1);
54
+ }
@@ -0,0 +1,76 @@
1
+ import { spawn } from 'child_process';
2
+
3
+ /**
4
+ * Launch a pager (like git does) if:
5
+ * - stdout is a TTY,
6
+ * - not explicitly disabled (via options.disabled),
7
+ * - PAGER env var is not empty / not set to something trivial.
8
+ *
9
+ * Returns an object with `write(str)` and async `close()`.
10
+ * Fallback to plain stdout if the pager can't be launched.
11
+ *
12
+ * Usage:
13
+ * const pager = createPager({ disabled: options.pager === false });
14
+ * pager.write('line\n');
15
+ * await pager.close();
16
+ */
17
+ export function createPager({ disabled = false } = {}) {
18
+ // Fallback: write directly to stdout, no pager involvement.
19
+ const stdoutFallback = {
20
+ write: (s) => process.stdout.write(s),
21
+ close: async () => {},
22
+ };
23
+
24
+ if (disabled) return stdoutFallback;
25
+ if (process.env.TGCLOUD_NO_PAGER === '1') return stdoutFallback;
26
+ if (!process.stdout.isTTY) return stdoutFallback;
27
+
28
+ // Resolve pager command.
29
+ // Convention: empty/unset PAGER → default to `less`. "cat" disables paging.
30
+ const raw = process.env.PAGER;
31
+ if (raw === '' || raw === 'cat') return stdoutFallback;
32
+
33
+ const cmd = raw && raw.trim() ? raw : 'less';
34
+
35
+ // Split into argv if PAGER had args; otherwise add sensible defaults for less.
36
+ let argv;
37
+ if (raw && raw.includes(' ')) {
38
+ const parts = raw.split(/\s+/).filter(Boolean);
39
+ argv = parts;
40
+ } else if (cmd === 'less') {
41
+ // -R: keep ANSI colors, -F: quit-if-one-screen, -X: no alt-screen clear
42
+ argv = ['less', '-R', '-F', '-X'];
43
+ } else {
44
+ argv = [cmd];
45
+ }
46
+
47
+ let child;
48
+ try {
49
+ child = spawn(argv[0], argv.slice(1), { stdio: ['pipe', 'inherit', 'inherit'] });
50
+ } catch {
51
+ return stdoutFallback;
52
+ }
53
+
54
+ // If user quits the pager with `q` while we're still writing, stdin closes
55
+ // and further writes raise EPIPE. Swallow those — expected behavior.
56
+ let closed = false;
57
+ child.stdin.on('error', (err) => {
58
+ if (err?.code === 'EPIPE') { closed = true; return; }
59
+ // Other errors — leave to default handler.
60
+ });
61
+ child.on('exit', () => { closed = true; });
62
+ child.on('error', () => { closed = true; });
63
+
64
+ return {
65
+ write: (s) => {
66
+ if (closed) return;
67
+ try { child.stdin.write(s); } catch { /* EPIPE */ }
68
+ },
69
+ close: () => new Promise((resolve) => {
70
+ if (closed) { resolve(); return; }
71
+ try { child.stdin.end(); } catch {}
72
+ if (child.exitCode != null) { resolve(); return; }
73
+ child.once('exit', resolve);
74
+ }),
75
+ };
76
+ }