insta 0.0.80 → 0.0.81

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
@@ -226,6 +226,7 @@ build never reaches a production installer.
226
226
  | `insta db` | `url` (print the postgres DSN) · `connect` (psql session) · `limits` · `stats` · `always-on` · `volume` |
227
227
  | `insta regions` | Regions available for postgres and compute |
228
228
  | `insta manifest` | Agent-legible view of every branch and its URLs |
229
+ | `insta build-logs <id>` | Read source-build output; `--source archive` (default) uses the deploy operation ID, `--source github` uses a GitHub build ID; `--follow` watches output, `--json` returns one snapshot |
229
230
  | `insta metrics` · `logs` · `events` | Service metrics; runtime logs (`--deploy` for deploy events); audit timeline |
230
231
  | `insta usage` · `billing` | Usage by billing dimension; `billing upgrade` · `billing portal` |
231
232
  | `insta approvals` | `list` · `approve` · `deny` |
@@ -0,0 +1,206 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { stripVTControlCharacters } from 'node:util';
3
+ import { ApiError } from './api.js';
4
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
5
+ const terminal = new Set(['succeeded', 'failed', 'canceled', 'unknown', 'live']);
6
+ const entryKey = (step, entry) => createHash('sha256').update(JSON.stringify([step, entry.timestamp, entry.message])).digest('hex');
7
+ class LogsNotReady extends Error {
8
+ }
9
+ export async function readBuildLogs(api, projectId, source, buildId, signal = AbortSignal.timeout(30_000), follow) {
10
+ let bytes = 0;
11
+ let requests = 0;
12
+ let pending = false;
13
+ async function pages(step) {
14
+ const result = [];
15
+ const seen = new Set();
16
+ const tail = step ? follow?.tails.get(step) ?? { counts: new Map() } : undefined;
17
+ let cursor = tail?.cursor;
18
+ do {
19
+ if (++requests > 200)
20
+ throw new Error('build logs exceed the per-read page limit');
21
+ const params = new URLSearchParams({ ...(step ? { step } : {}), ...(cursor ? { cursor } : {}) });
22
+ const { body } = await api.rawRequest('GET', `/projects/${projectId}/builds/${source}/${encodeURIComponent(buildId)}/logs?${params}`, undefined, { signal });
23
+ if (!body || !['ready', 'pending', 'unsupported', 'unavailable'].includes(body.state) || !Array.isArray(body.steps) || !Array.isArray(body.entries))
24
+ throw new Error('invalid build log response');
25
+ bytes += Buffer.byteLength(JSON.stringify(body));
26
+ if (bytes > 16 * 1024 * 1024)
27
+ throw new Error('build logs exceed the 16 MiB per-read limit');
28
+ if (body.state === 'pending')
29
+ pending = true;
30
+ if (step && follow && body.state === 'ready') {
31
+ const counts = new Map(tail.counts);
32
+ const entries = body.entries.map(entry => {
33
+ const key = entryKey(step, entry);
34
+ const occurrence = (counts.get(key) ?? 0) + 1;
35
+ if (counts.size >= 100_000 && !counts.has(key))
36
+ throw new Error('live build logs exceed the display limit');
37
+ counts.set(key, occurrence);
38
+ return { ...entry, occurrence };
39
+ });
40
+ const name = steps.find(item => item.digest === step).name;
41
+ follow.emit({ ...body, output: [{ step, name, entries }] });
42
+ if (body.nextCursor) {
43
+ tail.counts = counts;
44
+ tail.cursor = body.nextCursor;
45
+ }
46
+ follow.tails.set(step, tail);
47
+ result.push({ ...body, entries: [] });
48
+ }
49
+ else
50
+ result.push(body);
51
+ cursor = body.nextCursor || undefined;
52
+ if (cursor && seen.has(cursor))
53
+ throw new Error('build log pagination did not advance');
54
+ if (cursor)
55
+ seen.add(cursor);
56
+ } while (cursor);
57
+ return result;
58
+ }
59
+ const stepPages = await pages();
60
+ const first = stepPages[0];
61
+ if (stepPages.some((page) => page.state !== first.state))
62
+ throw new LogsNotReady('build steps are temporarily unavailable');
63
+ const steps = stepPages.flatMap((page) => page.steps);
64
+ follow?.emit({ ...first, steps, output: [] });
65
+ const output = [];
66
+ for (const step of steps) {
67
+ if (!step.hasLogs)
68
+ continue;
69
+ const logs = await pages(step.digest);
70
+ if (logs.some((page) => page.state !== 'ready' && page.state !== 'pending'))
71
+ throw new LogsNotReady('step output is temporarily unavailable');
72
+ output.push({ step: step.digest, name: step.name, entries: logs.flatMap((page) => page.entries) });
73
+ }
74
+ return { ...first, nextCursor: undefined, state: pending ? 'pending' : first.state, steps, output };
75
+ }
76
+ const safeText = (text) => stripVTControlCharacters(text).replace(/[\x00-\x08\x0b-\x1f\x7f]/g, '');
77
+ export class BuildLogPrinter {
78
+ seen = new Map();
79
+ diagnostics = new Set();
80
+ lastStep;
81
+ lineStart = true;
82
+ finishLine(write) {
83
+ if (!this.lineStart)
84
+ write('\n');
85
+ this.lineStart = true;
86
+ }
87
+ print(snapshot, write) {
88
+ const errors = [
89
+ ...(snapshot.error ? [{ id: 'build', message: snapshot.error }] : []),
90
+ ...snapshot.steps.filter(step => step.error).map(step => ({ id: step.digest, message: `${step.name}: ${step.error}` })),
91
+ ];
92
+ for (const error of errors) {
93
+ const key = JSON.stringify([error.id, error.message]);
94
+ if (this.diagnostics.has(key))
95
+ continue;
96
+ this.finishLine(write);
97
+ write(safeText(error.message) + '\n');
98
+ this.diagnostics.add(key);
99
+ }
100
+ for (const step of snapshot.output) {
101
+ const counts = new Map();
102
+ for (const entry of step.entries) {
103
+ const key = entryKey(step.step, entry);
104
+ const count = entry.occurrence ?? (counts.get(key) ?? 0) + 1;
105
+ counts.set(key, count);
106
+ if (count <= (this.seen.get(key) ?? 0))
107
+ continue;
108
+ if (this.seen.size >= 100_000)
109
+ throw new Error('live build logs exceed the display limit');
110
+ if (this.lastStep !== step.step) {
111
+ this.finishLine(write);
112
+ write(safeText(step.name) + '\n');
113
+ this.lastStep = step.step;
114
+ }
115
+ const text = safeText(entry.message);
116
+ write(text);
117
+ if (text)
118
+ this.lineStart = text.endsWith('\n');
119
+ }
120
+ for (const [key, count] of counts)
121
+ this.seen.set(key, Math.max(count, this.seen.get(key) ?? 0));
122
+ }
123
+ }
124
+ }
125
+ export function archiveLogWatcher(api, projectId, write, wait = sleep) {
126
+ const printer = new BuildLogPrinter();
127
+ const follow = { tails: new Map(), emit: snapshot => printer.print(snapshot, write) };
128
+ let warned = '';
129
+ let unavailable = false;
130
+ return async (buildId, finished, remainingMs = 30_000) => {
131
+ if (unavailable)
132
+ return;
133
+ const deadline = Date.now() + Math.min(remainingMs, finished ? 18_000 : 3000);
134
+ for (let attempt = 0; attempt < (finished ? 6 : 1); attempt++) {
135
+ if (attempt > 0)
136
+ await wait(Math.min(3000, Math.max(0, deadline - Date.now())));
137
+ const remaining = deadline - Date.now();
138
+ if (remaining <= 0)
139
+ return;
140
+ if (finished && (attempt === 0 || attempt === 5))
141
+ follow.tails.clear();
142
+ try {
143
+ const snapshot = await readBuildLogs(api, projectId, 'archive', buildId, AbortSignal.timeout(remaining), follow);
144
+ if (snapshot.state === 'unsupported') {
145
+ printer.finishLine(write);
146
+ write('Build logs are not supported for this build provider.\n');
147
+ unavailable = true;
148
+ return;
149
+ }
150
+ if (snapshot.state === 'unavailable')
151
+ throw new Error('build logs unavailable');
152
+ warned = '';
153
+ }
154
+ catch (error) {
155
+ if (error instanceof ApiError && error.status === 400)
156
+ follow.tails.clear();
157
+ printer.finishLine(write);
158
+ const message = `Could not read build logs. Retry with: insta build-logs ${buildId}\n`;
159
+ if (warned !== message)
160
+ write(message);
161
+ warned = message;
162
+ }
163
+ }
164
+ };
165
+ }
166
+ export async function followBuildLogs(api, projectId, source, buildId, write, wait = sleep) {
167
+ const printer = new BuildLogPrinter();
168
+ const follow = { tails: new Map(), emit: snapshot => printer.print(snapshot, write) };
169
+ let finalReads = 0;
170
+ let failures = 0;
171
+ for (;;) {
172
+ let snapshot;
173
+ try {
174
+ snapshot = await readBuildLogs(api, projectId, source, buildId, AbortSignal.timeout(30_000), follow);
175
+ failures = 0;
176
+ }
177
+ catch (error) {
178
+ const retryable = error instanceof ApiError ? error.status === 400 || error.status === 429 || error.status >= 500
179
+ : error instanceof LogsNotReady || error instanceof TypeError || (error instanceof Error && ['AbortError', 'TimeoutError'].includes(error.name));
180
+ if (error instanceof ApiError && error.status === 400)
181
+ follow.tails.clear();
182
+ if (!retryable || ++failures >= 5) {
183
+ printer.finishLine(write);
184
+ throw error;
185
+ }
186
+ printer.finishLine(write);
187
+ write('Could not refresh build logs; retrying…\n');
188
+ await wait(3000);
189
+ continue;
190
+ }
191
+ if (snapshot.state === 'unsupported' || snapshot.state === 'unavailable') {
192
+ printer.finishLine(write);
193
+ throw new Error(`Build logs ${snapshot.state}`);
194
+ }
195
+ if (snapshot.state === 'ready' && terminal.has(snapshot.buildState)) {
196
+ if (++finalReads >= 6) {
197
+ printer.finishLine(write);
198
+ return;
199
+ }
200
+ if (finalReads === 1 || finalReads === 5)
201
+ follow.tails.clear();
202
+ }
203
+ await wait(3000);
204
+ }
205
+ }
206
+ //# sourceMappingURL=build-logs.js.map
@@ -0,0 +1,23 @@
1
+ import { ApiClient, requireProject } from '../api.js';
2
+ import { BuildLogPrinter, followBuildLogs, readBuildLogs } from '../build-logs.js';
3
+ import { info, printJson } from '../util.js';
4
+ export async function buildLogs(buildId, opts) {
5
+ if (opts.source !== 'github' && opts.source !== 'archive')
6
+ throw new Error('--source must be github or archive');
7
+ if (opts.follow && opts.json)
8
+ throw new Error('--follow cannot be combined with --json');
9
+ const api = await ApiClient.load();
10
+ const { projectId } = await requireProject();
11
+ if (opts.follow)
12
+ return followBuildLogs(api, projectId, opts.source, buildId, (text) => { process.stdout.write(text); });
13
+ const snapshot = await readBuildLogs(api, projectId, opts.source, buildId);
14
+ if (opts.json)
15
+ return printJson(snapshot);
16
+ const printer = new BuildLogPrinter();
17
+ const write = (text) => { process.stdout.write(text); };
18
+ printer.print(snapshot, write);
19
+ printer.finishLine(write);
20
+ if (snapshot.state !== 'ready')
21
+ info(`Build logs ${snapshot.state}`);
22
+ }
23
+ //# sourceMappingURL=build-logs.js.map
@@ -1,3 +1,4 @@
1
+ import { archiveLogWatcher } from '../build-logs.js';
1
2
  import { resolve, join } from 'node:path';
2
3
  import { existsSync, readFileSync } from 'node:fs';
3
4
  import { ApiClient, ApiError, requireProject } from '../api.js';
@@ -74,7 +75,13 @@ export async function prepareSource(api, projectId, dir, branch, opts, run = opt
74
75
  // flyctl, local-docker and legacy all end in an image, and all three need a Dockerfile.
75
76
  return { image: await buildFromSource(api, projectId, dir, branch, opts, run) };
76
77
  }
77
- const log = note(opts);
78
+ const stream = opts.json ? process.stderr : process.stdout;
79
+ let lineStart = true;
80
+ const finishLine = () => { if (!lineStart)
81
+ stream.write('\n'); lineStart = true; };
82
+ const log = (message) => { finishLine(); note(opts)(message); };
83
+ const writeOutput = (text) => { stream.write(text); if (text)
84
+ lineStart = text.endsWith('\n'); };
78
85
  const absDir = resolve(process.cwd(), dir);
79
86
  const caveat = windowsModeCaveat();
80
87
  if (caveat)
@@ -89,7 +96,8 @@ export async function prepareSource(api, projectId, dir, branch, opts, run = opt
89
96
  // time, because a platform request has to answer inside the ALB's 60s while a build runs minutes.
90
97
  // A repo-connected service refuses this with a 409 the same way it refuses an image deploy, and
91
98
  // the hint that names the FLAG rather than the API field lives here, beside the `/deploy` path.
92
- const out = await deployArchive(api, projectId, ref, branch, opts, Date.now, undefined, log)
99
+ const out = await deployArchive(api, projectId, ref, branch, opts, Date.now, undefined, log, archiveLogWatcher(api, projectId, writeOutput))
100
+ .finally(finishLine)
93
101
  .catch((e) => { throw e instanceof ApiError && e.status === 409 ? new ApiError(e.status, repoConnectedHint(e.message), e.body) : e; });
94
102
  if (!out)
95
103
  return null;
@@ -64,7 +64,7 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
64
64
  // The operation is idempotent on (target, archive, build kind), so a re-run after approving finds
65
65
  // the one it already started rather than building again: same image, same result, no second
66
66
  // approval for a build that already happened.
67
- export async function deployArchive(api, projectId, ref, branch, opts, now = Date.now, wait = sleep, log = () => { }) {
67
+ export async function deployArchive(api, projectId, ref, branch, opts, now = Date.now, wait = sleep, log = () => { }, watchLogs) {
68
68
  const started = await api.rawRequest('POST', `/projects/${projectId}/archive-deploys`, {
69
69
  branch,
70
70
  group: opts.group,
@@ -79,6 +79,7 @@ export async function deployArchive(api, projectId, ref, branch, opts, now = Dat
79
79
  const operationId = started.body?.operationId;
80
80
  if (typeof operationId !== 'string' || !operationId)
81
81
  throw new Error('the platform accepted the deploy but returned no operation id — re-run the deploy');
82
+ log(`build logs: insta build-logs ${operationId}`);
82
83
  if (started.body?.resumed === true)
83
84
  log('resuming the deploy this archive already started');
84
85
  const deadline = now() + DEPLOY_DEADLINE_MS;
@@ -99,6 +100,7 @@ export async function deployArchive(api, projectId, ref, branch, opts, now = Dat
99
100
  throw remaining <= POLL_REQUEST_TIMEOUT_MS ? overdue() : new Error(`the platform did not answer a status poll within ${POLL_REQUEST_TIMEOUT_MS / 1000}s — check \`insta status\` or re-run`);
100
101
  });
101
102
  const state = res.body?.state;
103
+ await watchLogs?.(operationId, state === 'failed' || state === 'live', Math.max(0, deadline - now()));
102
104
  // A failed operation is an ANSWER, not a transport error: the poll worked, and the sentence
103
105
  // it carries (usually the gateway's own, e.g. "no Dockerfile at ./api") is the one to show.
104
106
  if (state === 'failed') {
package/dist/index.js CHANGED
@@ -21,6 +21,7 @@ import * as regions from './commands/regions.js';
21
21
  import * as secretsCmd from './commands/secrets.js';
22
22
  import { deploy } from './commands/deploy.js';
23
23
  import { build } from './commands/build.js';
24
+ import { buildLogs } from './commands/build-logs.js';
24
25
  import * as computeCmd from './commands/compute.js';
25
26
  import * as githubCmd from './commands/github.js';
26
27
  import * as dbCmd from './commands/db.js';
@@ -347,6 +348,9 @@ program.command('manifest').description('Print an agent-legible view of the proj
347
348
  // ---- regions ----
348
349
  program.command('regions').description('List regions available for postgres/compute services').option('--json').action(guard((o) => regions.regionsList(o)));
349
350
  // ---- observability ----
351
+ program.command('build-logs <build-id>').description('Read source-build output for a deploy operation or GitHub build')
352
+ .option('--source <source>', 'archive or github', 'archive').option('--follow', 'poll new output until the build ends').option('--json')
353
+ .action(guard((id, opts) => buildLogs(id, opts)));
350
354
  program.command('metrics <target> [group]').description('Service metrics (target: db|compute|redis|mysql|mongodb)')
351
355
  .option('--branch <b>').option('--from <unix>').option('--to <unix>').option('--step <s>').option('--json')
352
356
  .action(guard((target, group, o) => obs.metrics(target, group, o)));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "insta",
3
- "version": "0.0.80",
3
+ "version": "0.0.81",
4
4
  "type": "module",
5
5
  "description": "InstaCloud CLI — a thin client of the platform control-plane API.",
6
6
  "keywords": [