insta 0.0.62 → 0.0.64

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -121,10 +121,15 @@ only. Provider-minted service credentials (`DATABASE_URL`, `BUCKET_NAME`,
121
121
 
122
122
  ### Destructive actions can require approval
123
123
 
124
- Reading secrets, deploying, deleting a project or branch, and changing services are
125
- governed by a per-project policy. Where the policy says `approve`, the command stops and
126
- prints an approval id for an admin to grant with `insta approvals approve <id>`. Run
127
- `insta policy get` for the live policy.
124
+ Agent requests are governed by the project's `agent-policy`; human requests use normal RBAC.
125
+ Where the agent policy says `approve`, the command stops and prints an approval id for a human
126
+ admin to grant with `insta approvals approve <id>`. The agent then retries the unchanged request.
127
+ Run `insta --agent agent-policy get --json` for stored overrides, `defaultRules`, `effectiveRules`,
128
+ `bootstrapRules` and `ruleNotes`. Rules distinguish no affected branches (`project`), unprotected
129
+ branches and protected branches. They describe policy, not authorization: RBAC, session checks,
130
+ actual affected resources and compound actions still apply. An empty override object does not
131
+ mean rules are unavailable. Text output also lists effective rules. The old `policy` command and approval
132
+ `--always` option have been removed.
128
133
 
129
134
  ### Agents get the same surface
130
135
 
@@ -213,7 +218,7 @@ build never reaches a production installer.
213
218
  | `insta metrics` · `logs` · `events` | Service metrics; runtime logs (`--deploy` for deploy events); audit timeline |
214
219
  | `insta usage` · `billing` | Usage by billing dimension; `billing upgrade` · `billing portal` |
215
220
  | `insta approvals` | `list` · `approve` · `deny` |
216
- | `insta policy` | `get` · `set <action> <decision>` |
221
+ | `insta agent-policy` | `get` · `set <mode>` · `protect-branch` · `unprotect-branch` · `rule set <action> <decision>` · `revoke-sessions` |
217
222
  | `insta observe` | `install` · `uninstall` · `report` · `sync` — local credential audit |
218
223
  | `insta feedback` | Report an InstaCloud-side hurdle (bug / feature-request / friction) to the team — never for the app you are building; works logged-out |
219
224
  | `insta upgrade` · `autoupdate` | Update the CLI; show or set auto-update |
package/dist/agent.js ADDED
@@ -0,0 +1,86 @@
1
+ import { createHash, generateKeyPairSync, randomUUID, sign } from 'node:crypto';
2
+ import { chmod, mkdir, readFile, rename, writeFile } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { findProjectRoot, readProject } from './config.js';
5
+ import { alreadyTracked, ensureGitignore } from './gitignore.js';
6
+ let mode = null;
7
+ export function detectAgent(explicit, env = process.env) {
8
+ const client = env.CODEX_THREAD_ID || env.CODEX_CI === '1' ? 'codex'
9
+ : env.CLAUDECODE === '1' ? 'claude-code'
10
+ : env.CURSOR_AGENT === '1' ? 'cursor' : 'unknown';
11
+ return explicit || client !== 'unknown' ? { source: explicit ? 'cli-explicit' : 'cli-detected', client } : null;
12
+ }
13
+ export function configureAgent(value) { mode = value; }
14
+ export function agentMode() { return mode; }
15
+ const hash = (v) => createHash('sha256').update(v).digest('hex');
16
+ export function canonicalTarget(path) {
17
+ const url = new URL(path, 'https://platform.invalid');
18
+ return url.pathname + url.search;
19
+ }
20
+ const guidance = 'agent session missing, expired, or for another project/environment — run `insta setup agent`';
21
+ export async function issueAgentSession(api, projectId) {
22
+ const pair = generateKeyPairSync('ed25519');
23
+ const client = mode?.client ?? detectAgent(false)?.client ?? 'unknown';
24
+ const out = await api.request('POST', '/agent/sessions', {
25
+ projectId, client, publicKey: pair.publicKey.export({ type: 'spki', format: 'pem' }).toString(),
26
+ });
27
+ return { ...out, client, apiUrl: api.apiUrl.replace(/\/+$/, ''), privateKey: pair.privateKey.export({ type: 'pkcs8', format: 'pem' }).toString() };
28
+ }
29
+ export async function saveAgentSession(session, cwd = process.cwd()) {
30
+ const root = await findProjectRoot(cwd) ?? cwd;
31
+ const rel = '.insta/agent-session.json';
32
+ if (alreadyTracked(root, [rel]).length)
33
+ throw new Error('agent-session.json is tracked by Git; untrack it before running insta setup agent');
34
+ ensureGitignore(root, [rel], '# Local agent credentials');
35
+ const dir = join(root, '.insta');
36
+ await mkdir(dir, { recursive: true });
37
+ const temp = join(dir, `.agent-session-${randomUUID()}.tmp`);
38
+ // Ignore crash leftovers too; temporary files contain the same private material.
39
+ ensureGitignore(root, ['.insta/.agent-session-*.tmp']);
40
+ await writeFile(temp, JSON.stringify(session, null, 2), { mode: 0o600 });
41
+ await rename(temp, join(root, rel));
42
+ await chmod(join(root, rel), 0o600);
43
+ }
44
+ export async function setupProjectAgentSession(api, projectId) {
45
+ const id = projectId ?? (await readProject())?.projectId;
46
+ if (!id)
47
+ return false;
48
+ await saveAgentSession(await issueAgentSession(api, id));
49
+ return true;
50
+ }
51
+ export async function loadAgentSession(apiUrl, projectId, cwd = process.cwd()) {
52
+ try {
53
+ const root = await findProjectRoot(cwd) ?? cwd;
54
+ const session = JSON.parse(await readFile(join(root, '.insta/agent-session.json'), 'utf8'));
55
+ if (session.projectId !== projectId || session.apiUrl !== apiUrl.replace(/\/+$/, '') || !session.token || !session.privateKey
56
+ || !Number.isFinite(Date.parse(session.expiresAt)) || Date.parse(session.expiresAt) <= Date.now())
57
+ throw new Error();
58
+ return session;
59
+ }
60
+ catch {
61
+ throw new Error(guidance);
62
+ }
63
+ }
64
+ export async function agentHeaders(api, method, path, rawBody) {
65
+ if (!mode)
66
+ return {};
67
+ if (canonicalTarget(path) === '/agent/sessions' && method === 'POST')
68
+ return {
69
+ 'Insta-Actor-Type': 'agent', 'Insta-Agent-Source': mode.source, 'Insta-Agent-Client': mode.client,
70
+ };
71
+ const target = canonicalTarget(path);
72
+ const match = target.match(/^\/projects\/([^/?]+)/);
73
+ // Account reads/project creation have no project policy yet. Mint a short-lived bootstrap
74
+ // assertion in memory. It cannot access project routes; never downgrade to a human request.
75
+ const session = match ? await loadAgentSession(api.apiUrl, decodeURIComponent(match[1])) : await issueAgentSession(api);
76
+ const timestamp = String(Math.floor(Date.now() / 1000));
77
+ const nonce = randomUUID();
78
+ const proof = [method.toUpperCase(), target, hash(rawBody), session.agentSessionId, timestamp, nonce, mode.source, session.client].join('\n');
79
+ return {
80
+ 'Insta-Actor-Type': 'agent', 'Insta-Agent-Session': session.agentSessionId,
81
+ 'Insta-Agent-Session-Token': session.token, 'Insta-Agent-Source': mode.source,
82
+ 'Insta-Agent-Client': session.client, 'Insta-Agent-Timestamp': timestamp,
83
+ 'Insta-Agent-Nonce': nonce, 'Insta-Agent-Signature': sign(null, Buffer.from(proof), session.privateKey).toString('base64url'),
84
+ };
85
+ }
86
+ //# sourceMappingURL=agent.js.map
package/dist/api.js CHANGED
@@ -4,6 +4,7 @@ import { readGlobal, writeGlobal, readProject, writeProject } from './config.js'
4
4
  import { autoResolveProject, promptChoice } from './resolve-project.js';
5
5
  import { die } from './util.js';
6
6
  import { USER_AGENT } from './version.js';
7
+ import { agentHeaders, agentMode } from './agent.js';
7
8
  export class ApiError extends Error {
8
9
  status;
9
10
  body;
@@ -16,6 +17,13 @@ export class ApiError extends Error {
16
17
  this.name = 'ApiError';
17
18
  }
18
19
  }
20
+ export class AgentApprovalRequired extends Error {
21
+ body;
22
+ constructor(body) {
23
+ super(body.message ?? `approval required: ${body.approvalId}`);
24
+ this.body = body;
25
+ }
26
+ }
19
27
  // Store a durable insta_ key as the credential: set it as the bearer and drop any refresh token (an insta_ key never rotates; a stale one would leak to /auth/refresh on a 401).
20
28
  export function storeApiKeyCredential(cfg, token, user) {
21
29
  cfg.accessToken = token;
@@ -53,6 +61,8 @@ export class ApiClient {
53
61
  // Returns parsed body for status < 400 (incl. 202); throws ApiError otherwise.
54
62
  async request(method, path, body, opts = {}) {
55
63
  const res = await this.raw(method, path, body, opts.auth ?? true);
64
+ if (agentMode() && res.status === 202 && res.body?.status === 'approval_required')
65
+ throw new AgentApprovalRequired(res.body);
56
66
  if (res.status >= 400)
57
67
  throw new ApiError(res.status, res.body?.error ?? `HTTP ${res.status}`, res.body);
58
68
  return res.body;
@@ -76,6 +86,8 @@ export class ApiClient {
76
86
  const headers = { 'Content-Type': 'application/json', 'Insta-Hints': '1', 'User-Agent': USER_AGENT };
77
87
  if (auth && this.cfg.accessToken)
78
88
  headers.Authorization = `Bearer ${this.cfg.accessToken}`;
89
+ if (auth)
90
+ Object.assign(headers, await agentHeaders(this, method, path, body === undefined ? '' : JSON.stringify(body)));
79
91
  const res = await this.fetchImpl(this.apiUrl + path, {
80
92
  method,
81
93
  headers,
@@ -113,6 +125,8 @@ export async function requireProject() {
113
125
  const p = await readProject();
114
126
  if (p)
115
127
  return p;
128
+ if (agentMode())
129
+ die('agent mode requires a linked project — run `insta setup agent --project <id>`');
116
130
  // One command, just works: unlinked ≠ error. Resolve the project (auto when there's one,
117
131
  // one-keystroke picker when several) and persist the choice so this happens once per dir.
118
132
  const api = await ApiClient.load();
@@ -0,0 +1,72 @@
1
+ import { ApiClient, requireProject } from '../api.js';
2
+ import { handleApproval, info, printJson } from '../util.js';
3
+ async function current() {
4
+ const api = await ApiClient.load();
5
+ const project = await requireProject();
6
+ const path = `/projects/${project.projectId}/agent-policy`;
7
+ const out = await api.request('GET', path);
8
+ return { api, project, path, policy: out.policy, out };
9
+ }
10
+ export function displayPolicy(out, opts, output = { info, printJson }) {
11
+ const { info, printJson } = output;
12
+ const { policy, agentSessionEpoch } = out;
13
+ if (opts.json)
14
+ return printJson(out);
15
+ info(`agent policy: ${policy.mode}\nprotected branches: ${policy.protectedBranchIds.join(', ') || '(none)'}\nsession epoch: ${agentSessionEpoch}`);
16
+ if (out.effectiveRules) {
17
+ for (const [scope, rules] of Object.entries(out.effectiveRules)) {
18
+ info(`\n${scope}:`);
19
+ for (const [action, decision] of Object.entries(rules))
20
+ info(` ${action}: ${decision}`);
21
+ }
22
+ info(`\nbootstrap: project.create = ${out.bootstrapRules?.['project.create'] ?? '(not reported)'}`);
23
+ for (const note of out.ruleNotes ?? [])
24
+ info(note);
25
+ }
26
+ else
27
+ info('This Platform does not expose resolved rules; upgrade Platform to inspect defaults.');
28
+ }
29
+ export async function get(opts) {
30
+ const { out } = await current();
31
+ // Forward the public response, never the internal API client (which holds credentials).
32
+ displayPolicy(out, opts);
33
+ }
34
+ async function update(change, opts) {
35
+ const state = await current();
36
+ await change(state.policy, state);
37
+ const result = await state.api.rawRequest('PUT', state.path, state.policy);
38
+ if (handleApproval(result, opts.json))
39
+ return;
40
+ if (opts.json)
41
+ return printJson(result.body);
42
+ info(`agent policy updated: ${result.body.policy.mode}`);
43
+ }
44
+ export async function set(mode, opts) {
45
+ const normalized = mode.replace(/-/g, '_');
46
+ if (!['full_access', 'read_only', 'branch_developer'].includes(normalized))
47
+ throw new Error('mode must be full-access, read-only, or branch-developer');
48
+ return update(policy => { policy.mode = normalized; }, opts);
49
+ }
50
+ export async function protect(branch, enabled, opts) {
51
+ return update(async (policy, { api, project }) => {
52
+ const { branches } = await api.request('GET', `/projects/${project.projectId}/branches`);
53
+ const found = branches.find((b) => b.id === branch || b.name === branch);
54
+ if (!found)
55
+ throw new Error('branch not found');
56
+ policy.protectedBranchIds = enabled ? [...new Set([...policy.protectedBranchIds, found.id])] : policy.protectedBranchIds.filter((id) => id !== found.id);
57
+ }, opts);
58
+ }
59
+ export async function rule(action, decision, opts) {
60
+ if (!['allow', 'deny', 'approve'].includes(decision))
61
+ throw new Error('decision must be allow, deny, or approve');
62
+ return update(policy => { policy.branchDeveloperRules[action] = decision; }, opts);
63
+ }
64
+ export async function revoke(opts) {
65
+ const api = await ApiClient.load();
66
+ const project = await requireProject();
67
+ const out = await api.request('POST', `/projects/${project.projectId}/agent-sessions/revoke`);
68
+ if (opts.json)
69
+ return printJson(out);
70
+ info(`all project agent sessions revoked (epoch ${out.agentSessionEpoch})`);
71
+ }
72
+ //# sourceMappingURL=agent-policy.js.map
@@ -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 on GitHub to use the nixpacks lane`,
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}`);
@@ -429,6 +429,8 @@ function operandIndices(argv, from, to = argv.length) {
429
429
  function execCommandIndex(argv) {
430
430
  for (let cursor = 2; cursor < argv.length; cursor++) {
431
431
  const token = argv[cursor];
432
+ if (token === '--agent')
433
+ continue;
432
434
  if (token.startsWith('-'))
433
435
  return -1; // a global flag, or `--`: either way not our command path
434
436
  return token === 'compute' && argv[cursor + 1] === 'exec' ? cursor : -1;
@@ -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
- " - connect the app's GitHub repo in the console — that lane builds Dockerfile-less repos with nixpacks server-side",
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
@@ -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
@@ -28,10 +28,10 @@ export async function approvalsList(opts) {
28
28
  export async function approvalsApprove(id, opts) {
29
29
  const api = await ApiClient.load();
30
30
  const p = await requireProject();
31
- const out = await api.request('POST', `/projects/${p.projectId}/approvals/${id}/approve`, { always: !!opts.always });
31
+ const out = await api.request('POST', `/projects/${p.projectId}/approvals/${id}/approve`, {});
32
32
  if (opts.json)
33
33
  return printJson(out);
34
- info(`approved ${out.approval.action} (${id})${opts.always ? ' — policy set to allow' : ''}`);
34
+ info(`approved ${out.approval.action} (${id})`);
35
35
  }
36
36
  export async function approvalsDeny(id, opts = {}) {
37
37
  const api = await ApiClient.load();
@@ -41,21 +41,4 @@ export async function approvalsDeny(id, opts = {}) {
41
41
  return printJson(out);
42
42
  info(`denied ${out.approval.action} (${id})`);
43
43
  }
44
- export async function policyGet(opts) {
45
- const api = await ApiClient.load();
46
- const p = await requireProject();
47
- const { policy } = await api.request('GET', `/projects/${p.projectId}/policy`);
48
- if (opts.json)
49
- return printJson(policy);
50
- for (const [action, decision] of Object.entries(policy))
51
- info(`${action}: ${decision}`);
52
- }
53
- export async function policySet(action, decision, opts = {}) {
54
- const api = await ApiClient.load();
55
- const p = await requireProject();
56
- const out = await api.request('PUT', `/projects/${p.projectId}/policy/${action}`, { decision });
57
- if (opts.json)
58
- return printJson({ action, decision, ...(out ?? {}) });
59
- info(`policy ${action} = ${decision}`);
60
- }
61
44
  //# sourceMappingURL=govern.js.map
@@ -1,4 +1,5 @@
1
1
  import { homedir } from 'node:os';
2
+ import { agentMode, setupProjectAgentSession } from '../agent.js';
2
3
  import { ApiClient, requireProject } from '../api.js';
3
4
  import { writeProject } from '../config.js';
4
5
  import { info, die, printJson, handleApproval, renderNextActions } from '../util.js';
@@ -76,6 +77,8 @@ export async function projectCreate(name, opts) {
76
77
  const orgId = await resolveOrg(api, opts.org);
77
78
  const out = await api.request('POST', `/orgs/${orgId}/projects`, { name: resolved });
78
79
  await writeProject({ projectId: out.project.id, orgId, branch: out.defaultBranch.name });
80
+ if (agentMode())
81
+ await setupProjectAgentSession(api, out.project.id);
79
82
  if (opts.json) {
80
83
  printJson({ ...out, linked: { projectId: out.project.id, orgId, branch: out.defaultBranch.name } });
81
84
  }
@@ -101,6 +104,8 @@ export async function projectList(opts) {
101
104
  }
102
105
  export async function projectLink(id, opts = {}) {
103
106
  const api = await ApiClient.load();
107
+ if (agentMode())
108
+ await setupProjectAgentSession(api, id);
104
109
  const { project } = await api.request('GET', `/projects/${id}`);
105
110
  await writeProject({ projectId: project.id, orgId: project.org_id, branch: 'main' });
106
111
  if (opts.json)
@@ -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);
@@ -11,6 +11,7 @@ import { join } from 'node:path';
11
11
  import os from 'node:os';
12
12
  import { createInterface } from 'node:readline';
13
13
  import { ApiClient } from '../api.js';
14
+ import { setupProjectAgentSession } from '../agent.js';
14
15
  import { readPersistedGlobal, resolveEnv } from '../config.js';
15
16
  import { DEFAULT_ENV, ENVS, ENV_NAMES, envForApiUrl, envFromEnvVar, isEnvName, mcpServerName } from '../env.js';
16
17
  import { info, openUrl } from '../util.js';
@@ -362,7 +363,7 @@ export async function setupAgent(opts, run = defaultRunner, mint, installConfigs
362
363
  login: () => loginDevice({}, openUrl),
363
364
  stdinTty: canPromptViaTty(),
364
365
  stdoutTty: !!process.stdout.isTTY,
365
- }, link = projectLink, create = (n) => projectCreate(n, {})) {
366
+ }, link = projectLink, create = (n) => projectCreate(n, {}), enroll = async () => setupProjectAgentSession(await ApiClient.load())) {
366
367
  if (!opts.yes && !process.stdout.isTTY) {
367
368
  info('non-interactive shell — assuming -y');
368
369
  }
@@ -457,6 +458,13 @@ export async function setupAgent(opts, run = defaultRunner, mint, installConfigs
457
458
  }
458
459
  }
459
460
  }
461
+ if (loggedIn) {
462
+ if (await enroll())
463
+ info('✓ Project agent session ready (expires in 24 hours; refresh with insta setup agent)');
464
+ }
465
+ else {
466
+ info(' project agent session not created — run `insta login`, then `insta setup agent`');
467
+ }
460
468
  // THE summary line. The restart note exists because config-file agents only read their MCP
461
469
  // config at startup; the skill files need no restart.
462
470
  const mcpOk = claude === 'new' || claude === 'existing' || others.length > 0;
package/dist/config.js CHANGED
@@ -2,6 +2,7 @@
2
2
  import { homedir } from 'node:os';
3
3
  import { dirname, join, resolve } from 'node:path';
4
4
  import { mkdir, readFile, writeFile } from 'node:fs/promises';
5
+ import { ensureGitignore } from './gitignore.js';
5
6
  import { DEFAULT_ENV, ENVS, envForApiUrl, envFromEnvVar, normalizeUrl } from './env.js';
6
7
  const GLOBAL_DIR = join(homedir(), '.insta');
7
8
  const GLOBAL_FILE = join(GLOBAL_DIR, 'config.json');
@@ -125,6 +126,7 @@ export async function readProject(cwd = process.cwd()) {
125
126
  export async function writeProject(c, cwd = process.cwd()) {
126
127
  const target = (await findProjectRoot(cwd)) ?? cwd;
127
128
  await mkdir(join(target, PROJECT_DIR), { recursive: true });
129
+ ensureGitignore(target, ['.insta/agent-session.json'], '# Local agent credentials');
128
130
  await writeFile(join(target, PROJECT_DIR, PROJECT_FILE), JSON.stringify(c, null, 2));
129
131
  }
130
132
  //# sourceMappingURL=config.js.map
package/dist/index.js CHANGED
@@ -1,6 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command } from 'commander';
3
- import { ApiError } from './api.js';
3
+ import { configureAgent, detectAgent } from './agent.js';
4
+ import * as agentPolicy from './commands/agent-policy.js';
5
+ import { ApiError, AgentApprovalRequired } from './api.js';
4
6
  import { CliCancel, CliExit, fail, relayedExitCode } from './util.js';
5
7
  import { trackCommand } from './telemetry.js';
6
8
  import { cliVersion } from './version.js';
@@ -20,6 +22,7 @@ import * as secretsCmd from './commands/secrets.js';
20
22
  import { deploy } from './commands/deploy.js';
21
23
  import { build } from './commands/build.js';
22
24
  import * as computeCmd from './commands/compute.js';
25
+ import * as githubCmd from './commands/github.js';
23
26
  import * as dbCmd from './commands/db.js';
24
27
  import * as dbQueryCmd from './commands/db-query.js';
25
28
  import * as storageCmd from './commands/storage.js';
@@ -32,6 +35,14 @@ import { billing, billingUpgrade, billingPortal } from './commands/billing.js';
32
35
  import * as selfUpdate from './commands/upgrade.js';
33
36
  import * as feedbackCmd from './commands/feedback.js';
34
37
  function onError(e) {
38
+ if (e instanceof AgentApprovalRequired) {
39
+ if (process.argv.includes('--json'))
40
+ process.stdout.write(JSON.stringify(e.body) + '\n');
41
+ else
42
+ process.stderr.write(e.message + '\n');
43
+ process.exitCode = 2;
44
+ return;
45
+ }
35
46
  if (e instanceof CliExit || e instanceof CliCancel)
36
47
  return;
37
48
  if (e instanceof ApiError)
@@ -64,6 +75,8 @@ const program = new Command();
64
75
  // against the subcommand's own (identically-named) option instead.
65
76
  program.enablePositionalOptions();
66
77
  program.name('insta').description('InstaCloud CLI — manage projects, branches, secrets, deploys').version(cliVersion());
78
+ program.option('--agent', 'run as an agent with a verified project session and project agent policy');
79
+ program.hook('preAction', () => configureAgent(detectAgent(!!program.opts().agent)));
67
80
  // ---- auth ----
68
81
  program.command('login').description('Log in — bare: sign in from your browser (any account type); or --email <email> + password, --oauth <github|google>, --device (headless), --api-key <insta_…> (headless, durable token)')
69
82
  .option('--email <email>', 'account email (email + password login)')
@@ -197,6 +210,7 @@ program.command('build [dir]').description('Verify a source directory would buil
197
210
  program.command('deploy [dir]').description('Deploy a source directory (built remotely on Fly) or a prebuilt --image to a branch compute group')
198
211
  .option('--image <url>', 'prebuilt container image to deploy (instead of a source dir)').option('--branch <b>').option('--group <g>').option('--port <p>')
199
212
  .option('--websocket', 'run a WebSocket app (larger guest + connection-based concurrency)')
213
+ .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')
200
214
  .option('--json', 'print the deploy result as JSON (build progress goes to stderr)')
201
215
  .action(guard((dir, o) => deploy(dir, o)));
202
216
  // `insta compute exec` needs the command verbatim after a literal `--`; split it out of argv here,
@@ -232,6 +246,18 @@ const execCmd = compute.command('exec [service]').description("Run a one-shot co
232
246
  // new option cannot reach the CLI surface while the split still reads it as part of the command.
233
247
  for (const [flags, description] of computeCmd.EXEC_OPTIONS)
234
248
  execCmd.option(flags, description);
249
+ 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')
250
+ .option('--json').option('--branch <branch>', 'branch (default: current)').action(guard((service, o) => githubCmd.computeRepo(service, o)));
251
+ 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")
252
+ .option('--public', 'the repo is public and no GitHub App installation is needed (deploys are manual; pushes cannot redeploy)')
253
+ .option('--root-dir <dir>', 'the directory of the repo to build (a monorepo with several deployable directories lists them and exits 1 without it)')
254
+ .option('--repo-branch <name>', "the repository branch to build (default: the repo's default branch)")
255
+ .option('--no-auto-deploy', 'do not rebuild on pushes; redeploy by connecting again or from the console')
256
+ .option('--port <n>', 'port the app listens on (default: detected)')
257
+ .option('--branch <branch>', 'branch (default: current) — the environment the service is on')
258
+ .option('--json').action(guard((ref, service, o) => githubCmd.computeConnectRepo(ref, service, o)));
259
+ 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')
260
+ .option('--json').option('--branch <branch>', 'branch (default: current)').action(guard((service, o) => githubCmd.computeDisconnectRepo(service, o)));
235
261
  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")
236
262
  .option('--size <gi>', 'new size in whole Gi, e.g. 10 (must be ≥ the current size)')
237
263
  .option('--delete', 'destroy the volume and ALL its data (irreversible; download anything you need first)')
@@ -324,7 +350,7 @@ program.command('events').description('Show the audit + agent-event timeline').o
324
350
  // ---- approvals ----
325
351
  const ap = program.command('approvals').description('Governance approvals (HITL)');
326
352
  ap.command('list').option('--status <s>', 'pending|granted|denied|consumed').option('--json').action(guard((o) => govern.approvalsList(o)));
327
- ap.command('approve <id>').option('--always', 'also set the policy to allow').option('--json').action(guard((id, o) => govern.approvalsApprove(id, o)));
353
+ ap.command('approve <id>').option('--json').action(guard((id, o) => govern.approvalsApprove(id, o)));
328
354
  ap.command('deny <id>').option('--json').action(guard((id, o) => govern.approvalsDeny(id, o)));
329
355
  // ---- observe (local credential audit) ----
330
356
  const ob = program.command('observe').description('Local credential-audit hook');
@@ -333,9 +359,16 @@ ob.command('uninstall').action(guard(() => observe.observeUninstall()));
333
359
  ob.command('report').description('Render the local credential audit').option('--json').action(guard((o) => observe.observeReport(o)));
334
360
  ob.command('sync').description('Upload findings into the project timeline').action(guard(() => observe.observeSync()));
335
361
  // ---- policy ----
336
- const pol = program.command('policy').description('Governance policy');
337
- pol.command('get').option('--json').action(guard((o) => govern.policyGet(o)));
338
- pol.command('set <action> <decision>').description('action: secrets.read|secrets.write|deploy|project.delete|branch.delete|service.add|service.remove|service.scale|service.upgrade|service.setAccess|storage.read|storage.write|storage.delete; decision: allow|deny|approve').option('--json').action(guard((a, d, o) => govern.policySet(a, d, o)));
362
+ const agentPol = program.command('agent-policy').description('Project agent access policy');
363
+ agentPol.command('get').option('--json').action(guard((o) => agentPolicy.get(o)));
364
+ agentPol.command('set <mode>').description('full-access | read-only | branch-developer')
365
+ .option('--json').action(guard((mode, o) => agentPolicy.set(mode, o)));
366
+ agentPol.command('protect-branch <branch>').option('--json').action(guard((branch, o) => agentPolicy.protect(branch, true, o)));
367
+ agentPol.command('unprotect-branch <branch>').option('--json').action(guard((branch, o) => agentPolicy.protect(branch, false, o)));
368
+ agentPol.command('rule').command('set <action> <decision>').description('Set an unprotected-branch rule: allow | deny | approve')
369
+ .option('--json').action(guard((action, decision, o) => agentPolicy.rule(action, decision, o)));
370
+ agentPol.command('revoke-sessions').description('Revoke ALL CLI agent sessions for this project')
371
+ .option('--json').action(guard((o) => agentPolicy.revoke(o)));
339
372
  // ---- feedback (agent + human hurdle reports → the InstaCloud team) ----
340
373
  program.command('feedback')
341
374
  .description('Report an InstaCloud-side hurdle (bug / missing feature / friction) to the InstaCloud team — about the insta toolkit itself, NEVER about the app you are building. Works logged-out and unlinked.')
@@ -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
- // Same order, labels and default names as the dashboard's Add Service menu. Github Repo is left
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/telemetry.js CHANGED
@@ -39,7 +39,7 @@ const SAFE_ARGS = {
39
39
  'compute always-on': { 0: ON_OFF }, 'db always-on': { 0: ON_OFF }, metrics: { 0: TARGET }, logs: { 0: TARGET },
40
40
  'template info': { 0: SLUG }, 'billing upgrade': { 0: oneOf(['pro', 'team']) },
41
41
  'approvals approve': { 0: ID }, 'approvals deny': { 0: ID },
42
- 'policy set': { 0: POLICY_ACTION, 1: oneOf(['allow', 'deny', 'approve']) }, autoupdate: { 0: ON_OFF },
42
+ 'agent-policy rule set': { 0: POLICY_ACTION, 1: oneOf(['allow', 'deny', 'approve']) }, autoupdate: { 0: ON_OFF },
43
43
  };
44
44
  const SAFE_OPTIONS = {
45
45
  org: ID, project: ID, region: REGION, env: ENV, oauth: oneOf(['github', 'google']),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "insta",
3
- "version": "0.0.62",
3
+ "version": "0.0.64",
4
4
  "type": "module",
5
5
  "description": "InstaCloud CLI — a thin client of the platform control-plane API.",
6
6
  "keywords": [