insta 0.1.13 → 0.1.15

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.
@@ -147,12 +147,9 @@ export async function deploy(dir, opts) {
147
147
  const p = await requireProject();
148
148
  const branch = opts.branch ?? p.branch;
149
149
  const log = note(opts);
150
- // Junk fails here, before a directory is packed and uploaded for a body the platform would only
151
- // refuse: the same parser every other --port in this CLI runs. `Number()` alone sent NaN as
152
- // `null` and let 0 or 70000 travel to the server.
153
150
  let port;
154
151
  try {
155
- port = opts.port === undefined ? undefined : parsePort(opts.port);
152
+ port = opts.port === undefined ? undefined : parsePort(opts.port, { allowZero: true });
156
153
  }
157
154
  catch (e) {
158
155
  die(`--${e.message}`);
@@ -47,14 +47,13 @@ export function parseCount(raw) {
47
47
  }
48
48
  return n;
49
49
  }
50
- // Parse a TCP port. Junk fails here rather than reaching the API as NaN (the parseCpu lesson).
51
- // Decimal digits only, as parseVolumeGib: `Number()` alone would quietly read 0x1f90 as 8080 and
52
- // 1e3 as 1000, and a port written in hex is a typo worth reporting, not one worth honouring.
53
- export function parsePort(raw) {
50
+ // Keep TCP-only callers strict; port 0 is reserved for explicitly opted-in compute workers.
51
+ export function parsePort(raw, opts = {}) {
52
+ const min = opts.allowZero ? 0 : 1;
54
53
  const m = /^\s*(\d+)\s*$/.exec(raw);
55
54
  const n = m ? Number(m[1]) : NaN;
56
- if (!Number.isInteger(n) || n < 1 || n > 65535)
57
- throw new Error(`port must be an integer between 1 and 65535, got: ${raw}`);
55
+ if (!Number.isInteger(n) || n < min || n > 65535)
56
+ throw new Error(`port must be an integer between ${min} and 65535, got: ${raw}`);
58
57
  return n;
59
58
  }
60
59
  // Parse a volume size in whole Gi: "10" or "10Gi" (suffix case-insensitive — unlike the db
@@ -112,10 +111,11 @@ function defaultDatabasePort(type) {
112
111
  export function servicesAddRequestBody(type, name, branch, opts) {
113
112
  return {
114
113
  type, name, ...(branch ? { branch } : {}), public: !!opts.public,
115
- ...(opts.image ? { image: opts.image } : {}), ...(opts.port ? { port: parsePort(opts.port) } : {}),
114
+ ...(opts.image ? { image: opts.image } : {}), ...(opts.port !== undefined ? { port: parsePort(opts.port, { allowZero: type === 'compute' }) } : {}),
116
115
  ...(opts.region ? { region: opts.region } : {}),
117
- // Sent whenever the flag was given, false included: compute is born always-on by default,
118
- // so `--no-always-on` must reach the API as an explicit false. Omitted means the platform default.
116
+ // Sent whenever the flag was given, false included: the birth default is platform config
117
+ // (INSTA_COMPUTE_ALWAYS_ON_DEFAULT, scale-to-zero unless set), so an explicit --no-always-on
118
+ // must reach the API as false to hold whatever that config says. Omitted means the platform default.
119
119
  ...(opts.alwaysOn !== undefined ? { alwaysOn: opts.alwaysOn } : {}),
120
120
  ...(opts.volume !== undefined ? { volumeGib: parseVolumeGib(opts.volume) } : {}),
121
121
  ...(opts.mountPath !== undefined ? { volumeMountPath: opts.mountPath } : {}),
@@ -130,10 +130,10 @@ export async function servicesAdd(type, name, opts = {}) {
130
130
  throw new Error('--region is not valid for storage services');
131
131
  if (opts.image && type !== 'compute')
132
132
  throw new Error('--image is only valid for compute services');
133
- if (opts.port) {
133
+ if (opts.port !== undefined) {
134
134
  if (type !== 'compute')
135
135
  throw new Error('--port is only valid for compute services');
136
- parsePort(opts.port); // junk fails here, before any config/network access
136
+ parsePort(opts.port, { allowZero: true });
137
137
  }
138
138
  // Presence, not truthiness: `--no-always-on` is an explicit false and is just as compute-only.
139
139
  if (opts.alwaysOn !== undefined && type !== 'compute')
@@ -121,7 +121,7 @@ export async function deployArchive(api, projectId, ref, branch, opts, now = Dat
121
121
  if (state === 'live') {
122
122
  const image = res.body?.imageRef;
123
123
  const url = res.body?.url;
124
- if (typeof image !== 'string' || !image || typeof url !== 'string' || !url) {
124
+ if (typeof image !== 'string' || !image || typeof url !== 'string' || (!url && opts.port !== '0')) {
125
125
  throw new Error('the deploy finished but the platform returned no image or URL for it — check `insta status`');
126
126
  }
127
127
  const optionalString = (field, v) => {
package/dist/index.js CHANGED
@@ -153,9 +153,9 @@ svc.command('add [type] [name]').description('Provision a service on demand (com
153
153
  .option('--region <region>', 'region for postgres/compute/managed databases, e.g. us-east (see `insta config regions`)')
154
154
  .option('--public', 'storage only: serve the bucket with anonymous public-read (default private)')
155
155
  .option('--image <url>', 'compute only: run this container image at creation')
156
- .option('--port <n>', 'compute only: port the image listens on (default 8080)')
157
- .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)')
158
- .option('--no-always-on', 'compute only: create as scale-to-zero — idle machines suspend and wake on the next request')
156
+ .option('--port <n>', 'compute only: port the image listens on (default 8080; 0 for a worker without a routed port)')
157
+ .option('--always-on', 'compute only: create as always-on — never scales to zero (all plans; billing is actual usage either way). A worker (--port 0) is always-on regardless')
158
+ .option('--no-always-on', 'compute only: create as scale-to-zero — idle machines suspend and wake on the next request (the default for new compute services; refused with --port 0, a worker cannot scale to zero)')
159
159
  .option('--mount-path <path>', 'compute only: container mount path for a new volume (requires --volume; default /data)')
160
160
  .option('--volume <gi>', 'compute only: attach a persistent 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')
161
161
  .option('--pg-version <major>', 'postgres only: the Postgres major version to create the database on — 16, 17 or 18 (default 16). Fixed for the database\'s lifetime; branches keep it')
@@ -379,7 +379,7 @@ compute.command('scale [count] [service]').description('Set a compute service sa
379
379
  compute.command('limits [service]').description("Show or set a compute service's resource ceiling (any plan within the free cap; raising above it needs a paid plan). --memory is the dial; cpu derives from it unless --cpu is given. Billing is actual usage — the ceiling caps what the app may burn, it is not a price")
380
380
  .option('--memory <size>', 'memory ceiling, e.g. 512mb or 1gb').option('--cpu <n>', 'vCPU ceiling override (provider sizes: 1, 2, 4, 6, 8)')
381
381
  .option('--json').option('--branch <branch>', 'branch (default: current)').action(guard((service, o) => computeCmd.computeLimits(service, o)));
382
- compute.command('always-on <mode> [service]').description('Set a compute service always-on (mode: on|off). on = machines never scale to zero (the default for new compute services); off = scale-to-zero. All plans; billing is actual usage either way')
382
+ compute.command('always-on <mode> [service]').description('Set a compute service always-on (mode: on|off). on = machines never scale to zero; off = scale-to-zero (the default for new compute services; refused on a worker, port 0). All plans; billing is actual usage either way')
383
383
  .option('--json').option('--branch <branch>', 'branch (default: current)').action(guard((mode, service, o) => computeCmd.computeAlwaysOn(mode, service, o)));
384
384
  compute.command('ssh [service]')
385
385
  .description("Issue a short-lived SSH certificate for a compute service and print the command that uses it -- this command does NOT open the session itself, it makes `ssh` work. `--setup` does the one-time work: it generates a dedicated key under ~/.insta/ssh (your existing keys are never touched), has the platform sign a SHORT-LIVED certificate for it, adds one @cert-authority line to ~/.ssh/known_hosts so every region is trusted without per-node fingerprint prompts, and writes an ssh_config block AT THE TOP of ~/.ssh/config giving each compute service the alias `<service>.insta`. After that it is plain `ssh api.insta`, scp and -L: the block renews that alias's certificate for you while OpenSSH parses the config. Needs an interactive login -- API keys are refused; use `insta compute exec` for one-shot commands from CI")
@@ -515,7 +515,7 @@ buildCmd.command('logs <build-id>').description('Read source-build output for a
515
515
  .action(guard((id, opts) => buildLogs(id, opts)));
516
516
  // ---- deploy ----
517
517
  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')
518
- .option('--image <url>', 'prebuilt container image to deploy (instead of a source dir)').option('--branch <b>').option('--group <g>').option('--port <p>')
518
+ .option('--image <url>', 'prebuilt container image to deploy (instead of a source dir)').option('--branch <b>').option('--group <g>').option('--port <p>', 'port the app listens on (0 for a worker without a routed port)')
519
519
  .option('--websocket', 'run a WebSocket app (larger guest + connection-based concurrency)')
520
520
  .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')
521
521
  .option('--json', 'print the deploy result as JSON (build progress goes to stderr)')
package/dist/pack.js CHANGED
@@ -109,31 +109,8 @@ function walk(root, rel, out, links, ig, files, flavour, cap = Infinity) {
109
109
  }
110
110
  }
111
111
  }
112
- // The walk classifies with lstat and the read happens later, so a plain readFileSync would
113
- // FOLLOW a symlink that replaced the file in between and put a file from outside the directory
114
- // into an archive that promises none. Two guards, and neither is a full one on its own:
115
- //
116
- // O_NOFOLLOW refuses when the final component is a symlink AT OPEN TIME, closing the swap the
117
- // walk cannot see. Undefined on Windows, where it degrades to the check below.
118
- //
119
- // fstat on the OPEN HANDLE must still describe the file the walk measured: same inode, same
120
- // device, same size. Its job is the tar's own consistency -- a file rewritten to a different
121
- // length mid-pack would otherwise produce a header whose count disagrees with its payload.
122
- //
123
- // Two things neither closes, and both are stated rather than implied away:
124
- //
125
- // An ANCESTOR directory swapped for a symlink. Node exposes no openat, so resolving each
126
- // component against a directory handle is not available here.
127
- //
128
- // A same-size plain file deleted and recreated. Measured on linux rather than assumed: the
129
- // inode is REUSED and mtimeNs/ctimeNs are byte-identical for a delete+create inside one
130
- // timestamp tick, so no stat-based identity can see it. It is also the least interesting case
131
- // -- the symlink promise still holds, the tar stays well formed because the length did not
132
- // move, and the archive simply carries a slightly newer copy of a file the caller owns.
133
- //
134
- // The residual on both is narrow: someone able to rewrite files and directories inside the tree
135
- // being packed can already put any bytes they like into it by writing them.
136
- export function readEntry(abs, e) {
112
+ // O_NOFOLLOW closes final-component symlink swaps only on platforms that expose it.
113
+ export function readEntry(abs, e, platform = process.platform) {
137
114
  const noFollow = constants.O_NOFOLLOW ?? 0;
138
115
  let fd;
139
116
  try {
@@ -147,8 +124,10 @@ export function readEntry(abs, e) {
147
124
  }
148
125
  try {
149
126
  const st = fstatSync(fd);
127
+ // Some Windows libuv versions omit lstat's device ID; inode and size must still match.
128
+ const missingDevice = platform === 'win32' && (e.dev === 0 || e.dev === 0n);
150
129
  const same = st.isFile() && st.size === e.size
151
- && (e.ino === undefined || st.ino === e.ino) && (e.dev === undefined || st.dev === e.dev);
130
+ && (e.ino === undefined || st.ino === e.ino) && (e.dev === undefined || missingDevice || st.dev === e.dev);
152
131
  if (!same)
153
132
  throw new Error(`${e.path} changed while packing — re-run the deploy`);
154
133
  return readFileSync(fd);
@@ -70,7 +70,7 @@ export async function resolveServiceArgs(type, name, deps, given = {}) {
70
70
  throw new Error(missingArgsMessage(type));
71
71
  // A bad --port is a typo in the command, not an answer: fail before asking anything.
72
72
  if (given.port !== undefined)
73
- parsePort(given.port);
73
+ parsePort(given.port, { allowZero: !type || type === 'compute' });
74
74
  const kind = type
75
75
  ? SERVICE_KINDS.find((k) => k.type === type && !k.needsImage)
76
76
  : await deps.selectKind(SERVICE_KINDS);
@@ -133,12 +133,12 @@ export async function promptServiceName(kind, suggested) {
133
133
  }
134
134
  export async function promptPort(fallback) {
135
135
  const answer = await clack.text({
136
- message: 'Port the image listens on:',
136
+ message: 'Port the image listens on (0 for a worker):',
137
137
  initialValue: fallback,
138
138
  // The rule the command enforces, so the prompt and a --port can never disagree.
139
139
  validate: (v) => {
140
140
  try {
141
- parsePort(v.trim());
141
+ parsePort(v.trim(), { allowZero: true });
142
142
  return undefined;
143
143
  }
144
144
  catch (e) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "insta",
3
- "version": "0.1.13",
3
+ "version": "0.1.15",
4
4
  "type": "module",
5
5
  "description": "InstaCloud CLI — a thin client of the platform control-plane API.",
6
6
  "keywords": [