insta 0.1.10 → 0.1.12
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/dist/commands/billing.js +58 -1
- package/dist/commands/postgres.js +55 -10
- package/dist/index.js +6 -3
- package/package.json +1 -1
package/dist/commands/billing.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ApiClient, requireProject } from '../api.js';
|
|
1
|
+
import { ApiClient, ApiError, requireProject } from '../api.js';
|
|
2
2
|
import { die, info, openUrl, printJson } from '../util.js';
|
|
3
3
|
import { cycleLine, dimensionLines } from './metrics.js';
|
|
4
4
|
// Resolve the target org: explicit --org, else the linked project's org.
|
|
@@ -100,6 +100,63 @@ export async function billingUpgrade(tier, opts) {
|
|
|
100
100
|
return printJson({ url });
|
|
101
101
|
presentUrl(url, `Subscribe to ${tier} — complete checkout in your browser:`, opts.open);
|
|
102
102
|
}
|
|
103
|
+
// The platform's contract (RedeemError#reason in billing/service.ts) — four literals, never
|
|
104
|
+
// collapsed into one message, because the right next action differs per reason: `not_found` is
|
|
105
|
+
// the only one worth retyping; `expired`/`revoked` both mean waiting never helps, but only
|
|
106
|
+
// `revoked` has someone to go back to.
|
|
107
|
+
//
|
|
108
|
+
// `already_redeemed` deliberately does NOT say WHO redeemed it, because this CLI cannot know.
|
|
109
|
+
// A sequential retry replays the receipt and never reaches here — but two requests from the same
|
|
110
|
+
// org in flight at once both miss the replay read (it sees committed rows only), one wins the
|
|
111
|
+
// claim and the other lands exactly here. That is the case a client resending a request it never
|
|
112
|
+
// saw an answer to is IN. Telling that person "a different org took it" would be false about
|
|
113
|
+
// their own money: the grant is on their wallet. So the message points them at their balance
|
|
114
|
+
// instead of at a conclusion this side cannot support.
|
|
115
|
+
const REDEEM_ERROR_MESSAGE = {
|
|
116
|
+
not_found: 'that code was not recognized — check it for typos and try again',
|
|
117
|
+
expired: 'that code has expired — ask whoever sent it for a new one',
|
|
118
|
+
already_redeemed: 'that code has already been redeemed — if it was your org, the credits are already there (check `insta billing`)',
|
|
119
|
+
revoked: 'that code was revoked — contact whoever sent it to you',
|
|
120
|
+
};
|
|
121
|
+
// Pure — the human line for a redeem failure. `reason` is the platform's literal (RedeemError's
|
|
122
|
+
// four values); anything else (a platform version ahead of this CLI) falls back to relaying it
|
|
123
|
+
// as-is rather than a message that would be wrong about which of the four cases this is.
|
|
124
|
+
export function redeemErrorMessage(reason, fallback) {
|
|
125
|
+
return (reason && REDEEM_ERROR_MESSAGE[reason]) ?? fallback;
|
|
126
|
+
}
|
|
127
|
+
// Pure — the three facts the spec names, in the order it names them: what landed, when it stops
|
|
128
|
+
// being spendable, and which org — by NAME — so someone who belongs to several orgs isn't left
|
|
129
|
+
// reading a uuid to find out which one just got the money.
|
|
130
|
+
export function redeemLines(r) {
|
|
131
|
+
return [
|
|
132
|
+
`credited: $${(r.amountMicros / 1_000_000).toFixed(2)}`,
|
|
133
|
+
`expires: ${r.creditsExpireAt ? r.creditsExpireAt.slice(0, 10) : 'never'}`,
|
|
134
|
+
`org: ${r.orgName}`,
|
|
135
|
+
];
|
|
136
|
+
}
|
|
137
|
+
// insta billing redeem <code> — apply a one-time credit code to the org's wallet.
|
|
138
|
+
export async function billingRedeem(code, opts) {
|
|
139
|
+
const api = await ApiClient.load();
|
|
140
|
+
const orgId = await resolveOrgId(opts);
|
|
141
|
+
let res;
|
|
142
|
+
try {
|
|
143
|
+
res = await api.request('POST', `/orgs/${orgId}/billing/redeem`, { code });
|
|
144
|
+
}
|
|
145
|
+
catch (e) {
|
|
146
|
+
if (!(e instanceof ApiError) || e.status !== 400)
|
|
147
|
+
throw e;
|
|
148
|
+
// Under --json, stdout stays the one parseable document a script branches on (the platform's
|
|
149
|
+
// own `error` literal); the human line below is the diagnostic and belongs on stderr.
|
|
150
|
+
const reason = e.body?.error;
|
|
151
|
+
if (opts.json)
|
|
152
|
+
printJson({ error: reason ?? e.message });
|
|
153
|
+
die(redeemErrorMessage(reason, e.message));
|
|
154
|
+
}
|
|
155
|
+
if (opts.json)
|
|
156
|
+
return printJson(res);
|
|
157
|
+
for (const l of redeemLines(res))
|
|
158
|
+
info(l);
|
|
159
|
+
}
|
|
103
160
|
// insta billing portal — open the Stripe Customer Portal (change plan / card / cancel).
|
|
104
161
|
export async function billingPortal(opts) {
|
|
105
162
|
const api = await ApiClient.load();
|
|
@@ -3,13 +3,22 @@ import { constants as osConstants } from 'node:os';
|
|
|
3
3
|
import { ApiClient, ApiError, requireProject } from '../api.js';
|
|
4
4
|
import { info, printJson, handleApproval, relayExitCode } from '../util.js';
|
|
5
5
|
import { parseVolumeGib, q, resolveSoleService } from './services.js';
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
export async function dbAlwaysOn(mode, service, opts) {
|
|
11
|
-
if (mode !== 'on' && mode !== 'off')
|
|
6
|
+
export function alwaysOnArgs(first, second) {
|
|
7
|
+
if (first === 'on' || first === 'off')
|
|
8
|
+
return { mode: first, service: second };
|
|
9
|
+
if (second !== undefined)
|
|
12
10
|
throw new Error('mode must be on|off');
|
|
11
|
+
return { service: first };
|
|
12
|
+
}
|
|
13
|
+
export function alwaysOnLine(group, scaleToZero) {
|
|
14
|
+
return `postgres ${group}: always-on ${scaleToZero === false ? 'on — instance stays warm' : scaleToZero === true ? 'off — scales to zero when idle' : 'unknown — the provider did not report it'}`;
|
|
15
|
+
}
|
|
16
|
+
// Show or set a postgres service's idle mode: scale-to-zero (the default: instance suspends when
|
|
17
|
+
// idle, cold-starts on the next connection) or always-on (instance stays warm; idle RAM bills at
|
|
18
|
+
// actual usage). Reads GET /database/instance, sets PATCH /database/settings {scaleToZero} —
|
|
19
|
+
// insta-db-backed postgres only.
|
|
20
|
+
export async function dbAlwaysOn(first, second, opts) {
|
|
21
|
+
const { mode, service } = alwaysOnArgs(first, second);
|
|
13
22
|
const api = await ApiClient.load();
|
|
14
23
|
const p = await requireProject();
|
|
15
24
|
const qs = new URLSearchParams();
|
|
@@ -18,13 +27,49 @@ export async function dbAlwaysOn(mode, service, opts) {
|
|
|
18
27
|
qs.set('branch', branch);
|
|
19
28
|
if (service)
|
|
20
29
|
qs.set('group', service);
|
|
21
|
-
const
|
|
30
|
+
const suffix = qs.toString() ? `?${qs}` : '';
|
|
31
|
+
if (!mode) {
|
|
32
|
+
const read = await fetchDbInstance(api, p.projectId, suffix);
|
|
33
|
+
if (read.kind === 'no-instance') {
|
|
34
|
+
info(`postgres ${service ?? 'default'}: no manageable instance (this service manages its own resources)`);
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
if (opts.json)
|
|
38
|
+
return printJson(read.body);
|
|
39
|
+
info(alwaysOnLine(service ?? 'default', read.body?.scaleToZero));
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
const res = await api.rawRequest('PATCH', `/projects/${p.projectId}/database/settings${suffix}`, { scaleToZero: mode !== 'on' });
|
|
22
43
|
if (handleApproval(res, opts.json))
|
|
23
44
|
return;
|
|
45
|
+
const body = await settleScaleToZero(res.body, mode === 'off', async () => {
|
|
46
|
+
try {
|
|
47
|
+
return (await api.rawRequest('GET', `/projects/${p.projectId}/database/instance${suffix}`, undefined, { signal: AbortSignal.timeout(5000) })).body;
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
});
|
|
24
53
|
if (opts.json)
|
|
25
|
-
return printJson(
|
|
26
|
-
|
|
27
|
-
|
|
54
|
+
return printJson(body);
|
|
55
|
+
info(alwaysOnSetLine(service, opts.branch, mode, body?.scaleToZero));
|
|
56
|
+
}
|
|
57
|
+
// insta-db applies the change asynchronously and answers with the value from before it, so re-read until they agree.
|
|
58
|
+
export async function settleScaleToZero(body, want, read, wait = (ms) => new Promise((r) => setTimeout(r, ms))) {
|
|
59
|
+
for (let i = 0; i < 15 && typeof body?.scaleToZero === 'boolean' && body.scaleToZero !== want; i++) {
|
|
60
|
+
await wait(2000);
|
|
61
|
+
const next = await read();
|
|
62
|
+
if (next === undefined)
|
|
63
|
+
break;
|
|
64
|
+
body = next;
|
|
65
|
+
}
|
|
66
|
+
return body;
|
|
67
|
+
}
|
|
68
|
+
export function alwaysOnSetLine(service, branch, mode, scaleToZero) {
|
|
69
|
+
const group = service ?? 'default';
|
|
70
|
+
if (scaleToZero !== (mode === 'off'))
|
|
71
|
+
return `postgres ${group}: always-on ${mode} requested — the database has not applied it yet; confirm with \`insta postgres always-on${service ? ` ${service}` : ''}${branch ? ` --branch ${branch}` : ''}\``;
|
|
72
|
+
return `postgres ${group}: always-on ${mode === 'on' ? 'ENABLED — instance stays warm (no cold starts; idle RAM bills at actual usage)' : 'disabled — scales to zero when idle (default; first connection after idle cold-starts)'}`;
|
|
28
73
|
}
|
|
29
74
|
// Validated pass-throughs for the provider's quantity strings. The insta-db resize API takes
|
|
30
75
|
// k8s-style quantities (cpu: "2", "2500m"; memory: "4Gi", "2048Mi"), so unlike the compute path
|
package/dist/index.js
CHANGED
|
@@ -36,7 +36,7 @@ import * as template from './commands/template.js';
|
|
|
36
36
|
import * as govern from './commands/govern.js';
|
|
37
37
|
import * as observe from './commands/observe.js';
|
|
38
38
|
import * as obs from './commands/metrics.js';
|
|
39
|
-
import { billing, billingUpgrade, billingPortal } from './commands/billing.js';
|
|
39
|
+
import { billing, billingUpgrade, billingPortal, billingRedeem } from './commands/billing.js';
|
|
40
40
|
import * as domainCmd from './commands/domain.js';
|
|
41
41
|
import * as selfUpdate from './commands/upgrade.js';
|
|
42
42
|
import * as feedbackCmd from './commands/feedback.js';
|
|
@@ -438,7 +438,7 @@ pg.command('volume [service]').description("Show or grow a postgres service's pr
|
|
|
438
438
|
.option('--size <gi>', 'new size in whole Gi, e.g. 10 (must be ≥ the current size)')
|
|
439
439
|
.option('--json').option('--branch <branch>', 'branch (default: current)')
|
|
440
440
|
.action(guard((service, o) => pgCmd.dbVolume(service, o)));
|
|
441
|
-
pg.command('always-on
|
|
441
|
+
pg.command('always-on [mode] [service]').description('Show or set a postgres service always-on (mode: on|off; no mode = print the current setting). on = instance stays warm, no cold starts; off = default scale-to-zero (idle instance suspends; first connection cold-starts). insta-db-backed services only')
|
|
442
442
|
.option('--json').option('--branch <branch>', 'branch (default: current)')
|
|
443
443
|
.action(guard((mode, service, o) => pgCmd.dbAlwaysOn(mode, service, o)));
|
|
444
444
|
addObservability(pg, 'db', 'postgres');
|
|
@@ -530,7 +530,7 @@ tpl.command('deploy <code-or-dir-or-url>').description('Deploy a template onto a
|
|
|
530
530
|
.option('--json')
|
|
531
531
|
.action(guard((target, o) => template.templateDeploy(target, o)));
|
|
532
532
|
// ---- billing ----
|
|
533
|
-
const bill = program.command('billing').description('Billing: current cycle overview (bare), subscribe to a tier, Stripe portal, usage by dimension')
|
|
533
|
+
const bill = program.command('billing').description('Billing: current cycle overview (bare), subscribe to a tier, redeem a credit code, Stripe portal, usage by dimension')
|
|
534
534
|
.option('--org <id>', 'target org (default: linked project\'s org)').option('--json')
|
|
535
535
|
.action(guard((o) => billing(o)));
|
|
536
536
|
// commander 12 defaults allowExcessArguments to true, so a mistyped/retired subcommand (e.g.
|
|
@@ -539,6 +539,9 @@ bill.allowExcessArguments(false);
|
|
|
539
539
|
bill.command('subscribe <tier>').description('Subscribe the org to a paid tier (pro|team) via Stripe Checkout')
|
|
540
540
|
.option('--org <id>').option('--no-open', 'print the URL instead of opening a browser').option('--json')
|
|
541
541
|
.action(guard((tier, o) => billingUpgrade(tier, o)));
|
|
542
|
+
bill.command('redeem <code>').description("Redeem a one-time credit code into the org's wallet")
|
|
543
|
+
.option('--org <id>').option('--json')
|
|
544
|
+
.action(guard((code, o) => billingRedeem(code, o)));
|
|
542
545
|
bill.command('portal').description('Open the Stripe Customer Portal (change plan / card / cancel)')
|
|
543
546
|
.option('--org <id>').option('--no-open', 'print the URL instead of opening a browser').option('--json')
|
|
544
547
|
.action(guard((o) => billingPortal(o)));
|