insta 0.0.81 → 0.0.83

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.
@@ -6,6 +6,11 @@ const terminal = new Set(['succeeded', 'failed', 'canceled', 'unknown', 'live'])
6
6
  const entryKey = (step, entry) => createHash('sha256').update(JSON.stringify([step, entry.timestamp, entry.message])).digest('hex');
7
7
  class LogsNotReady extends Error {
8
8
  }
9
+ // Compute emits UTC RFC3339Nano; Date.parse discards submillisecond ordering.
10
+ function completionKey(value) {
11
+ const [seconds, fraction = ''] = value.slice(0, -1).split('.');
12
+ return `${seconds}.${fraction.padEnd(9, '0')}`;
13
+ }
9
14
  export async function readBuildLogs(api, projectId, source, buildId, signal = AbortSignal.timeout(30_000), follow) {
10
15
  let bytes = 0;
11
16
  let requests = 0;
@@ -19,7 +24,20 @@ export async function readBuildLogs(api, projectId, source, buildId, signal = Ab
19
24
  if (++requests > 200)
20
25
  throw new Error('build logs exceed the per-read page limit');
21
26
  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 });
27
+ signal.throwIfAborted();
28
+ const request = new AbortController();
29
+ const abort = () => request.abort(signal.reason);
30
+ signal.addEventListener('abort', abort, { once: true });
31
+ const timeout = setTimeout(() => request.abort(new DOMException('build log request timed out', 'TimeoutError')), 20_000);
32
+ let body;
33
+ try {
34
+ const response = await api.rawRequest('GET', `/projects/${projectId}/builds/${source}/${encodeURIComponent(buildId)}/logs?${params}`, undefined, { signal: request.signal });
35
+ body = response.body;
36
+ }
37
+ finally {
38
+ clearTimeout(timeout);
39
+ signal.removeEventListener('abort', abort);
40
+ }
23
41
  if (!body || !['ready', 'pending', 'unsupported', 'unavailable'].includes(body.state) || !Array.isArray(body.steps) || !Array.isArray(body.entries))
24
42
  throw new Error('invalid build log response');
25
43
  bytes += Buffer.byteLength(JSON.stringify(body));
@@ -60,7 +78,18 @@ export async function readBuildLogs(api, projectId, source, buildId, signal = Ab
60
78
  const first = stepPages[0];
61
79
  if (stepPages.some((page) => page.state !== first.state))
62
80
  throw new LogsNotReady('build steps are temporarily unavailable');
63
- const steps = stepPages.flatMap((page) => page.steps);
81
+ const byDigest = new Map();
82
+ for (const step of stepPages.flatMap((page) => page.steps)) {
83
+ const previous = byDigest.get(step.digest);
84
+ if (!previous) {
85
+ byDigest.set(step.digest, step);
86
+ continue;
87
+ }
88
+ const [older, newer] = previous.completedAt && (!step.completedAt || completionKey(previous.completedAt) > completionKey(step.completedAt))
89
+ ? [step, previous] : [previous, step];
90
+ byDigest.set(step.digest, { ...older, ...newer, hasLogs: older.hasLogs || newer.hasLogs, error: newer.error });
91
+ }
92
+ const steps = [...byDigest.values()];
64
93
  follow?.emit({ ...first, steps, output: [] });
65
94
  const output = [];
66
95
  for (const step of steps) {
@@ -127,20 +156,24 @@ export function archiveLogWatcher(api, projectId, write, wait = sleep) {
127
156
  const follow = { tails: new Map(), emit: snapshot => printer.print(snapshot, write) };
128
157
  let warned = '';
129
158
  let unavailable = false;
130
- return async (buildId, finished, remainingMs = 30_000) => {
159
+ return async (buildId, finished, remainingMs = 30_000, cancelSignal) => {
131
160
  if (unavailable)
132
161
  return;
133
- const deadline = Date.now() + Math.min(remainingMs, finished ? 18_000 : 3000);
162
+ const started = Date.now();
163
+ const deadline = started + remainingMs;
164
+ const refreshDeadline = started + Math.min(remainingMs, finished ? 18_000 : remainingMs);
134
165
  for (let attempt = 0; attempt < (finished ? 6 : 1); attempt++) {
135
166
  if (attempt > 0)
136
- await wait(Math.min(3000, Math.max(0, deadline - Date.now())));
137
- const remaining = deadline - Date.now();
167
+ await wait(Math.min(3000, Math.max(0, refreshDeadline - Date.now())));
168
+ const finalRead = finished && (attempt === 5 || Date.now() >= refreshDeadline);
169
+ const remaining = Math.min(deadline - Date.now(), finalRead ? 30_000 : refreshDeadline - Date.now());
138
170
  if (remaining <= 0)
139
171
  return;
140
- if (finished && (attempt === 0 || attempt === 5))
172
+ if (finished && (attempt === 0 || finalRead))
141
173
  follow.tails.clear();
174
+ const signal = cancelSignal ?? AbortSignal.timeout(remaining);
142
175
  try {
143
- const snapshot = await readBuildLogs(api, projectId, 'archive', buildId, AbortSignal.timeout(remaining), follow);
176
+ const snapshot = await readBuildLogs(api, projectId, 'archive', buildId, signal, follow);
144
177
  if (snapshot.state === 'unsupported') {
145
178
  printer.finishLine(write);
146
179
  write('Build logs are not supported for this build provider.\n');
@@ -152,14 +185,21 @@ export function archiveLogWatcher(api, projectId, write, wait = sleep) {
152
185
  warned = '';
153
186
  }
154
187
  catch (error) {
188
+ if (cancelSignal?.aborted)
189
+ return;
190
+ if (finished && signal.aborted && !finalRead && Date.now() < deadline)
191
+ continue;
155
192
  if (error instanceof ApiError && error.status === 400)
156
193
  follow.tails.clear();
157
194
  printer.finishLine(write);
158
- const message = `Could not read build logs. Retry with: insta build-logs ${buildId}\n`;
195
+ const reason = signal.aborted || (error instanceof Error && error.name === 'TimeoutError') ? ' (timed out)' : error instanceof ApiError ? ` (HTTP ${error.status})` : '';
196
+ const message = `Could not read build logs${reason}. Retry with: insta build-logs ${buildId}\n`;
159
197
  if (warned !== message)
160
198
  write(message);
161
199
  warned = message;
162
200
  }
201
+ if (finalRead)
202
+ return;
163
203
  }
164
204
  };
165
205
  }
@@ -1,3 +1,4 @@
1
+ import { setTimeout as delay } from 'node:timers/promises';
1
2
  import { handleApproval } from './util.js';
2
3
  export function archiveBuildSpec(hasDockerfile) {
3
4
  return hasDockerfile ? { type: 'dockerfile' } : { type: 'nixpacks' };
@@ -84,64 +85,72 @@ export async function deployArchive(api, projectId, ref, branch, opts, now = Dat
84
85
  log('resuming the deploy this archive already started');
85
86
  const deadline = now() + DEPLOY_DEADLINE_MS;
86
87
  const overdue = () => new Error(`the deploy did not finish within ${Math.round(DEPLOY_DEADLINE_MS / 60000)} minutes — check \`insta status\` or re-run`);
87
- let last = '';
88
- for (;;) {
89
- // The deadline bounds the wall clock, not the number of answers: it is checked before each poll,
90
- // and each poll is itself bounded by what remains, so a stalled endpoint cannot hold the CLI
91
- // past it, and an answer that would arrive after it is not waited for.
92
- const remaining = deadline - now();
93
- if (remaining <= 0)
94
- throw overdue();
95
- const res = await api.rawRequest('GET', `/projects/${projectId}/archive-deploys/${encodeURIComponent(operationId)}`, undefined, {
96
- signal: AbortSignal.timeout(Math.min(remaining, POLL_REQUEST_TIMEOUT_MS)),
97
- }).catch((e) => {
98
- if (!isAbort(e))
99
- throw e;
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`);
101
- });
102
- const state = res.body?.state;
103
- await watchLogs?.(operationId, state === 'failed' || state === 'live', Math.max(0, deadline - now()));
104
- // A failed operation is an ANSWER, not a transport error: the poll worked, and the sentence
105
- // it carries (usually the gateway's own, e.g. "no Dockerfile at ./api") is the one to show.
106
- if (state === 'failed') {
107
- // `||` would let a non-string through and the CLI would print "[object Object]" for the one
108
- // sentence that explains the failure. Only a non-empty string is a message.
109
- const error = res.body?.error;
110
- return { failed: typeof error === 'string' && error ? error : 'the deploy failed' };
88
+ const logController = new AbortController();
89
+ const logTask = watchLogs ? (async () => {
90
+ while (!logController.signal.aborted && now() < deadline) {
91
+ await watchLogs(operationId, false, Math.max(0, deadline - now()), logController.signal);
92
+ await delay(Math.min(POLL_MS, Math.max(0, deadline - now())), undefined, { signal: logController.signal });
111
93
  }
112
- if (state === 'live') {
113
- const image = res.body?.imageRef;
114
- const url = res.body?.url;
115
- if (typeof image !== 'string' || !image || typeof url !== 'string' || !url) {
116
- throw new Error('the deploy finished but the platform returned no image or URL for it — check `insta status`');
94
+ })().catch(() => {
95
+ if (!logController.signal.aborted)
96
+ log(`Could not follow build logs. Retry with: insta build-logs ${operationId}`);
97
+ }) : undefined;
98
+ let last = '';
99
+ try {
100
+ for (;;) {
101
+ const remaining = deadline - now();
102
+ if (remaining <= 0)
103
+ throw overdue();
104
+ const res = await api.rawRequest('GET', `/projects/${projectId}/archive-deploys/${encodeURIComponent(operationId)}`, undefined, {
105
+ signal: AbortSignal.timeout(Math.min(remaining, POLL_REQUEST_TIMEOUT_MS)),
106
+ }).catch((e) => {
107
+ if (!isAbort(e))
108
+ throw e;
109
+ 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`);
110
+ });
111
+ const state = res.body?.state;
112
+ if (state === 'failed' || state === 'live') {
113
+ logController.abort();
114
+ await logTask;
115
+ await watchLogs?.(operationId, true, Math.max(0, deadline - now()));
117
116
  }
118
- // Optional strings, validated as such. String() would have coerced a protocol error into a
119
- // plausible-looking branch or group and reported a target the deploy never named. An omitted
120
- // field falls back to what was requested; a field of the wrong type is a broken contract.
121
- const optionalString = (field, v) => {
122
- if (v === undefined || v === null)
123
- return undefined;
124
- if (typeof v !== 'string')
125
- throw new Error(`the platform returned a non-string ${field} for the deploy — upgrade with \`insta upgrade\``);
126
- return v;
127
- };
128
- return {
129
- image, url,
130
- branch: optionalString('branch', res.body.branch) ?? branch,
131
- group: optionalString('group', res.body.group) ?? opts.group ?? '',
132
- machineId: optionalString('machineId', res.body.machineId),
133
- };
134
- }
135
- // Only the platform's own in-flight states keep the loop going. An absent or unknown state
136
- // would otherwise spend the whole deadline looking like a slow build.
137
- if (state !== 'queued' && state !== 'building' && state !== 'deploying') {
138
- throw new Error(`the platform reported an unknown deploy state (${JSON.stringify(state)}) — upgrade with \`insta upgrade\``);
139
- }
140
- if (state !== last) {
141
- log(state === 'deploying' ? 'image built, deploying it' : `${state}…`);
142
- last = state;
117
+ if (state === 'failed') {
118
+ const error = res.body?.error;
119
+ return { failed: typeof error === 'string' && error ? error : 'the deploy failed' };
120
+ }
121
+ if (state === 'live') {
122
+ const image = res.body?.imageRef;
123
+ const url = res.body?.url;
124
+ if (typeof image !== 'string' || !image || typeof url !== 'string' || !url) {
125
+ throw new Error('the deploy finished but the platform returned no image or URL for it — check `insta status`');
126
+ }
127
+ const optionalString = (field, v) => {
128
+ if (v === undefined || v === null)
129
+ return undefined;
130
+ if (typeof v !== 'string')
131
+ throw new Error(`the platform returned a non-string ${field} for the deploy — upgrade with \`insta upgrade\``);
132
+ return v;
133
+ };
134
+ return {
135
+ image, url,
136
+ branch: optionalString('branch', res.body.branch) ?? branch,
137
+ group: optionalString('group', res.body.group) ?? opts.group ?? '',
138
+ machineId: optionalString('machineId', res.body.machineId),
139
+ };
140
+ }
141
+ if (state !== 'queued' && state !== 'building' && state !== 'deploying') {
142
+ throw new Error(`the platform reported an unknown deploy state (${JSON.stringify(state)}) — upgrade with \`insta upgrade\``);
143
+ }
144
+ if (state !== last) {
145
+ log(state === 'deploying' ? 'image built, deploying it' : `${state}…`);
146
+ last = state;
147
+ }
148
+ await wait(POLL_MS);
143
149
  }
144
- await wait(POLL_MS);
150
+ }
151
+ finally {
152
+ logController.abort();
153
+ await logTask;
145
154
  }
146
155
  }
147
156
  //# sourceMappingURL=deploy-archive.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "insta",
3
- "version": "0.0.81",
3
+ "version": "0.0.83",
4
4
  "type": "module",
5
5
  "description": "InstaCloud CLI — a thin client of the platform control-plane API.",
6
6
  "keywords": [