@typeship-ax/cli 0.8.0 → 0.9.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.
Files changed (106) hide show
  1. package/AGENTS.md +31 -0
  2. package/README.md +26 -9
  3. package/api.json +1875 -684
  4. package/api.md +352 -500
  5. package/dist/api-identity.d.ts +40 -0
  6. package/dist/api-identity.d.ts.map +1 -0
  7. package/dist/api-identity.js +128 -0
  8. package/dist/auth-profiles.d.ts +30 -0
  9. package/dist/auth-profiles.d.ts.map +1 -0
  10. package/dist/auth-profiles.js +138 -0
  11. package/dist/cli.js +522 -355
  12. package/dist/console-login-check.d.ts +21 -0
  13. package/dist/console-login-check.d.ts.map +1 -0
  14. package/dist/console-login-check.js +107 -0
  15. package/dist/console-login-contract.d.ts +45 -0
  16. package/dist/console-login-contract.d.ts.map +1 -0
  17. package/dist/console-login-contract.js +40 -0
  18. package/dist/core/http.d.ts +17 -2
  19. package/dist/core/http.d.ts.map +1 -1
  20. package/dist/core/http.js +78 -17
  21. package/dist/credential-storage.d.ts +24 -0
  22. package/dist/credential-storage.d.ts.map +1 -0
  23. package/dist/credential-storage.js +207 -0
  24. package/dist/docs.d.ts +25 -0
  25. package/dist/docs.d.ts.map +1 -1
  26. package/dist/docs.js +144 -0
  27. package/dist/errors.d.ts +18 -10
  28. package/dist/errors.d.ts.map +1 -1
  29. package/dist/errors.js +24 -14
  30. package/dist/index.d.ts +10 -3
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +20 -4
  33. package/dist/named-credentials.d.ts +21 -0
  34. package/dist/named-credentials.d.ts.map +1 -0
  35. package/dist/named-credentials.js +86 -0
  36. package/dist/oauth-login.d.ts +39 -0
  37. package/dist/oauth-login.d.ts.map +1 -0
  38. package/dist/oauth-login.js +171 -0
  39. package/dist/oauth-request.d.ts +21 -0
  40. package/dist/oauth-request.d.ts.map +1 -0
  41. package/dist/oauth-request.js +119 -0
  42. package/dist/oauth-session.d.ts +106 -0
  43. package/dist/oauth-session.d.ts.map +1 -0
  44. package/dist/oauth-session.js +244 -0
  45. package/dist/ops.d.ts +14 -1
  46. package/dist/ops.d.ts.map +1 -1
  47. package/dist/ops.js +30 -30
  48. package/dist/polling-login.d.ts +57 -0
  49. package/dist/polling-login.d.ts.map +1 -0
  50. package/dist/polling-login.js +204 -0
  51. package/dist/resources/account.d.ts +2 -2
  52. package/dist/resources/account.d.ts.map +1 -1
  53. package/dist/resources/account.js +1 -0
  54. package/dist/resources/api-keys.d.ts +3 -3
  55. package/dist/resources/api-keys.d.ts.map +1 -1
  56. package/dist/resources/api-keys.js +2 -0
  57. package/dist/resources/definition-revisions.d.ts +5 -5
  58. package/dist/resources/definition-revisions.d.ts.map +1 -1
  59. package/dist/resources/definition-revisions.js +4 -0
  60. package/dist/resources/definitions.d.ts +15 -4
  61. package/dist/resources/definitions.d.ts.map +1 -1
  62. package/dist/resources/definitions.js +11 -2
  63. package/dist/resources/generate.d.ts +14 -3
  64. package/dist/resources/generate.d.ts.map +1 -1
  65. package/dist/resources/generate.js +10 -2
  66. package/dist/resources/generations.d.ts +3 -3
  67. package/dist/resources/generations.d.ts.map +1 -1
  68. package/dist/resources/generations.js +2 -0
  69. package/dist/resources/projects.d.ts +56 -20
  70. package/dist/resources/projects.d.ts.map +1 -1
  71. package/dist/resources/projects.js +40 -4
  72. package/dist/resources/targets.d.ts +20 -9
  73. package/dist/resources/targets.d.ts.map +1 -1
  74. package/dist/resources/targets.js +14 -1
  75. package/dist/schemas.d.ts.map +1 -1
  76. package/dist/schemas.js +38 -22
  77. package/dist/types.d.ts +385 -119
  78. package/dist/types.d.ts.map +1 -1
  79. package/dist/types.js +11 -0
  80. package/package.json +2 -1
  81. package/src/api-identity.ts +98 -0
  82. package/src/auth-profiles.ts +114 -0
  83. package/src/cli.ts +444 -332
  84. package/src/console-login-check.ts +88 -0
  85. package/src/console-login-contract.ts +65 -0
  86. package/src/core/http.ts +88 -19
  87. package/src/credential-storage.ts +183 -0
  88. package/src/docs.ts +138 -0
  89. package/src/errors.ts +26 -15
  90. package/src/index.ts +29 -4
  91. package/src/named-credentials.ts +74 -0
  92. package/src/oauth-login.ts +184 -0
  93. package/src/oauth-request.ts +90 -0
  94. package/src/oauth-session.ts +258 -0
  95. package/src/ops.ts +44 -31
  96. package/src/polling-login.ts +165 -0
  97. package/src/resources/account.ts +3 -0
  98. package/src/resources/api-keys.ts +5 -0
  99. package/src/resources/definition-revisions.ts +9 -0
  100. package/src/resources/definitions.ts +25 -0
  101. package/src/resources/generate.ts +23 -0
  102. package/src/resources/generations.ts +5 -0
  103. package/src/resources/projects.ts +95 -7
  104. package/src/resources/targets.ts +32 -0
  105. package/src/schemas.ts +38 -22
  106. package/src/types.ts +404 -119
@@ -0,0 +1,88 @@
1
+ import { openSync, readSync, closeSync, fstatSync, constants } from "node:fs";
2
+ import { oauthJsonRequest } from "./oauth-request.js";
3
+ import { sameConsoleLoginConfiguration, matchesConsoleBrowserEvidence, type ConsoleLoginCheck, type ConsoleLoginConfiguration } from "./console-login-contract.js";
4
+ import { type OAuthLoginSession } from "./oauth-login.js";
5
+ import { parseNamedCredentials, type CredentialSchemes, type NamedCredentials } from "./named-credentials.js";
6
+
7
+ function readCheck(path: string): ConsoleLoginCheck {
8
+ let fd: number | undefined;
9
+ try {
10
+ fd = openSync(path, constants.O_RDONLY | constants.O_NONBLOCK);
11
+ if (!fstatSync(fd).isFile()) throw new Error();
12
+ const buffer = Buffer.alloc(65_537);
13
+ let size = 0;
14
+ while (size < buffer.length) { const n = readSync(fd, buffer, size, buffer.length - size, null); if (!n) break; size += n; }
15
+ if (size > 65_536) throw new Error();
16
+ const value = JSON.parse(buffer.subarray(0, size).toString("utf8")) as ConsoleLoginCheck;
17
+ if (value.version !== 1 || typeof value.ticket !== "string" || !/^[A-Za-z0-9_-]{43}$/.test(value.ticket) || !value.configuration || !Array.isArray(value.expectations) || !value.expectations.length || value.expectations.length > 3) throw new Error();
18
+ const endpoint = new URL(value.endpoint);
19
+ const development = endpoint.protocol === "http:" && ["127.0.0.1", "[::1]"].includes(endpoint.hostname);
20
+ if (!(endpoint.origin === "https://typeship.dev" || development) || endpoint.pathname !== "/api/authentication/browser" || endpoint.username || endpoint.password || endpoint.search || endpoint.hash) throw new Error();
21
+ const remaining = Date.parse(value.expiresAt) - Date.now();
22
+ if (!Number.isFinite(remaining) || remaining <= 0 || remaining > 600_000) throw new Error();
23
+ return value;
24
+ } catch { throw new Error("Download a new login check from your Typeship Console and pass its JSON file to --console-check. Checks expire after ten minutes."); }
25
+ finally { if (fd !== undefined) closeSync(fd); }
26
+ }
27
+
28
+ /** No traffic to Typeship occurs without the explicit --console-check action.
29
+ * Only the credentials selected for the identity read leave this process;
30
+ * refresh tokens and other stored logins are never sent to the Console. */
31
+ export async function checkConsoleBrowserLogin(input: {
32
+ file: string;
33
+ configuration: ConsoleLoginConfiguration;
34
+ schemes: CredentialSchemes;
35
+ credentials: NamedCredentials;
36
+ login(timeoutMs: number): Promise<OAuthLoginSession>;
37
+ verify(credentials: NamedCredentials, expectations: ConsoleLoginCheck["expectations"]): Promise<void>;
38
+ progress(message: string): void;
39
+ }): Promise<{ ok: true; method: "oauth_browser"; console_verified: true; revoked: boolean | null }> {
40
+ const check = readCheck(input.file);
41
+ if (!sameConsoleLoginConfiguration(input.configuration, check.configuration)) throw new Error("This CLI's authentication settings do not match the saved Target. Regenerate the CLI, select the matching environment, and download a new check.");
42
+ const send = async (body: Record<string, unknown>, timeoutMs = 30_000) => {
43
+ const response = await oauthJsonRequest(check.endpoint, { method: "POST", headers: { "Content-Type": "application/json", Accept: "application/json" }, body: JSON.stringify({ ticket: check.ticket, ...body }) }, timeoutMs);
44
+ if (response.status === 422) throw new Error("Typeship could not verify this login. Check the expected identity, API permissions and saved Target settings, then download a new check.");
45
+ if (response.status !== 200 || response.data?.ok !== true) throw new Error("The Console check expired, was already used, changed, or could not be completed. Download a new check and try again.");
46
+ return response.data;
47
+ };
48
+ await send({ action: "inspect" });
49
+ let revoked: boolean | null = null;
50
+ try {
51
+ const supplied = parseNamedCredentials(input.credentials, input.schemes);
52
+ // Only a complete alternative that uses the fresh browser token proves
53
+ // browser access. An unrelated key-only alternative is not sufficient.
54
+ const requirement = input.configuration.requirements.find((entry) => {
55
+ const names = Object.keys(entry);
56
+ if (!names.some((name) => input.schemes[name]?.kind === "bearer") || !names.every((name) => input.schemes[name]?.kind === "bearer" || supplied[name] !== undefined)) return false;
57
+ const selected = input.configuration.requirements.find((candidate) => Object.keys(candidate).every((name) => names.includes(name)));
58
+ return selected && Object.keys(selected).some((name) => input.schemes[name]?.kind === "bearer");
59
+ });
60
+ if (!requirement) throw new Error("Supply the additional named credentials required by the identity read with --credentials @file or environment variables.");
61
+ input.progress("This check runs a temporary browser login. Typeship will receive only the access token and any additional credentials required for the identity read; refresh tokens stay on this computer.");
62
+ const session = await input.login(Math.max(1, Date.parse(check.expiresAt) - Date.now()));
63
+ try {
64
+ const evidence = { method: "oauth_browser" as const, issuer: session.issuer, clientId: session.clientId, redirectUri: session.redirectUri };
65
+ if (!matchesConsoleBrowserEvidence(input.configuration, evidence)) throw new Error("The browser login did not use the Target's client registration and native callback.");
66
+ const credentials: NamedCredentials = Object.fromEntries(Object.keys(requirement).map((name) => [name, input.schemes[name]!.kind === "bearer" ? session.accessToken : supplied[name]! ]));
67
+ await input.verify(credentials, check.expectations);
68
+ await send({ action: "complete", configuration: input.configuration, evidence, credentials, expectations: check.expectations });
69
+ } finally {
70
+ // Verification sessions never replace the customer's saved login.
71
+ // Revoke the temporary grant when the provider advertises an endpoint.
72
+ if (session.revocationUrl) {
73
+ try {
74
+ const response = await fetch(session.revocationUrl, { method: "POST", redirect: "error", credentials: "omit", signal: AbortSignal.timeout(10_000), headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ token: session.refreshToken ?? session.accessToken, token_type_hint: session.refreshToken ? "refresh_token" : "access_token", client_id: session.clientId }) });
75
+ void response.body?.cancel().catch(() => {});
76
+ revoked = response.status === 200;
77
+ } catch { revoked = false; }
78
+ }
79
+ }
80
+ } catch (error) {
81
+ // Report only that the attempt ended. Never send provider errors, identity
82
+ // values or credentials, and never replace the original local error. A
83
+ // consumed check rejects this report, preserving any completed API proof.
84
+ try { await send({ action: "fail" }, 5_000); } catch { /* Best effort; the Console can still expire the check. */ }
85
+ throw error;
86
+ }
87
+ return { ok: true, method: "oauth_browser", console_verified: true, revoked };
88
+ }
@@ -0,0 +1,65 @@
1
+
2
+ export interface ConsoleLoginConfiguration {
3
+ baseUrl: string;
4
+ environment: string | null;
5
+ operation: string;
6
+ request: { method: string; path: string; graphqlField: string | null; graphqlQuery: string | null };
7
+ requirements: Record<string, string[]>[];
8
+ issuer: string;
9
+ clientId: string;
10
+ discoveryUrl: string | null;
11
+ authorizationUrl: string | null;
12
+ tokenUrl: string | null;
13
+ redirectUri: string;
14
+ scopes: string[];
15
+ audience: string | null;
16
+ resource: string | null;
17
+ }
18
+ export interface ConsoleIdentityExpectation {
19
+ kind: "account" | "organization" | "user";
20
+ pointer: string;
21
+ expected: string | number;
22
+ }
23
+ export interface ConsoleLoginCheck {
24
+ version: 1;
25
+ endpoint: string;
26
+ ticket: string;
27
+ expiresAt: string;
28
+ configuration: ConsoleLoginConfiguration;
29
+ expectations: ConsoleIdentityExpectation[];
30
+ }
31
+ export interface ConsoleBrowserEvidence {
32
+ method: "oauth_browser";
33
+ issuer: string;
34
+ clientId: string;
35
+ redirectUri: string;
36
+ }
37
+
38
+ function canonical(value: unknown): string {
39
+ if (Array.isArray(value)) return "[" + value.map(canonical).join(",") + "]";
40
+ if (value && typeof value === "object") return "{" + Object.entries(value).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([key, item]) => JSON.stringify(key) + ":" + canonical(item)).join(",") + "}";
41
+ return JSON.stringify(value) ?? "null";
42
+ }
43
+
44
+ /** Compare the generated product's own settings with the reviewed check.
45
+ * The downloaded file never supplies the CLI's OAuth or API destination. */
46
+ export function sameConsoleLoginConfiguration(left: ConsoleLoginConfiguration, right: ConsoleLoginConfiguration): boolean {
47
+ const normalize = (value: ConsoleLoginConfiguration) => ({ ...value,
48
+ baseUrl: new URL(value.baseUrl).href,
49
+ scopes: [...new Set(value.scopes)].sort(),
50
+ requirements: value.requirements.map((entry) => Object.fromEntries(Object.entries(entry).map(([key, scopes]) => [key, [...new Set(scopes)].sort()]))),
51
+ });
52
+ try { return canonical(normalize(left)) === canonical(normalize(right)); } catch { return false; }
53
+ }
54
+
55
+ export function matchesConsoleBrowserEvidence(config: ConsoleLoginConfiguration, evidence: ConsoleBrowserEvidence): boolean {
56
+ try {
57
+ if (evidence.method !== "oauth_browser" || evidence.issuer !== config.issuer || evidence.clientId !== config.clientId) return false;
58
+ const configured = new URL(config.redirectUri), actual = new URL(evidence.redirectUri);
59
+ if (configured.protocol !== "http:" || !["127.0.0.1", "[::1]"].includes(configured.hostname) || configured.username || configured.password || configured.search || configured.hash) return false;
60
+ if (configured.port && configured.port !== actual.port) return false;
61
+ if (!actual.port) return false;
62
+ configured.port = actual.port;
63
+ return configured.href === actual.href;
64
+ } catch { return false; }
65
+ }
package/src/core/http.ts CHANGED
@@ -81,6 +81,23 @@ export class UnexpectedApiError extends ApiError<number, unknown> {
81
81
  }
82
82
  }
83
83
 
84
+ /** A successful response declared JSON but carried a body that could not be parsed. */
85
+ export class ResponseParseError extends Error {
86
+ readonly status: number;
87
+ readonly body: string;
88
+ readonly response: ResponseMeta;
89
+ override readonly cause?: unknown;
90
+
91
+ constructor(body: string, response: ResponseMeta, cause?: unknown) {
92
+ super("HTTP " + response.status + " response body was not valid JSON");
93
+ this.name = "ResponseParseError";
94
+ this.status = response.status;
95
+ this.body = body;
96
+ this.response = response;
97
+ this.cause = cause;
98
+ }
99
+ }
100
+
84
101
 
85
102
  // ---------------------------------------------------------------------------
86
103
  // Optional runtime validation — zero-dependency, schema table in schemas.ts
@@ -222,7 +239,7 @@ function transportFailureMessage(method: string, url: string, cause: unknown): s
222
239
  return method + " " + url + " failed: " + detail;
223
240
  }
224
241
 
225
- /** The request never produced an HTTP response (network failure, timeout, abort). */
242
+ /** The request failed before a complete HTTP response arrived (network failure, timeout, abort, or truncated body). */
226
243
  export class TransportError extends Error {
227
244
  override readonly cause?: unknown;
228
245
 
@@ -236,6 +253,7 @@ export class TransportError extends Error {
236
253
  type ErrorCtor = new (body: any, response: ResponseMeta) => ApiError<number, unknown>;
237
254
 
238
255
  export interface CoreRequest {
256
+ security?: Record<string, string[]>[];
239
257
  method: string;
240
258
  path: string;
241
259
  query?: Record<string, unknown>;
@@ -268,7 +286,39 @@ export interface RetryPolicy {
268
286
  retryNonIdempotent?: boolean;
269
287
  }
270
288
 
289
+ export interface SecurityCredential {
290
+ headers?: Record<string, AuthValue>;
291
+ query?: Record<string, AuthValue>;
292
+ }
293
+
294
+ /** Resolve one complete alternative in spec order. Missing or incompatible
295
+ * combinations contribute no partial credentials. Anonymous operations send
296
+ * no generated credentials; explicit headers/hooks remain application owned. */
297
+ function selectSecurity(requirements: Record<string, string[]>[], credentials: Record<string, SecurityCredential>): SecurityCredential {
298
+ for (const requirement of requirements) {
299
+ const names = Object.keys(requirement);
300
+ if (!names.length || names.some((name) => !Object.hasOwn(credentials, name))) continue;
301
+ const selected: SecurityCredential = { headers: Object.create(null), query: Object.create(null) };
302
+ const destinations = new Set<string>();
303
+ let conflict = false;
304
+ for (const name of names) {
305
+ for (const location of ["headers", "query"] as const) {
306
+ for (const [wire, value] of Object.entries(credentials[name]![location] ?? {})) {
307
+ const destination = location + ":" + (location === "headers" ? wire.toLowerCase() : wire);
308
+ if (destinations.has(destination)) conflict = true;
309
+ destinations.add(destination);
310
+ selected[location]![wire] = value;
311
+ }
312
+ }
313
+ }
314
+ if (!conflict) return selected;
315
+ }
316
+ return {};
317
+ }
318
+
271
319
  export interface CoreConfig {
320
+ /** Named credentials are selected using each operation's requirements. */
321
+ credentials?: Record<string, SecurityCredential>;
272
322
  baseUrl: string;
273
323
  headers: Record<string, AuthValue>;
274
324
  /** Auth carried as query parameters (apiKey-in-query schemes). */
@@ -317,7 +367,7 @@ export interface DebugEvent {
317
367
  /** 1-based; >1 means this was a retry */
318
368
  attempt: number;
319
369
  requestId?: string;
320
- /** transport failure message, when there was no response */
370
+ /** transport failure message, when the request failed before a complete response */
321
371
  error?: string;
322
372
  }
323
373
 
@@ -376,8 +426,14 @@ export class HttpCore {
376
426
  const policy: RetryPolicy = { ...this.config.retry, ...req.retry };
377
427
  const maxRetries = req.options?.maxRetries ?? policy.maxRetries ?? this.config.maxRetries;
378
428
  const timeoutMs = req.options?.timeoutMs ?? this.config.timeoutMs;
379
- const retryAllowed = req.idempotent === true || req.method === "GET" || policy.retryNonIdempotent === true;
380
- const retryableStatuses = policy.statuses ? new Set(policy.statuses) : RETRYABLE_STATUSES;
429
+ const retryAllowed =
430
+ req.idempotent === true ||
431
+ req.method === "GET" ||
432
+ req.idempotencyKey !== undefined ||
433
+ policy.retryNonIdempotent === true;
434
+ const retryableStatuses = policy.statuses
435
+ ? new Set(policy.statuses)
436
+ : RETRYABLE_STATUSES;
381
437
 
382
438
  // One key per logical call, reused on every retry — that's the point
383
439
  // of idempotency keys.
@@ -449,13 +505,14 @@ export class HttpCore {
449
505
  try {
450
506
  data = (await parseBody(response, req.method)) as T;
451
507
  } catch (cause) {
452
- emitResponseDebug(response);
453
- const error = new TransportError(
454
- "The response body read was aborted before completing",
508
+ const parseError = cause instanceof ResponseParseError ? cause : undefined;
509
+ emitResponseDebug(response, parseError?.body);
510
+ const error = (parseError ?? new TransportError(
511
+ "The response body read failed before completing",
455
512
  cause,
456
- ) as unknown as E;
513
+ )) as unknown as E;
457
514
  await this.config.onError?.(error, { method: req.method, path: req.path });
458
- return { ok: false, error, response: meta(response) };
515
+ return { ok: false, error, response: parseError?.response ?? meta(response) };
459
516
  }
460
517
  emitResponseDebug(response, data);
461
518
  const responseMeta = meta(response, data);
@@ -527,8 +584,9 @@ export class HttpCore {
527
584
  attempt: number,
528
585
  autoIdempotencyKey?: string,
529
586
  ): Promise<Response> {
587
+ const selected = req.security && this.config.credentials ? selectSecurity(req.security, this.config.credentials) : {};
530
588
  const headers: Record<string, string> = {};
531
- for (const [k, v] of Object.entries(this.config.headers)) {
589
+ for (const [k, v] of Object.entries({ ...this.config.headers, ...selected.headers })) {
532
590
  headers[k] = await resolveAuthValue(v);
533
591
  }
534
592
  if (req.idempotencyKey && autoIdempotencyKey) {
@@ -544,7 +602,7 @@ export class HttpCore {
544
602
 
545
603
  const context: RequestContext = {
546
604
  method: req.method,
547
- url: await this.buildUrl(req),
605
+ url: await this.buildUrl(req, selected.query),
548
606
  headers,
549
607
  attempt,
550
608
  };
@@ -632,7 +690,7 @@ export class HttpCore {
632
690
  }
633
691
  }
634
692
 
635
- private async buildUrl(req: CoreRequest): Promise<string> {
693
+ private async buildUrl(req: CoreRequest, authQuery?: Record<string, AuthValue>): Promise<string> {
636
694
  const base = this.config.baseUrl.replace(/\/+$/, "");
637
695
  const url = new URL(base + req.path);
638
696
  for (const [k, v] of Object.entries(req.query ?? {})) {
@@ -644,7 +702,7 @@ export class HttpCore {
644
702
  appendDeep(url.searchParams, k, v);
645
703
  }
646
704
  }
647
- for (const [k, v] of Object.entries(this.config.query)) {
705
+ for (const [k, v] of Object.entries({ ...this.config.query, ...authQuery })) {
648
706
  url.searchParams.append(k, await resolveAuthValue(v));
649
707
  }
650
708
  return url.toString();
@@ -704,17 +762,28 @@ function serializeBody(req: CoreRequest): { body: NonNullable<RequestInit["body"
704
762
 
705
763
  async function parseBody(response: Response, method: string): Promise<unknown> {
706
764
  if (method === "HEAD" || response.status === 204 || response.status === 205) return undefined;
707
- const contentType = response.headers.get("content-type") ?? "";
765
+ const contentType = (response.headers.get("content-type") ?? "").toLowerCase();
766
+ const mediaType = contentType.split(";", 1)[0]!.trim();
767
+ const slash = mediaType.indexOf("/");
768
+ const subtype = slash === -1 ? "" : mediaType.slice(slash + 1);
708
769
  try {
709
- if (contentType.includes("json")) return await response.json();
770
+ if (subtype === "json" || subtype.endsWith("+json")) {
771
+ const body = await response.text();
772
+ if (body.length === 0) return undefined;
773
+ try {
774
+ return JSON.parse(body);
775
+ } catch (cause) {
776
+ throw new ResponseParseError(body, meta(response, body), cause);
777
+ }
778
+ }
710
779
  if (contentType.startsWith("text/")) return await response.text();
711
780
  if (response.body === null) return undefined;
712
781
  return await response.blob();
713
782
  } catch (cause) {
714
- // A timed-out or aborted body read is a transport failure, not an
715
- // empty body — surface it instead of faking success.
716
- if (cause instanceof Error && (cause.name === "AbortError" || cause.name === "TimeoutError")) throw cause;
717
- return undefined;
783
+ if (cause instanceof ResponseParseError) throw cause;
784
+ // A timed-out, aborted, or prematurely terminated body is a transport
785
+ // failure, not an empty body — surface it instead of faking success.
786
+ throw cause;
718
787
  }
719
788
  }
720
789
 
@@ -0,0 +1,183 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { createCipheriv, createDecipheriv, createHash, randomBytes, timingSafeEqual } from "node:crypto";
3
+ import { existsSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
4
+ import { join, resolve } from "node:path";
5
+ import { FileCredentialStore, CredentialStorageError, type CredentialCodec } from "./oauth-session.js";
6
+
7
+ export interface NativeCommandResult { status: number | null; stdout: string; stderr: string; error?: unknown }
8
+ export type NativeCommand = (executable: string, args: string[], input?: string) => NativeCommandResult;
9
+ export interface CredentialKeyStore {
10
+ readonly name: string;
11
+ read(): Buffer | null;
12
+ write(key: Buffer): void;
13
+ /** Used only when explicitly removing this wrapping key, not ordinary logout. */
14
+ remove(): void;
15
+ }
16
+
17
+ const nativeCommand: NativeCommand = (executable, args, input) => {
18
+ const result = spawnSync(executable, args, { input, encoding: "utf8", timeout: 15_000, maxBuffer: 64 * 1024, windowsHide: true, shell: false });
19
+ return { status: result.status, stdout: result.stdout ?? "", stderr: result.stderr ?? "", ...(result.error ? { error: result.error } : {}) };
20
+ };
21
+
22
+ function unavailable(name: string, environmentName: string): never {
23
+ throw new CredentialStorageError("Cannot access " + name + ". Unlock or enable your OS credential store and retry. For a headless session, supply credentials through environment variables. Plaintext storage requires explicitly setting " + environmentName + "=file.");
24
+ }
25
+ function parseKey(value: string): Buffer {
26
+ const encoded = value.trim();
27
+ if (!/^[A-Za-z0-9+/]{43}=$/.test(encoded)) throw new CredentialStorageError("The OS credential key is invalid. Restore access to the original key before using the saved session.");
28
+ const key = Buffer.from(encoded, "base64");
29
+ if (key.length !== 32) throw new CredentialStorageError("The OS credential key is invalid.");
30
+ return key;
31
+ }
32
+
33
+ /** A small wrapping key avoids OS item-size limits; session files are encrypted
34
+ * separately so refresh rotation can retain the atomic file/lock transaction. */
35
+ export function nativeCredentialKeyStore(path: string, platform: NodeJS.Platform = process.platform, run: NativeCommand = nativeCommand, environmentName = "CREDENTIAL_STORE"): CredentialKeyStore {
36
+ const failed = (name: string): never => unavailable(name, environmentName);
37
+ const identity = createHash("sha256").update(resolve(path)).digest("hex");
38
+ const service = "typeship.credentials." + identity;
39
+ const account = "session-key";
40
+ if (platform === "darwin") {
41
+ const name = "macOS Keychain";
42
+ const lookup = () => run("/usr/bin/security", ["find-generic-password", "-s", service, "-a", account, "-w"]);
43
+ return {
44
+ name,
45
+ read() {
46
+ const result = lookup();
47
+ if (!result.error && result.status === 44) return null; // errSecItemNotFound
48
+ if (result.error || result.status !== 0) return failed(name);
49
+ return parseKey(result.stdout);
50
+ },
51
+ write(key) {
52
+ // security's interactive command stream keeps the key off argv. All
53
+ // tokens below have a fixed safe alphabet; no shell or user text is run.
54
+ const input = "add-generic-password -U -s " + service + " -a " + account + " -w " + key.toString("base64") + "\n";
55
+ const result = run("/usr/bin/security", ["-i"], input);
56
+ if (result.error || result.status !== 0) return failed(name);
57
+ // Verify the write: an interactive tool's process status alone is not
58
+ // sufficient evidence that its command stored the requested item.
59
+ const saved = this.read();
60
+ if (!saved || !timingSafeEqual(saved, key)) return failed(name);
61
+ },
62
+ remove() {
63
+ const result = run("/usr/bin/security", ["delete-generic-password", "-s", service, "-a", account]);
64
+ if (result.error || (result.status !== 0 && result.status !== 44)) failed(name);
65
+ },
66
+ };
67
+ }
68
+ if (platform === "linux") {
69
+ const name = "Linux Secret Service (secret-tool)";
70
+ const attributes = ["service", service, "account", account];
71
+ return {
72
+ name,
73
+ read() {
74
+ const result = run("/usr/bin/secret-tool", ["lookup", ...attributes]);
75
+ if (!result.error && result.status === 1 && !result.stderr.trim()) return null;
76
+ if (result.error || result.status !== 0) return failed(name);
77
+ return parseKey(result.stdout);
78
+ },
79
+ write(key) {
80
+ const result = run("/usr/bin/secret-tool", ["store", "--label=CLI session encryption key", ...attributes], key.toString("base64"));
81
+ if (result.error || result.status !== 0) return failed(name);
82
+ const saved = this.read();
83
+ if (!saved || !timingSafeEqual(saved, key)) return failed(name);
84
+ },
85
+ remove() {
86
+ const result = run("/usr/bin/secret-tool", ["clear", ...attributes]);
87
+ if (result.error || result.status !== 0) failed(name);
88
+ },
89
+ };
90
+ }
91
+ if (platform === "win32") {
92
+ const name = "Windows DPAPI (current user)";
93
+ const keyPath = path + ".key";
94
+ const executable = join(process.env.SystemRoot ?? "C:\\Windows", "System32", "WindowsPowerShell", "v1.0", "powershell.exe");
95
+ const script = `
96
+ $ErrorActionPreference = 'Stop'
97
+ try {
98
+ Add-Type -AssemblyName System.Security
99
+ $payload = [Console]::In.ReadToEnd() | ConvertFrom-Json
100
+ $bytes = [Convert]::FromBase64String([string]$payload.data)
101
+ $entropy = [Text.Encoding]::UTF8.GetBytes([string]$payload.service)
102
+ $scope = [System.Security.Cryptography.DataProtectionScope]::CurrentUser
103
+ if ($payload.action -eq 'protect') { $result = [System.Security.Cryptography.ProtectedData]::Protect($bytes, $entropy, $scope) }
104
+ elseif ($payload.action -eq 'unprotect') { $result = [System.Security.Cryptography.ProtectedData]::Unprotect($bytes, $entropy, $scope) }
105
+ else { exit 1 }
106
+ [Console]::Out.Write([Convert]::ToBase64String($result))
107
+ exit 0
108
+ } catch { exit 1 }
109
+ `;
110
+ const crypt = (action: "protect" | "unprotect", data: string) => {
111
+ // EncodedCommand contains fixed code only. Sensitive data goes over stdin.
112
+ const result = run(executable, ["-NoProfile", "-NonInteractive", "-EncodedCommand", Buffer.from(script, "utf16le").toString("base64")], JSON.stringify({ action, data, service }));
113
+ if (result.error || result.status !== 0 || !result.stdout.trim()) return failed(name);
114
+ return result.stdout.trim();
115
+ };
116
+ return {
117
+ name,
118
+ read() {
119
+ let wrapped: string;
120
+ try { wrapped = readFileSync(keyPath, "utf8"); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; return failed(name); }
121
+ return parseKey(crypt("unprotect", wrapped));
122
+ },
123
+ write(key) {
124
+ const wrapped = crypt("protect", key.toString("base64"));
125
+ const temporary = keyPath + "." + randomBytes(16).toString("hex") + ".tmp";
126
+ try {
127
+ writeFileSync(temporary, wrapped, { mode: 0o600, flag: "wx", flush: true });
128
+ renameSync(temporary, keyPath);
129
+ } finally { rmSync(temporary, { force: true }); }
130
+ const saved = this.read();
131
+ if (!saved || !timingSafeEqual(saved, key)) return failed(name);
132
+ },
133
+ remove() { rmSync(keyPath, { force: true }); },
134
+ };
135
+ }
136
+ return { name: "OS credential storage", read: () => failed("OS credential storage on this platform"), write: () => failed("OS credential storage on this platform"), remove: () => failed("OS credential storage on this platform") };
137
+ }
138
+
139
+ export function encryptedCredentialCodec(path: string, keyStore: CredentialKeyStore): CredentialCodec {
140
+ const associatedData = Buffer.from("cli-credentials-v1:" + resolve(path));
141
+ const key = (create: boolean) => {
142
+ const saved = keyStore.read();
143
+ if (saved) return saved;
144
+ if (!create || existsSync(path)) throw new CredentialStorageError("The encryption key for this saved session is missing. Restore the original OS credential store, or run logout --local to remove the saved encrypted session before logging in again.");
145
+ const generated = randomBytes(32);
146
+ keyStore.write(generated);
147
+ return generated;
148
+ };
149
+ return {
150
+ name: keyStore.name,
151
+ prepare() { key(true); },
152
+ encode(plaintext) {
153
+ const nonce = randomBytes(12);
154
+ const cipher = createCipheriv("aes-256-gcm", key(true), nonce);
155
+ cipher.setAAD(associatedData);
156
+ const ciphertext = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
157
+ return JSON.stringify({ version: 1, algorithm: "aes-256-gcm", nonce: nonce.toString("base64"), tag: cipher.getAuthTag().toString("base64"), ciphertext: ciphertext.toString("base64") });
158
+ },
159
+ decode(encoded) {
160
+ let envelope: { version?: unknown; algorithm?: unknown; nonce?: unknown; tag?: unknown; ciphertext?: unknown };
161
+ try { envelope = JSON.parse(encoded); } catch { throw new CredentialStorageError("Saved encrypted credentials are damaged. Restore the credential file before logging in again."); }
162
+ if (!envelope || envelope.version !== 1 || envelope.algorithm !== "aes-256-gcm" || typeof envelope.nonce !== "string" || typeof envelope.tag !== "string" || typeof envelope.ciphertext !== "string") throw new CredentialStorageError("Saved encrypted credentials use an invalid format.");
163
+ const secret = key(false);
164
+ try {
165
+ const nonce = Buffer.from(envelope.nonce, "base64"), tag = Buffer.from(envelope.tag, "base64");
166
+ if (nonce.length !== 12 || tag.length !== 16) throw new Error();
167
+ const decipher = createDecipheriv("aes-256-gcm", secret, nonce);
168
+ decipher.setAAD(associatedData); decipher.setAuthTag(tag);
169
+ return Buffer.concat([decipher.update(Buffer.from(envelope.ciphertext, "base64")), decipher.final()]).toString("utf8");
170
+ } catch { throw new CredentialStorageError("Saved credentials could not be authenticated. Restore the original credential file and OS key; do not reuse this session."); }
171
+ },
172
+ };
173
+ }
174
+
175
+ /** OS protection is the default. Plaintext storage is an explicit, separate
176
+ * store for environments where the owner accepts that tradeoff. Never migrate
177
+ * or fall back silently when an OS service is unavailable. */
178
+ export function createCredentialStore(directory: string, mode: string = "os", environmentName = "CREDENTIAL_STORE"): FileCredentialStore {
179
+ if (mode === "file") return new FileCredentialStore(join(directory, "credentials.json"));
180
+ if (mode !== "os") throw new CredentialStorageError(environmentName + " must be os or file.");
181
+ const path = join(directory, "credentials.enc");
182
+ return new FileCredentialStore(path, 40_000, encryptedCredentialCodec(path, nativeCredentialKeyStore(path, process.platform, nativeCommand, environmentName)));
183
+ }