@syngy/account-cli-auth 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +134 -0
- package/dist/index.js +508 -0
- package/dist/index.js.map +1 -0
- package/package.json +35 -0
- package/src/__tests__/auth-command.spec.ts +74 -0
- package/src/__tests__/credentials.spec.ts +32 -0
- package/src/__tests__/login.spec.ts +96 -0
- package/src/index.ts +657 -0
- package/tsconfig.json +18 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,657 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
|
|
7
|
+
export interface AccountCLICredentials {
|
|
8
|
+
token: string;
|
|
9
|
+
refreshToken?: string;
|
|
10
|
+
expiresAt?: number;
|
|
11
|
+
userId: string;
|
|
12
|
+
phone?: string;
|
|
13
|
+
host: string;
|
|
14
|
+
accountHost: string;
|
|
15
|
+
accountWebHost: string;
|
|
16
|
+
scope: string;
|
|
17
|
+
defaultTeamId?: string;
|
|
18
|
+
defaultTeamName?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type AccountCLIConfigOptions = {
|
|
22
|
+
homeDir?: string;
|
|
23
|
+
configDirName?: string;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type AccountCLICredentialsManagerOptions = AccountCLIConfigOptions & {
|
|
27
|
+
profile?: string;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export interface CLIJCodeSessionResponse {
|
|
31
|
+
session_id?: string;
|
|
32
|
+
sessionId?: string;
|
|
33
|
+
jcode?: string;
|
|
34
|
+
status?: string;
|
|
35
|
+
login_url?: string;
|
|
36
|
+
loginUrl?: string;
|
|
37
|
+
expires_at?: string;
|
|
38
|
+
expiresAt?: string;
|
|
39
|
+
client_name?: string;
|
|
40
|
+
clientName?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface CLIJCodeExchangeResponse {
|
|
44
|
+
user?: {
|
|
45
|
+
id?: string;
|
|
46
|
+
phone?: string;
|
|
47
|
+
aud?: string;
|
|
48
|
+
};
|
|
49
|
+
session?: {
|
|
50
|
+
access_token?: string;
|
|
51
|
+
refresh_token?: string;
|
|
52
|
+
expires_at?: number;
|
|
53
|
+
expires_in?: number;
|
|
54
|
+
token_type?: string;
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export type LoginWithAccountJCodeOptions = {
|
|
59
|
+
cliName: string;
|
|
60
|
+
cliVersion: string;
|
|
61
|
+
configDirName?: string;
|
|
62
|
+
homeDir?: string;
|
|
63
|
+
profile?: string;
|
|
64
|
+
host: string;
|
|
65
|
+
accountHost: string;
|
|
66
|
+
accountWebHost: string;
|
|
67
|
+
scope: string;
|
|
68
|
+
openBrowser?: boolean;
|
|
69
|
+
pollIntervalMs?: number;
|
|
70
|
+
maxPollAttempts?: number;
|
|
71
|
+
output?: (message: string) => void;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export type LoginWithAccountJCodeResult = {
|
|
75
|
+
profile: string;
|
|
76
|
+
credentials: AccountCLICredentials;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
export type RefreshSessionResponse = {
|
|
80
|
+
user?: {
|
|
81
|
+
id?: string;
|
|
82
|
+
phone?: string;
|
|
83
|
+
};
|
|
84
|
+
session?: {
|
|
85
|
+
access_token?: string;
|
|
86
|
+
refresh_token?: string;
|
|
87
|
+
expires_at?: number;
|
|
88
|
+
expires_in?: number;
|
|
89
|
+
token_type?: string;
|
|
90
|
+
};
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
export type AccountAuthRuntime = {
|
|
94
|
+
host: string;
|
|
95
|
+
accountHost: string;
|
|
96
|
+
accountWebHost: string;
|
|
97
|
+
scope: string;
|
|
98
|
+
profile?: string;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
export type CreateAccountAuthCommandOptions = {
|
|
102
|
+
cliName: string;
|
|
103
|
+
cliVersion: string;
|
|
104
|
+
configDirName?: string;
|
|
105
|
+
homeDir?: string | (() => string | undefined);
|
|
106
|
+
resolveRuntime: (profile?: string) => AccountAuthRuntime;
|
|
107
|
+
login?: (options: LoginWithAccountJCodeOptions) => Promise<LoginWithAccountJCodeResult>;
|
|
108
|
+
output?: (message: string) => void;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
type PinResponse<T> = {
|
|
112
|
+
data?: T;
|
|
113
|
+
error?: {
|
|
114
|
+
key?: string;
|
|
115
|
+
code?: string;
|
|
116
|
+
message?: string;
|
|
117
|
+
};
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const credentialsSchema = z
|
|
121
|
+
.object({
|
|
122
|
+
token: z.string().min(1),
|
|
123
|
+
refreshToken: z.string().min(1).optional(),
|
|
124
|
+
expiresAt: z.number().int().positive().optional(),
|
|
125
|
+
userId: z.string().min(1),
|
|
126
|
+
phone: z.string().min(1).optional(),
|
|
127
|
+
host: z.string().min(1),
|
|
128
|
+
accountHost: z.string().min(1),
|
|
129
|
+
accountWebHost: z.string().min(1),
|
|
130
|
+
scope: z.string().min(1),
|
|
131
|
+
defaultTeamId: z.string().min(1).optional(),
|
|
132
|
+
defaultTeamName: z.string().min(1).optional(),
|
|
133
|
+
})
|
|
134
|
+
.passthrough();
|
|
135
|
+
|
|
136
|
+
const profileConfigSchema = z.object({
|
|
137
|
+
currentProfile: z.string().min(1).optional(),
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
const CREDS_FILE = "credentials.json";
|
|
141
|
+
const CONFIG_FILE = "config.json";
|
|
142
|
+
const PROFILES_DIR = "profiles";
|
|
143
|
+
const DEFAULT_PROFILE = "default";
|
|
144
|
+
const DEFAULT_CONFIG_DIR_NAME = "account-cli";
|
|
145
|
+
const DEFAULT_POLL_INTERVAL_MS = 2000;
|
|
146
|
+
const DEFAULT_MAX_POLL_ATTEMPTS = 150;
|
|
147
|
+
|
|
148
|
+
export function getDefaultProfileName() {
|
|
149
|
+
return DEFAULT_PROFILE;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function parseCredentials(input: unknown): AccountCLICredentials {
|
|
153
|
+
return credentialsSchema.parse(input) as AccountCLICredentials;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function validateProfileName(profile: string): string {
|
|
157
|
+
const normalized = String(profile || "").trim();
|
|
158
|
+
if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/.test(normalized)) {
|
|
159
|
+
throw new Error("Invalid profile name. Use 1-64 letters, numbers, dots, underscores, or dashes; start with a letter or number.");
|
|
160
|
+
}
|
|
161
|
+
return normalized;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function accountCLIConfigDir(options: AccountCLIConfigOptions = {}) {
|
|
165
|
+
const baseHome = options.homeDir || homedir();
|
|
166
|
+
return join(baseHome, ".config", options.configDirName || DEFAULT_CONFIG_DIR_NAME);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function ensureDir(path: string) {
|
|
170
|
+
if (!existsSync(path)) mkdirSync(path, { recursive: true });
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function loadCurrentProfile(options: AccountCLIConfigOptions = {}): string | null {
|
|
174
|
+
const path = join(accountCLIConfigDir(options), CONFIG_FILE);
|
|
175
|
+
if (!existsSync(path)) return null;
|
|
176
|
+
try {
|
|
177
|
+
const parsed = profileConfigSchema.parse(JSON.parse(readFileSync(path, "utf8")));
|
|
178
|
+
return parsed.currentProfile ? validateProfileName(parsed.currentProfile) : null;
|
|
179
|
+
} catch {
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function saveCurrentProfile(profile: string, options: AccountCLIConfigOptions = {}) {
|
|
185
|
+
const normalized = validateProfileName(profile);
|
|
186
|
+
const root = accountCLIConfigDir(options);
|
|
187
|
+
ensureDir(root);
|
|
188
|
+
writeFileSync(join(root, CONFIG_FILE), JSON.stringify({ currentProfile: normalized }, null, 2));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function listProfiles(options: AccountCLIConfigOptions = {}): string[] {
|
|
192
|
+
const profilesRoot = join(accountCLIConfigDir(options), PROFILES_DIR);
|
|
193
|
+
if (!existsSync(profilesRoot)) return [];
|
|
194
|
+
return readdirSync(profilesRoot, { withFileTypes: true })
|
|
195
|
+
.filter((entry) => entry.isDirectory())
|
|
196
|
+
.map((entry) => entry.name)
|
|
197
|
+
.filter((name) => {
|
|
198
|
+
try {
|
|
199
|
+
validateProfileName(name);
|
|
200
|
+
return true;
|
|
201
|
+
} catch {
|
|
202
|
+
return false;
|
|
203
|
+
}
|
|
204
|
+
})
|
|
205
|
+
.sort();
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function deleteProfile(profile: string, options: AccountCLIConfigOptions = {}) {
|
|
209
|
+
const normalized = validateProfileName(profile);
|
|
210
|
+
const profileDir = join(accountCLIConfigDir(options), PROFILES_DIR, normalized);
|
|
211
|
+
if (existsSync(profileDir)) rmSync(profileDir, { recursive: true, force: true });
|
|
212
|
+
if (loadCurrentProfile(options) === normalized) saveCurrentProfile(DEFAULT_PROFILE, options);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export class AccountCLICredentialsManager {
|
|
216
|
+
private readonly profileDir: string;
|
|
217
|
+
readonly profile: string;
|
|
218
|
+
|
|
219
|
+
constructor(private readonly options: AccountCLICredentialsManagerOptions = {}) {
|
|
220
|
+
this.profile = validateProfileName(options.profile || loadCurrentProfile(options) || DEFAULT_PROFILE);
|
|
221
|
+
this.profileDir = join(accountCLIConfigDir(options), PROFILES_DIR, this.profile);
|
|
222
|
+
ensureDir(this.profileDir);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
saveCredentials(creds: AccountCLICredentials) {
|
|
226
|
+
const path = join(this.profileDir, CREDS_FILE);
|
|
227
|
+
writeFileSync(path, JSON.stringify(creds, null, 2));
|
|
228
|
+
return path;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
loadCredentials(): AccountCLICredentials | null {
|
|
232
|
+
const path = join(this.profileDir, CREDS_FILE);
|
|
233
|
+
if (!existsSync(path)) return null;
|
|
234
|
+
try {
|
|
235
|
+
return parseCredentials(JSON.parse(readFileSync(path, "utf8")));
|
|
236
|
+
} catch {
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
clearCredentials() {
|
|
242
|
+
const path = join(this.profileDir, CREDS_FILE);
|
|
243
|
+
if (existsSync(path)) writeFileSync(path, "{}");
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export const CredentialsManager = AccountCLICredentialsManager;
|
|
248
|
+
export type Credentials = AccountCLICredentials;
|
|
249
|
+
|
|
250
|
+
function apiBase(host: string): string {
|
|
251
|
+
return String(host || "").replace(/\/$/, "");
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async function parsePinResponse<T>(response: Response, action: string): Promise<T> {
|
|
255
|
+
let body: PinResponse<T> | T;
|
|
256
|
+
try {
|
|
257
|
+
body = (await response.json()) as PinResponse<T> | T;
|
|
258
|
+
} catch {
|
|
259
|
+
throw new Error(`${action}: invalid JSON response`);
|
|
260
|
+
}
|
|
261
|
+
if (!response.ok || (body as PinResponse<T>)?.error) {
|
|
262
|
+
const message = (body as PinResponse<T>)?.error?.message || `${response.status} ${response.statusText}`;
|
|
263
|
+
throw new Error(`${action}: ${message}`);
|
|
264
|
+
}
|
|
265
|
+
return ((body as PinResponse<T>)?.data ?? body) as T;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function assertSession(value: CLIJCodeSessionResponse, requireCreateFields = false): CLIJCodeSessionResponse {
|
|
269
|
+
const sessionId = value.session_id || value.sessionId;
|
|
270
|
+
if (!sessionId) throw new Error("JCode session response missing session_id");
|
|
271
|
+
if (!value.status) throw new Error("JCode session response missing status");
|
|
272
|
+
if (requireCreateFields) {
|
|
273
|
+
if (!value.jcode || !/^\d{8}$/.test(value.jcode)) throw new Error("JCode session response missing 8-digit jcode");
|
|
274
|
+
const loginUrl = value.login_url || value.loginUrl || "";
|
|
275
|
+
if (!loginUrl) throw new Error("JCode session response missing login_url");
|
|
276
|
+
const parsedLoginUrl = new URL(loginUrl);
|
|
277
|
+
if (parsedLoginUrl.searchParams.has("jcode") || parsedLoginUrl.search.includes(value.jcode)) {
|
|
278
|
+
throw new Error("JCode login URL must not contain jcode");
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
return value;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function assertExchange(value: CLIJCodeExchangeResponse): CLIJCodeExchangeResponse {
|
|
285
|
+
if (!value?.user?.id) throw new Error("JCode exchange response missing user.id");
|
|
286
|
+
if (!value?.session?.access_token) throw new Error("JCode exchange response missing access_token");
|
|
287
|
+
if (!value?.session?.refresh_token) throw new Error("JCode exchange response missing refresh_token");
|
|
288
|
+
return value;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export async function createCLIJCodeSession(input: {
|
|
292
|
+
accountHost: string;
|
|
293
|
+
accountWebHost: string;
|
|
294
|
+
cliName: string;
|
|
295
|
+
cliVersion: string;
|
|
296
|
+
}): Promise<CLIJCodeSessionResponse> {
|
|
297
|
+
const response = await fetch(`${apiBase(input.accountHost)}/auth/v1/cli-jcode/sessions`, {
|
|
298
|
+
method: "POST",
|
|
299
|
+
headers: { "Content-Type": "application/json" },
|
|
300
|
+
body: JSON.stringify({
|
|
301
|
+
client_name: input.cliName,
|
|
302
|
+
client_version: input.cliVersion,
|
|
303
|
+
web_base_url: input.accountWebHost,
|
|
304
|
+
}),
|
|
305
|
+
});
|
|
306
|
+
return assertSession(await parsePinResponse(response, "Failed to create jcode session"), true);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
export async function getCLIJCodeSession(sessionId: string, accountHost: string): Promise<CLIJCodeSessionResponse> {
|
|
310
|
+
const response = await fetch(`${apiBase(accountHost)}/auth/v1/cli-jcode/sessions/${encodeURIComponent(sessionId)}`);
|
|
311
|
+
return assertSession(await parsePinResponse(response, "Failed to fetch jcode session"));
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export async function exchangeCLIJCodeSession(sessionId: string, jcode: string, accountHost: string): Promise<CLIJCodeExchangeResponse> {
|
|
315
|
+
const response = await fetch(`${apiBase(accountHost)}/auth/v1/cli-jcode/exchange`, {
|
|
316
|
+
method: "POST",
|
|
317
|
+
headers: { "Content-Type": "application/json" },
|
|
318
|
+
body: JSON.stringify({ session_id: sessionId, jcode }),
|
|
319
|
+
});
|
|
320
|
+
return assertExchange(await parsePinResponse(response, "Failed to exchange jcode session"));
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export async function pollCLIJCodeSession(
|
|
324
|
+
sessionId: string,
|
|
325
|
+
accountHost: string,
|
|
326
|
+
intervalMs = DEFAULT_POLL_INTERVAL_MS,
|
|
327
|
+
maxAttempts = DEFAULT_MAX_POLL_ATTEMPTS,
|
|
328
|
+
): Promise<CLIJCodeSessionResponse> {
|
|
329
|
+
let attempts = 0;
|
|
330
|
+
while (attempts < maxAttempts) {
|
|
331
|
+
const detail = await getCLIJCodeSession(sessionId, accountHost);
|
|
332
|
+
if (detail.status === "confirmed" || detail.status === "completed" || detail.status === "expired") {
|
|
333
|
+
return detail;
|
|
334
|
+
}
|
|
335
|
+
attempts += 1;
|
|
336
|
+
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
|
337
|
+
}
|
|
338
|
+
throw new Error("Login timeout. Please try again.");
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function sessionIdOf(value: { session_id?: string; sessionId?: string }): string {
|
|
342
|
+
return String(value.session_id || value.sessionId || "").trim();
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function loginUrlOf(value: { login_url?: string; loginUrl?: string }): string {
|
|
346
|
+
return String(value.login_url || value.loginUrl || "").trim();
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function readJwtPayload(token: string): { exp?: unknown } | null {
|
|
350
|
+
const payload = token.split(".")[1];
|
|
351
|
+
if (!payload) return null;
|
|
352
|
+
try {
|
|
353
|
+
return JSON.parse(Buffer.from(payload.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8")) as { exp?: unknown };
|
|
354
|
+
} catch {
|
|
355
|
+
return null;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
export function resolveExpiresAt(accessToken: string, expiresAt?: number, expiresIn?: number): number | undefined {
|
|
360
|
+
if (typeof expiresAt === "number" && Number.isFinite(expiresAt) && expiresAt > 0) return Math.floor(expiresAt);
|
|
361
|
+
if (typeof expiresIn === "number" && Number.isFinite(expiresIn) && expiresIn > 0) {
|
|
362
|
+
return Math.floor(Date.now() / 1000) + Math.floor(expiresIn);
|
|
363
|
+
}
|
|
364
|
+
const exp = readJwtPayload(accessToken)?.exp;
|
|
365
|
+
return typeof exp === "number" ? exp : undefined;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
export async function loginWithAccountJCode(options: LoginWithAccountJCodeOptions): Promise<LoginWithAccountJCodeResult> {
|
|
369
|
+
const output = options.output || console.log;
|
|
370
|
+
const manager = new AccountCLICredentialsManager({
|
|
371
|
+
homeDir: options.homeDir,
|
|
372
|
+
configDirName: options.configDirName || options.cliName,
|
|
373
|
+
profile: options.profile,
|
|
374
|
+
});
|
|
375
|
+
const created = await createCLIJCodeSession({
|
|
376
|
+
accountHost: options.accountHost,
|
|
377
|
+
accountWebHost: options.accountWebHost,
|
|
378
|
+
cliName: options.cliName,
|
|
379
|
+
cliVersion: options.cliVersion,
|
|
380
|
+
});
|
|
381
|
+
const sessionId = sessionIdOf(created);
|
|
382
|
+
const jcode = String(created.jcode || "").trim();
|
|
383
|
+
const loginUrl = loginUrlOf(created);
|
|
384
|
+
if (!sessionId) throw new Error("Failed to create jcode session: no session_id returned");
|
|
385
|
+
if (!/^\d{8}$/.test(jcode)) throw new Error("Failed to create jcode session: invalid jcode");
|
|
386
|
+
if (!loginUrl) throw new Error("Failed to create jcode session: no login_url returned");
|
|
387
|
+
const parsedLoginUrl = new URL(loginUrl);
|
|
388
|
+
if (parsedLoginUrl.searchParams.has("jcode") || parsedLoginUrl.search.includes(jcode)) {
|
|
389
|
+
throw new Error("Failed to create jcode session: login URL must not contain jcode");
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
output(`Open this URL to pair ${options.cliName}:`);
|
|
393
|
+
output(loginUrl);
|
|
394
|
+
output(`JCode: ${jcode}`);
|
|
395
|
+
|
|
396
|
+
if (options.openBrowser !== false) {
|
|
397
|
+
const mod = await import("open");
|
|
398
|
+
const openBrowser = (mod.default ?? mod) as (target: string) => Promise<unknown>;
|
|
399
|
+
await openBrowser(loginUrl);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const detail = await pollCLIJCodeSession(sessionId, options.accountHost, options.pollIntervalMs, options.maxPollAttempts);
|
|
403
|
+
if (detail.status === "expired") throw new Error("JCode session expired. Please try again.");
|
|
404
|
+
const exchanged = await exchangeCLIJCodeSession(sessionId, jcode, options.accountHost);
|
|
405
|
+
const accessToken = String(exchanged.session?.access_token || "");
|
|
406
|
+
const refreshToken = String(exchanged.session?.refresh_token || "");
|
|
407
|
+
const userId = String(exchanged.user?.id || "");
|
|
408
|
+
const phone = String(exchanged.user?.phone || "");
|
|
409
|
+
const credentials: AccountCLICredentials = {
|
|
410
|
+
token: accessToken,
|
|
411
|
+
refreshToken,
|
|
412
|
+
expiresAt: resolveExpiresAt(accessToken, exchanged.session?.expires_at, exchanged.session?.expires_in),
|
|
413
|
+
userId,
|
|
414
|
+
phone: phone || undefined,
|
|
415
|
+
host: options.host,
|
|
416
|
+
accountHost: options.accountHost,
|
|
417
|
+
accountWebHost: options.accountWebHost,
|
|
418
|
+
scope: options.scope,
|
|
419
|
+
};
|
|
420
|
+
manager.saveCredentials(credentials);
|
|
421
|
+
return { profile: manager.profile, credentials };
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function resolveRequestUrl(input: RequestInfo | URL, baseUrl: string): URL {
|
|
425
|
+
if (input instanceof URL) return input;
|
|
426
|
+
if (typeof input === "string") return new URL(input, baseUrl);
|
|
427
|
+
return new URL(input.url, baseUrl);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function methodOf(input: RequestInfo | URL, init?: RequestInit): string {
|
|
431
|
+
return init?.method ?? (input instanceof Request ? input.method : "GET");
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
async function bodyOf(input: RequestInfo | URL, init?: RequestInit): Promise<BodyInit | null | undefined> {
|
|
435
|
+
if (init && "body" in init) return init.body;
|
|
436
|
+
if (input instanceof Request) return input.clone().body;
|
|
437
|
+
return undefined;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function headersOf(input: RequestInfo | URL, token: string, init?: RequestInit): Headers {
|
|
441
|
+
const headers = new Headers(input instanceof Request ? input.headers : undefined);
|
|
442
|
+
if (init?.headers) {
|
|
443
|
+
for (const [key, value] of new Headers(init.headers).entries()) {
|
|
444
|
+
headers.set(key, value);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
headers.set("Authorization", `Bearer ${token}`);
|
|
448
|
+
return headers;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
export async function isUnauthorizedResponse(response: Response): Promise<boolean> {
|
|
452
|
+
if (response.status === 401) return true;
|
|
453
|
+
try {
|
|
454
|
+
const payload = (await response.clone().json()) as PinResponse<unknown>;
|
|
455
|
+
const error = payload?.error;
|
|
456
|
+
return error?.key === "UNAUTHORIZED" || error?.code === "UNAUTHORIZED";
|
|
457
|
+
} catch {
|
|
458
|
+
return false;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
export function refreshedCredentials(current: AccountCLICredentials, data: RefreshSessionResponse): AccountCLICredentials {
|
|
463
|
+
const accessToken = String(data.session?.access_token || "");
|
|
464
|
+
const refreshToken = String(data.session?.refresh_token || current.refreshToken || "");
|
|
465
|
+
if (!accessToken || !refreshToken) {
|
|
466
|
+
throw new Error("Refresh response missing token");
|
|
467
|
+
}
|
|
468
|
+
return {
|
|
469
|
+
...current,
|
|
470
|
+
token: accessToken,
|
|
471
|
+
refreshToken,
|
|
472
|
+
expiresAt: data.session?.expires_at || current.expiresAt,
|
|
473
|
+
userId: data.user?.id || current.userId,
|
|
474
|
+
phone: data.user?.phone || current.phone,
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
export async function refreshCredentials(credentials: AccountCLICredentials): Promise<AccountCLICredentials> {
|
|
479
|
+
if (!credentials.refreshToken) {
|
|
480
|
+
throw new Error("Missing refresh token. Please login again.");
|
|
481
|
+
}
|
|
482
|
+
const response = await fetch(new URL("/auth/v1/token?grant_type=refresh_token", credentials.accountHost).toString(), {
|
|
483
|
+
method: "POST",
|
|
484
|
+
headers: { "Content-Type": "application/json" },
|
|
485
|
+
body: JSON.stringify({ refresh_token: credentials.refreshToken }),
|
|
486
|
+
});
|
|
487
|
+
return refreshedCredentials(credentials, await parsePinResponse<RefreshSessionResponse>(response, "Failed to refresh token"));
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
export function createAuthenticatedFetch(input: {
|
|
491
|
+
credentialsManager: AccountCLICredentialsManager;
|
|
492
|
+
credentials: AccountCLICredentials;
|
|
493
|
+
host: string;
|
|
494
|
+
}): typeof fetch {
|
|
495
|
+
return async (requestInput: RequestInfo | URL, init?: RequestInit) => {
|
|
496
|
+
const url = resolveRequestUrl(requestInput, input.host);
|
|
497
|
+
const body = await bodyOf(requestInput, init);
|
|
498
|
+
const firstInit: RequestInit = {
|
|
499
|
+
...init,
|
|
500
|
+
method: methodOf(requestInput, init),
|
|
501
|
+
headers: headersOf(requestInput, input.credentials.token, init),
|
|
502
|
+
body,
|
|
503
|
+
};
|
|
504
|
+
const first = await fetch(url.toString(), firstInit);
|
|
505
|
+
if (!(await isUnauthorizedResponse(first))) {
|
|
506
|
+
return first;
|
|
507
|
+
}
|
|
508
|
+
const nextCredentials = await refreshCredentials(input.credentials);
|
|
509
|
+
input.credentialsManager.saveCredentials(nextCredentials);
|
|
510
|
+
const retryInit: RequestInit = {
|
|
511
|
+
...init,
|
|
512
|
+
method: methodOf(requestInput, init),
|
|
513
|
+
headers: headersOf(requestInput, nextCredentials.token, init),
|
|
514
|
+
body,
|
|
515
|
+
};
|
|
516
|
+
return fetch(url.toString(), retryInit);
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function resolveHomeDir(homeDir: CreateAccountAuthCommandOptions["homeDir"]): string | undefined {
|
|
521
|
+
return typeof homeDir === "function" ? homeDir() : homeDir;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function commandConfigOptions(options: CreateAccountAuthCommandOptions): AccountCLIConfigOptions {
|
|
525
|
+
return {
|
|
526
|
+
homeDir: resolveHomeDir(options.homeDir),
|
|
527
|
+
configDirName: options.configDirName || options.cliName,
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function currentProfile(options: CreateAccountAuthCommandOptions) {
|
|
532
|
+
return loadCurrentProfile(commandConfigOptions(options)) || getDefaultProfileName();
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
export function createAccountAuthCommand(options: CreateAccountAuthCommandOptions) {
|
|
536
|
+
const output = options.output || console.log;
|
|
537
|
+
const auth = new Command("auth").description("Manage account login");
|
|
538
|
+
|
|
539
|
+
auth
|
|
540
|
+
.command("login")
|
|
541
|
+
.description("Login through account jcode pairing")
|
|
542
|
+
.option("--profile <name>", "Save credentials to a named profile")
|
|
543
|
+
.option("--no-open", "Skip automatic browser opening")
|
|
544
|
+
.action(async (commandOptions: { open?: boolean; profile?: string }) => {
|
|
545
|
+
const runtime = options.resolveRuntime(commandOptions.profile);
|
|
546
|
+
const login = options.login || loginWithAccountJCode;
|
|
547
|
+
const result = await login({
|
|
548
|
+
cliName: options.cliName,
|
|
549
|
+
cliVersion: options.cliVersion,
|
|
550
|
+
configDirName: options.configDirName || options.cliName,
|
|
551
|
+
homeDir: resolveHomeDir(options.homeDir),
|
|
552
|
+
profile: commandOptions.profile || runtime.profile,
|
|
553
|
+
host: runtime.host,
|
|
554
|
+
accountHost: runtime.accountHost,
|
|
555
|
+
accountWebHost: runtime.accountWebHost,
|
|
556
|
+
scope: runtime.scope,
|
|
557
|
+
openBrowser: commandOptions.open,
|
|
558
|
+
output,
|
|
559
|
+
});
|
|
560
|
+
output(`Logged in successfully as ${result.credentials.phone || result.credentials.userId} via ${options.cliName} ${options.cliVersion}`);
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
auth
|
|
564
|
+
.command("logout")
|
|
565
|
+
.description("Clear local credentials")
|
|
566
|
+
.option("--profile <name>", "Clear credentials for a named profile")
|
|
567
|
+
.action((commandOptions: { profile?: string }) => {
|
|
568
|
+
const runtime = options.resolveRuntime(commandOptions.profile);
|
|
569
|
+
const manager = new AccountCLICredentialsManager({
|
|
570
|
+
...commandConfigOptions(options),
|
|
571
|
+
profile: commandOptions.profile || runtime.profile,
|
|
572
|
+
});
|
|
573
|
+
manager.clearCredentials();
|
|
574
|
+
output(`Logged out from profile "${manager.profile}".`);
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
auth
|
|
578
|
+
.command("whoami")
|
|
579
|
+
.description("Display the current login identity")
|
|
580
|
+
.option("--profile <name>", "Use a named login profile")
|
|
581
|
+
.option("--json", "Print JSON output")
|
|
582
|
+
.action((commandOptions: { json?: boolean; profile?: string }) => {
|
|
583
|
+
const runtime = options.resolveRuntime(commandOptions.profile);
|
|
584
|
+
const manager = new AccountCLICredentialsManager({
|
|
585
|
+
...commandConfigOptions(options),
|
|
586
|
+
profile: commandOptions.profile || runtime.profile,
|
|
587
|
+
});
|
|
588
|
+
const credentials = manager.loadCredentials();
|
|
589
|
+
if (!credentials?.token) {
|
|
590
|
+
throw new Error(`Not logged in for profile "${manager.profile}". Please run: ${options.cliName} auth login --profile ${manager.profile}`);
|
|
591
|
+
}
|
|
592
|
+
const data = {
|
|
593
|
+
profile: manager.profile,
|
|
594
|
+
userId: credentials.userId,
|
|
595
|
+
phone: credentials.phone,
|
|
596
|
+
host: credentials.host,
|
|
597
|
+
scope: credentials.scope,
|
|
598
|
+
};
|
|
599
|
+
if (commandOptions.json) {
|
|
600
|
+
output(JSON.stringify(data, null, 2));
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
output(`Logged in as ${credentials.phone || credentials.userId} [${credentials.scope}] @ ${credentials.host} profile=${manager.profile}`);
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
const profiles = new Command("profiles").description("Manage local login profiles");
|
|
607
|
+
|
|
608
|
+
profiles
|
|
609
|
+
.command("list")
|
|
610
|
+
.description("List local login profiles")
|
|
611
|
+
.option("--json", "Print JSON output")
|
|
612
|
+
.action((commandOptions: { json?: boolean }) => {
|
|
613
|
+
const current = currentProfile(options);
|
|
614
|
+
const rows = listProfiles(commandConfigOptions(options)).map((profile) => ({ profile, current: profile === current }));
|
|
615
|
+
if (commandOptions.json) {
|
|
616
|
+
output(JSON.stringify({ currentProfile: current, profiles: rows }, null, 2));
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
output(rows.map((row) => `${row.current ? "*" : " "} ${row.profile}`).join("\n") || `* ${current}`);
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
profiles
|
|
623
|
+
.command("current")
|
|
624
|
+
.description("Display the current login profile")
|
|
625
|
+
.option("--json", "Print JSON output")
|
|
626
|
+
.action((commandOptions: { json?: boolean }) => {
|
|
627
|
+
const profile = currentProfile(options);
|
|
628
|
+
if (commandOptions.json) {
|
|
629
|
+
output(JSON.stringify({ currentProfile: profile }, null, 2));
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
output(profile);
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
profiles
|
|
636
|
+
.command("use")
|
|
637
|
+
.description("Set the current login profile")
|
|
638
|
+
.argument("<profile>", "Profile name")
|
|
639
|
+
.action((profile: string) => {
|
|
640
|
+
const normalized = validateProfileName(profile);
|
|
641
|
+
saveCurrentProfile(normalized, commandConfigOptions(options));
|
|
642
|
+
output(`Current profile set to "${normalized}".`);
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
profiles
|
|
646
|
+
.command("delete")
|
|
647
|
+
.description("Delete a local login profile")
|
|
648
|
+
.argument("<profile>", "Profile name")
|
|
649
|
+
.action((profile: string) => {
|
|
650
|
+
const normalized = validateProfileName(profile);
|
|
651
|
+
deleteProfile(normalized, commandConfigOptions(options));
|
|
652
|
+
output(`Deleted profile "${normalized}".`);
|
|
653
|
+
});
|
|
654
|
+
|
|
655
|
+
auth.addCommand(profiles);
|
|
656
|
+
return auth;
|
|
657
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "NodeNext",
|
|
5
|
+
"moduleResolution": "NodeNext",
|
|
6
|
+
"strict": true,
|
|
7
|
+
"esModuleInterop": true,
|
|
8
|
+
"skipLibCheck": true,
|
|
9
|
+
"forceConsistentCasingInFileNames": true,
|
|
10
|
+
"declaration": true,
|
|
11
|
+
"sourceMap": true,
|
|
12
|
+
"outDir": "dist",
|
|
13
|
+
"rootDir": "src",
|
|
14
|
+
"types": ["node", "vitest/globals"]
|
|
15
|
+
},
|
|
16
|
+
"include": ["src/**/*.ts"],
|
|
17
|
+
"exclude": ["src/__tests__/**"]
|
|
18
|
+
}
|