@pipod/cli 0.1.4 → 0.1.6

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:
17183
17311
  ${url}`);
17184
- openBrowser2(url);
17312
+ openBrowser2(url);
17313
+ } else {
17314
+ info(`to sign in, open this page in a browser that reaches port ${port} on this machine:
17315
+ ${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,
@@ -18400,6 +18533,11 @@ async function showCmd(client, args) {
18400
18533
  info("bake script:");
18401
18534
  out(t.bakeScript);
18402
18535
  }
18536
+ if (t.agentInstructions) {
18537
+ out("");
18538
+ info("agent instructions:");
18539
+ out(t.agentInstructions);
18540
+ }
18403
18541
  if (t.piSettings && Object.keys(t.piSettings).length > 0) {
18404
18542
  info(`Pi settings ${describePiSettings(flatPiSettings(t.piSettings))}`);
18405
18543
  }
@@ -18478,6 +18616,12 @@ function parseOpts(args) {
18478
18616
  opts.bakeScript = fs30.readFileSync(file, "utf8");
18479
18617
  break;
18480
18618
  }
18619
+ case "--agent-instructions": {
18620
+ const file = next();
18621
+ if (!fs30.existsSync(file)) throw new PiPodError(`no such agent instructions file: ${file}`);
18622
+ opts.agentInstructions = fs30.readFileSync(file, "utf8");
18623
+ break;
18624
+ }
18481
18625
  default:
18482
18626
  rest.push(arg);
18483
18627
  }
@@ -18531,6 +18675,7 @@ async function createCmd(client, args, flags) {
18531
18675
  ...opts.description ? { description: opts.description } : {},
18532
18676
  ...initScript ? { initScript } : {},
18533
18677
  ...bakeScript ? { bakeScript } : {},
18678
+ ...opts.agentInstructions ? { agentInstructions: opts.agentInstructions } : {},
18534
18679
  // An explicit refresh is replacement, not merge: an empty project file set clears stale custody.
18535
18680
  ...opts.fromHere ? { piSettings: piSettings ?? {} } : {}
18536
18681
  });
@@ -18637,9 +18782,9 @@ async function editCmd(client, args) {
18637
18782
  });
18638
18783
  }
18639
18784
  const t = await findTemplate(client, rest[0]);
18640
- if (opts.name === void 0 && opts.description === void 0 && !opts.config && opts.initScript === void 0 && opts.bakeScript === void 0) {
18785
+ if (opts.name === void 0 && opts.description === void 0 && !opts.config && opts.initScript === void 0 && opts.bakeScript === void 0 && opts.agentInstructions === void 0) {
18641
18786
  throw new PiPodError("nothing to change", {
18642
- hint: "pass --name, --description, --config <file>, --init-script <file> or --bake-script <file>"
18787
+ hint: "pass --name, --description, --config <file>, --init-script <file>, --bake-script <file> or --agent-instructions <file>"
18643
18788
  });
18644
18789
  }
18645
18790
  const updated = await client.updateTemplate(t.id, {
@@ -18650,7 +18795,8 @@ async function editCmd(client, args) {
18650
18795
  ...opts.description !== void 0 ? { description: opts.description } : {},
18651
18796
  ...opts.config ? { config: opts.config } : {},
18652
18797
  ...opts.initScript !== void 0 ? { initScript: opts.initScript } : {},
18653
- ...opts.bakeScript !== void 0 ? { bakeScript: opts.bakeScript } : {}
18798
+ ...opts.bakeScript !== void 0 ? { bakeScript: opts.bakeScript } : {},
18799
+ ...opts.agentInstructions !== void 0 ? { agentInstructions: opts.agentInstructions } : {}
18654
18800
  });
18655
18801
  info(`updated template ${updated.name}`);
18656
18802
  await hintMissingCredentials(client);
@@ -19429,10 +19575,12 @@ reused pods, \`--on\` co-location and \`--no-seed\` never seed.
19429
19575
  Run \`pipod <command> --help\` for command-specific usage.
19430
19576
  `;
19431
19577
  var SUBCOMMAND_HELP = {
19432
- login: `Usage: pipod login [--server <url>] [--issuer <url>] [--org <alias>] [--token <jwt>]
19578
+ login: `Usage: pipod login [--server <url>] [--issuer <url>] [--org <alias>] [--device] [--token <jwt>]
19433
19579
 
19434
19580
  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.
19581
+ in your browser). Where no browser can open here (over SSH, on a headless
19582
+ server), it prints a page and a code to confirm on any other device instead.
19583
+ The session is stored in ~/.pi-pod/auth.json.
19436
19584
 
19437
19585
  Options:
19438
19586
  --server <url> Server to sign in to (default: the previous session's server,
@@ -19442,6 +19590,8 @@ Options:
19442
19590
  publish its own
19443
19591
  --org <alias> Only sign in to the organization with this primary domain;
19444
19592
  omit it to sign in to your own organization
19593
+ --device Sign in with a code on another device even if a browser
19594
+ could open here
19445
19595
  --token <jwt> Use an existing access token instead of the browser flow (dev/CI)
19446
19596
  `,
19447
19597
  account: `Usage: pipod account
@@ -19689,15 +19839,23 @@ are never stored on the server.
19689
19839
  templates: `Usage: pipod templates [action]
19690
19840
 
19691
19841
  Manage pod templates on the pi pod server. A template stores config, init and
19692
- bake scripts, Pi settings files, and named secrets. Templates are personal by
19693
- default: only their owner sees and launches them. --org at creation or
19694
- \`templates share\` makes one org-wide.
19842
+ bake scripts, Pi settings files, named secrets, and agent instructions.
19843
+ Templates are personal by default: only their owner sees and launches them.
19844
+ --org at creation or \`templates share\` makes one org-wide.
19845
+
19846
+ Agent instructions are added to the agent's system prompt in every pod launched
19847
+ from the template, with the hosts and secrets the pod actually has: say what
19848
+ access the template is meant to have. They guide the agent and enforce nothing;
19849
+ the template's secrets and egress decide what a pod can reach. A pod's child
19850
+ pods and the jobs it schedules launch from the pod's own template.
19695
19851
 
19696
19852
  Actions:
19697
19853
  list List templates with scope and key counts (default)
19698
- show <name|id> Show config, scripts, Pi settings, and secret names
19854
+ show <name|id> Show config, scripts, agent instructions, Pi settings, and
19855
+ secret names
19699
19856
  create <name> Create a template
19700
- edit <name|id> Update a template's name, description, config, or scripts
19857
+ edit <name|id> Update a template's name, description, config, scripts, or
19858
+ agent instructions
19701
19859
  share <name|id> Make a personal template org-wide (cannot be undone)
19702
19860
  rm <name|id> Delete a template (alias: delete)
19703
19861
 
@@ -19709,6 +19867,9 @@ create/edit options:
19709
19867
  scripts, and sanitized Pi files (contradicts --config)
19710
19868
  --init-script <file> Store this init script
19711
19869
  --bake-script <file> Store this bake script
19870
+ --agent-instructions <file>
19871
+ Store this file as the agent instructions (an empty
19872
+ file removes them); \`pipod push\` leaves them alone
19712
19873
  --org create only: make the template org-wide from the start
19713
19874
  --with-secrets With --from-here: also upload project env values as
19714
19875
  template secrets
@@ -19815,7 +19976,8 @@ var FLAG_SCOPE = {
19815
19976
  "--server": /* @__PURE__ */ new Set(["login"]),
19816
19977
  "--org": /* @__PURE__ */ new Set(["login", "jobs"]),
19817
19978
  "--token": /* @__PURE__ */ new Set(["login"]),
19818
- "--issuer": /* @__PURE__ */ new Set(["login"])
19979
+ "--issuer": /* @__PURE__ */ new Set(["login"]),
19980
+ "--device": /* @__PURE__ */ new Set(["login"])
19819
19981
  };
19820
19982
  function resolveSubcommand(positional) {
19821
19983
  if (positional === void 0) return null;
@@ -19959,6 +20121,9 @@ function parseArgs(argv) {
19959
20121
  case "--issuer":
19960
20122
  flags.issuer = takeValue();
19961
20123
  break;
20124
+ case "--device":
20125
+ flags.device = true;
20126
+ break;
19962
20127
  default:
19963
20128
  throw new PiPodError(`unknown pi pod option "${name}"`, {
19964
20129
  hint: "pass Pi options after `--`, for example: pipod -- --model claude-opus",
@@ -20025,7 +20190,7 @@ async function main(argv) {
20025
20190
  case "init":
20026
20191
  return runInit(flags);
20027
20192
  case "login":
20028
- return runLogin({ server: flags.server, org: flags.org, token: flags.token, issuer: flags.issuer });
20193
+ return runLogin({ server: flags.server, org: flags.org, token: flags.token, issuer: flags.issuer, device: flags.device });
20029
20194
  case "whoami":
20030
20195
  return runWhoami({});
20031
20196
  case "logout":