pennyrouter 0.3.7 → 0.3.8

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
@@ -23,6 +23,44 @@ npx pennyrouter status
23
23
  Installation opens a browser and requires login to the PennyRouter account that should own
24
24
  the new machine key. CLI installs no longer create anonymous ghost accounts.
25
25
 
26
+ ## Private and enterprise gateways
27
+
28
+ Installing against a self-hosted gateway is its own flow, with no browser login: the gateway
29
+ keeps its own accounts, so the key is minted there.
30
+
31
+ ```bash
32
+ npx pennyrouter install --private-gateway https://gateway.example.internal
33
+ ```
34
+
35
+ It asks for the gateway URL (unless given) and your email, and mints a key against that
36
+ gateway. Installing on a second machine with the same email adds a key to the same account.
37
+ Where an administrator issues keys instead, pass `--private-key pr-...` and no email is
38
+ asked for. Self-service minting works only where the operator enabled it
39
+ (`PENNYROUTER_OPEN_PROVISIONING`); otherwise install says so.
40
+
41
+ To hand a whole team one file, put the shared answers in JSON and leave out the per-person
42
+ ones:
43
+
44
+ ```json
45
+ {
46
+ "harnesses": ["claude-code", "codex"],
47
+ "gatewayBaseUrl": "https://gateway.example.internal",
48
+ "claudeFunding": ["anthropic-proxy"],
49
+ "proxyBaseUrl": "https://litellm.example.internal",
50
+ "codexFunding": ["openai-proxy"],
51
+ "openaiProxyBaseUrl": "https://litellm.example.internal"
52
+ }
53
+ ```
54
+
55
+ ```bash
56
+ npx pennyrouter install --config team-install.json
57
+ ```
58
+
59
+ Each person is prompted for their email and their proxy key; everything else is prefilled.
60
+ Any field left out of the file is prompted for, and an unattended run that cannot prompt
61
+ refuses by name rather than installing a half-configured harness. Add `email`, `proxyApiKey`
62
+ and `openaiProxyApiKey` to the file to skip the prompts entirely.
63
+
26
64
  With no `--harness` flag, the interactive installer shows Claude Code, Codex, and OpenCode. Detected
27
65
  tools start checked. The additional legacy integrations remain available through an explicit
28
66
  `--harness <id>` selection or `--all`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pennyrouter",
3
- "version": "0.3.7",
3
+ "version": "0.3.8",
4
4
  "description": "Install and manage PennyRouter local coding-agent integrations.",
5
5
  "homepage": "https://pennyrouter.com",
6
6
  "bugs": {
package/src/cli.js CHANGED
@@ -285,8 +285,13 @@ export function applyConfigFile(flags) {
285
285
  for (const option of INSTALL_MANIFEST.funding_groups[group].options) {
286
286
  if (!chosen.has(option.id)) continue;
287
287
  for (const field of option.fields) {
288
- if (flags[field.config_key] === undefined) {
289
- flags[field.config_key] = config[field.config_key] || "";
288
+ // Only copy what the file actually carries. Writing "" for an absent field would
289
+ // make it look supplied-but-empty, which reads as "this option was configured" to
290
+ // the provider selection below and skips the prompt that would have filled it --
291
+ // exactly what a shared file with the secrets left out depends on.
292
+ const value = String(config[field.config_key] ?? "").trim();
293
+ if (value && flags[field.config_key] === undefined) {
294
+ flags[field.config_key] = value;
290
295
  }
291
296
  }
292
297
  }
@@ -896,7 +901,7 @@ function mcpTargets(flags) {
896
901
  async function resolvePrivateGatewayUrl(flags) {
897
902
  let raw = (flags.gatewayBaseUrl || "").trim();
898
903
  if (!raw) {
899
- if (flags.yes || !input.isTTY || !output.isTTY) {
904
+ if ((flags.yes && !flags.fromConfig) || !input.isTTY || !output.isTTY) {
900
905
  throw new Error(
901
906
  "--private-gateway needs the gateway URL in a non-interactive install: "
902
907
  + "`--private-gateway https://gateway.example.com`.",
@@ -931,7 +936,9 @@ async function obtainPrivateGatewayKey(flags, gatewayBaseUrl) {
931
936
 
932
937
  let email = (flags.email || "").trim();
933
938
  if (!email) {
934
- if (flags.yes || !input.isTTY || !output.isTTY) {
939
+ // A shared --config file names the gateway but not who is installing, so the email is
940
+ // still asked for even though --config implies --yes.
941
+ if ((flags.yes && !flags.fromConfig) || !input.isTTY || !output.isTTY) {
935
942
  throw new Error(
936
943
  "--private-gateway needs an email in a non-interactive install: `--email you@example.com` "
937
944
  + "(or pass an admin-issued `--private-key pr-...`).",
@@ -1290,6 +1297,12 @@ async function selectClaudeFundingProviders(flags, selected) {
1290
1297
  && (flags.proxyApiKey || process.env.PENNYROUTER_PROXY_API_KEY)) {
1291
1298
  supplied.add("anthropic-proxy");
1292
1299
  }
1300
+ // A --config file states its providers outright, and that choice stands whether or not the
1301
+ // file also carried their secrets: a file shared with a team deliberately omits those, and
1302
+ // configureUserFunding prompts for what is missing (or refuses, with no TTY to prompt on).
1303
+ // Without this, an option whose secret was left out is silently dropped and the install
1304
+ // completes having configured nothing.
1305
+ for (const id of flags.configClaudeFunding || []) supplied.add(id);
1293
1306
 
1294
1307
  // Explicit flags are the automation selector. Interactive installs always default to the
1295
1308
  // subscription alone; environment keys are used if the user checks their provider.
@@ -1317,6 +1330,7 @@ async function selectCodexFundingProviders(flags, selected) {
1317
1330
  if ((flags.openaiProxyBaseUrl || process.env.PENNYROUTER_OPENAI_PROXY_BASE_URL)
1318
1331
  && (flags.openaiProxyApiKey || process.env.PENNYROUTER_OPENAI_PROXY_API_KEY)) supplied.add("openai-proxy");
1319
1332
  if (flags.openrouterApiKey || process.env.OPENROUTER_API_KEY) supplied.add("openrouter");
1333
+ for (const id of flags.configCodexFunding || []) supplied.add(id);
1320
1334
  const hasChatgptLogin = await detectCodexChatgptAuth();
1321
1335
  if (hasChatgptLogin) supplied.add("subscription");
1322
1336
 
@@ -1393,7 +1407,10 @@ async function configureUserFunding(flags, selected, claudeAnthropicAuth,
1393
1407
  claudeProviders = new Set(), codexProviders = new Set()) {
1394
1408
  const wantsClaude = selected.some((harness) => harness.id === "claude-code");
1395
1409
  const wantsCodex = selected.some((harness) => harness.id === "codex");
1396
- const interactive = !flags.yes && input.isTTY && output.isTTY;
1410
+ // --config drives --yes to skip the question screens, but a file meant to be shared with a
1411
+ // team deliberately carries no secrets: the fields it leaves out are still asked for here,
1412
+ // the same way the subscription sign-in below stays interactive under --config.
1413
+ const interactive = (!flags.yes || flags.fromConfig) && input.isTTY && output.isTTY;
1397
1414
  const credentials = [];
1398
1415
 
1399
1416
  let anthropicApiKey = String(
package/src/cli.test.js CHANGED
@@ -305,3 +305,49 @@ test("--private-gateway does not consume a following flag as its URL", async ()
305
305
  /needs the gateway URL/,
306
306
  );
307
307
  });
308
+
309
+ // A --config file meant to be shared with a team carries no secrets and does not know who is
310
+ // installing. --config implies --yes, so the omitted fields are prompted for when there is a
311
+ // TTY; with no TTY the install must REFUSE. It previously completed here having configured no
312
+ // credential at all, because a funding option whose secret was absent was silently dropped.
313
+ test("a shared --config without its secret refuses rather than installing nothing", async () => {
314
+ // Isolate the install state: against a real one where this harness is already installed
315
+ // with this funding, install takes its "nothing changed" refresh path and never reaches
316
+ // credential collection.
317
+ const stateDir = mkdtempSync(join(tmpdir(), "pennyrouter-state-"));
318
+ const priorState = process.env.XDG_STATE_HOME;
319
+ process.env.XDG_STATE_HOME = stateDir;
320
+ try {
321
+ await assert.rejects(
322
+ () => withConfigFile({
323
+ harnesses: ["claude-code"],
324
+ gatewayBaseUrl: "https://gateway.example.com",
325
+ claudeFunding: ["anthropic-proxy"],
326
+ proxyBaseUrl: "https://proxy.example.com",
327
+ privateKey: "pr-test",
328
+ }, (path) => captureLogs(() => main(["install", "--config", path]))),
329
+ /--proxy-base-url and --proxy-api-key were not both provided/,
330
+ );
331
+ } finally {
332
+ if (priorState === undefined) delete process.env.XDG_STATE_HOME;
333
+ else process.env.XDG_STATE_HOME = priorState;
334
+ rmSync(stateDir, { recursive: true, force: true });
335
+ }
336
+ });
337
+
338
+ // The config names its providers; that choice stands even when the file omits their secrets.
339
+ test("--config funding choices survive a missing secret", async () => {
340
+ await withConfigFile({
341
+ harnesses: ["claude-code"],
342
+ claudeFunding: ["anthropic-proxy"],
343
+ proxyBaseUrl: "https://proxy.example.com",
344
+ }, (path) => {
345
+ const flags = { configPath: path, args: [] };
346
+ applyConfigFile(flags);
347
+ assert.deepEqual([...flags.configClaudeFunding], ["anthropic-proxy"]);
348
+ // Absent fields stay undefined so the prompt path can fill them; "" would read as
349
+ // supplied-but-empty and skip it.
350
+ assert.equal(flags.proxyApiKey, undefined);
351
+ assert.equal(flags.proxyBaseUrl, "https://proxy.example.com");
352
+ });
353
+ });