run402 4.18.0 → 4.18.1

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.
@@ -36,6 +36,7 @@ Subcommands:
36
36
  status --project <id> Show one cached entry, redacted
37
37
  import --project <id> --service-key-stdin
38
38
  import --project <id> --service-key-env <env>
39
+ import --project <id> --anon-key-env <env> Rotate only the anon key
39
40
  export --project <id> --reveal Print cached keys, including secrets
40
41
  remove --project <id> Remove one cached key entry
41
42
 
@@ -44,6 +45,9 @@ Notes:
44
45
  - list/status never reveal full keys.
45
46
  - export requires --reveal.
46
47
  - import accepts service keys through stdin or an environment variable, not argv.
48
+ - import writes the whole entry, so the FIRST import must supply a service key.
49
+ Afterwards --anon-key-env alone rotates the anon key and keeps the cached
50
+ service key, so an anon rotation never puts a service key through a shell.
47
51
  `;
48
52
 
49
53
  function parseProjectKeyFlags(args, extraKnown = [], valueFlagsExtra = []) {
@@ -143,7 +147,7 @@ async function status(args) {
143
147
  console.log(JSON.stringify(redactedEntry(id, getProject(id)), null, 2));
144
148
  }
145
149
 
146
- function readSecretInput(parsed) {
150
+ function readSecretInput(parsed, { projectId, anonEnv, existing } = {}) {
147
151
  const fromEnv = flagValue(parsed, "--service-key-env");
148
152
  const fromStdin = parsed.includes("--service-key-stdin");
149
153
  if (fromEnv && fromStdin) {
@@ -161,6 +165,23 @@ function readSecretInput(parsed) {
161
165
  return value.trim();
162
166
  }
163
167
  if (fromStdin) return readFileSync(0, "utf-8").trim();
168
+
169
+ // Anon-only rotation: the caller passed --anon-key-env and the entry already
170
+ // caches a service key. Reuse it rather than making them round-trip a service
171
+ // key through --reveal and a shell just to change the anon key.
172
+ if (anonEnv && existing?.service_key) return existing.service_key;
173
+
174
+ // Still no service key. Report the flags the caller actually passed — an error
175
+ // that names only the service-key flags reads as "--anon-key-env is not a flag".
176
+ if (anonEnv) {
177
+ fail({
178
+ code: "BAD_USAGE",
179
+ message: `Importing an anon key also requires a service key, because import writes the whole cache entry and no service key is cached for ${projectId} yet.`,
180
+ hint: "Add --service-key-stdin or --service-key-env <env> to this first import. Once an entry exists, --anon-key-env alone rotates the anon key and keeps the cached service key.",
181
+ details: { project_id: projectId, anon_key_env: anonEnv },
182
+ });
183
+ }
184
+
164
185
  fail({
165
186
  code: "BAD_USAGE",
166
187
  message: "Import requires --service-key-stdin or --service-key-env <env>.",
@@ -178,16 +199,19 @@ async function importKey(args) {
178
199
  fail({ code: "BAD_USAGE", message: `Unexpected argument for project-keys import: ${rest[0]}` });
179
200
  }
180
201
  const id = requireProjectFlag(projectId, "run402 credentials project-keys import --project <id> --service-key-stdin");
181
- const serviceKey = readSecretInput(parsed);
182
- if (!serviceKey) {
183
- fail({ code: "BAD_USAGE", message: "Service key input was empty." });
184
- }
202
+ // Resolve --anon-key-env before requiring a service key, so a missing service
203
+ // key can report against the flags actually passed and an anon-only rotation
204
+ // can reuse the cached service key.
185
205
  const anonEnv = flagValue(parsed, "--anon-key-env");
186
206
  const anonKey = anonEnv ? process.env[anonEnv] : undefined;
187
207
  if (anonEnv && !anonKey) {
188
208
  fail({ code: "BAD_ENV", message: `Environment variable ${anonEnv} is empty or unset.`, details: { env: anonEnv } });
189
209
  }
190
210
  const existing = getProject(id);
211
+ const serviceKey = readSecretInput(parsed, { projectId: id, anonEnv, existing });
212
+ if (!serviceKey) {
213
+ fail({ code: "BAD_USAGE", message: "Service key input was empty." });
214
+ }
191
215
  saveProject(id, {
192
216
  anon_key: anonKey ?? existing?.anon_key ?? "",
193
217
  service_key: serviceKey,
package/lib/pay.mjs CHANGED
@@ -16,11 +16,13 @@ Usage:
16
16
 
17
17
  Options:
18
18
  --method <M> HTTP method (default: GET)
19
- --body <json-or-text> Request body (not valid with GET/HEAD)
19
+ --body <json-or-text> Request body the ONLY way to send a payload
20
+ (not valid with GET/HEAD)
20
21
  --max-usd <amount> Maximum payment in USD (default: 0.10)
21
22
  --idempotency-key <key> Forward a stable Idempotency-Key to the seller
22
23
  --require-receipt Require verified merchant evidence
23
- --json Print the response and payment receipt as JSON
24
+ --json No-op; pay always prints JSON. Takes no value —
25
+ to send a payload use --body
24
26
  --help, -h Show this help
25
27
 
26
28
  Examples:
@@ -50,10 +52,11 @@ export async function run(args = [], deps = {}) {
50
52
  );
51
53
  const positionals = positionalArgs(parsed, VALUE_FLAGS);
52
54
  if (positionals.length !== 1) {
55
+ const stray = positionals[1];
53
56
  fail({
54
57
  code: "BAD_USAGE",
55
- message: positionals.length === 0 ? "URL required." : `Unexpected argument: ${positionals[1]}`,
56
- hint: "run402 pay <url> [--method POST] [--body <value>] [--max-usd 0.10]",
58
+ message: positionals.length === 0 ? "URL required." : `Unexpected argument: ${stray}`,
59
+ hint: strayArgumentHint(parsed, stray),
57
60
  });
58
61
  }
59
62
 
@@ -113,6 +116,34 @@ export function parseUsdMicros(value) {
113
116
  return micros;
114
117
  }
115
118
 
119
+ const USAGE_HINT = "run402 pay <url> [--method POST] [--body <value>] [--max-usd 0.10]";
120
+
121
+ // `--json` selects output format and takes no value; `--body` sends the payload.
122
+ // Reaching for `--json '<payload>'` is the predictable confusion, and it lands
123
+ // here as a stray positional. Name the flag the caller actually wanted.
124
+ export function strayArgumentHint(parsed, stray) {
125
+ if (typeof stray !== "string") return USAGE_HINT;
126
+ const jsonIndex = parsed.indexOf("--json");
127
+ if (jsonIndex !== -1 && parsed[jsonIndex + 1] === stray) {
128
+ return `--json only selects JSON output and takes no value; the request body flag is --body. Retry with: --method POST --body '${stray}'`;
129
+ }
130
+ if (looksLikeJson(stray)) {
131
+ return `To send this payload, pass it as a body: --method POST --body '${stray}'`;
132
+ }
133
+ return USAGE_HINT;
134
+ }
135
+
136
+ function looksLikeJson(value) {
137
+ const trimmed = value.trim();
138
+ if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return false;
139
+ try {
140
+ JSON.parse(trimmed);
141
+ return true;
142
+ } catch {
143
+ return false;
144
+ }
145
+ }
146
+
116
147
  function validateUrl(value) {
117
148
  try {
118
149
  const parsed = new URL(value);
package/lib/pay.test.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import { describe, it } from "node:test";
2
2
  import assert from "node:assert/strict";
3
3
 
4
- import { parseUsdMicros, run } from "./pay.mjs";
4
+ import { parseUsdMicros, run, strayArgumentHint } from "./pay.mjs";
5
5
 
6
6
  describe("run402 pay", () => {
7
7
  it("converts decimal USD to micros without floating-point rounding", () => {
@@ -10,6 +10,28 @@ describe("run402 pay", () => {
10
10
  assert.equal(parseUsdMicros("0"), 0);
11
11
  });
12
12
 
13
+ // `--json` selects output format, `--body` sends the payload. An agent
14
+ // reaching for `--json '<payload>'` gets a stray positional; the hint has to
15
+ // name --body or the usage error reads as "the payload was accepted".
16
+ it("points --json '<payload>' at --body", () => {
17
+ const payload = '{"kind":"anon"}';
18
+ const hint = strayArgumentHint(["https://s.example/x", "--json", payload], payload);
19
+ assert.match(hint, /--json only selects JSON output and takes no value/);
20
+ assert.match(hint, /--body '\{"kind":"anon"\}'/);
21
+ });
22
+
23
+ it("points a bare JSON positional at --body even without --json", () => {
24
+ const payload = '{"kind":"anon"}';
25
+ const hint = strayArgumentHint(["https://s.example/x", payload], payload);
26
+ assert.match(hint, /pass it as a body: --method POST --body/);
27
+ });
28
+
29
+ it("falls back to plain usage for a stray non-JSON argument", () => {
30
+ const hint = strayArgumentHint(["https://s.example/x", "stray"], "stray");
31
+ assert.equal(hint, "run402 pay <url> [--method POST] [--body <value>] [--max-usd 0.10]");
32
+ assert.equal(strayArgumentHint(["https://s.example/x"], undefined), hint);
33
+ });
34
+
13
35
  it("delegates to SDK pay.fetch and prints the receipt", async () => {
14
36
  let captured;
15
37
  const output = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "run402",
3
- "version": "4.18.0",
3
+ "version": "4.18.1",
4
4
  "description": "CLI for Run402 — provision Postgres databases, deploy static sites, generate images, and manage wallets via x402 and MPP micropayments.",
5
5
  "type": "module",
6
6
  "bin": {