insta 0.0.61 → 0.0.63

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
@@ -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;
@@ -589,7 +591,7 @@ export async function computeAlwaysOn(mode, serviceName, opts) {
589
591
  if (opts.json)
590
592
  return printJson(res.body);
591
593
  const on = res.body.service?.always_on;
592
- info(`compute ${res.body.service?.name ?? id}: always-on ${on ? 'ENABLED — machines stay warm (no cold starts; idle RAM bills at actual usage)' : 'disabled — scales to zero when idle (default)'}`);
594
+ info(`compute ${res.body.service?.name ?? id}: always-on ${on ? 'ENABLED — machines stay warm (no cold starts; idle RAM bills at actual usage)' : 'disabled — scales to zero when idle'}`);
593
595
  }
594
596
  // ---- limits (the resource ceiling; paid plans) ----
595
597
  // Parse a human memory value into MB: "512", "512mb", "1gb", "2g", "1.5gb".
@@ -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)
@@ -85,7 +85,10 @@ export function servicesAddRequestBody(type, name, branch, opts) {
85
85
  type, name, ...(branch ? { branch } : {}), public: !!opts.public,
86
86
  ...(opts.image ? { image: opts.image } : {}), ...(opts.port ? { port: parsePort(opts.port) } : {}),
87
87
  ...(opts.region ? { region: opts.region } : {}),
88
- ...(opts.alwaysOn ? { alwaysOn: true } : {}),
88
+ // Sent whenever the flag was given, false included: compute is born always-on by default
89
+ // (insta-platform #385, 2026-09-07), so `--no-always-on` must reach the API as an explicit
90
+ // false. Omitted means the platform default.
91
+ ...(opts.alwaysOn !== undefined ? { alwaysOn: opts.alwaysOn } : {}),
89
92
  ...(opts.volume !== undefined ? { volumeGib: parseVolumeGib(opts.volume) } : {}),
90
93
  };
91
94
  }
@@ -102,8 +105,9 @@ export async function servicesAdd(type, name, opts = {}) {
102
105
  throw new Error('--port is only valid for compute services');
103
106
  parsePort(opts.port); // junk fails here, before any config/network access
104
107
  }
105
- if (opts.alwaysOn && type !== 'compute')
106
- throw new Error('--always-on is only valid for compute services (for postgres, use `insta db always-on on` after creation)');
108
+ // Presence, not truthiness: `--no-always-on` is an explicit false and is just as compute-only.
109
+ if (opts.alwaysOn !== undefined && type !== 'compute')
110
+ throw new Error('--always-on / --no-always-on is only valid for compute services (for postgres, use `insta db always-on on|off` after creation)');
107
111
  if (opts.volume !== undefined) {
108
112
  if (type !== 'compute')
109
113
  throw new Error('--volume is only valid for compute services (postgres has one by default — grow it with `insta db volume --size`)');
@@ -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
@@ -2,13 +2,17 @@
2
2
  // credentials. Parsing, ref resolution and the shallow clone live here; the deploy path stays in
3
3
  // commands/template.ts. See docs/superpowers/specs/2026-09-04-template-deploy-github-url-design.md.
4
4
  import { spawn as nodeSpawn } from 'node:child_process';
5
- import { mkdtempSync, rmSync, existsSync, realpathSync, statSync } from 'node:fs';
5
+ import { mkdtempSync, rmSync, existsSync, realpathSync, statSync, readFileSync } from 'node:fs';
6
6
  import { tmpdir } from 'node:os';
7
7
  import { join, sep } from 'node:path';
8
- import { loadTemplateManifest, MANIFEST_FILE } from './template-manifest.js';
8
+ import { parseManifestYaml, MANIFEST_FILE } from './template-manifest.js';
9
9
  const GITHUB_HOST = /^(?:https?:\/\/)?(?:www\.)?github\.com\//i;
10
10
  // An explicit address: a scheme, or an scp-style user@host:path. Never a local path.
11
- const EXPLICIT_ADDRESS = /^[a-z][a-z0-9+.-]*:\/\/|^[^/\\]+@[^/\\]+:/i;
11
+ // A scheme (with or without its slashes) or an scp-style user@host:path. The slashes are optional
12
+ // because `https:/github.com/o/r`, a URL that lost one, must be named as a bad address rather than
13
+ // resolved as a directory called `https:`. Two or more scheme characters are required so a Windows
14
+ // drive letter (`C:\src`, `C:/src`) stays a path.
15
+ const EXPLICIT_ADDRESS = /^[a-z][a-z0-9+.-]+:|^[^/\\]+@[^/\\]+:/i;
12
16
  // Scheme-less first segments we still read as a host. A bare `<name>/<path>` is otherwise a LOCAL
13
17
  // PATH: `v1.0/templates` and `my.app/bot` are directories, and reading every dotted first segment
14
18
  // as a host broke them. Only names that unambiguously host code belong here.
@@ -251,6 +255,11 @@ export async function resolveGitHubRef(t, run) {
251
255
  throw new Error(`no branch or tag ${t.refAndPath} in ${t.owner}/${t.repo}`);
252
256
  return split;
253
257
  }
258
+ /** What to call the manifest in a message: where the user pointed, never the temporary clone that
259
+ * is deleted before they read it. */
260
+ export function manifestLabel(t, r) {
261
+ return `${t.owner}/${t.repo}@${r.ref}:${r.path ? `${r.path}/` : ''}${MANIFEST_FILE}`;
262
+ }
254
263
  export function missingManifestMessage(t, r) {
255
264
  return [
256
265
  `no ${MANIFEST_FILE} at ${t.owner}/${t.repo}@${r.ref}:${r.path || '/'}.`,
@@ -271,7 +280,7 @@ function containedRealPath(root, candidate) {
271
280
  /** Resolve the ref, shallow-clone it, read the commit that was checked out, prove the manifest
272
281
  * sits inside the clone, parse it, delete the clone. Nothing on disk outlives this call: not the
273
282
  * variable prompt, not the POST, not the watcher. */
274
- export async function fetchGitHubTemplate(target, run = defaultGitRunner, load = loadTemplateManifest) {
283
+ export async function fetchGitHubTemplate(target, run = defaultGitRunner, parse = parseManifestYaml) {
275
284
  const resolved = await resolveGitHubRef(target, run);
276
285
  const dir = mkdtempSync(join(tmpdir(), 'insta-tpl-gh-'));
277
286
  try {
@@ -303,7 +312,9 @@ export async function fetchGitHubTemplate(target, run = defaultGitRunner, load =
303
312
  const real = containedRealPath(dir, join(manifestDir, MANIFEST_FILE));
304
313
  if (!real || !statSync(real).isFile())
305
314
  throw new Error(escapedManifestMessage(target, resolved));
306
- const manifest = load(manifestDir);
315
+ // Read and parse separately so a validation failure names where the user pointed. Handing the
316
+ // loader a directory makes it report the temp path, which is gone by the time they see it.
317
+ const manifest = parse(readFileSync(real, 'utf8'), manifestLabel(target, resolved));
307
318
  return {
308
319
  source: { repo: `${target.owner}/${target.repo}`, ref: resolved.ref, path: resolved.path, commit },
309
320
  manifest,
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';
@@ -32,6 +34,14 @@ import { billing, billingUpgrade, billingPortal } from './commands/billing.js';
32
34
  import * as selfUpdate from './commands/upgrade.js';
33
35
  import * as feedbackCmd from './commands/feedback.js';
34
36
  function onError(e) {
37
+ if (e instanceof AgentApprovalRequired) {
38
+ if (process.argv.includes('--json'))
39
+ process.stdout.write(JSON.stringify(e.body) + '\n');
40
+ else
41
+ process.stderr.write(e.message + '\n');
42
+ process.exitCode = 2;
43
+ return;
44
+ }
35
45
  if (e instanceof CliExit || e instanceof CliCancel)
36
46
  return;
37
47
  if (e instanceof ApiError)
@@ -64,6 +74,8 @@ const program = new Command();
64
74
  // against the subcommand's own (identically-named) option instead.
65
75
  program.enablePositionalOptions();
66
76
  program.name('insta').description('InstaCloud CLI — manage projects, branches, secrets, deploys').version(cliVersion());
77
+ program.option('--agent', 'run as an agent with a verified project session and project agent policy');
78
+ program.hook('preAction', () => configureAgent(detectAgent(!!program.opts().agent)));
67
79
  // ---- auth ----
68
80
  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
81
  .option('--email <email>', 'account email (email + password login)')
@@ -131,7 +143,8 @@ svc.command('add [type] [name]').description('Provision a service on demand (ass
131
143
  .option('--public', 'storage only: serve the bucket with anonymous public-read (default private)')
132
144
  .option('--image <url>', 'compute only: run this container image at creation')
133
145
  .option('--port <n>', 'compute only: port the image listens on (default 8080)')
134
- .option('--always-on', 'compute only: create as always-on — never scales to zero (all plans; billing is actual usage either way)')
146
+ .option('--always-on', 'compute only: create as always-on — never scales to zero (the default for new compute services; all plans; billing is actual usage either way)')
147
+ .option('--no-always-on', 'compute only: create as scale-to-zero — idle machines suspend and wake on the next request')
135
148
  .option('--volume <gi>', 'compute only: attach a persistent /data volume of this many whole Gi (also attachable later: `insta compute volume <name> --size <gi>`; any plan may attach at the default 10 (the free cap, on every plan); larger sizes are paid and plan-capped). Volume services keep 1 machine and stop (cold wake) instead of suspend when idle')
136
149
  .option('--json')
137
150
  .action(guard(async (type, name, o) => {
@@ -223,7 +236,7 @@ compute.command('status [service]').description("Show a compute service's desire
223
236
  compute.command('limits [service]').description("Show or set a compute service's resource ceiling (any plan within the free cap; raising above it needs a paid plan). --memory is the dial; cpu derives from it unless --cpu is given. Billing is actual usage — the ceiling caps what the app may burn, it is not a price")
224
237
  .option('--memory <size>', 'memory ceiling, e.g. 512mb or 1gb').option('--cpu <n>', 'vCPU ceiling override (provider sizes: 1, 2, 4, 6, 8)')
225
238
  .option('--json').option('--branch <branch>', 'branch (default: current)').action(guard((service, o) => computeCmd.computeLimits(service, o)));
226
- compute.command('always-on <mode> [service]').description('Set a compute service always-on (mode: on|off). on = machines never scale to zero; off = default scale-to-zero. All plans; billing is actual usage either way')
239
+ compute.command('always-on <mode> [service]').description('Set a compute service always-on (mode: on|off). on = machines never scale to zero (the default for new compute services); off = scale-to-zero. All plans; billing is actual usage either way')
227
240
  .option('--json').option('--branch <branch>', 'branch (default: current)').action(guard((mode, service, o) => computeCmd.computeAlwaysOn(mode, service, o)));
228
241
  const execCmd = compute.command('exec [service]').description("Run a one-shot command inside a compute service's machine (`insta compute exec [service] -- <command> [args…]`) — no interactive shell/PTY: `command` is argv, no shell is invoked (use [\"sh\", \"-c\", \"...\"] for shell features). Wakes the machine first if it's scaled to zero — expect a few seconds of latency, billed as uptime, not an error. Exits with the remote command's own exit code (agents rely on this)")
229
242
  .action(guard((service, o) => computeCmd.computeExec(service, execCommand, o, { windowsFallback: execWindowsFallback })));
@@ -323,7 +336,7 @@ program.command('events').description('Show the audit + agent-event timeline').o
323
336
  // ---- approvals ----
324
337
  const ap = program.command('approvals').description('Governance approvals (HITL)');
325
338
  ap.command('list').option('--status <s>', 'pending|granted|denied|consumed').option('--json').action(guard((o) => govern.approvalsList(o)));
326
- ap.command('approve <id>').option('--always', 'also set the policy to allow').option('--json').action(guard((id, o) => govern.approvalsApprove(id, o)));
339
+ ap.command('approve <id>').option('--json').action(guard((id, o) => govern.approvalsApprove(id, o)));
327
340
  ap.command('deny <id>').option('--json').action(guard((id, o) => govern.approvalsDeny(id, o)));
328
341
  // ---- observe (local credential audit) ----
329
342
  const ob = program.command('observe').description('Local credential-audit hook');
@@ -332,9 +345,16 @@ ob.command('uninstall').action(guard(() => observe.observeUninstall()));
332
345
  ob.command('report').description('Render the local credential audit').option('--json').action(guard((o) => observe.observeReport(o)));
333
346
  ob.command('sync').description('Upload findings into the project timeline').action(guard(() => observe.observeSync()));
334
347
  // ---- policy ----
335
- const pol = program.command('policy').description('Governance policy');
336
- pol.command('get').option('--json').action(guard((o) => govern.policyGet(o)));
337
- 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)));
348
+ const agentPol = program.command('agent-policy').description('Project agent access policy');
349
+ agentPol.command('get').option('--json').action(guard((o) => agentPolicy.get(o)));
350
+ agentPol.command('set <mode>').description('full-access | read-only | branch-developer')
351
+ .option('--json').action(guard((mode, o) => agentPolicy.set(mode, o)));
352
+ agentPol.command('protect-branch <branch>').option('--json').action(guard((branch, o) => agentPolicy.protect(branch, true, o)));
353
+ agentPol.command('unprotect-branch <branch>').option('--json').action(guard((branch, o) => agentPolicy.protect(branch, false, o)));
354
+ agentPol.command('rule').command('set <action> <decision>').description('Set an unprotected-branch rule: allow | deny | approve')
355
+ .option('--json').action(guard((action, decision, o) => agentPolicy.rule(action, decision, o)));
356
+ agentPol.command('revoke-sessions').description('Revoke ALL CLI agent sessions for this project')
357
+ .option('--json').action(guard((o) => agentPolicy.revoke(o)));
338
358
  // ---- feedback (agent + human hurdle reports → the InstaCloud team) ----
339
359
  program.command('feedback')
340
360
  .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.')
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']),
@@ -78,8 +78,30 @@ export function validateManifest(m) {
78
78
  for (const name of names) {
79
79
  const svc = services[name] ?? {};
80
80
  const where = `services.${name}`;
81
- if (svc.type !== 'web' && svc.type !== 'worker')
82
- problems.push(`${where}.type must be web or worker`);
81
+ if (svc.type !== 'web' && svc.type !== 'worker' && svc.type !== 'postgres') {
82
+ problems.push(`${where}.type must be web, worker or postgres`);
83
+ }
84
+ // A managed postgres service is BARE: the platform owns its image, port, sizing, credentials
85
+ // and env, so every other rule below would be asking about fields it must not carry. Mirrors
86
+ // the platform's own check (provisioning/templateManifest.ts) so an author hears it here.
87
+ if (svc.type === 'postgres') {
88
+ const bare = svc;
89
+ for (const field of ['image', 'build', 'port', 'healthcheck', 'volume', 'volumeGib', 'spec', 'alwaysOn']) {
90
+ if (bare[field] !== undefined) {
91
+ problems.push(`${where}.${field}: a postgres service is platform-managed and carries no ${field} — declare it bare ({ type: postgres })`);
92
+ }
93
+ }
94
+ const groups = ['fixed', 'generated', 'platform', 'required', 'optional'];
95
+ const envShell = bare.env;
96
+ if (envShell !== undefined) {
97
+ const emptyShell = !!envShell && typeof envShell === 'object' && !Array.isArray(envShell)
98
+ && Object.entries(envShell).every(([g, v]) => groups.includes(g) && !!v && typeof v === 'object' && !Array.isArray(v) && Object.keys(v).length === 0);
99
+ if (!emptyShell) {
100
+ problems.push(`${where}.env: a postgres service is platform-managed and carries no env — declare it bare ({ type: postgres })`);
101
+ }
102
+ }
103
+ continue;
104
+ }
83
105
  if (svc.image && svc.build)
84
106
  problems.push(`${where}: image and build are mutually exclusive`);
85
107
  if (!svc.image && !svc.build)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "insta",
3
- "version": "0.0.61",
3
+ "version": "0.0.63",
4
4
  "type": "module",
5
5
  "description": "InstaCloud CLI — a thin client of the platform control-plane API.",
6
6
  "keywords": [