@pipod/cli 0.1.3 → 0.1.5

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
@@ -4,15 +4,16 @@ Run [pi](https://github.com/earendil-works/pi) coding-agent sessions in server-m
4
4
 
5
5
  ```bash
6
6
  npm install -g @pipod/cli # or from a checkout of this directory: npm ci && npm run build && npm install -g .
7
- pipod login --server <url> # sign in to a pi pod server (the hosted one without --server)
7
+ pipod login --server <url> # sign in to your pi pod server
8
8
  pipod # launch a pod for the current directory and attach
9
9
  pipod --help # every command and launch option
10
10
  pipod update # upgrade the way it was installed
11
11
  ```
12
12
 
13
13
  Running your own server: [docs/self-host.md](https://github.com/pi-pod/pipod/blob/main/docs/self-host.md).
14
- A self-hosted server is deployed from a checkout; install the CLI from the same checkout so the
15
- two match.
14
+ The npm release works with any server at least as new as it; a CLI newer than its server is
15
+ refused, so upgrade the server first. Building the CLI from the server's own checkout always
16
+ matches.
16
17
 
17
18
  ## Secrets
18
19
 
@@ -53,12 +54,12 @@ Details, limits and a manual test checklist: [docs/workspace-seeding.md](docs/wo
53
54
  Local images named in a prompt (clipboard pastes, `@shot.png`, file paths) are
54
55
  attached inline for the pod agent: [docs/image-attachments.md](docs/image-attachments.md).
55
56
 
56
- ## Personal workstations (SaaS)
57
+ ## Personal workstations (hosted service)
57
58
 
58
- On the hosted service each user gets a whole machine of their own, which sleeps when idle. The
59
- first command after it sleeps waits for it to come back — measured at 243 to 708 seconds in
60
- production, so the CLI shows elapsed progress and says "several minutes" rather than promising
61
- a number. Nothing is lost while it sleeps: every workspace stays on the workstation's disk.
59
+ This section does not apply to a self-hosted server. On the hosted service (coming soon) each
60
+ user gets a whole machine of their own, which sleeps when idle. The first command after it
61
+ sleeps waits for it to come back — measured at 243 to 708 seconds in production, so the CLI
62
+ shows elapsed progress and says "several minutes" rather than promising a number. Nothing is lost while it sleeps: every workspace stays on the workstation's disk.
62
63
 
63
64
  The hosted service also reports the account's plan, active hours and spend cap, which `pipod
64
65
  list` prints under the pods. A self-hosted server reports none of that, and the client then
package/dist/cli.js CHANGED
@@ -256,6 +256,7 @@ async function discover(issuer) {
256
256
  authorization_endpoint: body.authorization_endpoint,
257
257
  token_endpoint: body.token_endpoint,
258
258
  jwks_uri: body.jwks_uri,
259
+ device_authorization_endpoint: body.device_authorization_endpoint,
259
260
  end_session_endpoint: body.end_session_endpoint,
260
261
  revocation_endpoint: body.revocation_endpoint
261
262
  })) {
@@ -270,6 +271,7 @@ async function discover(issuer) {
270
271
  authorization_endpoint: body.authorization_endpoint,
271
272
  token_endpoint: body.token_endpoint,
272
273
  jwks_uri: body.jwks_uri,
274
+ ...body.device_authorization_endpoint ? { device_authorization_endpoint: body.device_authorization_endpoint } : {},
273
275
  ...body.end_session_endpoint ? { end_session_endpoint: body.end_session_endpoint } : {},
274
276
  ...body.revocation_endpoint ? { revocation_endpoint: body.revocation_endpoint } : {}
275
277
  };
@@ -293,15 +295,18 @@ function organizationScope(alias) {
293
295
  }
294
296
  return `urn:zitadel:iam:org:domain:primary:${alias}`;
295
297
  }
298
+ function loginScopes(orgAlias) {
299
+ const scopes = ["openid", "profile", "email", OFFLINE_ACCESS_SCOPE, RESOURCE_OWNER_SCOPE, PROJECT_ROLES_SCOPE];
300
+ const orgScope = organizationScope(orgAlias);
301
+ if (orgScope) scopes.push(orgScope);
302
+ return scopes.join(" ");
303
+ }
296
304
  function authorizeUrl(meta, args) {
297
305
  const url = new URL(meta.authorization_endpoint);
298
306
  url.searchParams.set("response_type", "code");
299
307
  url.searchParams.set("client_id", args.clientId);
300
308
  url.searchParams.set("redirect_uri", args.redirectUri);
301
- const scopes = ["openid", "profile", "email", OFFLINE_ACCESS_SCOPE, RESOURCE_OWNER_SCOPE, PROJECT_ROLES_SCOPE];
302
- const orgScope = organizationScope(args.orgAlias);
303
- if (orgScope) scopes.push(orgScope);
304
- url.searchParams.set("scope", scopes.join(" "));
309
+ url.searchParams.set("scope", loginScopes(args.orgAlias));
305
310
  url.searchParams.set("code_challenge", args.challenge);
306
311
  url.searchParams.set("code_challenge_method", "S256");
307
312
  url.searchParams.set("state", args.state);
@@ -345,11 +350,105 @@ async function exchangeCode(meta, args) {
345
350
  hint: String(e instanceof Error ? e.message : e)
346
351
  });
347
352
  });
348
- const tokens = await readTokenResponse(res);
353
+ return verifiedLoginTokens(meta, await readTokenResponse(res), args.clientId, args.nonce);
354
+ }
355
+ async function verifiedLoginTokens(meta, tokens, clientId, nonce) {
349
356
  if (!tokens.idToken) throw new PiPodError("the identity provider returned no ID token");
350
- await verifyIdToken(meta, tokens.idToken, args.clientId, tokens.accessToken, args.nonce);
357
+ await verifyIdToken(meta, tokens.idToken, clientId, tokens.accessToken, nonce);
351
358
  return tokens;
352
359
  }
360
+ var DEVICE_CODE_GRANT = "urn:ietf:params:oauth:grant-type:device_code";
361
+ var DEVICE_POLL_SECONDS = 5;
362
+ async function startDeviceAuthorization(meta, args) {
363
+ if (!meta.device_authorization_endpoint) return null;
364
+ const res = await fetch(meta.device_authorization_endpoint, {
365
+ method: "POST",
366
+ redirect: "error",
367
+ headers: { "content-type": "application/x-www-form-urlencoded" },
368
+ body: new URLSearchParams({ client_id: args.clientId, scope: loginScopes(args.orgAlias) }),
369
+ signal: AbortSignal.timeout(OIDC_NETWORK_TIMEOUT_MS)
370
+ }).catch((e) => {
371
+ throw new PiPodError("cannot reach the identity provider device endpoint", {
372
+ hint: String(e instanceof Error ? e.message : e)
373
+ });
374
+ });
375
+ if (!res.ok) {
376
+ if (await oauthError(res) === "unauthorized_client") return null;
377
+ throw new PiPodError("the identity provider refused to start a device sign-in", {
378
+ hint: `HTTP ${res.status} \u2014 run \`pipod login\` again`
379
+ });
380
+ }
381
+ const body = await res.json().catch(() => null);
382
+ const deviceCode = body?.["device_code"];
383
+ const userCode = body?.["user_code"];
384
+ const verificationUri = body?.["verification_uri"];
385
+ const verificationUriComplete = body?.["verification_uri_complete"];
386
+ const expiresIn = body?.["expires_in"];
387
+ const interval = body?.["interval"];
388
+ if (typeof deviceCode !== "string" || typeof userCode !== "string" || typeof verificationUri !== "string" || typeof expiresIn !== "number") {
389
+ throw new PiPodError("the identity provider returned an invalid device sign-in");
390
+ }
391
+ parseSecureOidcUrl(verificationUri, "verification_uri");
392
+ if (typeof verificationUriComplete === "string") {
393
+ parseSecureOidcUrl(verificationUriComplete, "verification_uri_complete");
394
+ }
395
+ const authorization = {
396
+ userCode,
397
+ verificationUri,
398
+ ...typeof verificationUriComplete === "string" ? { verificationUriComplete } : {},
399
+ deviceCode,
400
+ intervalSeconds: typeof interval === "number" && interval > 0 ? interval : DEVICE_POLL_SECONDS,
401
+ expiresAt: Date.now() + expiresIn * 1e3
402
+ };
403
+ const probe = await pollDeviceToken(meta, args.clientId, deviceCode);
404
+ if ("error" in probe && probe.error === "unauthorized_client") return null;
405
+ if ("error" in probe && probe.error === "slow_down") authorization.intervalSeconds += DEVICE_POLL_SECONDS;
406
+ return authorization;
407
+ }
408
+ async function awaitDeviceTokens(meta, args) {
409
+ let intervalSeconds = args.authorization.intervalSeconds;
410
+ while (true) {
411
+ await new Promise((resolve14) => setTimeout(resolve14, intervalSeconds * 1e3));
412
+ if (Date.now() >= args.authorization.expiresAt) throw deviceCodeExpired();
413
+ const poll = await pollDeviceToken(meta, args.clientId, args.authorization.deviceCode);
414
+ if ("tokens" in poll) return verifiedLoginTokens(meta, poll.tokens, args.clientId);
415
+ switch (poll.error) {
416
+ case "authorization_pending":
417
+ case "unreachable":
418
+ continue;
419
+ case "slow_down":
420
+ intervalSeconds += DEVICE_POLL_SECONDS;
421
+ continue;
422
+ case "access_denied":
423
+ throw new PiPodError("the sign-in was declined", { hint: "run `pipod login` again" });
424
+ case "expired_token":
425
+ throw deviceCodeExpired();
426
+ default:
427
+ throw new PiPodError(`the identity provider refused the device sign-in (${poll.error})`, {
428
+ hint: "run `pipod login` again"
429
+ });
430
+ }
431
+ }
432
+ }
433
+ function deviceCodeExpired() {
434
+ return new PiPodError("the sign-in code expired before it was used", { hint: "run `pipod login` again" });
435
+ }
436
+ async function pollDeviceToken(meta, clientId, deviceCode) {
437
+ const res = await fetch(meta.token_endpoint, {
438
+ method: "POST",
439
+ redirect: "error",
440
+ headers: { "content-type": "application/x-www-form-urlencoded" },
441
+ body: new URLSearchParams({ grant_type: DEVICE_CODE_GRANT, client_id: clientId, device_code: deviceCode }),
442
+ signal: AbortSignal.timeout(OIDC_NETWORK_TIMEOUT_MS)
443
+ }).catch(() => null);
444
+ if (!res) return { error: "unreachable" };
445
+ if (res.ok) return { tokens: await readTokenResponse(res) };
446
+ return { error: await oauthError(res) ?? `HTTP ${res.status}` };
447
+ }
448
+ async function oauthError(res) {
449
+ const body = await res.json().catch(() => null);
450
+ return typeof body?.error === "string" ? body.error : null;
451
+ }
353
452
  async function verifyIdToken(meta, token, clientId, accessToken, expectedNonce) {
354
453
  let jwks = idTokenJwksCache.get(meta.jwks_uri);
355
454
  if (!jwks) {
@@ -7507,7 +7606,7 @@ function unattestedPackageNotice(podPackages, specs) {
7507
7606
  const remainder = mismatches.length - shown.length;
7508
7607
  const examples = `${shown.join(", ")}${remainder > 0 ? `, and ${remainder} more` : ""}`;
7509
7608
  const noun = mismatches.length === 1 ? "package" : "packages";
7510
- return `pod reports ${mismatches.length} configured ${noun} outside its exact launch-attested set: ${examples}; extension rendering from ${mismatches.length === 1 ? "that package is" : "those packages is"} unavailable locally \u2014 land intentional top-level Pi packages in your bundle's settings.json "packages" (pipod push, or a settings proposal); do not add transitive dependencies`;
7609
+ return `pod reports ${mismatches.length} configured ${noun} outside its exact launch-attested set: ${examples}; extension rendering from ${mismatches.length === 1 ? "that package is" : "those packages is"} unavailable locally \u2014 land intentional top-level Pi packages in your bundle's settings.json "packages" (pipod push); do not add transitive dependencies`;
7511
7610
  }
7512
7611
 
7513
7612
  // src/client/runtime/pod-command-overrides.ts
@@ -17020,7 +17119,7 @@ async function runLogin(flags) {
17020
17119
  if (flags.token) {
17021
17120
  accessToken = flags.token;
17022
17121
  } else {
17023
- const pair = await browserLogin({ issuer, clientId, orgAlias: flags.org });
17122
+ const pair = await interactiveLogin({ issuer, clientId, orgAlias: flags.org, device: flags.device === true });
17024
17123
  accessToken = pair.accessToken;
17025
17124
  refreshToken = pair.refreshToken;
17026
17125
  idToken = pair.idToken;
@@ -17162,8 +17261,36 @@ async function openConsole(flags, which) {
17162
17261
  openBrowser2(url);
17163
17262
  return 0;
17164
17263
  }
17165
- async function browserLogin(args) {
17264
+ async function interactiveLogin(args) {
17166
17265
  const meta = await discover(args.issuer);
17266
+ const browserHere = canOpenBrowserHere();
17267
+ if (args.device || !browserHere) {
17268
+ const authorization = await startDeviceAuthorization(meta, { clientId: args.clientId, orgAlias: args.orgAlias });
17269
+ if (authorization) {
17270
+ const page = authorization.verificationUriComplete ?? authorization.verificationUri;
17271
+ const verb = authorization.verificationUriComplete ? "confirm" : "enter";
17272
+ info(`to sign in, open this page on any device and ${verb} the code ${authorization.userCode}:
17273
+ ${page}`);
17274
+ info("waiting for you to finish signing in\u2026");
17275
+ return awaitDeviceTokens(meta, { clientId: args.clientId, authorization });
17276
+ }
17277
+ if (args.device) {
17278
+ throw new PiPodError(`${args.issuer} does not allow this CLI to sign in with a code`, {
17279
+ hint: "on a self-hosted server the operator enables it with `selfhost/upgrade --apply-zitadel`; until then run `pipod login` without --device"
17280
+ });
17281
+ }
17282
+ info("this identity provider does not offer sign-in with a code; using the browser flow");
17283
+ }
17284
+ return browserLogin(meta, { clientId: args.clientId, orgAlias: args.orgAlias, browserHere });
17285
+ }
17286
+ function canOpenBrowserHere() {
17287
+ const env = process.env;
17288
+ if (process.platform === "darwin" || process.platform === "win32") {
17289
+ return !env["SSH_CONNECTION"] && !env["SSH_TTY"];
17290
+ }
17291
+ return Boolean(env["DISPLAY"] || env["WAYLAND_DISPLAY"] || env["BROWSER"]);
17292
+ }
17293
+ async function browserLogin(meta, args) {
17167
17294
  const pkce = createPkce();
17168
17295
  const state = createState();
17169
17296
  const nonce = createNonce();
@@ -17178,10 +17305,16 @@ async function browserLogin(args) {
17178
17305
  nonce,
17179
17306
  orgAlias: args.orgAlias
17180
17307
  });
17181
- info("opening your browser to sign in\u2026");
17182
- info(`if nothing opens, visit:
17308
+ if (args.browserHere) {
17309
+ info("opening your browser to sign in\u2026");
17310
+ info(`if nothing opens, visit:
17311
+ ${url}`);
17312
+ openBrowser2(url);
17313
+ } else {
17314
+ info(`to sign in, open this page in a browser that reaches port ${port} on this machine:
17183
17315
  ${url}`);
17184
- openBrowser2(url);
17316
+ hint(`over SSH, forward it first: ssh -L ${port}:127.0.0.1:${port} <this host>`);
17317
+ }
17185
17318
  const code = await waitForCallback(server, state);
17186
17319
  return exchangeCode(meta, {
17187
17320
  clientId: args.clientId,
@@ -19429,10 +19562,12 @@ reused pods, \`--on\` co-location and \`--no-seed\` never seed.
19429
19562
  Run \`pipod <command> --help\` for command-specific usage.
19430
19563
  `;
19431
19564
  var SUBCOMMAND_HELP = {
19432
- login: `Usage: pipod login [--server <url>] [--issuer <url>] [--org <alias>] [--token <jwt>]
19565
+ login: `Usage: pipod login [--server <url>] [--issuer <url>] [--org <alias>] [--device] [--token <jwt>]
19433
19566
 
19434
19567
  Sign this machine in to a pi pod server via Zitadel (authorization code + PKCE,
19435
- in your browser). The session is stored in ~/.pi-pod/auth.json.
19568
+ in your browser). Where no browser can open here (over SSH, on a headless
19569
+ server), it prints a page and a code to confirm on any other device instead.
19570
+ The session is stored in ~/.pi-pod/auth.json.
19436
19571
 
19437
19572
  Options:
19438
19573
  --server <url> Server to sign in to (default: the previous session's server,
@@ -19442,6 +19577,8 @@ Options:
19442
19577
  publish its own
19443
19578
  --org <alias> Only sign in to the organization with this primary domain;
19444
19579
  omit it to sign in to your own organization
19580
+ --device Sign in with a code on another device even if a browser
19581
+ could open here
19445
19582
  --token <jwt> Use an existing access token instead of the browser flow (dev/CI)
19446
19583
  `,
19447
19584
  account: `Usage: pipod account
@@ -19815,7 +19952,8 @@ var FLAG_SCOPE = {
19815
19952
  "--server": /* @__PURE__ */ new Set(["login"]),
19816
19953
  "--org": /* @__PURE__ */ new Set(["login", "jobs"]),
19817
19954
  "--token": /* @__PURE__ */ new Set(["login"]),
19818
- "--issuer": /* @__PURE__ */ new Set(["login"])
19955
+ "--issuer": /* @__PURE__ */ new Set(["login"]),
19956
+ "--device": /* @__PURE__ */ new Set(["login"])
19819
19957
  };
19820
19958
  function resolveSubcommand(positional) {
19821
19959
  if (positional === void 0) return null;
@@ -19959,6 +20097,9 @@ function parseArgs(argv) {
19959
20097
  case "--issuer":
19960
20098
  flags.issuer = takeValue();
19961
20099
  break;
20100
+ case "--device":
20101
+ flags.device = true;
20102
+ break;
19962
20103
  default:
19963
20104
  throw new PiPodError(`unknown pi pod option "${name}"`, {
19964
20105
  hint: "pass Pi options after `--`, for example: pipod -- --model claude-opus",
@@ -20025,7 +20166,7 @@ async function main(argv) {
20025
20166
  case "init":
20026
20167
  return runInit(flags);
20027
20168
  case "login":
20028
- return runLogin({ server: flags.server, org: flags.org, token: flags.token, issuer: flags.issuer });
20169
+ return runLogin({ server: flags.server, org: flags.org, token: flags.token, issuer: flags.issuer, device: flags.device });
20029
20170
  case "whoami":
20030
20171
  return runWhoami({});
20031
20172
  case "logout":