insta 0.0.25 → 0.0.27

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
@@ -79,8 +79,8 @@ plane's address, not the CLI's loopback address.
79
79
  ### Services are branch-scoped
80
80
 
81
81
  A project holds services (`postgres`, `storage`, `compute`) and each branch owns its own
82
- set. `insta branch create feature-x` forks the parent's services: a Neon branch per
83
- Postgres, a copy-on-write bucket per storage, a clone of every compute service. From there
82
+ set. `insta branch create feature-x` forks the parent's services: a copy-on-write database
83
+ branch per Postgres, a copy-on-write bucket per storage, a clone of every compute service. From there
84
84
  the two branches diverge independently. A project is capped at 10 branches.
85
85
 
86
86
  ### Credentials come from the secret seam, not a file you maintain
@@ -207,8 +207,9 @@ build never reaches a production installer.
207
207
  The `insta` skill and its task guides live in
208
208
  [InsForge/insta-skills](https://github.com/InsForge/insta-skills). `insta setup agent`
209
209
  installs it user-globally for every coding agent on the machine. `insta project create` and
210
- `insta project link` additionally install the stack skills (Neon Postgres, Tigris, Better
211
- Auth) into the project, along with the `insta observe` credential-audit hook.
210
+ `insta project link` additionally install the stack skills (Tigris, Better Auth) into the
211
+ project, along with the `insta observe` credential-audit hook. Postgres needs no stack
212
+ skill — it's plain Postgres, reached directly via `DATABASE_URL`.
212
213
 
213
214
  ## Contributing
214
215
 
@@ -1,6 +1,6 @@
1
1
  import { createServer } from 'node:http';
2
2
  import { randomBytes } from 'node:crypto';
3
- import { ApiClient, linkedProject } from '../api.js';
3
+ import { ApiClient, ApiError, linkedProject } from '../api.js';
4
4
  import { ENVS, ENV_NAMES, envForApiUrl, isEnvName } from '../env.js';
5
5
  import { info, die, printJson, promptPassword, openUrl } from '../util.js';
6
6
  /** --api-url and --env both set the target host; --api-url wins (more specific), matching the
@@ -17,6 +17,8 @@ function targetApiUrl(opts) {
17
17
  return ENVS[want].api;
18
18
  }
19
19
  export async function login(opts) {
20
+ if (opts.device)
21
+ return loginDevice(opts);
20
22
  if (opts.oauth)
21
23
  return loginOauth(opts.oauth, opts);
22
24
  const api = await ApiClient.load();
@@ -24,7 +26,7 @@ export async function login(opts) {
24
26
  if (target)
25
27
  api.setApiUrl(target);
26
28
  if (!opts.email)
27
- die('--email is required (or use --oauth <github|google>)');
29
+ die('--email is required (or use --oauth <github|google>; on a headless machine, --device)');
28
30
  const password = opts.password ?? process.env.INSTA_PASSWORD ?? (await promptPassword());
29
31
  const res = await api.request('POST', '/auth/login', { email: opts.email, password }, { auth: false });
30
32
  api.setSession(res, res.user);
@@ -47,6 +49,80 @@ export async function loginOauth(provider, opts) {
47
49
  await api.persist();
48
50
  info(`logged in as ${me.user.email ?? me.user.id} @ ${api.apiUrl}`);
49
51
  }
52
+ // RFC 8628 device authorization — login from a machine with no usable browser (VM, SSH box, CI
53
+ // container). The loopback --oauth flow can never work there: its callback targets 127.0.0.1 on
54
+ // THIS machine. Here the roles invert — we mint a code, print a link the human opens on ANY
55
+ // device, and poll the platform until they approve in the console.
56
+ export async function loginDevice(opts) {
57
+ const api = await ApiClient.load();
58
+ const target = targetApiUrl(opts);
59
+ if (target)
60
+ api.setApiUrl(target);
61
+ const token = await deviceGrant((path, body) => api.request('POST', path, body, { auth: false }));
62
+ api.setSession({ accessToken: token, refreshToken: token });
63
+ const me = await api.request('GET', '/me');
64
+ api.setSession({ accessToken: token, refreshToken: token }, me.user);
65
+ await api.persist();
66
+ info(`logged in as ${me.user.email ?? me.user.id} @ ${api.apiUrl}`);
67
+ }
68
+ const sleepSeconds = (s) => new Promise((r) => setTimeout(r, s * 1000));
69
+ // Drives the device grant against the platform's Better Auth mount (/api/auth/device*) and
70
+ // returns the approved session token. Injectable poster + wait keep this testable without a
71
+ // network or real timers. Poll errors arrive as ApiError with the OAuth error code as message.
72
+ export async function deviceGrant(post, wait = sleepSeconds) {
73
+ const start = (await post('/api/auth/device/code', { client_id: 'insta-cli' }));
74
+ // A missing/garbage expires_in must fail loudly here — carried into the deadline arithmetic it
75
+ // becomes NaN, every `Date.now() < deadline` is false, and login dies as a bogus instant expiry.
76
+ // Cap the lifetime too: a huge-but-finite value (Number.MAX_VALUE) overflows the ms conversion
77
+ // to Infinity and would otherwise pin the CLI polling forever.
78
+ const expiresIn = Number(start.expires_in);
79
+ if (!Number.isFinite(expiresIn) || expiresIn <= 0) {
80
+ throw new Error('malformed device authorization response (missing expires_in) — is the platform up to date?');
81
+ }
82
+ const lifetime = Math.min(expiresIn, 3600); // no device code sensibly outlives an hour
83
+ info('to log in, open this link in a browser on any device:');
84
+ info(` ${start.verification_uri_complete ?? start.verification_uri}`);
85
+ info(`and check it shows this code: ${start.user_code}`);
86
+ info(`waiting for approval… (expires in ${Math.round(lifetime / 60)}m, ctrl-c to abort)`);
87
+ // Absent OR non-finite interval = the RFC 8628 §3.2 default 5s: NaN would fire the timer
88
+ // instantly and Infinity gets truncated to ~1ms by Node — both hot-poll the token endpoint.
89
+ const rawInterval = Number(start.interval);
90
+ let interval = Number.isFinite(rawInterval) ? Math.max(rawInterval, 1) : 5;
91
+ const deadline = Date.now() + lifetime * 1000;
92
+ while (Date.now() < deadline) {
93
+ await wait(interval);
94
+ let grant = null;
95
+ try {
96
+ grant = (await post('/api/auth/device/token', {
97
+ grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
98
+ device_code: start.device_code,
99
+ client_id: 'insta-cli',
100
+ }));
101
+ }
102
+ catch (e) {
103
+ if (!(e instanceof ApiError))
104
+ continue; // transport blip (dropped SSH/CI link) — keep polling until deadline
105
+ const code = e.message;
106
+ if (code === 'authorization_pending')
107
+ continue;
108
+ if (code === 'slow_down') {
109
+ interval += 5;
110
+ continue;
111
+ } // RFC 8628 §3.5: back off by 5s
112
+ if (code === 'expired_token')
113
+ break;
114
+ if (code === 'access_denied')
115
+ throw new Error('login request was denied in the console');
116
+ throw e; // a definite API-level error (invalid_grant, …) — not retryable
117
+ }
118
+ // Validated OUTSIDE the try: a 200 without a token is a malformed response that must fail
119
+ // loudly, not be mistaken for a transport blip and retried into an empty stored session.
120
+ if (!grant?.access_token)
121
+ throw new Error('malformed token response (missing access_token)');
122
+ return grant.access_token;
123
+ }
124
+ throw new Error('device login expired before it was approved — run `insta login --device` again');
125
+ }
50
126
  // Start a loopback server, open the browser at the platform bridge, and await the token.
51
127
  function browserOauth(apiUrl, provider) {
52
128
  return new Promise((resolve, reject) => {
@@ -3,7 +3,7 @@
3
3
  // every agent the skills tool knows: the skill is pure product knowledge with brand-gated
4
4
  // triggers — no project state in it (the project binding is carried by ./.insta/project.json
5
5
  // at command time), so one machine-level copy is strictly better than per-project copies.
6
- // Stack skills (neon/tigris/better-auth) intentionally stay per-project: their presence in a
6
+ // Stack skills (tigris/better-auth) intentionally stay per-project: their presence in a
7
7
  // project doubles as its stack manifest — that install happens on `project create|link`.
8
8
  import { spawn } from 'node:child_process';
9
9
  import os from 'node:os';
@@ -1,6 +1,7 @@
1
1
  // Install the related agent skills into a linked project so the developer's coding agent has
2
- // context for what InstaCloud provisions — the `insta` CLI itself plus the three services you build
3
- // directly against (Neon Postgres, Tigris storage, Better Auth). Runs `npx skills add`
2
+ // context for what InstaCloud provisions — the `insta` CLI itself plus the services you build
3
+ // directly against (Tigris storage, Better Auth). Postgres gets no vendor skill: instadb is
4
+ // plain Postgres, and agents talk to it directly via DATABASE_URL. Runs `npx skills add`
4
5
  // (vercel-labs/skills) fully non-interactively. Best-effort: a failure (offline, npx missing, a
5
6
  // repo moved) prints a manual fallback and never blocks or fails the host command — same contract
6
7
  // as the observe-hook install.
@@ -13,8 +14,8 @@ import { DEFAULT_ENV, ENVS } from './env.js';
13
14
  // Codex → .agents/skills/ (.github/skills/ is the third well-known dir). These are regenerable
14
15
  // agent context, not the developer's source — keep them out of git.
15
16
  const SKILL_DIRS = ['.claude/skills/', '.agents/skills/', '.github/skills/'];
16
- // `skills` prints a full-screen ASCII banner at the top of every `add`, so our 4 invocations
17
- // would stack 4 banners. It skips the banner when it detects an agent driving it rather than a
17
+ // `skills` prints a full-screen ASCII banner at the top of every `add`, so our 3 invocations
18
+ // would stack 3 banners. It skips the banner when it detects an agent driving it rather than a
18
19
  // human — AI_AGENT is its first-checked signal (any non-empty value ⇒ agent mode). Setting it is
19
20
  // honest here (this IS programmatic, not an interactive prompt) and, because we already pin the
20
21
  // agents/skills/-y, has no effect on the install beyond quieting the banner. Preserve a caller's
@@ -32,10 +33,9 @@ const defaultRunner = (cmd, args, inherit = false) => new Promise((resolve) => {
32
33
  const AGENT_FLAGS = ['-a', 'claude-code', '-a', 'codex', '-y', '--copy'];
33
34
  // `instaSpec` is the insta skill source for the resolved environment (`owner/repo[@ref]`), so a
34
35
  // project created against staging gets the staging skill text. The third-party stack skills are
35
- // environment-independent — they document Neon/Tigris/Better Auth, not our control plane.
36
+ // environment-independent — they document Tigris/Better Auth, not our control plane.
36
37
  const skillTargets = (instaSpec) => [
37
38
  { label: 'insta', args: ['skills', 'add', instaSpec, '-s', 'insta', ...AGENT_FLAGS] },
38
- { label: 'neon-postgres', args: ['skills', 'add', 'neondatabase/agent-skills', '-s', 'neon-postgres', ...AGENT_FLAGS] },
39
39
  { label: 'tigris', args: ['skills', 'add', 'tigrisdata/skills',
40
40
  '-s', 'tigris-object-operations', '-s', 'file-storage', '-s', 'tigris-sdk-guide',
41
41
  '-s', 'tigris-security-access-control', '-s', 'tigris-image-optimization',
@@ -63,7 +63,7 @@ export async function installSkills(deps) {
63
63
  targets = skillTargets(skills);
64
64
  }
65
65
  catch { /* keep production defaults */ }
66
- print(' installing related agent skills (insta, neon-postgres, tigris, better-auth) …');
66
+ print(' installing related agent skills (insta, tigris, better-auth) …');
67
67
  for (const s of targets) {
68
68
  // Don't stream: the `skills` tool's clack UI (clone spinner, banners) is noise. Run it
69
69
  // silent (stdio 'ignore') and let the per-skill ✓/failed line below be the clean output —
package/dist/index.js CHANGED
@@ -54,10 +54,11 @@ function resolveVersion() {
54
54
  }
55
55
  program.name('insta').description('InstaCloud CLI — manage projects, branches, secrets, deploys').version(resolveVersion());
56
56
  // ---- auth ----
57
- program.command('login').description('Log in with email + password, or --oauth <github|google> (browser)')
57
+ program.command('login').description('Log in with email + password, --oauth <github|google> (browser), or --device (headless)')
58
58
  .option('--email <email>', 'account email')
59
59
  .option('--password <password>', 'account password (else $INSTA_PASSWORD or prompt)')
60
60
  .option('--oauth <provider>', 'browser OAuth login: github | google')
61
+ .option('--device', 'device-code login: approve from a browser on any other machine (VMs, SSH, CI)')
61
62
  .option('--api-url <url>', 'control-plane API base URL')
62
63
  .option('--env <name>', `deployment environment: ${ENV_NAMES.join(' | ')}`)
63
64
  .action(guard((o) => auth.login(o)));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "insta",
3
- "version": "0.0.25",
3
+ "version": "0.0.27",
4
4
  "type": "module",
5
5
  "description": "InstaCloud CLI — a thin client of the platform control-plane API.",
6
6
  "keywords": [