@typeship-ax/mcp 0.8.0 → 0.10.0
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/AGENTS.md +31 -0
- package/README.md +66 -9
- package/api.json +3153 -761
- package/api.md +9796 -382
- package/dist/api-identity.d.ts +40 -0
- package/dist/api-identity.d.ts.map +1 -0
- package/dist/api-identity.js +128 -0
- package/dist/auth-profiles.d.ts +30 -0
- package/dist/auth-profiles.d.ts.map +1 -0
- package/dist/auth-profiles.js +138 -0
- package/dist/core/http.d.ts +17 -2
- package/dist/core/http.d.ts.map +1 -1
- package/dist/core/http.js +78 -17
- package/dist/credential-storage.d.ts +24 -0
- package/dist/credential-storage.d.ts.map +1 -0
- package/dist/credential-storage.js +207 -0
- package/dist/docs.d.ts +25 -0
- package/dist/docs.d.ts.map +1 -1
- package/dist/docs.js +144 -0
- package/dist/errors.d.ts +18 -10
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +24 -14
- package/dist/index.d.ts +10 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +20 -4
- package/dist/mcp-authorization.d.ts +52 -0
- package/dist/mcp-authorization.d.ts.map +1 -0
- package/dist/mcp-authorization.js +232 -0
- package/dist/mcp-protocol.d.ts +51 -2
- package/dist/mcp-protocol.d.ts.map +1 -1
- package/dist/mcp-protocol.js +249 -37
- package/dist/mcp.d.ts +21 -3
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +185 -68
- package/dist/named-credentials.d.ts +21 -0
- package/dist/named-credentials.d.ts.map +1 -0
- package/dist/named-credentials.js +86 -0
- package/dist/oauth-request.d.ts +21 -0
- package/dist/oauth-request.d.ts.map +1 -0
- package/dist/oauth-request.js +119 -0
- package/dist/oauth-session.d.ts +106 -0
- package/dist/oauth-session.d.ts.map +1 -0
- package/dist/oauth-session.js +244 -0
- package/dist/ops.d.ts +14 -1
- package/dist/ops.d.ts.map +1 -1
- package/dist/ops.js +34 -30
- package/dist/resources/account.d.ts +2 -2
- package/dist/resources/account.d.ts.map +1 -1
- package/dist/resources/account.js +1 -0
- package/dist/resources/api-keys.d.ts +3 -3
- package/dist/resources/api-keys.d.ts.map +1 -1
- package/dist/resources/api-keys.js +2 -0
- package/dist/resources/definition-revisions.d.ts +5 -5
- package/dist/resources/definition-revisions.d.ts.map +1 -1
- package/dist/resources/definition-revisions.js +4 -0
- package/dist/resources/definitions.d.ts +15 -4
- package/dist/resources/definitions.d.ts.map +1 -1
- package/dist/resources/definitions.js +11 -2
- package/dist/resources/generate.d.ts +14 -3
- package/dist/resources/generate.d.ts.map +1 -1
- package/dist/resources/generate.js +10 -2
- package/dist/resources/generations.d.ts +3 -3
- package/dist/resources/generations.d.ts.map +1 -1
- package/dist/resources/generations.js +2 -0
- package/dist/resources/projects.d.ts +56 -20
- package/dist/resources/projects.d.ts.map +1 -1
- package/dist/resources/projects.js +40 -4
- package/dist/resources/targets.d.ts +84 -10
- package/dist/resources/targets.d.ts.map +1 -1
- package/dist/resources/targets.js +127 -2
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +55 -26
- package/dist/types.d.ts +602 -123
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +24 -0
- package/dist/worker.js +4 -4
- package/package.json +11 -1
- package/server.json +42 -0
- package/src/api-identity.ts +98 -0
- package/src/auth-profiles.ts +114 -0
- package/src/core/http.ts +88 -19
- package/src/credential-storage.ts +183 -0
- package/src/docs.ts +138 -0
- package/src/errors.ts +26 -15
- package/src/index.ts +29 -4
- package/src/mcp-authorization.ts +211 -0
- package/src/mcp-protocol.ts +287 -38
- package/src/mcp.ts +186 -72
- package/src/named-credentials.ts +74 -0
- package/src/oauth-request.ts +90 -0
- package/src/oauth-session.ts +258 -0
- package/src/ops.ts +48 -31
- package/src/resources/account.ts +3 -0
- package/src/resources/api-keys.ts +5 -0
- package/src/resources/definition-revisions.ts +9 -0
- package/src/resources/definitions.ts +25 -0
- package/src/resources/generate.ts +23 -0
- package/src/resources/generations.ts +5 -0
- package/src/resources/projects.ts +95 -7
- package/src/resources/targets.ts +241 -0
- package/src/schemas.ts +55 -26
- package/src/types.ts +640 -123
- package/src/worker.ts +4 -4
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
|
|
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
|
|
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 =
|
|
380
|
-
|
|
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
|
-
|
|
453
|
-
|
|
454
|
-
|
|
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 (
|
|
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
|
-
|
|
715
|
-
//
|
|
716
|
-
|
|
717
|
-
|
|
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
|
+
}
|
package/src/docs.ts
CHANGED
|
@@ -55,6 +55,144 @@ function safeHttpUrl(value: string | null): string | null {
|
|
|
55
55
|
}
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
export interface GuideMatch {
|
|
59
|
+
title: string;
|
|
60
|
+
section: string | null;
|
|
61
|
+
/** Kept for callers that displayed the original heading-only results. */
|
|
62
|
+
heading: string;
|
|
63
|
+
excerpt: string;
|
|
64
|
+
url: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
interface GuidePage { title: string; url: string; description: string }
|
|
68
|
+
|
|
69
|
+
/** Links in an index follow URL semantics, including site-root relative links. */
|
|
70
|
+
function docsLink(base: string | null, indexUrl: string | null, value: string): string | null {
|
|
71
|
+
const index = resolveDocsContentUrl(base, indexUrl, "llms.txt");
|
|
72
|
+
if (!index) return null;
|
|
73
|
+
try { return resolveDocsContentUrl(base, indexUrl, new URL(value, index).toString()); }
|
|
74
|
+
catch { return null; }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function docsIndexPages(index: string | null, base: string | null, indexUrl: string | null): GuidePage[] {
|
|
78
|
+
const pages = new Map<string, GuidePage>();
|
|
79
|
+
for (const line of (index ?? "").split("\n")) {
|
|
80
|
+
for (const match of line.matchAll(/\[([^\]]+)\]\(([^\s)]+)\)/g)) {
|
|
81
|
+
const url = docsLink(base, indexUrl, match[2]!);
|
|
82
|
+
if (!url || /\/llms(?:-full)?\.txt(?:[?#]|$)/.test(url)) continue;
|
|
83
|
+
const description = line.slice(match.index! + match[0].length).replace(/^\s*:\s*/, "").trim();
|
|
84
|
+
if (!pages.has(url)) pages.set(url, { title: match[1]!, url, description: cleanDocsText(description) });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return [...pages.values()];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function cleanDocsText(text: string): string {
|
|
91
|
+
return text.replace(/^\s*(?:>\s*)+/, "").replace(/!?\[([^\]]+)\]\([^)]*\)/g, "$1").replace(/<[^>]*>/g, "").replace(/[*`]/g, "").replace(/^\s*[-*>]\s*/, "").replace(/\s+/g, " ").trim();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function docsTerms(query: string): string[] {
|
|
95
|
+
return [...new Set(query.replace(/([a-z])([A-Z])/g, "$1 $2").toLowerCase().split(/[^a-z0-9]+/).filter((term) => term.length >= 2))];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Rank sections, then keep the strongest section of each real page. A heading
|
|
99
|
+
* match must have content: blank lines, fences, and source markers are not hits. */
|
|
100
|
+
export function searchGuidePages(
|
|
101
|
+
prose: string | null, index: string | null, base: string | null, indexUrl: string | null, query: string,
|
|
102
|
+
): GuideMatch[] {
|
|
103
|
+
const pages = docsIndexPages(index, base, indexUrl);
|
|
104
|
+
const terms = docsTerms(query);
|
|
105
|
+
if (terms.length === 0) return [];
|
|
106
|
+
const phrase = query.trim().toLowerCase();
|
|
107
|
+
const candidates: { match: GuideMatch; score: number }[] = [];
|
|
108
|
+
const add = (title: string, section: string | null, url: string | null, lines: string[]) => {
|
|
109
|
+
if (!url) return;
|
|
110
|
+
const content = lines.map(cleanDocsText).filter((line) => line.length > 0);
|
|
111
|
+
if (content.length === 0) return;
|
|
112
|
+
const heading = (title + " " + (section ?? "")).toLowerCase();
|
|
113
|
+
const body = content.join(" ").toLowerCase();
|
|
114
|
+
const matched = terms.filter((term) => heading.includes(term) || body.includes(term));
|
|
115
|
+
if (matched.length === 0) return;
|
|
116
|
+
const score = matched.length * 10 + (matched.length === terms.length ? 50 : 0)
|
|
117
|
+
+ (heading.includes(phrase) ? 35 : body.includes(phrase) ? 25 : 0)
|
|
118
|
+
+ (title.toLowerCase().includes(phrase) ? 25 : 0)
|
|
119
|
+
+ terms.filter((term) => title.toLowerCase().includes(term)).length * 5
|
|
120
|
+
+ terms.filter((term) => heading.includes(term)).length * 5
|
|
121
|
+
+ terms.filter((term) => section?.toLowerCase().includes(term)).length * 3;
|
|
122
|
+
const excerpt = [...content].sort((a, b) => {
|
|
123
|
+
const rank = (line: string) => terms.filter((term) => line.toLowerCase().includes(term)).length;
|
|
124
|
+
return rank(b) - rank(a);
|
|
125
|
+
})[0]!.slice(0, 240);
|
|
126
|
+
candidates.push({ match: { title, section, heading: section ?? title, excerpt, url }, score });
|
|
127
|
+
};
|
|
128
|
+
let title = "";
|
|
129
|
+
let section: string | null = null;
|
|
130
|
+
let url: string | null = null;
|
|
131
|
+
let lines: string[] = [];
|
|
132
|
+
let fence: string | null = null;
|
|
133
|
+
const flush = () => { add(title, section, url, lines); lines = []; };
|
|
134
|
+
for (const line of (prose ?? "").split("\n")) {
|
|
135
|
+
const fenced = line.trim().match(/^(`{3,}|~{3,})/);
|
|
136
|
+
if (fenced) { if (fence === null) fence = fenced[1]![0]!; else if (fenced[1]![0] === fence) fence = null; continue; }
|
|
137
|
+
if (fence !== null) { if (line.trim()) lines.push(line); continue; }
|
|
138
|
+
const heading = line.match(/^(#{1,6})\s+(.+?)\s*#*$/);
|
|
139
|
+
if (heading) {
|
|
140
|
+
flush();
|
|
141
|
+
if (heading[1] === "#") {
|
|
142
|
+
title = cleanDocsText(heading[2]!);
|
|
143
|
+
section = null;
|
|
144
|
+
const link = heading[2]!.match(/\[[^\]]+\]\(([^\s)]+)\)/);
|
|
145
|
+
url = link ? docsLink(base, indexUrl, link[1]!) : pages.find((page) => page.title.toLowerCase() === title.toLowerCase())?.url ?? null;
|
|
146
|
+
} else section = cleanDocsText(heading[2]!);
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
const source = line.match(/^Source:\s*(?:\[[^\]]*\]\()?<?(https?:\/\/[^\s)>]+)>?\)?\s*$/i);
|
|
150
|
+
if (source) {
|
|
151
|
+
url = docsLink(base, indexUrl, source[1]!);
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
// Markdown callouts are blockquotes; keep their text for search and excerpts.
|
|
155
|
+
if (!line.trim() || /^\s*(?:---+|\|[\s:|-]+\|)\s*$/.test(line) || /^\s*</.test(line)) continue;
|
|
156
|
+
lines.push(line);
|
|
157
|
+
}
|
|
158
|
+
flush();
|
|
159
|
+
// An index is useful without llms-full.txt, and may include additional pages.
|
|
160
|
+
const described = new Set(candidates.map(({ match }) => match.url.replace(/\.md(?=[?#]|$)/, "")));
|
|
161
|
+
for (const page of pages) {
|
|
162
|
+
if (!described.has(page.url.replace(/\.md(?=[?#]|$)/, ""))) add(page.title, null, page.url, [page.description || page.title]);
|
|
163
|
+
}
|
|
164
|
+
candidates.sort((a, b) => b.score - a.score || a.match.title.localeCompare(b.match.title) || a.match.url.localeCompare(b.match.url));
|
|
165
|
+
const distinct = new Map<string, GuideMatch>();
|
|
166
|
+
for (const { match } of candidates) {
|
|
167
|
+
const key = match.url.replace(/\.md(?=[?#]|$)/, "");
|
|
168
|
+
if (!distinct.has(key)) distinct.set(key, match);
|
|
169
|
+
}
|
|
170
|
+
return [...distinct.values()];
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export async function searchConnectedGuides(
|
|
174
|
+
base: string | null, indexUrl: string | null, fetchText: (path: string) => Promise<string | null>, query: string,
|
|
175
|
+
): Promise<{ guides: GuideMatch[]; status: "not_configured" | "unavailable" | "ok" }> {
|
|
176
|
+
if (!base && !indexUrl) return { guides: [], status: "not_configured" };
|
|
177
|
+
const [index, prose] = await Promise.all([fetchText("llms.txt"), fetchText("llms-full.txt")]);
|
|
178
|
+
return { guides: searchGuidePages(prose, index, base, indexUrl, query), status: index === null && prose === null ? "unavailable" : "ok" };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function docsReadTarget(index: string | null, base: string | null, indexUrl: string | null, page: string): string {
|
|
182
|
+
if (/^https?:\/\//.test(page)) return page;
|
|
183
|
+
const pages = docsIndexPages(index, base, indexUrl);
|
|
184
|
+
const term = page.toLowerCase();
|
|
185
|
+
const exact = pages.find((item) => item.title.toLowerCase() === term || new URL(item.url).pathname.toLowerCase() === term);
|
|
186
|
+
if (exact) return exact.url;
|
|
187
|
+
const matches = pages.filter((item) => item.url.toLowerCase().includes(term));
|
|
188
|
+
return matches.length === 1 ? matches[0]!.url : page;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** One shell argument, including URLs with quotes, query strings, or fragments. */
|
|
192
|
+
export function docsReadCommand(bin: string, url: string): string {
|
|
193
|
+
return bin + " docs read '" + url.replace(/'/g, "'\\''") + "'";
|
|
194
|
+
}
|
|
195
|
+
|
|
58
196
|
/** Markdown-preferred fetch with same-origin redirects, one deadline, and a
|
|
59
197
|
* streaming byte cap. Returns null for every invalid or failed read. */
|
|
60
198
|
export async function fetchDocsText(url: string): Promise<string | null> {
|
package/src/errors.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import { ApiError, type ResponseMeta } from "./core/http.js";
|
|
5
5
|
import type { ErrorModel, ErrorModelRead } from "./types.js";
|
|
6
6
|
|
|
7
|
-
export { ApiError, TransportError, UnexpectedApiError, ValidationError, type Violation, unwrap } from "./core/http.js";
|
|
7
|
+
export { ApiError, ResponseParseError, TransportError, UnexpectedApiError, ValidationError, type Violation, unwrap } from "./core/http.js";
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* The request body, Definition source, target selection, or package name is invalid.
|
|
@@ -36,6 +36,16 @@ export class ForbiddenError extends ApiError<403, ErrorModelRead> {
|
|
|
36
36
|
}
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
/**
|
|
40
|
+
* The key identifies changed intent.
|
|
41
|
+
* Raised for HTTP 409 responses.
|
|
42
|
+
*/
|
|
43
|
+
export class ConflictError extends ApiError<409, ErrorModelRead> {
|
|
44
|
+
constructor(body: ErrorModelRead, response: ResponseMeta) {
|
|
45
|
+
super("The key identifies changed intent.", 409, body, response);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
39
49
|
/**
|
|
40
50
|
* Spec exceeds the 10MB limit.
|
|
41
51
|
* Raised for HTTP 413 responses.
|
|
@@ -57,12 +67,13 @@ export class UnprocessableEntityError extends ApiError<422, ErrorModelRead> {
|
|
|
57
67
|
}
|
|
58
68
|
|
|
59
69
|
/**
|
|
60
|
-
* Too many requests. Wait for Retry-After before
|
|
70
|
+
* Too many requests, or an identical write is still in progress. Wait for Retry-After before
|
|
71
|
+
* retrying.
|
|
61
72
|
* Raised for HTTP 429 responses.
|
|
62
73
|
*/
|
|
63
74
|
export class RateLimitedError extends ApiError<429, ErrorModelRead> {
|
|
64
75
|
constructor(body: ErrorModelRead, response: ResponseMeta) {
|
|
65
|
-
super("Too many requests. Wait for Retry-After before retrying.", 429, body, response);
|
|
76
|
+
super("Too many requests, or an identical write is still in progress. Wait for Retry-After before retrying.", 429, body, response);
|
|
66
77
|
}
|
|
67
78
|
}
|
|
68
79
|
|
|
@@ -87,22 +98,12 @@ export class PaymentRequiredError extends ApiError<402, ErrorModelRead> {
|
|
|
87
98
|
}
|
|
88
99
|
|
|
89
100
|
/**
|
|
90
|
-
*
|
|
91
|
-
* Raised for HTTP 409 responses.
|
|
92
|
-
*/
|
|
93
|
-
export class ConflictError extends ApiError<409, ErrorModelRead> {
|
|
94
|
-
constructor(body: ErrorModelRead, response: ResponseMeta) {
|
|
95
|
-
super("The Idempotency-Key was already used with different request parameters.", 409, body, response);
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
/**
|
|
100
|
-
* The original idempotent request is temporarily unavailable.
|
|
101
|
+
* Project setup failed unexpectedly; the key reservation is released.
|
|
101
102
|
* Raised for HTTP 500 responses.
|
|
102
103
|
*/
|
|
103
104
|
export class InternalServerError extends ApiError<500, ErrorModelRead> {
|
|
104
105
|
constructor(body: ErrorModelRead, response: ResponseMeta) {
|
|
105
|
-
super("
|
|
106
|
+
super("Project setup failed unexpectedly; the key reservation is released.", 500, body, response);
|
|
106
107
|
}
|
|
107
108
|
}
|
|
108
109
|
|
|
@@ -115,3 +116,13 @@ export class NotFoundError extends ApiError<404, ErrorModelRead> {
|
|
|
115
116
|
super("No such resource in this account.", 404, body, response);
|
|
116
117
|
}
|
|
117
118
|
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The Project was saved, but an obsolete release pull request could not be retired.
|
|
122
|
+
* Raised for HTTP 502 responses.
|
|
123
|
+
*/
|
|
124
|
+
export class BadGatewayError extends ApiError<502, ErrorModelRead> {
|
|
125
|
+
constructor(body: ErrorModelRead, response: ResponseMeta) {
|
|
126
|
+
super("The Project was saved, but an obsolete release pull request could not be retired.", 502, body, response);
|
|
127
|
+
}
|
|
128
|
+
}
|