insta 0.0.63 → 0.0.65
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 +8 -0
- package/dist/commands/build.js +2 -2
- package/dist/commands/deploy.js +9 -2
- package/dist/commands/github.js +122 -0
- package/dist/commands/regions.js +2 -2
- package/dist/commands/run.js +91 -16
- package/dist/commands/secrets.js +106 -17
- package/dist/index.js +22 -2
- package/dist/resolve-service.js +1 -2
- package/dist/util.js +10 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -119,6 +119,14 @@ only. Provider-minted service credentials (`DATABASE_URL`, `BUCKET_NAME`,
|
|
|
119
119
|
`insta secrets bind` rules, and the postgres connection string is read directly with
|
|
120
120
|
`insta db url` (or `insta db connect` for a psql session).
|
|
121
121
|
|
|
122
|
+
Secrets can be scoped per compute service, so several services may each define the same name — and
|
|
123
|
+
a flat bundle cannot carry two values for one name. Such a name is **withheld** from the bundle and
|
|
124
|
+
reported on stderr (which services define it, and how to read one). `insta run` then **refuses to
|
|
125
|
+
start the command** rather than let it inherit a stale value for that name from your shell; re-run
|
|
126
|
+
it as `insta run --service compute/<name>` to inject exactly what that one service receives, or
|
|
127
|
+
`--ignore-collisions` to run with the name removed from the child environment altogether.
|
|
128
|
+
`insta secrets --service compute/<name>` reads the same scoped env into `.env`.
|
|
129
|
+
|
|
122
130
|
### Destructive actions can require approval
|
|
123
131
|
|
|
124
132
|
Agent requests are governed by the project's `agent-policy`; human requests use normal RBAC.
|
package/dist/commands/build.js
CHANGED
|
@@ -169,7 +169,7 @@ export async function buildReport(dirArg, opts, deps) {
|
|
|
169
169
|
// NOT "save the generated Dockerfile here": it is not standalone (it COPYs the
|
|
170
170
|
// .nixpacks/nixpkgs-<hash>.nix support files nixpacks writes beside it, which this
|
|
171
171
|
// directory does not have). The detected commands above are the reusable part.
|
|
172
|
-
nextAction: `to deploy this directory, write a Dockerfile at ${userDockerfilePath} — the detected install/start commands above are the starting point; or connect the repo
|
|
172
|
+
nextAction: `to deploy this directory, write a Dockerfile at ${userDockerfilePath} — the detected install/start commands above are the starting point; or connect the GitHub repo to the service (\`insta compute connect-repo <owner/repo>\`) to use the nixpacks lane`,
|
|
173
173
|
}
|
|
174
174
|
: {
|
|
175
175
|
id: 'dockerfile',
|
|
@@ -226,7 +226,7 @@ export function renderReport(r, explain) {
|
|
|
226
226
|
lines.push(`plan for ${r.dir}:`);
|
|
227
227
|
// The builder line is the first thing read (and the thing an agent scrapes), so it carries the
|
|
228
228
|
// lane caveat too — "builder: nixpacks" on its own reads as a promise `insta deploy <dir>` breaks.
|
|
229
|
-
const lane = r.plan.builder === 'nixpacks' ? ' — GitHub lane only; `insta deploy <dir>` needs a Dockerfile' : '';
|
|
229
|
+
const lane = r.plan.builder === 'nixpacks' ? ' — GitHub lane only (`insta compute connect-repo`); `insta deploy <dir>` needs a Dockerfile' : '';
|
|
230
230
|
lines.push(` builder: ${r.plan.builder ?? 'none'}${r.plan.providers.length ? ` (providers: ${r.plan.providers.join(', ')})` : ''}${lane}`);
|
|
231
231
|
if (r.plan.installCommand)
|
|
232
232
|
lines.push(` install: ${r.plan.installCommand}`);
|
package/dist/commands/deploy.js
CHANGED
|
@@ -15,6 +15,7 @@ export function deployRequestBody(image, branch, opts) {
|
|
|
15
15
|
group: opts.group,
|
|
16
16
|
port: opts.port ? Number(opts.port) : undefined,
|
|
17
17
|
websocket: opts.websocket ? true : undefined,
|
|
18
|
+
replaceSource: opts.replaceSource ? true : undefined,
|
|
18
19
|
};
|
|
19
20
|
}
|
|
20
21
|
// A port mismatch is the #1 deploy mistake: the app boots "successfully" but the proxy routes to
|
|
@@ -45,7 +46,7 @@ export function noDockerfileMessage(absDir) {
|
|
|
45
46
|
'Options:',
|
|
46
47
|
` - add a Dockerfile to ${absDir} (\`insta build ${absDir}\` prints the install/start commands nixpacks detected, as a starting point)`,
|
|
47
48
|
' - deploy a prebuilt image instead: `insta deploy --image <url>`',
|
|
48
|
-
|
|
49
|
+
' - connect the GitHub repo to the service (`insta compute connect-repo <owner/repo>`) — that lane builds Dockerfile-less repos with nixpacks server-side',
|
|
49
50
|
].join('\n');
|
|
50
51
|
}
|
|
51
52
|
// Deploy either a prebuilt image (`--image`) or a source directory (positional `<dir>`, built
|
|
@@ -70,7 +71,8 @@ export async function deploy(dir, opts) {
|
|
|
70
71
|
}
|
|
71
72
|
const effOpts = { ...opts, port: port?.toString() };
|
|
72
73
|
const image = dir ? await buildFromSource(api, p.projectId, dir, branch, effOpts) : opts.image;
|
|
73
|
-
const res = await api.rawRequest('POST', `/projects/${p.projectId}/deploy`, deployRequestBody(image, branch, effOpts))
|
|
74
|
+
const res = await api.rawRequest('POST', `/projects/${p.projectId}/deploy`, deployRequestBody(image, branch, effOpts))
|
|
75
|
+
.catch((e) => { throw e instanceof ApiError && e.status === 409 ? new ApiError(e.status, repoConnectedHint(e.message), e.body) : e; });
|
|
74
76
|
if (handleApproval(res, opts.json))
|
|
75
77
|
return;
|
|
76
78
|
if (opts.json)
|
|
@@ -78,6 +80,11 @@ export async function deploy(dir, opts) {
|
|
|
78
80
|
info(`deployed ${image} -> ${res.body.url} (branch ${res.body.branch}, group ${res.body.group})`);
|
|
79
81
|
renderNextActions(res.body.nextActions);
|
|
80
82
|
}
|
|
83
|
+
// The platform refuses an image deploy onto a repo-connected service and names the body field it
|
|
84
|
+
// wants; a CLI user can only pass the flag. Pure, so it's unit-tested.
|
|
85
|
+
export function repoConnectedHint(message) {
|
|
86
|
+
return message.replace(/pass replaceSource: true/g, 'pass --replace-source');
|
|
87
|
+
}
|
|
81
88
|
// The local image tag a daemon-side deploy runs: unique per build so a redeploy replaces, and
|
|
82
89
|
// legible in `docker images`. Pure, so it's unit-tested.
|
|
83
90
|
export function localImageTag(projectId, group, now = Date.now()) {
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { ApiClient, requireProject } from '../api.js';
|
|
2
|
+
import { info, printJson } from '../util.js';
|
|
3
|
+
import { resolveSoleService, parsePort, q } from './services.js';
|
|
4
|
+
export function parseRepoRef(raw) {
|
|
5
|
+
const s = raw.trim().replace(/^https?:\/\//i, '').replace(/^(www\.)?github\.com\//i, '').replace(/\/+$/, '').replace(/\.git$/i, '');
|
|
6
|
+
const m = /^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)$/.exec(s);
|
|
7
|
+
if (!m)
|
|
8
|
+
throw new Error(`not a GitHub repository reference: ${raw} (use owner/repo or https://github.com/owner/repo)`);
|
|
9
|
+
return { owner: m[1], repo: m[2] };
|
|
10
|
+
}
|
|
11
|
+
// "", ".", "/", "./" all mean the repo root, which the platform spells null.
|
|
12
|
+
function normalizeRootDir(raw) {
|
|
13
|
+
if (raw === undefined)
|
|
14
|
+
return null;
|
|
15
|
+
const s = raw.trim().replace(/^\.\//, '').replace(/^\/+/, '').replace(/\/+$/, '');
|
|
16
|
+
return s === '' || s === '.' ? null : s;
|
|
17
|
+
}
|
|
18
|
+
export function pickCandidate(candidates, rootDir) {
|
|
19
|
+
if (candidates.length === 0)
|
|
20
|
+
throw new Error('no deployable service detected in this repository (no Dockerfile and nothing nixpacks recognises)');
|
|
21
|
+
if (rootDir !== undefined) {
|
|
22
|
+
const want = normalizeRootDir(rootDir);
|
|
23
|
+
const hit = candidates.find((c) => c.rootDir === want);
|
|
24
|
+
if (!hit)
|
|
25
|
+
throw new Error(`no deployable directory at ${want ?? '(repo root)'} — detected: ${candidates.map((c) => c.rootDir ?? '(repo root)').join(', ')}`);
|
|
26
|
+
return hit;
|
|
27
|
+
}
|
|
28
|
+
if (candidates.length === 1)
|
|
29
|
+
return candidates[0];
|
|
30
|
+
throw new Error([
|
|
31
|
+
`this repository has ${candidates.length} deployable directories; pass --root-dir to choose which one deploys into the service:`,
|
|
32
|
+
...candidates.map((c) => ` ${(c.rootDir ?? '(repo root)').padEnd(24)} ${c.builder}${c.startCommand ? ` start: ${c.startCommand}` : ''}`),
|
|
33
|
+
].join('\n'));
|
|
34
|
+
}
|
|
35
|
+
// Build/start come from detection only: the platform's nixpacks lane fails a build whose commands differ from it.
|
|
36
|
+
// autoDeploy rides along only when switched off: a public repo 400s on autoDeploy: true.
|
|
37
|
+
export function sourceBody(src, c, o) {
|
|
38
|
+
const repo = src.source === 'app'
|
|
39
|
+
? { installationId: src.installationId, repoId: src.repoId, owner: src.owner, repo: src.repo }
|
|
40
|
+
: { public: true, owner: src.owner, repo: src.repo };
|
|
41
|
+
return {
|
|
42
|
+
...repo,
|
|
43
|
+
rootDir: c.rootDir,
|
|
44
|
+
buildCommand: c.buildCommand,
|
|
45
|
+
startCommand: c.startCommand,
|
|
46
|
+
port: o.port !== undefined ? parsePort(o.port) : c.port,
|
|
47
|
+
...(o.repoBranch ? { branch: o.repoBranch } : {}),
|
|
48
|
+
...(o.autoDeploy === false ? { autoDeploy: false } : {}),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
export function repoLine(serviceName, s) {
|
|
52
|
+
if (s.type !== 'github')
|
|
53
|
+
return `compute ${serviceName}: no repository connected${s.image ? ` (runs image ${s.image})` : ''} — connect one with \`insta compute connect-repo <owner/repo> ${serviceName}\``;
|
|
54
|
+
const how = s.public
|
|
55
|
+
? 'public repo, deploys are manual (pushes do not redeploy)'
|
|
56
|
+
: s.auto_deploy ? `every push to ${s.branch} redeploys it` : 'auto-deploy off (pushes do not redeploy)';
|
|
57
|
+
const where = s.root_dir ? ` (${s.root_dir}/)` : '';
|
|
58
|
+
return `compute ${serviceName}: deploys from ${s.owner}/${s.repo}@${s.branch}${where} — ${how}`;
|
|
59
|
+
}
|
|
60
|
+
export async function findInstalledRepo(api, orgId, ref) {
|
|
61
|
+
if (!orgId)
|
|
62
|
+
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`');
|
|
63
|
+
const { installations = [] } = await api.request('GET', `/github/installations?orgId=${encodeURIComponent(orgId)}`);
|
|
64
|
+
if (installations.length === 0) {
|
|
65
|
+
throw new Error('no GitHub App installation for this org — connect GitHub in the console first (Add Service → GitHub Repo → Connect GitHub), or pass --public for a public repository');
|
|
66
|
+
}
|
|
67
|
+
for (const inst of installations) {
|
|
68
|
+
const { repos = [] } = await api.request('GET', `/github/installations/${encodeURIComponent(inst.installation_id)}/repos?orgId=${encodeURIComponent(orgId)}`);
|
|
69
|
+
const hit = repos.find((r) => r.owner.toLowerCase() === ref.owner.toLowerCase() && r.repo.toLowerCase() === ref.repo.toLowerCase());
|
|
70
|
+
if (hit)
|
|
71
|
+
return { installationId: Number(inst.installation_id), repoId: hit.id };
|
|
72
|
+
}
|
|
73
|
+
const accounts = installations.map((i) => i.account_login ?? i.installation_id).join(', ');
|
|
74
|
+
throw new Error(`${ref.owner}/${ref.repo} is not visible to the org's GitHub App installation (installed on: ${accounts}) — grant the App access to it in the console (Configure GitHub app), or pass --public for a public repository`);
|
|
75
|
+
}
|
|
76
|
+
async function targetService(api, projectId, branch, serviceName) {
|
|
77
|
+
const { services } = await api.request('GET', `/projects/${projectId}/services${q(branch)}`);
|
|
78
|
+
return resolveSoleService(services, 'compute', serviceName);
|
|
79
|
+
}
|
|
80
|
+
export async function computeRepo(serviceName, opts) {
|
|
81
|
+
const api = await ApiClient.load();
|
|
82
|
+
const p = await requireProject();
|
|
83
|
+
const branch = opts.branch ?? p.branch;
|
|
84
|
+
const svc = await targetService(api, p.projectId, branch, serviceName);
|
|
85
|
+
// insta-oss keys compute ids per group, not per branch, so the read carries the branch (the cloud ignores it).
|
|
86
|
+
const { source } = await api.request('GET', `/projects/${p.projectId}/services/${svc.id}/source${q(branch)}`);
|
|
87
|
+
if (opts.json)
|
|
88
|
+
return printJson({ service: { id: svc.id, name: svc.name }, source });
|
|
89
|
+
info(repoLine(svc.name, source));
|
|
90
|
+
}
|
|
91
|
+
export async function computeConnectRepo(rawRef, serviceName, opts) {
|
|
92
|
+
const ref = parseRepoRef(rawRef);
|
|
93
|
+
const api = await ApiClient.load();
|
|
94
|
+
const p = await requireProject();
|
|
95
|
+
const svc = await targetService(api, p.projectId, opts.branch ?? p.branch, serviceName);
|
|
96
|
+
const src = opts.public
|
|
97
|
+
? { source: 'public', ...ref }
|
|
98
|
+
: { source: 'app', ...(await findInstalledRepo(api, p.orgId, ref)), ...ref };
|
|
99
|
+
// Detection must scan the branch that will be built: the build refuses commands that differ from what it detects there.
|
|
100
|
+
const detected = await api.request('POST', `/projects/${p.projectId}/github/detect`, { ...src, ...(opts.repoBranch ? { ref: opts.repoBranch } : {}) });
|
|
101
|
+
const candidate = pickCandidate(detected.services, opts.rootDir);
|
|
102
|
+
const res = await api.request('PUT', `/projects/${p.projectId}/services/${svc.id}/source`, sourceBody(src, candidate, opts));
|
|
103
|
+
if (opts.json)
|
|
104
|
+
return printJson({ ...res, service: { id: svc.id, name: svc.name } });
|
|
105
|
+
const branch = res.source.branch;
|
|
106
|
+
const where = candidate.rootDir ? `${candidate.rootDir}/, ${candidate.builder}` : candidate.builder;
|
|
107
|
+
const now = res.build.queued ? `building ${branch} now` : `${branch} is already live at this commit`;
|
|
108
|
+
const how = src.source === 'public' ? 'deploys are manual from here: pushes will not redeploy (public repo)'
|
|
109
|
+
: opts.autoDeploy === false ? 'auto-deploy is off: redeploy with `insta compute connect-repo` again or from the console'
|
|
110
|
+
: `every push to ${branch} redeploys it`;
|
|
111
|
+
info(`connected ${ref.owner}/${ref.repo} → compute ${svc.name} (${where}): ${now} — ${how}`);
|
|
112
|
+
}
|
|
113
|
+
export async function computeDisconnectRepo(serviceName, opts) {
|
|
114
|
+
const api = await ApiClient.load();
|
|
115
|
+
const p = await requireProject();
|
|
116
|
+
const svc = await targetService(api, p.projectId, opts.branch ?? p.branch, serviceName);
|
|
117
|
+
const res = await api.request('DELETE', `/projects/${p.projectId}/services/${svc.id}/source`);
|
|
118
|
+
if (opts.json)
|
|
119
|
+
return printJson({ ...res, service: { id: svc.id, name: svc.name } });
|
|
120
|
+
info(`disconnected the repository from compute ${svc.name} — it keeps running its current image; pushes no longer deploy it`);
|
|
121
|
+
}
|
|
122
|
+
//# sourceMappingURL=github.js.map
|
package/dist/commands/regions.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { ApiClient } from '../api.js';
|
|
2
2
|
import { info, printJson } from '../util.js';
|
|
3
3
|
// insta regions — list the regions a postgres/compute service can be created in.
|
|
4
|
-
export async function regionsList(opts = {}) {
|
|
5
|
-
const api = await ApiClient.load();
|
|
4
|
+
export async function regionsList(opts = {}, deps) {
|
|
5
|
+
const api = deps?.api ?? await ApiClient.load();
|
|
6
6
|
const { regions } = await api.request('GET', '/regions');
|
|
7
7
|
if (opts.json)
|
|
8
8
|
return printJson(regions);
|
package/dist/commands/run.js
CHANGED
|
@@ -4,16 +4,93 @@
|
|
|
4
4
|
// as the process does.
|
|
5
5
|
import { spawn } from 'node:child_process';
|
|
6
6
|
import { ApiClient, requireProject } from '../api.js';
|
|
7
|
-
import { CliExit, die,
|
|
7
|
+
import { CliExit, die, refuse, relayExitCode } from '../util.js';
|
|
8
|
+
import { assertServiceRef, branchHint, collisionLines, fetchSecretBundle } from './secrets.js';
|
|
9
|
+
/** The bundle read, as run does it: the same GET as `insta secrets`, general or --service. */
|
|
10
|
+
export function bundleFetcher(api, projectId, opts) {
|
|
11
|
+
return async () => {
|
|
12
|
+
const b = await fetchSecretBundle(api, projectId, opts);
|
|
13
|
+
if (!b)
|
|
14
|
+
throw new CliExit(); // gated (202): handleApproval already said how to unblock it
|
|
15
|
+
return b;
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
/** The child's environment: the parent's, plus the bundle, MINUS every colliding name.
|
|
19
|
+
* Deleting is not belt-and-braces. A withheld name is simply ABSENT from the bundle, so the
|
|
20
|
+
* spread would let the parent's own export stand in for it — the developer who once exported
|
|
21
|
+
* codex's password would run hermes' command against it, with no sign anything was withheld.
|
|
22
|
+
*
|
|
23
|
+
* Windows needs more than an exact-key delete, for both halves. Env names there are
|
|
24
|
+
* case-insensitive; a plain JS object's keys are not, so this object can hold `Database_Url` from
|
|
25
|
+
* the shell and `DATABASE_URL` from the bundle as two keys for one variable — and CreateProcess,
|
|
26
|
+
* which does collapse them, picks. That makes a stale shell export able to beat the injected
|
|
27
|
+
* credential, which is the whole point of `insta run`, not just a hazard for a colliding name.
|
|
28
|
+
* So on win32 every name we DECIDE — injected or withheld — wins over whatever casings the parent
|
|
29
|
+
* had: the parent's are removed, then the bundle's own casing is written back. Off win32 nothing
|
|
30
|
+
* changes: POSIX names really are case-sensitive, so `Database_Url` there is a different variable
|
|
31
|
+
* that is none of our business, and collapsing the two would itself be the bug. */
|
|
32
|
+
export function childEnv(parent, bundle, collisions, platform = process.platform) {
|
|
33
|
+
const withheld = new Set(collisions.map((c) => c.name.toLowerCase()));
|
|
34
|
+
if (platform === 'win32') {
|
|
35
|
+
const env = { ...parent };
|
|
36
|
+
// Every name this run decides, whether it ends up injected or withheld.
|
|
37
|
+
const claimed = new Set([...Object.keys(bundle), ...collisions.map((c) => c.name)].map((n) => n.toLowerCase()));
|
|
38
|
+
for (const key of Object.keys(env))
|
|
39
|
+
if (claimed.has(key.toLowerCase()))
|
|
40
|
+
delete env[key];
|
|
41
|
+
// A withheld name stays gone in every casing — including one the bundle itself carried, which
|
|
42
|
+
// is a platform that answered `collisions` while still merging the values.
|
|
43
|
+
//
|
|
44
|
+
// defineProperty, not `env[k] = v`: a key of `__proto__` would otherwise set the prototype
|
|
45
|
+
// instead of creating an entry, and the secret would vanish silently. The platform's name rule
|
|
46
|
+
// (`^[A-Z][A-Z0-9_]{0,63}$`) makes that unreachable today — and that is exactly why it is worth
|
|
47
|
+
// one call rather than a trusted invariant: this CLI points at whatever `INSTA_API_URL` names,
|
|
48
|
+
// including a self-hosted insta-oss daemon whose validation is not this repo's to guarantee.
|
|
49
|
+
//
|
|
50
|
+
// The same rule is what makes two bundle keys differing ONLY by case impossible (case-differing
|
|
51
|
+
// needs a lowercase letter, which the rule forbids). If it ever loosens, this loop is where it
|
|
52
|
+
// bites: both variants would be written and Windows would resolve one of them arbitrarily.
|
|
53
|
+
for (const [k, v] of Object.entries(bundle)) {
|
|
54
|
+
if (withheld.has(k.toLowerCase()))
|
|
55
|
+
continue;
|
|
56
|
+
Object.defineProperty(env, k, { value: v, enumerable: true, configurable: true, writable: true });
|
|
57
|
+
}
|
|
58
|
+
return env;
|
|
59
|
+
}
|
|
60
|
+
const env = { ...parent, ...bundle };
|
|
61
|
+
for (const c of collisions)
|
|
62
|
+
delete env[c.name];
|
|
63
|
+
return env;
|
|
64
|
+
}
|
|
65
|
+
/** Pure: why run stopped, and the two ways forward. `branchHint` is the branch to name in the
|
|
66
|
+
* suggested commands — set only when the run was NOT on the linked branch, since a hint that
|
|
67
|
+
* drops an explicit --branch would send the user to read a different branch's secrets. */
|
|
68
|
+
export function refusalLines(collisions, branchHint) {
|
|
69
|
+
const n = collisions.length;
|
|
70
|
+
const b = branchHint ? ` --branch ${branchHint}` : '';
|
|
71
|
+
return [
|
|
72
|
+
`refusing to run: ${n} secret name${n === 1 ? '' : 's'} ${n === 1 ? 'is' : 'are'} defined by more than one service, so the bundle cannot say which value the command should get:`,
|
|
73
|
+
...collisionLines(collisions, branchHint),
|
|
74
|
+
`pick one service's env: insta run --service ${collisions[0]?.services[0] ?? '<type>/<name>'}${b} -- <cmd>`,
|
|
75
|
+
`or run without the name: insta run --ignore-collisions${b} -- <cmd>`,
|
|
76
|
+
];
|
|
77
|
+
}
|
|
8
78
|
/** Core, dependency-injected for tests: spawn cmd with the bundle in env, return its exit code. */
|
|
9
79
|
export async function runWithSecrets(cmd, args, deps) {
|
|
10
80
|
const bundle = await deps.fetchBundle();
|
|
81
|
+
const collisions = bundle.collisions ?? [];
|
|
82
|
+
if (collisions.length) {
|
|
83
|
+
// Refuse rather than warn: with the name missing from the bundle the child would silently
|
|
84
|
+
// inherit the parent's value, and even clearing it isn't enough — the child may hold a
|
|
85
|
+
// compiled-in default or load its own .env, so a missing credential need not be observable.
|
|
86
|
+
if (!deps.ignoreCollisions)
|
|
87
|
+
refuse(refusalLines(collisions, deps.branchHint));
|
|
88
|
+
process.stderr.write(`warning: --ignore-collisions — ${collisions.map((c) => c.name).join(', ')} removed from the child environment (${collisions.length} name${collisions.length === 1 ? '' : 's'} defined by more than one service)\n`);
|
|
89
|
+
}
|
|
90
|
+
const env = childEnv(process.env, bundle.secrets, collisions);
|
|
91
|
+
deps.announce?.(bundle);
|
|
11
92
|
return await new Promise((resolve, reject) => {
|
|
12
|
-
const child = (deps.spawnImpl ?? spawn)(cmd, args, {
|
|
13
|
-
stdio: 'inherit',
|
|
14
|
-
cwd: deps.cwd,
|
|
15
|
-
env: { ...process.env, ...bundle },
|
|
16
|
-
});
|
|
93
|
+
const child = (deps.spawnImpl ?? spawn)(cmd, args, { stdio: 'inherit', cwd: deps.cwd, env });
|
|
17
94
|
child.on('error', reject);
|
|
18
95
|
child.on('close', (code) => resolve(code ?? 1));
|
|
19
96
|
});
|
|
@@ -21,20 +98,18 @@ export async function runWithSecrets(cmd, args, deps) {
|
|
|
21
98
|
export async function run(cmdAndArgs, opts) {
|
|
22
99
|
const [cmd, ...rest] = cmdAndArgs;
|
|
23
100
|
if (!cmd)
|
|
24
|
-
die('usage: insta run [--branch <b>] -- <command> [args…]');
|
|
101
|
+
die('usage: insta run [--branch <b>] [--service <type/name>] -- <command> [args…]');
|
|
102
|
+
assertServiceRef(opts.service);
|
|
25
103
|
const api = await ApiClient.load();
|
|
26
104
|
const p = await requireProject();
|
|
27
105
|
const branch = opts.branch ?? p.branch;
|
|
28
106
|
const code = await runWithSecrets(cmd, rest, {
|
|
29
|
-
fetchBundle:
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
process.stderr.write(`running with ${Object.keys(res.body.secrets).length} injected secrets (branch ${branch}) — nothing written to disk\n`);
|
|
36
|
-
return res.body.secrets;
|
|
37
|
-
},
|
|
107
|
+
fetchBundle: bundleFetcher(api, p.projectId, { branch, service: opts.service }),
|
|
108
|
+
ignoreCollisions: opts.ignoreCollisions,
|
|
109
|
+
branchHint: branchHint(branch, p.branch),
|
|
110
|
+
// stderr, not stdout: `insta run`'s stdout belongs entirely to the child command (that's why
|
|
111
|
+
// run has no --json — wrapping would break the child's own output contract).
|
|
112
|
+
announce: (b) => process.stderr.write(`running with ${Object.keys(b.secrets).length} injected secrets (${opts.service ? `${opts.service}, ` : ''}branch ${branch}) — nothing written to disk\n`),
|
|
38
113
|
});
|
|
39
114
|
relayExitCode(code);
|
|
40
115
|
}
|
package/dist/commands/secrets.js
CHANGED
|
@@ -3,27 +3,99 @@ import { appendFileSync, existsSync, readFileSync } from 'node:fs';
|
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { ApiClient, requireProject } from '../api.js';
|
|
5
5
|
import { info, printJson, serializeEnv, handleApproval, die } from '../util.js';
|
|
6
|
-
function
|
|
7
|
-
return branch ? `?branch=${encodeURIComponent(branch)}` : '';
|
|
8
|
-
}
|
|
9
|
-
// Fetch the credential bundle (the secret seam) and write it to .env (or print).
|
|
10
|
-
export async function secrets(opts) {
|
|
6
|
+
async function loadDeps() {
|
|
11
7
|
const api = await ApiClient.load();
|
|
12
8
|
const p = await requireProject();
|
|
13
|
-
|
|
14
|
-
|
|
9
|
+
return { api, projectId: p.projectId, linkedBranch: p.branch };
|
|
10
|
+
}
|
|
11
|
+
/** A `--service` that arrived empty (`--service ""`, or an unset variable in a script) is a typo,
|
|
12
|
+
* not a request: the platform 400s it, and silently falling back to the branch-wide read would
|
|
13
|
+
* answer a different question than the one asked. Fail locally, naming the shape. */
|
|
14
|
+
export function assertServiceRef(service) {
|
|
15
|
+
if (service !== undefined && service.trim() === '')
|
|
16
|
+
die('--service requires <type>/<name>, e.g. compute/api');
|
|
17
|
+
}
|
|
18
|
+
/** Query for a bundle read. `--service` asks for the env ONE compute service actually receives
|
|
19
|
+
* (unambiguous by construction, so no collision can arise); a general read asks the platform to
|
|
20
|
+
* WITHHOLD any name several services define rather than silently returning one of the values. */
|
|
21
|
+
export function bundleQuery(opts) {
|
|
22
|
+
const parts = [];
|
|
23
|
+
if (opts.branch)
|
|
24
|
+
parts.push(`branch=${encodeURIComponent(opts.branch)}`);
|
|
25
|
+
// A bare/empty --service is a 400 on the platform; the flag is only ever sent with a value.
|
|
26
|
+
if (opts.service)
|
|
27
|
+
parts.push(`service=${encodeURIComponent(opts.service)}`);
|
|
28
|
+
else
|
|
29
|
+
parts.push('on_collision=withhold');
|
|
30
|
+
return `?${parts.join('&')}`;
|
|
31
|
+
}
|
|
32
|
+
/** GET the bundle. Returns null when the platform gated the read (202). A platform that doesn't
|
|
33
|
+
* know `collisions` reads back as none.
|
|
34
|
+
*
|
|
35
|
+
* That fallback FAILS OPEN and is deliberate: such a platform also ignores `on_collision`, so it
|
|
36
|
+
* answers a colliding name with a merged value and no report, and `insta run` would spawn against
|
|
37
|
+
* it believing nothing was withheld. It is safe only by shipping order — insta-platform#388 merges
|
|
38
|
+
* before this CLI is released, so no released build ever talks to a platform without the field
|
|
39
|
+
* (repo owner's call). Do not read the `?? []` as unconditionally safe: if that ordering ever
|
|
40
|
+
* changes, this is where capability detection belongs. */
|
|
41
|
+
export async function fetchSecretBundle(api, projectId, opts) {
|
|
42
|
+
const res = await api.rawRequest('GET', `/projects/${projectId}/secrets${bundleQuery(opts)}`);
|
|
15
43
|
if (handleApproval(res, opts.json))
|
|
44
|
+
return null;
|
|
45
|
+
return { secrets: res.body.secrets, collisions: (res.body.collisions ?? []) };
|
|
46
|
+
}
|
|
47
|
+
/** The branch a remediation hint has to name: the one that was actually read, whenever that is not
|
|
48
|
+
* the linked branch. A hint that silently dropped an explicit `--branch feat-x` would send the
|
|
49
|
+
* user to read the linked branch instead — a different set of secrets, and no sign of the swap. */
|
|
50
|
+
export function branchHint(read, linked) {
|
|
51
|
+
return read && read !== linked ? read : undefined;
|
|
52
|
+
}
|
|
53
|
+
/** Pure: the report for each withheld name — who defines it, and how to read one of them. */
|
|
54
|
+
export function collisionLines(collisions, hintBranch) {
|
|
55
|
+
return collisions.flatMap((c) => [
|
|
56
|
+
`${c.name} omitted — ${c.services.length} services define it:`,
|
|
57
|
+
` ${c.services.join(', ')}`,
|
|
58
|
+
` read one with: insta secrets --service ${c.services[0] ?? '<type>/<name>'}${hintBranch ? ` --branch ${hintBranch}` : ''}`,
|
|
59
|
+
]);
|
|
60
|
+
}
|
|
61
|
+
// STDERR, always: `secrets --print` writes the env to stdout and `insta run`'s stdout belongs to
|
|
62
|
+
// the child command, so a collision report on stdout would corrupt both.
|
|
63
|
+
export function warnCollisions(collisions, hintBranch) {
|
|
64
|
+
for (const line of collisionLines(collisions, hintBranch))
|
|
65
|
+
process.stderr.write(line + '\n');
|
|
66
|
+
}
|
|
67
|
+
// The same report for a machine reader, on stderr for the same reason: stdout carries the payload,
|
|
68
|
+
// which under --json is the bare `{NAME: value}` map every existing consumer parses. Nothing is
|
|
69
|
+
// written when there is nothing to choose between — a quiet stream is the signal, not `[]`.
|
|
70
|
+
export function warnCollisionsJson(collisions) {
|
|
71
|
+
if (collisions.length)
|
|
72
|
+
process.stderr.write(JSON.stringify({ collisions }) + '\n');
|
|
73
|
+
}
|
|
74
|
+
// Fetch the credential bundle (the secret seam) and write it to .env (or print). --service reads
|
|
75
|
+
// one compute service's own env instead of the branch-wide merge.
|
|
76
|
+
export async function secrets(opts, deps) {
|
|
77
|
+
assertServiceRef(opts.service);
|
|
78
|
+
const d = deps ?? (await loadDeps());
|
|
79
|
+
const branch = opts.branch ?? d.linkedBranch;
|
|
80
|
+
const b = await fetchSecretBundle(d.api, d.projectId, { branch, service: opts.service, json: opts.json });
|
|
81
|
+
if (!b)
|
|
16
82
|
return;
|
|
17
|
-
const bundle =
|
|
18
|
-
|
|
83
|
+
const bundle = b.secrets;
|
|
84
|
+
// --json's stdout stays the bare map it has always been; the collisions ride stderr as one JSON
|
|
85
|
+
// line, so a consumer that passes none of the new flags parses exactly what it parsed before.
|
|
86
|
+
if (opts.json) {
|
|
87
|
+
warnCollisionsJson(b.collisions);
|
|
19
88
|
return printJson(bundle);
|
|
89
|
+
}
|
|
90
|
+
warnCollisions(b.collisions, branchHint(branch, d.linkedBranch));
|
|
20
91
|
if (opts.print) {
|
|
21
92
|
process.stdout.write(serializeEnv(bundle));
|
|
22
93
|
return;
|
|
23
94
|
}
|
|
24
95
|
const out = opts.output ?? '.env';
|
|
25
96
|
await writeFile(out, serializeEnv(bundle));
|
|
26
|
-
|
|
97
|
+
const scope = opts.service ? `${opts.service}, branch ${branch}` : `branch ${branch}`;
|
|
98
|
+
info(`wrote ${Object.keys(bundle).length} secrets to ${out} (${scope})`);
|
|
27
99
|
if (ensureIgnored(process.cwd(), out))
|
|
28
100
|
info(` .gitignore += ${out} (credentials must never be committed)`);
|
|
29
101
|
info(' tip: `insta run -- <cmd>` injects these per-run with nothing written to disk');
|
|
@@ -94,6 +166,10 @@ async function readStdin() {
|
|
|
94
166
|
// it to a branch service instead, which implies the current branch (binding requires one). Value
|
|
95
167
|
// comes from the argument, or stdin when omitted (keeps secret values out of shell history).
|
|
96
168
|
export async function secretsSet(name, value, opts) {
|
|
169
|
+
// An empty --service must not fall through to a project-wide WRITE. The scoping test below is a
|
|
170
|
+
// truthiness check, so `--service ''` (a client interpolating an absent variable) would have put
|
|
171
|
+
// the secret at a WIDER scope than the caller asked for, visible to every service on the branch.
|
|
172
|
+
assertServiceRef(opts.service);
|
|
97
173
|
const api = await ApiClient.load();
|
|
98
174
|
const p = await requireProject();
|
|
99
175
|
const v = value ?? (await readStdin());
|
|
@@ -108,16 +184,29 @@ export async function secretsSet(name, value, opts) {
|
|
|
108
184
|
return printJson({ ok: true, name, branch: branch ?? null, service: opts.service ?? null });
|
|
109
185
|
info(`set ${name}${opts.service ? ` → ${opts.service}` : ''} (${branch ? `branch ${branch}` : 'project-wide'})`);
|
|
110
186
|
}
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
const
|
|
187
|
+
// Remove a user secret. --service removes only THAT service's copy (the platform has always
|
|
188
|
+
// honoured ?service= here; without the flag a name several services define stays defined).
|
|
189
|
+
export async function secretsUnset(name, opts, deps) {
|
|
190
|
+
assertServiceRef(opts.service);
|
|
191
|
+
const d = deps ?? (await loadDeps());
|
|
192
|
+
// Service scoping REQUIRES a branch (a service exists on a branch, so the platform rejects the
|
|
193
|
+
// pair without one) — so --service defaults to the linked branch, exactly as `secrets set` does.
|
|
194
|
+
const branch = opts.service ? (opts.branch ?? d.linkedBranch) : opts.branch;
|
|
195
|
+
const parts = [];
|
|
196
|
+
if (branch)
|
|
197
|
+
parts.push(`branch=${encodeURIComponent(branch)}`);
|
|
198
|
+
if (opts.service)
|
|
199
|
+
parts.push(`service=${encodeURIComponent(opts.service)}`);
|
|
200
|
+
const qs = parts.length ? `?${parts.join('&')}` : '';
|
|
201
|
+
const res = await d.api.rawRequest('DELETE', `/projects/${d.projectId}/secrets/${encodeURIComponent(name)}${qs}`);
|
|
116
202
|
if (handleApproval(res, opts.json))
|
|
117
203
|
return;
|
|
204
|
+
// The EFFECTIVE branch, not the flag: with --service and no --branch the scope that was deleted
|
|
205
|
+
// is the linked branch's, and the output has to say which scope it actually touched.
|
|
118
206
|
if (opts.json)
|
|
119
|
-
return printJson({ ok: true, name, branch: opts.
|
|
120
|
-
|
|
207
|
+
return printJson({ ok: true, name, branch: branch ?? null, service: opts.service ?? null });
|
|
208
|
+
const scope = opts.service ? `${opts.service}, branch ${branch}` : branch ? `branch ${branch}` : 'project-wide';
|
|
209
|
+
info(`unset ${name} (${scope})`);
|
|
121
210
|
}
|
|
122
211
|
export async function secretsBind(envName, source, opts) {
|
|
123
212
|
if (!opts.to)
|
package/dist/index.js
CHANGED
|
@@ -22,6 +22,7 @@ import * as secretsCmd from './commands/secrets.js';
|
|
|
22
22
|
import { deploy } from './commands/deploy.js';
|
|
23
23
|
import { build } from './commands/build.js';
|
|
24
24
|
import * as computeCmd from './commands/compute.js';
|
|
25
|
+
import * as githubCmd from './commands/github.js';
|
|
25
26
|
import * as dbCmd from './commands/db.js';
|
|
26
27
|
import * as dbQueryCmd from './commands/db-query.js';
|
|
27
28
|
import * as storageCmd from './commands/storage.js';
|
|
@@ -97,6 +98,8 @@ envCmd.command('use <name>').description(`Switch environment (${ENV_NAMES.join('
|
|
|
97
98
|
// ---- run (per-request secret injection — nothing written to disk) ----
|
|
98
99
|
program.command('run <cmd> [args...]').description('Run a command with the branch credential bundle injected into its environment (no .env written)')
|
|
99
100
|
.option('--branch <b>', 'branch bundle to inject (default: linked branch)')
|
|
101
|
+
.option('--service <type/name>', "inject one compute service's own slice of the branch bundle, e.g. compute/api — the unambiguous read when several services define the same name (NOT the container's env: it also carries the branch's provider credentials, which a container gets only where bound)")
|
|
102
|
+
.option('--ignore-collisions', 'run even when several services define the same name; every such name is REMOVED from the child environment (never inherited from your shell)')
|
|
100
103
|
.passThroughOptions().allowUnknownOption()
|
|
101
104
|
.action(guard((cmd, args, o) => runCmd.run([cmd, ...(args ?? [])], o)));
|
|
102
105
|
// ---- agent setup (the `curl … | sh --agents` target) ----
|
|
@@ -169,14 +172,18 @@ svc.command('secrets <type> <name>').description("List a service's secret names"
|
|
|
169
172
|
.option('--branch <b>').option('--json').action(guard((type, name, o) => services.servicesSecrets(type, name, o)));
|
|
170
173
|
// ---- secrets (seam) ----
|
|
171
174
|
const sec = program.command('secrets').description('Fetch the credential bundle (secret seam) into .env')
|
|
172
|
-
.option('--branch <branch>')
|
|
175
|
+
.option('--branch <branch>')
|
|
176
|
+
.option('--service <type/name>', "read one compute service's own slice of the bundle instead of the branch-wide merge, e.g. compute/api")
|
|
177
|
+
.option('-o, --output <file>', 'output file (default .env)').option('--print', 'print instead of writing').option('--json')
|
|
173
178
|
.action(guard((o) => secretsCmd.secrets(o)));
|
|
174
179
|
sec.command('list').description('List secret names, grouped by service').option('--branch <branch>').option('--json').action(guard((o) => secretsCmd.secretsList(o)));
|
|
175
180
|
sec.command('set <name> [value]').description('Set a user secret (project-wide; value from stdin if omitted)')
|
|
176
181
|
.option('--branch <branch>', 'scope to one branch').option('--service <type/name>', 'bind to a branch service (implies current branch)')
|
|
177
182
|
.option('--json').action(guard((n, v, o) => secretsCmd.secretsSet(n, v, o)));
|
|
178
183
|
sec.command('unset <name>').description('Remove a user secret')
|
|
179
|
-
.option('--branch <branch>', 'scope to one branch')
|
|
184
|
+
.option('--branch <branch>', 'scope to one branch')
|
|
185
|
+
.option('--service <type/name>', "remove only that service's copy, e.g. compute/api")
|
|
186
|
+
.option('--json').action(guard((n, o) => secretsCmd.secretsUnset(n, o)));
|
|
180
187
|
sec.command('bind <env-name> <source>').description('Bind a service credential into a compute env var')
|
|
181
188
|
.option('--branch <branch>', 'branch (default: current)')
|
|
182
189
|
.option('--to <compute-service>', 'target compute service, e.g. compute/api')
|
|
@@ -209,6 +216,7 @@ program.command('build [dir]').description('Verify a source directory would buil
|
|
|
209
216
|
program.command('deploy [dir]').description('Deploy a source directory (built remotely on Fly) or a prebuilt --image to a branch compute group')
|
|
210
217
|
.option('--image <url>', 'prebuilt container image to deploy (instead of a source dir)').option('--branch <b>').option('--group <g>').option('--port <p>')
|
|
211
218
|
.option('--websocket', 'run a WebSocket app (larger guest + connection-based concurrency)')
|
|
219
|
+
.option('--replace-source', 'the service deploys from a connected GitHub repo: switch it to this image and remove the repo connection (admin); without it such a deploy is refused')
|
|
212
220
|
.option('--json', 'print the deploy result as JSON (build progress goes to stderr)')
|
|
213
221
|
.action(guard((dir, o) => deploy(dir, o)));
|
|
214
222
|
// `insta compute exec` needs the command verbatim after a literal `--`; split it out of argv here,
|
|
@@ -244,6 +252,18 @@ const execCmd = compute.command('exec [service]').description("Run a one-shot co
|
|
|
244
252
|
// new option cannot reach the CLI surface while the split still reads it as part of the command.
|
|
245
253
|
for (const [flags, description] of computeCmd.EXEC_OPTIONS)
|
|
246
254
|
execCmd.option(flags, description);
|
|
255
|
+
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, and whether pushes redeploy it')
|
|
256
|
+
.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 reachable through the org's GitHub App installation — connect GitHub in the console first (Add Service → GitHub Repo) — or be public. Build and start commands come from detection and cannot be set. Connecting again replaces the service's current source")
|
|
258
|
+
.option('--public', 'the repo is public and no GitHub App installation is needed (deploys are manual; pushes cannot redeploy)')
|
|
259
|
+
.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
|
+
.option('--repo-branch <name>', "the repository branch to build (default: the repo's default branch)")
|
|
261
|
+
.option('--no-auto-deploy', 'do not rebuild on pushes; redeploy by connecting again or from the console')
|
|
262
|
+
.option('--port <n>', 'port the app listens on (default: detected)')
|
|
263
|
+
.option('--branch <branch>', 'branch (default: current) — the environment the service is on')
|
|
264
|
+
.option('--json').action(guard((ref, service, o) => githubCmd.computeConnectRepo(ref, service, o)));
|
|
265
|
+
compute.command('disconnect-repo [service]').description('Disconnect the GitHub repository from a compute service. The service keeps running its current image; pushes no longer deploy it, and its build history stays')
|
|
266
|
+
.option('--json').option('--branch <branch>', 'branch (default: current)').action(guard((service, o) => githubCmd.computeDisconnectRepo(service, o)));
|
|
247
267
|
compute.command('volume [service]').description("Show, attach, grow, or delete a compute service's persistent /data volume. No flag: print size, mount path, and the plan cap (any plan). --size on a volumeless service ATTACHES one (any plan at the default 10Gi, the free cap; larger is paid and plan-capped; the disk mounts at /data on the next deploy); on a volume-bearing one it grows (paid plans; grow-only — a provisioned disk cannot shrink). --delete DESTROYS the disk and ALL its data immediately (no detach, no undo; billing stops now, and suspend fast-wake + scale-out return). Billing is actual data stored — the size is a cap, not a price")
|
|
248
268
|
.option('--size <gi>', 'new size in whole Gi, e.g. 10 (must be ≥ the current size)')
|
|
249
269
|
.option('--delete', 'destroy the volume and ALL its data (irreversible; download anything you need first)')
|
package/dist/resolve-service.js
CHANGED
|
@@ -7,8 +7,7 @@
|
|
|
7
7
|
import * as clack from '@clack/prompts';
|
|
8
8
|
import { SERVICE_TYPES, assertServiceName, parsePort } from './commands/services.js';
|
|
9
9
|
import { CliCancel } from './util.js';
|
|
10
|
-
//
|
|
11
|
-
// out: the platform has no repo path yet, so a CLI entry could only say "coming soon".
|
|
10
|
+
// The CLI connects repos to existing services only; "GitHub Repo" is not a service kind here.
|
|
12
11
|
export const SERVICE_KINDS = [
|
|
13
12
|
{ id: 'image', label: 'Docker Image', type: 'compute', hint: 'run an existing container image', needsImage: true },
|
|
14
13
|
{ id: 'postgres', label: 'Postgres', type: 'postgres', hint: 'relational DB, usable as soon as it is added', defaultName: 'main-db' },
|
package/dist/util.js
CHANGED
|
@@ -81,6 +81,16 @@ export function die(msg) {
|
|
|
81
81
|
fail(msg);
|
|
82
82
|
throw new CliExit();
|
|
83
83
|
}
|
|
84
|
+
// The CLI declined to act and the caller must choose how to proceed — the same shape as the 202
|
|
85
|
+
// approval gate below, so it takes the same exit code 2: not success (a redirected stdout must
|
|
86
|
+
// never read the refusal as output), and not a plain failure either (die owns 1). Nothing ran,
|
|
87
|
+
// and re-running with the flag the message names will work.
|
|
88
|
+
export function refuse(lines) {
|
|
89
|
+
for (const line of lines)
|
|
90
|
+
process.stderr.write(line + '\n');
|
|
91
|
+
process.exitCode = 2;
|
|
92
|
+
throw new CliExit();
|
|
93
|
+
}
|
|
84
94
|
export function printJson(v) {
|
|
85
95
|
process.stdout.write(JSON.stringify(v, null, 2) + '\n');
|
|
86
96
|
}
|