epismo 1.2.1 → 1.4.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/LICENSE +201 -0
- package/README.md +84 -75
- package/npm/bin/epismo.exe +1 -0
- package/npm/install.js +82 -0
- package/package.json +30 -38
- package/dist/analytics.js +0 -44
- package/dist/analytics.js.map +0 -1
- package/dist/api.js +0 -111
- package/dist/api.js.map +0 -1
- package/dist/auth.js +0 -658
- package/dist/auth.js.map +0 -1
- package/dist/bin.js +0 -3
- package/dist/bin.js.map +0 -1
- package/dist/cases.js +0 -25
- package/dist/cases.js.map +0 -1
- package/dist/config.js +0 -109
- package/dist/config.js.map +0 -1
- package/dist/context.js +0 -16
- package/dist/context.js.map +0 -1
- package/dist/credits.js +0 -31
- package/dist/credits.js.map +0 -1
- package/dist/errors.js +0 -111
- package/dist/errors.js.map +0 -1
- package/dist/index.js +0 -36
- package/dist/index.js.map +0 -1
- package/dist/input.js +0 -211
- package/dist/input.js.map +0 -1
- package/dist/output.js +0 -10
- package/dist/output.js.map +0 -1
- package/dist/playbook-program.js +0 -466
- package/dist/playbook-program.js.map +0 -1
- package/dist/playbooks.js +0 -61
- package/dist/playbooks.js.map +0 -1
- package/dist/program.js +0 -368
- package/dist/program.js.map +0 -1
- package/dist/projects.js +0 -54
- package/dist/projects.js.map +0 -1
- package/dist/records.js +0 -12
- package/dist/records.js.map +0 -1
- package/dist/suggestions.js +0 -15
- package/dist/suggestions.js.map +0 -1
- package/dist/token.js +0 -10
- package/dist/token.js.map +0 -1
- package/dist/update-check.js +0 -121
- package/dist/update-check.js.map +0 -1
- package/dist/workspace.js +0 -19
- package/dist/workspace.js.map +0 -1
- package/dist/workspaces.js +0 -61
- package/dist/workspaces.js.map +0 -1
package/dist/auth.js
DELETED
|
@@ -1,658 +0,0 @@
|
|
|
1
|
-
import { createServer } from "node:http";
|
|
2
|
-
import { randomBytes, createHash, timingSafeEqual } from "node:crypto";
|
|
3
|
-
import { spawn } from "node:child_process";
|
|
4
|
-
import { once } from "node:events";
|
|
5
|
-
import { createInterface } from "node:readline/promises";
|
|
6
|
-
import { stdin as input, stderr as output } from "node:process";
|
|
7
|
-
import { clearCredentials, readCliConfig, readCredentials, resolveApiUrl, resolveWebUrl, writeCliConfig, writeCredentials } from "./config.js";
|
|
8
|
-
import { requestJson } from "./api.js";
|
|
9
|
-
import { CliError } from "./errors.js";
|
|
10
|
-
import { printWarning } from "./output.js";
|
|
11
|
-
import { resolveWorkspaceSelection } from "./workspace.js";
|
|
12
|
-
import { analytics } from "./analytics.js";
|
|
13
|
-
const CLI_CLIENT_ID = "epismo-cli";
|
|
14
|
-
const TOKEN_REFRESH_THRESHOLD_MS = 5 * 60 * 1000;
|
|
15
|
-
// #8: common hint strings — defined once so wording is consistent everywhere
|
|
16
|
-
const HINT_RUN_LOGIN = "Set EPISMO_TOKEN or run `epismo login`.";
|
|
17
|
-
const HINT_RUN_LOGIN_BROWSER = "Run `epismo login` in an interactive terminal, or use a pre-issued EPISMO_TOKEN.";
|
|
18
|
-
const REFRESH_RACE_RETRY_DELAYS_MS = [100, 200, 400, 800];
|
|
19
|
-
// #9: browser login progress warning codes — defined once so wording is consistent everywhere
|
|
20
|
-
const WARNING_BROWSER_OPENING = "BROWSER_OPENING";
|
|
21
|
-
const WARNING_BROWSER_FALLBACK_URL = "BROWSER_FALLBACK_URL";
|
|
22
|
-
const WARNING_BROWSER_WAITING = "BROWSER_WAITING";
|
|
23
|
-
const WARNING_BROWSER_AUTHORIZED = "BROWSER_AUTHORIZED";
|
|
24
|
-
const WARNING_OTP_SENT = "OTP_SENT";
|
|
25
|
-
export function hasEnvTokenAuthentication() {
|
|
26
|
-
return Boolean(process.env.EPISMO_TOKEN?.trim());
|
|
27
|
-
}
|
|
28
|
-
async function prompt(question) {
|
|
29
|
-
if (input.isTTY !== true || output.isTTY !== true) {
|
|
30
|
-
throw new CliError({
|
|
31
|
-
code: "NON_INTERACTIVE_INPUT_REQUIRED",
|
|
32
|
-
message: "This login requires interactive input, but no TTY is available.",
|
|
33
|
-
hint: "Run the command in a terminal, or use a pre-issued EPISMO_TOKEN."
|
|
34
|
-
});
|
|
35
|
-
}
|
|
36
|
-
const readline = createInterface({ input, output });
|
|
37
|
-
try {
|
|
38
|
-
return (await readline.question(question)).trim();
|
|
39
|
-
}
|
|
40
|
-
finally {
|
|
41
|
-
readline.close();
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
function generatePkce() {
|
|
45
|
-
const verifier = randomBytes(32).toString("base64url");
|
|
46
|
-
const challenge = createHash("sha256").update(verifier).digest("base64url");
|
|
47
|
-
return { verifier, challenge };
|
|
48
|
-
}
|
|
49
|
-
function generateState() {
|
|
50
|
-
return randomBytes(32).toString("base64url");
|
|
51
|
-
}
|
|
52
|
-
async function openBrowser(url) {
|
|
53
|
-
const platform = process.platform;
|
|
54
|
-
const command = platform === "darwin"
|
|
55
|
-
? { file: "open", args: [url] }
|
|
56
|
-
: platform === "win32"
|
|
57
|
-
? { file: "cmd", args: ["/c", "start", "", url] }
|
|
58
|
-
: { file: "xdg-open", args: [url] };
|
|
59
|
-
await new Promise((resolve, reject) => {
|
|
60
|
-
const child = spawn(command.file, command.args, { detached: true, stdio: "ignore" });
|
|
61
|
-
child.once("error", (error) => {
|
|
62
|
-
reject(new CliError({
|
|
63
|
-
code: "BROWSER_OPEN_FAILED",
|
|
64
|
-
message: error.message,
|
|
65
|
-
hint: `Open this URL manually in your browser: ${url}`
|
|
66
|
-
}));
|
|
67
|
-
});
|
|
68
|
-
child.once("spawn", () => {
|
|
69
|
-
child.unref();
|
|
70
|
-
resolve();
|
|
71
|
-
});
|
|
72
|
-
});
|
|
73
|
-
}
|
|
74
|
-
function respondCallbackHtml(res, message) {
|
|
75
|
-
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
76
|
-
res.end(`<html><body><p>${message}</p></body></html>`);
|
|
77
|
-
}
|
|
78
|
-
// Constant-time comparison so the callback state cannot be probed via timing.
|
|
79
|
-
function isExpectedState(received, expected) {
|
|
80
|
-
if (received === null) {
|
|
81
|
-
return false;
|
|
82
|
-
}
|
|
83
|
-
const a = Buffer.from(received);
|
|
84
|
-
const b = Buffer.from(expected);
|
|
85
|
-
return a.length === b.length && timingSafeEqual(a, b);
|
|
86
|
-
}
|
|
87
|
-
async function startOAuthCallbackServer(expectedState) {
|
|
88
|
-
const server = createServer();
|
|
89
|
-
server.listen(0, "127.0.0.1");
|
|
90
|
-
await once(server, "listening");
|
|
91
|
-
const address = server.address();
|
|
92
|
-
if (!address || typeof address === "string") {
|
|
93
|
-
server.close();
|
|
94
|
-
throw new CliError({
|
|
95
|
-
code: "OAUTH_CALLBACK_FAILED",
|
|
96
|
-
message: "Failed to bind a local callback port for browser login."
|
|
97
|
-
});
|
|
98
|
-
}
|
|
99
|
-
const redirectUri = `http://127.0.0.1:${address.port}/callback`;
|
|
100
|
-
let resolveCallback;
|
|
101
|
-
let rejectCallback;
|
|
102
|
-
const callbackPromise = new Promise((resolve, reject) => {
|
|
103
|
-
resolveCallback = resolve;
|
|
104
|
-
rejectCallback = reject;
|
|
105
|
-
});
|
|
106
|
-
let timeoutId;
|
|
107
|
-
let settled = false;
|
|
108
|
-
const finish = (settle) => {
|
|
109
|
-
if (settled) {
|
|
110
|
-
return;
|
|
111
|
-
}
|
|
112
|
-
settled = true;
|
|
113
|
-
clearTimeout(timeoutId);
|
|
114
|
-
server.close();
|
|
115
|
-
settle();
|
|
116
|
-
};
|
|
117
|
-
// Attach handlers immediately — before the browser is opened — so a fast
|
|
118
|
-
// callback can never arrive in the window between listen() and the listener
|
|
119
|
-
// being registered. Requests that land before waitForCallback() simply settle
|
|
120
|
-
// the promise it returns.
|
|
121
|
-
server.on("request", (req, res) => {
|
|
122
|
-
const url = new URL(req.url ?? "/", redirectUri);
|
|
123
|
-
if (url.pathname !== "/callback") {
|
|
124
|
-
res.writeHead(404);
|
|
125
|
-
res.end();
|
|
126
|
-
return;
|
|
127
|
-
}
|
|
128
|
-
const code = url.searchParams.get("code");
|
|
129
|
-
const error = url.searchParams.get("error");
|
|
130
|
-
const state = url.searchParams.get("state");
|
|
131
|
-
const workspaceId = url.searchParams.get("workspace_id") ?? undefined;
|
|
132
|
-
if (!isExpectedState(state, expectedState)) {
|
|
133
|
-
respondCallbackHtml(res, "Authorization failed. Please close this tab and try again.");
|
|
134
|
-
finish(() => rejectCallback(new CliError({
|
|
135
|
-
code: "INVALID_OAUTH_STATE",
|
|
136
|
-
message: "OAuth callback state did not match the login request.",
|
|
137
|
-
retryable: true,
|
|
138
|
-
hint: HINT_RUN_LOGIN_BROWSER
|
|
139
|
-
})));
|
|
140
|
-
return;
|
|
141
|
-
}
|
|
142
|
-
if (error) {
|
|
143
|
-
respondCallbackHtml(res, "Authorization denied. You may close this tab.");
|
|
144
|
-
finish(() => rejectCallback(new CliError({
|
|
145
|
-
code: "AUTHORIZATION_DENIED",
|
|
146
|
-
message: `Authorization denied: ${error}`,
|
|
147
|
-
retryable: false
|
|
148
|
-
})));
|
|
149
|
-
return;
|
|
150
|
-
}
|
|
151
|
-
if (!code) {
|
|
152
|
-
respondCallbackHtml(res, "Authorization failed. Please close this tab and try again.");
|
|
153
|
-
finish(() => rejectCallback(new CliError({
|
|
154
|
-
code: "AUTHORIZATION_CODE_MISSING",
|
|
155
|
-
message: "No authorization code received from the browser callback.",
|
|
156
|
-
retryable: true
|
|
157
|
-
})));
|
|
158
|
-
return;
|
|
159
|
-
}
|
|
160
|
-
respondCallbackHtml(res, "Authorization complete. Return to your terminal to continue.");
|
|
161
|
-
finish(() => resolveCallback({ code, workspaceId }));
|
|
162
|
-
});
|
|
163
|
-
server.on("error", (err) => {
|
|
164
|
-
finish(() => rejectCallback(err instanceof Error
|
|
165
|
-
? new CliError({
|
|
166
|
-
code: "OAUTH_CALLBACK_FAILED",
|
|
167
|
-
message: err.message,
|
|
168
|
-
retryable: true
|
|
169
|
-
})
|
|
170
|
-
: err));
|
|
171
|
-
});
|
|
172
|
-
const waitForCallback = () => {
|
|
173
|
-
if (settled || timeoutId !== undefined) {
|
|
174
|
-
return callbackPromise;
|
|
175
|
-
}
|
|
176
|
-
timeoutId = setTimeout(() => {
|
|
177
|
-
finish(() => rejectCallback(new CliError({
|
|
178
|
-
code: "AUTHORIZATION_TIMED_OUT",
|
|
179
|
-
message: "Authorization timed out after 5 minutes.",
|
|
180
|
-
retryable: true,
|
|
181
|
-
hint: HINT_RUN_LOGIN_BROWSER
|
|
182
|
-
})));
|
|
183
|
-
}, 5 * 60 * 1000);
|
|
184
|
-
return callbackPromise;
|
|
185
|
-
};
|
|
186
|
-
return { redirectUri, waitForCallback };
|
|
187
|
-
}
|
|
188
|
-
async function postOAuthToken(apiUrl, body) {
|
|
189
|
-
return requestJson(apiUrl, "/oauth/token", {
|
|
190
|
-
method: "POST",
|
|
191
|
-
body
|
|
192
|
-
});
|
|
193
|
-
}
|
|
194
|
-
async function resolveLoginMethod(apiUrl, email) {
|
|
195
|
-
const response = await requestJson(apiUrl, "/v1/login-options", {
|
|
196
|
-
method: "POST",
|
|
197
|
-
body: { email }
|
|
198
|
-
});
|
|
199
|
-
if (response.method !== "sso" && response.method !== "otp") {
|
|
200
|
-
throw new CliError({
|
|
201
|
-
code: "LOGIN_METHOD_INVALID",
|
|
202
|
-
message: "The server returned an unsupported login method.",
|
|
203
|
-
retryable: true
|
|
204
|
-
});
|
|
205
|
-
}
|
|
206
|
-
return response.method;
|
|
207
|
-
}
|
|
208
|
-
async function loginWithOtp(apiUrl, email) {
|
|
209
|
-
if (input.isTTY !== true || output.isTTY !== true) {
|
|
210
|
-
throw new CliError({
|
|
211
|
-
code: "NON_INTERACTIVE_INPUT_REQUIRED",
|
|
212
|
-
message: "Email-code login requires an interactive terminal.",
|
|
213
|
-
hint: "Run the command in a terminal, or use a pre-issued EPISMO_TOKEN."
|
|
214
|
-
});
|
|
215
|
-
}
|
|
216
|
-
const issued = await requestJson(apiUrl, "/v1/otp-tokens", {
|
|
217
|
-
method: "POST",
|
|
218
|
-
body: { email }
|
|
219
|
-
});
|
|
220
|
-
const otpId = issued.otpId?.trim();
|
|
221
|
-
if (!otpId) {
|
|
222
|
-
throw new CliError({
|
|
223
|
-
code: "OTP_ID_MISSING",
|
|
224
|
-
message: "Failed to obtain an OTP id from the server.",
|
|
225
|
-
retryable: true
|
|
226
|
-
});
|
|
227
|
-
}
|
|
228
|
-
printWarning({
|
|
229
|
-
warning: { code: WARNING_OTP_SENT, message: `A sign-in code was sent to ${email}.` }
|
|
230
|
-
});
|
|
231
|
-
const otp = await prompt("Code: ");
|
|
232
|
-
if (!otp) {
|
|
233
|
-
throw new CliError({
|
|
234
|
-
code: "OTP_CODE_REQUIRED",
|
|
235
|
-
message: "A sign-in code is required.",
|
|
236
|
-
hint: `Run \`epismo login --email ${email}\` to request a new code.`
|
|
237
|
-
});
|
|
238
|
-
}
|
|
239
|
-
const tokenResponse = await postOAuthToken(apiUrl, {
|
|
240
|
-
grant_type: "otp",
|
|
241
|
-
otp_id: otpId,
|
|
242
|
-
otp,
|
|
243
|
-
client_id: CLI_CLIENT_ID
|
|
244
|
-
});
|
|
245
|
-
return toVerifiedLogin(tokenResponse, await fetchUserInfo(apiUrl, tokenResponse.access_token));
|
|
246
|
-
}
|
|
247
|
-
async function fetchUserInfo(apiUrl, accessToken) {
|
|
248
|
-
return requestJson(apiUrl, "/oauth/userinfo", {
|
|
249
|
-
authToken: accessToken
|
|
250
|
-
});
|
|
251
|
-
}
|
|
252
|
-
export async function resolvePersonalAccountId(auth) {
|
|
253
|
-
const cached = auth.accountId?.trim();
|
|
254
|
-
if (cached)
|
|
255
|
-
return cached;
|
|
256
|
-
const userInfo = await fetchUserInfo(resolveApiUrl(), auth.accessToken);
|
|
257
|
-
const accountId = userInfo.account_id?.trim();
|
|
258
|
-
if (!accountId) {
|
|
259
|
-
throw new CliError({
|
|
260
|
-
code: "ACCOUNT_ID_UNAVAILABLE",
|
|
261
|
-
message: "The authenticated user does not have a personal Account ID.",
|
|
262
|
-
hint: "Pass --owner-id explicitly, or run `epismo login` again."
|
|
263
|
-
});
|
|
264
|
-
}
|
|
265
|
-
return accountId;
|
|
266
|
-
}
|
|
267
|
-
function sleep(ms) {
|
|
268
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
269
|
-
}
|
|
270
|
-
function shouldRefreshCredentials(credentials) {
|
|
271
|
-
const expiresAt = new Date(credentials.expiresAt).getTime();
|
|
272
|
-
return !Number.isFinite(expiresAt) || expiresAt - Date.now() < TOKEN_REFRESH_THRESHOLD_MS;
|
|
273
|
-
}
|
|
274
|
-
async function fetchWorkspaces(apiUrl, accessToken) {
|
|
275
|
-
const response = await requestJson(apiUrl, "/v1/workspaces", {
|
|
276
|
-
authToken: accessToken
|
|
277
|
-
});
|
|
278
|
-
return response.workspaces;
|
|
279
|
-
}
|
|
280
|
-
function toSavedWorkspace(workspace) {
|
|
281
|
-
if (!workspace) {
|
|
282
|
-
return undefined;
|
|
283
|
-
}
|
|
284
|
-
return {
|
|
285
|
-
id: workspace.id,
|
|
286
|
-
handle: workspace.handle,
|
|
287
|
-
accountId: workspace.accountId,
|
|
288
|
-
role: workspace.role
|
|
289
|
-
};
|
|
290
|
-
}
|
|
291
|
-
export function buildCliAuthorizationUrl(input) {
|
|
292
|
-
const authorizeUrl = new URL(`${input.webUrl}/oauth/authorize`);
|
|
293
|
-
authorizeUrl.searchParams.set("response_type", "code");
|
|
294
|
-
authorizeUrl.searchParams.set("client_id", CLI_CLIENT_ID);
|
|
295
|
-
authorizeUrl.searchParams.set("redirect_uri", input.redirectUri);
|
|
296
|
-
authorizeUrl.searchParams.set("scope", "read write offline_access");
|
|
297
|
-
authorizeUrl.searchParams.set("code_challenge", input.challenge);
|
|
298
|
-
authorizeUrl.searchParams.set("code_challenge_method", "S256");
|
|
299
|
-
authorizeUrl.searchParams.set("state", input.state);
|
|
300
|
-
const email = input.email?.trim();
|
|
301
|
-
if (email)
|
|
302
|
-
authorizeUrl.searchParams.set("login_hint", email);
|
|
303
|
-
return authorizeUrl;
|
|
304
|
-
}
|
|
305
|
-
async function loginWithBrowser(apiUrl, webUrl, email) {
|
|
306
|
-
if (output.isTTY !== true) {
|
|
307
|
-
throw new CliError({
|
|
308
|
-
code: "NON_INTERACTIVE_BROWSER_LOGIN",
|
|
309
|
-
message: "Browser login requires an interactive terminal.",
|
|
310
|
-
hint: HINT_RUN_LOGIN_BROWSER
|
|
311
|
-
});
|
|
312
|
-
}
|
|
313
|
-
const { verifier, challenge } = generatePkce();
|
|
314
|
-
const state = generateState();
|
|
315
|
-
const { redirectUri, waitForCallback } = await startOAuthCallbackServer(state);
|
|
316
|
-
const authorizeUrl = buildCliAuthorizationUrl({ webUrl, redirectUri, challenge, state, email });
|
|
317
|
-
const authorizeUrlString = authorizeUrl.toString();
|
|
318
|
-
printWarning({
|
|
319
|
-
warning: { code: WARNING_BROWSER_OPENING, message: "Opening browser for authorization..." }
|
|
320
|
-
});
|
|
321
|
-
printWarning({
|
|
322
|
-
warning: {
|
|
323
|
-
code: WARNING_BROWSER_FALLBACK_URL,
|
|
324
|
-
message: `If the browser does not open, visit: ${authorizeUrlString}`
|
|
325
|
-
}
|
|
326
|
-
});
|
|
327
|
-
try {
|
|
328
|
-
await openBrowser(authorizeUrlString);
|
|
329
|
-
}
|
|
330
|
-
catch (cause) {
|
|
331
|
-
if (!(cause instanceof CliError) || cause.code !== "BROWSER_OPEN_FAILED") {
|
|
332
|
-
throw cause;
|
|
333
|
-
}
|
|
334
|
-
// The callback server is already listening and the URL was printed above,
|
|
335
|
-
// so manual browser navigation can complete the same authorization flow.
|
|
336
|
-
}
|
|
337
|
-
printWarning({
|
|
338
|
-
warning: {
|
|
339
|
-
code: WARNING_BROWSER_WAITING,
|
|
340
|
-
message: "Waiting for authorization in your browser (times out in 5 minutes)..."
|
|
341
|
-
}
|
|
342
|
-
});
|
|
343
|
-
const { code, workspaceId } = await waitForCallback();
|
|
344
|
-
printWarning({
|
|
345
|
-
warning: {
|
|
346
|
-
code: WARNING_BROWSER_AUTHORIZED,
|
|
347
|
-
message: "Authorization received. Completing sign-in..."
|
|
348
|
-
}
|
|
349
|
-
});
|
|
350
|
-
const tokenResponse = await postOAuthToken(apiUrl, {
|
|
351
|
-
grant_type: "authorization_code",
|
|
352
|
-
code,
|
|
353
|
-
client_id: CLI_CLIENT_ID,
|
|
354
|
-
redirect_uri: redirectUri,
|
|
355
|
-
code_verifier: verifier
|
|
356
|
-
});
|
|
357
|
-
const verified = toVerifiedLogin(tokenResponse, await fetchUserInfo(apiUrl, tokenResponse.access_token));
|
|
358
|
-
return { ...verified, workspaceId };
|
|
359
|
-
}
|
|
360
|
-
// Compare and store canonical lowercased addresses so the same account typed
|
|
361
|
-
// with different casing does not trigger an unnecessary browser login.
|
|
362
|
-
export function normalizeEmail(email) {
|
|
363
|
-
return email.trim().toLowerCase();
|
|
364
|
-
}
|
|
365
|
-
function toVerifiedLogin(tokenResponse, userInfo) {
|
|
366
|
-
const expiresAt = new Date(Date.now() + tokenResponse.expires_in * 1000).toISOString();
|
|
367
|
-
return {
|
|
368
|
-
credentials: {
|
|
369
|
-
accessToken: tokenResponse.access_token,
|
|
370
|
-
refreshToken: tokenResponse.refresh_token,
|
|
371
|
-
expiresAt,
|
|
372
|
-
clientId: CLI_CLIENT_ID,
|
|
373
|
-
userId: userInfo.sub,
|
|
374
|
-
accountId: userInfo.account_id
|
|
375
|
-
},
|
|
376
|
-
email: userInfo.email ? normalizeEmail(userInfo.email) : undefined
|
|
377
|
-
};
|
|
378
|
-
}
|
|
379
|
-
// A workspace_id chosen on the browser consent screen that matches the caller's own
|
|
380
|
-
// user id is their personal account, not an org workspace — nothing to look up or
|
|
381
|
-
// adopt as a default. Otherwise, resolve it against the accessible workspace list so
|
|
382
|
-
// the CLI can adopt the same workspace the user just authorized in the browser.
|
|
383
|
-
async function resolveLoginWorkspace(apiUrl, accessToken, userId, workspaceId) {
|
|
384
|
-
if (workspaceId === userId) {
|
|
385
|
-
return undefined;
|
|
386
|
-
}
|
|
387
|
-
const workspaces = await fetchWorkspaces(apiUrl, accessToken);
|
|
388
|
-
return toSavedWorkspace(workspaces.find((workspace) => workspace.id === workspaceId));
|
|
389
|
-
}
|
|
390
|
-
// Persist tokens, remember the verified email, and identify.
|
|
391
|
-
async function persistLogin(credentials, config, lastLoginEmail) {
|
|
392
|
-
await writeCredentials(credentials);
|
|
393
|
-
await writeCliConfig({
|
|
394
|
-
...config,
|
|
395
|
-
lastLoginEmail: lastLoginEmail ?? config.lastLoginEmail
|
|
396
|
-
});
|
|
397
|
-
// auth.signed_in is emitted server-side by the API at /oauth/token.
|
|
398
|
-
analytics.identify({ userId: credentials.userId }, config.defaultWorkspace?.id);
|
|
399
|
-
}
|
|
400
|
-
function toLoggedIn(credentials, lastLoginEmail, config, alreadyLoggedIn) {
|
|
401
|
-
// The default-workspace nudge only applies to a fresh sign-in; the
|
|
402
|
-
// already-logged-in case carries its own hint in the CLI layer.
|
|
403
|
-
const hint = !alreadyLoggedIn && !config.defaultWorkspace
|
|
404
|
-
? "No default workspace set. Run `epismo workspace use --workspace-id <id>` to set a default workspace."
|
|
405
|
-
: undefined;
|
|
406
|
-
return {
|
|
407
|
-
kind: "logged_in",
|
|
408
|
-
alreadyLoggedIn,
|
|
409
|
-
userId: credentials.userId,
|
|
410
|
-
expiresAt: credentials.expiresAt,
|
|
411
|
-
lastLoginEmail: lastLoginEmail ?? config.lastLoginEmail,
|
|
412
|
-
...(hint ? { hint } : {})
|
|
413
|
-
};
|
|
414
|
-
}
|
|
415
|
-
// Without an email, Web browser OAuth owns login-method selection. With an
|
|
416
|
-
// email, the API performs the same domain discovery: SSO continues in the
|
|
417
|
-
// browser while OTP completes interactively here in the terminal.
|
|
418
|
-
export async function login(options) {
|
|
419
|
-
const apiUrl = resolveApiUrl();
|
|
420
|
-
const webUrl = resolveWebUrl();
|
|
421
|
-
const config = await readCliConfig();
|
|
422
|
-
const requestedEmail = options.email?.trim() ? normalizeEmail(options.email) : undefined;
|
|
423
|
-
// Reuse a fresh credential only when the requested account matches.
|
|
424
|
-
const existing = await readCredentials();
|
|
425
|
-
if (existing && !shouldRefreshCredentials(existing)) {
|
|
426
|
-
const sameAccount = !requestedEmail ||
|
|
427
|
-
(!!config.lastLoginEmail && requestedEmail === normalizeEmail(config.lastLoginEmail));
|
|
428
|
-
if (sameAccount) {
|
|
429
|
-
return toLoggedIn(existing, config.lastLoginEmail, config, true);
|
|
430
|
-
}
|
|
431
|
-
}
|
|
432
|
-
const verified = requestedEmail && (await resolveLoginMethod(apiUrl, requestedEmail)) === "otp"
|
|
433
|
-
? await loginWithOtp(apiUrl, requestedEmail)
|
|
434
|
-
: await loginWithBrowser(apiUrl, webUrl, requestedEmail);
|
|
435
|
-
const lastLoginEmail = verified.email ?? config.lastLoginEmail;
|
|
436
|
-
const { credentials } = verified;
|
|
437
|
-
// Adopt the workspace chosen on the browser consent screen (if any) as the local
|
|
438
|
-
// default, so a fresh browser login into an org workspace doesn't also require a
|
|
439
|
-
// separate `epismo workspace use`.
|
|
440
|
-
const selectedWorkspace = verified.workspaceId
|
|
441
|
-
? await resolveLoginWorkspace(apiUrl, credentials.accessToken, credentials.userId, verified.workspaceId)
|
|
442
|
-
: undefined;
|
|
443
|
-
const updatedConfig = selectedWorkspace
|
|
444
|
-
? { ...config, defaultWorkspace: selectedWorkspace }
|
|
445
|
-
: config;
|
|
446
|
-
await persistLogin(credentials, updatedConfig, lastLoginEmail);
|
|
447
|
-
return toLoggedIn(credentials, lastLoginEmail, updatedConfig, false);
|
|
448
|
-
}
|
|
449
|
-
export async function logout() {
|
|
450
|
-
const credentials = await readCredentials();
|
|
451
|
-
const hadCredentials = Boolean(credentials?.accessToken);
|
|
452
|
-
if (credentials?.refreshToken) {
|
|
453
|
-
try {
|
|
454
|
-
await requestJson(resolveApiUrl(), "/oauth/revoke", {
|
|
455
|
-
method: "POST",
|
|
456
|
-
authToken: credentials.accessToken,
|
|
457
|
-
body: { token: credentials.refreshToken, client_id: credentials.clientId }
|
|
458
|
-
});
|
|
459
|
-
}
|
|
460
|
-
catch {
|
|
461
|
-
// Clear local state even when server-side revocation fails.
|
|
462
|
-
}
|
|
463
|
-
}
|
|
464
|
-
await clearCredentials();
|
|
465
|
-
// auth.signed_out is emitted server-side by the API at /oauth/revoke.
|
|
466
|
-
return { cleared: true, hadCredentials };
|
|
467
|
-
}
|
|
468
|
-
async function refreshAccessToken(credentials, apiUrl) {
|
|
469
|
-
const tokenResponse = await postOAuthToken(apiUrl, {
|
|
470
|
-
grant_type: "refresh_token",
|
|
471
|
-
refresh_token: credentials.refreshToken,
|
|
472
|
-
client_id: credentials.clientId
|
|
473
|
-
});
|
|
474
|
-
const expiresAt = new Date(Date.now() + tokenResponse.expires_in * 1000).toISOString();
|
|
475
|
-
const updated = {
|
|
476
|
-
...credentials,
|
|
477
|
-
accessToken: tokenResponse.access_token,
|
|
478
|
-
refreshToken: tokenResponse.refresh_token,
|
|
479
|
-
expiresAt
|
|
480
|
-
};
|
|
481
|
-
await writeCredentials(updated);
|
|
482
|
-
return updated;
|
|
483
|
-
}
|
|
484
|
-
async function readCredentialsRefreshedByAnotherProcess(original) {
|
|
485
|
-
for (const delayMs of REFRESH_RACE_RETRY_DELAYS_MS) {
|
|
486
|
-
const latest = await readCredentials();
|
|
487
|
-
if (latest?.userId === original.userId &&
|
|
488
|
-
latest.accessToken !== original.accessToken &&
|
|
489
|
-
!shouldRefreshCredentials(latest)) {
|
|
490
|
-
return latest;
|
|
491
|
-
}
|
|
492
|
-
await sleep(delayMs);
|
|
493
|
-
}
|
|
494
|
-
return undefined;
|
|
495
|
-
}
|
|
496
|
-
export async function resolveAuthentication() {
|
|
497
|
-
const envToken = process.env.EPISMO_TOKEN?.trim();
|
|
498
|
-
if (envToken) {
|
|
499
|
-
// EPISMO_TOKEN is a pre-issued token (CI/CD). Skip the /oauth/userinfo
|
|
500
|
-
// round-trip on every command: userId here is only consumed by the no-op
|
|
501
|
-
// analytics.identify(), and `whoami` fetches identity itself when needed.
|
|
502
|
-
return {
|
|
503
|
-
mode: "oauth",
|
|
504
|
-
userId: "",
|
|
505
|
-
accessToken: envToken
|
|
506
|
-
};
|
|
507
|
-
}
|
|
508
|
-
const credentials = await readCredentials();
|
|
509
|
-
if (!credentials?.accessToken || !credentials.userId) {
|
|
510
|
-
throw new CliError({
|
|
511
|
-
code: "NOT_AUTHENTICATED",
|
|
512
|
-
message: "Not authenticated.",
|
|
513
|
-
hint: HINT_RUN_LOGIN
|
|
514
|
-
});
|
|
515
|
-
}
|
|
516
|
-
const apiUrl = resolveApiUrl();
|
|
517
|
-
if (shouldRefreshCredentials(credentials)) {
|
|
518
|
-
if (!credentials.refreshToken) {
|
|
519
|
-
throw new CliError({
|
|
520
|
-
code: "TOKEN_EXPIRED",
|
|
521
|
-
message: "Token expired and no refresh token is available.",
|
|
522
|
-
hint: HINT_RUN_LOGIN
|
|
523
|
-
});
|
|
524
|
-
}
|
|
525
|
-
try {
|
|
526
|
-
const refreshed = await refreshAccessToken(credentials, apiUrl);
|
|
527
|
-
return {
|
|
528
|
-
mode: "oauth",
|
|
529
|
-
userId: refreshed.userId,
|
|
530
|
-
accessToken: refreshed.accessToken,
|
|
531
|
-
expiresAt: refreshed.expiresAt,
|
|
532
|
-
accountId: refreshed.accountId
|
|
533
|
-
};
|
|
534
|
-
}
|
|
535
|
-
catch (refreshError) {
|
|
536
|
-
const refreshedByAnotherProcess = await readCredentialsRefreshedByAnotherProcess(credentials);
|
|
537
|
-
if (refreshedByAnotherProcess) {
|
|
538
|
-
return {
|
|
539
|
-
mode: "oauth",
|
|
540
|
-
userId: refreshedByAnotherProcess.userId,
|
|
541
|
-
accessToken: refreshedByAnotherProcess.accessToken,
|
|
542
|
-
expiresAt: refreshedByAnotherProcess.expiresAt,
|
|
543
|
-
accountId: refreshedByAnotherProcess.accountId
|
|
544
|
-
};
|
|
545
|
-
}
|
|
546
|
-
const isRetryable = refreshError instanceof CliError ? refreshError.retryable : true;
|
|
547
|
-
throw new CliError({
|
|
548
|
-
code: "TOKEN_REFRESH_FAILED",
|
|
549
|
-
message: isRetryable
|
|
550
|
-
? "Token refresh failed due to a network or server error."
|
|
551
|
-
: "Session expired. Please log in again.",
|
|
552
|
-
retryable: isRetryable,
|
|
553
|
-
hint: isRetryable
|
|
554
|
-
? `Check your network connection and try again, or run \`epismo login\`.`
|
|
555
|
-
: HINT_RUN_LOGIN
|
|
556
|
-
});
|
|
557
|
-
}
|
|
558
|
-
}
|
|
559
|
-
return {
|
|
560
|
-
mode: "oauth",
|
|
561
|
-
userId: credentials.userId,
|
|
562
|
-
accessToken: credentials.accessToken,
|
|
563
|
-
expiresAt: credentials.expiresAt,
|
|
564
|
-
accountId: credentials.accountId
|
|
565
|
-
};
|
|
566
|
-
}
|
|
567
|
-
export async function whoami(options) {
|
|
568
|
-
const workspace = await resolveWorkspaceSelection();
|
|
569
|
-
const auth = options.auth ?? (await resolveAuthentication());
|
|
570
|
-
const apiUrl = resolveApiUrl();
|
|
571
|
-
const [user, workspaces] = await Promise.all([
|
|
572
|
-
fetchUserInfo(apiUrl, auth.accessToken),
|
|
573
|
-
fetchWorkspaces(apiUrl, auth.accessToken)
|
|
574
|
-
]);
|
|
575
|
-
return {
|
|
576
|
-
auth: {
|
|
577
|
-
mode: auth.mode,
|
|
578
|
-
expiresAt: auth.expiresAt
|
|
579
|
-
},
|
|
580
|
-
user: {
|
|
581
|
-
id: user.sub,
|
|
582
|
-
email: user.email,
|
|
583
|
-
// Free-text display name, not unique and not addressable.
|
|
584
|
-
username: user.username,
|
|
585
|
-
// Public identifier: the <handle> in pb:<handle>/<alias>. Null until
|
|
586
|
-
// the user claims one.
|
|
587
|
-
handle: user.handle ?? null,
|
|
588
|
-
// Immutable; the default --owner-id for `epismo playbook create`.
|
|
589
|
-
accountId: user.account_id ?? null
|
|
590
|
-
},
|
|
591
|
-
defaultWorkspaceId: workspace.defaultWorkspace?.id ?? null,
|
|
592
|
-
defaultWorkspaceApplied: workspace.envTokenActive === false,
|
|
593
|
-
effectiveWorkspaceId: workspace.effectiveWorkspaceId ?? null,
|
|
594
|
-
workspaces
|
|
595
|
-
};
|
|
596
|
-
}
|
|
597
|
-
export async function listAvailableWorkspaces() {
|
|
598
|
-
const workspaceSelection = await resolveWorkspaceSelection();
|
|
599
|
-
const auth = await resolveAuthentication();
|
|
600
|
-
const workspaces = await fetchWorkspaces(resolveApiUrl(), auth.accessToken);
|
|
601
|
-
const defaultWorkspaceId = workspaceSelection.defaultWorkspace?.id ?? null;
|
|
602
|
-
return {
|
|
603
|
-
workspaces: workspaces.map((item) => ({
|
|
604
|
-
...item,
|
|
605
|
-
isDefault: item.id === defaultWorkspaceId
|
|
606
|
-
}))
|
|
607
|
-
};
|
|
608
|
-
}
|
|
609
|
-
export async function getCurrentWorkspace() {
|
|
610
|
-
const config = await readCliConfig();
|
|
611
|
-
const currentWorkspace = config.defaultWorkspace;
|
|
612
|
-
return {
|
|
613
|
-
currentWorkspace: currentWorkspace
|
|
614
|
-
? {
|
|
615
|
-
...currentWorkspace,
|
|
616
|
-
isDefault: true,
|
|
617
|
-
source: "local-config"
|
|
618
|
-
}
|
|
619
|
-
: null
|
|
620
|
-
};
|
|
621
|
-
}
|
|
622
|
-
export async function useWorkspace(options) {
|
|
623
|
-
const requestedWorkspaceId = options.workspaceId?.trim();
|
|
624
|
-
if (!requestedWorkspaceId) {
|
|
625
|
-
throw new CliError({
|
|
626
|
-
code: "WORKSPACE_ID_REQUIRED",
|
|
627
|
-
message: "--workspace-id is required.",
|
|
628
|
-
hint: "Run `epismo workspace use --workspace-id <workspace-id>`."
|
|
629
|
-
});
|
|
630
|
-
}
|
|
631
|
-
const config = await readCliConfig();
|
|
632
|
-
const listed = await listAvailableWorkspaces();
|
|
633
|
-
const matched = listed.workspaces.find((workspace) => workspace.id === requestedWorkspaceId);
|
|
634
|
-
if (!matched) {
|
|
635
|
-
throw new CliError({
|
|
636
|
-
code: "WORKSPACE_NOT_FOUND",
|
|
637
|
-
message: `Workspace not found or not accessible: ${requestedWorkspaceId}`,
|
|
638
|
-
hint: "Run `epismo workspace list` to see accessible workspaces."
|
|
639
|
-
});
|
|
640
|
-
}
|
|
641
|
-
await writeCliConfig({
|
|
642
|
-
...config,
|
|
643
|
-
defaultWorkspace: toSavedWorkspace(matched)
|
|
644
|
-
});
|
|
645
|
-
return {
|
|
646
|
-
workspace: { ...matched, isDefault: true }
|
|
647
|
-
};
|
|
648
|
-
}
|
|
649
|
-
export async function clearWorkspace() {
|
|
650
|
-
const config = await readCliConfig();
|
|
651
|
-
const previousWorkspace = config.defaultWorkspace ?? null;
|
|
652
|
-
await writeCliConfig({
|
|
653
|
-
...config,
|
|
654
|
-
defaultWorkspace: undefined
|
|
655
|
-
});
|
|
656
|
-
return { cleared: true, previousWorkspace };
|
|
657
|
-
}
|
|
658
|
-
//# sourceMappingURL=auth.js.map
|