@pipod/cli 0.1.4 → 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 +9 -8
- package/dist/cli.js +157 -16
- package/dist/cli.js.map +1 -1
- package/docs/config-simplification.md +4 -2
- package/package.json +1 -1
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
|
|
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
|
-
|
|
15
|
-
|
|
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 (
|
|
57
|
+
## Personal workstations (hosted service)
|
|
57
58
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
17182
|
-
|
|
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
|
-
|
|
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).
|
|
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":
|