epismo 0.19.1 → 1.0.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/README.md +76 -66
- package/dist/auth.js +121 -183
- package/dist/auth.js.map +1 -1
- package/dist/cases.js +25 -0
- package/dist/cases.js.map +1 -0
- package/dist/config.js +2 -7
- package/dist/config.js.map +1 -1
- package/dist/playbook-program.js +393 -0
- package/dist/playbook-program.js.map +1 -0
- package/dist/playbooks.js +56 -0
- package/dist/playbooks.js.map +1 -0
- package/dist/program.js +13 -880
- package/dist/program.js.map +1 -1
- package/dist/records.js +12 -0
- package/dist/records.js.map +1 -0
- package/dist/suggestions.js +12 -40
- package/dist/suggestions.js.map +1 -1
- package/package.json +17 -1
- package/dist/agents.js +0 -40
- package/dist/agents.js.map +0 -1
- package/dist/aliases.js +0 -49
- package/dist/aliases.js.map +0 -1
- package/dist/logs.js +0 -43
- package/dist/logs.js.map +0 -1
- package/dist/packs.js +0 -177
- package/dist/packs.js.map +0 -1
- package/dist/track-reference.js +0 -32
- package/dist/track-reference.js.map +0 -1
- package/dist/tracks.js +0 -110
- package/dist/tracks.js.map +0 -1
package/README.md
CHANGED
|
@@ -1,101 +1,111 @@
|
|
|
1
1
|
# Epismo CLI
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
Hugging Face made AI models reusable.
|
|
5
|
-
Epismo makes agentic workflows reusable.
|
|
6
|
-
|
|
7
|
-
Capture how you got your best results — not just the output — and share them as executable workflows that agents and humans can discover, adapt, and run across projects.
|
|
8
|
-
|
|
9
|
-
## CLI Contract
|
|
10
|
-
|
|
11
|
-
Epismo CLI is designed for agents first.
|
|
12
|
-
|
|
13
|
-
- Success output is machine-readable JSON on `stdout`, in both interactive and non-interactive runs.
|
|
14
|
-
- `stderr` carries prompts, browser-login guidance, warnings, and errors (all JSON-shaped).
|
|
15
|
-
- Exit code `0` means success; any non-zero code means failure.
|
|
16
|
-
- In non-interactive environments, commands that would need a prompt fail fast instead of waiting for input.
|
|
3
|
+
Agent-friendly CLI for Playbooks, Cases, Tasks, Records, Suggestions, Stars, and Aliases. Successful commands print JSON to stdout; warnings and failures are JSON-shaped on stderr.
|
|
17
4
|
|
|
18
5
|
## Install
|
|
19
6
|
|
|
7
|
+
Node.js 22.12 or newer is required.
|
|
8
|
+
|
|
20
9
|
```bash
|
|
21
|
-
npm install -g epismo
|
|
10
|
+
npm install -g epismo
|
|
11
|
+
# or
|
|
12
|
+
npx epismo <command>
|
|
22
13
|
```
|
|
23
14
|
|
|
24
|
-
## Authentication
|
|
15
|
+
## Authentication and workspace selection
|
|
25
16
|
|
|
26
17
|
```bash
|
|
27
|
-
epismo login
|
|
28
|
-
epismo
|
|
29
|
-
|
|
18
|
+
epismo login
|
|
19
|
+
epismo login --email you@example.com
|
|
20
|
+
epismo whoami
|
|
21
|
+
epismo logout
|
|
30
22
|
```
|
|
31
23
|
|
|
32
|
-
|
|
24
|
+
Commands use the workspace embedded in `EPISMO_TOKEN`, then the saved default from
|
|
25
|
+
`epismo workspace use`, then personal space. A workspace-scoped CI token can be issued
|
|
26
|
+
with `epismo token create --workspace-id <workspace-id>`.
|
|
33
27
|
|
|
34
28
|
## Commands
|
|
35
29
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
| Command | Purpose |
|
|
40
|
-
| ------------ | ----------------------------------------------------------------------------- |
|
|
41
|
-
| `whoami` | Show the current user, effective workspace, and accessible workspaces. |
|
|
42
|
-
| `workspace` | List/create/update workspaces, manage members, and get billing checkout URLs. |
|
|
43
|
-
| `project` | Manage projects within a workspace and their members. |
|
|
44
|
-
| `agent` | Manage which AI teammates appear in the assignee roster. |
|
|
45
|
-
| `track` | Create, update, search, and bulk-apply task/goal project tracks. |
|
|
46
|
-
| `log` | Append, list, and delete activity/comment logs on tracks. |
|
|
47
|
-
| `pack` | Create, update, fetch, search, like, rate, and delete packs. |
|
|
48
|
-
| `suggestion` | Send and triage improvement suggestions against packs. |
|
|
49
|
-
| `alias` | Manage pack aliases as a top-level resource. |
|
|
50
|
-
| `credit` | Check the credit balance and start credit checkout. |
|
|
51
|
-
| `token` | Issue workspace-scoped CLI tokens for CI/CD. |
|
|
30
|
+
```text
|
|
31
|
+
epismo login | logout | whoami
|
|
52
32
|
|
|
53
|
-
|
|
33
|
+
epismo workspace list | current | use | clear | create | checkout | update
|
|
34
|
+
epismo workspace member list | upsert | delete
|
|
35
|
+
epismo project list | create | update
|
|
36
|
+
epismo project member list | add | delete
|
|
37
|
+
epismo credit balance | checkout
|
|
38
|
+
epismo token create
|
|
54
39
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
40
|
+
epismo playbook search | create | get | acl | archive | star | unstar | starred
|
|
41
|
+
epismo playbook version list | get | publish
|
|
42
|
+
epismo playbook share create
|
|
43
|
+
epismo alias set | list | delete
|
|
59
44
|
|
|
60
|
-
|
|
45
|
+
epismo case start | get | list | assign | acl | update | close | reopen
|
|
46
|
+
epismo task create | list | assign | update | close | reopen
|
|
47
|
+
epismo record append | list
|
|
61
48
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
### Track references
|
|
49
|
+
epismo suggestion create | get | list | update | resolve
|
|
50
|
+
```
|
|
65
51
|
|
|
66
|
-
|
|
52
|
+
The Playbook, Case, Task, Record, Suggestion, Star, and Alias operations are also
|
|
53
|
+
available through MCP. MCP names mirror the CLI resource/verb hierarchy in snake_case;
|
|
54
|
+
for example, `epismo_playbook_version_list` maps to `epismo playbook version list`.
|
|
67
55
|
|
|
68
|
-
|
|
56
|
+
Run `epismo <command> --help` for flags and positional arguments.
|
|
69
57
|
|
|
70
|
-
|
|
71
|
-
The matching CLI flags are `--personal`, `--projects <id...>`, and `--share-with <userIdOrEmail...>`.
|
|
72
|
-
Search uses the same selectors additively; omit them to use the default scope for the current context.
|
|
58
|
+
## Structured input
|
|
73
59
|
|
|
74
|
-
|
|
60
|
+
Commands that accept payloads support:
|
|
75
61
|
|
|
76
|
-
|
|
62
|
+
```bash
|
|
63
|
+
epismo playbook create --input @playbook.json
|
|
64
|
+
epismo record append <case-id> --input @record.json
|
|
65
|
+
epismo suggestion create --input - < suggestion.json
|
|
66
|
+
```
|
|
77
67
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
68
|
+
`--input` accepts an inline JSON object, `@file`, or `-` for stdin. Explicit flags
|
|
69
|
+
override fields from `--input`. Complex Playbook Definitions and close-time Record
|
|
70
|
+
arrays are best supplied through files.
|
|
81
71
|
|
|
82
|
-
|
|
72
|
+
`--input` always carries the whole request payload, so `epismo case start` takes the
|
|
73
|
+
Case's own input object through `--case-input` instead:
|
|
83
74
|
|
|
84
75
|
```bash
|
|
85
|
-
epismo
|
|
86
|
-
|
|
76
|
+
epismo case start --version-id <version-id> --acl <account-id> \
|
|
77
|
+
--case-input '{"companyName":"Acme"}'
|
|
87
78
|
```
|
|
88
79
|
|
|
89
|
-
|
|
80
|
+
Mutation commands accept `--idempotency-key <uuid>`. When omitted, the CLI generates
|
|
81
|
+
a UUID automatically. Reuse an explicit key when retrying a command whose first result
|
|
82
|
+
was uncertain; reusing one with different arguments is rejected.
|
|
83
|
+
|
|
84
|
+
Every mutation of an existing Case or Task requires `--lock-version`, taken from the
|
|
85
|
+
most recent object returned by the API: `case assign | acl | update | close | reopen`
|
|
86
|
+
and `task assign | update | close | reopen`. After a conflict, re-read the object and
|
|
87
|
+
decide again rather than resending with a bumped number. Updates are partial: Case
|
|
88
|
+
currently supports `title`, while Task supports `title` and `instructions`.
|
|
89
|
+
|
|
90
|
+
`epismo record list` is a cross-Case activity feed constrained by current Case ACLs.
|
|
91
|
+
Use `--case-id`, `--task-id`, `--created-by`, `--kinds`, `--origins`, or `--acl` to
|
|
92
|
+
narrow it; these filters never grant access. Results default to newest first, and
|
|
93
|
+
`--order asc` walks a timeline forward.
|
|
90
94
|
|
|
91
|
-
|
|
92
|
-
|
|
95
|
+
## Playbook references
|
|
96
|
+
|
|
97
|
+
The CLI's REST commands currently use UUIDs for Playbook path arguments. Aliases are
|
|
98
|
+
managed with `epismo alias`; MCP exposes the same alias set/list/delete operations and
|
|
99
|
+
also accepts `pb:alias` and `pb:handle/alias` in `epismo_playbook_get`.
|
|
100
|
+
|
|
101
|
+
## Output contract
|
|
102
|
+
|
|
103
|
+
- Success: JSON on stdout and exit code `0`.
|
|
104
|
+
- Failure: JSON on stderr and a non-zero exit code.
|
|
105
|
+
- Non-interactive commands fail instead of waiting for an unavailable prompt.
|
|
93
106
|
|
|
94
107
|
```json
|
|
95
|
-
{ "error": { "code": "NOT_FOUND", "message": "...", "retryable": false
|
|
108
|
+
{ "error": { "code": "NOT_FOUND", "message": "...", "retryable": false } }
|
|
96
109
|
```
|
|
97
110
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
Update checks run once per day in interactive sessions.
|
|
101
|
-
Set `EPISMO_UPDATE_CHECK=0` to disable the background npm version check.
|
|
111
|
+
Set `EPISMO_UPDATE_CHECK=0` to disable the daily interactive npm update check.
|
package/dist/auth.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { createServer } from "node:http";
|
|
2
2
|
import { randomBytes, createHash, timingSafeEqual } from "node:crypto";
|
|
3
3
|
import { spawn } from "node:child_process";
|
|
4
|
-
import { createInterface } from "node:readline/promises";
|
|
5
4
|
import { once } from "node:events";
|
|
5
|
+
import { createInterface } from "node:readline/promises";
|
|
6
6
|
import { stdin as input, stderr as output } from "node:process";
|
|
7
7
|
import { clearCredentials, readCliConfig, readCredentials, resolveApiUrl, resolveWebUrl, writeCliConfig, writeCredentials } from "./config.js";
|
|
8
8
|
import { requestJson } from "./api.js";
|
|
@@ -14,13 +14,14 @@ const CLI_CLIENT_ID = "epismo-cli";
|
|
|
14
14
|
const TOKEN_REFRESH_THRESHOLD_MS = 5 * 60 * 1000;
|
|
15
15
|
// #8: common hint strings — defined once so wording is consistent everywhere
|
|
16
16
|
const HINT_RUN_LOGIN = "Set EPISMO_TOKEN or run `epismo login`.";
|
|
17
|
-
const HINT_RUN_LOGIN_BROWSER = "Run `epismo login
|
|
17
|
+
const HINT_RUN_LOGIN_BROWSER = "Run `epismo login` in an interactive terminal, or use a pre-issued EPISMO_TOKEN.";
|
|
18
18
|
const REFRESH_RACE_RETRY_DELAYS_MS = [100, 200, 400, 800];
|
|
19
19
|
// #9: browser login progress warning codes — defined once so wording is consistent everywhere
|
|
20
20
|
const WARNING_BROWSER_OPENING = "BROWSER_OPENING";
|
|
21
21
|
const WARNING_BROWSER_FALLBACK_URL = "BROWSER_FALLBACK_URL";
|
|
22
22
|
const WARNING_BROWSER_WAITING = "BROWSER_WAITING";
|
|
23
23
|
const WARNING_BROWSER_AUTHORIZED = "BROWSER_AUTHORIZED";
|
|
24
|
+
const WARNING_OTP_SENT = "OTP_SENT";
|
|
24
25
|
export function hasEnvTokenAuthentication() {
|
|
25
26
|
return Boolean(process.env.EPISMO_TOKEN?.trim());
|
|
26
27
|
}
|
|
@@ -28,8 +29,8 @@ async function prompt(question) {
|
|
|
28
29
|
if (input.isTTY !== true || output.isTTY !== true) {
|
|
29
30
|
throw new CliError({
|
|
30
31
|
code: "NON_INTERACTIVE_INPUT_REQUIRED",
|
|
31
|
-
message: "This
|
|
32
|
-
hint: "
|
|
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."
|
|
33
34
|
});
|
|
34
35
|
}
|
|
35
36
|
const readline = createInterface({ input, output });
|
|
@@ -186,6 +187,59 @@ async function postOAuthToken(apiUrl, body) {
|
|
|
186
187
|
body
|
|
187
188
|
});
|
|
188
189
|
}
|
|
190
|
+
async function resolveLoginMethod(apiUrl, email) {
|
|
191
|
+
const response = await requestJson(apiUrl, "/v1/login-options", {
|
|
192
|
+
method: "POST",
|
|
193
|
+
body: { email }
|
|
194
|
+
});
|
|
195
|
+
if (response.method !== "sso" && response.method !== "otp") {
|
|
196
|
+
throw new CliError({
|
|
197
|
+
code: "LOGIN_METHOD_INVALID",
|
|
198
|
+
message: "The server returned an unsupported login method.",
|
|
199
|
+
retryable: true
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
return response.method;
|
|
203
|
+
}
|
|
204
|
+
async function loginWithOtp(apiUrl, email) {
|
|
205
|
+
if (input.isTTY !== true || output.isTTY !== true) {
|
|
206
|
+
throw new CliError({
|
|
207
|
+
code: "NON_INTERACTIVE_INPUT_REQUIRED",
|
|
208
|
+
message: "Email-code login requires an interactive terminal.",
|
|
209
|
+
hint: "Run the command in a terminal, or use a pre-issued EPISMO_TOKEN."
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
const issued = await requestJson(apiUrl, "/v1/otp-tokens", {
|
|
213
|
+
method: "POST",
|
|
214
|
+
body: { email }
|
|
215
|
+
});
|
|
216
|
+
const otpId = issued.otpId?.trim();
|
|
217
|
+
if (!otpId) {
|
|
218
|
+
throw new CliError({
|
|
219
|
+
code: "OTP_ID_MISSING",
|
|
220
|
+
message: "Failed to obtain an OTP id from the server.",
|
|
221
|
+
retryable: true
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
printWarning({
|
|
225
|
+
warning: { code: WARNING_OTP_SENT, message: `A sign-in code was sent to ${email}.` }
|
|
226
|
+
});
|
|
227
|
+
const otp = await prompt("Code: ");
|
|
228
|
+
if (!otp) {
|
|
229
|
+
throw new CliError({
|
|
230
|
+
code: "OTP_CODE_REQUIRED",
|
|
231
|
+
message: "A sign-in code is required.",
|
|
232
|
+
hint: `Run \`epismo login --email ${email}\` to request a new code.`
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
const tokenResponse = await postOAuthToken(apiUrl, {
|
|
236
|
+
grant_type: "otp",
|
|
237
|
+
otp_id: otpId,
|
|
238
|
+
otp,
|
|
239
|
+
client_id: CLI_CLIENT_ID
|
|
240
|
+
});
|
|
241
|
+
return toVerifiedLogin(tokenResponse, await fetchUserInfo(apiUrl, tokenResponse.access_token));
|
|
242
|
+
}
|
|
189
243
|
async function fetchUserInfo(apiUrl, accessToken) {
|
|
190
244
|
return requestJson(apiUrl, "/oauth/userinfo", {
|
|
191
245
|
authToken: accessToken
|
|
@@ -214,7 +268,21 @@ function toSavedWorkspace(workspace) {
|
|
|
214
268
|
role: workspace.role
|
|
215
269
|
};
|
|
216
270
|
}
|
|
217
|
-
|
|
271
|
+
export function buildCliAuthorizationUrl(input) {
|
|
272
|
+
const authorizeUrl = new URL(`${input.webUrl}/oauth/authorize`);
|
|
273
|
+
authorizeUrl.searchParams.set("response_type", "code");
|
|
274
|
+
authorizeUrl.searchParams.set("client_id", CLI_CLIENT_ID);
|
|
275
|
+
authorizeUrl.searchParams.set("redirect_uri", input.redirectUri);
|
|
276
|
+
authorizeUrl.searchParams.set("scope", "read write offline_access");
|
|
277
|
+
authorizeUrl.searchParams.set("code_challenge", input.challenge);
|
|
278
|
+
authorizeUrl.searchParams.set("code_challenge_method", "S256");
|
|
279
|
+
authorizeUrl.searchParams.set("state", input.state);
|
|
280
|
+
const email = input.email?.trim();
|
|
281
|
+
if (email)
|
|
282
|
+
authorizeUrl.searchParams.set("login_hint", email);
|
|
283
|
+
return authorizeUrl;
|
|
284
|
+
}
|
|
285
|
+
async function loginWithBrowser(apiUrl, webUrl, email) {
|
|
218
286
|
if (output.isTTY !== true) {
|
|
219
287
|
throw new CliError({
|
|
220
288
|
code: "NON_INTERACTIVE_BROWSER_LOGIN",
|
|
@@ -225,21 +293,40 @@ async function loginWithBrowser(apiUrl, webUrl) {
|
|
|
225
293
|
const { verifier, challenge } = generatePkce();
|
|
226
294
|
const state = generateState();
|
|
227
295
|
const { redirectUri, waitForCallback } = await startOAuthCallbackServer(state);
|
|
228
|
-
const authorizeUrl =
|
|
229
|
-
authorizeUrl.searchParams.set("response_type", "code");
|
|
230
|
-
authorizeUrl.searchParams.set("client_id", CLI_CLIENT_ID);
|
|
231
|
-
authorizeUrl.searchParams.set("redirect_uri", redirectUri);
|
|
232
|
-
authorizeUrl.searchParams.set("scope", "read write offline_access");
|
|
233
|
-
authorizeUrl.searchParams.set("code_challenge", challenge);
|
|
234
|
-
authorizeUrl.searchParams.set("code_challenge_method", "S256");
|
|
235
|
-
authorizeUrl.searchParams.set("state", state);
|
|
296
|
+
const authorizeUrl = buildCliAuthorizationUrl({ webUrl, redirectUri, challenge, state, email });
|
|
236
297
|
const authorizeUrlString = authorizeUrl.toString();
|
|
237
|
-
printWarning({
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
printWarning({
|
|
298
|
+
printWarning({
|
|
299
|
+
warning: { code: WARNING_BROWSER_OPENING, message: "Opening browser for authorization..." }
|
|
300
|
+
});
|
|
301
|
+
printWarning({
|
|
302
|
+
warning: {
|
|
303
|
+
code: WARNING_BROWSER_FALLBACK_URL,
|
|
304
|
+
message: `If the browser does not open, visit: ${authorizeUrlString}`
|
|
305
|
+
}
|
|
306
|
+
});
|
|
307
|
+
try {
|
|
308
|
+
await openBrowser(authorizeUrlString);
|
|
309
|
+
}
|
|
310
|
+
catch (cause) {
|
|
311
|
+
if (!(cause instanceof CliError) || cause.code !== "BROWSER_OPEN_FAILED") {
|
|
312
|
+
throw cause;
|
|
313
|
+
}
|
|
314
|
+
// The callback server is already listening and the URL was printed above,
|
|
315
|
+
// so manual browser navigation can complete the same authorization flow.
|
|
316
|
+
}
|
|
317
|
+
printWarning({
|
|
318
|
+
warning: {
|
|
319
|
+
code: WARNING_BROWSER_WAITING,
|
|
320
|
+
message: "Waiting for authorization in your browser (times out in 5 minutes)..."
|
|
321
|
+
}
|
|
322
|
+
});
|
|
241
323
|
const { code } = await waitForCallback();
|
|
242
|
-
printWarning({
|
|
324
|
+
printWarning({
|
|
325
|
+
warning: {
|
|
326
|
+
code: WARNING_BROWSER_AUTHORIZED,
|
|
327
|
+
message: "Authorization received. Completing sign-in..."
|
|
328
|
+
}
|
|
329
|
+
});
|
|
243
330
|
const tokenResponse = await postOAuthToken(apiUrl, {
|
|
244
331
|
grant_type: "authorization_code",
|
|
245
332
|
code,
|
|
@@ -249,9 +336,8 @@ async function loginWithBrowser(apiUrl, webUrl) {
|
|
|
249
336
|
});
|
|
250
337
|
return toVerifiedLogin(tokenResponse, await fetchUserInfo(apiUrl, tokenResponse.access_token));
|
|
251
338
|
}
|
|
252
|
-
//
|
|
253
|
-
//
|
|
254
|
-
// different case looks like a different account.
|
|
339
|
+
// Compare and store canonical lowercased addresses so the same account typed
|
|
340
|
+
// with different casing does not trigger an unnecessary browser login.
|
|
255
341
|
export function normalizeEmail(email) {
|
|
256
342
|
return email.trim().toLowerCase();
|
|
257
343
|
}
|
|
@@ -268,86 +354,11 @@ function toVerifiedLogin(tokenResponse, userInfo) {
|
|
|
268
354
|
email: userInfo.email ? normalizeEmail(userInfo.email) : undefined
|
|
269
355
|
};
|
|
270
356
|
}
|
|
271
|
-
//
|
|
272
|
-
// pairs with it. There is no local expiry — the server alone decides whether the
|
|
273
|
-
// otpId/code is still valid when it is later verified.
|
|
274
|
-
async function sendOtp(apiUrl, email) {
|
|
275
|
-
const response = await requestJson(apiUrl, "/v1/otp-tokens", {
|
|
276
|
-
method: "POST",
|
|
277
|
-
body: { email }
|
|
278
|
-
});
|
|
279
|
-
const otpId = response.otpId?.trim();
|
|
280
|
-
if (!otpId) {
|
|
281
|
-
throw new CliError({
|
|
282
|
-
code: "OTP_ID_MISSING",
|
|
283
|
-
message: "Failed to obtain an OTP id from the server.",
|
|
284
|
-
retryable: true
|
|
285
|
-
});
|
|
286
|
-
}
|
|
287
|
-
return { otpId, email };
|
|
288
|
-
}
|
|
289
|
-
// Exchange an (otpId, otp) pair for tokens. Hits /oauth/token only — never the
|
|
290
|
-
// rate-limited send endpoint — so verify retries are cheap and self-contained.
|
|
291
|
-
async function verifyOtp(apiUrl, otpId, otp) {
|
|
292
|
-
const tokenResponse = await postOAuthToken(apiUrl, {
|
|
293
|
-
grant_type: "otp",
|
|
294
|
-
otp_id: otpId,
|
|
295
|
-
otp,
|
|
296
|
-
client_id: CLI_CLIENT_ID
|
|
297
|
-
});
|
|
298
|
-
return toVerifiedLogin(tokenResponse, await fetchUserInfo(apiUrl, tokenResponse.access_token));
|
|
299
|
-
}
|
|
300
|
-
// Resolve which otpId to verify against without sending a new code: an explicit
|
|
301
|
-
// --otp-id wins (CI/stateless), otherwise the one saved by an earlier send. We
|
|
302
|
-
// never reject on a local clock — verifyOtp surfaces the server's verdict on an
|
|
303
|
-
// expired/consumed code.
|
|
304
|
-
export function resolvePendingOtpId(pending, options) {
|
|
305
|
-
const explicit = options.otpId?.trim();
|
|
306
|
-
if (explicit) {
|
|
307
|
-
return explicit;
|
|
308
|
-
}
|
|
309
|
-
if (!pending) {
|
|
310
|
-
throw new CliError({
|
|
311
|
-
code: "PENDING_LOGIN_NOT_FOUND",
|
|
312
|
-
message: "No pending login to verify.",
|
|
313
|
-
hint: "Run `epismo login --email you@example.com` first to receive a code."
|
|
314
|
-
});
|
|
315
|
-
}
|
|
316
|
-
const requestedEmail = options.email?.trim();
|
|
317
|
-
if (requestedEmail && normalizeEmail(requestedEmail) !== normalizeEmail(pending.email)) {
|
|
318
|
-
throw new CliError({
|
|
319
|
-
code: "PENDING_LOGIN_EMAIL_MISMATCH",
|
|
320
|
-
message: `The pending login is for ${pending.email}, not ${requestedEmail}.`,
|
|
321
|
-
hint: "Omit --email, or run `epismo login --email ...` to start over."
|
|
322
|
-
});
|
|
323
|
-
}
|
|
324
|
-
return pending.otpId;
|
|
325
|
-
}
|
|
326
|
-
async function resolveEmailForSend(optionEmail, lastLoginEmail) {
|
|
327
|
-
const provided = optionEmail?.trim();
|
|
328
|
-
if (provided) {
|
|
329
|
-
return normalizeEmail(provided);
|
|
330
|
-
}
|
|
331
|
-
const defaultEmail = lastLoginEmail?.trim();
|
|
332
|
-
const entered = defaultEmail
|
|
333
|
-
? (await prompt(`Email [${defaultEmail}]: `)) || defaultEmail
|
|
334
|
-
: await prompt("Email: ");
|
|
335
|
-
const email = entered.trim();
|
|
336
|
-
if (!email) {
|
|
337
|
-
throw new CliError({
|
|
338
|
-
code: "EMAIL_REQUIRED",
|
|
339
|
-
message: "Email is required.",
|
|
340
|
-
hint: "Pass --email or provide it interactively."
|
|
341
|
-
});
|
|
342
|
-
}
|
|
343
|
-
return normalizeEmail(email);
|
|
344
|
-
}
|
|
345
|
-
// Persist tokens, clear any pending login, remember the email, and identify.
|
|
357
|
+
// Persist tokens, remember the verified email, and identify.
|
|
346
358
|
async function persistLogin(credentials, config, lastLoginEmail) {
|
|
347
359
|
await writeCredentials(credentials);
|
|
348
360
|
await writeCliConfig({
|
|
349
361
|
...config,
|
|
350
|
-
pendingLogin: undefined,
|
|
351
362
|
lastLoginEmail: lastLoginEmail ?? config.lastLoginEmail
|
|
352
363
|
});
|
|
353
364
|
// auth.signed_in is emitted server-side by the API at /oauth/token.
|
|
@@ -368,103 +379,30 @@ function toLoggedIn(credentials, lastLoginEmail, config, alreadyLoggedIn) {
|
|
|
368
379
|
...(hint ? { hint } : {})
|
|
369
380
|
};
|
|
370
381
|
}
|
|
371
|
-
//
|
|
372
|
-
//
|
|
373
|
-
//
|
|
374
|
-
async function promptForOtp() {
|
|
375
|
-
const interactive = input.isTTY === true && output.isTTY === true;
|
|
376
|
-
const otp = interactive ? (await prompt("OTP: ")).trim() : "";
|
|
377
|
-
if (!otp) {
|
|
378
|
-
throw new CliError({
|
|
379
|
-
code: "OTP_CODE_REQUIRED",
|
|
380
|
-
message: "OTP code is required.",
|
|
381
|
-
hint: "Pass --otp <code> to verify."
|
|
382
|
-
});
|
|
383
|
-
}
|
|
384
|
-
return otp;
|
|
385
|
-
}
|
|
386
|
-
// The OTP flow is split by which inputs are present, so a send and its verify
|
|
387
|
-
// can happen in separate processes (e.g. an agent sending, then a human pasting
|
|
388
|
-
// the code back):
|
|
389
|
-
// --otp or --otp-id present → VERIFY against the pending/explicit otpId
|
|
390
|
-
// (prompts for the code on a TTY when --otp absent)
|
|
391
|
-
// --email present, no --otp → SEND; on a TTY also prompt + verify in-process
|
|
392
|
-
// neither, on a TTY → interactive: prompt email → send → prompt → verify
|
|
393
|
-
// neither, no TTY → error (prompt() throws NON_INTERACTIVE)
|
|
394
|
-
// The email persisted as lastLoginEmail always comes from the verified token, so
|
|
395
|
-
// it reflects the account actually signed into — not whatever the user typed.
|
|
382
|
+
// Without an email, Web browser OAuth owns login-method selection. With an
|
|
383
|
+
// email, the API performs the same domain discovery: SSO continues in the
|
|
384
|
+
// browser while OTP completes interactively here in the terminal.
|
|
396
385
|
export async function login(options) {
|
|
397
386
|
const apiUrl = resolveApiUrl();
|
|
398
387
|
const webUrl = resolveWebUrl();
|
|
399
388
|
const config = await readCliConfig();
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
const lastLoginEmail = email ?? config.lastLoginEmail;
|
|
403
|
-
await persistLogin(credentials, config, lastLoginEmail);
|
|
404
|
-
return toLoggedIn(credentials, lastLoginEmail, config, false);
|
|
405
|
-
}
|
|
406
|
-
const providedOtp = options.otp?.trim();
|
|
407
|
-
const explicitOtpId = options.otpId?.trim();
|
|
408
|
-
// VERIFY: a code (or an explicit --otp-id signalling verify intent) was given.
|
|
409
|
-
// Never call the send endpoint here — so retries can't trip its rate limit or
|
|
410
|
-
// invalidate the code already sitting in the user's inbox.
|
|
411
|
-
if (providedOtp || explicitOtpId) {
|
|
412
|
-
const otpId = resolvePendingOtpId(config.pendingLogin, {
|
|
413
|
-
otpId: explicitOtpId,
|
|
414
|
-
email: options.email
|
|
415
|
-
});
|
|
416
|
-
const otp = providedOtp || (await promptForOtp());
|
|
417
|
-
const { credentials, email: verifiedEmail } = await verifyOtp(apiUrl, otpId, otp);
|
|
418
|
-
const lastLoginEmail = verifiedEmail ??
|
|
419
|
-
config.pendingLogin?.email ??
|
|
420
|
-
(options.email ? normalizeEmail(options.email) : config.lastLoginEmail);
|
|
421
|
-
await persistLogin(credentials, config, lastLoginEmail);
|
|
422
|
-
return toLoggedIn(credentials, lastLoginEmail, config, false);
|
|
423
|
-
}
|
|
424
|
-
// Already authenticated? Don't waste an OTP email (or a slot against the send
|
|
425
|
-
// rate limit). Short-circuit only when the session is still fresh and the same
|
|
426
|
-
// account is implied — an expired/near-expiry session, or a different --email,
|
|
427
|
-
// falls through to a normal send so the user can (re-)authenticate.
|
|
389
|
+
const requestedEmail = options.email?.trim() ? normalizeEmail(options.email) : undefined;
|
|
390
|
+
// Reuse a fresh credential only when the requested account matches.
|
|
428
391
|
const existing = await readCredentials();
|
|
429
392
|
if (existing && !shouldRefreshCredentials(existing)) {
|
|
430
|
-
const requestedEmail = options.email?.trim();
|
|
431
393
|
const sameAccount = !requestedEmail ||
|
|
432
|
-
(!!config.lastLoginEmail &&
|
|
433
|
-
normalizeEmail(requestedEmail) === normalizeEmail(config.lastLoginEmail));
|
|
394
|
+
(!!config.lastLoginEmail && requestedEmail === normalizeEmail(config.lastLoginEmail));
|
|
434
395
|
if (sameAccount) {
|
|
435
396
|
return toLoggedIn(existing, config.lastLoginEmail, config, true);
|
|
436
397
|
}
|
|
437
398
|
}
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
const
|
|
442
|
-
const
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
if (interactive) {
|
|
446
|
-
const otp = (await prompt("OTP: ")).trim();
|
|
447
|
-
if (otp) {
|
|
448
|
-
const { credentials, email: verifiedEmail } = await verifyOtp(apiUrl, pendingLogin.otpId, otp);
|
|
449
|
-
const lastLoginEmail = verifiedEmail ?? email;
|
|
450
|
-
await persistLogin(credentials, config, lastLoginEmail);
|
|
451
|
-
return toLoggedIn(credentials, lastLoginEmail, config, false);
|
|
452
|
-
}
|
|
453
|
-
// No code entered — fall through to persist the pending login so the user can
|
|
454
|
-
// resume with `epismo login --otp <code>` once it arrives.
|
|
455
|
-
}
|
|
456
|
-
// Remember the otpId so a later `--otp` (possibly another process) can verify it.
|
|
457
|
-
await writeCliConfig({ ...config, lastLoginEmail: email, pendingLogin });
|
|
458
|
-
if (interactive) {
|
|
459
|
-
throw new CliError({
|
|
460
|
-
code: "OTP_CODE_REQUIRED",
|
|
461
|
-
message: "OTP code is required.",
|
|
462
|
-
hint: "Run `epismo login --otp <code>` once the code arrives."
|
|
463
|
-
});
|
|
464
|
-
}
|
|
465
|
-
// Non-interactive (agent / CI): the code was emailed; the caller verifies with
|
|
466
|
-
// `epismo login --otp <code>`.
|
|
467
|
-
return { kind: "otp_sent", email };
|
|
399
|
+
const verified = requestedEmail && (await resolveLoginMethod(apiUrl, requestedEmail)) === "otp"
|
|
400
|
+
? await loginWithOtp(apiUrl, requestedEmail)
|
|
401
|
+
: await loginWithBrowser(apiUrl, webUrl, requestedEmail);
|
|
402
|
+
const lastLoginEmail = verified.email ?? config.lastLoginEmail;
|
|
403
|
+
const { credentials } = verified;
|
|
404
|
+
await persistLogin(credentials, config, lastLoginEmail);
|
|
405
|
+
return toLoggedIn(credentials, lastLoginEmail, config, false);
|
|
468
406
|
}
|
|
469
407
|
export async function logout() {
|
|
470
408
|
const credentials = await readCredentials();
|