insta 0.0.66 → 0.0.68
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 +1 -0
- package/dist/commands/billing.js +2 -2
- package/dist/commands/compute.js +2 -2
- package/dist/commands/domain.js +164 -0
- package/dist/commands/github.js +88 -13
- package/dist/commands/mcp.js +11 -4
- package/dist/commands/setup.js +30 -17
- package/dist/index.js +30 -3
- package/dist/telemetry.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -220,6 +220,7 @@ build never reaches a production installer.
|
|
|
220
220
|
| `insta run <cmd>` | Run a command with the branch bundle injected, nothing written to disk |
|
|
221
221
|
| `insta deploy [dir]` | Deploy a source directory (built remotely) or `--image <url>` |
|
|
222
222
|
| `insta compute` | `start` · `stop` · `suspend` · `status` · `set-domain` · `check-domain` · `remove-domain` |
|
|
223
|
+
| `insta domain` | Buy a domain through InstaCloud: `search` · `buy` · `attach` · `list` · `status` · `contact` |
|
|
223
224
|
| `insta db` | `url` (print the postgres DSN) · `connect` (psql session) · `limits` · `stats` · `always-on` · `volume` |
|
|
224
225
|
| `insta regions` | Regions available for postgres and compute |
|
|
225
226
|
| `insta manifest` | Agent-legible view of every branch and its URLs |
|
package/dist/commands/billing.js
CHANGED
|
@@ -2,7 +2,7 @@ import { ApiClient, 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.
|
|
5
|
-
async function resolveOrgId(opts) {
|
|
5
|
+
export async function resolveOrgId(opts) {
|
|
6
6
|
if (opts.org)
|
|
7
7
|
return opts.org;
|
|
8
8
|
return (await requireProject()).orgId;
|
|
@@ -108,7 +108,7 @@ export async function billingPortal(opts) {
|
|
|
108
108
|
// "opening", not "opened": a launcher that starts and then fails reports it asynchronously,
|
|
109
109
|
// so openUrl's true return is an attempt, not a confirmation (see util.ts) — and the URL is
|
|
110
110
|
// already printed above for exactly that case.
|
|
111
|
-
function presentUrl(url, label, open) {
|
|
111
|
+
export function presentUrl(url, label, open) {
|
|
112
112
|
info(label);
|
|
113
113
|
info(` ${url}`);
|
|
114
114
|
if (open !== false && openUrl(url))
|
package/dist/commands/compute.js
CHANGED
|
@@ -262,11 +262,11 @@ export function domainConflictMessage(host, e, services, ctx = {}) {
|
|
|
262
262
|
// Resolve branch + target service, so every domain verb names the service AND its region, and an
|
|
263
263
|
// ambiguous project is refused with the list instead of the platform's `default` fallback picking
|
|
264
264
|
// one silently.
|
|
265
|
-
async function domainTarget(api, projectId, branch, host, group) {
|
|
265
|
+
export async function domainTarget(api, projectId, branch, host, group) {
|
|
266
266
|
const { services } = await api.request('GET', `/projects/${projectId}/services${q(branch)}`);
|
|
267
267
|
return { target: resolveDomainTarget(services, host, group), services };
|
|
268
268
|
}
|
|
269
|
-
async function domainDeps(deps) {
|
|
269
|
+
export async function domainDeps(deps) {
|
|
270
270
|
if (deps)
|
|
271
271
|
return deps;
|
|
272
272
|
const [api, project] = [await ApiClient.load(), await requireProject()];
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { ApiClient } from '../api.js';
|
|
3
|
+
import { info, printJson, handleApproval, die } from '../util.js';
|
|
4
|
+
import { presentUrl, resolveOrgId } from './billing.js';
|
|
5
|
+
import { domainDeps, domainTarget } from './compute.js';
|
|
6
|
+
const usd = (cents) => `$${(cents / 100).toFixed(2)}`;
|
|
7
|
+
// --org wins; otherwise the linked project's org — the org verbs must not force a project link.
|
|
8
|
+
async function orgDeps(opts, deps) {
|
|
9
|
+
if (deps)
|
|
10
|
+
return { api: deps.api, orgId: opts.org ?? deps.project.orgId };
|
|
11
|
+
return { api: await ApiClient.load(), orgId: await resolveOrgId(opts) };
|
|
12
|
+
}
|
|
13
|
+
export function searchLines(results) {
|
|
14
|
+
if (!results.length)
|
|
15
|
+
return ['no results'];
|
|
16
|
+
const w = Math.max(...results.map((r) => r.domainName.length));
|
|
17
|
+
return results.map((r) => r.purchasable
|
|
18
|
+
? ` ${r.domainName.padEnd(w)} ${usd(r.priceCents)}${r.renewalPriceCents !== undefined ? ` (renews ${usd(r.renewalPriceCents)}/yr)` : ''}`
|
|
19
|
+
: ` ${r.domainName.padEnd(w)} unavailable${r.reason ? ` — ${r.reason}` : ''}`);
|
|
20
|
+
}
|
|
21
|
+
export async function domainSearch(keyword, opts, deps) {
|
|
22
|
+
const { api, orgId } = await orgDeps(opts, deps);
|
|
23
|
+
const qs = new URLSearchParams({ q: keyword });
|
|
24
|
+
if (opts.tlds)
|
|
25
|
+
qs.set('tlds', opts.tlds);
|
|
26
|
+
const r = await api.request('GET', `/orgs/${orgId}/domains/search?${qs}`);
|
|
27
|
+
if (opts.json)
|
|
28
|
+
return printJson(r);
|
|
29
|
+
for (const line of searchLines(r.results))
|
|
30
|
+
info(line);
|
|
31
|
+
const buyable = r.results.find((x) => x.purchasable);
|
|
32
|
+
if (buyable)
|
|
33
|
+
info(`buy one: insta domain buy ${buyable.domainName}`);
|
|
34
|
+
}
|
|
35
|
+
const CONTACT_KEYS = ['firstName', 'lastName', 'companyName', 'address1', 'address2', 'city', 'state', 'zip', 'country', 'email', 'phone'];
|
|
36
|
+
export function contactFromOpts(opts, file) {
|
|
37
|
+
if (file !== undefined)
|
|
38
|
+
return file;
|
|
39
|
+
const out = {};
|
|
40
|
+
for (const k of CONTACT_KEYS)
|
|
41
|
+
if (opts[k] !== undefined)
|
|
42
|
+
out[k] = opts[k];
|
|
43
|
+
return Object.keys(out).length ? out : undefined;
|
|
44
|
+
}
|
|
45
|
+
async function readContactFile(path) {
|
|
46
|
+
if (!path)
|
|
47
|
+
return undefined;
|
|
48
|
+
try {
|
|
49
|
+
return JSON.parse(await readFile(path, 'utf8'));
|
|
50
|
+
}
|
|
51
|
+
catch (e) {
|
|
52
|
+
throw new Error(`cannot read ${path} as JSON: ${e.message}`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
function contactLines(c) {
|
|
56
|
+
if (!c)
|
|
57
|
+
return ['no registrant contact set — set one with: insta domain contact set --first-name … (or --contact-file contact.json)'];
|
|
58
|
+
return [
|
|
59
|
+
`${c.firstName} ${c.lastName}${c.companyName ? ` (${c.companyName} — the organization is the legal registrant)` : ''}`,
|
|
60
|
+
`${c.address1}${c.address2 ? `, ${c.address2}` : ''}, ${c.city}, ${c.state} ${c.zip}, ${c.country}`,
|
|
61
|
+
`${c.email} ${c.phone}`,
|
|
62
|
+
];
|
|
63
|
+
}
|
|
64
|
+
export async function domainContactShow(opts, deps) {
|
|
65
|
+
const { api, orgId } = await orgDeps(opts, deps);
|
|
66
|
+
const r = await api.request('GET', `/orgs/${orgId}/domains/contact`);
|
|
67
|
+
if (opts.json)
|
|
68
|
+
return printJson(r);
|
|
69
|
+
for (const line of contactLines(r.contact))
|
|
70
|
+
info(line);
|
|
71
|
+
}
|
|
72
|
+
export async function domainContactSet(opts, deps) {
|
|
73
|
+
const { api, orgId } = await orgDeps(opts, deps);
|
|
74
|
+
const contact = contactFromOpts(opts, await readContactFile(opts.contactFile));
|
|
75
|
+
if (!contact)
|
|
76
|
+
die('pass the contact as flags (--first-name … --phone) or as --contact-file <contact.json>');
|
|
77
|
+
const r = await api.request('PUT', `/orgs/${orgId}/domains/contact`, contact);
|
|
78
|
+
if (opts.json)
|
|
79
|
+
return printJson(r);
|
|
80
|
+
info('registrant contact saved:');
|
|
81
|
+
for (const line of contactLines(r.contact))
|
|
82
|
+
info(` ${line}`);
|
|
83
|
+
}
|
|
84
|
+
export async function domainBuy(name, opts, deps) {
|
|
85
|
+
const { api, project: p } = await domainDeps(deps);
|
|
86
|
+
const branch = opts.branch ?? p.branch;
|
|
87
|
+
const { target } = await domainTarget(api, p.projectId, branch, name, opts.group);
|
|
88
|
+
const contact = await readContactFile(opts.contactFile);
|
|
89
|
+
// JSON.stringify drops undefined but keeps NaN as null, which the platform rejects as a type
|
|
90
|
+
// error rather than a bad term — so a malformed --years is refused here, with the reason.
|
|
91
|
+
let years;
|
|
92
|
+
if (opts.years !== undefined) {
|
|
93
|
+
years = Number(opts.years);
|
|
94
|
+
if (!Number.isInteger(years))
|
|
95
|
+
die(`--years must be a whole number of years, not ${opts.years}`);
|
|
96
|
+
}
|
|
97
|
+
const res = await api.rawRequest('POST', `/projects/${p.projectId}/domains/orders`, { domainName: name, years, branch, group: target.name, contact });
|
|
98
|
+
if (handleApproval(res, opts.json))
|
|
99
|
+
return;
|
|
100
|
+
if (opts.json)
|
|
101
|
+
return printJson(res.body);
|
|
102
|
+
const { order } = res.body;
|
|
103
|
+
info(`${order.domainName} — ${usd(order.priceCents)} for ${order.years} year${order.years === 1 ? '' : 's'}${order.renewalPriceCents !== null ? `, then ${usd(order.renewalPriceCents)}/yr` : ''}`);
|
|
104
|
+
info(`attaches to ${target.name}${order.branch ? ` (branch ${order.branch})` : ''} as ${order.domainName} and www.${order.domainName} once paid`);
|
|
105
|
+
presentUrl(order.checkoutUrl, 'Complete the payment in your browser:', opts.open);
|
|
106
|
+
info(`then: insta domain status ${order.domainName}`);
|
|
107
|
+
}
|
|
108
|
+
export async function domainAttach(name, opts, deps) {
|
|
109
|
+
const { api, project: p } = await domainDeps(deps);
|
|
110
|
+
const branch = opts.branch ?? p.branch;
|
|
111
|
+
const { target } = await domainTarget(api, p.projectId, branch, name, opts.group);
|
|
112
|
+
const res = await api.rawRequest('POST', `/projects/${p.projectId}/domains/${encodeURIComponent(name)}/attach`, { branch, group: target.name });
|
|
113
|
+
if (handleApproval(res, opts.json))
|
|
114
|
+
return;
|
|
115
|
+
if (opts.json)
|
|
116
|
+
return printJson(res.body);
|
|
117
|
+
const d = res.body;
|
|
118
|
+
info(`${d.domainName} will attach to ${target.name} as ${d.hostnames.map((h) => h.hostname).join(' and ')}`);
|
|
119
|
+
info(`then: insta domain status ${d.domainName}`);
|
|
120
|
+
}
|
|
121
|
+
// ---- list / status ----
|
|
122
|
+
function domainLines(d) {
|
|
123
|
+
const out = [`${d.domainName} ${d.status}${d.service ? ` → ${d.service}` : ''}${d.expiresAt ? ` (expires ${d.expiresAt.slice(0, 10)}${d.autorenew ? ', auto-renews' : ''})` : ''}`];
|
|
124
|
+
if (d.status === 'detached' || d.status === 'attach_failed')
|
|
125
|
+
out.push(` attach it again: insta domain attach ${d.domainName}`);
|
|
126
|
+
const w = Math.max(0, ...d.hostnames.map((x) => x.hostname.length));
|
|
127
|
+
for (const h of d.hostnames)
|
|
128
|
+
out.push(` ${h.hostname.padEnd(w)} ${h.state}${h.reason ? ` — ${h.reason}` : ''}`);
|
|
129
|
+
return out;
|
|
130
|
+
}
|
|
131
|
+
function orderStatusLines(o) {
|
|
132
|
+
const out = [`order ${o.id}: ${o.domainName} — ${o.status}${o.failedReason ? ` — ${o.failedReason}` : ''}`];
|
|
133
|
+
if (o.status === 'canceled')
|
|
134
|
+
out.push(` the checkout closed without payment — order again: insta domain buy ${o.domainName}`);
|
|
135
|
+
return out;
|
|
136
|
+
}
|
|
137
|
+
export async function domainList(opts, deps) {
|
|
138
|
+
const { api, project: p } = await domainDeps(deps);
|
|
139
|
+
const r = await api.request('GET', `/projects/${p.projectId}/domains`);
|
|
140
|
+
if (opts.json)
|
|
141
|
+
return printJson(r);
|
|
142
|
+
if (!r.items.length)
|
|
143
|
+
return info('no domains bought through InstaCloud in this project (search: insta domain search <keyword>)');
|
|
144
|
+
for (const d of r.items)
|
|
145
|
+
for (const line of domainLines(d))
|
|
146
|
+
info(line);
|
|
147
|
+
}
|
|
148
|
+
export async function domainStatus(name, opts, deps) {
|
|
149
|
+
const { api, project: p } = await domainDeps(deps);
|
|
150
|
+
const host = name.trim().toLowerCase();
|
|
151
|
+
const [{ items: domains }, { items: orders }] = await Promise.all([
|
|
152
|
+
api.request('GET', `/projects/${p.projectId}/domains`),
|
|
153
|
+
api.request('GET', `/projects/${p.projectId}/domains/orders`),
|
|
154
|
+
]);
|
|
155
|
+
const domain = domains.find((d) => d.domainName === host) ?? null;
|
|
156
|
+
const order = orders.find((o) => o.domainName === host) ?? null;
|
|
157
|
+
if (!domain && !order)
|
|
158
|
+
die(`${host} was not bought through this project`);
|
|
159
|
+
if (opts.json)
|
|
160
|
+
return printJson({ domain, order });
|
|
161
|
+
for (const line of domain ? domainLines(domain) : orderStatusLines(order))
|
|
162
|
+
info(line);
|
|
163
|
+
}
|
|
164
|
+
//# sourceMappingURL=domain.js.map
|
package/dist/commands/github.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { ApiClient, requireProject } from '../api.js';
|
|
1
|
+
import { ApiClient, ApiError, requireProject } from '../api.js';
|
|
2
|
+
import { agentMode } from '../agent.js';
|
|
2
3
|
import { info, printJson } from '../util.js';
|
|
3
4
|
import { resolveSoleService, parsePort, q } from './services.js';
|
|
4
5
|
export function parseRepoRef(raw) {
|
|
@@ -74,21 +75,95 @@ export function repoLine(serviceName, s) {
|
|
|
74
75
|
: `; watch paths ${s.watch_paths.join(', ')} are stored but cannot apply`;
|
|
75
76
|
return `compute ${serviceName}: deploys from ${s.owner}/${s.repo}@${s.branch}${where} — ${how}${only}`;
|
|
76
77
|
}
|
|
77
|
-
|
|
78
|
+
const sleepSeconds = (s) => new Promise((r) => setTimeout(r, s * 1000));
|
|
79
|
+
// Node fires a timer of ~1ms for anything it cannot represent — a huge delay overflows, a negative one
|
|
80
|
+
// is clamped — so both ends are pinned or the wait becomes a hot poll of GitHub through us.
|
|
81
|
+
const pollDelay = (s) => Math.min(Math.max(s, 1), 60);
|
|
82
|
+
// GitHub shows the person a code to type; the platform holds the device code and finishes the exchange,
|
|
83
|
+
// so nothing secret passes through the CLI.
|
|
84
|
+
export async function authorizeTerminal(api, orgId, wait = sleepSeconds) {
|
|
85
|
+
const start = await api.request('POST', `/orgs/${encodeURIComponent(orgId)}/github/device`, {});
|
|
86
|
+
const deadline = Date.parse(start.expiresAt);
|
|
87
|
+
// A NaN deadline makes every comparison false, which reads as an instant expiry — or, inverted, as a
|
|
88
|
+
// loop with no way out. Fail on it rather than guess which.
|
|
89
|
+
if (!Number.isFinite(deadline))
|
|
90
|
+
throw new Error('malformed device authorization response (missing expiresAt) — is the platform up to date?');
|
|
91
|
+
// stderr, not stdout: --json must stay one parseable document.
|
|
92
|
+
const say = (line) => process.stderr.write(line + '\n');
|
|
93
|
+
say('this terminal is not authorized with GitHub yet — authorize it once:');
|
|
94
|
+
say(` open ${start.verificationUri} and enter the code ${start.userCode}`);
|
|
95
|
+
say('waiting for you to confirm… (ctrl-c to abort)');
|
|
96
|
+
const stopAt = Math.min(deadline, Date.now() + 3600_000); // no device code sensibly outlives an hour
|
|
97
|
+
const asked = Number(start.interval);
|
|
98
|
+
let interval = pollDelay(Number.isFinite(asked) && asked > 0 ? asked : 5);
|
|
99
|
+
while (Date.now() < stopAt) {
|
|
100
|
+
await wait(interval);
|
|
101
|
+
let answer;
|
|
102
|
+
try {
|
|
103
|
+
answer = await api.request('POST', `/orgs/${encodeURIComponent(orgId)}/github/device/poll`, { state: start.state });
|
|
104
|
+
}
|
|
105
|
+
catch (e) {
|
|
106
|
+
// A dropped link, or the per-IP limiter this cadence already tripped on the login flow, must not
|
|
107
|
+
// end an authorization the person may be one click from finishing.
|
|
108
|
+
if (e instanceof ApiError && e.status !== 429)
|
|
109
|
+
throw e;
|
|
110
|
+
interval = pollDelay(interval + 5);
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (!answer.pending) {
|
|
114
|
+
if (!answer.repos)
|
|
115
|
+
throw new Error('the GitHub authorization completed but returned no repositories — is the platform up to date?');
|
|
116
|
+
return answer.repos;
|
|
117
|
+
}
|
|
118
|
+
// GitHub asking for more room between polls, relayed by the platform; ignoring it gets us limited.
|
|
119
|
+
interval = pollDelay(interval + Math.max(Number(answer.slowDownBy) || 0, 0));
|
|
120
|
+
}
|
|
121
|
+
throw new Error('the GitHub authorization expired before it was confirmed — run the command again');
|
|
122
|
+
}
|
|
123
|
+
// Only the platform's own "you have no usable authorization" may send the person to GitHub: any other
|
|
124
|
+
// failure is real, and a device flow cannot fix it.
|
|
125
|
+
const needsAuthorization = (e) => e instanceof ApiError && e.status === 400 && /not linked|no longer accepted/i.test(e.message);
|
|
126
|
+
// The repositories THIS caller's GitHub account can reach — the same question the platform asks again
|
|
127
|
+
// when the connect lands, so a repo missing here would be refused there anyway.
|
|
128
|
+
// Someone has to read the code and type it at GitHub. A terminal qualifies, and so does agent mode —
|
|
129
|
+
// an agent relays the URL to the person driving it — but --json and a bare pipe have no reader, and a
|
|
130
|
+
// ten-minute wait there is a stall where the old code failed with something to act on.
|
|
131
|
+
export function canAuthorizeHere(opts = {}) {
|
|
132
|
+
if (opts.json)
|
|
133
|
+
return false;
|
|
134
|
+
return !!agentMode() || !!process.stderr.isTTY;
|
|
135
|
+
}
|
|
136
|
+
export async function findCallerRepo(api, orgId, ref, authorize = authorizeTerminal, canAuthorize = canAuthorizeHere()) {
|
|
78
137
|
if (!orgId)
|
|
79
138
|
throw new Error('this directory is linked without an org — set INSTA_ORG_ID alongside INSTA_PROJECT_ID, or link it with `insta project link`');
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
139
|
+
let repos;
|
|
140
|
+
try {
|
|
141
|
+
repos = (await api.request('POST', `/orgs/${encodeURIComponent(orgId)}/github/repos`, {})).repos ?? [];
|
|
83
142
|
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
143
|
+
catch (e) {
|
|
144
|
+
// Two 403s carry an action; a third kind would be guessed at, so it is rethrown as the platform put it.
|
|
145
|
+
if (e instanceof ApiError && e.status === 403 && /unclassified_agent_action/.test(e.message)) {
|
|
146
|
+
throw new Error('this backend does not let an agent authorize GitHub yet — connect the repository from the console, or pass --public for a public repository');
|
|
147
|
+
}
|
|
148
|
+
if (e instanceof ApiError && e.status === 403 && /requires admin/i.test(e.message)) {
|
|
149
|
+
throw new Error('connecting a repository needs the org admin role — ask an admin to connect it, or pass --public for a public repository');
|
|
150
|
+
}
|
|
151
|
+
if (!needsAuthorization(e))
|
|
152
|
+
throw e;
|
|
153
|
+
if (!canAuthorize) {
|
|
154
|
+
throw new Error('this GitHub account is not authorized for InstaCloud yet, and nothing here can read the code GitHub shows — run `insta compute connect-repo` from a terminal, connect the repository from the console, or pass --public for a public repository');
|
|
155
|
+
}
|
|
156
|
+
repos = await authorize(api, orgId);
|
|
89
157
|
}
|
|
90
|
-
const
|
|
91
|
-
|
|
158
|
+
const whole = (n) => n !== null && n !== '' && Number.isInteger(Number(n)) && Number(n) > 0;
|
|
159
|
+
const hit = repos.find((r) => r.owner.toLowerCase() === ref.owner.toLowerCase() && r.repo.toLowerCase() === ref.repo.toLowerCase());
|
|
160
|
+
if (hit && whole(hit.installationId) && whole(hit.id))
|
|
161
|
+
return { installationId: Number(hit.installationId), repoId: Number(hit.id) };
|
|
162
|
+
if (hit)
|
|
163
|
+
throw new Error(`${ref.owner}/${ref.repo} came back without an installation to build it through — reconnect GitHub in the console, or pass --public for a public repository`);
|
|
164
|
+
if (repos.length === 0)
|
|
165
|
+
throw new Error('the InstaCloud GitHub App reaches none of your repositories — install it on the account that owns this one (console → Add Service → GitHub Repo → Connect GitHub), or pass --public for a public repository');
|
|
166
|
+
throw new Error(`${ref.owner}/${ref.repo} is not one your GitHub account can reach through the App — grant the App access to it on GitHub, or pass --public for a public repository`);
|
|
92
167
|
}
|
|
93
168
|
async function targetService(api, projectId, branch, serviceName) {
|
|
94
169
|
const { services } = await api.request('GET', `/projects/${projectId}/services${q(branch)}`);
|
|
@@ -112,7 +187,7 @@ export async function computeConnectRepo(rawRef, serviceName, opts) {
|
|
|
112
187
|
const svc = await targetService(api, p.projectId, opts.branch ?? p.branch, serviceName);
|
|
113
188
|
const src = opts.public
|
|
114
189
|
? { source: 'public', ...ref }
|
|
115
|
-
: { source: 'app', ...(await
|
|
190
|
+
: { source: 'app', ...(await findCallerRepo(api, p.orgId, ref, authorizeTerminal, canAuthorizeHere(opts))), ...ref };
|
|
116
191
|
// Detection must scan the branch that will be built: the build refuses commands that differ from what it detects there.
|
|
117
192
|
const detected = await api.request('POST', `/projects/${p.projectId}/github/detect`, { ...src, ...(opts.repoBranch ? { ref: opts.repoBranch } : {}) });
|
|
118
193
|
const candidate = pickCandidate(detected.services, opts.rootDir);
|
package/dist/commands/mcp.js
CHANGED
|
@@ -8,7 +8,7 @@ import { existsSync } from 'node:fs';
|
|
|
8
8
|
import os from 'node:os';
|
|
9
9
|
import path from 'node:path';
|
|
10
10
|
import { info } from '../util.js';
|
|
11
|
-
import { MCP_SERVER_NAME, registerMcp, resolveMcpTarget } from './setup.js';
|
|
11
|
+
import { MCP_SERVER_NAME, registerMcp, requireMcpRegistration, resolveMcpTarget } from './setup.js';
|
|
12
12
|
export const MCP_AGENT_TARGETS = ['cursor', 'codex', 'opencode', 'copilot', 'factory-droid'];
|
|
13
13
|
export function configPath(slug, home) {
|
|
14
14
|
switch (slug) {
|
|
@@ -120,13 +120,20 @@ export async function installAgentConfigs(agent, home = os.homedir()) {
|
|
|
120
120
|
}
|
|
121
121
|
// `insta mcp install [--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
|
-
export async function mcpInstall(opts) {
|
|
123
|
+
export async function mcpInstall(opts, register = registerMcp, installConfigs = installAgentConfigs) {
|
|
124
|
+
if (opts.mcpToken && opts.agent && opts.agent !== 'claude-code') {
|
|
125
|
+
throw new Error('--mcp-token supports Claude Code only; other clients use OAuth');
|
|
126
|
+
}
|
|
124
127
|
if (!opts.agent || opts.agent === 'claude-code') {
|
|
125
|
-
await
|
|
128
|
+
const status = await register(undefined, undefined, !!opts.mcpToken);
|
|
129
|
+
// Missing Claude is an optional discovery miss only for the default OAuth install. A
|
|
130
|
+
// targeted install, token request, or attempted-but-failed add must not report success.
|
|
131
|
+
if ((opts.agent || opts.mcpToken || status === 'failed') && !requireMcpRegistration(status))
|
|
132
|
+
return;
|
|
126
133
|
if (opts.agent)
|
|
127
134
|
return;
|
|
128
135
|
}
|
|
129
|
-
const done = await
|
|
136
|
+
const done = await installConfigs(opts.agent);
|
|
130
137
|
if (done.length)
|
|
131
138
|
info(`✓ MCP — configured for ${done.join(', ')} (restart those tools to pick it up)`);
|
|
132
139
|
else if (opts.agent) { /* messages already printed */ }
|
package/dist/commands/setup.js
CHANGED
|
@@ -14,7 +14,7 @@ import { ApiClient } from '../api.js';
|
|
|
14
14
|
import { setupProjectAgentSession } from '../agent.js';
|
|
15
15
|
import { readPersistedGlobal, resolveEnv } from '../config.js';
|
|
16
16
|
import { DEFAULT_ENV, ENVS, ENV_NAMES, envForApiUrl, envFromEnvVar, isEnvName, mcpServerName } from '../env.js';
|
|
17
|
-
import { info, openUrl } from '../util.js';
|
|
17
|
+
import { fail, info, openUrl } from '../util.js';
|
|
18
18
|
import { isRunnableFile, resolveSpawnable } from '../spawn.js';
|
|
19
19
|
import { loginDevice } from './auth.js';
|
|
20
20
|
import { projectCreate, projectLink, slugifyName } from './project.js';
|
|
@@ -203,18 +203,16 @@ export async function resolveMcpTarget() {
|
|
|
203
203
|
const { env, mcpUrl } = await resolveEnv();
|
|
204
204
|
return { name: mcpServerName(env ?? DEFAULT_ENV), url: mcpUrl };
|
|
205
205
|
}
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
const api = await ApiClient.load();
|
|
209
|
-
if (!api.config.accessToken)
|
|
210
|
-
return null;
|
|
211
|
-
const { token } = await api.request('POST', '/tokens', { name: `mcp-${os.hostname()}` });
|
|
212
|
-
return token ?? null;
|
|
213
|
-
}
|
|
214
|
-
catch {
|
|
206
|
+
export async function mintMcpToken(api) {
|
|
207
|
+
if (!api.config.accessToken)
|
|
215
208
|
return null;
|
|
209
|
+
const result = await api.request('POST', '/tokens', { name: `mcp-${os.hostname()}` });
|
|
210
|
+
if (typeof result?.token !== 'string' || !result.token.trim()) {
|
|
211
|
+
throw new Error('MCP token creation did not return a token; registration was not written');
|
|
216
212
|
}
|
|
217
|
-
|
|
213
|
+
return result.token;
|
|
214
|
+
}
|
|
215
|
+
const defaultMinter = async () => mintMcpToken(await ApiClient.load());
|
|
218
216
|
export async function registerMcp(run = defaultRunner, mint = defaultMinter, useToken = false, announce = true) {
|
|
219
217
|
const { name, url } = await resolveMcpTarget();
|
|
220
218
|
if (!(await run('claude', ['--version'])).ok)
|
|
@@ -241,10 +239,20 @@ export async function registerMcp(run = defaultRunner, mint = defaultMinter, use
|
|
|
241
239
|
if (announce) {
|
|
242
240
|
info(`✓ MCP — ${name} registered with Claude Code (\`claude mcp list\` to verify)`);
|
|
243
241
|
if (!useToken)
|
|
244
|
-
info(' first use: run `/mcp` in Claude Code and authorize in the browser (
|
|
242
|
+
info(' first use: run `/mcp` in Claude Code and authorize in the browser (--mcp-token requires token-creation permission)');
|
|
245
243
|
}
|
|
246
244
|
return 'new';
|
|
247
245
|
}
|
|
246
|
+
/** Callers decide when an optional probe becomes a required registration (after the interactive
|
|
247
|
+
* login retry in setup). An OAuth registration for another client cannot satisfy --mcp-token. */
|
|
248
|
+
export function requireMcpRegistration(status) {
|
|
249
|
+
if (status === 'new' || status === 'existing')
|
|
250
|
+
return true;
|
|
251
|
+
fail(status === 'no-claude'
|
|
252
|
+
? 'Claude Code MCP registration incomplete: Claude Code is not available on PATH'
|
|
253
|
+
: 'Claude Code MCP registration incomplete; see the error above');
|
|
254
|
+
return false;
|
|
255
|
+
}
|
|
248
256
|
/** The environment `setup agent` should target, and whether the machine must be switched to it
|
|
249
257
|
* first. Pure — decides only; the caller performs the switch.
|
|
250
258
|
*
|
|
@@ -409,7 +417,8 @@ export async function setupAgent(opts, run = defaultRunner, mint, installConfigs
|
|
|
409
417
|
// Default into login on an interactive terminal (see shouldOfferLogin) BEFORE the MCP summary:
|
|
410
418
|
// a --mcp-token registration needs the session to mint, so a post-login retry must land in the
|
|
411
419
|
// same combined line instead of announcing Claude Code separately. Best-effort: a declined
|
|
412
|
-
// prompt or a failed browser flow leaves
|
|
420
|
+
// prompt or a failed browser flow leaves the manual hint. Explicit --mcp-token still requires
|
|
421
|
+
// registration to finish; without that flag, login remains optional.
|
|
413
422
|
const stored = await readStored();
|
|
414
423
|
let loggedIn = !!(stored.accessToken || stored.user);
|
|
415
424
|
if (shouldOfferLogin(!!opts.yes, loggedIn, loginFlow.stdinTty, loginFlow.stdoutTty)) {
|
|
@@ -417,15 +426,19 @@ export async function setupAgent(opts, run = defaultRunner, mint, installConfigs
|
|
|
417
426
|
try {
|
|
418
427
|
await loginFlow.login();
|
|
419
428
|
loggedIn = true;
|
|
420
|
-
if (opts.mcpToken)
|
|
421
|
-
claude = await registerMcp(run, mint, true, false);
|
|
422
429
|
}
|
|
423
430
|
catch (e) {
|
|
424
|
-
info(` login did not complete (${e instanceof Error ? e.message : String(e)})
|
|
431
|
+
info(` login did not complete (${e instanceof Error ? e.message : String(e)})`);
|
|
425
432
|
info(' run `insta login` to try again — the sign-in link it prints works from a browser on any device.');
|
|
426
433
|
}
|
|
434
|
+
// Keep token-creation errors outside the browser-login catch. A signed-in caller can be
|
|
435
|
+
// forbidden from minting; relabeling that as a login failure hides the real platform error.
|
|
436
|
+
if (opts.mcpToken && loggedIn)
|
|
437
|
+
claude = await registerMcp(run, mint, true, false);
|
|
427
438
|
}
|
|
428
439
|
}
|
|
440
|
+
if (opts.mcpToken && !requireMcpRegistration(claude))
|
|
441
|
+
return;
|
|
429
442
|
// --project / --create: bind this directory to a project inside the SAME process. Never split
|
|
430
443
|
// this back into `setup agent && insta project <cmd>` as one paste: no shell joiner survives
|
|
431
444
|
// every Windows shell, and in shells without bracketed paste the queued second line is eaten
|
|
@@ -470,7 +483,7 @@ export async function setupAgent(opts, run = defaultRunner, mint, installConfigs
|
|
|
470
483
|
const mcpOk = claude === 'new' || claude === 'existing' || others.length > 0;
|
|
471
484
|
info(`${summarizeInstall(res.output ?? '')} — ready to use InstaCloud${mcpOk ? ' (CLI + skill + MCP; restart any open tools)' : ''}`);
|
|
472
485
|
if (claude === 'new' && !opts.mcpToken) {
|
|
473
|
-
info(' Claude Code first use: run `/mcp` and authorize in the browser (
|
|
486
|
+
info(' Claude Code first use: run `/mcp` and authorize in the browser (--mcp-token requires token-creation permission)');
|
|
474
487
|
}
|
|
475
488
|
// The user's next move: one concrete action, not a concept. The agents drive `insta` themselves
|
|
476
489
|
// (project create/link, deploys, login via the device flow), so the human just asks for the
|
package/dist/index.js
CHANGED
|
@@ -32,6 +32,7 @@ import * as govern from './commands/govern.js';
|
|
|
32
32
|
import * as observe from './commands/observe.js';
|
|
33
33
|
import * as obs from './commands/metrics.js';
|
|
34
34
|
import { billing, billingUpgrade, billingPortal } from './commands/billing.js';
|
|
35
|
+
import * as domainCmd from './commands/domain.js';
|
|
35
36
|
import * as selfUpdate from './commands/upgrade.js';
|
|
36
37
|
import * as feedbackCmd from './commands/feedback.js';
|
|
37
38
|
function onError(e) {
|
|
@@ -107,7 +108,7 @@ const setupCmd = program.command('setup').description('Set up this machine for I
|
|
|
107
108
|
setupCmd.command('agent').description('Install the insta CLI (if missing), the insta skill for all coding agents, and the MCP server — targets production; pass --env staging for the staging deployment')
|
|
108
109
|
.option('-y, --yes', 'non-interactive')
|
|
109
110
|
.option('--env <prod|staging>', 'deployment to set this machine up for (default: prod — switches and persists, like `insta env use`)')
|
|
110
|
-
.option('--mcp-token', 'register
|
|
111
|
+
.option('--mcp-token', 'register Claude Code with a minted insta_ API token instead of OAuth (requires login and token-creation permission)')
|
|
111
112
|
.option('--project <id>', 'also link this directory to an existing project after setup (flows through login first if needed)')
|
|
112
113
|
.option('--create [name]', 'also create a new project and link this directory after setup (default name: this directory; mutually exclusive with --project)')
|
|
113
114
|
.action(guard((o) => setup.setupAgent(o)));
|
|
@@ -115,7 +116,7 @@ setupCmd.command('agent').description('Install the insta CLI (if missing), the i
|
|
|
115
116
|
const mcpCmd = program.command('mcp').description('insta-cloud remote MCP server integration');
|
|
116
117
|
mcpCmd.command('install').description('Register the remote MCP server with coding agents (default: Claude Code + all detected)')
|
|
117
118
|
.option('--agent <slug>', 'one agent: claude-code, cursor, codex, opencode, copilot, factory-droid')
|
|
118
|
-
.option('--mcp-token', 'claude-code only: minted insta_ API token instead of OAuth (
|
|
119
|
+
.option('--mcp-token', 'claude-code only: minted insta_ API token instead of OAuth (requires login and token-creation permission)')
|
|
119
120
|
.action(guard((o) => mcp.mcpInstall(o)));
|
|
120
121
|
// ---- org ----
|
|
121
122
|
const orgCmd = program.command('org').description('Manage organizations');
|
|
@@ -254,7 +255,7 @@ for (const [flags, description] of computeCmd.EXEC_OPTIONS)
|
|
|
254
255
|
execCmd.option(flags, description);
|
|
255
256
|
compute.command('repo [service]').description('Show what a compute service deploys from: the image it runs, or the GitHub repository — owner/repo, the branch it builds, root directory, which paths a push must change to redeploy it, and whether pushes redeploy it at all')
|
|
256
257
|
.option('--json').option('--branch <branch>', 'branch (default: current)').action(guard((service, o) => githubCmd.computeRepo(service, o)));
|
|
257
|
-
compute.command('connect-repo <owner/repo> [service]').description("Connect a GitHub repository to an EXISTING compute service: the repo is built (its Dockerfile, or nixpacks when there is none) and deployed into that service, and every later push to the tracked repository branch redeploys it. The repo must be
|
|
258
|
+
compute.command('connect-repo <owner/repo> [service]').description("Connect a GitHub repository to an EXISTING compute service: the repo is built (its Dockerfile, or nixpacks when there is none) and deployed into that service, and every later push to the tracked repository branch redeploys it. The repo must be one your own GitHub account can reach through the InstaCloud App, or be public; the first connect from a terminal prints a GitHub URL and a code to authorize it once. Build and start commands come from detection and cannot be set. Connecting again replaces the service's current source")
|
|
258
259
|
.option('--public', 'the repo is public and no GitHub App installation is needed (deploys are manual; pushes cannot redeploy)')
|
|
259
260
|
.option('--root-dir <dir>', 'the directory of the repo to build (a monorepo with several deployable directories lists them and exits 1 without it)')
|
|
260
261
|
.option('--repo-branch <name>', "the repository branch to build (default: the repo's default branch)")
|
|
@@ -347,6 +348,32 @@ program.command('logs <target> [group]').description('Service logs (runtime by d
|
|
|
347
348
|
program.command('usage').description('Usage for the current billing cycle by billing dimension (org by default; --proj for one project)')
|
|
348
349
|
.option('--from <unix>').option('--to <unix>').option('--proj [id]', 'show one project (the linked one, or a given id) instead of the whole org').option('--json')
|
|
349
350
|
.action(guard((o) => obs.usage(o)));
|
|
351
|
+
// ---- domains bought through InstaCloud (BYO domains: `insta compute set-domain`) ----
|
|
352
|
+
const dom = program.command('domain').description('Buy a domain through InstaCloud and attach it to a compute service (your own domain: `insta compute set-domain`)');
|
|
353
|
+
dom.command('search <keyword>').description('Search purchasable names with prices (a label like "myapp" or a full name like "myapp.com")')
|
|
354
|
+
.option('--tlds <list>', 'comma-separated TLDs to include').option('--org <id>', "target org (default: linked project's org)").option('--json')
|
|
355
|
+
.action(guard((keyword, o) => domainCmd.domainSearch(keyword, o)));
|
|
356
|
+
dom.command('buy <name>').description('Buy a domain and attach it to a branch compute service — pay at the printed Stripe Checkout link (gated: domain.purchase)')
|
|
357
|
+
.option('--years <n>', 'registration term in years (default 1)').option('--branch <b>').option('--group <g>', "compute service (default: the branch's sole compute service)")
|
|
358
|
+
.option('--contact-file <path>', 'registrant contact as JSON (default: the org contact from `insta domain contact set`)')
|
|
359
|
+
.option('--no-open', 'print the checkout URL instead of opening a browser').option('--json')
|
|
360
|
+
.action(guard((name, o) => domainCmd.domainBuy(name, o)));
|
|
361
|
+
dom.command('attach <name>').description('Attach a bought domain whose service was deleted (or whose attach failed) to a compute service (gated: deploy)')
|
|
362
|
+
.option('--branch <b>').option('--group <g>', "compute service (default: the branch's sole compute service)").option('--json')
|
|
363
|
+
.action(guard((name, o) => domainCmd.domainAttach(name, o)));
|
|
364
|
+
dom.command('list').description('Domains bought through InstaCloud in this project, with attach state per hostname').option('--json')
|
|
365
|
+
.action(guard((o) => domainCmd.domainList(o)));
|
|
366
|
+
dom.command('status <name>').description("A bought domain's order and attach state").option('--json')
|
|
367
|
+
.action(guard((name, o) => domainCmd.domainStatus(name, o)));
|
|
368
|
+
const domContact = dom.command('contact').description("Show the org's default registrant contact (the legal registrant of every domain bought with it)")
|
|
369
|
+
.option('--org <id>').option('--json').action(guard((o) => domainCmd.domainContactShow(o)));
|
|
370
|
+
domContact.command('set').description('Set the org default registrant contact (admin) from flags or --contact-file <path>; --company-name makes that organization the legal registrant')
|
|
371
|
+
.option('--first-name <s>').option('--last-name <s>').option('--company-name <s>').option('--address1 <s>').option('--address2 <s>').option('--city <s>').option('--state <s>').option('--zip <s>')
|
|
372
|
+
.option('--country <cc>', 'ISO 3166-1 alpha-2, e.g. US').option('--email <s>').option('--phone <e164>', 'E.164, e.g. +14155550100')
|
|
373
|
+
.option('--contact-file <path>', 'JSON file with the contact fields').option('--org <id>').option('--json')
|
|
374
|
+
// `contact --org X set` parks --org on the GROUP (enablePositionalOptions); without this merge the
|
|
375
|
+
// wrong org's registrant contact is written, and that field is legal ownership.
|
|
376
|
+
.action(guard((o) => domainCmd.domainContactSet({ ...domContact.opts(), ...o })));
|
|
350
377
|
const bill = program.command('billing').description('Current billing cycle overview (tier / used / included / overage / credits / forecast + per-dimension & per-project breakdown)')
|
|
351
378
|
.option('--org <id>', 'target org (default: linked project\'s org)').option('--json')
|
|
352
379
|
.action(guard((o) => billing(o)));
|
package/dist/telemetry.js
CHANGED
|
@@ -46,7 +46,7 @@ const SAFE_OPTIONS = {
|
|
|
46
46
|
agent: oneOf(['claude-code', 'cursor', 'codex', 'opencode', 'copilot', 'factory-droid']),
|
|
47
47
|
type: oneOf(TYPES), component: oneOf(COMPONENTS), severity: oneOf(SEVERITIES),
|
|
48
48
|
status: oneOf(['pending', 'granted', 'denied', 'consumed']),
|
|
49
|
-
limit: NUMBER, step: NUMBER, since: NUMBER, port: NUMBER, memory: NUMBER, cpu: NUMBER, size: NUMBER, volume: NUMBER,
|
|
49
|
+
limit: NUMBER, step: NUMBER, since: NUMBER, port: NUMBER, memory: NUMBER, cpu: NUMBER, size: NUMBER, volume: NUMBER, years: NUMBER,
|
|
50
50
|
};
|
|
51
51
|
export function telemetryDisabled(env = process.env) {
|
|
52
52
|
return !!(env.DO_NOT_TRACK || env.INSTA_NO_TELEMETRY);
|