insta 0.0.68 → 0.0.69

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
@@ -16,7 +16,7 @@ Native binary, no Node required (macOS / Linux / WSL). Installs to `~/.insta/bin
16
16
  verifies the download against `SHA256SUMS`:
17
17
 
18
18
  ```bash
19
- curl -fsSL https://raw.githubusercontent.com/InsForge/insta-cli/main/install.sh | sh
19
+ curl -fsSL https://raw.githubusercontent.com/InsForge/instacloud-cli/main/install.sh | sh
20
20
  ```
21
21
 
22
22
  From npm:
@@ -48,7 +48,7 @@ On macOS/Linux without Node, the native-binary installer puts the `insta` CLI on
48
48
  skill + MCP steps it then runs still need Node — the skills tool runs via npx). Never run it
49
49
  on native Windows — PowerShell's `curl` alias and the WSL `bash` shim break it; use npx
50
50
  above, or download `insta-windows-x64.exe` from the
51
- [releases page](https://github.com/InsForge/insta-cli/releases):
51
+ [releases page](https://github.com/InsForge/instacloud-cli/releases):
52
52
 
53
53
  ```bash
54
54
  curl -fsSL agents.instacloud.com | sh
@@ -74,7 +74,9 @@ insta deploy .
74
74
  opt-in, so you add only what you need. `secrets` writes the current branch's user-defined
75
75
  secrets to `./.env` (the postgres connection string is read with `insta db url`). `deploy .`
76
76
  builds the directory remotely and ships it to the branch's compute
77
- service; it needs a `Dockerfile`, but no local Docker.
77
+ service, with no local Docker. Whether it needs a `Dockerfile` depends on where the
78
+ service runs: on insta-compute it is optional, and a directory without one is built
79
+ by nixpacks on the build gateway; on Fly-backed services one is still required.
78
80
 
79
81
  ## Authentication
80
82
 
@@ -157,7 +159,7 @@ other, so switching environments drops the stored session and you log in again.
157
159
  | control plane | `api.instacloud.com` | `api.staging.instacloud.com` |
158
160
  | MCP server | `mcp.instacloud.com/mcp` | `mcp.staging.instacloud.com/mcp` |
159
161
  | MCP registers as | `insta-cloud` | `insta-cloud-staging` |
160
- | agent skills | `InsForge/insta-skills` | `InsForge/insta-skills#devel` |
162
+ | agent skills | `InsForge/instacloud-skills` | `InsForge/instacloud-skills#devel` |
161
163
  | CLI channel | latest stable release | newest prerelease, else stable |
162
164
 
163
165
  ```bash
@@ -180,7 +182,7 @@ That host is a CloudFront cache, so after a change to the installer it can serve
180
182
  previous copy for up to about a day. This form is equivalent and always current:
181
183
 
182
184
  ```bash
183
- curl -fsSL https://raw.githubusercontent.com/InsForge/insta-cli/main/install.sh | sh -s -- --agents --staging
185
+ curl -fsSL https://raw.githubusercontent.com/InsForge/instacloud-cli/main/install.sh | sh -s -- --agents --staging
184
186
  ```
185
187
 
186
188
  If the environment cannot be applied — an installed CLI older than 0.0.23 has no `insta
@@ -204,7 +206,7 @@ build never reaches a production installer.
204
206
  ## Commands
205
207
 
206
208
  `insta --help` is the authoritative list. For flags, approval gates and plan limits, see the
207
- [full command reference](https://github.com/InsForge/insta-skills/blob/main/insta/cli-reference.md).
209
+ [full command reference](https://github.com/InsForge/instacloud-skills/blob/main/insta/cli-reference.md).
208
210
 
209
211
  | Command | What it covers |
210
212
  |---|---|
@@ -252,7 +254,7 @@ build never reaches a production installer.
252
254
  ## Agent skills
253
255
 
254
256
  The `insta` skill and its task guides live in
255
- [InsForge/insta-skills](https://github.com/InsForge/insta-skills). `insta setup agent`
257
+ [InsForge/instacloud-skills](https://github.com/InsForge/instacloud-skills). `insta setup agent`
256
258
  installs it user-globally for every coding agent on the machine. `insta project create` and
257
259
  `insta project link` additionally install the stack skills (Tigris, Better Auth) into the
258
260
  project, along with the `insta observe` credential-audit hook. Postgres needs no stack
package/dist/api.js CHANGED
@@ -77,7 +77,7 @@ export class ApiClient {
77
77
  async raw(method, path, body, auth, scope = {}) {
78
78
  let r = await this.fetch(method, path, body, auth, scope);
79
79
  if (r.status === 401 && auth && this.cfg.refreshToken) {
80
- if (await this.refresh())
80
+ if (await this.refresh(scope.signal))
81
81
  r = await this.fetch(method, path, body, auth, scope);
82
82
  }
83
83
  return r;
@@ -92,6 +92,7 @@ export class ApiClient {
92
92
  method,
93
93
  headers,
94
94
  body: body === undefined ? undefined : JSON.stringify(body),
95
+ signal: scope.signal,
95
96
  });
96
97
  const text = await res.text();
97
98
  let parsed = null;
@@ -103,9 +104,9 @@ export class ApiClient {
103
104
  }
104
105
  return { status: res.status, body: parsed };
105
106
  }
106
- async refresh() {
107
+ async refresh(signal) {
107
108
  try {
108
- const res = await this.fetch('POST', '/auth/refresh', { refreshToken: this.cfg.refreshToken }, false);
109
+ const res = await this.fetch('POST', '/auth/refresh', { refreshToken: this.cfg.refreshToken }, false, { signal });
109
110
  if (res.status >= 400)
110
111
  return false;
111
112
  this.cfg.accessToken = res.body.accessToken;
@@ -113,7 +114,12 @@ export class ApiClient {
113
114
  await this.persist();
114
115
  return true;
115
116
  }
116
- catch {
117
+ catch (e) {
118
+ // Refresh belongs to the original request's time budget. Do not turn its cancellation
119
+ // into the earlier 401: the caller needs the abort to report a timeout or cancellation.
120
+ signal?.throwIfAborted();
121
+ if (e instanceof Error && (e.name === 'AbortError' || e.name === 'TimeoutError'))
122
+ throw e;
117
123
  return false;
118
124
  }
119
125
  }
@@ -155,21 +155,21 @@ export async function buildReport(dirArg, opts, deps) {
155
155
  const envKeys = existsSync(envExample) ? envKeysFromDotEnvExample(readFileSync(envExample, 'utf8')) : [];
156
156
  const checks = [];
157
157
  // Only a Dockerfile IN the directory passes. A nixpacks-generated one is a real plan, but it is
158
- // not a plan `insta deploy <dir>` can execute: that path builds the directory's own Dockerfile and
159
- // dies without one, and the nixpacks lane runs server-side for GitHub-connected repos only. This
160
- // check used to pass on the generated Dockerfile, so a verifier said "deployable" about a
161
- // directory `deploy` refuses — the whole point of the command is to not do that.
158
+ // not one this machine can hand to every target: on a Fly-backed service `insta deploy <dir>`
159
+ // builds the directory's own Dockerfile and dies without one. On insta-compute the gateway runs
160
+ // nixpacks itself, so the same directory ships. The check stays a non-pass because it cannot know
161
+ // the target, and the detail below says both halves rather than promising either.
162
162
  checks.push(dockerfile.source === 'nixpacks'
163
163
  ? {
164
164
  id: 'dockerfile',
165
165
  severity: 'warning',
166
166
  status: 'fail',
167
167
  title: 'Dockerfile',
168
- detail: `${dockerfileDetail} — but \`insta deploy <dir>\` builds the directory's own Dockerfile; the nixpacks lane runs server-side for GitHub-connected repos only`,
168
+ detail: `${dockerfileDetail} — \`insta deploy <dir>\` builds this on the gateway with nixpacks when the service runs on insta-compute, and needs your own Dockerfile on a Fly-backed one`,
169
169
  // NOT "save the generated Dockerfile here": it is not standalone (it COPYs the
170
170
  // .nixpacks/nixpkgs-<hash>.nix support files nixpacks writes beside it, which this
171
171
  // directory does not have). The detected commands above are the reusable part.
172
- nextAction: `to deploy this directory, write a Dockerfile at ${userDockerfilePath} — the detected install/start commands above are the starting point; or connect the GitHub repo to the service (\`insta compute connect-repo <owner/repo>\`) to use the nixpacks lane`,
172
+ nextAction: `on insta-compute this deploys as-is (nixpacks builds it on the gateway). On a Fly-backed service, write a Dockerfile at ${userDockerfilePath} — the detected install/start commands above are the starting point — or connect the GitHub repo (\`insta compute connect-repo <owner/repo>\`)`,
173
173
  }
174
174
  : {
175
175
  id: 'dockerfile',
@@ -225,8 +225,9 @@ export function renderReport(r, explain) {
225
225
  const lines = [];
226
226
  lines.push(`plan for ${r.dir}:`);
227
227
  // The builder line is the first thing read (and the thing an agent scrapes), so it carries the
228
- // lane caveat too — "builder: nixpacks" on its own reads as a promise `insta deploy <dir>` breaks.
229
- const lane = r.plan.builder === 'nixpacks' ? ' — GitHub lane only (`insta compute connect-repo`); `insta deploy <dir>` needs a Dockerfile' : '';
228
+ // lane caveat too — "builder: nixpacks" on its own reads as a promise that is only true on some
229
+ // targets. It has to say the SAME thing the detail below it says, or a scrape sees both claims.
230
+ const lane = r.plan.builder === 'nixpacks' ? ' — server-side: insta-compute builds this as-is, a Fly-backed service needs your own Dockerfile' : '';
230
231
  lines.push(` builder: ${r.plan.builder ?? 'none'}${r.plan.providers.length ? ` (providers: ${r.plan.providers.join(', ')})` : ''}${lane}`);
231
232
  if (r.plan.installCommand)
232
233
  lines.push(` install: ${r.plan.installCommand}`);
@@ -3,14 +3,17 @@ import { existsSync, readFileSync } from 'node:fs';
3
3
  import { ApiClient, ApiError, requireProject } from '../api.js';
4
4
  import { info, die, printJson, handleApproval, renderNextActions, CliExit } from '../util.js';
5
5
  import { flyctlBuildAndPush, ensureFlyctl, defaultBuildRunner, stderrBuildRunner } from '../flyctl-build.js';
6
+ import { packDirectory, windowsModeCaveat } from '../pack.js';
7
+ import { deployArchive, uploadArchive } from '../deploy-archive.js';
8
+ import { parsePort } from './services.js';
6
9
  // With --json, stdout must carry exactly one JSON document (the deploy result), so every progress
7
10
  // line moves to stderr.
8
11
  const note = (opts) => (opts.json ? (m) => void process.stderr.write(m + '\n') : info);
9
12
  // Map CLI options to the platform deploy request body. Pure, so it's unit-tested. --websocket is only
10
- // sent when set (plain deploys unchanged).
11
- export function deployRequestBody(image, branch, opts) {
13
+ // sent when set (plain deploys unchanged). Exactly one of image | archive rides along.
14
+ export function deployRequestBody(source, branch, opts) {
12
15
  return {
13
- image,
16
+ image: source.image,
14
17
  branch,
15
18
  group: opts.group,
16
19
  port: opts.port ? Number(opts.port) : undefined,
@@ -18,6 +21,82 @@ export function deployRequestBody(image, branch, opts) {
18
21
  replaceSource: opts.replaceSource ? true : undefined,
19
22
  };
20
23
  }
24
+ // Ask the platform which lane serves this target, so the CLI stops knowing which provider backs
25
+ // its service. A 404 means the platform predates the contract — and because this is a GET, it
26
+ // 404s the same way for a human and an agent, which a POST would not.
27
+ async function discoverLane(api, projectId, branch, opts) {
28
+ const q = new URLSearchParams({ branch, ...(opts.group ? { group: opts.group } : {}) });
29
+ try {
30
+ const res = await api.rawRequest('GET', `/projects/${projectId}/source-build?${q}`);
31
+ // Validated, not cast. Every value other than the four we know silently fell through to the
32
+ // flyctl path below, so a server that grew a fifth lane would send this CLI down the wrong
33
+ // one and fail somewhere unrelated. An unknown answer is the server being ahead of us, and
34
+ // saying that is more useful than guessing.
35
+ const lane = (res.body ?? {});
36
+ const tag = lane.lane ?? '';
37
+ const known = ['flyctl', 'local-docker', 'archive', 'none'];
38
+ if (!known.includes(tag)) {
39
+ die(`this platform answered with a source-build lane this CLI does not know (${JSON.stringify(tag)}) — upgrade with \`insta upgrade\``);
40
+ }
41
+ // The tag alone is not the contract: each branch carries a payload this code then trusts.
42
+ // An `archive` with malformed limits fell back to local defaults, so the CLI would enforce
43
+ // caps the SERVER does not have, and a `none` with no reason died with `undefined`.
44
+ if (tag === 'archive') {
45
+ const l = lane.limits;
46
+ const positive = (v) => typeof v === 'number' && Number.isSafeInteger(v) && v > 0;
47
+ if (!l || !positive(l.maxArchiveBytes) || !positive(l.maxExtractedBytes) || !positive(l.maxFiles)) {
48
+ die('this platform offered the archive lane without usable size limits — upgrade with `insta upgrade`');
49
+ }
50
+ }
51
+ const reason = lane.reason;
52
+ if (tag === 'none' && (typeof reason !== 'string' || !reason.trim())) {
53
+ die('this platform refused a source build without saying why — upgrade with `insta upgrade`');
54
+ }
55
+ return lane;
56
+ }
57
+ catch (e) {
58
+ if (e instanceof ApiError && e.status === 404) {
59
+ // The route answered and the TARGET is what is missing: say so, or flyctl turns it into "no Dockerfile".
60
+ // Both ways out: the project has several groups and none is named, or it has none at all.
61
+ if (/compute group not found|branch not found/.test(e.message))
62
+ die(`${e.message} — name it with \`--group <name>\` (see \`insta services list\`), or add one: \`insta services add compute <name>\``);
63
+ return { lane: 'legacy' };
64
+ }
65
+ throw e;
66
+ }
67
+ }
68
+ // Turn a source directory into something deployable. Returns null when an approval is pending.
69
+ export async function prepareSource(api, projectId, dir, branch, opts, run = opts.json ? stderrBuildRunner : defaultBuildRunner, upload) {
70
+ const lane = await discoverLane(api, projectId, branch, opts);
71
+ if (lane.lane === 'none')
72
+ die(lane.reason);
73
+ if (lane.lane !== 'archive') {
74
+ // flyctl, local-docker and legacy all end in an image, and all three need a Dockerfile.
75
+ return { image: await buildFromSource(api, projectId, dir, branch, opts, run) };
76
+ }
77
+ const log = note(opts);
78
+ const absDir = resolve(process.cwd(), dir);
79
+ const caveat = windowsModeCaveat();
80
+ if (caveat)
81
+ log(caveat);
82
+ const packed = packDirectory(absDir, lane.limits);
83
+ log(`packed ${dir}: ${packed.files} files, ${packed.archive.length} bytes`);
84
+ const ref = await uploadArchive(api, projectId, packed, branch, opts, upload);
85
+ if (!ref)
86
+ return null;
87
+ log(`deploying ${packed.archive.length} bytes via the gateway (${ref.build.type})`);
88
+ // One gated call enqueues build+deploy as an operation; the wait is ours, one short poll at a
89
+ // time, because a platform request has to answer inside the ALB's 60s while a build runs minutes.
90
+ // A repo-connected service refuses this with a 409 the same way it refuses an image deploy, and
91
+ // 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)
93
+ .catch((e) => { throw e instanceof ApiError && e.status === 409 ? new ApiError(e.status, repoConnectedHint(e.message), e.body) : e; });
94
+ if (!out)
95
+ return null;
96
+ if ('failed' in out)
97
+ die(out.failed);
98
+ return { deployed: out };
99
+ }
21
100
  // A port mismatch is the #1 deploy mistake: the app boots "successfully" but the proxy routes to
22
101
  // the wrong internal port and every request is refused. For source deploys the Dockerfile states
23
102
  // the truth — use its (last) EXPOSE as the default instead of a blind 8080.
@@ -30,10 +109,10 @@ export function dockerfileExposedPort(dockerfile) {
30
109
  }
31
110
  return port;
32
111
  }
33
- // A directory deploy builds the Dockerfile IN the directory — there is no no-Dockerfile lane here.
34
- // The nixpacks (no-Dockerfile) lane is real but server-side: it runs on the build gateway for
35
- // GitHub-connected repos only, and nothing reachable from `insta deploy <dir>` can enter it. So the
36
- // dead-end message names every way forward instead of the bare "add one".
112
+ // This message is for the target that still REQUIRES a Dockerfile: a Fly-backed service, where a
113
+ // directory deploy builds the Dockerfile in the directory and dies without one. On insta-compute
114
+ // the archive lane carries the directory to the gateway and nixpacks builds it, so this dead end is
115
+ // no longer universal. It names every way forward instead of the bare "add one".
37
116
  //
38
117
  // It deliberately does NOT say "save the Dockerfile `insta build --explain` prints": that file is
39
118
  // not standalone — it COPYs `.nixpacks/nixpkgs-<hash>.nix` support files nixpacks writes beside it,
@@ -60,7 +139,16 @@ export async function deploy(dir, opts) {
60
139
  const p = await requireProject();
61
140
  const branch = opts.branch ?? p.branch;
62
141
  const log = note(opts);
63
- let port = opts.port ? Number(opts.port) : undefined;
142
+ // Junk fails here, before a directory is packed and uploaded for a body the platform would only
143
+ // refuse: the same parser every other --port in this CLI runs. `Number()` alone sent NaN as
144
+ // `null` and let 0 or 70000 travel to the server.
145
+ let port;
146
+ try {
147
+ port = opts.port === undefined ? undefined : parsePort(opts.port);
148
+ }
149
+ catch (e) {
150
+ die(`--${e.message}`);
151
+ }
64
152
  if (dir && port === undefined) {
65
153
  const dockerfile = join(resolve(process.cwd(), dir), 'Dockerfile');
66
154
  const exposed = existsSync(dockerfile) ? dockerfileExposedPort(readFileSync(dockerfile, 'utf8')) : undefined;
@@ -70,14 +158,26 @@ export async function deploy(dir, opts) {
70
158
  }
71
159
  }
72
160
  const effOpts = { ...opts, port: port?.toString() };
73
- const image = dir ? await buildFromSource(api, p.projectId, dir, branch, effOpts) : opts.image;
74
- const res = await api.rawRequest('POST', `/projects/${p.projectId}/deploy`, deployRequestBody(image, branch, effOpts))
161
+ const source = dir ? await prepareSource(api, p.projectId, dir, branch, effOpts) : { image: opts.image };
162
+ if (!source)
163
+ return; // an approval is pending; the user approves and re-runs
164
+ if ('deployed' in source) {
165
+ // The archive lane's operation already deployed. One output shape whichever lane ran, minus
166
+ // nextActions, which the operation read does not carry.
167
+ const d = source.deployed;
168
+ if (opts.json)
169
+ return printJson({ image: d.image, url: d.url, branch: d.branch, group: d.group, machineId: d.machineId });
170
+ info(`deployed ${d.image} -> ${d.url} (branch ${d.branch}, group ${d.group})`);
171
+ return;
172
+ }
173
+ const res = await api.rawRequest('POST', `/projects/${p.projectId}/deploy`, deployRequestBody(source, branch, effOpts))
75
174
  .catch((e) => { throw e instanceof ApiError && e.status === 409 ? new ApiError(e.status, repoConnectedHint(e.message), e.body) : e; });
76
175
  if (handleApproval(res, opts.json))
77
176
  return;
177
+ const what = source.image;
78
178
  if (opts.json)
79
- return printJson({ image, ...res.body });
80
- info(`deployed ${image} -> ${res.body.url} (branch ${res.body.branch}, group ${res.body.group})`);
179
+ return printJson({ image: source.image, ...res.body });
180
+ info(`deployed ${what} -> ${res.body.url} (branch ${res.body.branch}, group ${res.body.group})`);
81
181
  renderNextActions(res.body.nextActions);
82
182
  }
83
183
  // The platform refuses an image deploy onto a repo-connected service and names the body field it
@@ -4,7 +4,7 @@
4
4
  // server, the platform, the skills, the docs. Never for problems in the app the user is building.
5
5
  //
6
6
  // The backend is InstaCloud dogfooding itself: the "InstaCloud Agent Feedback" project runs the
7
- // ingest service (InsForge/insta-feedback repo) on a postgres + compute pair. It is NOT the
7
+ // ingest service (InsForge/instacloud-feedback repo) on a postgres + compute pair. It is NOT the
8
8
  // control-plane API on purpose — feedback must work logged-out, unlinked, and from insta-oss,
9
9
  // and a control-plane outage is exactly when we most want reports to still arrive.
10
10
  import { readFileSync, statSync } from 'node:fs';
@@ -24,7 +24,7 @@ import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
24
24
  import { readGlobal, writeGlobal } from '../config.js';
25
25
  import { resolveSpawnable } from '../spawn.js';
26
26
  import { info } from '../util.js';
27
- const INSTALL_SH = 'https://raw.githubusercontent.com/InsForge/insta-cli/main/install.sh';
27
+ const INSTALL_SH = 'https://raw.githubusercontent.com/InsForge/instacloud-cli/main/install.sh';
28
28
  // The dist-tag document is the authoritative, tiny (~50 byte) answer for `latest`. The full
29
29
  // `latest` manifest is the fallback if that route is ever unavailable.
30
30
  const REGISTRY_DIST_TAGS = 'https://registry.npmjs.org/-/package/insta/dist-tags';
@@ -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/insta-skills',
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/insta-skills#devel',
10
+ skills: 'InsForge/instacloud-skills#devel',
11
11
  },
12
12
  };
13
13
  export const DEFAULT_ENV = 'prod';
package/dist/index.js CHANGED
@@ -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 for the GitHub lane — `insta deploy <dir>` needs your own) + static checks. Local and offline — no login needed, nothing pushed. Exit 1 when the verdict is failed')
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 Fly) or a prebuilt --image to a branch compute group')
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')
@@ -0,0 +1,267 @@
1
+ import { posix } from 'node:path';
2
+ import ignore from 'ignore';
3
+ export function compileIgnore(files, flavour) {
4
+ return flavour === 'git' ? compileGit(files) : compileDocker(files);
5
+ }
6
+ // ---- git ----
7
+ const depth = (base) => (base === '' ? 0 : base.split('/').length);
8
+ // One matcher per .gitignore, each asked only about the paths beneath its own directory and
9
+ // spelled relative to it, the way git reads them. Shallower files are consulted first, so a deeper
10
+ // file's last matching rule wins, which is gitignore's precedence, and a file with no matching
11
+ // rule leaves the verdict where the previous file put it.
12
+ //
13
+ // Case-sensitive on purpose. The package defaults to ignorecase, git itself follows
14
+ // core.ignorecase, which differs between a macOS laptop and the Linux box that extracts the
15
+ // archive. One tree has to pack to one identity everywhere, so the rule is the Linux one.
16
+ function compileGit(files) {
17
+ const scoped = [...files]
18
+ .sort((a, b) => depth(a.base) - depth(b.base))
19
+ .map((f) => ({ base: f.base, ig: ignore({ ignorecase: false }).add(f.text) }));
20
+ return {
21
+ excludes(relPath, isDir) {
22
+ let excluded = false;
23
+ for (const { base, ig } of scoped) {
24
+ const sub = base === '' ? relPath : relPath.startsWith(base + '/') ? relPath.slice(base.length + 1) : '';
25
+ if (sub === '')
26
+ continue;
27
+ // A trailing slash is how the package is told the path is a directory, for `logs/` rules.
28
+ const verdict = ig.test(isDir ? sub + '/' : sub);
29
+ if (verdict.ignored)
30
+ excluded = true;
31
+ else if (verdict.unignored)
32
+ excluded = false;
33
+ }
34
+ return excluded;
35
+ },
36
+ // git cannot re-include under an excluded directory, so there is never a reason to descend.
37
+ canPrune: () => true,
38
+ };
39
+ }
40
+ const escapeLiteral = (c) => c.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
41
+ // The rest of a pattern carries no glob syntax, so docker's fast paths apply to it.
42
+ const plain = (rest) => !/[*?[\]\\]/.test(rest);
43
+ // Glob to RegExp the way moby/patternmatcher compiles one (its compile(), read line by line):
44
+ // `*` and `?` stop at a separator; a `**` is an optional run of whole directories, `(.*/)?`, and
45
+ // the slash right after it is eaten with it, wherever it stands, so `a**/b` and `foo**bar` both
46
+ // reach `ab`/`foobar` at the root and `a/x/b`/`foo/x/bar` below, and neither reaches `fooXbar`.
47
+ // Two fast paths are broader than that regex and are matched exactly: a pattern that is `**` plus
48
+ // plain text is a suffix match (`**foo` takes `xfoo`), and one ending in `**` is a prefix match.
49
+ // Reading every interior `**` as `.*` excluded `fooXbar`, a file a local docker build keeps.
50
+ function translate(p) {
51
+ let out = '';
52
+ let i = 0;
53
+ while (i < p.length) {
54
+ if (p.startsWith('**', i)) {
55
+ if (p.charAt(i + 2) === '/') {
56
+ out += '(?:.*/)?'; // any number of directories, including none; the slash goes with it
57
+ i += 3;
58
+ }
59
+ else if (i + 2 === p.length) {
60
+ // Trailing: bare `**` is everything, `foo**` a prefix match. `abc/**` is everything INSIDE
61
+ // abc and NOT abc itself: making the suffix optional matched the directory too, and a rule
62
+ // that reads as "drop this tree but keep one file" then dropped the file with it.
63
+ out += i > 0 && p.charAt(i - 1) === '/' ? '.+' : '.*';
64
+ i += 2;
65
+ }
66
+ else if (i === 0 && plain(p.slice(2))) {
67
+ out += '.*'; // docker's suffixMatch: `**foo` is "ends with foo"
68
+ i += 2;
69
+ }
70
+ else {
71
+ out += '(?:.*/)?';
72
+ i += 2;
73
+ }
74
+ }
75
+ else if (p.charAt(i) === '*') {
76
+ out += '[^/]*';
77
+ i += 1;
78
+ }
79
+ else if (p.charAt(i) === '?') {
80
+ out += '[^/]';
81
+ i += 1;
82
+ }
83
+ else if (p.charAt(i) === '[') {
84
+ const cls = bracket(p, i);
85
+ if (cls) {
86
+ out += cls.re;
87
+ i = cls.end;
88
+ }
89
+ else {
90
+ out += '\\[';
91
+ i += 1;
92
+ }
93
+ }
94
+ else if (p.charAt(i) === '\\' && i + 1 < p.length) {
95
+ // Escape: the next character is data, not a wildcard. Go's filepath.Match honours it.
96
+ out += escapeLiteral(p.charAt(i + 1));
97
+ i += 2;
98
+ }
99
+ else {
100
+ out += escapeLiteral(p.charAt(i));
101
+ i += 1;
102
+ }
103
+ }
104
+ return out;
105
+ }
106
+ // Escape members, including an explicitly escaped hyphen. bracket() preserves raw range hyphens.
107
+ const escapeInClass = (c) => ('\\][^-'.includes(c) ? '\\' + c : c);
108
+ // Go/RE2's POSIX classes are ASCII, not JavaScript's Unicode \s/\w or locale-dependent classes.
109
+ // https://pkg.go.dev/regexp/syntax#hdr-Syntax
110
+ const POSIX_CLASSES = {
111
+ alnum: '0-9A-Za-z', alpha: 'A-Za-z', ascii: '\\x00-\\x7f', blank: '\\t ',
112
+ cntrl: '\\x00-\\x1f\\x7f', digit: '0-9', graph: '\\x21-\\x7e', lower: 'a-z',
113
+ print: '\\x20-\\x7e', punct: '\\x21-\\x2f\\x3a-\\x40\\x5b-\\x60\\x7b-\\x7e',
114
+ space: '\\t\\n\\v\\f\\r ', upper: 'A-Z', word: '0-9A-Za-z_', xdigit: '0-9A-Fa-f',
115
+ };
116
+ function posixClass(name, negated) {
117
+ const body = Object.hasOwn(POSIX_CLASSES, name) ? POSIX_CLASSES[name] : undefined;
118
+ if (body === undefined)
119
+ throw new Error('unsupported POSIX class in .dockerignore: ' + name);
120
+ if (!negated)
121
+ return body;
122
+ // A complemented named class can be mixed with other members inside [...]. Expand its
123
+ // ranges instead of nesting a negated JS class, which would silently change the grammar.
124
+ const member = new RegExp('[' + body + ']', 'u');
125
+ const point = (n) => '\\u{' + n.toString(16) + '}';
126
+ const range = (a, b) => a === b ? point(a) : point(a) + '-' + point(b);
127
+ let out = '';
128
+ let start = 0;
129
+ for (let c = 0; c < 128; c++) {
130
+ if (!member.test(String.fromCodePoint(c)))
131
+ continue;
132
+ if (start < c)
133
+ out += range(start, c - 1);
134
+ start = c + 1;
135
+ }
136
+ return out + range(start, 0x10ffff);
137
+ }
138
+ // A bracket expression starting at p[start], or null when no `]` closes it and the `[` is a
139
+ // literal. docker hands the class to Go's regexp: a `]` right after the opening `[` (or after
140
+ // the `^`) is a MEMBER, not the close, `\` quotes the next character, and only `^` negates, so a
141
+ // `!` is an ordinary member. Unlike `*` and `?`, these Go regexp classes CAN match a separator.
142
+ // Verified against moby/patternmatcher compile() on 2026-09-11: it preserves bracket expressions
143
+ // without adding a separator exclusion (private[^x]token matches private/token).
144
+ function bracket(p, start) {
145
+ let j = start + 1;
146
+ let negated = false;
147
+ if (p.charAt(j) === '^') {
148
+ negated = true;
149
+ j += 1;
150
+ }
151
+ let body = '';
152
+ let first = true;
153
+ while (j < p.length) {
154
+ const c = p.charAt(j);
155
+ if (c === ']' && !first)
156
+ return { re: `[${negated ? '^' : ''}${body}]`, end: j + 1 };
157
+ first = false;
158
+ if (p.startsWith('[:', j)) {
159
+ const named = /^\[:(\^?)([a-z]+):\]/.exec(p.slice(j));
160
+ if (!named)
161
+ throw new Error('invalid POSIX class in .dockerignore');
162
+ body += posixClass(named[2], named[1] === '^');
163
+ j += named[0].length;
164
+ continue;
165
+ }
166
+ if (c === '\\' && j + 1 < p.length) {
167
+ body += escapeInClass(p.charAt(j + 1));
168
+ j += 2;
169
+ continue;
170
+ }
171
+ body += c === '-' ? c : escapeInClass(c);
172
+ j += 1;
173
+ }
174
+ return null;
175
+ }
176
+ // Wildcard-free head of a pattern; empty means it could match anywhere. Escape-aware for the same
177
+ // reason translate() is: `\*` is a literal star, so a head stopping at it would prune the wrong
178
+ // tree, and the head must be UNESCAPED because it is compared against real path text.
179
+ function literalHead(full) {
180
+ let out = '';
181
+ for (let i = 0; i < full.length; i++) {
182
+ const c = full.charAt(i);
183
+ if (c === '\\' && i + 1 < full.length) {
184
+ out += full.charAt(i + 1);
185
+ i += 1;
186
+ continue;
187
+ }
188
+ if (c === '*' || c === '?' || c === '[')
189
+ return out;
190
+ out += c;
191
+ }
192
+ return out;
193
+ }
194
+ // Docker's own preprocessing, in its order (moby/patternmatcher ReadAll): the comment test runs
195
+ // BEFORE trimming, so ` #x` is a pattern and not a comment, and every surviving pattern goes
196
+ // through filepath.Clean. Clean is the part that matters most here: it resolves `foo/../secrets`
197
+ // to `secrets` and DROPS a trailing slash, so `secrets/` excludes a file named `secrets` too.
198
+ // Treating that slash as directory-only, the way git does, under-excludes exactly the shape a
199
+ // user writes when they mean "keep this out".
200
+ function cleanDockerPattern(pat) {
201
+ const normalized = posix.normalize(pat);
202
+ const cut = normalized.length > 1 ? normalized.replace(/\/+$/, '') : normalized;
203
+ return cut === '' ? '.' : cut;
204
+ }
205
+ function compileDocker(files) {
206
+ const rules = [];
207
+ for (const f of files) {
208
+ // A UTF-8 BOM belongs to the FILE, not to its first pattern: without stripping it a
209
+ // BOM-prefixed `secrets.env` silently matches nothing.
210
+ const text = f.text.charCodeAt(0) === 0xfeff ? f.text.slice(1) : f.text;
211
+ for (const raw of text.split('\n')) {
212
+ const noEol = raw.replace(/\r+$/, '');
213
+ // Comment test first, untrimmed, then trim: docker's order, not ours.
214
+ if (noEol.startsWith('#'))
215
+ continue;
216
+ let pat = noEol.trim();
217
+ if (!pat)
218
+ continue;
219
+ const negated = pat.startsWith('!');
220
+ if (negated)
221
+ pat = pat.slice(1).trim();
222
+ if (!pat)
223
+ continue;
224
+ pat = cleanDockerPattern(pat);
225
+ if (pat === '.')
226
+ continue;
227
+ // Every pattern is anchored to the context root, slash or not.
228
+ if (pat.startsWith('/'))
229
+ pat = pat.slice(1);
230
+ if (!pat)
231
+ continue;
232
+ // The base is a real DIRECTORY NAME, not pattern syntax, so it is escaped as a literal and
233
+ // joined at the regex level, and prefixed to the prune head the same way, since that head
234
+ // is compared against real path text.
235
+ const prefix = f.base ? escapeLiteral(f.base) + '/' : '';
236
+ const head = f.base ? `${f.base}/${literalHead(pat)}` : literalHead(pat);
237
+ // Go matches runes: without Unicode mode, ? consumes half of a non-BMP filename,
238
+ // letting files the user excluded into the uploaded archive.
239
+ rules.push({ re: new RegExp('^' + prefix + translate(pat) + '$', 'u'), negated, literal: head });
240
+ }
241
+ }
242
+ // A rule matching an ancestor excludes the path too: excluding a dir excludes its contents.
243
+ const hits = (r, path) => {
244
+ if (r.re.test(path))
245
+ return true;
246
+ const parts = path.split('/');
247
+ for (let i = 1; i < parts.length; i++)
248
+ if (r.re.test(parts.slice(0, i).join('/')))
249
+ return true;
250
+ return false;
251
+ };
252
+ const negations = rules.filter((r) => r.negated);
253
+ return {
254
+ excludes(relPath) {
255
+ let excluded = false;
256
+ for (const r of rules)
257
+ if (hits(r, relPath))
258
+ excluded = !r.negated; // last match wins
259
+ return excluded;
260
+ },
261
+ // docker can re-include under an excluded directory, so prune only where no negation reaches.
262
+ canPrune(dirPath) {
263
+ return !negations.some((r) => r.literal === '' || r.literal.startsWith(dirPath + '/') || dirPath.startsWith(r.literal));
264
+ },
265
+ };
266
+ }
267
+ //# sourceMappingURL=pack-ignore.js.map
package/dist/pack.js ADDED
@@ -0,0 +1,229 @@
1
+ import { readdirSync, readFileSync, lstatSync, readlinkSync, existsSync, openSync, closeSync, fstatSync, constants } from 'node:fs';
2
+ import { join, sep } from 'node:path';
3
+ import { createHash } from 'node:crypto';
4
+ // Not node:zlib. The digest of this archive IS its identity: the id the object is stored under,
5
+ // the dedup key, and part of the approval-bound deploy body. The runtime's zlib is native and its
6
+ // output differs between the runtimes this CLI ships on -- the same tree packs to 360 bytes under
7
+ // Node 25 and 353 under Bun -- so with it the identity of a tree changed with the install channel.
8
+ // fflate is pure JS: same algorithm, same bytes, everywhere.
9
+ //
10
+ // PINNED EXACTLY in package.json, not caret-ranged, and that is load-bearing rather than tidy.
11
+ // A compiled binary bundles whatever the lockfile resolved, while `npx insta` resolves the range
12
+ // afresh against the registry. A patch release is free to emit different valid gzip for the same
13
+ // input, so a caret would let the two channels produce different digests for one tree -- exactly
14
+ // the property this dependency was taken on to guarantee.
15
+ import { gzipSync } from 'fflate';
16
+ import { compileIgnore } from './pack-ignore.js';
17
+ export const ARCHIVE_LIMITS = {
18
+ maxArchiveBytes: 256 * 1024 * 1024,
19
+ maxExtractedBytes: 1024 * 1024 * 1024,
20
+ maxFiles: 10000,
21
+ };
22
+ const BLOCK = 512;
23
+ const PAD = Buffer.alloc(BLOCK, 0);
24
+ // Only the exec bit matters: normalising to 0644 breaks entrypoints, raw mode leaks the umask.
25
+ const fileMode = (mode) => (mode & 0o111 ? 0o755 : 0o644);
26
+ const octal = (n, width) => n.toString(8).padStart(width - 1, '0') + '\0';
27
+ // ustar splits a long path across prefix(155) + name(100); refuse by name rather than truncate.
28
+ function splitName(path) {
29
+ if (Buffer.byteLength(path) <= 100)
30
+ return { name: path, prefix: '' };
31
+ for (let i = path.indexOf('/'); i !== -1; i = path.indexOf('/', i + 1)) {
32
+ const prefix = path.slice(0, i);
33
+ const name = path.slice(i + 1);
34
+ if (Buffer.byteLength(prefix) <= 155 && Buffer.byteLength(name) <= 100)
35
+ return { name, prefix };
36
+ }
37
+ throw new Error(`path too long for a tar archive: ${path}`);
38
+ }
39
+ function header(path, mode, size, type) {
40
+ const h = Buffer.alloc(BLOCK, 0);
41
+ const { name, prefix } = splitName(path);
42
+ h.write(name, 0, 100, 'utf8');
43
+ h.write(octal(mode, 8), 100, 8, 'ascii');
44
+ h.write(octal(0, 8), 108, 8, 'ascii'); // uid, pinned
45
+ h.write(octal(0, 8), 116, 8, 'ascii'); // gid, pinned
46
+ h.write(octal(size, 12), 124, 12, 'ascii');
47
+ h.write(octal(0, 12), 136, 12, 'ascii'); // mtime, pinned
48
+ h.write(' ', 148, 8, 'ascii'); // checksum is summed as spaces, then overwritten
49
+ h.write(type, 156, 1, 'ascii');
50
+ h.write('ustar\0', 257, 6, 'ascii');
51
+ h.write('00', 263, 2, 'ascii');
52
+ h.write(prefix, 345, 155, 'utf8');
53
+ let sum = 0;
54
+ for (const b of h)
55
+ sum += b;
56
+ h.write(octal(sum, 7) + ' ', 148, 8, 'ascii');
57
+ return h;
58
+ }
59
+ // The builder fails the whole build on a symlink entry; hardlinks are fine, we only write type '0'.
60
+ function linkError(links) {
61
+ const shown = links.slice(0, 5).map((l) => ` ${l.path} -> ${l.target}`);
62
+ const more = links.length > shown.length ? [` … and ${links.length - shown.length} more`] : [];
63
+ return new Error([
64
+ 'a deploy archive cannot contain symlinks — the build gateway rejects them:',
65
+ ...shown,
66
+ ...more,
67
+ 'replace them with real files, or exclude them (.dockerignore, or .gitignore when there is no .dockerignore)',
68
+ ].join('\n'));
69
+ }
70
+ // .git blows the 10k entry cap on its own; .insta is CLI state. A deliberate departure from docker.
71
+ const ALWAYS_SKIP = new Set(['.git', '.insta']);
72
+ // docker keeps these whatever the ignore file says; avoids a remote-only "Dockerfile not found".
73
+ const KEPT_AT_ROOT = new Set(['Dockerfile', '.dockerignore']);
74
+ // One global sort emits a parent before its children, since a dir name prefixes everything inside.
75
+ function walk(root, rel, out, links, ig, files, flavour) {
76
+ const dirAbs = join(root, rel === '' ? '.' : rel.split('/').join(sep));
77
+ const names = readdirSync(dirAbs).sort();
78
+ // A nested .gitignore extends its own subtree; recompiled only where one exists. docker has none.
79
+ if (flavour === 'git' && rel !== '' && names.includes('.gitignore')) {
80
+ files = [...files, { base: rel, text: readFileSync(join(dirAbs, '.gitignore'), 'utf8') }];
81
+ ig = compileIgnore(files, 'git');
82
+ }
83
+ for (const name of names) {
84
+ if (ALWAYS_SKIP.has(name))
85
+ continue;
86
+ const relPath = rel === '' ? name : `${rel}/${name}`;
87
+ const keep = rel === '' && KEPT_AT_ROOT.has(name);
88
+ const abs = join(root, relPath.split('/').join(sep));
89
+ const st = lstatSync(abs);
90
+ if (st.isSymbolicLink()) {
91
+ // An ignored symlink is not the user's problem to solve.
92
+ if (!keep && ig.excludes(relPath, false))
93
+ continue;
94
+ links.push({ path: relPath, target: readlinkSync(abs) });
95
+ }
96
+ else if (st.isDirectory()) {
97
+ if (ig.excludes(relPath, true) && ig.canPrune(relPath))
98
+ continue;
99
+ // Emitted whenever we descend, so a re-included child has its parent.
100
+ out.push({ path: `${relPath}/`, mode: 0o755, size: 0, dir: true });
101
+ walk(root, relPath, out, links, ig, files, flavour);
102
+ }
103
+ else if (st.isFile()) {
104
+ if (!keep && ig.excludes(relPath, false))
105
+ continue;
106
+ out.push({ path: relPath, mode: fileMode(st.mode), size: st.size, dir: false, ino: st.ino, dev: st.dev });
107
+ }
108
+ }
109
+ }
110
+ // The walk classifies with lstat and the read happens later, so a plain readFileSync would
111
+ // FOLLOW a symlink that replaced the file in between and put a file from outside the directory
112
+ // into an archive that promises none. Two guards, and neither is a full one on its own:
113
+ //
114
+ // O_NOFOLLOW refuses when the final component is a symlink AT OPEN TIME, closing the swap the
115
+ // walk cannot see. Undefined on Windows, where it degrades to the check below.
116
+ //
117
+ // fstat on the OPEN HANDLE must still describe the file the walk measured: same inode, same
118
+ // device, same size. Its job is the tar's own consistency -- a file rewritten to a different
119
+ // length mid-pack would otherwise produce a header whose count disagrees with its payload.
120
+ //
121
+ // Two things neither closes, and both are stated rather than implied away:
122
+ //
123
+ // An ANCESTOR directory swapped for a symlink. Node exposes no openat, so resolving each
124
+ // component against a directory handle is not available here.
125
+ //
126
+ // A same-size plain file deleted and recreated. Measured on linux rather than assumed: the
127
+ // inode is REUSED and mtimeNs/ctimeNs are byte-identical for a delete+create inside one
128
+ // timestamp tick, so no stat-based identity can see it. It is also the least interesting case
129
+ // -- the symlink promise still holds, the tar stays well formed because the length did not
130
+ // move, and the archive simply carries a slightly newer copy of a file the caller owns.
131
+ //
132
+ // The residual on both is narrow: someone able to rewrite files and directories inside the tree
133
+ // being packed can already put any bytes they like into it by writing them.
134
+ export function readEntry(abs, e) {
135
+ const noFollow = constants.O_NOFOLLOW ?? 0;
136
+ let fd;
137
+ try {
138
+ fd = openSync(abs, constants.O_RDONLY | noFollow);
139
+ }
140
+ catch (err) {
141
+ if (err.code === 'ELOOP') {
142
+ throw new Error(`${e.path} became a symlink while packing — re-run the deploy`);
143
+ }
144
+ throw err;
145
+ }
146
+ try {
147
+ const st = fstatSync(fd);
148
+ const same = st.isFile() && st.size === e.size
149
+ && (e.ino === undefined || st.ino === e.ino) && (e.dev === undefined || st.dev === e.dev);
150
+ if (!same)
151
+ throw new Error(`${e.path} changed while packing — re-run the deploy`);
152
+ return readFileSync(fd);
153
+ }
154
+ finally {
155
+ closeSync(fd);
156
+ }
157
+ }
158
+ // A root .dockerignore wins outright; merging would drop artefacts the image needs.
159
+ function rootIgnore(absDir) {
160
+ const read = (name) => readFileSync(join(absDir, name), 'utf8');
161
+ if (existsSync(join(absDir, '.dockerignore'))) {
162
+ const files = [{ base: '', text: read('.dockerignore') }];
163
+ return { ig: compileIgnore(files, 'docker'), files, flavour: 'docker' };
164
+ }
165
+ const files = existsSync(join(absDir, '.gitignore')) ? [{ base: '', text: read('.gitignore') }] : [];
166
+ return { ig: compileIgnore(files, 'git'), files, flavour: 'git' };
167
+ }
168
+ const mib = (n) => `${(n / (1024 * 1024)).toFixed(1)} MiB`;
169
+ // Windows has no POSIX exec bit for lstat to report, so a script packs as 0644 and the image
170
+ // cannot run it. Same as `docker build` from Windows; say so rather than let it fail at start-up.
171
+ export function windowsModeCaveat(platform = process.platform) {
172
+ if (platform !== 'win32')
173
+ return null;
174
+ return 'packing on Windows: file permissions are not preserved, so an executable script arrives as 0644 — add `RUN chmod +x <path>` to your Dockerfile if the image runs one';
175
+ }
176
+ export function packDirectory(absDir, limits = {}) {
177
+ const cap = { ...ARCHIVE_LIMITS, ...limits };
178
+ const found = [];
179
+ const links = [];
180
+ const { ig, files, flavour } = rootIgnore(absDir);
181
+ walk(absDir, '', found, links, ig, files, flavour);
182
+ if (links.length)
183
+ throw linkError(links);
184
+ found.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
185
+ // Known from the walk alone, so both fail before a byte is read or compressed.
186
+ const extractedBytes = found.reduce((n, e) => n + e.size, 0);
187
+ if (found.length > cap.maxFiles) {
188
+ throw new Error(`archive has too many files: ${found.length} > ${cap.maxFiles} (directories count) — exclude what the build does not need`);
189
+ }
190
+ if (extractedBytes > cap.maxExtractedBytes) {
191
+ throw new Error(`archive would extract to ${mib(extractedBytes)}, over the ${mib(cap.maxExtractedBytes)} limit — exclude what the build does not need`);
192
+ }
193
+ const chunks = [];
194
+ for (const e of found) {
195
+ if (e.dir) {
196
+ chunks.push(header(e.path, e.mode, 0, '5'));
197
+ continue;
198
+ }
199
+ const data = readEntry(join(absDir, e.path.split('/').join(sep)), e);
200
+ chunks.push(header(e.path, e.mode, data.length, '0'), data);
201
+ const rem = data.length % BLOCK;
202
+ if (rem)
203
+ chunks.push(PAD.subarray(0, BLOCK - rem));
204
+ }
205
+ chunks.push(PAD, PAD); // two zero blocks close a tar
206
+ const tar = Buffer.concat(chunks);
207
+ // Drop the per-file buffers before the compressor allocates: concat has copied every byte, so
208
+ // holding the originals through gzip is a third full copy of the tree for nothing. This does
209
+ // not make the packer streaming -- the peak is still two copies plus the compressor's own
210
+ // working set -- but it is the part that costs nothing to give back.
211
+ chunks.length = 0;
212
+ const archive = Buffer.from(gzipSync(tar, { level: 9, mtime: 0 }));
213
+ // Pinned here as well as asked of the library: gzip carries its own mtime (4-7) and OS byte (9),
214
+ // and a header the packer writes itself cannot drift with a dependency's defaults.
215
+ archive.writeUInt32LE(0, 4);
216
+ archive[9] = 255;
217
+ if (archive.length > cap.maxArchiveBytes) {
218
+ throw new Error(`archive is too large: ${mib(archive.length)} > ${mib(cap.maxArchiveBytes)} — exclude what the build does not need`);
219
+ }
220
+ return {
221
+ archive,
222
+ sha256: createHash('sha256').update(archive).digest('hex'),
223
+ files: found.filter((e) => !e.dir).length,
224
+ entries: found.length,
225
+ extractedBytes,
226
+ hasDockerfile: found.some((e) => e.path === 'Dockerfile'),
227
+ };
228
+ }
229
+ //# sourceMappingURL=pack.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "insta",
3
- "version": "0.0.68",
3
+ "version": "0.0.69",
4
4
  "type": "module",
5
5
  "description": "InstaCloud CLI — a thin client of the platform control-plane API.",
6
6
  "keywords": [
@@ -12,13 +12,13 @@
12
12
  "platform"
13
13
  ],
14
14
  "license": "Apache-2.0",
15
- "homepage": "https://github.com/InsForge/insta-cli#readme",
15
+ "homepage": "https://github.com/InsForge/instacloud-cli#readme",
16
16
  "repository": {
17
17
  "type": "git",
18
- "url": "git+https://github.com/InsForge/insta-cli.git"
18
+ "url": "git+https://github.com/InsForge/instacloud-cli.git"
19
19
  },
20
20
  "bugs": {
21
- "url": "https://github.com/InsForge/insta-cli/issues"
21
+ "url": "https://github.com/InsForge/instacloud-cli/issues"
22
22
  },
23
23
  "bin": {
24
24
  "insta": "dist/index.js"
@@ -44,6 +44,8 @@
44
44
  "dependencies": {
45
45
  "@clack/prompts": "^0.9.1",
46
46
  "commander": "^12.1.0",
47
+ "fflate": "0.8.3",
48
+ "ignore": "7.0.9",
47
49
  "yaml": "^2.9.0"
48
50
  },
49
51
  "devDependencies": {