@typeship-ax/cli 0.6.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.
- package/AGENTS.md +31 -0
- package/README.md +26 -9
- package/api.json +6861 -3083
- package/api.md +508 -277
- 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/cli-agent.d.ts +9 -1
- package/dist/cli-agent.d.ts.map +1 -1
- package/dist/cli-agent.js +26 -9
- package/dist/cli.js +600 -582
- package/dist/console-login-check.d.ts +21 -0
- package/dist/console-login-check.d.ts.map +1 -0
- package/dist/console-login-check.js +107 -0
- package/dist/console-login-contract.d.ts +45 -0
- package/dist/console-login-contract.d.ts.map +1 -0
- package/dist/console-login-contract.js +40 -0
- package/dist/core/http.d.ts +21 -92
- package/dist/core/http.d.ts.map +1 -1
- package/dist/core/http.js +143 -221
- package/dist/core/pagination.d.ts.map +1 -1
- package/dist/core/pagination.js +6 -34
- 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/dates.d.ts +0 -2
- package/dist/dates.d.ts.map +1 -1
- package/dist/dates.js +0 -1
- package/dist/docs.d.ts +36 -0
- package/dist/docs.d.ts.map +1 -0
- package/dist/docs.js +258 -0
- package/dist/errors.d.ts +42 -34
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +30 -20
- package/dist/index.d.ts +27 -12
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +40 -14
- 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-login.d.ts +39 -0
- package/dist/oauth-login.d.ts.map +1 -0
- package/dist/oauth-login.js +171 -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 +18 -0
- package/dist/ops.d.ts.map +1 -1
- package/dist/ops.js +31 -17
- package/dist/polling-login.d.ts +57 -0
- package/dist/polling-login.d.ts.map +1 -0
- package/dist/polling-login.js +204 -0
- package/dist/resources/account.d.ts +4 -4
- package/dist/resources/account.d.ts.map +1 -1
- package/dist/resources/account.js +1 -0
- package/dist/resources/api-keys.d.ts +13 -8
- package/dist/resources/api-keys.d.ts.map +1 -1
- package/dist/resources/api-keys.js +5 -1
- package/dist/resources/definition-revisions.d.ts +58 -0
- package/dist/resources/definition-revisions.d.ts.map +1 -0
- package/dist/resources/definition-revisions.js +114 -0
- package/dist/resources/definitions.d.ts +35 -0
- package/dist/resources/definitions.d.ts.map +1 -0
- package/dist/resources/definitions.js +60 -0
- package/dist/resources/generate.d.ts +18 -7
- package/dist/resources/generate.d.ts.map +1 -1
- package/dist/resources/generate.js +13 -5
- package/dist/resources/generations.d.ts +6 -6
- package/dist/resources/generations.d.ts.map +1 -1
- package/dist/resources/generations.js +3 -1
- package/dist/resources/projects.d.ts +111 -35
- package/dist/resources/projects.d.ts.map +1 -1
- package/dist/resources/projects.js +125 -15
- package/dist/resources/targets.d.ts +97 -0
- package/dist/resources/targets.d.ts.map +1 -0
- package/dist/resources/targets.js +197 -0
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +135 -62
- package/dist/types.d.ts +2072 -267
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +20 -3
- package/package.json +2 -1
- package/src/api-identity.ts +98 -0
- package/src/auth-profiles.ts +114 -0
- package/src/cli-agent.ts +28 -11
- package/src/cli.ts +528 -560
- package/src/console-login-check.ts +88 -0
- package/src/console-login-contract.ts +65 -0
- package/src/core/http.ts +156 -305
- package/src/core/pagination.ts +6 -30
- package/src/credential-storage.ts +183 -0
- package/src/dates.ts +0 -1
- package/src/docs.ts +239 -0
- package/src/errors.ts +52 -41
- package/src/index.ts +49 -14
- package/src/named-credentials.ts +74 -0
- package/src/oauth-login.ts +184 -0
- package/src/oauth-request.ts +90 -0
- package/src/oauth-session.ts +258 -0
- package/src/ops.ts +56 -17
- package/src/polling-login.ts +165 -0
- package/src/resources/account.ts +6 -3
- package/src/resources/api-keys.ts +27 -7
- package/src/resources/definition-revisions.ts +207 -0
- package/src/resources/definitions.ts +122 -0
- package/src/resources/generate.ts +29 -6
- package/src/resources/generations.ts +9 -4
- package/src/resources/projects.ts +274 -41
- package/src/resources/targets.ts +378 -0
- package/src/schemas.ts +135 -62
- package/src/types.ts +2273 -322
- package/dist/resources/spec-revisions.d.ts +0 -47
- package/dist/resources/spec-revisions.d.ts.map +0 -1
- package/dist/resources/spec-revisions.js +0 -90
- package/src/resources/spec-revisions.ts +0 -150
package/src/cli.ts
CHANGED
|
@@ -6,39 +6,65 @@
|
|
|
6
6
|
// Exit codes: 0 success, 1 API/transport error, 2 usage error.
|
|
7
7
|
|
|
8
8
|
import { spawnSync } from "node:child_process";
|
|
9
|
-
import {
|
|
9
|
+
import { oauthBrowserLogin, type OAuthLoginSession } from "./oauth-login.js";
|
|
10
|
+
import { oauthDeviceLogin, customBrowserApproval, loginEndpoint, type ApprovedCredential } from "./polling-login.js";
|
|
11
|
+
import { oauthStatusRequest } from "./oauth-request.js";
|
|
12
|
+
import { checkConsoleBrowserLogin } from "./console-login-check.js";
|
|
13
|
+
import { type FileCredentialStore, assertCredentialDestination, assertStoredIdentity, credentialIdentityBinding, type CredentialDestination, oauthSessionToken, sessionBinding, type SessionConfiguration, type StoredCredentials as StoredCreds } from "./oauth-session.js";
|
|
14
|
+
import { createCredentialStore } from "./credential-storage.js";
|
|
15
|
+
import { identityPolicyOf, identityFetch, identityResult, verifyApiIdentity, verifyClientIdentity, readApiIdentity, assertApiIdentity, type ApiIdentity, type IdentityConfiguration, type IdentityPolicy, type VerifiedIdentity } from "./api-identity.js";
|
|
16
|
+
import { parseNamedCredentials, readNamedCredentialsFile, resolveNamedCredentials, namedCredentialAvailability, type NamedCredentials, type CredentialSchemes } from "./named-credentials.js";
|
|
17
|
+
import { resolveProfile, listProfiles, selectProfile, removeProfile, readProfileConfig, updateProfileConfig, type ProfileContext } from "./auth-profiles.js";
|
|
18
|
+
import { randomBytes } from "node:crypto";
|
|
10
19
|
import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
11
20
|
import { homedir, hostname } from "node:os";
|
|
12
21
|
import { basename, dirname, join } from "node:path";
|
|
13
22
|
import { fileURLToPath } from "node:url";
|
|
14
|
-
import { TypeshipClient, formatDebugEvent, type DebugEvent } from "./index.js";
|
|
15
|
-
import { GLOBALS, OPS, buildArgs, findOp, missingRequired, type OpSpec, type ParamSpec } from "./ops.js";
|
|
23
|
+
import { TypeshipClient, formatDebugEvent, type ClientOptions, type DebugEvent } from "./index.js";
|
|
24
|
+
import { GLOBALS, OMITTED_OPS, OPS, buildArgs, findOp, missingRequired, type OmittedOpSpec, type OpSpec, type ParamSpec } from "./ops.js";
|
|
16
25
|
import {
|
|
17
26
|
MCP_CLIENTS, agentGuide, agentBlock, agentInstructionsFile, agentMode, bundleProperty, claimProperty, classifyApiError, collectionProperty, detectHarness, envelope,
|
|
18
27
|
exitCodeFor, findMcpClient, installSkills, mcpConfigured, pendingClaims, recordClaim, summarizeDoctor, upsertAgentBlock, writeBundle, writeMcpConfig,
|
|
19
28
|
type AgentContext, type CommandSummary, type DoctorCheck, type EnvelopeInput, type IssueCode, type McpEntry, type McpWriteResult,
|
|
20
29
|
} from "./cli-agent.js";
|
|
21
30
|
import { relativeDate } from "./dates.js";
|
|
31
|
+
import { docsReadCommand, docsReadTarget, fetchDocsText, resolveDocsContentUrl, searchConnectedGuides } from "./docs.js";
|
|
22
32
|
|
|
23
33
|
|
|
24
34
|
const BIN = "typeship";
|
|
25
35
|
const DEFAULT_BASE_URL = "https://typeship.dev/api/v1";
|
|
36
|
+
const NAMED_SCHEMES: CredentialSchemes = {"apiKey":{"kind":"bearer","options":["bearerToken"]}};
|
|
26
37
|
const AUTH_SCALARS: { option: string; flag: string; env: string }[] = [{"option":"bearerToken","flag":"token","env":"TYPESHIP_TOKEN"}];
|
|
27
38
|
const BASIC: { envUser: string; envPass: string } | null = null;
|
|
39
|
+
/** Operations omitted from the generated package by its plan cap. */
|
|
28
40
|
const EXCLUDED_OPS = 0;
|
|
29
|
-
|
|
30
|
-
const
|
|
41
|
+
/** Generated CLI operations that are intentionally unavailable to MCP. */
|
|
42
|
+
const MCP_EXCLUDED_OPS = 0;
|
|
43
|
+
const VERSION = "0.9.1";
|
|
44
|
+
const API_VERSION = "1.0.0";
|
|
31
45
|
const SPEC_FORMAT = "openapi";
|
|
46
|
+
const IDENTITY_POLICY: IdentityPolicy = {};
|
|
47
|
+
let LOGIN_IDENTITY: VerifiedIdentity | undefined;
|
|
32
48
|
const WHOAMI: { resource: string; method: string } | null = {"resource":"account","method":"retrieve"};
|
|
33
49
|
const ENVIRONMENTS: Record<string, string> = {};
|
|
34
50
|
const HAS_MCP = false;
|
|
35
51
|
const PKG_NAME = "@typeship-ax/cli";
|
|
36
52
|
const UPDATE_NOTICE = false;
|
|
37
|
-
const API_DESCRIPTION: string | null = "
|
|
53
|
+
const API_DESCRIPTION: string | null = "Resolve an OpenAPI or GraphQL Definition, diagnose it, and keep every\nselected SDK, CLI, and MCP Target current.\n\nEvery operation but one requires a bearer credential: an organization\nAPI key from the console, or an OAuth access token carrying the operation's\nread, generate, or write capability and the organization selected during\nconsent. OAuth grants cannot switch organizations after consent. A browser\nsession is not a credential for this API. The exception is POST /generate,\nwhich works anonymously with the free plan's limits.\n";
|
|
38
54
|
const DOCS_URL_DEFAULT: string | null = "https://typeship.dev";
|
|
55
|
+
const DOCS_INDEX_URL_DEFAULT: string | null = null;
|
|
39
56
|
const RELAY: { mintUrl: string; project: string } | null = null;
|
|
40
57
|
const SUPPORT_URL: string | null = null;
|
|
41
58
|
const OAUTH_TOKEN_URL: string | null = null;
|
|
59
|
+
const OAUTH_ISSUER: string | null = null;
|
|
60
|
+
const OAUTH_DISCOVERY_URL: string | undefined = undefined;
|
|
61
|
+
const OAUTH_DISCOVERY_URLS: string[] = [];
|
|
62
|
+
const OAUTH_DEVICE_URL: string | null = null;
|
|
63
|
+
const HAS_OAUTH_LOGIN = OAUTH_TOKEN_URL !== null || OAUTH_DISCOVERY_URLS.length > 0;
|
|
64
|
+
const OAUTH_LOGIN_METHOD: string = "device";
|
|
65
|
+
const OAUTH_REDIRECT_URI: string | undefined = undefined;
|
|
66
|
+
const OAUTH_ORGANIZATION_PARAMETER: "organization" | "organization_id" | undefined = undefined;
|
|
67
|
+
const OAUTH_AUTHORIZATION_URL: string | undefined = undefined;
|
|
42
68
|
const OAUTH_CLIENT_ID: string | null = null;
|
|
43
69
|
const OAUTH_SCOPES: string[] = [];
|
|
44
70
|
const OAUTH_TOKEN_PARAMS: Record<string, string> = {};
|
|
@@ -65,11 +91,12 @@ interface Parsed {
|
|
|
65
91
|
* is known. */
|
|
66
92
|
const CORE_BOOLEAN_FLAGS = new Set(["all", "version", "non-interactive", "debug", "validate", "yes", "force", "json"]);
|
|
67
93
|
const BUILTIN_BOOLEAN_FLAGS: Record<string, string[]> = {
|
|
68
|
-
login: ["with-token", "no-browser"],
|
|
94
|
+
login: ["with-token", "no-browser", "device"],
|
|
95
|
+
logout: ["local"],
|
|
69
96
|
upgrade: ["check"],
|
|
70
97
|
mcp: ["claude", "cursor", "claude-desktop", "codex", "vscode", "windsurf", "gemini", "opencode", "zed", "all", "read-only"],
|
|
71
98
|
docs: ["web", "schema"],
|
|
72
|
-
init: ["all", "yes", "no-skills", "no-mcp", "no-agents-md"],
|
|
99
|
+
init: ["all", "yes", "no-skills", "no-mcp", "no-agents-md", "no-browser"],
|
|
73
100
|
auth: ["live"],
|
|
74
101
|
doctor: [],
|
|
75
102
|
};
|
|
@@ -258,7 +285,7 @@ function humanError(body: ReturnType<typeof envelope>, code: number): string {
|
|
|
258
285
|
lines.push(paintErr("red", BIN + ":") + " " + (issue?.message ?? "failed"));
|
|
259
286
|
for (const extra of body.issues.slice(1)) lines.push(" " + extra.message);
|
|
260
287
|
for (const step of body.next_steps ?? []) lines.push(" " + step);
|
|
261
|
-
const detail = body.detail as { status?: number; body?: unknown; violations?: unknown } | undefined;
|
|
288
|
+
const detail = body.detail as { status?: number; body?: unknown; violations?: unknown; request_id?: string } | undefined;
|
|
262
289
|
if (detail?.violations !== undefined) {
|
|
263
290
|
for (const v of (detail.violations as { path?: string; message?: string }[]).slice(0, 8)) lines.push(" " + paintErr("dim", (v.path ? v.path + ": " : "") + (v.message ?? "")));
|
|
264
291
|
} else if (detail?.body !== undefined) {
|
|
@@ -267,7 +294,7 @@ function humanError(body: ReturnType<typeof envelope>, code: number): string {
|
|
|
267
294
|
const compact = typeof detail.body === "string" ? detail.body : JSON.stringify(detail.body);
|
|
268
295
|
const firstWords = (issue?.message ?? "").slice(0, 40);
|
|
269
296
|
if (compact && compact !== "{}" && !(firstWords && compact.includes(firstWords))) lines.push(" " + paintErr("dim", "API said: " + (compact.length > 300 ? compact.slice(0, 297) + "…" : compact)));
|
|
270
|
-
const requestId = (detail.body as { request_id?: unknown; requestId?: unknown } | null)?.request_id ?? (detail.body as { requestId?: unknown } | null)?.requestId;
|
|
297
|
+
const requestId = detail.request_id ?? (detail.body as { request_id?: unknown; requestId?: unknown } | null)?.request_id ?? (detail.body as { requestId?: unknown } | null)?.requestId;
|
|
271
298
|
if (typeof requestId === "string") lines.push(" " + paintErr("dim", "request id: " + requestId));
|
|
272
299
|
}
|
|
273
300
|
const tags = [issue?.code ?? "ERROR", ...(typeof detail?.status === "number" && detail.status > 0 ? ["HTTP " + detail.status] : []), "exit " + code, "pipe stderr or --mode agent for JSON"];
|
|
@@ -287,7 +314,7 @@ function fail(code: number, message: string, extra?: unknown, nextSteps?: string
|
|
|
287
314
|
: /^(Missing required|Expected \d+ argument)/.test(message) ? "MISSING_ARGUMENT"
|
|
288
315
|
: "INVALID_USAGE";
|
|
289
316
|
return failWith({
|
|
290
|
-
code: code === 2 ? usageCode : "
|
|
317
|
+
code: code === 2 ? usageCode : "CALL_FAILED",
|
|
291
318
|
message,
|
|
292
319
|
...(extra !== undefined ? { detail: extra } : {}),
|
|
293
320
|
nextSteps: nextSteps ?? (code === 2 ? ["Run '" + USAGE_HINT + "' for the flags this command takes."] : []),
|
|
@@ -303,35 +330,84 @@ function failApi(error: unknown, hadCredential: boolean): never {
|
|
|
303
330
|
// credentials — written by `login`, cleared by `logout`
|
|
304
331
|
// ---------------------------------------------------------------------------
|
|
305
332
|
|
|
306
|
-
|
|
307
|
-
scalars?: Record<string, string>;
|
|
308
|
-
basic?: { username: string; password: string };
|
|
309
|
-
oauth?: { accessToken: string; refreshToken?: string; expiresAt?: number };
|
|
310
|
-
/** Set when this CLI minted the stored credential for itself (browser
|
|
311
|
-
* approval), so `logout` knows it may revoke it. A pasted key is not. */
|
|
312
|
-
minted?: { via: "browser"; key_name: string; org_id?: string };
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
function configDir(): string {
|
|
333
|
+
function configRoot(): string {
|
|
316
334
|
const base = process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config");
|
|
317
335
|
return join(base, BIN);
|
|
318
336
|
}
|
|
337
|
+
let PROFILE: ProfileContext;
|
|
338
|
+
function configDir(): string { return PROFILE.directory; }
|
|
319
339
|
|
|
320
340
|
function credsPath(): string {
|
|
321
|
-
return
|
|
341
|
+
return credentialStore().path;
|
|
322
342
|
}
|
|
323
343
|
|
|
324
|
-
function
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
344
|
+
function credentialStore(): FileCredentialStore { return createCredentialStore(configDir(), process.env["TYPESHIP_CREDENTIAL_STORE"], "TYPESHIP_CREDENTIAL_STORE"); }
|
|
345
|
+
function readCreds(): StoredCreds | null { return credentialStore().read(); }
|
|
346
|
+
function sessionConfiguration(baseUrl: string, config = readConfig()): SessionConfiguration {
|
|
347
|
+
return {
|
|
348
|
+
apiBaseUrl: baseUrl, environment: config.environment, profile: PROFILE.name,
|
|
349
|
+
identity: identityConfiguration(),
|
|
350
|
+
clientId: process.env["TYPESHIP_CLIENT_ID"] ?? OAUTH_CLIENT_ID,
|
|
351
|
+
issuer: OAUTH_ISSUER, discoveryUrl: OAUTH_DISCOVERY_URL, tokenUrl: OAUTH_TOKEN_URL,
|
|
352
|
+
authorizationUrl: OAUTH_AUTHORIZATION_URL, scopes: OAUTH_SCOPES,
|
|
353
|
+
audience: OAUTH_TOKEN_PARAMS.audience, resource: OAUTH_TOKEN_PARAMS.resource,
|
|
354
|
+
organizationParameter: OAUTH_ORGANIZATION_PARAMETER,
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function identityConfiguration(): IdentityConfiguration | undefined {
|
|
359
|
+
return Object.keys(IDENTITY_POLICY).length && WHOAMI ? { operation: WHOAMI.resource + "." + WHOAMI.method, fields: IDENTITY_POLICY } : undefined;
|
|
360
|
+
}
|
|
361
|
+
function expectedLoginIdentity(flags: Map<string, string | boolean>): ApiIdentity | undefined {
|
|
362
|
+
const expected: ApiIdentity = {};
|
|
363
|
+
for (const kind of ["subject", "account", "organization"] as const) {
|
|
364
|
+
const value = flags.get(kind);
|
|
365
|
+
if (value === undefined) continue;
|
|
366
|
+
if (typeof value !== "string" || !value || !IDENTITY_POLICY[kind]) fail(2, "--" + kind + " requires an expected ID and a configured identity field for that ID.");
|
|
367
|
+
expected[kind] = value as string;
|
|
368
|
+
}
|
|
369
|
+
const selection = requestedLoginOrganization(flags);
|
|
370
|
+
if (selection) {
|
|
371
|
+
if (expected.organization !== undefined && expected.organization !== selection.id) fail(2, "--organization must match --login-organization when both are provided.");
|
|
372
|
+
expected.organization = selection.id;
|
|
329
373
|
}
|
|
374
|
+
return Object.keys(expected).length ? expected : undefined;
|
|
330
375
|
}
|
|
376
|
+
function requestedLoginOrganization(flags: Map<string, string | boolean>): { parameter: "organization" | "organization_id"; id: string } | undefined {
|
|
377
|
+
const id = flags.get("login-organization");
|
|
378
|
+
if (id === undefined) return;
|
|
379
|
+
if (!OAUTH_ORGANIZATION_PARAMETER || !IDENTITY_POLICY.organization) fail(2, "--login-organization requires auth.oauth_organization_parameter and identity_organization configured by the API owner.");
|
|
380
|
+
if (typeof id !== "string" || !id || id.length > 512 || /\s|[\u0000-\u001F\u007F]/.test(id)) fail(2, "--login-organization requires a nonempty provider organization ID without whitespace.");
|
|
381
|
+
return { parameter: OAUTH_ORGANIZATION_PARAMETER!, id: id as string };
|
|
382
|
+
}
|
|
383
|
+
function loginIdentityReport(): Record<string, unknown> { return LOGIN_IDENTITY ? { verified_identity: LOGIN_IDENTITY.values, identity_checked_at: LOGIN_IDENTITY.checkedAt } : {}; }
|
|
331
384
|
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
385
|
+
/** Each login replaces this profile's credentials and retains its API destination. */
|
|
386
|
+
async function saveLoginCredentials(credentials: StoredCreds, flags: Map<string, string | boolean>, destination?: CredentialDestination, onIdentityFailure?: () => Promise<void>): Promise<void> {
|
|
387
|
+
const config = readConfig();
|
|
388
|
+
const apiBaseUrl = destination?.apiBaseUrl ?? resolveBaseUrl(flags, config);
|
|
389
|
+
if (!apiBaseUrl) fail(2, "Set the API base URL before logging in.");
|
|
390
|
+
const url = new URL(apiBaseUrl!);
|
|
391
|
+
if (!["https:", "http:"].includes(url.protocol) || url.username || url.password || url.search || url.hash) fail(2, "The API base URL must be HTTP or HTTPS without credentials, query, or fragment.");
|
|
392
|
+
const bound = destination ? { apiBaseUrl: destination.apiBaseUrl, environment: destination.environment, profile: destination.profile } : { apiBaseUrl: url.href, environment: config.environment, profile: PROFILE.name };
|
|
393
|
+
const next: StoredCreds = { ...credentials, destination: bound };
|
|
394
|
+
const identity = identityConfiguration();
|
|
395
|
+
if (identity && WHOAMI) {
|
|
396
|
+
const op = OPS.find((op) => op.resource === WHOAMI.resource && op.method === WHOAMI.method);
|
|
397
|
+
if (!op) fail(2, "The identity operation is unavailable. Regenerate this product with the configured identity read.");
|
|
398
|
+
const transport = identityFetch(bound.apiBaseUrl);
|
|
399
|
+
const verified = await verifyApiIdentity(async (anonymous) => {
|
|
400
|
+
const client = anonymous ? new TypeshipClient({ baseUrl: bound.apiBaseUrl, fetch: transport, maxRetries: 0, timeoutMs: 10_000 }) : await makeClient(flags, op!, next, true);
|
|
401
|
+
return identityResult(client, op!);
|
|
402
|
+
}, identity.fields, expectedLoginIdentity(flags)).catch(async (error) => {
|
|
403
|
+
await onIdentityFailure?.();
|
|
404
|
+
throw error;
|
|
405
|
+
});
|
|
406
|
+
next.identity = { ...verified, binding: credentialIdentityBinding(next, identity) };
|
|
407
|
+
LOGIN_IDENTITY = verified;
|
|
408
|
+
}
|
|
409
|
+
await credentialStore().update(() => next);
|
|
410
|
+
await updateProfileConfig(configDir(), (current) => ({ ...current, baseUrl: bound.apiBaseUrl }));
|
|
335
411
|
}
|
|
336
412
|
|
|
337
413
|
function readStdin(): Promise<string> {
|
|
@@ -391,118 +467,41 @@ function promptHidden(promptText: string): Promise<string> {
|
|
|
391
467
|
});
|
|
392
468
|
}
|
|
393
469
|
|
|
394
|
-
async function
|
|
395
|
-
const
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
} catch { /* try the next well-known path */ }
|
|
423
|
-
}
|
|
424
|
-
if (!deviceEndpoint) {
|
|
425
|
-
fail(1, "The authorization server does not advertise a device flow. Run '" + BIN + " login' with a pasted credential instead.");
|
|
426
|
-
}
|
|
427
|
-
const start = await oauthForm(deviceEndpoint!, {
|
|
428
|
-
client_id: clientId,
|
|
429
|
-
...(OAUTH_SCOPES.length > 0 ? { scope: OAUTH_SCOPES.join(" ") } : {}),
|
|
430
|
-
...OAUTH_TOKEN_PARAMS,
|
|
431
|
-
});
|
|
432
|
-
const startBody = start.body as { device_code?: string; user_code?: string; verification_uri?: string; verification_uri_complete?: string; interval?: number; expires_in?: number } | null;
|
|
433
|
-
if (start.status !== 200 || !startBody?.device_code) {
|
|
434
|
-
fail(1, "Device authorization failed (HTTP " + start.status + ").", start.body);
|
|
435
|
-
}
|
|
436
|
-
const uri = startBody!.verification_uri_complete ?? startBody!.verification_uri;
|
|
437
|
-
process.stderr.write("Open " + paintErr("cyan", String(uri)) + " and enter code: " + paintErr("bold", String(startBody!.user_code)) + "\n");
|
|
438
|
-
// Under an agent the same facts also go out as one JSON line (stderr,
|
|
439
|
-
// so stdout stays the single result document), for the agent to hand
|
|
440
|
-
// the URL and code to the user while this polls.
|
|
441
|
-
if (agent) process.stderr.write(JSON.stringify({ event: "device_code", verification_uri: uri, user_code: startBody!.user_code, expires_in: startBody!.expires_in ?? 900 }) + "\n");
|
|
442
|
-
let intervalMs = (startBody!.interval ?? 5) * 1000;
|
|
443
|
-
const deadline = Date.now() + (startBody!.expires_in ?? 900) * 1000;
|
|
444
|
-
while (Date.now() < deadline) {
|
|
445
|
-
await new Promise((r) => setTimeout(r, intervalMs));
|
|
446
|
-
const poll = await oauthForm(tokenEndpoint, {
|
|
447
|
-
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
|
448
|
-
device_code: startBody!.device_code!,
|
|
449
|
-
client_id: clientId,
|
|
450
|
-
});
|
|
451
|
-
const tokenBody = poll.body as { access_token?: string; refresh_token?: string; expires_in?: number; error?: string } | null;
|
|
452
|
-
if (poll.status === 200 && tokenBody?.access_token) {
|
|
453
|
-
writeCreds({
|
|
454
|
-
...(readCreds() ?? {}),
|
|
455
|
-
oauth: {
|
|
456
|
-
accessToken: tokenBody.access_token,
|
|
457
|
-
refreshToken: tokenBody.refresh_token,
|
|
458
|
-
expiresAt: tokenBody.expires_in ? Date.now() + tokenBody.expires_in * 1000 : undefined,
|
|
459
|
-
},
|
|
460
|
-
});
|
|
461
|
-
process.stderr.write(paintErr("green", "Logged in.") + "\n");
|
|
462
|
-
out({ ok: true, method: "device", credentials: credsPath() });
|
|
463
|
-
await flushExit(0);
|
|
464
|
-
}
|
|
465
|
-
const errorCode = tokenBody?.error;
|
|
466
|
-
if (errorCode === "authorization_pending") continue;
|
|
467
|
-
if (errorCode === "slow_down") { intervalMs += 5000; continue; }
|
|
468
|
-
fail(1, "Device login failed: " + (errorCode ?? "HTTP " + poll.status), poll.body);
|
|
469
|
-
}
|
|
470
|
-
fail(1, "Device login timed out before the code was entered.");
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
/** Stored OAuth access token, refreshed through the token URL when expired.
|
|
474
|
-
* A failed refresh returns the stale token; the API's 401 explains better
|
|
475
|
-
* than a local guess. */
|
|
476
|
-
async function refreshedOauthToken(stored: StoredCreds): Promise<string | undefined> {
|
|
477
|
-
const oauth = stored.oauth;
|
|
478
|
-
if (!oauth) return undefined;
|
|
479
|
-
const expired = oauth.expiresAt !== undefined && Date.now() > oauth.expiresAt - 60_000;
|
|
480
|
-
if (!expired || !oauth.refreshToken || !OAUTH_TOKEN_URL) return oauth.accessToken;
|
|
481
|
-
const clientId = process.env["TYPESHIP_CLIENT_ID"] ?? OAUTH_CLIENT_ID;
|
|
482
|
-
const result = await oauthForm(OAUTH_TOKEN_URL, {
|
|
483
|
-
grant_type: "refresh_token",
|
|
484
|
-
refresh_token: oauth.refreshToken,
|
|
485
|
-
...(clientId ? { client_id: clientId } : {}),
|
|
486
|
-
});
|
|
487
|
-
const body = result.body as { access_token?: string; refresh_token?: string; expires_in?: number } | null;
|
|
488
|
-
if (result.status === 200 && body?.access_token) {
|
|
489
|
-
const next = {
|
|
490
|
-
accessToken: body.access_token,
|
|
491
|
-
refreshToken: body.refresh_token ?? oauth.refreshToken,
|
|
492
|
-
expiresAt: body.expires_in ? Date.now() + body.expires_in * 1000 : undefined,
|
|
493
|
-
};
|
|
494
|
-
writeCreds({ ...stored, oauth: next });
|
|
495
|
-
return next.accessToken;
|
|
496
|
-
}
|
|
497
|
-
return oauth.accessToken;
|
|
470
|
+
async function withLoginCancellation<T>(work: (signal: AbortSignal) => Promise<T>): Promise<T> {
|
|
471
|
+
const controller = new AbortController();
|
|
472
|
+
const cancel = () => controller.abort();
|
|
473
|
+
process.once("SIGINT", cancel); process.once("SIGTERM", cancel);
|
|
474
|
+
try { return await work(controller.signal); }
|
|
475
|
+
finally { process.off("SIGINT", cancel); process.off("SIGTERM", cancel); }
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
async function deviceLogin(clientId: string, parsed: Parsed): Promise<void> {
|
|
479
|
+
const apiBaseUrl = resolveBaseUrl(parsed.flags);
|
|
480
|
+
if (!apiBaseUrl) fail(2, "Set the API base URL before logging in.");
|
|
481
|
+
const loginConfiguration = sessionConfiguration(apiBaseUrl!);
|
|
482
|
+
await credentialStore().prepare();
|
|
483
|
+
const session = await withLoginCancellation((signal) => oauthDeviceLogin({
|
|
484
|
+
clientId, issuer: OAUTH_ISSUER, discoveryUrls: OAUTH_DISCOVERY_URLS,
|
|
485
|
+
deviceUrl: OAUTH_DEVICE_URL, tokenUrl: OAUTH_TOKEN_URL, scopes: OAUTH_SCOPES,
|
|
486
|
+
audience: OAUTH_TOKEN_PARAMS.audience, resource: OAUTH_TOKEN_PARAMS.resource,
|
|
487
|
+
}, { signal, authorize({ verificationUri, userCode, expiresIn }) {
|
|
488
|
+
process.stderr.write("Open " + paintErr("cyan", verificationUri) + " and enter code: " + paintErr("bold", userCode) + "\n");
|
|
489
|
+
if (isAgentMode(parsed)) process.stderr.write(JSON.stringify({ event: "device_code", verification_uri: verificationUri, user_code: userCode, expires_in: expiresIn }) + "\n");
|
|
490
|
+
} }));
|
|
491
|
+
await saveLoginCredentials({ oauth: { ...session,
|
|
492
|
+
sessionId: randomBytes(16).toString("hex"), binding: sessionBinding(loginConfiguration),
|
|
493
|
+
apiBaseUrl, configuredClientId: loginConfiguration.clientId ?? null,
|
|
494
|
+
} }, parsed.flags, loginConfiguration);
|
|
495
|
+
process.stderr.write(paintErr("green", "Logged in.") + "\n");
|
|
496
|
+
out({ ok: true, method: "device", credentials: credsPath(), ...loginIdentityReport() });
|
|
497
|
+
await flushExit(0);
|
|
498
498
|
}
|
|
499
499
|
|
|
500
|
-
async function storePastedToken(
|
|
500
|
+
async function storePastedToken(token: string, flags: Map<string, string | boolean>): Promise<void> {
|
|
501
501
|
const first = AUTH_SCALARS[0];
|
|
502
502
|
if (!first) fail(2, "This API declares no credential the CLI can store. Use --username/--password if it uses basic auth.");
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
out({ ok: true, method: "paste", stored_as: first!.flag, credentials: credsPath() });
|
|
503
|
+
await saveLoginCredentials({ scalars: { [first!.option]: token } }, flags);
|
|
504
|
+
out({ ok: true, method: "paste", stored_as: first!.flag, credentials: credsPath(), ...loginIdentityReport() });
|
|
506
505
|
await flushExit(0);
|
|
507
506
|
}
|
|
508
507
|
|
|
@@ -513,92 +512,126 @@ async function storePastedToken(stored: StoredCreds, token: string): Promise<voi
|
|
|
513
512
|
* until the API hands back a key minted for this CLI, store it. The key
|
|
514
513
|
* never crosses the chat: the agent relays a URL, the person clicks once.
|
|
515
514
|
*/
|
|
516
|
-
/**
|
|
517
|
-
*
|
|
515
|
+
/** A same-origin API override uses that deployment's approval route. A
|
|
516
|
+
* separately hosted identity service retains its explicitly configured URL. */
|
|
518
517
|
function cliAuthUrl(flags: Map<string, string | boolean>): string {
|
|
518
|
+
const auth = loginEndpoint(CLI_AUTH_URL!);
|
|
519
519
|
const base = resolveBaseUrl(flags);
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
520
|
+
if (DEFAULT_BASE_URL && base && auth.origin === new URL(DEFAULT_BASE_URL).origin) {
|
|
521
|
+
const selected = loginEndpoint(base);
|
|
522
|
+
auth.protocol = selected.protocol; auth.host = selected.host;
|
|
523
|
+
}
|
|
524
|
+
return auth.href;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
async function browserApprove(headless: boolean, flags: Map<string, string | boolean>): Promise<ApprovedCredential> {
|
|
528
|
+
return withLoginCancellation((signal) => customBrowserApproval({
|
|
529
|
+
authUrl: cliAuthUrl(flags), name: BIN + " CLI on " + hostname(), source: headless && !process.stdin.isTTY ? "agent" : "cli",
|
|
530
|
+
}, { signal, authorize({ verificationUrl, expiresIn }) {
|
|
531
|
+
process.stderr.write("Approve this CLI in your browser: " + paintErr("cyan", verificationUrl) + "\n");
|
|
532
|
+
if (headless) process.stderr.write(JSON.stringify({ event: "browser_approval", verification_url: verificationUrl, expires_in: expiresIn, note: "Give this URL to the user; polling until they approve or it expires." }) + "\n");
|
|
533
|
+
else openInBrowser(verificationUrl);
|
|
534
|
+
} }));
|
|
528
535
|
}
|
|
529
536
|
|
|
530
|
-
/**
|
|
531
|
-
|
|
532
|
-
* failure exits with the envelope. Shared by `login` and `init`. */
|
|
533
|
-
async function browserApprove(headless: boolean, flags: Map<string, string | boolean>): Promise<{ api_key: string; key_name: string; org_id?: string }> {
|
|
537
|
+
/** Store what the browser approval minted, marked as this CLI's own. */
|
|
538
|
+
async function storeMinted(minted: ApprovedCredential, flags: Map<string, string | boolean>): Promise<void> {
|
|
534
539
|
const first = AUTH_SCALARS[0]!;
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
const source = headless && !process.stdin.isTTY ? "agent" : "cli";
|
|
540
|
-
let start: { session?: string; verification_url?: string; expires_in?: number; interval?: number; error?: string };
|
|
541
|
-
try {
|
|
542
|
-
const response = await fetch(authUrl + "/start", {
|
|
543
|
-
method: "POST",
|
|
544
|
-
headers: { "Content-Type": "application/json" },
|
|
545
|
-
body: JSON.stringify({ code_challenge: challenge, name, source }),
|
|
546
|
-
signal: AbortSignal.timeout(15_000),
|
|
547
|
-
});
|
|
548
|
-
start = await response.json() as typeof start;
|
|
549
|
-
if (!response.ok || !start.session || !start.verification_url) {
|
|
550
|
-
failWith({ code: "COMMAND_FAILED", message: "Could not start the browser login: " + (start.error ?? "HTTP " + response.status), nextSteps: ["Pass the credential directly: '" + BIN + " login --" + first.flag + " <value>'."] });
|
|
551
|
-
}
|
|
552
|
-
} catch (e) {
|
|
553
|
-
failWith({ code: "NETWORK_ERROR", message: "Could not reach " + authUrl + "/start: " + (e as Error).message, nextSteps: ["Check the network, or pass the credential directly: '" + BIN + " login --" + first.flag + " <value>'."] });
|
|
554
|
-
}
|
|
555
|
-
const url = start!.verification_url!;
|
|
556
|
-
const expiresIn = start!.expires_in ?? 600;
|
|
557
|
-
const intervalMs = Math.max(1, start!.interval ?? 3) * 1000;
|
|
558
|
-
process.stderr.write("Approve this CLI in your browser: " + paintErr("cyan", url) + "\n");
|
|
559
|
-
if (headless) {
|
|
560
|
-
process.stderr.write(JSON.stringify({ event: "browser_approval", verification_url: url, expires_in: expiresIn, note: "Give this URL to the user; polling until they approve or it expires." }) + "\n");
|
|
561
|
-
} else {
|
|
562
|
-
openInBrowser(url);
|
|
563
|
-
}
|
|
564
|
-
const deadline = Date.now() + expiresIn * 1000;
|
|
565
|
-
while (Date.now() < deadline) {
|
|
566
|
-
await new Promise((r) => setTimeout(r, intervalMs));
|
|
567
|
-
let poll: { status?: string; api_key?: string; key_name?: string; org_id?: string };
|
|
540
|
+
await saveLoginCredentials({
|
|
541
|
+
scalars: { [first.option]: minted.api_key },
|
|
542
|
+
minted: { via: "browser", key_name: minted.key_name, revocationUrl: minted.revocationUrl, ...(minted.org_id ? { org_id: minted.org_id } : {}) },
|
|
543
|
+
}, flags, undefined, async () => {
|
|
568
544
|
try {
|
|
569
|
-
const
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
});
|
|
575
|
-
|
|
545
|
+
const saved = credentialStore().read();
|
|
546
|
+
if ([...Object.values(saved?.scalars ?? {}), ...Object.values(saved?.named ?? {}), saved?.oauth?.accessToken, saved?.oauth?.refreshToken].includes(minted.api_key)) {
|
|
547
|
+
process.stderr.write("The rejected approval returned an existing saved credential; it was not revoked.\n");
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
const response = await oauthStatusRequest(loginEndpoint(minted.revocationUrl), { method: "POST", headers: { Authorization: "Bearer " + minted.api_key } }, 15_000);
|
|
551
|
+
if (response.status < 200 || response.status >= 300) throw new Error("Revocation failed");
|
|
576
552
|
} catch {
|
|
577
|
-
|
|
578
|
-
}
|
|
579
|
-
if (poll.status === "pending") continue;
|
|
580
|
-
if (poll.status === "complete" && poll.api_key) {
|
|
581
|
-
return { api_key: poll.api_key, key_name: poll.key_name ?? name, ...(poll.org_id ? { org_id: poll.org_id } : {}) };
|
|
553
|
+
process.stderr.write("The rejected approval's new credential could not be revoked. Revoke it in your API account.\n");
|
|
582
554
|
}
|
|
583
|
-
|
|
584
|
-
if (poll.status === "expired") break;
|
|
585
|
-
failWith({ code: "COMMAND_FAILED", message: "Browser login stopped: " + (poll.status ?? "unknown status"), nextSteps: ["Run '" + BIN + " login' again."] });
|
|
586
|
-
}
|
|
587
|
-
failWith({ status: "action_required", code: "TTY_REQUIRED", message: "The browser approval expired after " + expiresIn + "s without a decision.", nextSteps: ["Run '" + BIN + " login' again and approve the link within ten minutes.", "Or pass the credential directly: '" + BIN + " login --" + first.flag + " <value>'."] });
|
|
555
|
+
});
|
|
588
556
|
}
|
|
589
557
|
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
const
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
558
|
+
async function browserLogin(headless: boolean, flags: Map<string, string | boolean>): Promise<void> {
|
|
559
|
+
await credentialStore().prepare();
|
|
560
|
+
const minted = await browserApprove(headless, flags);
|
|
561
|
+
await storeMinted(minted, flags);
|
|
562
|
+
out({ ok: true, method: "browser", key_name: minted.key_name, ...(minted.org_id ? { org_id: minted.org_id } : {}), credentials: credsPath(), ...loginIdentityReport() });
|
|
563
|
+
await flushExit(0);
|
|
596
564
|
}
|
|
597
565
|
|
|
598
|
-
async function
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
566
|
+
async function acquireOAuthBrowserSession(parsed: Parsed, clientId: string): Promise<void> {
|
|
567
|
+
if (!OAUTH_ISSUER) fail(2, "Browser OAuth requires the exact auth.oauth_issuer configured by the API owner.");
|
|
568
|
+
const apiBaseUrl = resolveBaseUrl(parsed.flags);
|
|
569
|
+
if (!apiBaseUrl) fail(2, "Set the API base URL before logging in.");
|
|
570
|
+
const loginConfiguration = sessionConfiguration(apiBaseUrl!);
|
|
571
|
+
await credentialStore().prepare();
|
|
572
|
+
const session = await startOAuthBrowserSession(parsed, clientId);
|
|
573
|
+
await saveLoginCredentials({ oauth: { ...session, apiBaseUrl,
|
|
574
|
+
sessionId: randomBytes(16).toString("hex"), binding: sessionBinding(loginConfiguration),
|
|
575
|
+
configuredClientId: loginConfiguration.clientId ?? null,
|
|
576
|
+
} }, parsed.flags, loginConfiguration);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/** Normal login and Console verification use the same native exchange. */
|
|
580
|
+
async function startOAuthBrowserSession(parsed: Parsed, clientId: string, timeoutMs?: number): Promise<OAuthLoginSession> {
|
|
581
|
+
const controller = new AbortController();
|
|
582
|
+
const cancel = () => controller.abort();
|
|
583
|
+
process.once("SIGINT", cancel);
|
|
584
|
+
process.once("SIGTERM", cancel);
|
|
585
|
+
try {
|
|
586
|
+
return await oauthBrowserLogin({
|
|
587
|
+
issuer: OAUTH_ISSUER!, clientId, discoveryUrl: OAUTH_DISCOVERY_URL,
|
|
588
|
+
authorizationUrl: OAUTH_AUTHORIZATION_URL, tokenUrl: OAUTH_TOKEN_URL ?? undefined,
|
|
589
|
+
redirectUri: OAUTH_REDIRECT_URI, scopes: OAUTH_SCOPES,
|
|
590
|
+
audience: OAUTH_TOKEN_PARAMS.audience, resource: OAUTH_TOKEN_PARAMS.resource,
|
|
591
|
+
organization: requestedLoginOrganization(parsed.flags),
|
|
592
|
+
}, { signal: controller.signal, timeoutMs, authorize(url) {
|
|
593
|
+
process.stderr.write("Sign in to your existing account: " + url + "\n");
|
|
594
|
+
if (isAgentMode(parsed) || parsed.flags.get("no-browser") === true) process.stderr.write(JSON.stringify({ event: "oauth_browser", authorization_url: url, note: "Open this URL in a browser on the same computer as the CLI." }) + "\n");
|
|
595
|
+
else openInBrowser(url);
|
|
596
|
+
} });
|
|
597
|
+
} finally { process.off("SIGINT", cancel); process.off("SIGTERM", cancel); }
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
async function cmdConsoleLoginCheck(parsed: Parsed): Promise<void> {
|
|
601
|
+
const file = parsed.flags.get("console-check");
|
|
602
|
+
if (typeof file !== "string" || !file || parsed.flags.get("device") === true || parsed.flags.get("with-token") === true || explicitNonInteractive(parsed)) fail(2, "--console-check requires a downloaded JSON file and browser login. Use --no-browser to print the sign-in link.");
|
|
603
|
+
const baseUrl = resolveBaseUrl(parsed.flags);
|
|
604
|
+
const clientId = (typeof parsed.flags.get("client-id") === "string" ? parsed.flags.get("client-id") as string : undefined) ?? process.env[ENV_PREFIX + "_CLIENT_ID"] ?? OAUTH_CLIENT_ID;
|
|
605
|
+
const op = WHOAMI && OPS.find((value) => value.resource === WHOAMI.resource && value.method === WHOAMI.method);
|
|
606
|
+
if (!baseUrl || !OAUTH_ISSUER || !clientId || OAUTH_LOGIN_METHOD !== "browser" || !op || op.auth !== "required" || !op.security?.length) fail(2, "Configure browser OAuth and a required-authentication identity read, then regenerate this CLI.");
|
|
607
|
+
const envOptions: Record<string, unknown> = {}, flagOptions: Record<string, unknown> = {};
|
|
608
|
+
for (const scalar of AUTH_SCALARS) {
|
|
609
|
+
if (process.env[scalar.env] !== undefined) envOptions[scalar.option] = process.env[scalar.env];
|
|
610
|
+
if (typeof parsed.flags.get(scalar.flag) === "string") flagOptions[scalar.option] = parsed.flags.get(scalar.flag);
|
|
611
|
+
}
|
|
612
|
+
if (BASIC && process.env[BASIC.envUser] && process.env[BASIC.envPass]) envOptions.basicAuth = { username: process.env[BASIC.envUser], password: process.env[BASIC.envPass] };
|
|
613
|
+
if (BASIC && typeof parsed.flags.get("username") === "string" && typeof parsed.flags.get("password") === "string") flagOptions.basicAuth = { username: parsed.flags.get("username"), password: parsed.flags.get("password") };
|
|
614
|
+
const environment = (typeof parsed.flags.get("environment") === "string" ? parsed.flags.get("environment") as string : Object.entries(ENVIRONMENTS).find(([, url]) => new URL(url).href === new URL(baseUrl!).href)?.[0]) ?? null;
|
|
615
|
+
const result = await checkConsoleBrowserLogin({
|
|
616
|
+
file: file as string, configuration: {
|
|
617
|
+
baseUrl: baseUrl!, environment, operation: op!.resource + "." + op!.method, requirements: op!.security!,
|
|
618
|
+
request: { method: op!.graphql ? "POST" : op!.httpMethod, path: op!.graphql ? "" : op!.path, graphqlField: op!.graphql?.field ?? null, graphqlQuery: op!.graphql ? op!.graphql.docPrefix + op!.graphql.defaultSelection + " }" : null },
|
|
619
|
+
issuer: OAUTH_ISSUER!, clientId: clientId!, discoveryUrl: OAUTH_DISCOVERY_URL ?? null,
|
|
620
|
+
authorizationUrl: OAUTH_AUTHORIZATION_URL ?? null, tokenUrl: OAUTH_TOKEN_URL,
|
|
621
|
+
redirectUri: OAUTH_REDIRECT_URI ?? "http://127.0.0.1/callback", scopes: OAUTH_SCOPES,
|
|
622
|
+
audience: OAUTH_TOKEN_PARAMS.audience ?? null, resource: OAUTH_TOKEN_PARAMS.resource ?? null,
|
|
623
|
+
}, schemes: NAMED_SCHEMES,
|
|
624
|
+
credentials: resolveNamedCredentials(NAMED_SCHEMES, [{ options: envOptions, named: environmentCredentials() }, { options: flagOptions, named: flagCredentials(parsed.flags) }]),
|
|
625
|
+
login: (timeoutMs) => startOAuthBrowserSession(parsed, clientId!, timeoutMs),
|
|
626
|
+
async verify(credentials, expectations) {
|
|
627
|
+
const kind = (value: "user" | "account" | "organization") => value === "user" ? "subject" : value;
|
|
628
|
+
const policy = Object.fromEntries(expectations.map((entry) => [kind(entry.kind), entry.pointer])) as IdentityPolicy;
|
|
629
|
+
const expected = Object.fromEntries(expectations.map((entry) => [kind(entry.kind), String(entry.expected)])) as ApiIdentity;
|
|
630
|
+
await verifyClientIdentity((options) => new TypeshipClient(options as unknown as ClientOptions), { baseUrl: baseUrl!, credentials }, op!, policy, expected);
|
|
631
|
+
},
|
|
632
|
+
progress: (message) => process.stderr.write(message + "\n"),
|
|
633
|
+
});
|
|
634
|
+
out(result);
|
|
602
635
|
await flushExit(0);
|
|
603
636
|
}
|
|
604
637
|
|
|
@@ -610,17 +643,30 @@ async function cmdLogin(parsed: Parsed): Promise<void> {
|
|
|
610
643
|
...AUTH_SCALARS.map((a) => " " + BIN + " login --" + a.flag + " <value>"),
|
|
611
644
|
...(CLI_AUTH_URL ? [" " + BIN + " login approve in the browser: a key is minted for you (add --no-browser to print the link instead of opening it)"] : []),
|
|
612
645
|
" " + BIN + " login --with-token read the credential from stdin (CI)",
|
|
613
|
-
|
|
646
|
+
" " + BIN + " login --console-check <file> test browser login for your Typeship Console without replacing saved logins",
|
|
647
|
+
...(HAS_OAUTH_LOGIN ? [" " + BIN + " login --client-id <id> OAuth " + OAUTH_LOGIN_METHOD + " login" + (OAUTH_CLIENT_ID ? " (a default id is built in)" : "")] : []),
|
|
648
|
+
...(HAS_OAUTH_LOGIN ? [" " + BIN + " login --no-browser print the approval URL instead of opening a browser", " " + BIN + " login --device use device authorization when your provider supports it"] : []),
|
|
614
649
|
...(BASIC ? [" " + BIN + " login --username <u> --password <p>"] : []),
|
|
615
|
-
...(CLI_AUTH_URL ? [] : [" " + BIN + " login interactive prompt (TTY only)"]),
|
|
650
|
+
...(CLI_AUTH_URL || HAS_OAUTH_LOGIN ? [] : [" " + BIN + " login interactive prompt (TTY only)"]),
|
|
616
651
|
"",
|
|
617
|
-
"Precedence
|
|
652
|
+
"Precedence per scheme: flags > env vars > stored credentials. Named inputs beat convenience flags within the same source.",
|
|
653
|
+
" " + BIN + " login --credentials @<JSON-file> store named credentials (use - for stdin)",
|
|
654
|
+
"Named schemes: " + Object.entries(NAMED_SCHEMES).map(([name, scheme]) => name + " (" + scheme.kind + ")").join(", "),
|
|
655
|
+
"JSON values are tokens/API keys, or {username, password} for Basic auth. Runtime env: TYPESHIP_CREDENTIALS.",
|
|
656
|
+
"--profile <name> selects an isolated login. Use auth profiles to list profiles and auth use <name> to select a default.",
|
|
657
|
+
...(Object.keys(IDENTITY_POLICY).length ? ["Login verifies your API identity before saving. Add --subject <id>, --account <id> or --organization <id> to require a particular mapped identity."] : []),
|
|
658
|
+
...(OAUTH_ORGANIZATION_PARAMETER ? ["--login-organization <id> requests that provider organization during browser login and requires the API to confirm the same ID."] : []),
|
|
659
|
+
"Saved credentials use OS protection by default. TYPESHIP_CREDENTIAL_STORE=file explicitly opts into plaintext storage.",
|
|
618
660
|
];
|
|
619
661
|
process.stdout.write(lines.join("\n") + "\n");
|
|
620
662
|
await flushExit(0);
|
|
621
663
|
}
|
|
622
|
-
|
|
623
|
-
|
|
664
|
+
if (parsed.flags.has("login-organization")) {
|
|
665
|
+
expectedLoginIdentity(parsed.flags);
|
|
666
|
+
if (!HAS_OAUTH_LOGIN || OAUTH_LOGIN_METHOD !== "browser" || parsed.flags.has("device") || parsed.flags.has("console-check") || parsed.flags.has("with-token") || explicitNonInteractive(parsed)) fail(2, "--login-organization is available only for interactive OAuth browser login, including --no-browser.");
|
|
667
|
+
}
|
|
668
|
+
if (parsed.flags.has("console-check")) { await cmdConsoleLoginCheck(parsed); return; }
|
|
669
|
+
const named = flagCredentials(parsed.flags);
|
|
624
670
|
const scalarValues: Record<string, string> = {};
|
|
625
671
|
for (const a of AUTH_SCALARS) {
|
|
626
672
|
const v = parsed.flags.get(a.flag);
|
|
@@ -629,31 +675,39 @@ async function cmdLogin(parsed: Parsed): Promise<void> {
|
|
|
629
675
|
const username = parsed.flags.get("username");
|
|
630
676
|
const password = parsed.flags.get("password");
|
|
631
677
|
const gotBasic = BASIC !== null && typeof username === "string" && typeof password === "string";
|
|
632
|
-
if (Object.keys(scalarValues).length > 0 || gotBasic) {
|
|
633
|
-
if (
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
678
|
+
if (Object.keys(scalarValues).length > 0 || gotBasic || Object.keys(named).length > 0) {
|
|
679
|
+
if (parsed.flags.has("login-organization")) fail(2, "--login-organization cannot be combined with pasted credentials.");
|
|
680
|
+
await saveLoginCredentials({
|
|
681
|
+
...(Object.keys(named).length ? { named } : {}),
|
|
682
|
+
...(Object.keys(scalarValues).length > 0 ? { scalars: scalarValues } : {}),
|
|
683
|
+
...(gotBasic ? { basic: { username: username as string, password: password as string } } : {}),
|
|
684
|
+
}, parsed.flags);
|
|
685
|
+
out({ ok: true, method: "flags", credentials: credsPath(), ...loginIdentityReport() });
|
|
637
686
|
await flushExit(0);
|
|
638
687
|
}
|
|
639
688
|
|
|
640
689
|
if (parsed.flags.get("with-token") === true) {
|
|
641
690
|
const token = (await readStdin()).trim();
|
|
642
691
|
if (!token) fail(2, "--with-token expects the credential on stdin.");
|
|
643
|
-
await storePastedToken(
|
|
692
|
+
await storePastedToken(token, parsed.flags);
|
|
644
693
|
}
|
|
645
694
|
|
|
646
695
|
const clientId = (typeof parsed.flags.get("client-id") === "string" ? parsed.flags.get("client-id") as string : undefined)
|
|
647
696
|
?? process.env["TYPESHIP_CLIENT_ID"] ?? OAUTH_CLIENT_ID ?? undefined;
|
|
648
|
-
if (
|
|
649
|
-
|
|
697
|
+
if (HAS_OAUTH_LOGIN && clientId !== undefined && !explicitNonInteractive(parsed)) {
|
|
698
|
+
if (OAUTH_LOGIN_METHOD === "browser" && parsed.flags.get("device") !== true) {
|
|
699
|
+
await acquireOAuthBrowserSession(parsed, clientId);
|
|
700
|
+
out({ ok: true, method: "oauth_browser", credentials: credsPath(), ...loginIdentityReport() });
|
|
701
|
+
await flushExit(0);
|
|
702
|
+
}
|
|
703
|
+
await deviceLogin(clientId, parsed);
|
|
650
704
|
}
|
|
651
705
|
|
|
652
706
|
// Browser approval: the API mints a key for this CLI once a person
|
|
653
707
|
// approves in the browser. Works under an agent too (it prints the URL and
|
|
654
708
|
// polls); only the explicit non-interactive switch turns it off.
|
|
655
709
|
if (CLI_AUTH_URL && AUTH_SCALARS[0] && !explicitNonInteractive(parsed)) {
|
|
656
|
-
await browserLogin(
|
|
710
|
+
await browserLogin(isAgentMode(parsed) || parsed.flags.get("no-browser") === true, parsed.flags);
|
|
657
711
|
}
|
|
658
712
|
|
|
659
713
|
if (nonInteractive(parsed) || !process.stdin.isTTY) {
|
|
@@ -664,7 +718,7 @@ async function cmdLogin(parsed: Parsed): Promise<void> {
|
|
|
664
718
|
nextSteps: [
|
|
665
719
|
...AUTH_SCALARS.map((a) => "Pass the credential: '" + BIN + " login --" + a.flag + " <value>', or set " + a.env + " in the environment."),
|
|
666
720
|
"Pipe it: echo \"$TOKEN\" | " + BIN + " login --with-token",
|
|
667
|
-
...(
|
|
721
|
+
...(HAS_OAUTH_LOGIN ? ["OAuth login (or --device when supported by your provider): '" + BIN + " login --client-id <id>' (opens or prints the provider sign-in URL)."] : []),
|
|
668
722
|
...(CLI_AUTH_URL ? ["Browser approval: '" + BIN + " login --no-browser' prints a link for the user to approve and waits."] : []),
|
|
669
723
|
],
|
|
670
724
|
});
|
|
@@ -673,27 +727,39 @@ async function cmdLogin(parsed: Parsed): Promise<void> {
|
|
|
673
727
|
if (!first) fail(2, "This API declares no credential the CLI can prompt for. See '" + BIN + " login --help'.");
|
|
674
728
|
const token = (await promptHidden("Paste " + first.flag.replace(/-/g, " ") + " (input hidden): ")).trim();
|
|
675
729
|
if (!token) fail(2, "Nothing entered.");
|
|
676
|
-
await storePastedToken(
|
|
730
|
+
await storePastedToken(token, parsed.flags);
|
|
677
731
|
}
|
|
678
732
|
|
|
679
|
-
async function cmdLogout(): Promise<void> {
|
|
680
|
-
|
|
681
|
-
|
|
733
|
+
async function cmdLogout(parsed: Parsed): Promise<void> {
|
|
734
|
+
if (parsed.flags.get("local") === true) {
|
|
735
|
+
const removed = await credentialStore().clear();
|
|
736
|
+
out({ ok: true, removed: removed ? credsPath() : null, revoked: false, revocation_skipped: true });
|
|
737
|
+
await flushExit(0);
|
|
738
|
+
}
|
|
739
|
+
const stored = await credentialStore().take();
|
|
740
|
+
const existed = stored !== null;
|
|
682
741
|
// A key this CLI minted for itself (browser approval) is revoked on the
|
|
683
742
|
// way out, so logging out ends the credential and not just the file. A
|
|
684
743
|
// pasted or CI key is someone else's to revoke, and is left alone.
|
|
685
744
|
let revoked: boolean | null = null;
|
|
686
745
|
const first = AUTH_SCALARS[0];
|
|
687
746
|
const ownKey = first && stored?.minted?.via === "browser" ? stored.scalars?.[first.option] : undefined;
|
|
688
|
-
if (ownKey
|
|
747
|
+
if (ownKey) {
|
|
689
748
|
try {
|
|
690
|
-
const response = await
|
|
691
|
-
revoked = response.
|
|
749
|
+
const response = await oauthStatusRequest(loginEndpoint(stored!.minted!.revocationUrl!), { method: "POST", headers: { Authorization: "Bearer " + ownKey } }, 15_000);
|
|
750
|
+
revoked = response.status >= 200 && response.status < 300;
|
|
692
751
|
} catch {
|
|
693
752
|
revoked = false;
|
|
694
753
|
}
|
|
695
754
|
}
|
|
696
|
-
|
|
755
|
+
if (stored?.oauth?.revocationUrl) {
|
|
756
|
+
try {
|
|
757
|
+
const oauth = stored.oauth;
|
|
758
|
+
const token = oauth.refreshToken ?? oauth.accessToken;
|
|
759
|
+
const result = await oauthStatusRequest(loginEndpoint(oauth.revocationUrl!), { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ token, token_type_hint: oauth.refreshToken ? "refresh_token" : "access_token", ...(oauth.clientId ? { client_id: oauth.clientId } : {}) }) });
|
|
760
|
+
revoked = result.status >= 200 && result.status < 300;
|
|
761
|
+
} catch { revoked = false; }
|
|
762
|
+
}
|
|
697
763
|
out({ ok: true, removed: existed ? credsPath() : null, ...(revoked === null ? {} : { revoked, key_name: stored?.minted?.key_name }) });
|
|
698
764
|
await flushExit(0);
|
|
699
765
|
}
|
|
@@ -701,21 +767,13 @@ async function cmdLogout(): Promise<void> {
|
|
|
701
767
|
async function cmdWhoami(parsed: Parsed): Promise<void> {
|
|
702
768
|
const op = WHOAMI ? OPS.find((o) => o.resource === WHOAMI.resource && o.method === WHOAMI.method) : undefined;
|
|
703
769
|
if (op) {
|
|
704
|
-
const client = await makeClient(parsed.flags);
|
|
770
|
+
const client = await makeClient(parsed.flags, op);
|
|
705
771
|
const target = (client as unknown as Record<string, Record<string, () => Promise<{ ok: boolean; data?: unknown; error?: unknown }>>>)[op.resource]!;
|
|
706
772
|
const result = await target[op.method]!();
|
|
707
773
|
if (result.ok) { out(result.data ?? { ok: true }); await flushExit(0); }
|
|
708
774
|
failApi(result.error, LAST_CLIENT_HAD_CREDENTIAL);
|
|
709
775
|
}
|
|
710
|
-
const
|
|
711
|
-
let source = "none";
|
|
712
|
-
if (AUTH_SCALARS.some((a) => typeof parsed.flags.get(a.flag) === "string")) {
|
|
713
|
-
source = "flags";
|
|
714
|
-
} else {
|
|
715
|
-
const envScalar = AUTH_SCALARS.find((a) => process.env[a.env] !== undefined);
|
|
716
|
-
if (envScalar) source = "env:" + envScalar.env;
|
|
717
|
-
else if (stored && (stored.scalars || stored.basic || stored.oauth)) source = "login";
|
|
718
|
-
}
|
|
776
|
+
const source = credentialSource(parsed.flags) ?? "none";
|
|
719
777
|
out({ authenticated: source !== "none", source, credentials: existsSync(credsPath()) ? credsPath() : null });
|
|
720
778
|
await flushExit(source === "none" ? 1 : 0);
|
|
721
779
|
}
|
|
@@ -734,18 +792,7 @@ function configFilePath(): string {
|
|
|
734
792
|
return join(configDir(), "config.json");
|
|
735
793
|
}
|
|
736
794
|
|
|
737
|
-
function readConfig(): CliConfigFile {
|
|
738
|
-
try {
|
|
739
|
-
return JSON.parse(readFileSync(configFilePath(), "utf8")) as CliConfigFile;
|
|
740
|
-
} catch {
|
|
741
|
-
return {};
|
|
742
|
-
}
|
|
743
|
-
}
|
|
744
|
-
|
|
745
|
-
function writeConfig(config: CliConfigFile): void {
|
|
746
|
-
mkdirSync(configDir(), { recursive: true, mode: 0o700 });
|
|
747
|
-
writeFileSync(configFilePath(), JSON.stringify(config, null, 2) + "\n");
|
|
748
|
-
}
|
|
795
|
+
function readConfig(): CliConfigFile { return readProfileConfig(configDir()); }
|
|
749
796
|
|
|
750
797
|
const CONFIG_KEYS = ["base-url", "environment", "docs-url"];
|
|
751
798
|
|
|
@@ -779,6 +826,7 @@ async function cmdConfig(parsed: Parsed): Promise<void> {
|
|
|
779
826
|
if (sub === undefined || sub === "list") {
|
|
780
827
|
const config = readConfig();
|
|
781
828
|
out({
|
|
829
|
+
profile: PROFILE.name, profile_source: PROFILE.source,
|
|
782
830
|
base_url: config.baseUrl ?? null,
|
|
783
831
|
environment: config.environment ?? null,
|
|
784
832
|
docs_url: config.docsUrl ?? DOCS_URL_DEFAULT,
|
|
@@ -801,10 +849,13 @@ async function cmdConfig(parsed: Parsed): Promise<void> {
|
|
|
801
849
|
await flushExit(0);
|
|
802
850
|
}
|
|
803
851
|
if (sub === "unset") {
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
852
|
+
await updateProfileConfig(configDir(), (current) => {
|
|
853
|
+
const next = { ...current };
|
|
854
|
+
if (key === "base-url") delete next.baseUrl;
|
|
855
|
+
else if (key === "environment") delete next.environment;
|
|
856
|
+
else delete next.docsUrl;
|
|
857
|
+
return next;
|
|
858
|
+
});
|
|
808
859
|
out({ ok: true });
|
|
809
860
|
await flushExit(0);
|
|
810
861
|
}
|
|
@@ -815,17 +866,14 @@ async function cmdConfig(parsed: Parsed): Promise<void> {
|
|
|
815
866
|
} catch {
|
|
816
867
|
fail(2, key + " must be a valid URL");
|
|
817
868
|
}
|
|
818
|
-
if (key === "base-url") config.baseUrl = value;
|
|
819
|
-
else config.docsUrl = value;
|
|
820
869
|
} else {
|
|
821
870
|
if (!(value! in ENVIRONMENTS)) {
|
|
822
871
|
fail(2, Object.keys(ENVIRONMENTS).length > 0
|
|
823
872
|
? "environment must be one of: " + Object.keys(ENVIRONMENTS).join(", ")
|
|
824
873
|
: "The spec declares no named environments; use 'config set base-url' instead.");
|
|
825
874
|
}
|
|
826
|
-
config.environment = value;
|
|
827
875
|
}
|
|
828
|
-
|
|
876
|
+
await updateProfileConfig(configDir(), (current) => key === "base-url" ? { ...current, baseUrl: value } : key === "docs-url" ? { ...current, docsUrl: value } : { ...current, environment: value, baseUrl: undefined });
|
|
829
877
|
out({ ok: true, [key.replace(/-/g, "_")]: value });
|
|
830
878
|
await flushExit(0);
|
|
831
879
|
}
|
|
@@ -849,16 +897,6 @@ function mcpServerPath(): { path: string; warning: string | null } {
|
|
|
849
897
|
return { path, warning };
|
|
850
898
|
}
|
|
851
899
|
|
|
852
|
-
function claudeDesktopConfigPath(): string {
|
|
853
|
-
if (process.platform === "darwin") {
|
|
854
|
-
return join(homedir(), "Library", "Application Support", "Claude", "claude_desktop_config.json");
|
|
855
|
-
}
|
|
856
|
-
if (process.platform === "win32") {
|
|
857
|
-
return join(process.env.APPDATA ?? join(homedir(), "AppData", "Roaming"), "Claude", "claude_desktop_config.json");
|
|
858
|
-
}
|
|
859
|
-
return join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "Claude", "claude_desktop_config.json");
|
|
860
|
-
}
|
|
861
|
-
|
|
862
900
|
/** The mcp entry for a client: the hosted endpoint when the API has one
|
|
863
901
|
* (auth env var as a reference, never a literal), else the local stdio
|
|
864
902
|
* server. --url overrides. */
|
|
@@ -878,7 +916,12 @@ function mcpEntryFor(url: string | undefined, readOnly = false): { entry: McpEnt
|
|
|
878
916
|
}
|
|
879
917
|
const server = mcpServerPath();
|
|
880
918
|
if (server.warning) warnings.push(server.warning);
|
|
881
|
-
|
|
919
|
+
const environment = {
|
|
920
|
+
...(process.env.XDG_CONFIG_HOME !== undefined ? { XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME } : {}),
|
|
921
|
+
...(process.env["TYPESHIP_CREDENTIAL_STORE"] !== undefined ? { "TYPESHIP_CREDENTIAL_STORE": process.env["TYPESHIP_CREDENTIAL_STORE"]! } : {}),
|
|
922
|
+
};
|
|
923
|
+
const pinned = PROFILE.source === "flag" || PROFILE.source === "environment";
|
|
924
|
+
return { entry: { command: "node", args: [server.path, ...(pinned ? ["--profile", PROFILE.name] : []), ...(readOnly ? ["--read-only"] : [])], ...(Object.keys(environment).length ? { env: environment } : {}) }, warnings };
|
|
882
925
|
}
|
|
883
926
|
|
|
884
927
|
const MCP_CLIENT_FLAGS: Record<string, string> = {
|
|
@@ -902,11 +945,20 @@ async function cmdMcp(parsed: Parsed): Promise<void> {
|
|
|
902
945
|
" " + BIN + " mcp install --claude --read-only register a read-only server (writes are not callable)",
|
|
903
946
|
"",
|
|
904
947
|
(MCP_URL ? "Default entry: the hosted endpoint " + MCP_URL + " with the auth env var as a reference (never a literal key)." : "Default entry: this package's local stdio server, which reads credentials saved by '" + BIN + " login' or the CLI's auth env vars."),
|
|
905
|
-
"
|
|
948
|
+
"--all skips Cursor until it speaks MCP 2026-07-28.",
|
|
906
949
|
];
|
|
907
950
|
process.stdout.write(lines.join("\n") + "\n");
|
|
908
951
|
await flushExit(0);
|
|
909
952
|
}
|
|
953
|
+
const sub = parsed.positionals[1];
|
|
954
|
+
if (sub !== undefined && sub !== "install") {
|
|
955
|
+
fail(2, "Unknown mcp command: " + sub + ". Try '" + BIN + " mcp install --all'.");
|
|
956
|
+
}
|
|
957
|
+
const requestedInstall = parsed.flags.get("all") === true
|
|
958
|
+
|| Object.keys(MCP_CLIENT_FLAGS).some((flag) => parsed.flags.get(flag) === true);
|
|
959
|
+
if (requestedInstall && sub !== "install") {
|
|
960
|
+
fail(2, "Installing an MCP entry requires '" + BIN + " mcp install'.");
|
|
961
|
+
}
|
|
910
962
|
const url = typeof parsed.flags.get("url") === "string" ? parsed.flags.get("url") as string : undefined;
|
|
911
963
|
if (url) {
|
|
912
964
|
try {
|
|
@@ -966,8 +1018,11 @@ function agentContext(): AgentContext {
|
|
|
966
1018
|
apiTitle: API_TITLE,
|
|
967
1019
|
version: VERSION,
|
|
968
1020
|
envPrefix: ENV_PREFIX,
|
|
969
|
-
authEnvVars: [...AUTH_SCALARS.map((a) => a.env), ...(BASIC ? [BASIC.envUser, BASIC.envPass] : [])],
|
|
1021
|
+
authEnvVars: [...AUTH_SCALARS.map((a) => a.env), ...(BASIC ? [BASIC.envUser, BASIC.envPass] : []), ...(Object.keys(NAMED_SCHEMES).length ? ["TYPESHIP_CREDENTIALS"] : [])],
|
|
970
1022
|
docsUrl: docsSiteUrl(),
|
|
1023
|
+
docsIndexUrl: docsIndexUrl(),
|
|
1024
|
+
generatedOperationCount: OPS.length,
|
|
1025
|
+
omittedOperations: OMITTED_OPS.map((op) => ({ command: op.command.join(" "), tool: op.tool, method: op.httpMethod, path: op.path })),
|
|
971
1026
|
mcpUrl: MCP_URL,
|
|
972
1027
|
skillsRepo: SKILLS_REPO,
|
|
973
1028
|
hasMcp: HAS_MCP,
|
|
@@ -1028,13 +1083,21 @@ function helpJson(): Record<string, unknown> {
|
|
|
1028
1083
|
};
|
|
1029
1084
|
}),
|
|
1030
1085
|
})),
|
|
1086
|
+
...(EXCLUDED_OPS > 0 ? {
|
|
1087
|
+
coverage: {
|
|
1088
|
+
generated_operations: OPS.length,
|
|
1089
|
+
total_operations: OPS.length + EXCLUDED_OPS,
|
|
1090
|
+
omitted_operations: OMITTED_OPS.map((op) => ({ command: op.command.join(" "), tool: op.tool, method: op.httpMethod, path: op.path })),
|
|
1091
|
+
reason: "plan_limit",
|
|
1092
|
+
},
|
|
1093
|
+
} : {}),
|
|
1031
1094
|
discovery: {
|
|
1032
1095
|
search: BIN + " docs search <term> --json",
|
|
1033
1096
|
operation: BIN + " docs <resource> <command> --json",
|
|
1034
1097
|
note: "Choose an operation from this index, then read only that operation's complete schemas and example arguments.",
|
|
1035
1098
|
},
|
|
1036
1099
|
builtins: BUILTIN_COMMANDS,
|
|
1037
|
-
global_flags: ["--help", "--version", "--debug", "--non-interactive", "--mode agent|human", "--yes", "--force", "--color on|off|auto", "--base-url <url>", "--data '<json>' | @<file> | -", "--fields <a,b.c>", "--all", "--validate", "--out <dir>", ...AUTH_SCALARS.map((a) => "--" + a.flag + " <value>")],
|
|
1100
|
+
global_flags: ["--help", "--version", "--debug", "--non-interactive", "--mode agent|human", "--yes", "--force", "--color on|off|auto", "--credentials @<JSON-file>|-", "--base-url <url>", "--profile <name>", "--data '<json>' | @<file> | -", "--fields <a,b.c>", "--all", "--validate", "--out <dir>", ...AUTH_SCALARS.map((a) => "--" + a.flag + " <value>")],
|
|
1038
1101
|
auth_env_vars: agentContext().authEnvVars,
|
|
1039
1102
|
};
|
|
1040
1103
|
}
|
|
@@ -1051,29 +1114,44 @@ async function cmdAgentGuide(parsed: Parsed): Promise<void> {
|
|
|
1051
1114
|
/** Which credential the CLI would send, without sending it. --live calls the identity endpoint too. */
|
|
1052
1115
|
async function cmdAuth(parsed: Parsed): Promise<void> {
|
|
1053
1116
|
const sub = parsed.positionals[1];
|
|
1117
|
+
if (!parsed.help && sub === "profiles") {
|
|
1118
|
+
out({ profile: PROFILE.name, source: PROFILE.source, profiles: listProfiles(configRoot()).map((entry) => ({ name: entry.name, selected: entry.name === PROFILE.name, has_saved_credentials: entry.hasSavedCredentials, base_url: entry.config.baseUrl ?? (entry.config.environment ? ENVIRONMENTS[entry.config.environment] : undefined) ?? DEFAULT_BASE_URL, environment: entry.config.environment ?? null })) });
|
|
1119
|
+
await flushExit(0);
|
|
1120
|
+
}
|
|
1121
|
+
if (!parsed.help && (sub === "use" || sub === "remove")) {
|
|
1122
|
+
const name = parsed.positionals[2];
|
|
1123
|
+
if (!name) fail(2, "auth " + sub + " requires a profile name.");
|
|
1124
|
+
if (sub === "use") selectProfile(configRoot(), name!);
|
|
1125
|
+
else await removeProfile(configRoot(), name!);
|
|
1126
|
+
const effective = resolveProfile(configRoot(), { flag: typeof parsed.flags.get("profile") === "string" ? parsed.flags.get("profile") as string : undefined, environment: process.env["TYPESHIP_PROFILE"], allowMissing: true });
|
|
1127
|
+
out({ ok: true, ...(sub === "use" ? { selected: name } : { removed: name }), profile: effective.name, source: effective.source });
|
|
1128
|
+
await flushExit(0);
|
|
1129
|
+
}
|
|
1054
1130
|
if (parsed.help || sub !== "check") {
|
|
1055
1131
|
process.stdout.write([
|
|
1056
1132
|
BIN + " auth check [--live] — report the credential the CLI would use, as JSON: {status: ok|action_required, authenticated, source, ...}",
|
|
1133
|
+
" " + BIN + " auth profiles list profiles without unlocking credentials",
|
|
1134
|
+
" " + BIN + " auth use <name> select the default profile",
|
|
1135
|
+
" " + BIN + " auth remove <name> remove a profile after logout",
|
|
1136
|
+
" --profile <name> overrides TYPESHIP_PROFILE, then the saved selection, then default.",
|
|
1057
1137
|
" --live also call the API's identity endpoint" + (WHOAMI ? "" : " (none in this API; --live is a no-op)"),
|
|
1058
1138
|
"",
|
|
1059
1139
|
"Precedence: flags > env vars > stored credentials (" + credsPath() + ").",
|
|
1060
1140
|
].join("\n") + "\n");
|
|
1061
1141
|
await flushExit(parsed.help ? 0 : 2);
|
|
1062
1142
|
}
|
|
1063
|
-
const
|
|
1064
|
-
let source = "none";
|
|
1065
|
-
if (AUTH_SCALARS.some((a) => typeof parsed.flags.get(a.flag) === "string")) source = "flags";
|
|
1066
|
-
else {
|
|
1067
|
-
const envScalar = AUTH_SCALARS.find((a) => process.env[a.env] !== undefined);
|
|
1068
|
-
if (envScalar) source = "env:" + envScalar.env;
|
|
1069
|
-
else if (stored && (stored.scalars || stored.basic || stored.oauth)) source = "login";
|
|
1070
|
-
}
|
|
1143
|
+
const source = credentialSource(parsed.flags) ?? "none";
|
|
1071
1144
|
const authenticated = source !== "none";
|
|
1145
|
+
const savedIdentity = source === "login" ? readCreds() : null;
|
|
1146
|
+
if (savedIdentity) assertStoredIdentity(savedIdentity, identityConfiguration());
|
|
1072
1147
|
const report: Record<string, unknown> = {
|
|
1073
1148
|
status: authenticated ? "ok" : "action_required",
|
|
1074
1149
|
authenticated,
|
|
1075
1150
|
source,
|
|
1076
1151
|
credentials_path: existsSync(credsPath()) ? credsPath() : null,
|
|
1152
|
+
credential_storage: credentialStore().backend,
|
|
1153
|
+
verified_identity: savedIdentity?.identity?.values ?? null, identity_checked_at: savedIdentity?.identity?.checkedAt ?? null,
|
|
1154
|
+
profile: PROFILE.name, profile_source: PROFILE.source,
|
|
1077
1155
|
auth_env_vars: agentContext().authEnvVars,
|
|
1078
1156
|
base_url: resolveBaseUrl(parsed.flags) ?? null,
|
|
1079
1157
|
next_steps: authenticated ? [] : [
|
|
@@ -1084,10 +1162,13 @@ async function cmdAuth(parsed: Parsed): Promise<void> {
|
|
|
1084
1162
|
if (authenticated && parsed.flags.get("live") === true && WHOAMI) {
|
|
1085
1163
|
const op = OPS.find((o) => o.resource === WHOAMI.resource && o.method === WHOAMI.method);
|
|
1086
1164
|
if (op) {
|
|
1087
|
-
const client = await makeClient(parsed.flags);
|
|
1165
|
+
const client = await makeClient(parsed.flags, op);
|
|
1088
1166
|
const target = (client as unknown as Record<string, Record<string, () => Promise<{ ok: boolean; data?: unknown; error?: unknown }>>>)[op.resource]!;
|
|
1089
1167
|
const result = await target[op.method]!();
|
|
1090
|
-
if (result.ok)
|
|
1168
|
+
if (result.ok) {
|
|
1169
|
+
if (identityConfiguration() && savedIdentity?.identity) assertApiIdentity(savedIdentity.identity.values, readApiIdentity(result.data, IDENTITY_POLICY));
|
|
1170
|
+
report.identity = result.data;
|
|
1171
|
+
}
|
|
1091
1172
|
else {
|
|
1092
1173
|
const why = classifyApiError(result.error, { bin: BIN, hadCredential: true, docsUrl: DOCS_URL_DEFAULT });
|
|
1093
1174
|
report.status = "action_required";
|
|
@@ -1109,10 +1190,11 @@ async function cmdDoctor(parsed: Parsed): Promise<void> {
|
|
|
1109
1190
|
const nodeMajor = Number(process.versions.node.split(".")[0]);
|
|
1110
1191
|
checks.push({ name: "node", ok: nodeMajor >= 18, detail: process.version, ...(nodeMajor >= 18 ? {} : { fix: "Install Node 18 or newer." }) });
|
|
1111
1192
|
checks.push({ name: "cli", ok: true, detail: BIN + " " + VERSION + " (" + PKG_NAME + ")" });
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1193
|
+
let source: string | null = null;
|
|
1194
|
+
let storageProblem: string | undefined;
|
|
1195
|
+
try { source = credentialSource(parsed.flags); } catch (error) { storageProblem = (error as Error).message; }
|
|
1196
|
+
const hasCred = source !== null;
|
|
1197
|
+
checks.push({ name: "credentials", ok: hasCred, detail: storageProblem ?? (source === "login" ? credentialStore().backend : source ?? "none"), ...(hasCred ? {} : { fix: storageProblem ?? (AUTH_SCALARS[0] ? "Set " + AUTH_SCALARS[0].env + " or run '" + BIN + " login'." : "Run '" + BIN + " login'.") }) });
|
|
1116
1198
|
const baseUrl = resolveBaseUrl(parsed.flags);
|
|
1117
1199
|
if (baseUrl) {
|
|
1118
1200
|
try {
|
|
@@ -1128,7 +1210,7 @@ async function cmdDoctor(parsed: Parsed): Promise<void> {
|
|
|
1128
1210
|
const op = OPS.find((o) => o.resource === WHOAMI.resource && o.method === WHOAMI.method);
|
|
1129
1211
|
if (op) {
|
|
1130
1212
|
try {
|
|
1131
|
-
const client = await makeClient(parsed.flags);
|
|
1213
|
+
const client = await makeClient(parsed.flags, op);
|
|
1132
1214
|
const target = (client as unknown as Record<string, Record<string, () => Promise<{ ok: boolean; error?: unknown }>>>)[op.resource]!;
|
|
1133
1215
|
const result = await target[op.method]!();
|
|
1134
1216
|
checks.push(result.ok ? { name: "identity", ok: true, detail: op.command.join(" ") + " ok" } : { name: "identity", ok: false, detail: classifyApiError(result.error, { bin: BIN, hadCredential: true, docsUrl: DOCS_URL_DEFAULT }).message, fix: "The credential was rejected; run '" + BIN + " login' with a current one." });
|
|
@@ -1181,24 +1263,33 @@ async function cmdInit(parsed: Parsed): Promise<void> {
|
|
|
1181
1263
|
const harness = detectHarness();
|
|
1182
1264
|
|
|
1183
1265
|
// 1. credential
|
|
1184
|
-
const stored: StoredCreds = readCreds() ?? {};
|
|
1185
1266
|
const first = AUTH_SCALARS[0];
|
|
1267
|
+
const named = flagCredentials(parsed.flags), envNamed = environmentCredentials();
|
|
1268
|
+
const stored: StoredCreds = Object.keys(named).length || Object.keys(envNamed).length || first && process.env[first.env] ? {} : readCreds() ?? {};
|
|
1186
1269
|
const given = (typeof parsed.flags.get("k") === "string" ? parsed.flags.get("k") as string : undefined)
|
|
1187
1270
|
?? (first && typeof parsed.flags.get(first.flag) === "string" ? parsed.flags.get(first.flag) as string : undefined);
|
|
1188
|
-
if (
|
|
1189
|
-
|
|
1190
|
-
|
|
1271
|
+
if (Object.keys(named).length) {
|
|
1272
|
+
await saveLoginCredentials({ named }, parsed.flags);
|
|
1273
|
+
report.credential = { status: "stored", path: credsPath() };
|
|
1274
|
+
} else if (given && first) {
|
|
1275
|
+
await saveLoginCredentials({ scalars: { [first.option]: given } }, parsed.flags);
|
|
1191
1276
|
report.credential = { status: "stored", path: credsPath() };
|
|
1192
1277
|
} else if (first && process.env[first.env]) {
|
|
1193
1278
|
report.credential = { status: "env", variable: first.env };
|
|
1194
|
-
} else if (
|
|
1279
|
+
} else if (Object.keys(envNamed).length) {
|
|
1280
|
+
report.credential = { status: "env", variable: "TYPESHIP_CREDENTIALS" };
|
|
1281
|
+
} else if (stored.scalars || stored.basic || stored.oauth || stored.named) {
|
|
1195
1282
|
report.credential = { status: "stored", path: credsPath() };
|
|
1283
|
+
} else if (first && HAS_OAUTH_LOGIN && OAUTH_LOGIN_METHOD === "browser" && (process.env[ENV_PREFIX + "_CLIENT_ID"] ?? OAUTH_CLIENT_ID) && !explicitNonInteractive(parsed)) {
|
|
1284
|
+
await acquireOAuthBrowserSession(parsed, (process.env[ENV_PREFIX + "_CLIENT_ID"] ?? OAUTH_CLIENT_ID)!);
|
|
1285
|
+
report.credential = { status: "stored", method: "oauth_browser", path: credsPath() };
|
|
1196
1286
|
} else if (first && CLI_AUTH_URL && !explicitNonInteractive(parsed)) {
|
|
1197
1287
|
// Nothing anywhere: approve a credential in the browser, as `login`
|
|
1198
1288
|
// would, then carry on. Under an agent the URL is printed for the person
|
|
1199
1289
|
// and polled; only the explicit non-interactive switch skips this.
|
|
1290
|
+
await credentialStore().prepare();
|
|
1200
1291
|
const minted = await browserApprove(isAgentMode(parsed) || parsed.flags.get("no-browser") === true, parsed.flags);
|
|
1201
|
-
storeMinted(
|
|
1292
|
+
await storeMinted(minted, parsed.flags);
|
|
1202
1293
|
report.credential = { status: "minted", method: "browser", key_name: minted.key_name, ...(minted.org_id ? { org_id: minted.org_id } : {}), path: credsPath() };
|
|
1203
1294
|
} else {
|
|
1204
1295
|
report.credential = { status: "none" };
|
|
@@ -1303,7 +1394,7 @@ async function cmdUpgrade(parsed: Parsed): Promise<void> {
|
|
|
1303
1394
|
if (manager === "npx") fail(1, "This run came through npx, which fetches the latest version each time; there is nothing to upgrade in place.");
|
|
1304
1395
|
if (manager !== "npm") {
|
|
1305
1396
|
const command = manager === "pnpm" ? "pnpm add -g " + PKG_NAME + "@" + latest : manager === "bun" ? "bun add -g " + PKG_NAME + "@" + latest : "yarn global add " + PKG_NAME + "@" + latest;
|
|
1306
|
-
failWith({ status: "action_required", code: "
|
|
1397
|
+
failWith({ status: "action_required", code: "CALL_FAILED", message: PKG_NAME + " was installed with " + manager + ", so npm can't upgrade it in place.", nextSteps: ["Run: " + command] });
|
|
1307
1398
|
}
|
|
1308
1399
|
process.stderr.write("Upgrading " + PKG_NAME + " " + VERSION + " -> " + latest + "\n");
|
|
1309
1400
|
// Windows resolves npm to npm.cmd, which only a shell can start.
|
|
@@ -1319,7 +1410,7 @@ async function cmdUpgrade(parsed: Parsed): Promise<void> {
|
|
|
1319
1410
|
// completion — shell completion scripts built from the op table
|
|
1320
1411
|
// ---------------------------------------------------------------------------
|
|
1321
1412
|
|
|
1322
|
-
const TOP_WORDS = ["login",
|
|
1413
|
+
const TOP_WORDS = ["login","logout","whoami","config","mcp","docs","upgrade","completion","help","version","init","agent-guide","auth","doctor"];
|
|
1323
1414
|
|
|
1324
1415
|
/** Every flag an API command accepts, with the values a flag completes to (enums, on|off|auto, agent|human). */
|
|
1325
1416
|
function completionFlagsFor(op: OpSpec): { flags: string[]; values: Record<string, string[]> } {
|
|
@@ -1333,13 +1424,13 @@ function completionFlagsFor(op: OpSpec): { flags: string[]; values: Record<strin
|
|
|
1333
1424
|
return { flags, values };
|
|
1334
1425
|
}
|
|
1335
1426
|
|
|
1336
|
-
const COMPLETION_GLOBAL_FLAGS = ["--help", "--version", "--non-interactive", "--color", "--base-url", "--data", "--fields", "--all", "--validate", "--debug", "--mode", "--yes", "--force", "--out", ...AUTH_SCALARS.map((a) => "--" + a.flag)];
|
|
1427
|
+
const COMPLETION_GLOBAL_FLAGS = ["--help", "--version", "--non-interactive", "--color", "--credentials", "--base-url", "--profile", "--data", "--fields", "--all", "--validate", "--debug", "--mode", "--yes", "--force", "--out", ...AUTH_SCALARS.map((a) => "--" + a.flag)];
|
|
1337
1428
|
const BUILTIN_WORDS: Record<string, string[]> = {
|
|
1338
1429
|
config: ["list", "get", "set", "unset", "path"],
|
|
1339
1430
|
completion: ["bash", "zsh", "fish"],
|
|
1340
1431
|
auth: ["check"],
|
|
1341
1432
|
mcp: ["install"],
|
|
1342
|
-
|
|
1433
|
+
|
|
1343
1434
|
};
|
|
1344
1435
|
|
|
1345
1436
|
/** bash (and zsh via bashcompinit): resources, commands, flags, and the values a flag takes. */
|
|
@@ -1439,16 +1530,19 @@ function docsSiteUrl(): string | null {
|
|
|
1439
1530
|
return readConfig().docsUrl ?? DOCS_URL_DEFAULT;
|
|
1440
1531
|
}
|
|
1441
1532
|
|
|
1533
|
+
function docsIndexUrl(): string | null {
|
|
1534
|
+
const configured = readConfig().docsUrl;
|
|
1535
|
+
return configured
|
|
1536
|
+
? resolveDocsContentUrl(configured, null, "llms.txt")
|
|
1537
|
+
: DOCS_INDEX_URL_DEFAULT ?? resolveDocsContentUrl(DOCS_URL_DEFAULT, null, "llms.txt");
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1442
1540
|
/** Fetch llms.txt / llms-full.txt / a prose page from the docs site,
|
|
1443
1541
|
* with a 1h cache in the config dir. Explicit command = explicit fetch;
|
|
1444
1542
|
* nothing here runs unless the user asked for docs. */
|
|
1445
1543
|
async function fetchDocs(pathOrFile: string): Promise<string | null> {
|
|
1446
1544
|
const base = docsSiteUrl();
|
|
1447
|
-
const url =
|
|
1448
|
-
? pathOrFile
|
|
1449
|
-
: base === null
|
|
1450
|
-
? null
|
|
1451
|
-
: base.replace(/\/+$/, "") + "/" + pathOrFile.replace(/^\/+/, "");
|
|
1545
|
+
const url = resolveDocsContentUrl(base, docsIndexUrl(), pathOrFile);
|
|
1452
1546
|
if (url === null) return null;
|
|
1453
1547
|
const cacheFile = join(configDir(), "docs-cache", url.replace(/[^a-zA-Z0-9.]+/g, "_").slice(-120));
|
|
1454
1548
|
try {
|
|
@@ -1456,12 +1550,8 @@ async function fetchDocs(pathOrFile: string): Promise<string | null> {
|
|
|
1456
1550
|
if (Date.now() - stat.mtimeMs < 60 * 60 * 1000) return readFileSync(cacheFile, "utf8");
|
|
1457
1551
|
} catch { /* not cached */ }
|
|
1458
1552
|
try {
|
|
1459
|
-
const
|
|
1460
|
-
|
|
1461
|
-
signal: AbortSignal.timeout(10_000),
|
|
1462
|
-
});
|
|
1463
|
-
if (!response.ok) return null;
|
|
1464
|
-
const text = await response.text();
|
|
1553
|
+
const text = await fetchDocsText(url);
|
|
1554
|
+
if (text === null) return null;
|
|
1465
1555
|
mkdirSync(join(configDir(), "docs-cache"), { recursive: true, mode: 0o700 });
|
|
1466
1556
|
writeFileSync(cacheFile, text);
|
|
1467
1557
|
return text;
|
|
@@ -1594,30 +1684,7 @@ async function cmdDocs(parsed: Parsed): Promise<void> {
|
|
|
1594
1684
|
.filter((match) => match.score > 0)
|
|
1595
1685
|
.sort((a, b) => b.score - a.score || a.op.command.join(" ").localeCompare(b.op.command.join(" ")))
|
|
1596
1686
|
.map((match) => match.op);
|
|
1597
|
-
const
|
|
1598
|
-
const proseMatches: { heading: string; excerpt: string; score: number }[] = [];
|
|
1599
|
-
if (prose !== null) {
|
|
1600
|
-
let heading = "";
|
|
1601
|
-
const terms = searchTerms(term);
|
|
1602
|
-
for (const line of prose.split("\n")) {
|
|
1603
|
-
if (/^#{1,3} /.test(line)) heading = line.replace(/^#+ /, "").trim();
|
|
1604
|
-
else {
|
|
1605
|
-
const lowerHeading = heading.toLowerCase();
|
|
1606
|
-
const lowerLine = line.toLowerCase();
|
|
1607
|
-
const matched = terms.filter((word) => lowerHeading.includes(word) || lowerLine.includes(word));
|
|
1608
|
-
if (matched.length > 0) {
|
|
1609
|
-
const allTerms = matched.length === terms.length;
|
|
1610
|
-
proseMatches.push({
|
|
1611
|
-
heading,
|
|
1612
|
-
excerpt: line.trim().slice(0, 160),
|
|
1613
|
-
score: matched.length * 10 + (allTerms ? 50 : 0) + (lowerHeading.includes(term.toLowerCase()) || lowerLine.includes(term.toLowerCase()) ? 25 : 0),
|
|
1614
|
-
});
|
|
1615
|
-
}
|
|
1616
|
-
}
|
|
1617
|
-
}
|
|
1618
|
-
proseMatches.sort((a, b) => b.score - a.score || a.heading.localeCompare(b.heading) || a.excerpt.localeCompare(b.excerpt));
|
|
1619
|
-
}
|
|
1620
|
-
const docsStatus = docsSiteUrl() === null ? "not_configured" : prose === null ? "unavailable" : "ok";
|
|
1687
|
+
const { guides: proseMatches, status: docsStatus } = await searchConnectedGuides(docsSiteUrl(), docsIndexUrl(), fetchDocs, term);
|
|
1621
1688
|
if (jsonOutput) {
|
|
1622
1689
|
out({
|
|
1623
1690
|
schema_version: "1",
|
|
@@ -1629,7 +1696,7 @@ async function cmdDocs(parsed: Parsed): Promise<void> {
|
|
|
1629
1696
|
...(op.summary ? { summary: op.summary } : {}),
|
|
1630
1697
|
details_command: BIN + " docs " + op.command.join(" ") + " --json",
|
|
1631
1698
|
})),
|
|
1632
|
-
guides: proseMatches.slice(0, 15).map((
|
|
1699
|
+
guides: proseMatches.slice(0, 15).map((match) => ({ ...match, read_command: docsReadCommand(BIN, match.url) })),
|
|
1633
1700
|
totals: { reference: refMatches.length, guides: proseMatches.length },
|
|
1634
1701
|
guides_status: docsStatus,
|
|
1635
1702
|
...(docsStatus === "not_configured" ? { next_steps: ["Run '" + BIN + " config set docs-url <url>' to add guide search; the API reference was still searched."] } : {}),
|
|
@@ -1644,7 +1711,7 @@ async function cmdDocs(parsed: Parsed): Promise<void> {
|
|
|
1644
1711
|
}
|
|
1645
1712
|
if (proseMatches.length > 0) {
|
|
1646
1713
|
lines.push(...(lines.length > 0 ? [""] : []), paintOut("bold", "Guides:"));
|
|
1647
|
-
for (const match of proseMatches.slice(0, 15)) lines.push(" " +
|
|
1714
|
+
for (const match of proseMatches.slice(0, 15)) lines.push(" " + paintOut("cyan", match.title + (match.section ? " / " + match.section : "")), " " + match.excerpt, " " + docsReadCommand(BIN, match.url));
|
|
1648
1715
|
} else if (docsStatus === "not_configured") {
|
|
1649
1716
|
lines.push(...(lines.length > 0 ? [""] : []), "(no docs site configured for guide search: '" + BIN + " config set docs-url <url>')");
|
|
1650
1717
|
} else if (docsStatus === "unavailable") {
|
|
@@ -1661,9 +1728,7 @@ async function cmdDocs(parsed: Parsed): Promise<void> {
|
|
|
1661
1728
|
let target = page!;
|
|
1662
1729
|
if (!/^https?:\/\//.test(target)) {
|
|
1663
1730
|
const index = await fetchDocs("llms.txt");
|
|
1664
|
-
|
|
1665
|
-
const hit = linked.find((u) => u.toLowerCase().includes(target.toLowerCase()));
|
|
1666
|
-
if (hit !== undefined) target = hit;
|
|
1731
|
+
target = docsReadTarget(index, docsSiteUrl(), docsIndexUrl(), target);
|
|
1667
1732
|
}
|
|
1668
1733
|
const text = await fetchDocs(target);
|
|
1669
1734
|
if (text === null) {
|
|
@@ -1678,6 +1743,8 @@ async function cmdDocs(parsed: Parsed): Promise<void> {
|
|
|
1678
1743
|
if (sub !== undefined) {
|
|
1679
1744
|
const op = findOp(sub!, parsed.positionals[2] ?? "");
|
|
1680
1745
|
if (!op) {
|
|
1746
|
+
const omitted = omittedCommand(sub!, parsed.positionals[2]);
|
|
1747
|
+
if (omitted) failOmitted(omitted);
|
|
1681
1748
|
const list = OPS.filter((o) => o.command[0] === sub);
|
|
1682
1749
|
if (list.length === 0) fail(2, "Unknown docs topic: " + sub + ". Run '" + BIN + " docs' for the overview.");
|
|
1683
1750
|
const lines = [paintOut("bold", "Commands for " + sub + ":"), ""];
|
|
@@ -1730,185 +1797,11 @@ async function cmdDocs(parsed: Parsed): Promise<void> {
|
|
|
1730
1797
|
// webhooks listen — forward relayed events to a local handler
|
|
1731
1798
|
// ---------------------------------------------------------------------------
|
|
1732
1799
|
|
|
1733
|
-
interface RelayedEvent {
|
|
1734
|
-
seq: number;
|
|
1735
|
-
method: string;
|
|
1736
|
-
headers: Record<string, string>;
|
|
1737
|
-
content_type: string | null;
|
|
1738
|
-
body: string;
|
|
1739
|
-
}
|
|
1740
1800
|
|
|
1741
|
-
function eventTypeOf(body: string): string | null {
|
|
1742
|
-
try {
|
|
1743
|
-
const parsed = JSON.parse(body) as Record<string, unknown>;
|
|
1744
|
-
for (const key of ["type", "event", "event_type", "eventType", "name"]) {
|
|
1745
|
-
if (typeof parsed[key] === "string") return parsed[key] as string;
|
|
1746
|
-
}
|
|
1747
|
-
} catch { /* not JSON */ }
|
|
1748
|
-
return null;
|
|
1749
|
-
}
|
|
1750
1801
|
|
|
1751
|
-
async function cmdWebhooksFake(parsed: Parsed): Promise<void> {
|
|
1752
|
-
void parsed;
|
|
1753
|
-
fail(2, "This API's spec declares no webhooks, so there is nothing to fake.");
|
|
1754
|
-
}
|
|
1755
1802
|
|
|
1756
|
-
async function cmdWebhooks(parsed: Parsed): Promise<void> {
|
|
1757
|
-
const sub = parsed.positionals[1];
|
|
1758
|
-
if (parsed.help || sub === undefined) {
|
|
1759
|
-
const lines = [
|
|
1760
|
-
BIN + " webhooks — work with this API's webhook events",
|
|
1761
|
-
"",
|
|
1762
|
-
" " + BIN + " webhooks listen --forward-to localhost:3000/webhooks",
|
|
1763
|
-
" " + BIN + " webhooks listen --forward-to localhost:3000/hooks --events account.created,account.updated",
|
|
1764
|
-
" " + BIN + " webhooks fake [<event>] [--key whsec_...] [--forward-to <url>]",
|
|
1765
|
-
"",
|
|
1766
|
-
"listen mints a private relay URL to point the API's webhook settings",
|
|
1767
|
-
"at; events replay locally with their original headers, so signature",
|
|
1768
|
-
"verification keeps working. No tunnels, no exposed ports.",
|
|
1769
|
-
"fake sends (or prints) a signed sample event for local handler testing.",
|
|
1770
|
-
];
|
|
1771
|
-
process.stdout.write(lines.join("\n") + "\n");
|
|
1772
|
-
await flushExit(parsed.help ? 0 : 2);
|
|
1773
|
-
}
|
|
1774
|
-
if (sub === "fake") { await cmdWebhooksFake(parsed); }
|
|
1775
|
-
if (sub !== "listen") fail(2, "Unknown webhooks command: " + String(sub) + ". Try '" + BIN + " webhooks listen' or '" + BIN + " webhooks fake'.");
|
|
1776
|
-
if (!RELAY) {
|
|
1777
|
-
fail(2, "The webhook relay isn't enabled for this package. The API provider can enable it in their typeship console; the next regeneration bakes it in.");
|
|
1778
|
-
}
|
|
1779
|
-
const forwardRaw = parsed.flags.get("forward-to");
|
|
1780
|
-
if (typeof forwardRaw !== "string") {
|
|
1781
|
-
fail(2, "webhooks listen requires --forward-to <url>, e.g. --forward-to localhost:3000/webhooks");
|
|
1782
|
-
}
|
|
1783
|
-
const forwardTo = /^https?:\/\//.test(forwardRaw as string) ? forwardRaw as string : "http://" + (forwardRaw as string);
|
|
1784
|
-
const eventsRaw = parsed.flags.get("events");
|
|
1785
|
-
const eventsFilter = typeof eventsRaw === "string"
|
|
1786
|
-
? eventsRaw.split(",").map((s) => s.trim()).filter(Boolean)
|
|
1787
|
-
: null;
|
|
1788
1803
|
|
|
1789
|
-
interface MintedSession {
|
|
1790
|
-
session_id?: string;
|
|
1791
|
-
ingest_url?: string;
|
|
1792
|
-
poll_url?: string;
|
|
1793
|
-
errors?: { message?: string }[];
|
|
1794
|
-
}
|
|
1795
|
-
let minted: MintedSession | null = null;
|
|
1796
|
-
try {
|
|
1797
|
-
const response = await fetch(RELAY!.mintUrl, {
|
|
1798
|
-
method: "POST",
|
|
1799
|
-
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
|
1800
|
-
body: JSON.stringify({ project: RELAY!.project }),
|
|
1801
|
-
signal: AbortSignal.timeout(15_000),
|
|
1802
|
-
});
|
|
1803
|
-
minted = await response.json().catch(() => null) as MintedSession | null;
|
|
1804
|
-
if (!response.ok || typeof minted?.ingest_url !== "string" || typeof minted.poll_url !== "string") {
|
|
1805
|
-
const detail = minted?.errors?.[0]?.message ?? "HTTP " + response.status;
|
|
1806
|
-
fail(1, "Couldn't start a relay session: " + detail);
|
|
1807
|
-
}
|
|
1808
|
-
} catch (e) {
|
|
1809
|
-
if (e instanceof ExitPending) throw e;
|
|
1810
|
-
fail(1, "Couldn't reach the relay: " + (e as Error).message);
|
|
1811
|
-
}
|
|
1812
1804
|
|
|
1813
|
-
process.stderr.write(paintErr("green", "Ready!") + " Forwarding relayed events to " + forwardTo + "\n");
|
|
1814
|
-
process.stderr.write("Point this API's webhook endpoint at: " + paintErr("cyan", minted!.ingest_url!) + " (^C to quit)\n");
|
|
1815
|
-
|
|
1816
|
-
let cursor = 0;
|
|
1817
|
-
let pollFailures = 0;
|
|
1818
|
-
for (;;) {
|
|
1819
|
-
let payload: { events: RelayedEvent[]; next: number };
|
|
1820
|
-
try {
|
|
1821
|
-
const response = await fetch(minted!.poll_url! + "?after=" + cursor + "&wait=1", {
|
|
1822
|
-
headers: { Accept: "application/json" },
|
|
1823
|
-
signal: AbortSignal.timeout(30_000),
|
|
1824
|
-
});
|
|
1825
|
-
if (response.status === 404) fail(1, "The relay session expired. Run '" + BIN + " webhooks listen' again.");
|
|
1826
|
-
if (!response.ok) throw new Error("HTTP " + response.status);
|
|
1827
|
-
payload = await response.json() as typeof payload;
|
|
1828
|
-
pollFailures = 0;
|
|
1829
|
-
} catch (e) {
|
|
1830
|
-
if (e instanceof ExitPending) throw e;
|
|
1831
|
-
pollFailures++;
|
|
1832
|
-
if (pollFailures > 5) fail(1, "Lost the relay: " + (e as Error).message);
|
|
1833
|
-
await new Promise((resolve) => setTimeout(resolve, 2000 * pollFailures));
|
|
1834
|
-
continue;
|
|
1835
|
-
}
|
|
1836
|
-
for (const event of payload.events) {
|
|
1837
|
-
cursor = event.seq;
|
|
1838
|
-
const type = eventTypeOf(event.body);
|
|
1839
|
-
if (eventsFilter !== null && (type === null || !eventsFilter.includes(type))) continue;
|
|
1840
|
-
const started = Date.now();
|
|
1841
|
-
try {
|
|
1842
|
-
const forwarded = await fetch(forwardTo, {
|
|
1843
|
-
method: event.method,
|
|
1844
|
-
headers: event.headers,
|
|
1845
|
-
body: event.method === "GET" ? undefined : event.body,
|
|
1846
|
-
signal: AbortSignal.timeout(30_000),
|
|
1847
|
-
});
|
|
1848
|
-
process.stderr.write(
|
|
1849
|
-
(forwarded.ok ? paintErr("green", String(forwarded.status)) : paintErr("yellow", String(forwarded.status))) +
|
|
1850
|
-
" " + (type ?? event.method) + " [" + event.seq + "] (" + (Date.now() - started) + "ms)\n",
|
|
1851
|
-
);
|
|
1852
|
-
} catch (e) {
|
|
1853
|
-
process.stderr.write(paintErr("yellow", "unreachable") + " " + (type ?? event.method) + " [" + event.seq + "]: " + (e as Error).message + "\n");
|
|
1854
|
-
}
|
|
1855
|
-
}
|
|
1856
|
-
if (payload.next > cursor) cursor = payload.next;
|
|
1857
|
-
}
|
|
1858
|
-
}
|
|
1859
|
-
|
|
1860
|
-
async function cmdFeedback(parsed: Parsed): Promise<void> {
|
|
1861
|
-
if (parsed.help) {
|
|
1862
|
-
process.stdout.write(BIN + " feedback — open the API provider's issue tracker with environment details prefilled\n");
|
|
1863
|
-
await flushExit(0);
|
|
1864
|
-
}
|
|
1865
|
-
if (!SUPPORT_URL) {
|
|
1866
|
-
fail(2, "No support URL is configured for this CLI. The API provider can set one in their typeship console.");
|
|
1867
|
-
}
|
|
1868
|
-
let target = SUPPORT_URL!;
|
|
1869
|
-
if (/github\.com\/[^/]+\/[^/]+\/issues\/new/.test(target)) {
|
|
1870
|
-
const bodyLines = [
|
|
1871
|
-
"<!-- describe the problem or request above the line -->",
|
|
1872
|
-
"",
|
|
1873
|
-
"---",
|
|
1874
|
-
"- cli: " + BIN + " " + VERSION + " (api " + API_VERSION + ")",
|
|
1875
|
-
"- node: " + process.version,
|
|
1876
|
-
"- platform: " + process.platform + "/" + process.arch,
|
|
1877
|
-
];
|
|
1878
|
-
const sep = target.includes("?") ? "&" : "?";
|
|
1879
|
-
target += sep + "title=" + encodeURIComponent("[" + BIN + "] ") + "&body=" + encodeURIComponent(bodyLines.join("\n"));
|
|
1880
|
-
}
|
|
1881
|
-
if (!nonInteractive(parsed)) process.stderr.write("Opening " + paintErr("cyan", SUPPORT_URL!) + "\n");
|
|
1882
|
-
const opened = openInBrowser(target, parsed);
|
|
1883
|
-
if (opened.opened) out({ ok: true, opened: SUPPORT_URL });
|
|
1884
|
-
await flushExit(0);
|
|
1885
|
-
}
|
|
1886
|
-
|
|
1887
|
-
/** Opt-in (console setting) once-a-day upgrade hint. Off by default:
|
|
1888
|
-
* generated code phones nobody unless the project owner chose this. The
|
|
1889
|
-
* notice prints the previous run's cached answer; at most once a day it
|
|
1890
|
-
* refreshes the cache first, with a 1.5s cap so a slow registry can't
|
|
1891
|
-
* hold a command hostage. */
|
|
1892
|
-
async function maybeUpdateNotice(commandWord: string | undefined, quiet: boolean): Promise<void> {
|
|
1893
|
-
if (!UPDATE_NOTICE || quiet) return;
|
|
1894
|
-
if (commandWord === undefined || ["upgrade", "version", "help", "login", "logout"].includes(commandWord)) return;
|
|
1895
|
-
const cacheFile = join(configDir(), "update-check.json");
|
|
1896
|
-
let cache: { checkedAt?: number; latest?: string } = {};
|
|
1897
|
-
try {
|
|
1898
|
-
cache = JSON.parse(readFileSync(cacheFile, "utf8")) as typeof cache;
|
|
1899
|
-
} catch { /* no cache yet */ }
|
|
1900
|
-
if ((cache.checkedAt ?? 0) < Date.now() - 24 * 60 * 60 * 1000) {
|
|
1901
|
-
const latest = await latestVersion(1500);
|
|
1902
|
-
if (latest !== null) {
|
|
1903
|
-
cache = { checkedAt: Date.now(), latest };
|
|
1904
|
-
mkdirSync(configDir(), { recursive: true, mode: 0o700 });
|
|
1905
|
-
writeFileSync(cacheFile, JSON.stringify(cache) + "\n");
|
|
1906
|
-
}
|
|
1907
|
-
}
|
|
1908
|
-
if (cache.latest !== undefined && semverLess(VERSION, cache.latest)) {
|
|
1909
|
-
process.stderr.write(paintErr("yellow", "A newer " + BIN + " is available: " + VERSION + " -> " + cache.latest + ". Run '" + BIN + " upgrade'.") + "\n");
|
|
1910
|
-
}
|
|
1911
|
-
}
|
|
1912
1805
|
|
|
1913
1806
|
/** The command that fetches the next page: same positionals, the next page's query flags. */
|
|
1914
1807
|
function nextCommandFor(op: OpSpec, pathValues: string[], next: Record<string, unknown>): string {
|
|
@@ -2051,7 +1944,7 @@ function printRoot(stream: NodeJS.WriteStream = process.stdout): void {
|
|
|
2051
1944
|
}
|
|
2052
1945
|
const width = termWidth();
|
|
2053
1946
|
const lines: string[] = [];
|
|
2054
|
-
lines.push(paintOut("bold", BIN) + ": " + "typeship" + " (v" + "0.
|
|
1947
|
+
lines.push(paintOut("bold", BIN) + ": " + "typeship API" + " (v" + "1.0.0" + "), package " + "0.9.1");
|
|
2055
1948
|
lines.push("");
|
|
2056
1949
|
lines.push(paintOut("bold", "Usage:") + " " + BIN + " <resource> <command> [args] [--flags]");
|
|
2057
1950
|
lines.push("");
|
|
@@ -2068,12 +1961,18 @@ function printRoot(stream: NodeJS.WriteStream = process.stdout): void {
|
|
|
2068
1961
|
lines.push(" " + padPaint("cyan", resource, col) + wrapped[0]);
|
|
2069
1962
|
for (const more of wrapped.slice(1)) lines.push(" ".repeat(2 + col) + more);
|
|
2070
1963
|
}
|
|
1964
|
+
if (EXCLUDED_OPS > 0) {
|
|
1965
|
+
lines.push("");
|
|
1966
|
+
lines.push(...labeled(paintOut("yellow", "Plan limit:") + " ", "generated " + OPS.length + " of " + (OPS.length + EXCLUDED_OPS) + " operations", width, 14));
|
|
1967
|
+
lines.push(...labeled("Omitted: ", OMITTED_OPS.map((op) => op.command.join(" ") + " (" + op.httpMethod + " " + op.path + ")").join(", "), width, 14));
|
|
1968
|
+
lines.push(...labeled("Upgrade: ", "https://typeship.dev/pricing, then regenerate without the operation cap", width, 14));
|
|
1969
|
+
}
|
|
2071
1970
|
lines.push("");
|
|
2072
|
-
const flagsText = "-v/--version, -h/--help, --debug, --non-interactive, --color on|off|auto, --base-url <url>, --data '<json>', --fields <a,b.c>, --all (paginated lists), --validate (schema-check bodies)" +
|
|
1971
|
+
const flagsText = "-v/--version, -h/--help, --debug, --non-interactive, --color on|off|auto, --base-url <url>, --profile <name>, --credentials @<file>|-, --data '<json>', --fields <a,b.c>, --all (paginated lists), --validate (schema-check bodies)" +
|
|
2073
1972
|
(AUTH_SCALARS.length > 0 ? ", " + AUTH_SCALARS.map((a) => "--" + a.flag + " <value>").join(", ") : "");
|
|
2074
1973
|
lines.push(...labeled(paintOut("bold", "Global flags:") + " ", flagsText, width, 14).map((l, i) => (i === 0 ? l : l)));
|
|
2075
1974
|
lines.push(...labeled("Credential env vars: ", [
|
|
2076
|
-
...AUTH_SCALARS.map((a) => a.env),
|
|
1975
|
+
"TYPESHIP_CREDENTIALS", ...AUTH_SCALARS.map((a) => a.env),
|
|
2077
1976
|
...(BASIC ? [BASIC.envUser, BASIC.envPass] : []),
|
|
2078
1977
|
].join(", ") || "none", width, 21));
|
|
2079
1978
|
lines.push(...labeled("Endpoint env var: ", "TYPESHIP_BASE_URL", width, 18));
|
|
@@ -2083,9 +1982,9 @@ function printRoot(stream: NodeJS.WriteStream = process.stdout): void {
|
|
|
2083
1982
|
lines.push(...labeled("Docs: ", BIN + " docs [<resource> <command> | search <term> | read <page> | --web]", width, 6));
|
|
2084
1983
|
if (RELAY) lines.push("Webhooks: " + BIN + " webhooks listen --forward-to <url> (local event forwarding)");
|
|
2085
1984
|
if (SUPPORT_URL) lines.push("Feedback: " + BIN + " feedback (opens the provider's issue tracker)");
|
|
2086
|
-
if (
|
|
1985
|
+
if (MCP_EXCLUDED_OPS > 0 && HAS_MCP) {
|
|
2087
1986
|
lines.push("");
|
|
2088
|
-
lines.push("Note: " +
|
|
1987
|
+
lines.push("Note: " + MCP_EXCLUDED_OPS + " generated operation(s) with uploads or event streams are CLI/SDK-only, not MCP tools.");
|
|
2089
1988
|
}
|
|
2090
1989
|
lines.push("");
|
|
2091
1990
|
lines.push("Run '" + BIN + " <resource>' for a resource's commands, '" + BIN + " <resource> <command> --help' for flags.");
|
|
@@ -2115,6 +2014,7 @@ function printResource(resource: string, stream: NodeJS.WriteStream = process.st
|
|
|
2115
2014
|
/** Flags the CLI itself adds to an API command, as [flag, description] rows. */
|
|
2116
2015
|
function commandExtras(op: OpSpec): [string, string][] {
|
|
2117
2016
|
const extras: [string, string][] = [];
|
|
2017
|
+
if (op.auth !== "none" && Object.keys(NAMED_SCHEMES).length) extras.push(["--credentials @<file>|-", "named credentials as JSON; use - for stdin"]);
|
|
2118
2018
|
if (op.hasBody && op.bodyKind === "binary") extras.push(["--file <path>", "raw request body, uploaded as-is (- reads stdin)"]);
|
|
2119
2019
|
else if (op.hasBody) extras.push(["--data '<json>'", "raw JSON body" + (op.bodyStyle === "fields" ? " (merged under field flags)" : "") + "; @<file> reads a file, - reads stdin"]);
|
|
2120
2020
|
if (op.select) extras.push(["--select '<selection>'", "GraphQL selection set replacing the default, e.g. '{ id name }'"]);
|
|
@@ -2158,7 +2058,7 @@ function exampleLine(op: OpSpec): string {
|
|
|
2158
2058
|
|
|
2159
2059
|
/** " (no auth needed)" for an anonymous operation in an API that otherwise authenticates. */
|
|
2160
2060
|
function authNote(op: OpSpec): string {
|
|
2161
|
-
const apiHasAuth = AUTH_SCALARS.length > 0 || BASIC !== null ||
|
|
2061
|
+
const apiHasAuth = AUTH_SCALARS.length > 0 || BASIC !== null || HAS_OAUTH_LOGIN;
|
|
2162
2062
|
return apiHasAuth && op.auth === "none" ? " (no auth needed)" : apiHasAuth && op.auth === "optional" ? " (auth optional)" : "";
|
|
2163
2063
|
}
|
|
2164
2064
|
|
|
@@ -2310,21 +2210,48 @@ function coerce(spec: ParamSpec, raw: string | boolean, repeated?: string[]): un
|
|
|
2310
2210
|
/** Whether the last client built carried any credential; failApi tells NO_AUTH from AUTH_INVALID with it. */
|
|
2311
2211
|
let LAST_CLIENT_HAD_CREDENTIAL = false;
|
|
2312
2212
|
|
|
2213
|
+
function validateCredentialsInput(flags: Map<string, string | boolean>): void {
|
|
2214
|
+
const input = flags.get("credentials");
|
|
2215
|
+
if (BASIC && flags.has("username") !== flags.has("password")) fail(2, "Supply --username and --password together, or use a complete named Basic credential.");
|
|
2216
|
+
if (input === undefined) return;
|
|
2217
|
+
if (typeof input !== "string" || (input !== "-" && (!input.startsWith("@") || input.length < 2))) fail(2, "--credentials expects @<JSON-file> or - to read JSON from stdin.");
|
|
2218
|
+
if (input === "-" && (flags.get("data") === "-" || flags.get("file") === "-" || flags.get("with-token") === true)) fail(2, "Stdin cannot supply both named credentials and another input. Use a credential file instead.");
|
|
2219
|
+
}
|
|
2220
|
+
let namedFlagCache: NamedCredentials | undefined;
|
|
2221
|
+
function flagCredentials(flags: Map<string, string | boolean>): NamedCredentials {
|
|
2222
|
+
if (namedFlagCache) return namedFlagCache;
|
|
2223
|
+
validateCredentialsInput(flags);
|
|
2224
|
+
const input = flags.get("credentials");
|
|
2225
|
+
if (input === undefined) return {};
|
|
2226
|
+
try {
|
|
2227
|
+
const named = readNamedCredentialsFile(input as string, NAMED_SCHEMES);
|
|
2228
|
+
if (!Object.keys(named).length) fail(2, "The named credential file must contain at least one security scheme.");
|
|
2229
|
+
return namedFlagCache = named;
|
|
2230
|
+
}
|
|
2231
|
+
catch (error) { fail(2, (error as Error).message); }
|
|
2232
|
+
}
|
|
2233
|
+
function environmentCredentials(): NamedCredentials {
|
|
2234
|
+
if (BASIC && (process.env[BASIC.envUser] !== undefined) !== (process.env[BASIC.envPass] !== undefined)) fail(2, "Supply both Basic-auth environment variables together, or use a complete named Basic credential.");
|
|
2235
|
+
const value = process.env["TYPESHIP_CREDENTIALS"];
|
|
2236
|
+
return value === undefined ? {} : parseNamedCredentials(value, NAMED_SCHEMES);
|
|
2237
|
+
}
|
|
2238
|
+
|
|
2313
2239
|
/** Where a credential would come from, without sending it: "flags", "env:<VAR>", "login", or null. */
|
|
2314
2240
|
function credentialSource(flags: Map<string, string | boolean>): string | null {
|
|
2241
|
+
if (Object.keys(flagCredentials(flags)).length) return "flags";
|
|
2315
2242
|
if (AUTH_SCALARS.some((a) => typeof flags.get(a.flag) === "string")) return "flags";
|
|
2316
2243
|
if (BASIC && typeof flags.get("username") === "string" && typeof flags.get("password") === "string") return "flags";
|
|
2244
|
+
if (Object.keys(environmentCredentials()).length) return "env:TYPESHIP_CREDENTIALS";
|
|
2317
2245
|
const envScalar = AUTH_SCALARS.find((a) => process.env[a.env] !== undefined);
|
|
2318
2246
|
if (envScalar) return "env:" + envScalar.env;
|
|
2319
2247
|
if (BASIC && process.env[BASIC.envUser] !== undefined && process.env[BASIC.envPass] !== undefined) return "env:" + BASIC.envUser;
|
|
2320
2248
|
const stored = readCreds();
|
|
2321
|
-
if (stored && (stored.scalars || stored.basic || stored.oauth)) return "login";
|
|
2249
|
+
if (stored && (stored.scalars || stored.basic || stored.oauth || stored.named)) return "login";
|
|
2322
2250
|
return null;
|
|
2323
2251
|
}
|
|
2324
2252
|
|
|
2325
2253
|
/** Base URL resolution: --base-url > env > config base-url > config environment > spec default. */
|
|
2326
|
-
function resolveBaseUrl(flags: Map<string, string | boolean
|
|
2327
|
-
const config = readConfig();
|
|
2254
|
+
function resolveBaseUrl(flags: Map<string, string | boolean>, config = readConfig()): string | undefined {
|
|
2328
2255
|
return (typeof flags.get("base-url") === "string" ? flags.get("base-url") as string : undefined)
|
|
2329
2256
|
?? process.env["TYPESHIP_BASE_URL"]
|
|
2330
2257
|
?? config.baseUrl
|
|
@@ -2332,12 +2259,23 @@ function resolveBaseUrl(flags: Map<string, string | boolean>): string | undefine
|
|
|
2332
2259
|
?? DEFAULT_BASE_URL ?? undefined;
|
|
2333
2260
|
}
|
|
2334
2261
|
|
|
2335
|
-
async function makeClient(flags: Map<string, string | boolean
|
|
2336
|
-
const
|
|
2337
|
-
const
|
|
2338
|
-
|
|
2262
|
+
async function makeClient(flags: Map<string, string | boolean>, op: OpSpec, candidate?: StoredCreds, forIdentity = false): Promise<TypeshipClient> {
|
|
2263
|
+
const flagNamed = flagCredentials(flags), envNamed = environmentCredentials();
|
|
2264
|
+
const explicitOptions = new Set(AUTH_SCALARS.filter((a) => typeof flags.get(a.flag) === "string" || process.env[a.env] !== undefined).map((a) => a.option));
|
|
2265
|
+
if (BASIC && (typeof flags.get("username") === "string" || process.env[BASIC.envUser] !== undefined) && (typeof flags.get("password") === "string" || process.env[BASIC.envPass] !== undefined)) explicitOptions.add("basicAuth");
|
|
2266
|
+
namedCredentialAvailability(NAMED_SCHEMES, explicitOptions, { ...envNamed, ...flagNamed });
|
|
2267
|
+
const explicitCredentials = op.credentialOptions?.some((alternative) => alternative.length > 0 && alternative.every((option) => explicitOptions.has(option)));
|
|
2268
|
+
// A complete explicit alternative for this endpoint does not unlock or mix
|
|
2269
|
+
// in saved credentials for another API, account, or authentication method.
|
|
2270
|
+
const stored = candidate ?? (op.auth === "none" || explicitCredentials ? null : readCreds());
|
|
2271
|
+
const config = readConfig();
|
|
2272
|
+
const baseUrl = resolveBaseUrl(flags, config);
|
|
2339
2273
|
if (baseUrl === undefined) fail(2, "No base URL. Pass --base-url, set TYPESHIP_BASE_URL, or run '" + BIN + " config set base-url <url>'.");
|
|
2340
|
-
|
|
2274
|
+
if (stored) {
|
|
2275
|
+
assertCredentialDestination(stored, { apiBaseUrl: baseUrl, environment: config.environment, profile: PROFILE.name });
|
|
2276
|
+
if (!forIdentity) assertStoredIdentity(stored, identityConfiguration());
|
|
2277
|
+
}
|
|
2278
|
+
const options: ClientOptions & Record<string, unknown> = { baseUrl };
|
|
2341
2279
|
for (const a of AUTH_SCALARS) {
|
|
2342
2280
|
const v = (typeof flags.get(a.flag) === "string" ? flags.get(a.flag) as string : undefined)
|
|
2343
2281
|
?? process.env[a.env] ?? stored?.scalars?.[a.option];
|
|
@@ -2350,9 +2288,24 @@ async function makeClient(flags: Map<string, string | boolean>): Promise<Typeshi
|
|
|
2350
2288
|
?? process.env[BASIC.envPass] ?? stored?.basic?.password;
|
|
2351
2289
|
if (username !== undefined && password !== undefined) options.basicAuth = { username, password };
|
|
2352
2290
|
}
|
|
2353
|
-
|
|
2354
|
-
|
|
2355
|
-
if (
|
|
2291
|
+
const envOptions: Record<string, unknown> = {}, flagOptions: Record<string, unknown> = {};
|
|
2292
|
+
for (const a of AUTH_SCALARS) {
|
|
2293
|
+
if (process.env[a.env] !== undefined) envOptions[a.option] = process.env[a.env];
|
|
2294
|
+
if (typeof flags.get(a.flag) === "string") flagOptions[a.option] = flags.get(a.flag);
|
|
2295
|
+
}
|
|
2296
|
+
if (BASIC && process.env[BASIC.envUser] !== undefined && process.env[BASIC.envPass] !== undefined) envOptions.basicAuth = { username: process.env[BASIC.envUser], password: process.env[BASIC.envPass] };
|
|
2297
|
+
if (BASIC && typeof flags.get("username") === "string" && typeof flags.get("password") === "string") flagOptions.basicAuth = { username: flags.get("username"), password: flags.get("password") };
|
|
2298
|
+
options.credentials = resolveNamedCredentials(NAMED_SCHEMES, [
|
|
2299
|
+
{ named: stored?.named, options: { ...stored?.scalars, ...(stored?.basic ? { basicAuth: stored.basic } : {}) } },
|
|
2300
|
+
{ named: envNamed, options: envOptions }, { named: flagNamed, options: flagOptions },
|
|
2301
|
+
...(forIdentity && candidate ? [{ named: candidate.named, options: { ...candidate.scalars, ...(candidate.basic ? { basicAuth: candidate.basic } : {}), ...(candidate.oauth ? { bearerToken: candidate.oauth.accessToken } : {}) } }] : []),
|
|
2302
|
+
]);
|
|
2303
|
+
if (stored?.oauth && (forIdentity || options.bearerToken === undefined)) {
|
|
2304
|
+
const sessionId = stored.oauth.sessionId;
|
|
2305
|
+
options.bearerToken = forIdentity ? stored.oauth.accessToken : () => oauthSessionToken(credentialStore(), {
|
|
2306
|
+
...sessionConfiguration(baseUrl, config),
|
|
2307
|
+
...(identityConfiguration() && WHOAMI ? { verifyIdentity: (accessToken: string) => verifyClientIdentity((values) => new TypeshipClient(values as unknown as ClientOptions), { ...options, bearerToken: accessToken, credentials: resolveNamedCredentials(NAMED_SCHEMES, [{ named: options.credentials as NamedCredentials }, { options: { bearerToken: accessToken } }]) }, WHOAMI!, IDENTITY_POLICY) } : {}),
|
|
2308
|
+
}, sessionId, OAUTH_TOKEN_PARAMS);
|
|
2356
2309
|
}
|
|
2357
2310
|
if (flags.get("debug") === true || process.env["TYPESHIP_DEBUG"] === "1") {
|
|
2358
2311
|
options.debug = (event: DebugEvent) => process.stderr.write(paintErr("dim", formatDebugEvent(BIN, event)) + "\n");
|
|
@@ -2363,7 +2316,7 @@ async function makeClient(flags: Map<string, string | boolean>): Promise<Typeshi
|
|
|
2363
2316
|
const value = typeof flagValue === "string" ? flagValue : process.env["TYPESHIP_" + g.envSuffix];
|
|
2364
2317
|
if (value !== undefined) options[g.option] = value;
|
|
2365
2318
|
}
|
|
2366
|
-
LAST_CLIENT_HAD_CREDENTIAL = AUTH_SCALARS.some((a) => options[a.option] !== undefined) || options.basicAuth !== undefined || options.bearerToken !== undefined;
|
|
2319
|
+
LAST_CLIENT_HAD_CREDENTIAL = Object.keys(options.credentials ?? {}).length > 0 || AUTH_SCALARS.some((a) => options[a.option] !== undefined) || options.basicAuth !== undefined || options.bearerToken !== undefined;
|
|
2367
2320
|
// Who is calling: the CLI, under which agent harness, and whether an
|
|
2368
2321
|
// agent is driving. "agent" means a harness was detected or the caller
|
|
2369
2322
|
// said so (--mode agent / env); a bare non-TTY run (CI, a pipeline) is
|
|
@@ -2377,7 +2330,8 @@ async function makeClient(flags: Map<string, string | boolean>): Promise<Typeshi
|
|
|
2377
2330
|
...(options.defaultHeaders as Record<string, string> | undefined),
|
|
2378
2331
|
"User-Agent": PKG_NAME + "-cli/" + VERSION + " (typeship" + (harness ? "; harness=" + harness : "") + caller + ")",
|
|
2379
2332
|
};
|
|
2380
|
-
|
|
2333
|
+
if (forIdentity) { options.fetch = identityFetch(baseUrl); options.maxRetries = 0; options.timeoutMs = 10_000; }
|
|
2334
|
+
return new TypeshipClient(options);
|
|
2381
2335
|
}
|
|
2382
2336
|
|
|
2383
2337
|
function editDistance(a: string, b: string): number {
|
|
@@ -2405,11 +2359,31 @@ function didYouMean(input: string, candidates: Iterable<string>): string | undef
|
|
|
2405
2359
|
return best;
|
|
2406
2360
|
}
|
|
2407
2361
|
|
|
2408
|
-
|
|
2362
|
+
function omittedCommand(resource: string, method?: string): OmittedOpSpec | undefined {
|
|
2363
|
+
return OMITTED_OPS.find((op) => op.command[0] === resource &&
|
|
2364
|
+
(method === undefined || op.command[1] === method || op.commandAlias === method));
|
|
2365
|
+
}
|
|
2366
|
+
|
|
2367
|
+
function failOmitted(op: OmittedOpSpec): never {
|
|
2368
|
+
return failWith({
|
|
2369
|
+
status: "action_required",
|
|
2370
|
+
code: "PLAN_LIMIT",
|
|
2371
|
+
message: "The command '" + BIN + " " + op.command.join(" ") + "' exists in the API Definition but was omitted from this generated package by its plan limit.",
|
|
2372
|
+
detail: { operation: op.tool, method: op.httpMethod, path: op.path, generated_operations: OPS.length, total_operations: OPS.length + EXCLUDED_OPS },
|
|
2373
|
+
nextSteps: ["Upgrade at https://typeship.dev/pricing and regenerate the package without the operation cap.", "Do not invent or retry an omitted command against this generated package."],
|
|
2374
|
+
});
|
|
2375
|
+
}
|
|
2376
|
+
|
|
2377
|
+
const BUILTIN_COMMANDS = ["login","logout","whoami","config","mcp","docs","upgrade","completion","help","version","init","agent-guide","auth","doctor"];
|
|
2409
2378
|
|
|
2410
2379
|
async function main(): Promise<void> {
|
|
2411
2380
|
const argv = process.argv.slice(2);
|
|
2412
2381
|
const parsed = parseArgv(argv);
|
|
2382
|
+
if (!parsed.help) validateCredentialsInput(parsed.flags);
|
|
2383
|
+
const profileFlag = parsed.flags.get("profile");
|
|
2384
|
+
if (profileFlag !== undefined && typeof profileFlag !== "string") fail(2, "--profile requires a profile name.");
|
|
2385
|
+
PROFILE = resolveProfile(configRoot(), { flag: profileFlag as string | undefined, environment: process.env["TYPESHIP_PROFILE"], allowMissing: ["login", "config", "auth", "init", "help"].includes(parsed.positionals[0] ?? "") || parsed.help });
|
|
2386
|
+
if (!parsed.help && ["login", "init"].includes(parsed.positionals[0] ?? "")) expectedLoginIdentity(parsed.flags);
|
|
2413
2387
|
if (parsed.positionals[0] === "help") {
|
|
2414
2388
|
// help --json: the command surface as data (agents read this once).
|
|
2415
2389
|
if (parsed.flags.get("json") === true || parsed.flags.get("format") === "json") { out(helpJson()); await flushExit(0); }
|
|
@@ -2430,25 +2404,25 @@ async function main(): Promise<void> {
|
|
|
2430
2404
|
COLOR_ERR = colorEnabled(process.stderr, parsed);
|
|
2431
2405
|
// Prose errors for a person: stderr is a terminal (or --mode human says
|
|
2432
2406
|
// to behave as if), and nothing asked for JSON.
|
|
2433
|
-
const forcedHuman = parsed.flags.get("mode") === "human" ||
|
|
2407
|
+
const forcedHuman = parsed.flags.get("mode") === "human" || process.env["TYPESHIP_MODE"] === "human";
|
|
2434
2408
|
HUMAN_ERRORS = (process.stderr.isTTY === true || forcedHuman) && !isAgentMode(parsed) && parsed.flags.get("format") !== "json" && parsed.flags.get("json") !== true;
|
|
2435
|
-
|
|
2409
|
+
|
|
2436
2410
|
|
|
2437
2411
|
if (resourceCmd === "init") { await cmdInit(parsed); }
|
|
2438
2412
|
if (resourceCmd === "agent-guide") { await cmdAgentGuide(parsed); }
|
|
2439
2413
|
if (resourceCmd === "auth") { await cmdAuth(parsed); }
|
|
2440
2414
|
if (resourceCmd === "doctor") { await cmdDoctor(parsed); }
|
|
2441
2415
|
if (resourceCmd === "upgrade") { await cmdUpgrade(parsed); }
|
|
2442
|
-
|
|
2443
|
-
|
|
2416
|
+
|
|
2417
|
+
|
|
2444
2418
|
if (resourceCmd === "docs") { await cmdDocs(parsed); }
|
|
2445
2419
|
if (resourceCmd === "completion") { await cmdCompletion(parsed); }
|
|
2446
2420
|
if (resourceCmd === "config") { await cmdConfig(parsed); }
|
|
2447
2421
|
if (resourceCmd === "mcp") { await cmdMcp(parsed); }
|
|
2448
2422
|
if (resourceCmd === "login") { await cmdLogin(parsed); }
|
|
2449
2423
|
if (resourceCmd === "logout") {
|
|
2450
|
-
if (parsed.help) { process.stdout.write(BIN + " logout — remove " + credsPath() + "\n"); await flushExit(0); }
|
|
2451
|
-
await cmdLogout();
|
|
2424
|
+
if (parsed.help) { process.stdout.write(BIN + " logout — remove " + credsPath() + "\n --local remove the local session without unlocking OS storage or revoking tokens\n"); await flushExit(0); }
|
|
2425
|
+
await cmdLogout(parsed);
|
|
2452
2426
|
}
|
|
2453
2427
|
if (resourceCmd === "whoami") {
|
|
2454
2428
|
if (parsed.help) {
|
|
@@ -2465,6 +2439,8 @@ async function main(): Promise<void> {
|
|
|
2465
2439
|
if (!resourceCmd) { printRoot(parsed.help ? process.stdout : process.stderr); await flushExit(parsed.help ? 0 : 2); }
|
|
2466
2440
|
const resourceExists = OPS.some((o) => o.command[0] === resourceCmd);
|
|
2467
2441
|
if (!resourceExists) {
|
|
2442
|
+
const omitted = omittedCommand(resourceCmd, methodCmd);
|
|
2443
|
+
if (omitted) failOmitted(omitted);
|
|
2468
2444
|
const suggestion = didYouMean(resourceCmd, [...new Set(OPS.map((o) => o.command[0])), ...BUILTIN_COMMANDS]);
|
|
2469
2445
|
fail(2, "Unknown command: " + resourceCmd + "." + (suggestion ? " Did you mean '" + BIN + " " + suggestion + "'?" : ""),
|
|
2470
2446
|
undefined, ["Run '" + BIN + " --help' for the commands."]);
|
|
@@ -2473,6 +2449,8 @@ async function main(): Promise<void> {
|
|
|
2473
2449
|
|
|
2474
2450
|
const op = findOp(resourceCmd, methodCmd);
|
|
2475
2451
|
if (!op) {
|
|
2452
|
+
const omitted = omittedCommand(resourceCmd, methodCmd);
|
|
2453
|
+
if (omitted) failOmitted(omitted);
|
|
2476
2454
|
const suggestion = didYouMean(methodCmd, OPS.filter((o) => o.command[0] === resourceCmd).map((o) => o.command[1]));
|
|
2477
2455
|
fail(2, "Unknown command: " + resourceCmd + " " + methodCmd + "." + (suggestion ? " Did you mean '" + BIN + " " + resourceCmd + " " + suggestion + "'?" : ""),
|
|
2478
2456
|
undefined, ["Run '" + BIN + " " + resourceCmd + "' for its commands."]);
|
|
@@ -2508,7 +2486,7 @@ async function main(): Promise<void> {
|
|
|
2508
2486
|
// Mirrors opReservedFlags() in the generator: API parameters never use these
|
|
2509
2487
|
// names (colliding ones are emitted as --<kind>-<name>), so an unknown flag
|
|
2510
2488
|
// check can be exact.
|
|
2511
|
-
const RESERVED_FLAGS = new Set(["data", "all", "select", "base-url", "debug", "validate", "non-interactive", "color", "version", "help", "yes", "force", "mode", "format", "json", "out", "fields", ...AUTH_SCALARS.map((a) => a.flag), ...(BASIC ? ["username", "password"] : []), ...GLOBALS.map((g) => g.flag)]);
|
|
2489
|
+
const RESERVED_FLAGS = new Set(["data", "credentials", "all", "select", "base-url", "profile", "debug", "validate", "non-interactive", "color", "version", "help", "yes", "force", "mode", "format", "json", "out", "fields", ...AUTH_SCALARS.map((a) => a.flag), ...(BASIC ? ["username", "password"] : []), ...GLOBALS.map((g) => g.flag)]);
|
|
2512
2490
|
for (const spec of op.params) {
|
|
2513
2491
|
if (spec.kind === "path") continue;
|
|
2514
2492
|
const raw = parsed.flags.get(spec.flag);
|
|
@@ -2563,7 +2541,7 @@ async function main(): Promise<void> {
|
|
|
2563
2541
|
|
|
2564
2542
|
// The spec says this operation needs a credential and none resolved:
|
|
2565
2543
|
// say so now, locally, instead of sending a request to learn it.
|
|
2566
|
-
if (op.auth === "required" && (AUTH_SCALARS.length > 0 || BASIC ||
|
|
2544
|
+
if (op.auth === "required" && (AUTH_SCALARS.length > 0 || BASIC || HAS_OAUTH_LOGIN) && credentialSource(parsed.flags) === null) {
|
|
2567
2545
|
failWith({
|
|
2568
2546
|
status: "action_required",
|
|
2569
2547
|
code: "NO_AUTH",
|
|
@@ -2595,7 +2573,7 @@ async function main(): Promise<void> {
|
|
|
2595
2573
|
if (answer !== "y" && answer !== "yes") failWith({ status: "action_required", code: "CONFIRMATION_REQUIRED", message: "Cancelled.", nextSteps: ["Run again with --force to skip the prompt: " + rerun] });
|
|
2596
2574
|
}
|
|
2597
2575
|
|
|
2598
|
-
const client = await makeClient(parsed.flags);
|
|
2576
|
+
const client = await makeClient(parsed.flags, op);
|
|
2599
2577
|
const selectValue = typeof parsed.flags.get("select") === "string" ? (parsed.flags.get("select") as string) : undefined;
|
|
2600
2578
|
const args = buildArgs(op, values, dataBody, selectValue);
|
|
2601
2579
|
const target = (client as unknown as Record<string, Record<string, (...a: unknown[]) => unknown>>)[op.resource]!;
|
|
@@ -2612,7 +2590,7 @@ async function main(): Promise<void> {
|
|
|
2612
2590
|
}
|
|
2613
2591
|
}
|
|
2614
2592
|
|
|
2615
|
-
const result = await (callResult as Promise<{ ok: boolean; data?: unknown; error?: unknown }>);
|
|
2593
|
+
const result = await (callResult as Promise<{ ok: boolean; data?: unknown; error?: unknown; response?: { requestId?: string } }>);
|
|
2616
2594
|
if (result.ok) {
|
|
2617
2595
|
if (op.sse) {
|
|
2618
2596
|
// Server-sent events as NDJSON, one line per event, until the stream ends.
|
|
@@ -2638,12 +2616,13 @@ async function main(): Promise<void> {
|
|
|
2638
2616
|
// One page, plus what fetches the next: the raw arguments (the MCP
|
|
2639
2617
|
// tool's nextPage shape) and the exact command, so a script or an
|
|
2640
2618
|
// agent never has to reconstruct the cursor flag.
|
|
2641
|
-
const page = result.data as { items: unknown[]; hasNextPage(): boolean; nextPageParams(): Record<string, unknown> | null };
|
|
2619
|
+
const page = result.data as { items: unknown[]; hasNextPage(): boolean; nextPageParams(): Record<string, unknown> | null; response: { requestId?: string } };
|
|
2642
2620
|
const next = page.nextPageParams();
|
|
2643
2621
|
out({
|
|
2644
2622
|
items: project(page.items),
|
|
2645
2623
|
hasMore: next !== null,
|
|
2646
2624
|
...(next !== null ? { nextPage: next, nextCommand: nextCommandFor(op, pathValues, next) } : {}),
|
|
2625
|
+
...(page.response.requestId ? { request_id: page.response.requestId } : {}),
|
|
2647
2626
|
});
|
|
2648
2627
|
} else if (FIELDS !== null && collectionField !== null && result.data !== null && typeof result.data === "object" && !Array.isArray(result.data) && Array.isArray((result.data as Record<string, unknown>)[collectionField])) {
|
|
2649
2628
|
// Batch-style collection envelopes ({data: [...]}) use item-relative
|
|
@@ -2674,17 +2653,6 @@ function errorMessage(error: unknown): string {
|
|
|
2674
2653
|
return base;
|
|
2675
2654
|
}
|
|
2676
2655
|
|
|
2677
|
-
function serializeError(error: unknown): unknown {
|
|
2678
|
-
if (error && typeof error === "object" && "violations" in error) {
|
|
2679
|
-
return { violations: (error as { violations: unknown }).violations };
|
|
2680
|
-
}
|
|
2681
|
-
if (error && typeof error === "object" && "status" in error) {
|
|
2682
|
-
const e = error as { status: number; body?: unknown };
|
|
2683
|
-
return { status: e.status, body: e.body };
|
|
2684
|
-
}
|
|
2685
|
-
return undefined;
|
|
2686
|
-
}
|
|
2687
|
-
|
|
2688
2656
|
main().catch((e) => {
|
|
2689
2657
|
if (e instanceof ExitPending) return;
|
|
2690
2658
|
try {
|