insta 0.0.68 → 0.0.70
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 +10 -7
- package/dist/api.js +31 -11
- package/dist/commands/billing.js +11 -8
- package/dist/commands/branch.js +6 -1
- package/dist/commands/build.js +9 -8
- package/dist/commands/deploy.js +112 -12
- package/dist/commands/feedback.js +1 -1
- package/dist/commands/project.js +10 -1
- package/dist/commands/upgrade.js +1 -1
- package/dist/config.js +177 -9
- package/dist/deploy-archive.js +145 -0
- package/dist/env.js +2 -2
- package/dist/index.js +4 -4
- package/dist/pack-ignore.js +267 -0
- package/dist/pack.js +229 -0
- package/package.json +6 -4
package/dist/config.js
CHANGED
|
@@ -1,13 +1,21 @@
|
|
|
1
1
|
// CLI config: global (~/.insta/config.json: api url + tokens) and per-project (./.insta/project.json).
|
|
2
2
|
import { homedir } from 'node:os';
|
|
3
3
|
import { dirname, join, resolve } from 'node:path';
|
|
4
|
-
import {
|
|
4
|
+
import { realpathSync } from 'node:fs';
|
|
5
|
+
import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
5
6
|
import { ensureGitignore } from './gitignore.js';
|
|
7
|
+
import { die } from './util.js';
|
|
6
8
|
import { DEFAULT_ENV, ENVS, envForApiUrl, envFromEnvVar, normalizeUrl } from './env.js';
|
|
7
9
|
const GLOBAL_DIR = join(homedir(), '.insta');
|
|
8
10
|
const GLOBAL_FILE = join(GLOBAL_DIR, 'config.json');
|
|
9
11
|
const PROJECT_DIR = '.insta';
|
|
10
12
|
const PROJECT_FILE = 'project.json';
|
|
13
|
+
// Machine-local, gitignored: which project this machine linked here, and on which control plane.
|
|
14
|
+
// It is NOT in project.json because that file is the team's committed binding — a URL chosen by one
|
|
15
|
+
// machine (a staging switch, a local box, a transient INSTA_API_URL) would make every teammate's
|
|
16
|
+
// CLI treat the shared link as foreign. It records the project id too, because project.json is
|
|
17
|
+
// committed and this file is not: a pull or checkout can replace the project underneath it.
|
|
18
|
+
const LINK_PLANE_FILE = 'link-plane.json';
|
|
11
19
|
// The cloud API default. Uses the instacloud.com brand domain (matches the agents.instacloud.com
|
|
12
20
|
// onboarding), NOT the legacy beta-api.insta.insforge.dev host — same backend, branded domain.
|
|
13
21
|
// Only affects fresh installs: a persisted apiUrl (from a prior login) or INSTA_API_URL wins below.
|
|
@@ -85,11 +93,46 @@ export async function writeGlobal(c) {
|
|
|
85
93
|
await mkdir(GLOBAL_DIR, { recursive: true });
|
|
86
94
|
await writeFile(GLOBAL_FILE, JSON.stringify(c, null, 2));
|
|
87
95
|
}
|
|
96
|
+
/** The home directory is never a project root. `~/.insta/` is this CLI's GLOBAL config directory,
|
|
97
|
+
* so a project.json there is not a project link: honouring one made every directory under the home
|
|
98
|
+
* dir inherit it, and `insta project link` run anywhere below home silently overwrote it. */
|
|
99
|
+
function isHomeDir(dir) {
|
|
100
|
+
return canonicalDir(dir) === canonicalDir(homedir());
|
|
101
|
+
}
|
|
102
|
+
/** A directory's filesystem identity rather than its spelling: the native real path, which resolves
|
|
103
|
+
* symlinks and returns the on-disk case on case-insensitive filesystems (macOS, Windows). Comparing
|
|
104
|
+
* spellings let a symlinked or differently-cased path to home through the home check. A path that
|
|
105
|
+
* doesn't exist has no identity to resolve, so it falls back to its resolved spelling. */
|
|
106
|
+
function canonicalDir(dir) {
|
|
107
|
+
try {
|
|
108
|
+
return realpathSync.native(resolve(dir));
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
return resolve(dir);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
export const HOME_LINK_REFUSAL = 'refusing to link the home directory — ~/.insta is the insta CLI\'s global config, not a project. Run this inside a project directory';
|
|
115
|
+
/** Where a link written from `cwd` lands: the existing project root, or `cwd` itself. */
|
|
116
|
+
async function linkTarget(cwd) {
|
|
117
|
+
return (await findProjectRoot(cwd)) ?? resolve(cwd);
|
|
118
|
+
}
|
|
119
|
+
/** True when a link written from `cwd` would land in the home directory, which never holds one.
|
|
120
|
+
* Lets a caller refuse BEFORE it does anything else with side effects. */
|
|
121
|
+
export async function isHomeLinkTarget(cwd = process.cwd()) {
|
|
122
|
+
return isHomeDir(await linkTarget(cwd));
|
|
123
|
+
}
|
|
88
124
|
/** Git-style ancestor lookup: the nearest directory at-or-above `cwd` containing
|
|
89
|
-
* .insta/project.json — so "link once" works from any subdirectory of the project.
|
|
125
|
+
* .insta/project.json — so "link once" works from any subdirectory of the project. The search
|
|
126
|
+
* stops at the home directory (see isHomeDir): neither home nor anything above it is a project
|
|
127
|
+
* root for a directory inside home. */
|
|
90
128
|
export async function findProjectRoot(cwd = process.cwd()) {
|
|
91
129
|
let dir = resolve(cwd);
|
|
92
130
|
for (;;) {
|
|
131
|
+
// Stop AT the home directory rather than skipping it: a link above home is not a project for
|
|
132
|
+
// home or anything below it, and climbing past home let `insta project link` run in ~ resolve
|
|
133
|
+
// to, and overwrite, an ancestor's link after the home-directory check had passed.
|
|
134
|
+
if (isHomeDir(dir))
|
|
135
|
+
return null;
|
|
93
136
|
try {
|
|
94
137
|
await readFile(join(dir, PROJECT_DIR, PROJECT_FILE), 'utf8');
|
|
95
138
|
return dir;
|
|
@@ -101,32 +144,157 @@ export async function findProjectRoot(cwd = process.cwd()) {
|
|
|
101
144
|
dir = parent;
|
|
102
145
|
}
|
|
103
146
|
}
|
|
104
|
-
|
|
147
|
+
/** The link that applies to `cwd`, and whether it was made against a DIFFERENT control plane. A
|
|
148
|
+
* project id means nothing on another control plane (cloud, staging and every insta-oss box each
|
|
149
|
+
* have their own), and the CLI used to reuse a link against whatever API it was pointed at. */
|
|
150
|
+
export async function resolveProjectLink(cwd = process.cwd()) {
|
|
105
151
|
// Linkless targeting (CI / one-offs / agents): INSTA_PROJECT_ID resolves the project with no
|
|
106
152
|
// link file, and beats one when both exist — an explicit parameter outranks ambient state.
|
|
107
153
|
if (process.env.INSTA_PROJECT_ID) {
|
|
108
154
|
return {
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
155
|
+
link: {
|
|
156
|
+
projectId: process.env.INSTA_PROJECT_ID,
|
|
157
|
+
orgId: process.env.INSTA_ORG_ID ?? '',
|
|
158
|
+
branch: process.env.INSTA_BRANCH ?? 'main',
|
|
159
|
+
},
|
|
112
160
|
};
|
|
113
161
|
}
|
|
114
162
|
const root = await findProjectRoot(cwd);
|
|
115
163
|
if (!root)
|
|
116
164
|
return null;
|
|
165
|
+
let link;
|
|
166
|
+
try {
|
|
167
|
+
link = JSON.parse(await readFile(join(root, PROJECT_DIR, PROJECT_FILE), 'utf8'));
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
// No sidecar — a link from before this existed, or a teammate who just cloned — resolves as it
|
|
173
|
+
// always did: there is nothing to say which control plane it belongs to.
|
|
174
|
+
const record = await readLinkPlane(root);
|
|
175
|
+
if (record) {
|
|
176
|
+
const current = safeUrl((await readGlobal()).apiUrl);
|
|
177
|
+
const base = {
|
|
178
|
+
file: join(root, PROJECT_DIR, PROJECT_FILE), projectId: String(link.projectId),
|
|
179
|
+
linkedProjectId: record.projectId, linkedApiUrl: record.apiUrl, currentApiUrl: current,
|
|
180
|
+
};
|
|
181
|
+
// The record vouches for the project it was written for, and only that one. A record for
|
|
182
|
+
// another project says nothing about this one: trusting it would send this project to the
|
|
183
|
+
// control plane of the project it replaced, or refuse it for a reason that is not true. Fail
|
|
184
|
+
// closed with the real reason instead.
|
|
185
|
+
if (record.projectId !== base.projectId)
|
|
186
|
+
return { link, foreign: { reason: 'changed', ...base } };
|
|
187
|
+
if (normalizeUrl(record.apiUrl) !== normalizeUrl(current))
|
|
188
|
+
return { link, foreign: { reason: 'plane', ...base } };
|
|
189
|
+
}
|
|
190
|
+
return { link };
|
|
191
|
+
}
|
|
192
|
+
/** The link for this control plane, or null. A foreign link is NOT returned (it names a project
|
|
193
|
+
* that does not exist here); callers that must act on a project use requireProject, which stops
|
|
194
|
+
* with guidance instead of treating a foreign link as "unlinked". */
|
|
195
|
+
export async function readProject(cwd = process.cwd()) {
|
|
196
|
+
const r = await resolveProjectLink(cwd);
|
|
197
|
+
if (!r)
|
|
198
|
+
return null;
|
|
199
|
+
if (r.foreign) {
|
|
200
|
+
if (!warnedForeignLinks.has(r.foreign.file)) {
|
|
201
|
+
warnedForeignLinks.add(r.foreign.file);
|
|
202
|
+
process.stderr.write(`note: ${foreignLinkMessage(r.foreign)}\n`);
|
|
203
|
+
}
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
return r.link;
|
|
207
|
+
}
|
|
208
|
+
const warnedForeignLinks = new Set();
|
|
209
|
+
export function foreignLinkMessage(f) {
|
|
210
|
+
// Every field can come from a file (a committed project.json, a copied or hand-edited record),
|
|
211
|
+
// so all of it is stripped of control characters before it reaches a terminal.
|
|
212
|
+
const file = safeText(f.file);
|
|
213
|
+
const id = safeText(f.projectId);
|
|
214
|
+
const linked = safeUrl(f.linkedApiUrl);
|
|
215
|
+
const current = safeUrl(f.currentApiUrl);
|
|
216
|
+
if (f.reason === 'changed') {
|
|
217
|
+
return `${file} now links project ${id}, but this machine linked project ${safeText(f.linkedProjectId)} there, on ${linked}. `
|
|
218
|
+
+ `The link changed since (a pull or checkout), so its control plane is unknown. `
|
|
219
|
+
+ `Confirm it for ${current} with \`insta project link ${id}\`.`;
|
|
220
|
+
}
|
|
221
|
+
return `${file} links project ${id} on ${linked}, but the CLI is pointed at ${current}. `
|
|
222
|
+
+ `Link this directory for ${current} with \`insta project link <id>\`, or point the CLI back at ${linked}.`;
|
|
223
|
+
}
|
|
224
|
+
async function readLinkPlane(root) {
|
|
117
225
|
try {
|
|
118
|
-
|
|
226
|
+
const raw = JSON.parse(await readFile(join(root, PROJECT_DIR, LINK_PLANE_FILE), 'utf8'));
|
|
227
|
+
// Validated, not cast: a malformed record is ignored rather than crashing every command. A
|
|
228
|
+
// record with no project id cannot say which project it vouches for, so it is ignored too.
|
|
229
|
+
if (!raw || typeof raw.projectId !== 'string' || !raw.projectId || typeof raw.apiUrl !== 'string' || !raw.apiUrl)
|
|
230
|
+
return null;
|
|
231
|
+
// Untrusted input (it may have been copied or edited), so sanitized before it is compared or printed.
|
|
232
|
+
return { projectId: raw.projectId, apiUrl: safeUrl(raw.apiUrl) };
|
|
119
233
|
}
|
|
120
234
|
catch {
|
|
121
235
|
return null;
|
|
122
236
|
}
|
|
123
237
|
}
|
|
238
|
+
/** Text safe to echo to a terminal: C0 and C1 control characters and DEL removed — ESC and the
|
|
239
|
+
* single-byte C1 introducers (U+009B CSI among them) alike, so no escape sequence survives. */
|
|
240
|
+
function safeText(text) {
|
|
241
|
+
return String(text).replace(/[\u0000-\u001f\u007f-\u009f]/g, '');
|
|
242
|
+
}
|
|
243
|
+
/** A control-plane URL safe to persist and to print: control characters removed, surrounding
|
|
244
|
+
* whitespace trimmed, and any userinfo (INSTA_API_URL may carry credentials) removed.
|
|
245
|
+
*
|
|
246
|
+
* Userinfo is removed with the same WHATWG parser fetch uses, not with a pattern. That parser is
|
|
247
|
+
* lenient in ways a pattern keeps missing: it accepts leading whitespace, `\` for `/`, and a
|
|
248
|
+
* missing `//` on special schemes, and each of those defeated an anchored pattern while fetch
|
|
249
|
+
* would still have sent the credentials. A value the parser rejects is never requested, but it can
|
|
250
|
+
* still be stored or printed, so everything up to its last `@` is dropped. The same goes for a
|
|
251
|
+
* value that parses under any other scheme: `user:token@host` parses as scheme `user:` with the
|
|
252
|
+
* credentials in its path, where clearing username and password removes nothing. */
|
|
253
|
+
export function safeUrl(url) {
|
|
254
|
+
const text = safeText(url).trim();
|
|
255
|
+
const pastLastAt = () => (text.includes('@') ? text.slice(text.lastIndexOf('@') + 1) : text);
|
|
256
|
+
let parsed;
|
|
257
|
+
try {
|
|
258
|
+
parsed = new URL(text);
|
|
259
|
+
}
|
|
260
|
+
catch {
|
|
261
|
+
return pastLastAt();
|
|
262
|
+
}
|
|
263
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:')
|
|
264
|
+
return pastLastAt();
|
|
265
|
+
if (!parsed.username && !parsed.password)
|
|
266
|
+
return text;
|
|
267
|
+
parsed.username = '';
|
|
268
|
+
parsed.password = '';
|
|
269
|
+
return parsed.href;
|
|
270
|
+
}
|
|
124
271
|
/** Writes to the existing project root when inside a linked project (branch switches from a
|
|
125
|
-
* subdirectory must not mint a nested link); a fresh `link` in an unlinked tree writes to cwd.
|
|
272
|
+
* subdirectory must not mint a nested link); a fresh `link` in an unlinked tree writes to cwd.
|
|
273
|
+
* Records the control plane in the machine-local sidecar beside it. Never writes into the home
|
|
274
|
+
* directory: `~/.insta/` is the global config, not a project. */
|
|
126
275
|
export async function writeProject(c, cwd = process.cwd()) {
|
|
127
|
-
const target =
|
|
276
|
+
const target = await linkTarget(cwd);
|
|
277
|
+
if (isHomeDir(target))
|
|
278
|
+
die(HOME_LINK_REFUSAL);
|
|
279
|
+
const { apiUrl } = await readGlobal();
|
|
128
280
|
await mkdir(join(target, PROJECT_DIR), { recursive: true });
|
|
129
281
|
ensureGitignore(target, ['.insta/agent-session.json'], '# Local agent credentials');
|
|
282
|
+
ensureGitignore(target, [`.insta/${LINK_PLANE_FILE}`], '# Local: the control plane this machine linked against');
|
|
130
283
|
await writeFile(join(target, PROJECT_DIR, PROJECT_FILE), JSON.stringify(c, null, 2));
|
|
284
|
+
// Owner-only, like agent-session.json: it can name a private self-hosted box. writeFile's mode
|
|
285
|
+
// applies only when it creates the file, so an existing record is chmod-ed too.
|
|
286
|
+
const record = join(target, PROJECT_DIR, LINK_PLANE_FILE);
|
|
287
|
+
await writeFile(record, JSON.stringify({ projectId: c.projectId, apiUrl: safeUrl(apiUrl) }, null, 2), { mode: 0o600 });
|
|
288
|
+
await chmod(record, 0o600);
|
|
289
|
+
}
|
|
290
|
+
/** Save a link that auto-resolution chose. Unlike an explicit `insta project link`, the command it
|
|
291
|
+
* was resolved for must still run: in the home directory the choice is used for this command and
|
|
292
|
+
* simply not remembered, rather than showing the picker and then failing the command. Returns
|
|
293
|
+
* whether the link was saved. */
|
|
294
|
+
export async function persistAutoLink(c, cwd = process.cwd()) {
|
|
295
|
+
if (await isHomeLinkTarget(cwd))
|
|
296
|
+
return false;
|
|
297
|
+
await writeProject(c, cwd);
|
|
298
|
+
return true;
|
|
131
299
|
}
|
|
132
300
|
//# sourceMappingURL=config.js.map
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { handleApproval } from './util.js';
|
|
2
|
+
export function archiveBuildSpec(hasDockerfile) {
|
|
3
|
+
return hasDockerfile ? { type: 'dockerfile' } : { type: 'nixpacks' };
|
|
4
|
+
}
|
|
5
|
+
const defaultUpload = async (url, body) => {
|
|
6
|
+
const res = await fetch(url, { method: 'PUT', body });
|
|
7
|
+
if (!res.ok)
|
|
8
|
+
throw new Error(`uploading the archive failed: HTTP ${res.status}`);
|
|
9
|
+
};
|
|
10
|
+
const statusPath = (projectId, sha256) => `/projects/${projectId}/build-uploads/${sha256}`;
|
|
11
|
+
// Put an archive where the build gateway can fetch it, and answer what the deploy body needs.
|
|
12
|
+
// Returns null when an approval is pending — the caller stops, the user approves and re-runs.
|
|
13
|
+
//
|
|
14
|
+
// The status read comes FIRST and that ordering is the whole recovery story: it is ungated, the
|
|
15
|
+
// packer is deterministic and the upload id is derived from the content, so a re-run after an
|
|
16
|
+
// approval finds the object already there, skips the gated mint it no longer needs, and submits a
|
|
17
|
+
// byte-identical deploy body. That is why no resumable state is written to disk.
|
|
18
|
+
export async function uploadArchive(api, projectId, packed, branch, opts, upload = defaultUpload) {
|
|
19
|
+
const ref = { archiveSha256: packed.sha256, build: archiveBuildSpec(packed.hasDockerfile) };
|
|
20
|
+
// The id the platform derives storage from is this digest, so an object that is already there is
|
|
21
|
+
// BYTE-IDENTICAL to the one in hand and the ref describes it truthfully. Keying on the tar digest
|
|
22
|
+
// instead bought cross-runtime dedup and paid for it with a lie: a Bun re-run of a Node upload
|
|
23
|
+
// matched the id, skipped the upload, and sent Bun's digest for Node's bytes, which the worker
|
|
24
|
+
// then rejected on every attempt until the object expired.
|
|
25
|
+
const first = await api.rawRequest('GET', statusPath(projectId, packed.sha256));
|
|
26
|
+
if (first.body?.state === 'valid')
|
|
27
|
+
return ref;
|
|
28
|
+
const minted = await api.rawRequest('POST', `/projects/${projectId}/build-uploads`, {
|
|
29
|
+
branch,
|
|
30
|
+
group: opts.group,
|
|
31
|
+
sha256: packed.sha256,
|
|
32
|
+
size: packed.archive.length,
|
|
33
|
+
});
|
|
34
|
+
if (handleApproval(minted, opts.json))
|
|
35
|
+
return null;
|
|
36
|
+
// The mint's whole product is this URL. An absent one would be PUT to as the string
|
|
37
|
+
// "undefined" and the failure would surface two steps later as a missing object.
|
|
38
|
+
const uploadUrl = minted.body?.uploadUrl;
|
|
39
|
+
if (typeof uploadUrl !== 'string' || !uploadUrl)
|
|
40
|
+
throw new Error('the platform minted an upload with no URL — re-run the deploy');
|
|
41
|
+
await upload(uploadUrl, packed.archive);
|
|
42
|
+
// Never let the deploy call be the thing that discovers a failed upload: its grant is spent in
|
|
43
|
+
// the governance preHandler, so a retry would need a NEW approval.
|
|
44
|
+
const after = await api.rawRequest('GET', statusPath(projectId, packed.sha256));
|
|
45
|
+
if (after.body?.state !== 'valid') {
|
|
46
|
+
throw new Error('the archive upload did not land — re-run the deploy to try again');
|
|
47
|
+
}
|
|
48
|
+
return ref;
|
|
49
|
+
}
|
|
50
|
+
// How often to ask, and how long to keep asking. The platform submits the build and returns; the
|
|
51
|
+
// WAIT is ours, one short request at a time, because a deploy is answered synchronously and the
|
|
52
|
+
// ALB in front of the platform cuts an idle request at 60s while an image build runs minutes.
|
|
53
|
+
const POLL_MS = 3000;
|
|
54
|
+
const DEPLOY_DEADLINE_MS = 30 * 60 * 1000;
|
|
55
|
+
// One status read is a small GET; anything longer than this is a stalled endpoint, not a slow one.
|
|
56
|
+
const POLL_REQUEST_TIMEOUT_MS = 20_000;
|
|
57
|
+
const isAbort = (e) => e instanceof Error && (e.name === 'TimeoutError' || e.name === 'AbortError');
|
|
58
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
59
|
+
// ONE gated call, then a poll. The platform enqueues the build+deploy as an operation and answers
|
|
60
|
+
// 202 at once, because a request has to finish inside the ALB's 60s and an image build runs
|
|
61
|
+
// minutes; the wait is ours, one short GET at a time. Two gates on the lane with the mint, the
|
|
62
|
+
// same as the flyctl lane. Returns null when an approval is pending.
|
|
63
|
+
//
|
|
64
|
+
// The operation is idempotent on (target, archive, build kind), so a re-run after approving finds
|
|
65
|
+
// the one it already started rather than building again: same image, same result, no second
|
|
66
|
+
// approval for a build that already happened.
|
|
67
|
+
export async function deployArchive(api, projectId, ref, branch, opts, now = Date.now, wait = sleep, log = () => { }) {
|
|
68
|
+
const started = await api.rawRequest('POST', `/projects/${projectId}/archive-deploys`, {
|
|
69
|
+
branch,
|
|
70
|
+
group: opts.group,
|
|
71
|
+
archive: ref,
|
|
72
|
+
port: opts.port ? Number(opts.port) : undefined,
|
|
73
|
+
websocket: typeof opts.websocket === 'boolean' ? opts.websocket : undefined,
|
|
74
|
+
replaceSource: opts.replaceSource === true ? true : undefined,
|
|
75
|
+
});
|
|
76
|
+
// An approval is a 202 too, told apart by its status word; anything else here is our operation.
|
|
77
|
+
if (handleApproval(started, opts.json))
|
|
78
|
+
return null;
|
|
79
|
+
const operationId = started.body?.operationId;
|
|
80
|
+
if (typeof operationId !== 'string' || !operationId)
|
|
81
|
+
throw new Error('the platform accepted the deploy but returned no operation id — re-run the deploy');
|
|
82
|
+
if (started.body?.resumed === true)
|
|
83
|
+
log('resuming the deploy this archive already started');
|
|
84
|
+
const deadline = now() + DEPLOY_DEADLINE_MS;
|
|
85
|
+
const overdue = () => new Error(`the deploy did not finish within ${Math.round(DEPLOY_DEADLINE_MS / 60000)} minutes — check \`insta status\` or re-run`);
|
|
86
|
+
let last = '';
|
|
87
|
+
for (;;) {
|
|
88
|
+
// The deadline bounds the wall clock, not the number of answers: it is checked before each poll,
|
|
89
|
+
// and each poll is itself bounded by what remains, so a stalled endpoint cannot hold the CLI
|
|
90
|
+
// past it, and an answer that would arrive after it is not waited for.
|
|
91
|
+
const remaining = deadline - now();
|
|
92
|
+
if (remaining <= 0)
|
|
93
|
+
throw overdue();
|
|
94
|
+
const res = await api.rawRequest('GET', `/projects/${projectId}/archive-deploys/${encodeURIComponent(operationId)}`, undefined, {
|
|
95
|
+
signal: AbortSignal.timeout(Math.min(remaining, POLL_REQUEST_TIMEOUT_MS)),
|
|
96
|
+
}).catch((e) => {
|
|
97
|
+
if (!isAbort(e))
|
|
98
|
+
throw e;
|
|
99
|
+
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
|
+
const state = res.body?.state;
|
|
102
|
+
// A failed operation is an ANSWER, not a transport error: the poll worked, and the sentence
|
|
103
|
+
// it carries (usually the gateway's own, e.g. "no Dockerfile at ./api") is the one to show.
|
|
104
|
+
if (state === 'failed') {
|
|
105
|
+
// `||` would let a non-string through and the CLI would print "[object Object]" for the one
|
|
106
|
+
// sentence that explains the failure. Only a non-empty string is a message.
|
|
107
|
+
const error = res.body?.error;
|
|
108
|
+
return { failed: typeof error === 'string' && error ? error : 'the deploy failed' };
|
|
109
|
+
}
|
|
110
|
+
if (state === 'live') {
|
|
111
|
+
const image = res.body?.imageRef;
|
|
112
|
+
const url = res.body?.url;
|
|
113
|
+
if (typeof image !== 'string' || !image || typeof url !== 'string' || !url) {
|
|
114
|
+
throw new Error('the deploy finished but the platform returned no image or URL for it — check `insta status`');
|
|
115
|
+
}
|
|
116
|
+
// Optional strings, validated as such. String() would have coerced a protocol error into a
|
|
117
|
+
// plausible-looking branch or group and reported a target the deploy never named. An omitted
|
|
118
|
+
// field falls back to what was requested; a field of the wrong type is a broken contract.
|
|
119
|
+
const optionalString = (field, v) => {
|
|
120
|
+
if (v === undefined || v === null)
|
|
121
|
+
return undefined;
|
|
122
|
+
if (typeof v !== 'string')
|
|
123
|
+
throw new Error(`the platform returned a non-string ${field} for the deploy — upgrade with \`insta upgrade\``);
|
|
124
|
+
return v;
|
|
125
|
+
};
|
|
126
|
+
return {
|
|
127
|
+
image, url,
|
|
128
|
+
branch: optionalString('branch', res.body.branch) ?? branch,
|
|
129
|
+
group: optionalString('group', res.body.group) ?? opts.group ?? '',
|
|
130
|
+
machineId: optionalString('machineId', res.body.machineId),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
// Only the platform's own in-flight states keep the loop going. An absent or unknown state
|
|
134
|
+
// would otherwise spend the whole deadline looking like a slow build.
|
|
135
|
+
if (state !== 'queued' && state !== 'building' && state !== 'deploying') {
|
|
136
|
+
throw new Error(`the platform reported an unknown deploy state (${JSON.stringify(state)}) — upgrade with \`insta upgrade\``);
|
|
137
|
+
}
|
|
138
|
+
if (state !== last) {
|
|
139
|
+
log(state === 'deploying' ? 'image built, deploying it' : `${state}…`);
|
|
140
|
+
last = state;
|
|
141
|
+
}
|
|
142
|
+
await wait(POLL_MS);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
//# sourceMappingURL=deploy-archive.js.map
|
package/dist/env.js
CHANGED
|
@@ -2,12 +2,12 @@ export const ENVS = {
|
|
|
2
2
|
prod: {
|
|
3
3
|
api: 'https://api.instacloud.com',
|
|
4
4
|
mcp: 'https://mcp.instacloud.com/mcp',
|
|
5
|
-
skills: 'InsForge/
|
|
5
|
+
skills: 'InsForge/instacloud-skills',
|
|
6
6
|
},
|
|
7
7
|
staging: {
|
|
8
8
|
api: 'https://api.staging.instacloud.com',
|
|
9
9
|
mcp: 'https://mcp.staging.instacloud.com/mcp',
|
|
10
|
-
skills: 'InsForge/
|
|
10
|
+
skills: 'InsForge/instacloud-skills#devel',
|
|
11
11
|
},
|
|
12
12
|
};
|
|
13
13
|
export const DEFAULT_ENV = 'prod';
|
package/dist/index.js
CHANGED
|
@@ -149,7 +149,7 @@ svc.command('add [type] [name]').description('Provision a service on demand (ass
|
|
|
149
149
|
.option('--port <n>', 'compute only: port the image listens on (default 8080)')
|
|
150
150
|
.option('--always-on', 'compute only: create as always-on — never scales to zero (the default for new compute services; all plans; billing is actual usage either way)')
|
|
151
151
|
.option('--no-always-on', 'compute only: create as scale-to-zero — idle machines suspend and wake on the next request')
|
|
152
|
-
.option('--volume <gi>', 'compute only: attach a persistent /data volume of this many whole Gi (also attachable later: `insta compute volume <name> --size <gi
|
|
152
|
+
.option('--volume <gi>', 'compute only: attach a persistent /data volume of this many whole Gi (also attachable later: `insta compute volume <name> --size <gi>`). Any plan may attach up to its own plan cap (10Gi free, 50Gi paid by default; the bare `insta compute volume <name>` read prints it as plan max); a size above the free cap is paid. Volume services keep 1 machine and stop (cold wake) instead of suspend when idle')
|
|
153
153
|
.option('--json')
|
|
154
154
|
.action(guard(async (type, name, o) => {
|
|
155
155
|
const a = await resolveServiceArgs(type, name, serviceArgsDeps(o.json), o);
|
|
@@ -208,13 +208,13 @@ sec.command('sources').description('List service credential sources available fo
|
|
|
208
208
|
sec.command('tree').description('Show secrets as project → branch → service → secrets').option('--json')
|
|
209
209
|
.action(guard((o) => secretsCmd.secretsTree(o)));
|
|
210
210
|
// ---- build (pre-push verification — local, offline, deploys nothing) ----
|
|
211
|
-
program.command('build [dir]').description('Verify a source directory would build before deploying: detection plan + the Dockerfile (yours, or the one nixpacks would generate
|
|
211
|
+
program.command('build [dir]').description('Verify a source directory would build before deploying: detection plan + the Dockerfile (yours, or the one nixpacks would generate server-side) + static checks. Local and offline — no login needed, nothing pushed. Exit 1 when the verdict is failed')
|
|
212
212
|
.option('--explain', 'include the Dockerfile content in the output')
|
|
213
213
|
.option('--port <p>', 'port the app listens on (else the Dockerfile EXPOSE)')
|
|
214
214
|
.option('--json')
|
|
215
215
|
.action(guard((dir, o) => build(dir, o)));
|
|
216
216
|
// ---- deploy ----
|
|
217
|
-
program.command('deploy [dir]').description('Deploy a source directory (built remotely on
|
|
217
|
+
program.command('deploy [dir]').description('Deploy a source directory (built remotely; on insta-compute a Dockerfile is optional and nixpacks detects the runtime) or a prebuilt --image to a branch compute group')
|
|
218
218
|
.option('--image <url>', 'prebuilt container image to deploy (instead of a source dir)').option('--branch <b>').option('--group <g>').option('--port <p>')
|
|
219
219
|
.option('--websocket', 'run a WebSocket app (larger guest + connection-based concurrency)')
|
|
220
220
|
.option('--replace-source', 'the service deploys from a connected GitHub repo: switch it to this image and remove the repo connection (admin); without it such a deploy is refused')
|
|
@@ -270,7 +270,7 @@ compute.command('watch-paths [service]').description("Show or change which paths
|
|
|
270
270
|
.option('--json').option('--branch <branch>', 'branch (default: current)').action(guard((service, o) => githubCmd.computeWatchPaths(service, o)));
|
|
271
271
|
compute.command('disconnect-repo [service]').description('Disconnect the GitHub repository from a compute service. The service keeps running its current image; pushes no longer deploy it, and its build history stays')
|
|
272
272
|
.option('--json').option('--branch <branch>', 'branch (default: current)').action(guard((service, o) => githubCmd.computeDisconnectRepo(service, o)));
|
|
273
|
-
compute.command('volume [service]').description("Show, attach, grow, or delete a compute service's persistent /data volume. No flag: print size, mount path, and the plan cap (any plan). --size on a volumeless service ATTACHES one (any plan
|
|
273
|
+
compute.command('volume [service]').description("Show, attach, grow, or delete a compute service's persistent /data volume. No flag: print size, mount path, and the plan cap (any plan). --size on a volumeless service ATTACHES one (any plan up to its own plan cap — 10Gi free, 50Gi paid by default — which is also what a disk with no size named is born at; a size above the free cap is paid; the disk mounts at /data on the next deploy); on a volume-bearing one it grows (paid plans; grow-only — a provisioned disk cannot shrink). --delete DESTROYS the disk and ALL its data immediately (no detach, no undo; billing stops now, and suspend fast-wake + scale-out return). Billing is actual data stored — the size is a cap, not a price")
|
|
274
274
|
.option('--size <gi>', 'new size in whole Gi, e.g. 10 (must be ≥ the current size)')
|
|
275
275
|
.option('--delete', 'destroy the volume and ALL its data (irreversible; download anything you need first)')
|
|
276
276
|
.option('--json').option('--branch <branch>', 'branch (default: current)').action(guard((service, o) => computeCmd.computeVolume(service, o)));
|