putitoutthere 0.1.43 → 0.1.45

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.
Files changed (82) hide show
  1. package/CHANGELOG.md +115 -0
  2. package/MIGRATIONS.md +632 -0
  3. package/README.md +342 -94
  4. package/action.yml +5 -5
  5. package/dist/action.js +2 -2
  6. package/dist/action.js.map +1 -1
  7. package/dist/cascade.d.ts +1 -1
  8. package/dist/cascade.js +2 -2
  9. package/dist/cli.d.ts +9 -6
  10. package/dist/cli.d.ts.map +1 -1
  11. package/dist/cli.js +12 -426
  12. package/dist/cli.js.map +1 -1
  13. package/dist/config.d.ts +13 -61
  14. package/dist/config.d.ts.map +1 -1
  15. package/dist/config.js +31 -45
  16. package/dist/config.js.map +1 -1
  17. package/dist/git.js +1 -1
  18. package/dist/git.js.map +1 -1
  19. package/dist/handlers/npm-platform.d.ts.map +1 -1
  20. package/dist/handlers/npm-platform.js +5 -1
  21. package/dist/handlers/npm-platform.js.map +1 -1
  22. package/dist/handlers/pypi.d.ts.map +1 -1
  23. package/dist/handlers/pypi.js +29 -6
  24. package/dist/handlers/pypi.js.map +1 -1
  25. package/dist/plan.d.ts +0 -1
  26. package/dist/plan.d.ts.map +1 -1
  27. package/dist/plan.js +19 -18
  28. package/dist/plan.js.map +1 -1
  29. package/dist/preflight.js +1 -1
  30. package/dist/preflight.js.map +1 -1
  31. package/dist/publish.d.ts +0 -10
  32. package/dist/publish.d.ts.map +1 -1
  33. package/dist/publish.js +6 -52
  34. package/dist/publish.js.map +1 -1
  35. package/dist/types.d.ts +1 -21
  36. package/dist/types.d.ts.map +1 -1
  37. package/dist/types.js.map +1 -1
  38. package/package.json +5 -3
  39. package/dist/auth.d.ts +0 -68
  40. package/dist/auth.d.ts.map +0 -1
  41. package/dist/auth.js +0 -285
  42. package/dist/auth.js.map +0 -1
  43. package/dist/doctor.d.ts +0 -115
  44. package/dist/doctor.d.ts.map +0 -1
  45. package/dist/doctor.js +0 -292
  46. package/dist/doctor.js.map +0 -1
  47. package/dist/init.d.ts +0 -59
  48. package/dist/init.d.ts.map +0 -1
  49. package/dist/init.js +0 -181
  50. package/dist/init.js.map +0 -1
  51. package/dist/keyring.d.ts +0 -35
  52. package/dist/keyring.d.ts.map +0 -1
  53. package/dist/keyring.js +0 -88
  54. package/dist/keyring.js.map +0 -1
  55. package/dist/oidc-policy.d.ts +0 -145
  56. package/dist/oidc-policy.d.ts.map +0 -1
  57. package/dist/oidc-policy.js +0 -383
  58. package/dist/oidc-policy.js.map +0 -1
  59. package/dist/preflight-run.d.ts +0 -47
  60. package/dist/preflight-run.d.ts.map +0 -1
  61. package/dist/preflight-run.js +0 -197
  62. package/dist/preflight-run.js.map +0 -1
  63. package/dist/registries/crates-trust.d.ts +0 -69
  64. package/dist/registries/crates-trust.d.ts.map +0 -1
  65. package/dist/registries/crates-trust.js +0 -95
  66. package/dist/registries/crates-trust.js.map +0 -1
  67. package/dist/release.d.ts +0 -36
  68. package/dist/release.d.ts.map +0 -1
  69. package/dist/release.js +0 -102
  70. package/dist/release.js.map +0 -1
  71. package/dist/templates.d.ts +0 -43
  72. package/dist/templates.d.ts.map +0 -1
  73. package/dist/templates.js +0 -310
  74. package/dist/templates.js.map +0 -1
  75. package/dist/token-scope.d.ts +0 -65
  76. package/dist/token-scope.d.ts.map +0 -1
  77. package/dist/token-scope.js +0 -163
  78. package/dist/token-scope.js.map +0 -1
  79. package/dist/token.d.ts +0 -211
  80. package/dist/token.d.ts.map +0 -1
  81. package/dist/token.js +0 -719
  82. package/dist/token.js.map +0 -1
package/dist/token.js DELETED
@@ -1,719 +0,0 @@
1
- /**
2
- * `putitoutthere token inspect` — decode/introspect registry tokens.
3
- *
4
- * Scaffolding and PyPI offline decode: #107.
5
- * npm live probe: #108 (whoami + SHA-512 match against /-/npm/v1/tokens).
6
- * crates.io live probe: #109 (/api/v1/me + /api/v1/me/tokens).
7
- *
8
- * Dispatch is driven by token format, not by a hardcoded registry list:
9
- * - `pypi-` prefix → PyPI macaroon decode (offline, full scope)
10
- * - `npm_` prefix → npm (live probe)
11
- * - otherwise → crates.io (live probe), unless `--registry` overrides
12
- *
13
- * Token values are never logged. Logs identify a token by the first 8
14
- * hex chars of its SHA-256. Live probes are timeboxed at 5s per request.
15
- */
16
- import { createHash } from 'node:crypto';
17
- import { execFileSync } from 'node:child_process';
18
- import { loadConfig } from './config.js';
19
- import { defaultKeyring } from './keyring.js';
20
- export function isError(r) {
21
- return 'error' in r;
22
- }
23
- export async function inspect(opts) {
24
- const token = opts.token;
25
- const digest = sha256Prefix(token);
26
- const registry = opts.registry ?? detectRegistry(token);
27
- const timeoutMs = opts.timeoutMs ?? 5000;
28
- if (registry === 'pypi')
29
- return inspectPypi(token, digest);
30
- /* v8 ignore next -- default baseUrl for production; tests always inject a mock URL */
31
- const baseUrl = opts.baseUrl ?? (registry === 'npm' ? NPM_REGISTRY : CRATES_REGISTRY);
32
- if (registry === 'npm')
33
- return inspectNpm(token, digest, baseUrl, timeoutMs);
34
- return inspectCrates(token, digest, baseUrl, timeoutMs);
35
- }
36
- export function detectRegistry(token) {
37
- if (token.startsWith('pypi-'))
38
- return 'pypi';
39
- if (token.startsWith('npm_'))
40
- return 'npm';
41
- return 'crates';
42
- }
43
- function sha256Prefix(token) {
44
- return createHash('sha256').update(token).digest('hex').slice(0, 8);
45
- }
46
- function sha512Hex(s) {
47
- return createHash('sha512').update(s).digest('hex');
48
- }
49
- // ---- PyPI --------------------------------------------------------------
50
- const PYPI_PREFIX = 'pypi-';
51
- function inspectPypi(token, digest) {
52
- if (!token.startsWith(PYPI_PREFIX)) {
53
- return {
54
- registry: 'pypi',
55
- source_digest: digest,
56
- error: 'token does not start with "pypi-"',
57
- };
58
- }
59
- const body = token.slice(PYPI_PREFIX.length);
60
- const bytes = tryBase64Decode(body);
61
- if (!bytes) {
62
- return {
63
- registry: 'pypi',
64
- source_digest: digest,
65
- error: 'invalid base64 in token body',
66
- };
67
- }
68
- const blobs = extractJsonObjects(bytes);
69
- if (blobs.length === 0) {
70
- return {
71
- registry: 'pypi',
72
- source_digest: digest,
73
- error: 'no parseable macaroon payload found',
74
- };
75
- }
76
- const identifier = blobs[0];
77
- const caveatBlobs = blobs.slice(1);
78
- const restrictions = caveatBlobs.map(toRestriction);
79
- const expired = hasExpired(restrictions);
80
- return {
81
- registry: 'pypi',
82
- source_digest: digest,
83
- format: 'macaroon',
84
- identifier,
85
- restrictions,
86
- expired,
87
- };
88
- }
89
- /**
90
- * Try base64 decode — accept both standard and URL-safe alphabets, with
91
- * or without padding. PyPI macaroons have been serialized with both
92
- * variants historically.
93
- */
94
- function tryBase64Decode(s) {
95
- const normalized = s.replace(/-/g, '+').replace(/_/g, '/');
96
- const padding = normalized.length % 4 === 0 ? '' : '='.repeat(4 - (normalized.length % 4));
97
- const bin = Buffer.from(normalized + padding, 'base64');
98
- if (bin.length === 0)
99
- return null;
100
- return new Uint8Array(bin);
101
- }
102
- /**
103
- * Walk the decoded macaroon bytes looking for top-level JSON objects.
104
- * PyPI's macaroon identifier and every caveat are JSON blobs embedded
105
- * in an otherwise-binary envelope; we don't need the full pymacaroons
106
- * parser to pull them out.
107
- */
108
- function extractJsonObjects(bytes) {
109
- const text = new TextDecoder('utf-8', { fatal: false }).decode(bytes);
110
- const results = [];
111
- let i = 0;
112
- while (i < text.length) {
113
- if (text[i] !== '{') {
114
- i++;
115
- continue;
116
- }
117
- const end = findMatchingBrace(text, i);
118
- if (end === -1) {
119
- i++;
120
- continue;
121
- }
122
- const candidate = text.slice(i, end + 1);
123
- try {
124
- const parsed = JSON.parse(candidate);
125
- if (isPlainObject(parsed)) {
126
- results.push(parsed);
127
- i = end + 1;
128
- continue;
129
- }
130
- }
131
- catch {
132
- /* fall through */
133
- }
134
- i++;
135
- }
136
- return results;
137
- }
138
- function findMatchingBrace(text, start) {
139
- let depth = 0;
140
- let inString = false;
141
- let escaped = false;
142
- for (let j = start; j < text.length; j++) {
143
- const c = text[j];
144
- if (escaped) {
145
- escaped = false;
146
- continue;
147
- }
148
- if (c === '\\') {
149
- escaped = true;
150
- continue;
151
- }
152
- if (c === '"') {
153
- inString = !inString;
154
- continue;
155
- }
156
- if (inString)
157
- continue;
158
- if (c === '{')
159
- depth++;
160
- else if (c === '}') {
161
- depth--;
162
- if (depth === 0)
163
- return j;
164
- }
165
- }
166
- return -1;
167
- }
168
- function isPlainObject(v) {
169
- return typeof v === 'object' && v !== null && !Array.isArray(v);
170
- }
171
- /**
172
- * Map a decoded caveat JSON blob to a typed `Restriction`. Caveat
173
- * shapes evolved — see pypi/warehouse#11873 and pypitoken docs. We
174
- * support the common shapes and preserve anything unknown verbatim.
175
- */
176
- function toRestriction(raw) {
177
- // v2 compact shapes: {version: 1, projects: [...]}
178
- if (Array.isArray(raw.projects) && raw.projects.every((p) => typeof p === 'string')) {
179
- return { type: 'ProjectNames', names: raw.projects };
180
- }
181
- if (Array.isArray(raw.project_ids) && raw.project_ids.every((p) => typeof p === 'string')) {
182
- return { type: 'ProjectIDs', ids: raw.project_ids };
183
- }
184
- // v1 legacy shape: {permissions: {projects: [...]}}
185
- if (isPlainObject(raw.permissions) && Array.isArray(raw.permissions.projects)) {
186
- const projects = raw.permissions.projects.filter((p) => typeof p === 'string');
187
- return { type: 'ProjectNames', names: projects };
188
- }
189
- // Date restriction: {nbf: <unix>, exp: <unix>} or {not_before / not_after}
190
- if (typeof raw.nbf === 'number' || typeof raw.exp === 'number') {
191
- const out = { type: 'Date' };
192
- if (typeof raw.nbf === 'number')
193
- out.not_before = raw.nbf;
194
- if (typeof raw.exp === 'number')
195
- out.not_after = raw.exp;
196
- return out;
197
- }
198
- if (typeof raw.not_before === 'number' || typeof raw.not_after === 'number') {
199
- const out = { type: 'Date' };
200
- if (typeof raw.not_before === 'number')
201
- out.not_before = raw.not_before;
202
- if (typeof raw.not_after === 'number')
203
- out.not_after = raw.not_after;
204
- return out;
205
- }
206
- return { type: 'Unknown', raw };
207
- }
208
- function hasExpired(restrictions) {
209
- const now = Math.floor(Date.now() / 1000);
210
- for (const r of restrictions) {
211
- if (r.type !== 'Date')
212
- continue;
213
- if (typeof r.not_after === 'number' && r.not_after < now)
214
- return true;
215
- }
216
- return false;
217
- }
218
- async function probe(url, headers, timeoutMs) {
219
- try {
220
- const res = await fetch(url, {
221
- method: 'GET',
222
- headers: { 'user-agent': 'putitoutthere/0.0.1', ...headers },
223
- signal: AbortSignal.timeout(timeoutMs),
224
- });
225
- let body = null;
226
- /* v8 ignore next 5 -- registries always return JSON on the endpoints we hit */
227
- try {
228
- body = await res.json();
229
- }
230
- catch {
231
- body = null;
232
- }
233
- return { status: res.status, body };
234
- }
235
- catch {
236
- // Timeout, aborted fetch, and network errors all collapse to the same outcome:
237
- // the caller treats the probe as a timeout and reports it to the user.
238
- return { timeout: true };
239
- }
240
- }
241
- // ---- npm --------------------------------------------------------------
242
- const NPM_REGISTRY = 'https://registry.npmjs.org';
243
- async function inspectNpm(token, digest, baseUrl, timeoutMs) {
244
- const authHeader = { authorization: `Bearer ${token}` };
245
- const format = token.startsWith('npm_')
246
- ? 'granular'
247
- : 'legacy';
248
- const whoami = await probe(`${baseUrl}/-/whoami`, authHeader, timeoutMs);
249
- if ('timeout' in whoami) {
250
- return { registry: 'npm', source_digest: digest, error: 'timeout' };
251
- }
252
- if (whoami.status === 401 || whoami.status === 403) {
253
- return {
254
- registry: 'npm',
255
- source_digest: digest,
256
- error: 'token invalid or expired',
257
- };
258
- }
259
- /* v8 ignore next 7 -- unexpected non-200 from /-/whoami; real responses hit 200/401/403 */
260
- if (whoami.status !== 200 || !isPlainObject(whoami.body) || typeof whoami.body.username !== 'string') {
261
- return {
262
- registry: 'npm',
263
- source_digest: digest,
264
- error: `unexpected whoami response (status ${whoami.status})`,
265
- };
266
- }
267
- const username = whoami.body.username;
268
- const tokens = await probe(`${baseUrl}/-/npm/v1/tokens`, authHeader, timeoutMs);
269
- if ('timeout' in tokens) {
270
- return {
271
- registry: 'npm',
272
- source_digest: digest,
273
- error: 'timeout',
274
- };
275
- }
276
- if (tokens.status === 401 || tokens.status === 403) {
277
- return {
278
- registry: 'npm',
279
- source_digest: digest,
280
- format,
281
- username,
282
- scope_row: null,
283
- note: `tokens endpoint returned ${tokens.status}; only username is confirmed`,
284
- };
285
- }
286
- /* v8 ignore next 10 -- unexpected non-200 from tokens endpoint; 200/401/403 cover real cases */
287
- if (tokens.status !== 200) {
288
- return {
289
- registry: 'npm',
290
- source_digest: digest,
291
- format,
292
- username,
293
- scope_row: null,
294
- note: `tokens endpoint returned ${tokens.status}; only username is confirmed`,
295
- };
296
- }
297
- const rows = extractNpmTokenRows(tokens.body);
298
- const want = sha512Hex(token);
299
- const match = rows.find((r) => typeof r.key === 'string' && r.key === want);
300
- if (!match) {
301
- return {
302
- registry: 'npm',
303
- source_digest: digest,
304
- format,
305
- username,
306
- scope_row: null,
307
- note: 'no SHA-512 match in tokens list (legacy UUID token, or bearer not listable)',
308
- };
309
- }
310
- return {
311
- registry: 'npm',
312
- source_digest: digest,
313
- format,
314
- username,
315
- scope_row: normalizeNpmRow(match),
316
- };
317
- }
318
- function extractNpmTokenRows(body) {
319
- if (Array.isArray(body))
320
- return body.filter(isPlainObject);
321
- if (isPlainObject(body) && Array.isArray(body.objects)) {
322
- return body.objects.filter(isPlainObject);
323
- }
324
- return [];
325
- }
326
- function normalizeNpmRow(row) {
327
- const scopes = Array.isArray(row.scopes)
328
- ? row.scopes.filter((s) => typeof s === 'string')
329
- : null;
330
- const packages = [];
331
- const orgs = [];
332
- const atScopes = [];
333
- if (scopes) {
334
- for (const s of scopes) {
335
- if (s.startsWith('pkg:'))
336
- packages.push(s.slice(4));
337
- else if (s.startsWith('org:'))
338
- orgs.push(s.slice(4));
339
- else if (s.startsWith('@'))
340
- atScopes.push(s);
341
- }
342
- }
343
- return {
344
- readonly: row.readonly === true,
345
- automation: row.automation === true,
346
- packages: packages.length > 0 ? packages : null,
347
- scopes: atScopes.length > 0 ? atScopes : null,
348
- orgs: orgs.length > 0 ? orgs : null,
349
- expires_at: typeof row.expires === 'string' ? row.expires : null,
350
- cidr_whitelist: Array.isArray(row.cidr_whitelist)
351
- ? row.cidr_whitelist.filter((c) => typeof c === 'string')
352
- : null,
353
- created: typeof row.created === 'string' ? row.created : null,
354
- };
355
- }
356
- // ---- crates.io --------------------------------------------------------
357
- const CRATES_REGISTRY = 'https://crates.io';
358
- async function inspectCrates(token, digest, baseUrl, timeoutMs) {
359
- // crates.io takes the raw token as the Authorization header — no Bearer prefix.
360
- const authHeader = { authorization: token };
361
- const me = await probe(`${baseUrl}/api/v1/me`, authHeader, timeoutMs);
362
- if ('timeout' in me) {
363
- return { registry: 'crates', source_digest: digest, error: 'timeout' };
364
- }
365
- if (me.status === 401 || me.status === 403) {
366
- return {
367
- registry: 'crates',
368
- source_digest: digest,
369
- error: 'token invalid or expired',
370
- };
371
- }
372
- /* v8 ignore next 7 -- unexpected non-200 from /me; 200/401/403 cover real cases */
373
- if (me.status !== 200 || !isPlainObject(me.body)) {
374
- return {
375
- registry: 'crates',
376
- source_digest: digest,
377
- error: `unexpected /me response (status ${me.status})`,
378
- };
379
- }
380
- const username = extractCratesUsername(me.body);
381
- /* v8 ignore next 7 -- /me schema always includes user.login */
382
- if (username === null) {
383
- return {
384
- registry: 'crates',
385
- source_digest: digest,
386
- error: 'could not find username in /me response',
387
- };
388
- }
389
- const tokensRes = await probe(`${baseUrl}/api/v1/me/tokens`, authHeader, timeoutMs);
390
- if ('timeout' in tokensRes) {
391
- return { registry: 'crates', source_digest: digest, error: 'timeout' };
392
- }
393
- if (tokensRes.status === 401 || tokensRes.status === 403) {
394
- return {
395
- registry: 'crates',
396
- source_digest: digest,
397
- username,
398
- account_tokens: null,
399
- bearer_row: null,
400
- note: `tokens endpoint returned ${tokensRes.status}; only username is confirmed`,
401
- };
402
- }
403
- /* v8 ignore next 11 -- unexpected non-200 from tokens endpoint; 200/401/403 cover real cases */
404
- if (tokensRes.status !== 200 || !isPlainObject(tokensRes.body)) {
405
- return {
406
- registry: 'crates',
407
- source_digest: digest,
408
- username,
409
- account_tokens: null,
410
- bearer_row: null,
411
- note: `tokens endpoint returned ${tokensRes.status}; only username is confirmed`,
412
- };
413
- }
414
- const raw = Array.isArray(tokensRes.body.api_tokens) ? tokensRes.body.api_tokens : [];
415
- const account_tokens = raw.filter(isPlainObject).map(normalizeCratesRow);
416
- return {
417
- registry: 'crates',
418
- source_digest: digest,
419
- username,
420
- account_tokens,
421
- bearer_row: null,
422
- note: 'crates.io does not expose which row corresponds to the bearer.',
423
- };
424
- }
425
- function extractCratesUsername(body) {
426
- if (isPlainObject(body.user) && typeof body.user.login === 'string') {
427
- return body.user.login;
428
- }
429
- /* v8 ignore next 3 -- alternate shape guard; /me always returns {user:{login}} */
430
- if (typeof body.login === 'string')
431
- return body.login;
432
- return null;
433
- }
434
- function normalizeCratesRow(row) {
435
- return {
436
- name: typeof row.name === 'string' ? row.name : '(unnamed)',
437
- endpoint_scopes: Array.isArray(row.endpoint_scopes)
438
- ? row.endpoint_scopes.filter((s) => typeof s === 'string')
439
- : null,
440
- crate_scopes: Array.isArray(row.crate_scopes)
441
- ? row.crate_scopes.filter((s) => typeof s === 'string')
442
- : null,
443
- expired_at: typeof row.expired_at === 'string' ? row.expired_at : null,
444
- };
445
- }
446
- // Crates has no identifying prefix, so we only classify an opaque env var
447
- // as crates when its *name* is the one the crates handler reads. Kept in
448
- // sync with src/preflight.ts TOKEN_ENV['crates'].
449
- const CRATES_ENV_NAMES = new Set(['CARGO_REGISTRY_TOKEN']);
450
- /**
451
- * Enumerate registry tokens discoverable to the CLI from the environment.
452
- *
453
- * Classification is by *value format*:
454
- * - `pypi-` prefix → PyPI macaroon
455
- * - `npm_` prefix → npm granular
456
- * - opaque value under a crates-recognized env var, iff the config has a
457
- * crates package → crates.io
458
- *
459
- * Token values are never returned or logged.
460
- *
461
- * Issue #106. Ships env-only; `--secrets` (repo/env/org secret listing via
462
- * the stored GitHub user access token) is gated on #105.
463
- */
464
- export function tokenList(opts = {}) {
465
- const env = opts.env ?? process.env;
466
- const packages = resolvePackages(opts);
467
- const hasCrates = packages.some((p) => p.kind === 'crates');
468
- const rows = [];
469
- for (const [name, raw] of Object.entries(env)) {
470
- if (typeof raw !== 'string')
471
- continue;
472
- const value = raw.trim();
473
- if (value === '')
474
- continue;
475
- if (value.startsWith('pypi-')) {
476
- rows.push({ registry: 'pypi', source: 'env', name, details: 'pypi- prefix, macaroon' });
477
- }
478
- else if (value.startsWith('npm_')) {
479
- rows.push({ registry: 'npm', source: 'env', name, details: 'npm_ prefix (granular)' });
480
- }
481
- else if (hasCrates && CRATES_ENV_NAMES.has(name)) {
482
- rows.push({ registry: 'crates', source: 'env', name, details: 'opaque (from config)' });
483
- }
484
- // anything else: ignored. We never dump the full environment.
485
- }
486
- rows.sort((a, b) => (a.registry === b.registry ? a.name.localeCompare(b.name) : a.registry.localeCompare(b.registry)));
487
- return rows;
488
- }
489
- function resolvePackages(opts) {
490
- if (opts.packages !== undefined)
491
- return opts.packages;
492
- const cwd = opts.cwd ?? process.cwd();
493
- const cfgPath = opts.configPath ?? `${cwd.replace(/\/+$/, '')}/putitoutthere.toml`;
494
- try {
495
- return loadConfig(cfgPath).packages;
496
- }
497
- catch {
498
- // No config / unreadable config → env-only, prefix-based classification
499
- // still works. Crates opaque-tokens are skipped (no config to key on).
500
- return [];
501
- }
502
- }
503
- // ---- token list --secrets ---------------------------------------------
504
- /**
505
- * Classify a GitHub Actions secret *by name* to a registry. The API never
506
- * exposes secret values, so prefix-by-value classification (the env-var
507
- * path) is unavailable here. Conservative: unrecognized names return
508
- * `null` and are omitted from the listing rather than dumping every
509
- * secret on the repo.
510
- *
511
- * Exact matches line up with `src/preflight.ts#TOKEN_ENV`, plus the
512
- * common `TWINE_PASSWORD` / `PYPI_TOKEN` aliases used in the wild.
513
- * Prefixed names (`NPM_FOO`, `PYPI_BAR`, `CARGO_FOO`, `TWINE_FOO`)
514
- * are treated as likely-registry.
515
- */
516
- export function classifySecretName(name) {
517
- if (name === 'CARGO_REGISTRY_TOKEN')
518
- return 'crates';
519
- if (name === 'NPM_TOKEN' || name === 'NODE_AUTH_TOKEN')
520
- return 'npm';
521
- if (name === 'PYPI_API_TOKEN' || name === 'PYPI_TOKEN' || name === 'TWINE_PASSWORD')
522
- return 'pypi';
523
- if (name.startsWith('CARGO_'))
524
- return 'crates';
525
- if (name.startsWith('NPM_'))
526
- return 'npm';
527
- if (name.startsWith('PYPI_') || name.startsWith('TWINE_'))
528
- return 'pypi';
529
- return null;
530
- }
531
- const GITHUB_API = 'https://api.github.com';
532
- /**
533
- * List GitHub Actions secrets (repo + environment) whose names look like
534
- * registry credentials. Uses the stored user access token from the
535
- * `Keyring` (populated by `auth login`).
536
- *
537
- * Secret *values* are never returned — the GitHub API doesn't expose
538
- * them. Classification is by name (see `classifySecretName`).
539
- *
540
- * Degrades gracefully: missing keyring, unresolvable repo, 401/403 from
541
- * GitHub, and network errors all become outcome kinds rather than
542
- * thrown errors. The caller decides whether to surface them.
543
- */
544
- export async function tokenListSecrets(opts = {}) {
545
- // Hard-pin the API base so a caller who type-asserts `apiBase` onto
546
- // the opts object cannot redirect the authenticated request (#139).
547
- return tokenListSecretsImpl({ ...opts, apiBase: GITHUB_API });
548
- }
549
- /**
550
- * Test-only entry point. Accepts an `apiBase` override so tests can
551
- * point at a mocked GitHub API. Not part of the public SDK surface —
552
- * do not re-export from the SDK index (#139).
553
- *
554
- * @internal
555
- */
556
- export async function __tokenListSecretsForTests(opts) {
557
- return tokenListSecretsImpl(opts);
558
- }
559
- async function tokenListSecretsImpl(opts) {
560
- const keyring = opts.keyring ?? defaultKeyring();
561
- const fetchFn = opts.fetchFn ?? fetch;
562
- const apiBase = opts.apiBase ?? GITHUB_API;
563
- const timeoutMs = opts.timeoutMs ?? 5000;
564
- const env = opts.env ?? process.env;
565
- const stored = await keyring.get();
566
- if (!stored) {
567
- return {
568
- kind: 'not_logged_in',
569
- message: '--secrets: not logged in. Run `putitoutthere auth login` to list repo secrets.',
570
- };
571
- }
572
- const repo = opts.repo ?? resolveRepo(env, opts.cwd ?? process.cwd());
573
- if (repo === null) {
574
- return {
575
- kind: 'no_repo',
576
- message: '--secrets: could not resolve owner/repo from $GITHUB_REPOSITORY or git remote "origin".',
577
- };
578
- }
579
- const auth = `Bearer ${stored.access_token}`;
580
- const rows = [];
581
- const repoSecrets = await ghGetJson(fetchFn, `${apiBase}/repos/${repo}/actions/secrets`, auth, timeoutMs);
582
- if (repoSecrets.kind === 'error') {
583
- return {
584
- kind: 'error',
585
- message: `--secrets: listing repo secrets failed: ${repoSecrets.message}`,
586
- rows,
587
- };
588
- }
589
- for (const s of extractSecretNames(repoSecrets.body)) {
590
- const reg = classifySecretName(s);
591
- if (reg !== null) {
592
- rows.push({ registry: reg, source: 'repo-secret', name: s, details: `repo secret (${repo})` });
593
- }
594
- }
595
- const envsRes = await ghGetJson(fetchFn, `${apiBase}/repos/${repo}/environments`, auth, timeoutMs);
596
- if (envsRes.kind === 'ok') {
597
- const envNames = extractEnvironmentNames(envsRes.body);
598
- for (const envName of envNames) {
599
- const encoded = encodeURIComponent(envName);
600
- const envSecrets = await ghGetJson(fetchFn, `${apiBase}/repos/${repo}/environments/${encoded}/secrets`, auth, timeoutMs);
601
- /* v8 ignore next -- per-environment failure is rare; covered by repo-secrets error path above */
602
- if (envSecrets.kind !== 'ok')
603
- continue;
604
- for (const s of extractSecretNames(envSecrets.body)) {
605
- const reg = classifySecretName(s);
606
- if (reg !== null) {
607
- rows.push({
608
- registry: reg,
609
- source: 'environment-secret',
610
- name: s,
611
- details: `environment secret (${envName})`,
612
- environment: envName,
613
- });
614
- }
615
- }
616
- }
617
- }
618
- // envs endpoint 404 on repos without environments configured; treat as no envs, keep repo rows.
619
- rows.sort((a, b) => {
620
- if (a.registry !== b.registry)
621
- return a.registry.localeCompare(b.registry);
622
- if (a.source !== b.source)
623
- return a.source.localeCompare(b.source);
624
- return a.name.localeCompare(b.name);
625
- });
626
- return { kind: 'ok', rows };
627
- }
628
- async function ghGetJson(fetchFn, url, authorization, timeoutMs) {
629
- try {
630
- const res = await fetchFn(url, {
631
- method: 'GET',
632
- headers: {
633
- authorization,
634
- accept: 'application/vnd.github+json',
635
- 'user-agent': 'putitoutthere',
636
- 'x-github-api-version': '2022-11-28',
637
- },
638
- signal: AbortSignal.timeout(timeoutMs),
639
- });
640
- if (res.status === 404) {
641
- return { kind: 'ok', body: { not_found: true } };
642
- }
643
- if (!res.ok) {
644
- return { kind: 'error', message: `HTTP ${res.status}` };
645
- }
646
- const body = await res.json();
647
- return { kind: 'ok', body };
648
- /* v8 ignore next 3 -- network/timeout path; msw covers HTTP error paths, real throws aren't reachable in tests */
649
- }
650
- catch (err) {
651
- return { kind: 'error', message: err instanceof Error ? err.message : 'unknown network error' };
652
- }
653
- }
654
- function extractSecretNames(body) {
655
- if (!isPlainObject(body) || !Array.isArray(body.secrets))
656
- return [];
657
- const out = [];
658
- for (const s of body.secrets) {
659
- if (isPlainObject(s) && typeof s.name === 'string')
660
- out.push(s.name);
661
- }
662
- return out;
663
- }
664
- function extractEnvironmentNames(body) {
665
- if (!isPlainObject(body))
666
- return [];
667
- if (body.not_found === true)
668
- return [];
669
- if (!Array.isArray(body.environments))
670
- return [];
671
- const out = [];
672
- for (const e of body.environments) {
673
- if (isPlainObject(e) && typeof e.name === 'string')
674
- out.push(e.name);
675
- }
676
- return out;
677
- }
678
- /**
679
- * Resolve "owner/repo" from, in order:
680
- * 1. `GITHUB_REPOSITORY` env var (CI case)
681
- * 2. `git remote get-url origin`, parsing common GitHub URL shapes:
682
- * - `https://github.com/owner/repo(.git)?`
683
- * - `git@github.com:owner/repo(.git)?`
684
- * - `ssh://git@github.com/owner/repo(.git)?`
685
- */
686
- export function resolveRepo(env, cwd) {
687
- const fromEnv = env.GITHUB_REPOSITORY;
688
- if (typeof fromEnv === 'string' && /^[^/\s]+\/[^/\s]+$/.test(fromEnv)) {
689
- return fromEnv;
690
- }
691
- let url;
692
- try {
693
- url = execFileSync('git', ['remote', 'get-url', 'origin'], {
694
- cwd,
695
- encoding: 'utf8',
696
- stdio: ['ignore', 'pipe', 'ignore'],
697
- }).trim();
698
- }
699
- catch {
700
- return null;
701
- }
702
- return parseGithubRemote(url);
703
- }
704
- export function parseGithubRemote(url) {
705
- // https://github.com/<owner>/<repo>(.git)?
706
- const https = /^https?:\/\/github\.com\/([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/.exec(url);
707
- if (https)
708
- return `${https[1]}/${https[2]}`;
709
- // git@github.com:<owner>/<repo>(.git)?
710
- const ssh = /^git@github\.com:([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/.exec(url);
711
- if (ssh)
712
- return `${ssh[1]}/${ssh[2]}`;
713
- // ssh://git@github.com/<owner>/<repo>(.git)?
714
- const sshProto = /^ssh:\/\/git@github\.com\/([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/.exec(url);
715
- if (sshProto)
716
- return `${sshProto[1]}/${sshProto[2]}`;
717
- return null;
718
- }
719
- //# sourceMappingURL=token.js.map