insta 0.0.35 → 0.0.37

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,8 +1,11 @@
1
1
  import { resolve, join } from 'node:path';
2
2
  import { existsSync, readFileSync } from 'node:fs';
3
- import { ApiClient, requireProject } from '../api.js';
4
- import { info, die, handleApproval, renderNextActions } from '../util.js';
5
- import { flyctlBuildAndPush, ensureFlyctl } from '../flyctl-build.js';
3
+ import { ApiClient, ApiError, requireProject } from '../api.js';
4
+ import { info, die, printJson, handleApproval, renderNextActions } from '../util.js';
5
+ import { flyctlBuildAndPush, ensureFlyctl, defaultBuildRunner, stderrBuildRunner } from '../flyctl-build.js';
6
+ // With --json, stdout must carry exactly one JSON document (the deploy result), so every progress
7
+ // line moves to stderr.
8
+ const note = (opts) => (opts.json ? (m) => void process.stderr.write(m + '\n') : info);
6
9
  // Map CLI options to the platform deploy request body. Pure, so it's unit-tested. --websocket is only
7
10
  // sent when set (plain deploys unchanged).
8
11
  export function deployRequestBody(image, branch, opts) {
@@ -36,38 +39,73 @@ export async function deploy(dir, opts) {
36
39
  const api = await ApiClient.load();
37
40
  const p = await requireProject();
38
41
  const branch = opts.branch ?? p.branch;
42
+ const log = note(opts);
39
43
  let port = opts.port ? Number(opts.port) : undefined;
40
44
  if (dir && port === undefined) {
41
45
  const dockerfile = join(resolve(process.cwd(), dir), 'Dockerfile');
42
46
  const exposed = existsSync(dockerfile) ? dockerfileExposedPort(readFileSync(dockerfile, 'utf8')) : undefined;
43
47
  if (exposed) {
44
48
  port = exposed;
45
- info(`using port ${exposed} (Dockerfile EXPOSE) — override with --port`);
49
+ log(`using port ${exposed} (Dockerfile EXPOSE) — override with --port`);
46
50
  }
47
51
  }
48
52
  const effOpts = { ...opts, port: port?.toString() };
49
53
  const image = dir ? await buildFromSource(api, p.projectId, dir, branch, effOpts) : opts.image;
50
54
  const res = await api.rawRequest('POST', `/projects/${p.projectId}/deploy`, deployRequestBody(image, branch, effOpts));
51
- if (handleApproval(res))
55
+ if (handleApproval(res, opts.json))
52
56
  return;
57
+ if (opts.json)
58
+ return printJson({ image, ...res.body });
53
59
  info(`deployed ${image} -> ${res.body.url} (branch ${res.body.branch}, group ${res.body.group})`);
54
60
  renderNextActions(res.body.nextActions);
55
61
  }
62
+ // The local image tag a daemon-side deploy runs: unique per build so a redeploy replaces, and
63
+ // legible in `docker images`. Pure, so it's unit-tested.
64
+ export function localImageTag(projectId, group, now = Date.now()) {
65
+ return `insta-src-${projectId.slice(0, 8)}-${group ?? 'default'}:${now}`;
66
+ }
67
+ // Local build for a local daemon (insta-oss): the CLI and the daemon share ONE docker, so a
68
+ // locally-built tag is directly runnable — no registry, no push. Same injectable-runner pattern
69
+ // as flyctl-build.ts.
70
+ export async function dockerBuildLocal(absDir, tag, run = defaultBuildRunner) {
71
+ const { code } = await run('docker', ['build', '-t', tag, '.'], { cwd: absDir, env: process.env });
72
+ if (code !== 0)
73
+ throw new Error(`docker build failed (exit ${code}). See output above.`);
74
+ return tag;
75
+ }
56
76
  // Source mode: mint a scoped Fly deploy token from the platform, then build+push <dir> (needs a
57
- // Dockerfile) with flyctl's remote builder, returning the pushed image ref to deploy.
58
- async function buildFromSource(api, projectId, dir, branch, opts) {
77
+ // Dockerfile) with flyctl's remote builder, returning the pushed image ref to deploy. Against a
78
+ // local daemon (insta-oss) the token mint answers 501 — build with docker instead, same contract.
79
+ // Exported with injectable pieces for tests (the repo's DI pattern; no global mocks).
80
+ export async function buildFromSource(api, projectId, dir, branch, opts, run = opts.json ? stderrBuildRunner : defaultBuildRunner) {
59
81
  const absDir = resolve(process.cwd(), dir);
60
82
  if (!existsSync(join(absDir, 'Dockerfile')))
61
83
  die(`no Dockerfile at ${join(absDir, 'Dockerfile')} — add one, or use --image <url>`);
62
- await ensureFlyctl();
63
- const port = opts.port ? Number(opts.port) : 8080;
64
- const tok = await api.rawRequest('POST', `/projects/${projectId}/deploy-token`, { branch, group: opts.group });
65
- if (handleApproval(tok))
66
- die('deploy requires approval — get it approved, then re-run');
84
+ const log = note(opts);
85
+ let tok;
86
+ try {
87
+ tok = await api.rawRequest('POST', `/projects/${projectId}/deploy-token`, { branch, group: opts.group });
88
+ }
89
+ catch (e) {
90
+ // 501 = no remote builder here (insta-oss is the only deployment that answers it) — the
91
+ // daemon deploys from the SAME docker this shell uses, so build locally and hand it the tag.
92
+ if (!(e instanceof ApiError) || e.status !== 501)
93
+ throw e;
94
+ const tag = localImageTag(projectId, opts.group);
95
+ log(`no remote builder on this daemon — building ${dir} locally with docker…`);
96
+ const built = await dockerBuildLocal(absDir, tag, run);
97
+ log(` built ${built}`);
98
+ return built;
99
+ }
100
+ // exit() with no argument honors the exit code handleApproval just set (2).
101
+ if (handleApproval(tok, opts.json))
102
+ process.exit();
67
103
  const { token, flyApp } = tok.body;
68
- info(`building ${dir} for ${flyApp} (remote builder)…`);
69
- const { imageRef } = await flyctlBuildAndPush({ dir: absDir, flyApp, imageLabel: `insta-${Date.now()}`, token, port });
70
- info(` pushed ${imageRef}`);
104
+ await ensureFlyctl(); // cloud path only — the local path needs docker, which the daemon requires anyway
105
+ const port = opts.port ? Number(opts.port) : 8080;
106
+ log(`building ${dir} for ${flyApp} (remote builder)…`);
107
+ const { imageRef } = await flyctlBuildAndPush({ dir: absDir, flyApp, imageLabel: `insta-${Date.now()}`, token, port }, run);
108
+ log(` pushed ${imageRef}`);
71
109
  return imageRef;
72
110
  }
73
111
  //# sourceMappingURL=deploy.js.map
@@ -20,7 +20,20 @@ export async function envShow(opts) {
20
20
  if (!env)
21
21
  info(' (custom apiUrl — `insta env use <name>` to switch to a named environment)');
22
22
  }
23
- export async function envUse(name) {
23
+ // One stable schema for BOTH envUse outcomes (no-op and real switch), so a scripted caller can key
24
+ // on any field — mcpServer, previous — without probing which branch ran. Pure, unit-tested.
25
+ export function envUseResult(target, previous, changed, sessionDropped) {
26
+ return {
27
+ env: target,
28
+ previous,
29
+ apiUrl: ENVS[target].api,
30
+ mcpUrl: ENVS[target].mcp,
31
+ mcpServer: mcpServerName(target),
32
+ changed,
33
+ sessionDropped,
34
+ };
35
+ }
36
+ export async function envUse(name, opts = {}) {
24
37
  const want = name.trim().toLowerCase();
25
38
  if (!isEnvName(want))
26
39
  die(`unknown environment "${name}" — expected one of: ${ENV_NAMES.join(', ')}`);
@@ -33,6 +46,8 @@ export async function envUse(name) {
33
46
  // how envForApiUrl already treats it) instead of being rewritten as a "switch" that needlessly
34
47
  // drops a perfectly good session.
35
48
  if (normalizeUrl(stored.apiUrl) === normalizeUrl(nextApi)) {
49
+ if (opts.json)
50
+ return printJson(envUseResult(target, from ?? target, false, false));
36
51
  info(`already on ${target} (${nextApi})`);
37
52
  return;
38
53
  }
@@ -49,6 +64,8 @@ export async function envUse(name) {
49
64
  delete next.refreshToken;
50
65
  delete next.user;
51
66
  await writeGlobal(next);
67
+ if (opts.json)
68
+ return printJson(envUseResult(target, from ?? null, true, hadSession));
52
69
  info(`switched ${from ?? '(custom)'} → ${target}`);
53
70
  info(` api: ${nextApi}`);
54
71
  info(` mcp: ${ENVS[target].mcp} (registers as \`${mcpServerName(target)}\`)`);
@@ -29,12 +29,16 @@ export async function approvalsApprove(id, opts) {
29
29
  const api = await ApiClient.load();
30
30
  const p = await requireProject();
31
31
  const out = await api.request('POST', `/projects/${p.projectId}/approvals/${id}/approve`, { always: !!opts.always });
32
+ if (opts.json)
33
+ return printJson(out);
32
34
  info(`approved ${out.approval.action} (${id})${opts.always ? ' — policy set to allow' : ''}`);
33
35
  }
34
- export async function approvalsDeny(id) {
36
+ export async function approvalsDeny(id, opts = {}) {
35
37
  const api = await ApiClient.load();
36
38
  const p = await requireProject();
37
39
  const out = await api.request('POST', `/projects/${p.projectId}/approvals/${id}/deny`);
40
+ if (opts.json)
41
+ return printJson(out);
38
42
  info(`denied ${out.approval.action} (${id})`);
39
43
  }
40
44
  export async function policyGet(opts) {
@@ -46,10 +50,12 @@ export async function policyGet(opts) {
46
50
  for (const [action, decision] of Object.entries(policy))
47
51
  info(`${action}: ${decision}`);
48
52
  }
49
- export async function policySet(action, decision) {
53
+ export async function policySet(action, decision, opts = {}) {
50
54
  const api = await ApiClient.load();
51
55
  const p = await requireProject();
52
- await api.request('PUT', `/projects/${p.projectId}/policy/${action}`, { decision });
56
+ const out = await api.request('PUT', `/projects/${p.projectId}/policy/${action}`, { decision });
57
+ if (opts.json)
58
+ return printJson({ action, decision, ...(out ?? {}) });
53
59
  info(`policy ${action} = ${decision}`);
54
60
  }
55
61
  //# sourceMappingURL=govern.js.map
@@ -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> [group]
63
+ // insta metrics <db|compute|redis|mysql|mongodb> [group]
64
64
  export async function metrics(component, group, opts) {
65
65
  const api = await ApiClient.load();
66
66
  const p = await requireProject();
@@ -129,23 +129,60 @@ export async function usage(opts) {
129
129
  info(` ${pr.name}: $${Number(pr.totalCostUsd ?? 0).toFixed(4)}`);
130
130
  }
131
131
  }
132
- // pure: platform path for a compute deploy-events request (used by `insta logs --deploy`).
132
+ // pure: platform path for a deploy-events request (used by `insta logs --deploy`). Any Fly-backed
133
+ // component (compute or a managed database) has machine lifecycle events; omitted → the platform
134
+ // defaults to compute.
133
135
  export function deployEventsPath(projectId, opts) {
134
- return `/projects/${projectId}/deploy-events${qs({ group: opts.group, branch: opts.branch, limit: opts.limit, instance: opts.instance })}`;
136
+ return `/projects/${projectId}/deploy-events${qs({ component: opts.component, group: opts.group, branch: opts.branch, limit: opts.limit, instance: opts.instance })}`;
135
137
  }
136
138
  // pure: render one deploy event as a log-style line.
137
139
  export function deployEventLine(ev) {
138
140
  const inst = ev.instance ? ` (${ev.instance})` : '';
139
141
  return `${ev.ts ?? ''} [${ev.origin ?? ''}] ${ev.type ?? ''}: ${ev.status ?? ''}${inst}`;
140
142
  }
141
- // insta logs <db|compute> [group]
143
+ // A log-window instant from the CLI: unix seconds (all digits) or anything Date.parse reads
144
+ // (ISO-8601 etc.). Throws on junk — a mistyped instant must fail here, not become NaN on the wire.
145
+ export function parseLogInstant(raw, flag) {
146
+ if (/^\d+$/.test(raw))
147
+ return Number(raw);
148
+ const ms = Date.parse(raw);
149
+ if (Number.isNaN(ms))
150
+ throw new Error(`invalid ${flag}: ${raw} (unix seconds or an ISO-8601 date)`);
151
+ return Math.floor(ms / 1000);
152
+ }
153
+ // '90s' | '30m' | '2h' | '1d' → seconds. Throws on junk, zero, and unknown units.
154
+ export function parseSinceSeconds(raw) {
155
+ const m = /^(\d+)([smhd])$/.exec(raw.trim());
156
+ if (!m)
157
+ throw new Error(`invalid --since: ${raw} (e.g. 90s, 30m, 2h, 1d)`);
158
+ const n = Number(m[1]);
159
+ if (n === 0)
160
+ throw new Error(`invalid --since: ${raw} (must be > 0)`);
161
+ return n * { s: 1, m: 60, h: 3600, d: 86400 }[m[2]];
162
+ }
163
+ // The from/to pair for the logs request, from whichever window flags were given. Pure, unit-tested.
164
+ export function resolveLogWindow(opts, now = Math.floor(Date.now() / 1000)) {
165
+ if (opts.since && opts.from)
166
+ throw new Error('pass --since or --from, not both');
167
+ const from = opts.since ? now - parseSinceSeconds(opts.since) : opts.from !== undefined ? parseLogInstant(opts.from, '--from') : undefined;
168
+ const to = opts.to !== undefined ? parseLogInstant(opts.to, '--to') : undefined;
169
+ if (from !== undefined && to !== undefined && to < from)
170
+ throw new Error('--to is before --from');
171
+ return { from, to };
172
+ }
173
+ // insta logs <db|compute|redis|mysql|mongodb> [group]
142
174
  export async function logs(component, group, opts) {
175
+ const windowFlags = opts.from !== undefined || opts.to !== undefined || opts.since !== undefined;
176
+ if (opts.deploy && windowFlags)
177
+ throw new Error('--from/--to/--since apply to runtime logs, not --deploy events');
178
+ const { from, to } = resolveLogWindow(opts);
143
179
  const api = await ApiClient.load();
144
180
  const p = await requireProject();
145
181
  if (opts.deploy) {
146
- if (component !== 'compute')
147
- return info('deploy events are only available for compute');
148
- const res = await api.request('GET', deployEventsPath(p.projectId, { group, branch: opts.branch ?? p.branch, limit: opts.limit, instance: opts.instance }));
182
+ // Machine lifecycle events exist for every Fly-backed component; 'db' (postgres) has no machines.
183
+ if (component === 'db')
184
+ return info('deploy events are not available for db — use compute, redis, mysql or mongodb');
185
+ const res = await api.request('GET', deployEventsPath(p.projectId, { component, group, branch: opts.branch ?? p.branch, limit: opts.limit, instance: opts.instance }));
149
186
  if (opts.json)
150
187
  return printJson(res);
151
188
  if (res.note)
@@ -156,7 +193,7 @@ export async function logs(component, group, opts) {
156
193
  info(deployEventLine(ev));
157
194
  return;
158
195
  }
159
- const res = await api.request('GET', `/projects/${p.projectId}/logs${qs({ component, group, branch: opts.branch ?? p.branch, limit: opts.limit, region: opts.region, instance: opts.instance })}`);
196
+ const res = await api.request('GET', `/projects/${p.projectId}/logs${qs({ component, group, branch: opts.branch ?? p.branch, limit: opts.limit, region: opts.region, instance: opts.instance, from: from !== undefined ? String(from) : undefined, to: to !== undefined ? String(to) : undefined })}`);
160
197
  if (opts.json)
161
198
  return printJson(res);
162
199
  if (res.note)
@@ -8,9 +8,11 @@ export async function orgList(opts) {
8
8
  for (const o of orgs)
9
9
  info(`${o.id} ${o.name}${o.is_personal ? ' (personal)' : ''} [${o.role}]`);
10
10
  }
11
- export async function orgCreate(name) {
11
+ export async function orgCreate(name, opts = {}) {
12
12
  const api = await ApiClient.load();
13
13
  const { org } = await api.request('POST', '/orgs', { name });
14
+ if (opts.json)
15
+ return printJson(org);
14
16
  info(`created org ${org.id} (${org.name})`);
15
17
  }
16
18
  //# sourceMappingURL=org.js.map
@@ -13,14 +13,19 @@ const GENERIC_DIRS = new Set([
13
13
  'app', 'apps', 'users', 'user', 'bin', 'new', 'test', 'tests',
14
14
  ]);
15
15
  // Best-effort: wire the credential-audit hook into the project (no-op if assets aren't built).
16
- function tryInstallObserve() {
16
+ // quiet: with --json the install still runs, but its note moves to stderr (stdout is JSON-only).
17
+ function tryInstallObserve(quiet = false) {
17
18
  try {
18
19
  const r = installObserve({ cwd: process.cwd() });
19
- if (r.claude || r.codex)
20
- info(' installed observe hook (credential audit) → ./.insta/observe');
20
+ if (r.claude || r.codex) {
21
+ const line = ' installed observe hook (credential audit) → ./.insta/observe';
22
+ quiet ? process.stderr.write(line + '\n') : info(line);
23
+ }
21
24
  }
22
25
  catch { /* assets missing (dev/unbuilt) — skip silently */ }
23
26
  }
27
+ // installSkills prints to stdout by default; with --json its notes go to stderr instead.
28
+ const skillsPrint = (json) => (json ? (s) => void process.stderr.write(s + '\n') : undefined);
24
29
  async function resolveOrg(api, given) {
25
30
  if (given)
26
31
  return given;
@@ -51,7 +56,10 @@ export async function projectCreate(name, opts) {
51
56
  const resolved = resolveProjectName(name, process.cwd());
52
57
  if (!resolved) {
53
58
  // No name given and the cwd name is generic — don't provision resources under a junk name.
54
- // Guide instead (no hang, no error): name it explicitly, or just ask the skill-equipped agent.
59
+ // A terminal gets guidance (no hang, no error); --json is a scripted caller with no human to
60
+ // guide, so it gets a hard error instead of an empty success.
61
+ if (opts.json)
62
+ die('no project name — pass one: insta project create <name>');
55
63
  info('name your project: insta project create <name>');
56
64
  info(' (or just ask your coding agent — it has the insta skill and will do this for you)');
57
65
  return;
@@ -60,12 +68,17 @@ export async function projectCreate(name, opts) {
60
68
  const orgId = await resolveOrg(api, opts.org);
61
69
  const out = await api.request('POST', `/orgs/${orgId}/projects`, { name: resolved });
62
70
  await writeProject({ projectId: out.project.id, orgId, branch: out.defaultBranch.name });
63
- info(`created project ${out.project.id} (${resolved})`);
64
- info(` resources: ${out.resources.map((r) => r.kind).join(', ')}`);
65
- info(` linked ./.insta/project.json (branch ${out.defaultBranch.name})`);
66
- renderNextActions(out.nextActions);
67
- tryInstallObserve();
68
- await installSkills({ cwd: process.cwd() });
71
+ if (opts.json) {
72
+ printJson({ ...out, linked: { projectId: out.project.id, orgId, branch: out.defaultBranch.name } });
73
+ }
74
+ else {
75
+ info(`created project ${out.project.id} (${resolved})`);
76
+ info(` resources: ${out.resources.map((r) => r.kind).join(', ')}`);
77
+ info(` linked ./.insta/project.json (branch ${out.defaultBranch.name})`);
78
+ renderNextActions(out.nextActions);
79
+ }
80
+ tryInstallObserve(opts.json);
81
+ await installSkills({ cwd: process.cwd(), print: skillsPrint(opts.json) });
69
82
  }
70
83
  export async function projectList(opts) {
71
84
  const api = await ApiClient.load();
@@ -78,20 +91,25 @@ export async function projectList(opts) {
78
91
  for (const p of projects)
79
92
  info(`${p.id} ${p.name} [${p.status}]`);
80
93
  }
81
- export async function projectLink(id) {
94
+ export async function projectLink(id, opts = {}) {
82
95
  const api = await ApiClient.load();
83
96
  const { project } = await api.request('GET', `/projects/${id}`);
84
97
  await writeProject({ projectId: project.id, orgId: project.org_id, branch: 'main' });
85
- info(`linked project ${project.id} (${project.name})`);
86
- tryInstallObserve();
87
- await installSkills({ cwd: process.cwd() });
98
+ if (opts.json)
99
+ printJson({ project, linked: { projectId: project.id, orgId: project.org_id, branch: 'main' } });
100
+ else
101
+ info(`linked project ${project.id} (${project.name})`);
102
+ tryInstallObserve(opts.json);
103
+ await installSkills({ cwd: process.cwd(), print: skillsPrint(opts.json) });
88
104
  }
89
105
  export async function projectDelete(opts) {
90
106
  const api = await ApiClient.load();
91
107
  const projectId = opts.project ?? (await requireProject()).projectId;
92
108
  const res = await api.rawRequest('DELETE', `/projects/${projectId}`);
93
- if (handleApproval(res))
109
+ if (handleApproval(res, opts.json))
94
110
  return;
111
+ if (opts.json)
112
+ return printJson({ ok: true, projectId });
95
113
  info(`deleted project ${projectId}`);
96
114
  }
97
115
  //# sourceMappingURL=project.js.map
@@ -4,7 +4,7 @@
4
4
  // as the process does.
5
5
  import { spawn } from 'node:child_process';
6
6
  import { ApiClient, requireProject } from '../api.js';
7
- import { die, info } from '../util.js';
7
+ import { die, handleApproval } from '../util.js';
8
8
  /** Core, dependency-injected for tests: spawn cmd with the bundle in env, return its exit code. */
9
9
  export async function runWithSecrets(cmd, args, deps) {
10
10
  const bundle = await deps.fetchBundle();
@@ -28,10 +28,12 @@ export async function run(cmdAndArgs, opts) {
28
28
  const code = await runWithSecrets(cmd, rest, {
29
29
  fetchBundle: async () => {
30
30
  const res = await api.rawRequest('GET', `/projects/${p.projectId}/secrets?branch=${encodeURIComponent(branch)}`);
31
- if (res.status === 202) {
32
- die(`secrets.read requires approval — run: insta approvals approve ${res.body.approvalId}, then re-run`);
33
- }
34
- info(`running with ${Object.keys(res.body.secrets).length} injected secrets (branch ${branch}) — nothing written to disk`);
31
+ // exit() with no argument honors the exit code handleApproval just set (2).
32
+ if (handleApproval(res))
33
+ process.exit();
34
+ // stderr, not stdout: `insta run`'s stdout belongs entirely to the child command (that's why
35
+ // run has no --json — wrapping would break the child's own output contract).
36
+ process.stderr.write(`running with ${Object.keys(res.body.secrets).length} injected secrets (branch ${branch}) — nothing written to disk\n`);
35
37
  return res.body.secrets;
36
38
  },
37
39
  });
@@ -12,7 +12,7 @@ export async function secrets(opts) {
12
12
  const p = await requireProject();
13
13
  const branch = opts.branch ?? p.branch;
14
14
  const res = await api.rawRequest('GET', `/projects/${p.projectId}/secrets${q(branch)}`);
15
- if (handleApproval(res))
15
+ if (handleApproval(res, opts.json))
16
16
  return;
17
17
  const bundle = res.body.secrets;
18
18
  if (opts.json)
@@ -47,7 +47,7 @@ export async function secretsTree(opts) {
47
47
  const api = await ApiClient.load();
48
48
  const p = await requireProject();
49
49
  const res = await api.rawRequest('GET', `/projects/${p.projectId}/secrets/tree`);
50
- if (handleApproval(res))
50
+ if (handleApproval(res, opts.json))
51
51
  return;
52
52
  const tree = res.body;
53
53
  if (opts.json)
@@ -68,7 +68,7 @@ export async function secretsList(opts) {
68
68
  const p = await requireProject();
69
69
  const branch = opts.branch ?? p.branch;
70
70
  const res = await api.rawRequest('GET', `/projects/${p.projectId}/secrets/tree`);
71
- if (handleApproval(res))
71
+ if (handleApproval(res, opts.json))
72
72
  return;
73
73
  const tree = res.body;
74
74
  const b = tree.branches.find((x) => x.name === branch);
@@ -102,8 +102,10 @@ export async function secretsSet(name, value, opts) {
102
102
  const branch = opts.service ? (opts.branch ?? p.branch) : opts.branch;
103
103
  const payload = { value: v, ...(branch ? { branch } : {}), ...(opts.service ? { service: opts.service } : {}) };
104
104
  const res = await api.rawRequest('PUT', `/projects/${p.projectId}/secrets/${encodeURIComponent(name)}`, payload);
105
- if (handleApproval(res))
105
+ if (handleApproval(res, opts.json))
106
106
  return;
107
+ if (opts.json)
108
+ return printJson({ ok: true, name, branch: branch ?? null, service: opts.service ?? null });
107
109
  info(`set ${name}${opts.service ? ` → ${opts.service}` : ''} (${branch ? `branch ${branch}` : 'project-wide'})`);
108
110
  }
109
111
  export async function secretsUnset(name, opts) {
@@ -111,10 +113,75 @@ export async function secretsUnset(name, opts) {
111
113
  const p = await requireProject();
112
114
  const qs = opts.branch ? `?branch=${encodeURIComponent(opts.branch)}` : '';
113
115
  const res = await api.rawRequest('DELETE', `/projects/${p.projectId}/secrets/${encodeURIComponent(name)}${qs}`);
114
- if (handleApproval(res))
116
+ if (handleApproval(res, opts.json))
115
117
  return;
118
+ if (opts.json)
119
+ return printJson({ ok: true, name, branch: opts.branch ?? null });
116
120
  info(`unset ${name} (${opts.branch ? `branch ${opts.branch}` : 'project-wide'})`);
117
121
  }
122
+ export async function secretsBind(envName, source, opts) {
123
+ if (!opts.to)
124
+ die('--to <compute/name> is required');
125
+ const api = await ApiClient.load();
126
+ const p = await requireProject();
127
+ const branch = opts.branch ?? p.branch;
128
+ const res = await api.rawRequest('PUT', `/projects/${p.projectId}/secret-bindings/${encodeURIComponent(envName)}`, {
129
+ branch,
130
+ target: opts.to,
131
+ source,
132
+ ...(opts.sourceName ? { sourceName: opts.sourceName } : {}),
133
+ });
134
+ if (handleApproval(res, opts.json))
135
+ return;
136
+ if (opts.json)
137
+ return printJson({ ok: true });
138
+ info(`bound ${envName} on ${opts.to} to ${source}${opts.sourceName ? `.${opts.sourceName}` : ''} (branch ${branch})`);
139
+ }
140
+ export async function secretsUnbind(envName, opts) {
141
+ if (!opts.from)
142
+ die('--from <compute/name> is required');
143
+ const api = await ApiClient.load();
144
+ const p = await requireProject();
145
+ const branch = opts.branch ?? p.branch;
146
+ const res = await api.rawRequest('DELETE', `/projects/${p.projectId}/secret-bindings/${encodeURIComponent(envName)}?branch=${encodeURIComponent(branch)}&target=${encodeURIComponent(opts.from)}`);
147
+ if (handleApproval(res, opts.json))
148
+ return;
149
+ if (opts.json)
150
+ return printJson({ ok: true });
151
+ info(`unbound ${envName} from ${opts.from} (branch ${branch})`);
152
+ }
153
+ export async function secretsBindings(opts) {
154
+ if (!opts.target)
155
+ die('--target <compute/name> is required');
156
+ const api = await ApiClient.load();
157
+ const p = await requireProject();
158
+ const branch = opts.branch ?? p.branch;
159
+ const res = await api.rawRequest('GET', `/projects/${p.projectId}/secret-bindings?branch=${encodeURIComponent(branch)}&target=${encodeURIComponent(opts.target)}`);
160
+ if (handleApproval(res, opts.json))
161
+ return;
162
+ const bindings = res.body.bindings ?? [];
163
+ if (opts.json)
164
+ return printJson(bindings);
165
+ if (!bindings.length)
166
+ return info(`(no secret bindings for ${opts.target} on ${branch})`);
167
+ for (const b of bindings)
168
+ info(`${b.envName} <- ${b.source.type}/${b.source.name}.${b.sourceName}`);
169
+ }
170
+ export async function secretsSources(opts) {
171
+ const api = await ApiClient.load();
172
+ const p = await requireProject();
173
+ const branch = opts.branch ?? p.branch;
174
+ const res = await api.rawRequest('GET', `/projects/${p.projectId}/secret-sources?branch=${encodeURIComponent(branch)}`);
175
+ if (handleApproval(res, opts.json))
176
+ return;
177
+ const sources = res.body.sources ?? [];
178
+ if (opts.json)
179
+ return printJson(sources);
180
+ if (!sources.length)
181
+ return info(`(no credential sources on ${branch})`);
182
+ for (const s of sources)
183
+ info(`${s.service.type}/${s.service.name}: ${s.secrets.join(', ')}`);
184
+ }
118
185
  /** Gitignore the env file we just wrote (git repos only; idempotent). Returns true if added. */
119
186
  export function ensureIgnored(cwd, name) {
120
187
  if (!existsSync(join(cwd, '.git')))
@@ -1,7 +1,7 @@
1
- // `insta services` — manage a project's opt-in services (postgres | storage | compute | redis).
1
+ // `insta services` — manage a project's opt-in services (postgres | storage | compute | redis | mysql | mongodb).
2
2
  import { ApiClient, requireProject } from '../api.js';
3
3
  import { info, printJson, handleApproval, renderNextActions } from '../util.js';
4
- export const SERVICE_TYPES = ['postgres', 'storage', 'compute', 'redis'];
4
+ export const SERVICE_TYPES = ['postgres', 'storage', 'compute', 'redis', 'mysql', 'mongodb'];
5
5
  const SERVICE_NAME_RE = /^[a-z0-9][a-z0-9-]{0,38}$/;
6
6
  export function q(branch) {
7
7
  return branch ? `?branch=${encodeURIComponent(branch)}` : '';
@@ -72,6 +72,9 @@ export function resolveSoleService(services, type, name) {
72
72
  export function resolveComputeServiceId(services, name) {
73
73
  return resolveSoleService(services, 'compute', name).id;
74
74
  }
75
+ function defaultDatabasePort(type) {
76
+ return type === 'mysql' ? 3306 : type === 'mongodb' ? 27017 : 6379;
77
+ }
75
78
  // Map service-add options to the platform POST body. Pure, so it's unit-tested without a network
76
79
  // mock (mirrors deployRequestBody in deploy.ts). Validation (which options are valid for which
77
80
  // type) stays in servicesAdd, ahead of any network/config access.
@@ -108,7 +111,7 @@ export async function servicesAdd(type, name, opts = {}) {
108
111
  const p = await requireProject();
109
112
  const branch = opts.branch ?? p.branch;
110
113
  const res = await api.rawRequest('POST', `/projects/${p.projectId}/services`, servicesAddRequestBody(type, name, branch, opts));
111
- if (handleApproval(res))
114
+ if (handleApproval(res, opts.json))
112
115
  return;
113
116
  if (opts.json)
114
117
  return printJson(res.body.service);
@@ -124,7 +127,7 @@ export async function servicesAdd(type, name, opts = {}) {
124
127
  export function serviceListLine(s) {
125
128
  const extra = s.type === 'compute'
126
129
  ? ` x${s.machine_count}${s.volume_gib ? ` vol ${s.volume_gib}Gi` : ''}${s.image ? ` running ${s.image}${s.port ? `:${s.port}` : ''}` : ''}`
127
- : s.type === 'redis' ? ` tcp/${s.port ?? 6379}${s.volume_gib ? ` vol ${s.volume_gib}Gi` : ''}`
130
+ : ['redis', 'mysql', 'mongodb'].includes(s.type) ? ` tcp/${s.port ?? defaultDatabasePort(s.type)}${s.volume_gib ? ` vol ${s.volume_gib}Gi` : ''}`
128
131
  : s.type === 'storage' ? ` ${s.public ? 'public' : 'private'}` : '';
129
132
  return `${s.type}/${s.name} [${s.status}]${extra}${s.domain ? ` ${s.domain}` : ''} ${s.id}`;
130
133
  }
@@ -136,7 +139,7 @@ export async function servicesList(opts) {
136
139
  if (opts.json)
137
140
  return printJson(services);
138
141
  if (!services.length)
139
- return info(`(no services on ${branch ?? 'default'} — add one with \`insta services add <postgres|storage|compute|redis> <name>\`)`);
142
+ return info(`(no services on ${branch ?? 'default'} — add one with \`insta services add <postgres|storage|compute|redis|mysql|mongodb> <name>\`)`);
140
143
  for (const s of services)
141
144
  info(serviceListLine(s));
142
145
  }
@@ -148,8 +151,10 @@ export async function servicesRemove(type, name, opts = {}) {
148
151
  const { services } = await api.request('GET', `/projects/${p.projectId}/services${q(branch)}`);
149
152
  const id = resolveServiceId(services, type, name);
150
153
  const res = await api.rawRequest('DELETE', `/projects/${p.projectId}/services/${id}`);
151
- if (handleApproval(res))
154
+ if (handleApproval(res, opts.json))
152
155
  return;
156
+ if (opts.json)
157
+ return printJson({ ok: true, removed: { id, type, name, branch: branch ?? null } });
153
158
  info(`removed ${type} service ${name} from ${branch ?? 'default'}`);
154
159
  }
155
160
  export async function servicesRename(type, name, newName, opts = {}) {
@@ -161,7 +166,7 @@ export async function servicesRename(type, name, newName, opts = {}) {
161
166
  const { services } = await api.request('GET', `/projects/${p.projectId}/services${q(branch)}`);
162
167
  const id = resolveServiceId(services, type, name);
163
168
  const res = await api.rawRequest('POST', `/projects/${p.projectId}/services/${id}/rename`, { name: newName });
164
- if (handleApproval(res))
169
+ if (handleApproval(res, opts.json))
165
170
  return;
166
171
  if (opts.json)
167
172
  return printJson(res.body.service);
@@ -184,7 +189,7 @@ export async function servicesSetAccess(type, name, access, _opts) {
184
189
  const { services } = await api.request('GET', `/projects/${p.projectId}/services${q(p.branch)}`);
185
190
  const id = resolveServiceId(services, type, name);
186
191
  const res = await api.rawRequest('PUT', `/projects/${p.projectId}/services/${id}/access`, { public: isPublic });
187
- if (handleApproval(res))
192
+ if (handleApproval(res, _opts.json))
188
193
  return;
189
194
  if (_opts.json)
190
195
  return printJson(res.body.service);
@@ -199,7 +204,7 @@ export async function servicesScale(type, name, number, region, _opts) {
199
204
  const { services } = await api.request('GET', `/projects/${p.projectId}/services${q(_opts.branch ?? p.branch)}`);
200
205
  const id = resolveServiceId(services, type, name);
201
206
  const res = await api.rawRequest('POST', `/projects/${p.projectId}/services/${id}/scale`, { machineCount, region });
202
- if (handleApproval(res))
207
+ if (handleApproval(res, _opts.json))
203
208
  return;
204
209
  if (_opts.json)
205
210
  return printJson(res.body.service);
@@ -213,7 +218,7 @@ export async function servicesUpgrade(type, name, spec, _opts) {
213
218
  const { services } = await api.request('GET', `/projects/${p.projectId}/services${q(_opts.branch ?? p.branch)}`);
214
219
  const id = resolveServiceId(services, type, name);
215
220
  const res = await api.rawRequest('POST', `/projects/${p.projectId}/services/${id}/upgrade`, { spec });
216
- if (handleApproval(res))
221
+ if (handleApproval(res, _opts.json))
217
222
  return;
218
223
  if (_opts.json)
219
224
  return printJson(res.body.service);
@@ -227,7 +232,7 @@ export async function servicesSecrets(type, name, opts = {}) {
227
232
  const { services } = await api.request('GET', `/projects/${p.projectId}/services${q(opts.branch ?? p.branch)}`);
228
233
  const id = resolveServiceId(services, type, name);
229
234
  const res = await api.rawRequest('GET', `/projects/${p.projectId}/services/${id}/secrets`);
230
- if (handleApproval(res))
235
+ if (handleApproval(res, opts.json))
231
236
  return;
232
237
  const { secrets } = res.body;
233
238
  if (opts.json)