insta 0.0.25 → 0.0.26

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.
@@ -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) => {
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.26",
4
4
  "type": "module",
5
5
  "description": "InstaCloud CLI — a thin client of the platform control-plane API.",
6
6
  "keywords": [