insta 0.0.83 → 0.1.0

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.
@@ -1,6 +1,6 @@
1
1
  import { ApiClient, ApiError, requireProject } from '../api.js';
2
2
  import { info, printJson, handleApproval, relayExitCode, writeFileAtomicSync, resolveThroughSymlink } from '../util.js';
3
- import { resolveComputeServiceId, resolveSoleService, q, parseVolumeGib } from './services.js';
3
+ import { resolveComputeServiceId, resolveSoleService, q, parseVolumeGib, parseCount } from './services.js';
4
4
  export const isWorker = (s) => s.port === 0;
5
5
  // One line per compute service for the disambiguation error: name, region, default URL, status —
6
6
  // enough to pick one without a second command. Pure, exported for tests.
@@ -25,7 +25,7 @@ export function resolveDomainTarget(services, host, group) {
25
25
  return svc;
26
26
  }
27
27
  if (compute.length === 0)
28
- throw new Error('no compute service in this project (add one with `insta services add compute <name>`)');
28
+ throw new Error('no compute service in this project (add one with `insta service add compute <name>`)');
29
29
  if (compute.length === 1) {
30
30
  const only = compute[0];
31
31
  if (isWorker(only))
@@ -43,7 +43,7 @@ const pad = (s, n) => s.padEnd(n);
43
43
  // An older platform may omit `dns` entirely; every reader treats that as "no records", not a crash.
44
44
  const recordsOf = (r) => r.dns ?? [];
45
45
  const flags = (c = {}) => `${c.group ? ` --group ${c.group}` : ''}${c.branch ? ` --branch ${c.branch}` : ''}`;
46
- // After set-domain: exactly what to do next, from the records the platform returned — never a
46
+ // After attach: exactly what to do next, from the records the platform returned — never a
47
47
  // hand-built template. No records = say so; a template here would send the customer publishing
48
48
  // values the plane never issued. Pure, exported for tests.
49
49
  export function domainGuidanceLines(r, ctx = {}) {
@@ -58,7 +58,7 @@ export function domainGuidanceLines(r, ctx = {}) {
58
58
  out.push('add these DNS records at your DNS provider:');
59
59
  for (const d of records)
60
60
  out.push(` ${pad(d.type, 6)} ${pad(d.name, nameW)} -> ${d.value}`);
61
- out.push(`then: insta compute check-domain ${r.hostname}${flags(ctx)}`);
61
+ out.push(`then: insta domain check ${r.hostname}${flags(ctx)}`);
62
62
  return out;
63
63
  }
64
64
  // Whether this provider reports an edge routing target AT ALL. The compute plane's domain view
@@ -96,10 +96,10 @@ export function domainResolveLine(r) {
96
96
  }
97
97
  return { line: ` ${pad('resolves to', 12)}${r.origin} (${region} router) ok`, ready: true };
98
98
  }
99
- // check-domain: every stage, what each still needs, and where it routes. Pure, exported for tests.
99
+ // check: every stage, what each still needs, and where it routes. Pure, exported for tests.
100
100
  export function domainStatusLines(r, ctx = {}) {
101
101
  if (r.status === 'not added') {
102
- return [`${r.hostname} is not attached to ${targetOf(r)} — attach it with: insta compute set-domain ${r.hostname}${flags(ctx)}`];
102
+ return [`${r.hostname} is not attached to ${targetOf(r)} — attach it with: insta domain attach ${r.hostname}${flags(ctx)}`];
103
103
  }
104
104
  const records = recordsOf(r);
105
105
  const out = [`${r.hostname} -> ${targetOf(r)}`];
@@ -131,7 +131,7 @@ export function domainStatusLines(r, ctx = {}) {
131
131
  blockers.push('add the ownership TXT');
132
132
  }
133
133
  else {
134
- stage('ownership', 'unchecked', `TXT ${txt.name} -> ${txt.value} (the plane has not checked it yet — re-run check-domain)`);
134
+ stage('ownership', 'unchecked', `TXT ${txt.name} -> ${txt.value} (the plane has not checked it yet — re-run insta domain check)`);
135
135
  blockers.push('ownership unchecked');
136
136
  }
137
137
  }
@@ -169,7 +169,7 @@ export function domainStatusLines(r, ctx = {}) {
169
169
  blockers.push(`add the ${d.type}`);
170
170
  }
171
171
  else {
172
- stage(lbl, 'unchecked', `${d.type} ${d.name} -> ${d.value} (not checked yet — re-run check-domain)`);
172
+ stage(lbl, 'unchecked', `${d.type} ${d.name} -> ${d.value} (not checked yet — re-run insta domain check)`);
173
173
  blockers.push(`${d.type} unchecked`);
174
174
  }
175
175
  }
@@ -192,7 +192,7 @@ export function domainStatusLines(r, ctx = {}) {
192
192
  blockers.push(`add the ${d.type} ${d.name}`);
193
193
  }
194
194
  else {
195
- stage(lbl, 'unchecked', `${where} (not checked yet — re-run check-domain)`);
195
+ stage(lbl, 'unchecked', `${where} (not checked yet — re-run insta domain check)`);
196
196
  blockers.push(`${d.type} ${d.name} unchecked`);
197
197
  }
198
198
  }
@@ -237,7 +237,7 @@ export function domainStatusLines(r, ctx = {}) {
237
237
  }
238
238
  // The platform's 409: the hostname is already bound elsewhere. Domains are never MOVED — the only
239
239
  // path is unbind there, then bind here — so the hint names the release step. Three shapes:
240
- // owner named and present in this project → the exact remove-domain command;
240
+ // owner named and present in this project → the exact detach command;
241
241
  // owner named but NOT in this project's services → it is held by a deleted (or other-project)
242
242
  // service: an operator must release it (the plane has no self-serve orphan release yet);
243
243
  // owner not named (today's plane) → the generic release instruction.
@@ -248,11 +248,11 @@ export function domainConflictMessage(host, e, services, ctx = {}) {
248
248
  const region = m?.[2];
249
249
  // The release command must name the OWNER's group, and the branch the user is working on — a
250
250
  // command that defaults back to the linked branch would release nothing.
251
- const release = (group) => `insta compute remove-domain ${host}${flags({ group, branch: ctx.branch })}`;
251
+ const release = (group) => `insta domain detach ${host}${flags({ group, branch: ctx.branch })}`;
252
252
  if (owner) {
253
253
  const here = services.find((s) => s.type === 'compute' && s.name === owner);
254
254
  if (here)
255
- return `${host} is already attached to ${owner}${region ? ` (${region})` : here.region ? ` (${here.region})` : ''} — domains are not moved; release it first: ${release(owner)}, then re-run set-domain`;
255
+ return `${host} is already attached to ${owner}${region ? ` (${region})` : here.region ? ` (${here.region})` : ''} — domains are not moved; release it first: ${release(owner)}, then re-run insta domain attach`;
256
256
  return `${host} is already attached to ${owner}${region ? ` in ${region}` : ''}, which is not a service in this project — it is held by a deleted service (or one in another project); ask an operator to release the hostname before re-binding it`;
257
257
  }
258
258
  return `${host} is already attached to another compute service — domains are not moved; release it there first (${release('<that service>')}) or, if that service was deleted, ask an operator to release the hostname`;
@@ -421,6 +421,12 @@ function execCommandIndex(argv) {
421
421
  const token = argv[cursor];
422
422
  if (token === '--agent')
423
423
  continue;
424
+ if (token === '--api-url') {
425
+ cursor++;
426
+ continue;
427
+ } // root flag with a value: skip both tokens
428
+ if (token.startsWith('--api-url='))
429
+ continue;
424
430
  if (token.startsWith('-'))
425
431
  return -1; // a global flag, or `--`: either way not our command path
426
432
  return token === 'compute' && argv[cursor + 1] === 'exec' ? cursor : -1;
@@ -470,7 +476,7 @@ export function resolveExecFallback(services, payload, note = (msg) => process.s
470
476
  if (head === undefined)
471
477
  return { serviceName: undefined, command: [] };
472
478
  if (!services.some((service) => service.type === 'compute' && service.name === head)) {
473
- note(`note: no \`--\` separator was found and \`${head}\` is not a compute service, so it was read as the command. If \`${head}\` was the service, check the name with \`insta services list\`.`);
479
+ note(`note: no \`--\` separator was found and \`${head}\` is not a compute service, so it was read as the command. If \`${head}\` was the service, check the name with \`insta service list\`.`);
474
480
  return { serviceName: undefined, command: payload };
475
481
  }
476
482
  // `head` really is a service, so whatever follows it cannot be the command's first token.
@@ -567,21 +573,38 @@ export async function computeExec(serviceName, command, opts, recovery = {}) {
567
573
  applyExecResult(res, opts.json);
568
574
  }
569
575
  // ---- always-on (opt out of scale-to-zero; all plans; billing is actual usage either way) ----
570
- export async function computeAlwaysOn(mode, serviceName, opts) {
576
+ export async function serviceAlwaysOn(type, mode, serviceName, opts) {
571
577
  if (mode !== 'on' && mode !== 'off')
572
578
  throw new Error('mode must be on|off');
573
579
  const api = await ApiClient.load();
574
580
  const p = await requireProject();
575
581
  const branch = opts.branch ?? p.branch;
576
582
  const { services } = await api.request('GET', `/projects/${p.projectId}/services${q(branch)}`);
577
- const id = resolveComputeServiceId(services, serviceName);
578
- const res = await api.rawRequest('PUT', `/projects/${p.projectId}/services/${id}/always-on`, { enabled: mode === 'on' });
583
+ const svc = resolveSoleService(services, type, serviceName);
584
+ const res = await api.rawRequest('PUT', `/projects/${p.projectId}/services/${svc.id}/always-on`, { enabled: mode === 'on' });
579
585
  if (handleApproval(res, opts.json))
580
586
  return;
581
587
  if (opts.json)
582
588
  return printJson(res.body);
583
589
  const on = res.body.service?.always_on;
584
- info(`compute ${res.body.service?.name ?? id}: always-on ${on ? 'ENABLED — machines stay warm (no cold starts; idle RAM bills at actual usage)' : 'disabled — scales to zero when idle'}`);
590
+ info(`${type} ${res.body.service?.name ?? svc.name}: always-on ${on ? 'ENABLED — machines stay warm (no cold starts; idle RAM bills at actual usage)' : 'disabled — scales to zero when idle'}`);
591
+ }
592
+ export const computeAlwaysOn = (mode, serviceName, opts) => serviceAlwaysOn('compute', mode, serviceName, opts);
593
+ // `insta compute scale <count> [service] [--region <r>]` — POST /services/:id/scale. Count is
594
+ // validated locally (1..10); the paid-plan gate is the backend's and its 403 flows verbatim.
595
+ export async function computeScale(count, serviceName, opts) {
596
+ const machineCount = parseCount(count);
597
+ const api = await ApiClient.load();
598
+ const p = await requireProject();
599
+ const branch = opts.branch ?? p.branch;
600
+ const { services } = await api.request('GET', `/projects/${p.projectId}/services${q(branch)}`);
601
+ const svc = resolveSoleService(services, 'compute', serviceName);
602
+ const res = await api.rawRequest('POST', `/projects/${p.projectId}/services/${svc.id}/scale`, { machineCount, region: opts.region });
603
+ if (handleApproval(res, opts.json))
604
+ return;
605
+ if (opts.json)
606
+ return printJson(res.body.service);
607
+ info(`scaled compute ${svc.name} to ${machineCount} replica(s)${opts.region ? ` in ${opts.region}` : ''}`);
585
608
  }
586
609
  // ---- limits (the resource ceiling; paid plans) ----
587
610
  // Parse a human memory value into MB: "512", "512mb", "1gb", "2g", "1.5gb".
@@ -615,30 +638,47 @@ export function parseCpu(raw) {
615
638
  // ---- volume (the persistent /data disk; attach any time, grow-only, deletable; never detach) ----
616
639
  // Render the volume read. Pure, exported for tests (mirrors serviceListLine). Every plan may view;
617
640
  // only growth is paid — that gate is the backend's to enforce, so nothing here pre-blocks.
618
- export function volumeLines(name, volume, cap) {
641
+ export function volumeLines(name, volume, cap, type = 'compute') {
619
642
  if (!volume)
620
643
  return [
621
- `compute ${name}: no volume attached (attach one: \`insta compute volume ${name} --size <gi>\` — it mounts at /data on the next deploy)`,
644
+ type === 'compute'
645
+ ? `compute ${name}: no volume attached (attach one: \`insta compute volume ${name} --size <gi>\` — it mounts at /data on the next deploy)`
646
+ : `${type} ${name}: no volume attached (attach one: \`insta ${type} volume ${name} --size <gi>\` — it mounts at the image's data directory)`,
622
647
  ];
648
+ // `--delete` exists on `compute volume` only — a managed database's volume IS its data
649
+ // directory, so the group never registered the flag (index.ts) and naming it here would print
650
+ // a command that fails.
651
+ const grow = type === 'compute'
652
+ ? 'grow with --size (grow-only), delete with --delete (destroys the data)'
653
+ : 'grow with --size (grow-only); the volume cannot be deleted — remove the service instead';
623
654
  return [
624
- `compute ${name}: volume ${volume.sizeGib}Gi at ${volume.mountPath} (plan max ${cap.volumeGib}Gi)`,
625
- ' billing is actual data stored — the size is a cap, not a price; grow with --size (grow-only), delete with --delete (destroys the data)',
655
+ `${type} ${name}: volume ${volume.sizeGib}Gi at ${volume.mountPath} (plan max ${cap.volumeGib}Gi)`,
656
+ ` billing is actual data stored — the size is a cap, not a price; ${grow}`,
626
657
  ];
627
658
  }
628
659
  // Render the PUT result. Pure, exported for tests. `attached` comes from the backend and is what
629
660
  // tells a FIRST attach (no disk yet — it mounts on the next deploy) apart from a grow (the live
630
661
  // disk was already extended); the wire size is authoritative in both cases.
631
- export function volumeWriteLine(name, body) {
662
+ export function volumeWriteLine(name, body, type = 'compute') {
632
663
  if (body.attached) {
633
- return `compute ${name}: volume ${body.volume.sizeGib}Gi attached — mounts at ${body.volume.mountPath} on the next deploy (plan max ${body.cap.volumeGib}Gi)`;
664
+ // Only a compute service has a deploy step for the mount to wait on; a managed database has
665
+ // no deploy, so its disk is simply mounted.
666
+ const mounts = type === 'compute'
667
+ ? `mounts at ${body.volume.mountPath} on the next deploy`
668
+ : `mounts at ${body.volume.mountPath}`;
669
+ return `${type} ${name}: volume ${body.volume.sizeGib}Gi attached — ${mounts} (plan max ${body.cap.volumeGib}Gi)`;
634
670
  }
635
- return `compute ${name}: volume grown to ${body.volume.sizeGib}Gi at ${body.volume.mountPath} (plan max ${body.cap.volumeGib}Gi)`;
671
+ return `${type} ${name}: volume grown to ${body.volume.sizeGib}Gi at ${body.volume.mountPath} (plan max ${body.cap.volumeGib}Gi)`;
636
672
  }
637
673
  // Render the DELETE result. Pure, exported for tests. Deleting is the only way off the volume
638
674
  // path (there is no detach), so the line says what came back with it: the two constraints the
639
675
  // volume imposed.
640
- export function volumeDeleteLine(name) {
641
- return `compute ${name}: volume deleted — the disk and its data are gone; suspend fast-wake and scale-out are back`;
676
+ export function volumeDeleteLine(name, type = 'compute') {
677
+ // The two constraints a volume imposes — no suspend fast-wake, no scale-out — are compute-plane
678
+ // facts. `--delete` is registered on compute only, so the other branch is unreachable today;
679
+ // it is written type-aware anyway so the line cannot start lying if it ever becomes reachable.
680
+ const regained = type === 'compute' ? '; suspend fast-wake and scale-out are back' : '';
681
+ return `${type} ${name}: volume deleted — the disk and its data are gone${regained}`;
642
682
  }
643
683
  // Map a DELETE .../volume failure. Pure, exported for tests. An older backend has no DELETE
644
684
  // route, and what its 404 looks like depends on who answered: the real platform (Fastify, no
@@ -661,7 +701,7 @@ export function volumeDeleteError(e) {
661
701
  // no undo; billing stops now). The paid/cap/machine-count gates all belong to the backend, whose
662
702
  // 403/400 messages carry the upgrade hints and must reach the user verbatim (the guard prints
663
703
  // ApiError messages as-is).
664
- export async function computeVolume(serviceName, opts) {
704
+ export async function serviceVolume(type, serviceName, opts) {
665
705
  if (opts.delete && opts.mountPath !== undefined)
666
706
  throw new Error('--delete cannot be combined with --mount-path');
667
707
  if (opts.mountPath !== undefined && !opts.size)
@@ -672,11 +712,11 @@ export async function computeVolume(serviceName, opts) {
672
712
  const p = await requireProject();
673
713
  const branch = opts.branch ?? p.branch;
674
714
  const { services } = await api.request('GET', `/projects/${p.projectId}/services${q(branch)}`);
675
- const id = resolveComputeServiceId(services, serviceName);
715
+ const svc = resolveSoleService(services, type, serviceName);
676
716
  if (opts.delete) {
677
717
  let res;
678
718
  try {
679
- res = await api.rawRequest('DELETE', `/projects/${p.projectId}/services/${id}/volume`);
719
+ res = await api.rawRequest('DELETE', `/projects/${p.projectId}/services/${svc.id}/volume`);
680
720
  }
681
721
  catch (e) {
682
722
  throw volumeDeleteError(e);
@@ -685,40 +725,41 @@ export async function computeVolume(serviceName, opts) {
685
725
  return;
686
726
  if (opts.json)
687
727
  return printJson(res.body);
688
- info(volumeDeleteLine(res.body.service?.name ?? serviceName ?? id));
728
+ info(volumeDeleteLine(res.body.service?.name ?? svc.name, type));
689
729
  return;
690
730
  }
691
731
  if (!opts.size) {
692
- const r = await api.request('GET', `/projects/${p.projectId}/services/${id}/volume`);
732
+ const r = await api.request('GET', `/projects/${p.projectId}/services/${svc.id}/volume`);
693
733
  if (opts.json)
694
734
  return printJson(r);
695
- for (const line of volumeLines(serviceName ?? id, r.volume, r.cap))
735
+ for (const line of volumeLines(svc.name, r.volume, r.cap, type))
696
736
  info(line);
697
737
  return;
698
738
  }
699
739
  const sizeGib = parseVolumeGib(opts.size);
700
- const res = await api.rawRequest('PUT', `/projects/${p.projectId}/services/${id}/volume`, { sizeGib, ...(opts.mountPath !== undefined ? { mountPath: opts.mountPath } : {}) });
740
+ const res = await api.rawRequest('PUT', `/projects/${p.projectId}/services/${svc.id}/volume`, { sizeGib, ...(opts.mountPath !== undefined ? { mountPath: opts.mountPath } : {}) });
701
741
  if (handleApproval(res, opts.json))
702
742
  return;
703
743
  if (opts.json)
704
744
  return printJson(res.body);
705
- info(volumeWriteLine(res.body.service?.name ?? serviceName ?? id, res.body));
745
+ info(volumeWriteLine(res.body.service?.name ?? svc.name, res.body, type));
706
746
  }
747
+ export const computeVolume = (serviceName, opts) => serviceVolume('compute', serviceName, opts);
707
748
  // Show or set a compute service's ceiling. With no --memory it PRINTS the current limits and the
708
749
  // plan cap (so `insta compute limits` is a safe read), which is also what a UI renders as a slider
709
750
  // with its plan-limit marker.
710
- export async function computeLimits(serviceName, opts) {
751
+ export async function serviceLimits(type, serviceName, opts) {
711
752
  const api = await ApiClient.load();
712
753
  const p = await requireProject();
713
754
  const branch = opts.branch ?? p.branch;
714
755
  const { services } = await api.request('GET', `/projects/${p.projectId}/services${q(branch)}`);
715
- const id = resolveComputeServiceId(services, serviceName);
756
+ const svc = resolveSoleService(services, type, serviceName);
716
757
  if (!opts.memory && !opts.cpu) {
717
- const r = await api.request('GET', `/projects/${p.projectId}/services/${id}/limits`);
758
+ const r = await api.request('GET', `/projects/${p.projectId}/services/${svc.id}/limits`);
718
759
  if (opts.json)
719
760
  return printJson(r);
720
- info(`compute ${serviceName ?? id}: ceiling ${r.limits.cpu} vCPU / ${fmtMb(r.limits.memoryMb)} (plan max ${r.cap.cpu} vCPU / ${fmtMb(r.cap.memoryMb)})`);
721
- info(' billing is actual usage — the ceiling caps what the app may burn, it is not a price');
761
+ info(`${type} ${svc.name}: ceiling ${r.limits.cpu} vCPU / ${fmtMb(r.limits.memoryMb)} (plan max ${r.cap.cpu} vCPU / ${fmtMb(r.cap.memoryMb)})`);
762
+ info(` billing is actual usage — the ceiling caps what the ${type === 'compute' ? 'app' : 'database'} may burn, it is not a price`);
722
763
  return;
723
764
  }
724
765
  if (!opts.memory)
@@ -726,14 +767,15 @@ export async function computeLimits(serviceName, opts) {
726
767
  const body = { memoryMb: parseMemoryMb(opts.memory) };
727
768
  if (opts.cpu)
728
769
  body.cpu = parseCpu(opts.cpu);
729
- const res = await api.rawRequest('PUT', `/projects/${p.projectId}/services/${id}/limits`, body);
770
+ const res = await api.rawRequest('PUT', `/projects/${p.projectId}/services/${svc.id}/limits`, body);
730
771
  if (handleApproval(res, opts.json))
731
772
  return;
732
773
  if (opts.json)
733
774
  return printJson(res.body);
734
775
  const l = res.body.limits;
735
- info(`compute ${res.body.service?.name ?? id}: ceiling set to ${l.cpu} vCPU / ${fmtMb(l.memoryMb)}`);
776
+ info(`${type} ${res.body.service?.name ?? svc.name}: ceiling set to ${l.cpu} vCPU / ${fmtMb(l.memoryMb)}`);
736
777
  }
778
+ export const computeLimits = (serviceName, opts) => serviceLimits('compute', serviceName, opts);
737
779
  // ---- ssh (interactive sessions) --------------------------------------------
738
780
  import { chmodSync, existsSync, linkSync, lstatSync, mkdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
739
781
  import { homedir } from 'node:os';
@@ -1,9 +1,9 @@
1
- // `insta db query <service> [args...]` — run a query/command against a MANAGED database
2
- // (mysql/redis/mongodb) through the platform's console exec API. Postgres is not a console target
3
- // (it has the SQL editor / DATABASE_URL, and `insta db url|connect`), so a postgres service is
4
- // rejected here. The shape logic — path, request body, result rendering — lives in pure,
5
- // unit-tested seams; the handler just resolves the service and wires them to the API, this repo's
6
- // pure-seam convention.
1
+ // `insta redis|mysql|mongodb query <service> [args...]` — run a query/command against a MANAGED
2
+ // database through the platform's console exec API. Postgres is not a console target (it has the
3
+ // SQL editor / DATABASE_URL, and `insta postgres url|connect`), so a postgres service is rejected
4
+ // here. The shape logic — path, request body, result rendering — lives in pure, unit-tested seams;
5
+ // the handler just resolves the service and wires them to the API, this repo's pure-seam
6
+ // convention.
7
7
  import { ApiClient, requireProject } from '../api.js';
8
8
  import { info, printJson, die, handleApproval } from '../util.js';
9
9
  import { q } from './services.js';
@@ -62,11 +62,13 @@ async function dbQueryDeps(deps) {
62
62
  return { api, project };
63
63
  }
64
64
  // Resolve <service> (a service NAME) to its id + engine, then dispatch to the console exec API.
65
- export async function dbQuery(service, args, opts = {}, deps) {
65
+ // `engine` is the group the command was typed under (`insta redis query …`): a service of another
66
+ // type is refused with the right group named, never queried through the wrong renderer.
67
+ export async function dbQuery(service, args, opts = {}, deps, engine) {
66
68
  // An empty command is never valid — reject it before loading config or hitting the network,
67
69
  // rather than posting an empty statement/argv to the console.
68
70
  if (args.length === 0) {
69
- die('usage: insta db query <service> <query…> (mysql/mongodb: one quoted statement; redis: e.g. GET mykey)');
71
+ die(`usage: insta ${engine ?? '<redis|mysql|mongodb>'} query <service> <query…> (mysql/mongodb: one quoted statement; redis: e.g. GET mykey)`);
70
72
  }
71
73
  const { api, project: p } = await dbQueryDeps(deps);
72
74
  const branch = opts.branch ?? p.branch;
@@ -75,24 +77,26 @@ export async function dbQuery(service, args, opts = {}, deps) {
75
77
  if (!svc)
76
78
  die(`service not found: ${service}`);
77
79
  if (!MANAGED_ENGINES.includes(svc.type)) {
78
- die('db query is for managed databases (mysql/redis/mongodb); postgres uses the SQL editor / DATABASE_URL');
80
+ die('query is for managed databases (mysql/redis/mongodb); postgres uses `insta postgres url|connect` / the SQL editor');
79
81
  }
80
- const engine = svc.type;
82
+ if (engine && svc.type !== engine)
83
+ die(`${service} is a ${svc.type} service — use: insta ${svc.type} query ${service} …`);
84
+ const resolved = svc.type;
81
85
  // --database is a mongodb-only selector (execBody drops it for the others). Rejecting it here,
82
86
  // rather than silently ignoring it, keeps the documented mongodb-only contract honest.
83
- if (opts.database !== undefined && engine !== 'mongodb') {
87
+ if (opts.database !== undefined && resolved !== 'mongodb') {
84
88
  die('--database is only supported for mongodb services');
85
89
  }
86
- const res = await api.rawRequest('POST', consoleExecPath(p.projectId, svc.id), execBody(engine, args, opts.database));
90
+ const res = await api.rawRequest('POST', consoleExecPath(p.projectId, svc.id), execBody(resolved, args, opts.database));
87
91
  if (handleApproval(res, opts.json))
88
92
  return;
89
93
  if (opts.json)
90
94
  return printJson(res.body);
91
- if (engine === 'mysql') {
95
+ if (resolved === 'mysql') {
92
96
  for (const line of renderMysqlRows(res.body ?? {}))
93
97
  info(line);
94
98
  }
95
- else if (engine === 'redis') {
99
+ else if (resolved === 'redis') {
96
100
  info(renderRedisReply(res.body?.reply));
97
101
  }
98
102
  else {
@@ -60,7 +60,7 @@ async function discoverLane(api, projectId, branch, opts) {
60
60
  // The route answered and the TARGET is what is missing: say so, or flyctl turns it into "no Dockerfile".
61
61
  // Both ways out: the project has several groups and none is named, or it has none at all.
62
62
  if (/compute group not found|branch not found/.test(e.message))
63
- die(`${e.message} — name it with \`--group <name>\` (see \`insta services list\`), or add one: \`insta services add compute <name>\``);
63
+ die(`${e.message} — name it with \`--group <name>\` (see \`insta service list\`), or add one: \`insta service add compute <name>\``);
64
64
  return { lane: 'legacy' };
65
65
  }
66
66
  throw e;
@@ -1,7 +1,7 @@
1
1
  import { ApiClient } from '../api.js';
2
2
  import { info, printJson, handleApproval, die } from '../util.js';
3
3
  import { presentUrl, resolveOrgId } from './billing.js';
4
- import { domainDeps, domainTarget } from './compute.js';
4
+ import { domainDeps, domainTarget, setDomain, checkDomain, removeDomain } from './compute.js';
5
5
  const usd = (cents) => `$${(cents / 100).toFixed(2)}`;
6
6
  // --org wins; otherwise the linked project's org — the org verbs must not force a project link.
7
7
  async function orgDeps(opts, deps) {
@@ -70,7 +70,9 @@ export async function domainAttach(host, opts, deps) {
70
70
  const o = ownerOf(name, orders);
71
71
  if (o)
72
72
  die(`${o.domainName} is not registered yet — its order is ${o.status}: insta domain status ${o.domainName}`);
73
- die(`no domain this org bought covers ${name} — for a domain you own elsewhere: insta compute set-domain ${name}`);
73
+ // Not a name this org bought: a domain owned elsewhere. Same verb, the bring-your-own path —
74
+ // the plane issues the edge cert and the DNS records to publish in your own zone are printed.
75
+ return setDomain(name, opts, { api, project: p });
74
76
  }
75
77
  const branch = opts.branch ?? p.branch;
76
78
  const { target } = await domainTarget(api, p.projectId, branch, name, opts.group);
@@ -191,4 +193,49 @@ export async function domainRecordsRemove(domainName, id, opts, deps) {
191
193
  return printJson(r);
192
194
  info(`removed record ${id} from ${domainName}`);
193
195
  }
196
+ export async function domainCheck(host, opts, deps) {
197
+ return checkDomain(host.trim().toLowerCase(), opts, deps);
198
+ }
199
+ /** The bought domain `host` sits under, or null — including when the question cannot be answered.
200
+ *
201
+ * This lookup is a GUARD on a command that worked without it: a control plane without the
202
+ * org-scoped domains route (an older deployment, insta-oss), a 403, or a transient 5xx must not
203
+ * turn a bring-your-own detach into a hard failure. A failed read means "nothing says this is a
204
+ * bought name", which is exactly how the command behaved before the guard existed. A read that
205
+ * SUCCEEDS is authoritative, and its refusal stands. */
206
+ async function boughtOwner(d, orgId, host) {
207
+ try {
208
+ const { items } = await d.api.request('GET', `/orgs/${orgId}/domains`);
209
+ return ownerOf(host, items);
210
+ }
211
+ catch {
212
+ return null;
213
+ }
214
+ }
215
+ /**
216
+ * Release a hostname from its compute service. Bring-your-own only: a hostname under a domain
217
+ * bought through InstaCloud is refused here.
218
+ *
219
+ * `attach` writes a bought hostname into the platform's DOMAINS record (state, serviceId,
220
+ * releaseFrom) and lets a reconciler bind it on the compute plane; this verb's only route,
221
+ * DELETE /projects/:id/compute/domain, unbinds on the compute plane alone and the platform
222
+ * exposes no detach route for the domains record. Running it would leave the record still
223
+ * claiming a binding that no longer exists — and, for an apex, `attach` binds both the name and
224
+ * its www while this takes one hostname. The supported way to move a bought hostname is another
225
+ * `attach`, which releases it from the old service itself.
226
+ */
227
+ export async function domainDetach(host, opts, deps) {
228
+ const d = await domainDeps(deps);
229
+ const name = host.trim().toLowerCase();
230
+ // Without an org there is no domains list to check against (INSTA_PROJECT_ID-only CI links name
231
+ // none); a bring-your-own detach must keep working there, so the guard is simply not applied.
232
+ if (d.project.orgId) {
233
+ const owner = await boughtOwner(d, d.project.orgId, name);
234
+ if (owner) {
235
+ die(`${name} belongs to ${owner.domainName}, a domain bought through InstaCloud — its binding lives on the domain, not on the compute plane, so detaching it here would leave the domain still claiming it. `
236
+ + `Move it with \`insta domain attach ${name} --group <other service>\` (attach releases it from the current one), and see where it stands with \`insta domain status ${owner.domainName}\`.`);
237
+ }
238
+ }
239
+ return removeDomain(name, opts, d);
240
+ }
194
241
  //# sourceMappingURL=domain.js.map
@@ -76,6 +76,6 @@ export async function envUse(name, opts = {}) {
76
76
  // files were written for the previous environment and are keyed by a different server name, so
77
77
  // they keep talking to it until setup is re-run. --env is REQUIRED in the hint: since 0.0.38 a
78
78
  // bare `setup agent` forces prod, which would silently undo the switch the user just made.
79
- info(` re-point this machine's agents at it with: insta setup agent --env ${target}`);
79
+ info(` re-point this machine's agents at it with: insta agent setup --env ${target}`);
80
80
  }
81
81
  //# sourceMappingURL=env.js.map
@@ -0,0 +1,34 @@
1
+ // `insta redis|mysql|mongodb …` — the managed Fly databases. Their settings verbs (limits / volume /
2
+ // always-on) reuse compute.ts's type-generalized handlers: the platform's /services/:id/{limits,
3
+ // volume,always-on} accept "compute or managed-database" services. `status` cannot: GET
4
+ // /services/:id/state is compute-only on the platform (services.state() throws for anything else),
5
+ // so it reads the project's runtime-health — which covers compute, postgres and the managed DBs in
6
+ // one call — and picks this service's entry.
7
+ import { ApiClient, requireProject } from '../api.js';
8
+ import { info, printJson } from '../util.js';
9
+ import { q, resolveSoleService } from './services.js';
10
+ // pure: `redis cache: standby (1 machine, 0 failing)`. Exported for tests.
11
+ export function statusLine(type, name, entry) {
12
+ if (!entry)
13
+ return `${type} ${name}: unknown (the runtime-health read did not include this service)`;
14
+ const machines = `${entry.machines} machine${entry.machines === 1 ? '' : 's'}`;
15
+ return `${type} ${name}: ${entry.status} (${machines}, ${entry.failing} failing)`;
16
+ }
17
+ async function managedDeps(deps) {
18
+ if (deps)
19
+ return deps;
20
+ const [api, project] = [await ApiClient.load(), await requireProject()];
21
+ return { api, project };
22
+ }
23
+ export async function managedStatus(type, serviceName, opts, deps) {
24
+ const { api, project: p } = await managedDeps(deps);
25
+ const branch = opts.branch ?? p.branch;
26
+ const { services } = await api.request('GET', `/projects/${p.projectId}/services${q(branch)}`);
27
+ const svc = resolveSoleService(services, type, serviceName);
28
+ const res = await api.request('GET', `/projects/${p.projectId}/runtime-health${q(branch)}`);
29
+ const entry = res.services?.find((e) => e.serviceId === svc.id);
30
+ if (opts.json)
31
+ return printJson(entry ?? { serviceId: svc.id, status: 'unknown', machines: 0, failing: 0 });
32
+ info(statusLine(type, svc.name, entry));
33
+ }
34
+ //# sourceMappingURL=managed-db.js.map
@@ -1,4 +1,4 @@
1
- // `insta mcp install` — write the insta-cloud remote MCP server into each coding agent's own
1
+ // `insta config install-mcp` — write the insta-cloud remote MCP server into each coding agent's own
2
2
  // config format. Claude Code is NOT handled here — it has a real registry CLI (`claude mcp add`,
3
3
  // see setup.ts registerMcp); these are the config-file agents. All entries are OAuth (no
4
4
  // credential written): each client discovers the platform AS via RFC 9728 and runs the browser
@@ -118,7 +118,7 @@ export async function installAgentConfigs(agent, home = os.homedir()) {
118
118
  }
119
119
  return done;
120
120
  }
121
- // `insta mcp install [--agent <slug>] [--mcp-token]` — claude-code goes through its registry CLI
121
+ // `insta config install-mcp [--agent <slug>] [--mcp-token]` — claude-code goes through its registry CLI
122
122
  // (registerMcp); everything else is a config-file write. No --agent = claude-code + all detected.
123
123
  export async function mcpInstall(opts, register = registerMcp, installConfigs = installAgentConfigs) {
124
124
  if (opts.mcpToken && opts.agent && opts.agent !== 'claude-code') {
@@ -60,7 +60,7 @@ function humanBytes(v, base) {
60
60
  const shown = i === 0 && Number.isInteger(value) ? String(value) : value.toFixed(1);
61
61
  return `${shown} ${units[i]}`;
62
62
  }
63
- // insta metrics <db|compute|redis|mysql|mongodb> [group]
63
+ // shared handler behind `insta <compute|postgres|redis|mysql|mongodb> metrics [service]`
64
64
  export async function metrics(component, group, opts) {
65
65
  const api = await ApiClient.load();
66
66
  const p = await requireProject();
@@ -82,7 +82,7 @@ export function cycleLine(res) {
82
82
  const day = (sec) => new Date(sec * 1000).toISOString().slice(0, 10);
83
83
  return `billing cycle ${day(res.from)} → ${day(res.to - 86400)}`;
84
84
  }
85
- // Pure: format the per-dimension lines (label: qty unit (cost)). Shared by `insta usage` and
85
+ // Pure: format the per-dimension lines (label: qty unit (cost)). Shared by `insta billing usage` and
86
86
  // `insta billing` so both render dimensions identically.
87
87
  export function dimensionLines(dims) {
88
88
  return dims.map((d) => {
@@ -95,7 +95,7 @@ function printDimensions(dims) {
95
95
  for (const l of dimensionLines(dims))
96
96
  info(l);
97
97
  }
98
- // insta usage — usage across the 5 billing dimensions (cpu/memory/volume/egress/storage) for the
98
+ // insta billing usage — usage across the 5 billing dimensions (cpu/memory/volume/egress/storage) for the
99
99
  // current billing cycle. Shows the whole ORG by default (with a per-project breakdown); pass --proj
100
100
  // [id] for a single project (the linked one, or a given id). Billed dimensions, not raw provider
101
101
  // meters.
@@ -128,7 +128,8 @@ export async function usage(opts) {
128
128
  info(` ${pr.name}: $${Number(pr.totalCostUsd ?? 0).toFixed(4)}`);
129
129
  }
130
130
  }
131
- // pure: platform path for a deploy-events request (used by `insta logs --deploy`). Any Fly-backed
131
+ // pure: platform path for a deploy-events request (used by `insta compute logs --deploy` and the
132
+ // equivalent on redis/mysql/mongodb). Any Fly-backed
132
133
  // component (compute or a managed database) has machine lifecycle events; omitted → the platform
133
134
  // defaults to compute.
134
135
  export function deployEventsPath(projectId, opts) {
@@ -169,7 +170,7 @@ export function resolveLogWindow(opts, now = Math.floor(Date.now() / 1000)) {
169
170
  throw new Error('--to is before --from');
170
171
  return { from, to };
171
172
  }
172
- // insta logs <db|compute|redis|mysql|mongodb> [group]
173
+ // shared handler behind `insta <compute|postgres|redis|mysql|mongodb> logs [service]`
173
174
  export async function logs(component, group, opts) {
174
175
  const windowFlags = opts.from !== undefined || opts.to !== undefined || opts.since !== undefined;
175
176
  if (opts.deploy && windowFlags)
@@ -1,4 +1,4 @@
1
- // `insta observe` — the local credential-audit hook. install wires a PostToolUse hook into the
1
+ // `insta agent observe` — the local credential-audit hook. install wires a PostToolUse hook into the
2
2
  // agent harness; report renders the local audit; sync uploads findings into the project timeline
3
3
  // (idempotent via a stable dedup key, matching the platform's audit-event ingest).
4
4
  import { existsSync } from 'node:fs';
@@ -67,7 +67,7 @@ export async function observeInstall() {
67
67
  if (hint)
68
68
  info(hint);
69
69
  info('it scans agent tool-use for credential exposure; findings append to ./.insta/audit.jsonl');
70
- info('run `insta observe report` to review, `insta observe sync` to upload to the project timeline');
70
+ info('run `insta agent observe report` to review, `insta agent observe sync` to upload to the project timeline');
71
71
  }
72
72
  export async function observeUninstall() {
73
73
  uninstallObserve(await installRoot());