@typeship-ax/cli 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/AGENTS.md +31 -0
  2. package/README.md +26 -9
  3. package/api.json +3509 -800
  4. package/api.md +444 -494
  5. package/dist/api-identity.d.ts +40 -0
  6. package/dist/api-identity.d.ts.map +1 -0
  7. package/dist/api-identity.js +128 -0
  8. package/dist/auth-profiles.d.ts +30 -0
  9. package/dist/auth-profiles.d.ts.map +1 -0
  10. package/dist/auth-profiles.js +138 -0
  11. package/dist/cli.js +522 -355
  12. package/dist/console-login-check.d.ts +21 -0
  13. package/dist/console-login-check.d.ts.map +1 -0
  14. package/dist/console-login-check.js +107 -0
  15. package/dist/console-login-contract.d.ts +45 -0
  16. package/dist/console-login-contract.d.ts.map +1 -0
  17. package/dist/console-login-contract.js +40 -0
  18. package/dist/core/http.d.ts +17 -2
  19. package/dist/core/http.d.ts.map +1 -1
  20. package/dist/core/http.js +78 -17
  21. package/dist/credential-storage.d.ts +24 -0
  22. package/dist/credential-storage.d.ts.map +1 -0
  23. package/dist/credential-storage.js +207 -0
  24. package/dist/docs.d.ts +25 -0
  25. package/dist/docs.d.ts.map +1 -1
  26. package/dist/docs.js +144 -0
  27. package/dist/errors.d.ts +18 -10
  28. package/dist/errors.d.ts.map +1 -1
  29. package/dist/errors.js +24 -14
  30. package/dist/index.d.ts +10 -3
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +20 -4
  33. package/dist/named-credentials.d.ts +21 -0
  34. package/dist/named-credentials.d.ts.map +1 -0
  35. package/dist/named-credentials.js +86 -0
  36. package/dist/oauth-login.d.ts +39 -0
  37. package/dist/oauth-login.d.ts.map +1 -0
  38. package/dist/oauth-login.js +171 -0
  39. package/dist/oauth-request.d.ts +21 -0
  40. package/dist/oauth-request.d.ts.map +1 -0
  41. package/dist/oauth-request.js +119 -0
  42. package/dist/oauth-session.d.ts +106 -0
  43. package/dist/oauth-session.d.ts.map +1 -0
  44. package/dist/oauth-session.js +244 -0
  45. package/dist/ops.d.ts +14 -1
  46. package/dist/ops.d.ts.map +1 -1
  47. package/dist/ops.js +34 -30
  48. package/dist/polling-login.d.ts +57 -0
  49. package/dist/polling-login.d.ts.map +1 -0
  50. package/dist/polling-login.js +204 -0
  51. package/dist/resources/account.d.ts +2 -2
  52. package/dist/resources/account.d.ts.map +1 -1
  53. package/dist/resources/account.js +1 -0
  54. package/dist/resources/api-keys.d.ts +3 -3
  55. package/dist/resources/api-keys.d.ts.map +1 -1
  56. package/dist/resources/api-keys.js +2 -0
  57. package/dist/resources/definition-revisions.d.ts +5 -5
  58. package/dist/resources/definition-revisions.d.ts.map +1 -1
  59. package/dist/resources/definition-revisions.js +4 -0
  60. package/dist/resources/definitions.d.ts +15 -4
  61. package/dist/resources/definitions.d.ts.map +1 -1
  62. package/dist/resources/definitions.js +11 -2
  63. package/dist/resources/generate.d.ts +14 -3
  64. package/dist/resources/generate.d.ts.map +1 -1
  65. package/dist/resources/generate.js +10 -2
  66. package/dist/resources/generations.d.ts +3 -3
  67. package/dist/resources/generations.d.ts.map +1 -1
  68. package/dist/resources/generations.js +2 -0
  69. package/dist/resources/projects.d.ts +56 -20
  70. package/dist/resources/projects.d.ts.map +1 -1
  71. package/dist/resources/projects.js +40 -4
  72. package/dist/resources/targets.d.ts +84 -10
  73. package/dist/resources/targets.d.ts.map +1 -1
  74. package/dist/resources/targets.js +127 -2
  75. package/dist/schemas.d.ts.map +1 -1
  76. package/dist/schemas.js +55 -26
  77. package/dist/types.d.ts +602 -123
  78. package/dist/types.d.ts.map +1 -1
  79. package/dist/types.js +24 -0
  80. package/package.json +2 -1
  81. package/src/api-identity.ts +98 -0
  82. package/src/auth-profiles.ts +114 -0
  83. package/src/cli.ts +444 -332
  84. package/src/console-login-check.ts +88 -0
  85. package/src/console-login-contract.ts +65 -0
  86. package/src/core/http.ts +88 -19
  87. package/src/credential-storage.ts +183 -0
  88. package/src/docs.ts +138 -0
  89. package/src/errors.ts +26 -15
  90. package/src/index.ts +29 -4
  91. package/src/named-credentials.ts +74 -0
  92. package/src/oauth-login.ts +184 -0
  93. package/src/oauth-request.ts +90 -0
  94. package/src/oauth-session.ts +258 -0
  95. package/src/ops.ts +48 -31
  96. package/src/polling-login.ts +165 -0
  97. package/src/resources/account.ts +3 -0
  98. package/src/resources/api-keys.ts +5 -0
  99. package/src/resources/definition-revisions.ts +9 -0
  100. package/src/resources/definitions.ts +25 -0
  101. package/src/resources/generate.ts +23 -0
  102. package/src/resources/generations.ts +5 -0
  103. package/src/resources/projects.ts +95 -7
  104. package/src/resources/targets.ts +241 -0
  105. package/src/schemas.ts +55 -26
  106. package/src/types.ts +640 -123
@@ -0,0 +1,258 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import { closeSync, existsSync, linkSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
+ import { dirname } from "node:path";
4
+ import { assertApiIdentity, type IdentityConfiguration, type VerifiedIdentity } from "./api-identity.js";
5
+ import { oauthJsonRequest } from "./oauth-request.js";
6
+
7
+ export interface StoredOAuthSession {
8
+ accessToken: string;
9
+ refreshToken?: string;
10
+ expiresAt?: number;
11
+ tokenUrl?: string;
12
+ issuer?: string;
13
+ clientId?: string;
14
+ revocationUrl?: string;
15
+ apiBaseUrl?: string;
16
+ configuredClientId?: string | null;
17
+ scopes?: string[];
18
+ /** New login identity, independent of rotating tokens. */
19
+ sessionId: string;
20
+ binding: string;
21
+ /** Persisted before exchange: an interrupted exchange must not replay a rotating token. */
22
+ refreshPending?: boolean;
23
+ }
24
+
25
+ export interface CredentialDestination { apiBaseUrl: string; environment?: string; profile?: string }
26
+
27
+ export function assertCredentialDestination(credentials: StoredCredentials, destination: CredentialDestination): void {
28
+ const saved = credentials.destination;
29
+ if (!saved) throw new Error("Saved credentials have no API and profile binding. Log in again before using them.");
30
+ if (new URL(saved.apiBaseUrl).href !== new URL(destination.apiBaseUrl).href) throw new Error("The API destination changed. Log in again before using saved credentials.");
31
+ if ((saved.environment ?? null) !== (destination.environment ?? null) || (saved.profile ?? null) !== (destination.profile ?? null)) throw new Error("The credential environment or profile changed. Log in again before using saved credentials.");
32
+ }
33
+
34
+ export interface StoredCredentials {
35
+ destination?: CredentialDestination;
36
+ named?: Record<string, string | { username: string; password: string }>;
37
+ scalars?: Record<string, string>;
38
+ basic?: { username: string; password: string };
39
+ oauth?: StoredOAuthSession;
40
+ identity?: VerifiedIdentity & { binding: string };
41
+ minted?: { via: "browser"; key_name: string; org_id?: string; revocationUrl?: string };
42
+ }
43
+
44
+ export interface SessionConfiguration extends CredentialDestination {
45
+ identity?: IdentityConfiguration;
46
+ verifyIdentity?: (accessToken: string) => Promise<VerifiedIdentity>;
47
+ clientId?: string | null;
48
+ issuer?: string | null;
49
+ tokenUrl?: string | null;
50
+ discoveryUrl?: string | null;
51
+ authorizationUrl?: string | null;
52
+ scopes?: string[];
53
+ audience?: string;
54
+ resource?: string;
55
+ organizationParameter?: "organization" | "organization_id";
56
+ }
57
+
58
+ /** Bind minimal identity evidence to this credential set and its configured read. */
59
+ export function credentialIdentityBinding(credentials: StoredCredentials, identity: IdentityConfiguration): string {
60
+ return createHash("sha256").update(JSON.stringify({
61
+ destination: credentials.destination, scalars: credentials.scalars, named: credentials.named, basic: credentials.basic,
62
+ oauth: credentials.oauth ? { sessionId: credentials.oauth.sessionId, binding: credentials.oauth.binding } : undefined,
63
+ operation: identity.operation, fields: Object.entries(identity.fields).sort(([a], [b]) => a.localeCompare(b)),
64
+ })).digest("hex");
65
+ }
66
+
67
+ export function assertStoredIdentity(credentials: StoredCredentials, identity?: IdentityConfiguration): void {
68
+ if (!identity) return;
69
+ if (!credentials.identity?.values || credentials.identity.binding !== credentialIdentityBinding(credentials, identity) || Object.keys(identity.fields).some((kind) => !Object.hasOwn(credentials.identity!.values, kind))) {
70
+ throw new Error("The saved login has no matching API identity verification. Sign in again to verify the current account and organization.");
71
+ }
72
+ }
73
+
74
+ /** Bind the login to effective nonsecret settings. Scope ordering is irrelevant. */
75
+ export function sessionBinding(config: SessionConfiguration): string {
76
+ const url = (value?: string | null) => value ? new URL(value).href : null;
77
+ return createHash("sha256").update(JSON.stringify({
78
+ apiBaseUrl: url(config.apiBaseUrl), environment: config.environment ?? null, profile: config.profile ?? null,
79
+ clientId: config.clientId ?? null, issuer: config.issuer ?? null,
80
+ tokenUrl: url(config.tokenUrl), discoveryUrl: url(config.discoveryUrl), authorizationUrl: url(config.authorizationUrl),
81
+ scopes: [...new Set(config.scopes ?? [])].sort(), audience: config.audience ?? null, resource: config.resource ?? null,
82
+ organizationParameter: config.organizationParameter ?? null,
83
+ identity: config.identity ? { operation: config.identity.operation, fields: Object.entries(config.identity.fields).sort(([a], [b]) => a.localeCompare(b)) } : null,
84
+ })).digest("hex");
85
+ }
86
+
87
+ interface LockedCredentials {
88
+ read(): StoredCredentials | null;
89
+ write(credentials: StoredCredentials | null): void;
90
+ }
91
+
92
+ export interface CredentialStore {
93
+ read(): StoredCredentials | null;
94
+ withLock<T>(work: (store: LockedCredentials) => Promise<T>): Promise<T>;
95
+ }
96
+
97
+ function code(error: unknown): string | undefined { return (error as NodeJS.ErrnoException)?.code; }
98
+ function alive(pid: number): boolean {
99
+ try { process.kill(pid, 0); return true; } catch (error) { return code(error) !== "ESRCH"; }
100
+ }
101
+
102
+ export class CredentialStorageError extends Error {}
103
+
104
+ export interface CredentialCodec {
105
+ readonly name: string;
106
+ prepare(): void;
107
+ encode(plaintext: string): string;
108
+ decode(encoded: string): string;
109
+ }
110
+
111
+ /** Private file adapter. All writers (login, refresh and logout) use the same
112
+ * cross-process lock. Credential storage can be replaced independently of OAuth. */
113
+ export class FileCredentialStore implements CredentialStore {
114
+ constructor(readonly path: string, private readonly lockTimeoutMs = 40_000, private readonly codec?: CredentialCodec) {}
115
+
116
+ get backend(): string { return this.codec?.name ?? "private plaintext file"; }
117
+
118
+ /** Check storage access before starting an interactive login. */
119
+ async prepare(): Promise<void> { await this.withLock(async () => { this.codec?.prepare(); }); }
120
+
121
+ read(): StoredCredentials | null {
122
+ try {
123
+ const encoded = readFileSync(this.path, "utf8");
124
+ const data: unknown = JSON.parse(this.codec ? this.codec.decode(encoded) : encoded);
125
+ if (!data || typeof data !== "object" || Array.isArray(data)) throw new Error();
126
+ return data as StoredCredentials;
127
+ } catch (error) {
128
+ if (code(error) === "ENOENT") return null;
129
+ if (error instanceof CredentialStorageError) throw error;
130
+ throw new Error("Cannot read saved credentials. Check the credential store before logging in again.");
131
+ }
132
+ }
133
+
134
+ private write(credentials: StoredCredentials | null): void {
135
+ if (credentials === null) { rmSync(this.path, { force: true }); return; }
136
+ const temporary = this.path + "." + randomBytes(16).toString("hex") + ".tmp";
137
+ try {
138
+ const plaintext = JSON.stringify(credentials, null, 2);
139
+ writeFileSync(temporary, (this.codec ? this.codec.encode(plaintext) : plaintext) + "\n", { mode: 0o600, flag: "wx", flush: true });
140
+ renameSync(temporary, this.path);
141
+ } finally { rmSync(temporary, { force: true }); }
142
+ }
143
+
144
+ async withLock<T>(work: (store: LockedCredentials) => Promise<T>): Promise<T> {
145
+ mkdirSync(dirname(this.path), { recursive: true, mode: 0o700 });
146
+ const lock = this.path + ".lock";
147
+ const owner = { pid: process.pid, nonce: randomBytes(16).toString("hex") };
148
+ const claim = lock + "." + owner.nonce;
149
+ // Hard-link a complete owner record: no empty-file window if the process dies.
150
+ writeFileSync(claim, JSON.stringify(owner), { mode: 0o600, flag: "wx" });
151
+ let acquired = false;
152
+ const deadline = Date.now() + this.lockTimeoutMs;
153
+ try {
154
+ while (!acquired) {
155
+ try { linkSync(claim, lock); acquired = true; }
156
+ catch (error) {
157
+ if (code(error) !== "EEXIST") throw new Error("Cannot lock the credential store.");
158
+ this.recoverDeadOwner(lock);
159
+ if (Date.now() >= deadline) throw new Error("The credential store is busy. Wait for the other login or refresh to finish and retry. If its process stopped, inspect " + lock + ".");
160
+ await new Promise((resolve) => setTimeout(resolve, 25));
161
+ }
162
+ }
163
+ return await work({ read: () => this.read(), write: (value) => this.write(value) });
164
+ } finally {
165
+ if (acquired) rmSync(lock, { force: true });
166
+ rmSync(claim, { force: true });
167
+ }
168
+ }
169
+
170
+ private recoverDeadOwner(lock: string): void {
171
+ let previous: { pid?: unknown; nonce?: unknown };
172
+ try { previous = JSON.parse(readFileSync(lock, "utf8")); } catch { return; }
173
+ if (!Number.isSafeInteger(previous?.pid) || (previous.pid as number) <= 0 || typeof previous.nonce !== "string" || !/^[a-f0-9]{32}$/.test(previous.nonce) || alive(previous.pid as number)) return;
174
+ // One reclaimer per dead owner. Keep this empty marker: deleting it lets a
175
+ // delayed contender remove a later owner's lock (the ABA race). A double
176
+ // crash during reclamation fails closed with the bounded busy diagnostic.
177
+ const marker = lock + ".reaped-" + previous.nonce;
178
+ try { closeSync(openSync(marker, "wx", 0o600)); } catch { return; }
179
+ try {
180
+ const current = JSON.parse(readFileSync(lock, "utf8")) as { nonce?: string };
181
+ if (current.nonce === previous.nonce) rmSync(lock);
182
+ } catch { /* another process already completed recovery */ }
183
+ }
184
+
185
+ async update(change: (current: StoredCredentials) => StoredCredentials): Promise<void> {
186
+ await this.withLock(async (store) => { store.write(change(store.read() ?? {})); });
187
+ }
188
+
189
+ /** Explicit local-only logout can remove an unreadable encrypted session. */
190
+ async clear(): Promise<boolean> {
191
+ return this.withLock(async (store) => { const existed = existsSync(this.path); store.write(null); return existed; });
192
+ }
193
+
194
+ /** Remove under the refresh lock, then revoke the latest token outside it. */
195
+ async take(): Promise<StoredCredentials | null> {
196
+ return this.withLock(async (store) => { const current = store.read(); store.write(null); return current; });
197
+ }
198
+ }
199
+
200
+ function validSession(credentials: StoredCredentials | null, config: SessionConfiguration, sessionId: string, allowPending = false): StoredOAuthSession {
201
+ const session = credentials?.oauth;
202
+ if (!session || !session.sessionId || session.sessionId !== sessionId) throw new Error("The saved OAuth session changed or was logged out. Run login again and retry the request.");
203
+ if (session.apiBaseUrl && new URL(session.apiBaseUrl).href !== new URL(config.apiBaseUrl).href) throw new Error("The API destination changed. Log in again.");
204
+ if (session.issuer && config.issuer && session.issuer !== config.issuer) throw new Error("The configured issuer changed. Log in again.");
205
+ if (session.configuredClientId !== (config.clientId ?? null)) throw new Error("The OAuth client changed. Log in again.");
206
+ if (!session.binding || session.binding !== sessionBinding(config)) throw new Error("The API or OAuth configuration changed. Log in again before using saved credentials.");
207
+ if (session.refreshPending && !allowPending) throw new Error("The previous OAuth refresh did not finish safely. Log in again; its refresh token will not be reused.");
208
+ if (typeof session.accessToken !== "string" || !session.accessToken) throw new Error("The saved OAuth session is invalid. Log in again.");
209
+ assertStoredIdentity(credentials!, config.identity);
210
+ return session;
211
+ }
212
+
213
+ /** Re-read on every request attempt; only one process may rotate the token.
214
+ * A failed or interrupted exchange requires login, since the server may have
215
+ * consumed its refresh token even when no response reached this process. */
216
+ export async function oauthSessionToken(store: CredentialStore, config: SessionConfiguration, sessionId: string, tokenParams: Record<string, string> = {}): Promise<string> {
217
+ const fresh = (session: StoredOAuthSession) => session.expiresAt === undefined || (Number.isFinite(session.expiresAt) && session.expiresAt > Date.now() + 60_000);
218
+ const first = validSession(store.read(), config, sessionId, true);
219
+ if (!first.refreshPending && fresh(first)) return first.accessToken;
220
+ return store.withLock(async (locked) => {
221
+ const credentials = locked.read();
222
+ const session = validSession(credentials, config, sessionId);
223
+ if (fresh(session)) return session.accessToken;
224
+ if (!session.refreshToken || !session.tokenUrl || !session.clientId) throw new Error("The OAuth session expired and cannot be refreshed. Log in again.");
225
+ const endpoint = new URL(session.tokenUrl);
226
+ if ((endpoint.protocol !== "https:" && !(endpoint.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(endpoint.hostname))) || endpoint.username || endpoint.password || endpoint.hash) throw new Error("The OAuth token endpoint requires HTTPS; loopback HTTP is allowed for development.");
227
+ locked.write({ ...credentials, oauth: { ...session, refreshPending: true } });
228
+ try {
229
+ const response = await oauthJsonRequest(endpoint, {
230
+ method: "POST",
231
+ headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
232
+ body: new URLSearchParams({ ...tokenParams, grant_type: "refresh_token", refresh_token: session.refreshToken, client_id: session.clientId }),
233
+ });
234
+ const body = response.data;
235
+ if (response.status !== 200 || !body || typeof body.access_token !== "string" || !body.access_token || typeof body.token_type !== "string" || body.token_type.toLowerCase() !== "bearer") throw new Error();
236
+ if (body.expires_in !== undefined && (typeof body.expires_in !== "number" || !Number.isFinite(body.expires_in) || body.expires_in <= 0)) throw new Error();
237
+ if (body.refresh_token !== undefined && (typeof body.refresh_token !== "string" || !body.refresh_token)) throw new Error();
238
+ if (body.scope !== undefined && typeof body.scope !== "string") throw new Error();
239
+ const next: StoredOAuthSession = {
240
+ ...session, accessToken: body.access_token, refreshToken: body.refresh_token as string | undefined ?? session.refreshToken,
241
+ expiresAt: typeof body.expires_in === "number" ? Date.now() + body.expires_in * 1000 : undefined,
242
+ ...(typeof body.scope === "string" ? { scopes: body.scope.split(/\s+/).filter(Boolean) } : {}), refreshPending: undefined,
243
+ };
244
+ let identity = credentials?.identity;
245
+ if (config.identity) {
246
+ if (!config.verifyIdentity || !identity) throw new Error();
247
+ const verified = await config.verifyIdentity(next.accessToken);
248
+ assertApiIdentity(identity.values, verified.values);
249
+ identity = { ...identity, ...verified };
250
+ }
251
+ locked.write({ ...credentials, oauth: next, ...(identity ? { identity } : {}) });
252
+ return next.accessToken;
253
+ } catch {
254
+ // Never expose an error body, a refresh token, or fetch's nested cause.
255
+ throw new Error("The OAuth session could not be refreshed safely. Log in again.");
256
+ }
257
+ });
258
+ }