insta 0.1.6 → 0.1.8

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
@@ -231,7 +231,7 @@ build never reaches a production installer.
231
231
  | `insta billing` | Current cycle overview; `subscribe <tier>` · `portal` · `usage` |
232
232
  | `insta agent` | `setup` (this machine's coding agents) · `manifest` · `policy …` · `approvals …` · `observe …` · `events` |
233
233
  | `insta config` | `install-mcp` · `regions` · `autoupdate` |
234
- | `insta feedback` | Report an InstaCloud-side hurdle (bug / feature-request / friction) to the team — never for the app you are building; on InstaCloud it needs `insta login`, so the team can reply |
234
+ | `insta feedback` | Report an InstaCloud-side hurdle (bug / feature-request / friction) to the team — never for the app you are building; on InstaCloud it needs `insta login`, so the team can reply, and prints the ticket it opened; `status <ticket-id>` — New / In Progress / Resolved / Closed (the replies are in the console) |
235
235
  | `insta upgrade` | Update the CLI |
236
236
 
237
237
  Every command accepts `--api-url <url>` for this invocation only (internal debugging); for `compute exec`, place it before `compute`. `insta --help` documents it.
@@ -590,9 +590,25 @@ export async function serviceAlwaysOn(type, mode, serviceName, opts) {
590
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
591
  }
592
592
  export const computeAlwaysOn = (mode, serviceName, opts) => serviceAlwaysOn('compute', mode, serviceName, opts);
593
+ const INSTANCE_ID = /^inst-[a-f0-9]{12}$/;
594
+ // A lone positional is the service even when it is all digits: service names may be.
595
+ export function removeTarget(count, service, opts) {
596
+ if (!INSTANCE_ID.test(opts.remove))
597
+ throw new Error(`invalid instance id: ${opts.remove} (expected inst-<12 hex digits>, the \`instance\` field of \`insta compute logs --json\`)`);
598
+ if (opts.region !== undefined)
599
+ throw new Error('--region does not apply to --remove');
600
+ if (service !== undefined) {
601
+ throw new Error(count !== undefined && count.trim() !== '' && Number.isFinite(Number(count)) ? '--remove lowers the replica count by one; pass no count' : `unexpected argument: ${service} (usage: insta compute scale [service] --remove <instance>)`);
602
+ }
603
+ return count;
604
+ }
593
605
  // `insta compute scale <count> [service] [--region <r>]` — POST /services/:id/scale. Count is
594
606
  // validated locally (1..10); the paid-plan gate is the backend's and its 403 flows verbatim.
595
607
  export async function computeScale(count, serviceName, opts) {
608
+ if (opts.remove !== undefined)
609
+ return computeRemoveInstance(removeTarget(count, serviceName, { remove: opts.remove, region: opts.region }), opts.remove, opts);
610
+ if (count === undefined)
611
+ throw new Error('a replica count is required (or --remove <instance> to drop one named instance)');
596
612
  const machineCount = parseCount(count);
597
613
  const api = await ApiClient.load();
598
614
  const p = await requireProject();
@@ -606,6 +622,20 @@ export async function computeScale(count, serviceName, opts) {
606
622
  return printJson(res.body.service);
607
623
  info(`scaled compute ${svc.name} to ${machineCount} replica(s)${opts.region ? ` in ${opts.region}` : ''}`);
608
624
  }
625
+ // `insta compute scale [service] --remove <instance>` — DELETE /services/:id/instances/:instance.
626
+ async function computeRemoveInstance(serviceName, instance, opts) {
627
+ const api = await ApiClient.load();
628
+ const p = await requireProject();
629
+ const branch = opts.branch ?? p.branch;
630
+ const { services } = await api.request('GET', `/projects/${p.projectId}/services${q(branch)}`);
631
+ const svc = resolveSoleService(services, 'compute', serviceName);
632
+ const res = await api.rawRequest('DELETE', `/projects/${p.projectId}/services/${svc.id}/instances/${instance}`);
633
+ if (handleApproval(res, opts.json))
634
+ return;
635
+ if (opts.json)
636
+ return printJson(res.body.service);
637
+ info(`removed ${instance} from compute ${svc.name}; ${res.body.service?.machine_count ?? '?'} replica(s) remain`);
638
+ }
609
639
  // ---- limits (the resource ceiling; paid plans) ----
610
640
  // Parse a human memory value into MB: "512", "512mb", "1gb", "2g", "1.5gb".
611
641
  // Exported for unit tests — this is the only place a user-typed size becomes a number.
@@ -13,7 +13,7 @@ import * as clack from '@clack/prompts';
13
13
  import { ApiClient, ApiError } from '../api.js';
14
14
  import { readGlobal, readProject } from '../config.js';
15
15
  import { envForApiUrl } from '../env.js';
16
- import { info, printJson, refuse, CliCancel } from '../util.js';
16
+ import { info, printJson, refuse, CliCancel, CliExit } from '../util.js';
17
17
  import { clean } from '../redact.js';
18
18
  import { cliVersion } from '../version.js';
19
19
  export const TYPES = ['bug', 'feature-request', 'friction', 'other'];
@@ -186,20 +186,24 @@ export async function submit(payload, fetchImpl, assertion) {
186
186
  catch { /* non-JSON body — fall through to status handling */ }
187
187
  if (!res.ok)
188
188
  return { status: 'error', error: body?.error ?? `HTTP ${res.status}` };
189
- return { status: body?.status === 'duplicate' ? 'duplicate' : 'received', id: body?.id ?? null };
189
+ return { status: body?.status === 'duplicate' ? 'duplicate' : 'received', id: body?.id ?? null, ticket: body?.ticket };
190
190
  }
191
191
  const SIGNED_OUT = 'not signed in to InstaCloud — run `insta login`, then send this again so the team can reply to you';
192
192
  const STAGING = 'not accepted from staging — send InstaCloud feedback from production';
193
+ // Bearer only: agent evidence adds a session round trip the timeout cannot bound, and 401s signing in cannot fix.
194
+ async function userAssertion(api) {
195
+ return (await api.request('GET', '/me/feedback-assertion', undefined, { evidence: false, signal: AbortSignal.timeout(ASSERTION_TIMEOUT_MS) })).token;
196
+ }
193
197
  // Exit 2, not the submit path's 0: the caller can act on this one.
194
198
  function refuseFeedback(message, json) {
195
199
  if (json)
196
200
  printJson({ status: 'refused', submitted: false, error: message });
197
201
  refuse([`insta feedback: ${message}`]);
198
202
  }
199
- function inputError(e, json) {
203
+ function inputError(e, json, fields = { submitted: false }) {
200
204
  if (!json)
201
205
  throw e;
202
- printJson({ status: 'error', submitted: false, error: e instanceof Error ? e.message : String(e) });
206
+ printJson({ status: 'error', ...fields, error: e instanceof Error ? e.message : String(e) });
203
207
  process.exitCode = 1;
204
208
  }
205
209
  export async function feedback(opts, deps = {}) {
@@ -235,8 +239,7 @@ export async function feedback(opts, deps = {}) {
235
239
  let assertion;
236
240
  if (env === 'prod') {
237
241
  try {
238
- // Bearer only: agent evidence adds a session round trip the timeout cannot bound, and 401s signing in cannot fix.
239
- assertion = (await api.request('GET', '/me/feedback-assertion', undefined, { evidence: false, signal: AbortSignal.timeout(ASSERTION_TIMEOUT_MS) })).token;
242
+ assertion = await userAssertion(api);
240
243
  }
241
244
  catch (e) {
242
245
  if (e instanceof ApiError && e.status === 401)
@@ -262,13 +265,66 @@ export async function feedback(opts, deps = {}) {
262
265
  return;
263
266
  }
264
267
  if (opts.json)
265
- return printJson({ status: result.status, id: result.id });
268
+ return printJson({ status: result.status, id: result.id, ticket: result.ticket });
266
269
  if (result.status === 'duplicate') {
267
270
  info(`already reported this week — bumped its count instead (id: ${result.id})`);
268
271
  }
269
272
  else {
270
273
  info(`feedback submitted (id: ${result.id}) — thank you!`);
271
274
  }
275
+ if (result.ticket) {
276
+ info(`ticket ${result.ticket.id} — the team's replies are in the console: ${result.ticket.url}`);
277
+ info(`check its status with \`insta feedback status ${result.ticket.id}\``);
278
+ }
272
279
  info('PII (emails, tokens, keys, home paths) was redacted before sending.');
273
280
  }
281
+ const STATUS_LABEL = { open: 'New', in_progress: 'In Progress', resolved: 'Resolved', closed: 'Closed' };
282
+ const SIGNED_OUT_STATUS = 'not signed in to InstaCloud — run `insta login` to check your ticket';
283
+ function refuseStatus(message, json) {
284
+ if (json)
285
+ printJson({ status: 'refused', error: message });
286
+ refuse([`insta feedback status: ${message}`]);
287
+ }
288
+ export async function feedbackStatus(id, opts, deps = {}) {
289
+ try {
290
+ const api = deps.api ?? await ApiClient.load();
291
+ const env = envForApiUrl(api.apiUrl);
292
+ if (env !== 'prod')
293
+ refuseStatus('ticket status is only available on production InstaCloud', opts.json);
294
+ if (!api.config.accessToken)
295
+ refuseStatus(SIGNED_OUT_STATUS, opts.json);
296
+ let assertion;
297
+ try {
298
+ assertion = await userAssertion(api);
299
+ }
300
+ catch (e) {
301
+ if (e instanceof ApiError && e.status === 401)
302
+ refuseStatus(SIGNED_OUT_STATUS, opts.json);
303
+ throw e;
304
+ }
305
+ // Relative, so an endpoint served under a path prefix keeps it.
306
+ const res = await (deps.fetchImpl ?? fetch)(new URL(`tickets/${encodeURIComponent(id)}`, FEEDBACK_ENDPOINT), {
307
+ headers: { Authorization: `Bearer ${FEEDBACK_INGEST_TOKEN}`, 'Insta-User-Assertion': assertion },
308
+ signal: AbortSignal.timeout(FEEDBACK_TIMEOUT_MS),
309
+ });
310
+ if (res.status === 404)
311
+ throw new Error(`no ticket ${id} of yours — use the ticket id \`insta feedback\` printed`);
312
+ // The platform vouched for this login a moment ago: a 401 here is the service's, not a signed-out user.
313
+ if (res.status === 401)
314
+ throw new Error('the feedback service could not verify who you are — signing in again will not fix it');
315
+ const body = (await res.json().catch(() => ({})));
316
+ if (!res.ok || !body.status)
317
+ throw new Error(`the feedback service answered ${res.status}${body.error ? `: ${body.error}` : ''}`);
318
+ if (opts.json)
319
+ return printJson({ id: body.id, status: body.status, url: body.url });
320
+ info(`ticket ${body.id}: ${STATUS_LABEL[body.status] ?? body.status}`);
321
+ info(`open it in the console: ${body.url}`);
322
+ }
323
+ catch (e) {
324
+ // A refusal has already printed and set exit 2; handled again it would print twice and exit 1.
325
+ if (e instanceof CliExit)
326
+ throw e;
327
+ return inputError(e, opts.json, {});
328
+ }
329
+ }
274
330
  //# sourceMappingURL=feedback.js.map
package/dist/index.js CHANGED
@@ -365,8 +365,9 @@ compute.command('restart [service]').description("Restart a compute service by r
365
365
  .option('--json').option('--branch <branch>', 'branch (default: current)').action(guard((service, o) => computeCmd.computeRestart(service, o)));
366
366
  compute.command('status [service]').description("Show a compute service's desired vs. live state")
367
367
  .option('--json').option('--branch <branch>', 'branch (default: current)').action(guard((service, o) => computeCmd.computeStatus(service, o)));
368
- compute.command('scale <count> [service]').description('Set a compute service same-region replica count, 1 to 10 (paid plans only)')
368
+ compute.command('scale [count] [service]').description('Set a compute service same-region replica count, 1 to 10 (paid plans only). --remove <instance> instead drops that one instance and runs one fewer; instance ids are the `instance` field of `insta compute logs --json`. The last instance cannot be removed')
369
369
  .option('--region <region>', 'region to scale in (default: the service region)')
370
+ .option('--remove <instance>', 'drop this instance (inst-<12 hex>) and run one fewer replica')
370
371
  .option('--json').option('--branch <branch>', 'branch (default: current)')
371
372
  .action(guard((count, service, o) => computeCmd.computeScale(count, service, o)));
372
373
  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")
@@ -591,7 +592,7 @@ cfg.command('autoupdate [mode]').description('Show or set auto-update: on | off
591
592
  const setupCompat = program.command('setup', { hidden: true }).description('Compatibility alias: `insta setup agent` is `insta agent setup`');
592
593
  withSetupAgentOptions(setupCompat.command('agent').description('Alias of `insta agent setup`, kept for the console one-liner'));
593
594
  // ---- feedback (agent + human hurdle reports → the InstaCloud team) ----
594
- program.command('feedback')
595
+ const fb = program.command('feedback')
595
596
  .description('Report an InstaCloud-side hurdle (bug / missing feature / friction) to the InstaCloud team — about the insta toolkit itself, NEVER about the app you are building. On InstaCloud it needs `insta login`, so the team can reply; works unlinked.')
596
597
  .option('--type <type>', `what kind of hurdle: ${feedbackCmd.TYPES.join(' | ')}`)
597
598
  .option('--component <component>', `which part of the toolkit: ${feedbackCmd.COMPONENTS.join(' | ')}`)
@@ -607,6 +608,12 @@ program.command('feedback')
607
608
  .option('--severity <severity>', `${feedbackCmd.SEVERITIES.join(' | ')} (default: minor)`)
608
609
  .option('--json')
609
610
  .action(guard((o) => feedbackCmd.feedback(o)));
611
+ // As with billing: a mistyped subcommand must fail, not open the report wizard.
612
+ fb.allowExcessArguments(false);
613
+ fb.command('status <ticket-id>')
614
+ .description('Show the status of a ticket `insta feedback` opened: New, In Progress, Resolved or Closed. The replies are read and answered in the console.')
615
+ .option('--json')
616
+ .action(guard((id, o) => feedbackCmd.feedbackStatus(id, o)));
610
617
  // ---- self-update ----
611
618
  program.command('upgrade').description('Update the insta CLI to the latest release (binary or npm install)')
612
619
  .action(guard(() => selfUpdate.upgrade(cliVersion())));
package/dist/telemetry.js CHANGED
@@ -34,7 +34,7 @@ const SAFE_ARGS = {
34
34
  'env use': { 0: ENV }, 'project link': { 0: ID },
35
35
  'service add': { 0: SERVICE }, 'service remove': { 0: SERVICE }, 'service rename': { 0: SERVICE },
36
36
  'storage set-access': { 0: oneOf(['public', 'private']) },
37
- 'compute scale': { 0: NUMBER },
37
+ 'compute scale': { 0: (v) => /^\d+$/.test(v) },
38
38
  'compute always-on': { 0: ON_OFF }, 'postgres always-on': { 0: ON_OFF },
39
39
  'redis always-on': { 0: ON_OFF }, 'mysql always-on': { 0: ON_OFF }, 'mongodb always-on': { 0: ON_OFF },
40
40
  'template info': { 0: SLUG }, 'billing subscribe': { 0: oneOf(['pro', 'team']) },
@@ -69,8 +69,9 @@ export function redactOptions(opts) {
69
69
  }
70
70
  return out;
71
71
  }
72
- export function redactArgs(command, args) {
73
- const checks = SAFE_ARGS[command] ?? {};
72
+ export function redactArgs(command, args, options = {}) {
73
+ // with --remove the count's slot holds the service name
74
+ const checks = command === 'compute scale' && options.remove !== undefined ? {} : SAFE_ARGS[command] ?? {};
74
75
  return args.map((a, i) => (a === undefined ? null : typeof a === 'string' && checks[i]?.(a) ? a : REDACTED));
75
76
  }
76
77
  /** The deployment a `login --env|--api-url` targets. It is persisted only when the login succeeds, so
@@ -125,7 +126,7 @@ export function buildCommandEvent(command, args, options, outcome, ctx) {
125
126
  timestamp: new Date().toISOString(),
126
127
  properties: {
127
128
  command,
128
- args: redactArgs(command, args),
129
+ args: redactArgs(command, args, options),
129
130
  options: redactOptions(options),
130
131
  success: !cancelled && (outcome.exitCode === 0 || ranChild),
131
132
  cancelled,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "insta",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "type": "module",
5
5
  "description": "InstaCloud CLI — a thin client of the platform control-plane API.",
6
6
  "keywords": [