@elliotsystems/eai 0.1.5 → 0.1.9
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/bin/eai +54 -2
- package/lib/auth.js +173 -0
- package/lib/context.js +52 -0
- package/lib/login.js +114 -0
- package/lib/setup.js +364 -94
- package/lib/stage.js +60 -7
- package/lib/status.js +2 -0
- package/lib/teams.js +233 -0
- package/package.json +2 -2
- package/plugin/opencode-plugin/package.json +1 -1
- package/plugin/opencode-plugin/src/blocked-turns.ts +145 -0
- package/plugin/opencode-plugin/src/elliot-auth.ts +10 -0
- package/plugin/opencode-plugin/src/guardrail-sync.ts +330 -0
- package/plugin/opencode-plugin/src/identity-headers.ts +81 -0
- package/plugin/opencode-plugin/src/index.ts +118 -3
- package/plugin/opencode-plugin/src/mcp-keys.ts +4 -0
- package/plugin/opencode-plugin/src/proxy-provider.ts +31 -2
- package/plugin/opencode-plugin/src/session-affinity.ts +41 -0
- package/plugin/opencode-plugin/src/tui/format.ts +37 -0
- package/plugin/opencode-plugin/src/tui/sidebar.tsx +90 -0
- package/plugin/opencode-plugin/src/tui/usage.ts +132 -0
package/bin/eai
CHANGED
|
@@ -25,12 +25,15 @@ const {
|
|
|
25
25
|
resolveOpencodeBin,
|
|
26
26
|
} = require("../lib/resolve.js")
|
|
27
27
|
const { injectToken } = require("../lib/token-env.js")
|
|
28
|
-
const { stageOverlay } = require("../lib/stage.js")
|
|
28
|
+
const { stageOverlay, readGatewayAlias } = require("../lib/stage.js")
|
|
29
29
|
const { runSetup, parseArgs } = require("../lib/setup.js")
|
|
30
30
|
const { runStatus } = require("../lib/status.js")
|
|
31
31
|
const { runLogout } = require("../lib/logout.js")
|
|
32
32
|
const { runConnect } = require("../lib/connect.js")
|
|
33
33
|
const { runUpdate, maybeNotify } = require("../lib/update.js")
|
|
34
|
+
const { runLogin } = require("../lib/login.js")
|
|
35
|
+
const { runTeamsList, runTeamsUse, runTeamsCurrent, runTeamsClear } = require("../lib/teams.js")
|
|
36
|
+
const { startupSummary } = require("../lib/context.js")
|
|
34
37
|
const gdrive = require("../lib/gdrive.js")
|
|
35
38
|
|
|
36
39
|
/**
|
|
@@ -119,6 +122,37 @@ if (process.argv[2] === "setup") {
|
|
|
119
122
|
return
|
|
120
123
|
}
|
|
121
124
|
|
|
125
|
+
// `eai login` and `eai teams` are intercepted here for the same reason as
|
|
126
|
+
// `setup`/`status`/`logout`: they configure this machine's Elliot session
|
|
127
|
+
// and must work when nothing is configured yet (or before the runtime can
|
|
128
|
+
// even be located). Neither is an OpenCode command, so nothing is shadowed.
|
|
129
|
+
//
|
|
130
|
+
// `login` reuses the paste-token provisioning path (`lib/login.js`), and
|
|
131
|
+
// `teams` the same session service (`lib/auth.js`) + the exact setup
|
|
132
|
+
// rematerialisation path (`lib/setup.js::runSetup`) — one auth implementation
|
|
133
|
+
// for every entry point.
|
|
134
|
+
if (process.argv[2] === "login") {
|
|
135
|
+
const argv = process.argv.slice(3)
|
|
136
|
+
const backendIdx = argv.indexOf("--backend-url")
|
|
137
|
+
const backendUrl = backendIdx >= 0 && argv[backendIdx + 1] ? argv[backendIdx + 1] : undefined
|
|
138
|
+
finishWith(runLogin({ backendUrl }), "login")
|
|
139
|
+
return
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (process.argv[2] === "teams") {
|
|
143
|
+
const verb = process.argv[3]
|
|
144
|
+
const run =
|
|
145
|
+
verb === "use"
|
|
146
|
+
? () => runTeamsUse({ team: process.argv[4] })
|
|
147
|
+
: verb === "current"
|
|
148
|
+
? runTeamsCurrent
|
|
149
|
+
: verb === "clear"
|
|
150
|
+
? runTeamsClear
|
|
151
|
+
: runTeamsList
|
|
152
|
+
finishWith(run(), "teams")
|
|
153
|
+
return
|
|
154
|
+
}
|
|
155
|
+
|
|
122
156
|
// `--version` answers for BOTH layers, because a bug report that says only
|
|
123
157
|
// "1.18.30" names the runtime and not the product — and the two move
|
|
124
158
|
// independently: one is this package, the other is its `opencode-ai`
|
|
@@ -230,6 +264,20 @@ if (process.env.ELLIOT_LAUNCHER_DEBUG) {
|
|
|
230
264
|
// the child inherits process.env, so setting it here is enough.
|
|
231
265
|
injectToken(process.env)
|
|
232
266
|
|
|
267
|
+
// A concise startup context summary (TTY only, read-only, network-free — see
|
|
268
|
+
// lib/context.js). The fail-closed gate for an invalid context lives at the
|
|
269
|
+
// CREDENTIAL level (empty ELLIOT_TOKEN => LiteLLM rejects, no master-key
|
|
270
|
+
// fallback), so this never blocks or fails startup; it just tells the user
|
|
271
|
+
// what context the session resolves to.
|
|
272
|
+
if (process.stdout.isTTY) {
|
|
273
|
+
try {
|
|
274
|
+
for (const line of startupSummary()) process.stdout.write(`${line}\n`)
|
|
275
|
+
process.stdout.write("\n")
|
|
276
|
+
} catch {
|
|
277
|
+
// A context summary may never be the reason a session fails to start.
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
233
281
|
// Elliot's identity travels WITH this package, not with the directory the user
|
|
234
282
|
// happens to be standing in. Before this, branding came from a `tui.json` in the
|
|
235
283
|
// Elliot repository root, so an installed `eai` outside that repo rendered as
|
|
@@ -241,7 +289,11 @@ if (!env.OPENCODE_CONFIG_DIR) {
|
|
|
241
289
|
// node_modules does not load (its tsconfig, and so its jsxImportSource, is
|
|
242
290
|
// not applied there), and the TUI comes up as plain OpenCode with nothing
|
|
243
291
|
// logged. See lib/stage.js.
|
|
244
|
-
const staged = stageOverlay({
|
|
292
|
+
const staged = stageOverlay({
|
|
293
|
+
packageRoot: PACKAGE_ROOT,
|
|
294
|
+
version: PACKAGE_VERSION,
|
|
295
|
+
gatewayAlias: readGatewayAlias(),
|
|
296
|
+
})
|
|
245
297
|
if (staged) env.OPENCODE_CONFIG_DIR = staged
|
|
246
298
|
}
|
|
247
299
|
|
package/lib/auth.js
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"use strict"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Shared authentication/session service for the EAI CLI.
|
|
5
|
+
*
|
|
6
|
+
* `eai login`, `eai teams`, `eai status` and the startup path all resolve the
|
|
7
|
+
* same facts from the same file and the same backend endpoints, so this
|
|
8
|
+
* module is where those facts live once:
|
|
9
|
+
*
|
|
10
|
+
* ~/.elliot/config.json the secure session store (0600, written by
|
|
11
|
+
* `eai login`/`eai setup`, single source of truth)
|
|
12
|
+
* POST /auth/cli-token exchange a portal token for a CLI access/refresh
|
|
13
|
+
* pair (backend/app/auth/router.py:238)
|
|
14
|
+
* POST /auth/refresh refresh a session (backend/app/auth/router.py:175)
|
|
15
|
+
* GET /usage/me verify the session and read the user (usage.py:86)
|
|
16
|
+
*
|
|
17
|
+
* Security rules from docs/architecture/EAI_ONBOARDING_BILLING_AUDIT.md §5/§7:
|
|
18
|
+
* - the backend is the only authority for identity and membership;
|
|
19
|
+
* - a missing/expired/revoked session fails closed with an actionable
|
|
20
|
+
* message — never a silent fallback to a privileged credential;
|
|
21
|
+
* - credentials are never printed or written to project files.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const fs = require("fs")
|
|
25
|
+
const os = require("os")
|
|
26
|
+
const path = require("path")
|
|
27
|
+
|
|
28
|
+
const { elliotConfigPath, writeElliotConfig } = require("./setup.js")
|
|
29
|
+
|
|
30
|
+
// The default backend a pasted login token targets. The token itself carries
|
|
31
|
+
// no backend URL (it is minted by whichever portal produced it), so an
|
|
32
|
+
// existing config's `backend_url` wins, `--backend-url` overrides, and this
|
|
33
|
+
// production default is the fallback — see runLogin.
|
|
34
|
+
const DEFAULT_BACKEND_URL = "https://backend.elliotsystemsonline.com"
|
|
35
|
+
|
|
36
|
+
/** Read ~/.elliot/config.json, or null when absent/unreadable. */
|
|
37
|
+
function readConfig() {
|
|
38
|
+
try {
|
|
39
|
+
const parsed = JSON.parse(fs.readFileSync(elliotConfigPath(), "utf8"))
|
|
40
|
+
return parsed && typeof parsed === "object" ? parsed : null
|
|
41
|
+
} catch {
|
|
42
|
+
return null
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The backend URL to use, from config or the default. */
|
|
47
|
+
function backendUrl(config) {
|
|
48
|
+
return String((config && config.backend_url) || "").replace(/\/+$/, "") || DEFAULT_BACKEND_URL
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Resolve a fresh access token, refreshing from `~/.elliot/config.json` when
|
|
53
|
+
* the stored one is expired or the backend rejects it. Returns the token, or
|
|
54
|
+
* null when no session is usable (and explains why).
|
|
55
|
+
*
|
|
56
|
+
* `POST /auth/refresh` takes the refresh token in the body (auth/router.py:
|
|
57
|
+
* 175-235); it is the credential there, and is refused for audience-bound
|
|
58
|
+
* tokens. Non-rotating: the same refresh token keeps working until revoked.
|
|
59
|
+
*
|
|
60
|
+
* @param {{log?: (s: string) => void}} [opts]
|
|
61
|
+
* @returns {Promise<{token: string, config: object} | null>}
|
|
62
|
+
*/
|
|
63
|
+
async function ensureFreshAccessToken(opts = {}) {
|
|
64
|
+
const log = opts.log || ((s) => process.stdout.write(s + "\n"))
|
|
65
|
+
const config = readConfig()
|
|
66
|
+
if (!config) {
|
|
67
|
+
log("Not logged in. Run `eai login` first.")
|
|
68
|
+
return null
|
|
69
|
+
}
|
|
70
|
+
if (!config.jwt_token) {
|
|
71
|
+
log("Your login is incomplete (no session token). Run `eai login` again.")
|
|
72
|
+
return null
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Refresh proactively when we know the access token has expired. Parsed
|
|
76
|
+
// leniently: a missing/odd `expires_at` simply means "don't refresh early".
|
|
77
|
+
const expiresAt = Date.parse(String(config.access_token_expires_at || ""))
|
|
78
|
+
const needsRefresh = Number.isFinite(expiresAt) && expiresAt <= Date.now()
|
|
79
|
+
|
|
80
|
+
if (needsRefresh && config.refresh_token) {
|
|
81
|
+
const fresh = await refresh(config)
|
|
82
|
+
if (fresh) return { token: fresh.token, config: fresh.config }
|
|
83
|
+
log(
|
|
84
|
+
"Your session has expired and could not be refreshed. Run `eai login` again.",
|
|
85
|
+
)
|
|
86
|
+
return null
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return { token: config.jwt_token, config }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Call `POST /auth/refresh` and persist the new access token. Returns
|
|
94
|
+
* `{token, config}` on success, null on any failure (never throws).
|
|
95
|
+
*/
|
|
96
|
+
async function refresh(config) {
|
|
97
|
+
try {
|
|
98
|
+
const base = backendUrl(config)
|
|
99
|
+
const res = await fetch(`${base}/auth/refresh`, {
|
|
100
|
+
method: "POST",
|
|
101
|
+
headers: { "Content-Type": "application/json" },
|
|
102
|
+
body: JSON.stringify({ refresh_token: config.refresh_token }),
|
|
103
|
+
})
|
|
104
|
+
if (!res.ok) return null
|
|
105
|
+
const body = await res.json()
|
|
106
|
+
if (!body || typeof body.access_token !== "string") return null
|
|
107
|
+
const next = {
|
|
108
|
+
...config,
|
|
109
|
+
jwt_token: body.access_token,
|
|
110
|
+
...(typeof body.access_token_expires_at === "string"
|
|
111
|
+
? { access_token_expires_at: body.access_token_expires_at }
|
|
112
|
+
: {}),
|
|
113
|
+
}
|
|
114
|
+
writeElliotConfig(next)
|
|
115
|
+
return { token: next.jwt_token, config: next }
|
|
116
|
+
} catch {
|
|
117
|
+
return null
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Authenticated GET. Uses `ensureFreshAccessToken` for a fresh bearer, and
|
|
123
|
+
* retries once after a refresh when the backend answers 401 (a token that
|
|
124
|
+
* was still unexpired locally but revoked server-side).
|
|
125
|
+
*
|
|
126
|
+
* @returns {Promise<{ok: boolean, status: number, body: any} | null>}
|
|
127
|
+
* null when no usable session exists.
|
|
128
|
+
*/
|
|
129
|
+
async function authorizedGet(url, opts = {}) {
|
|
130
|
+
const session = await ensureFreshAccessToken(opts)
|
|
131
|
+
if (!session) return null
|
|
132
|
+
const { token, config } = session
|
|
133
|
+
const base = backendUrl(config)
|
|
134
|
+
|
|
135
|
+
let res
|
|
136
|
+
try {
|
|
137
|
+
res = await fetch(`${base}${url}`, {
|
|
138
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
139
|
+
signal: opts.signal,
|
|
140
|
+
})
|
|
141
|
+
} catch (error) {
|
|
142
|
+
opts.log && opts.log(`Could not reach the backend at ${base}: ${error.message}`)
|
|
143
|
+
return { ok: false, status: 0, body: null }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (res.status === 401 && config.refresh_token) {
|
|
147
|
+
const fresh = await refresh(config)
|
|
148
|
+
if (fresh) {
|
|
149
|
+
res = await fetch(`${base}${url}`, {
|
|
150
|
+
headers: { Authorization: `Bearer ${fresh.token}` },
|
|
151
|
+
signal: opts.signal,
|
|
152
|
+
})
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
let body = null
|
|
157
|
+
try {
|
|
158
|
+
body = await res.json()
|
|
159
|
+
} catch {
|
|
160
|
+
// Non-JSON body; callers fall back to the status.
|
|
161
|
+
}
|
|
162
|
+
return { ok: res.ok, status: res.status, body }
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
module.exports = {
|
|
166
|
+
DEFAULT_BACKEND_URL,
|
|
167
|
+
readConfig,
|
|
168
|
+
backendUrl,
|
|
169
|
+
ensureFreshAccessToken,
|
|
170
|
+
refresh,
|
|
171
|
+
authorizedGet,
|
|
172
|
+
elliotConfigPath,
|
|
173
|
+
}
|
package/lib/context.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"use strict"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The EAI CLI's effective billing context, resolved from the local session.
|
|
5
|
+
*
|
|
6
|
+
* The BACKEND remains the authority for identity, membership and the
|
|
7
|
+
* budgeted credential — this module only renders what a session already
|
|
8
|
+
* resolved (see `docs/architecture/EAI_ONBOARDING_BILLING_AUDIT.md` §6/§7).
|
|
9
|
+
* It is read-only and network-free, so it can run on the startup path and in
|
|
10
|
+
* `eai status` without adding latency or a hard dependency on the backend.
|
|
11
|
+
*
|
|
12
|
+
* The fail-closed gate for an invalid/unresolved context is at the CREDENTIAL
|
|
13
|
+
* level, not here: if `~/.elliot/config.json` carries no budgeted LiteLLM key
|
|
14
|
+
* (`litellm_master_key`), `lib/token-env.js` injects no `ELLIOT_TOKEN`, and
|
|
15
|
+
* the runtime's model calls are rejected by LiteLLM — no unbudgeted spend, no
|
|
16
|
+
* shared-master-key fallback.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const { readConfig } = require("./auth.js")
|
|
20
|
+
|
|
21
|
+
/** `{type: "team"|"individual", label, teamId}` from the local session. */
|
|
22
|
+
function billingContext(config) {
|
|
23
|
+
if (!config) return { type: "none", label: "Not signed in", teamId: null }
|
|
24
|
+
if (config.team_id) {
|
|
25
|
+
return {
|
|
26
|
+
type: "team",
|
|
27
|
+
label: `Team — ${config.team_name || config.team_id}`,
|
|
28
|
+
teamId: String(config.team_id),
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return { type: "individual", label: "Individual", teamId: null }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The lines printed when starting `eai` (TTY only) and in `eai status`.
|
|
36
|
+
* Never prints credentials; identifiers are shown only where they are the
|
|
37
|
+
* user-facing value (e.g. the team id when no name is known).
|
|
38
|
+
*/
|
|
39
|
+
function startupSummary(config = readConfig()) {
|
|
40
|
+
const billing = billingContext(config)
|
|
41
|
+
const signedIn = config && (config.email || config.user_id) ? config.email || config.user_id : "(none)"
|
|
42
|
+
const gateway = config && config.litellm_base_url ? "LiteLLM" : "(unconfigured)"
|
|
43
|
+
return [
|
|
44
|
+
"Elliot AI",
|
|
45
|
+
`Signed in: ${signedIn}`,
|
|
46
|
+
`Billing: ${billing.label}`,
|
|
47
|
+
`Gateway: ${gateway}`,
|
|
48
|
+
"Project: (none)",
|
|
49
|
+
]
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
module.exports = { billingContext, startupSummary }
|
package/lib/login.js
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"use strict"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `eai login` — establish an authenticated CLI session with a pasted token.
|
|
5
|
+
*
|
|
6
|
+
* Approved flow (docs/architecture/EAI_ONBOARDING_BILLING_AUDIT.md §5/§15):
|
|
7
|
+
* reuse the existing paste-token mechanism. The token is obtained from the
|
|
8
|
+
* portal (or the existing `eai setup` handoff), prompted for SECURELY (never
|
|
9
|
+
* via argv, so it cannot leak into shell history or `ps`), exchanged for a
|
|
10
|
+
* CLI access/refresh pair at `POST /auth/cli-token`, then provisioned through
|
|
11
|
+
* the exact same path `eai setup` uses (`lib/setup.js::runSetup`), so there
|
|
12
|
+
* is ONE authentication implementation, not two.
|
|
13
|
+
*
|
|
14
|
+
* Fail-closed: an unreadable/invalid token, an expired/revoked session or a
|
|
15
|
+
* backend that cannot resolve a budgeted credential all abort with an
|
|
16
|
+
* actionable message. No credential is ever printed.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const readline = require("readline")
|
|
20
|
+
|
|
21
|
+
const auth = require("./auth.js")
|
|
22
|
+
const { decodeJwtPayload, runSetup } = require("./setup.js")
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Prompt for a token without echoing it to the terminal (no shell history, no
|
|
26
|
+
* process-argument exposure). On a non-TTY stdin (piped/CI), reads a plain
|
|
27
|
+
* line — callers can also pass `opts.token` to skip the prompt entirely.
|
|
28
|
+
*/
|
|
29
|
+
function promptHidden(question) {
|
|
30
|
+
return new Promise((resolve) => {
|
|
31
|
+
if (!process.stdin.isTTY) {
|
|
32
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
|
|
33
|
+
rl.question(`${question} `, (answer) => {
|
|
34
|
+
rl.close()
|
|
35
|
+
resolve(String(answer || "").trim())
|
|
36
|
+
})
|
|
37
|
+
return
|
|
38
|
+
}
|
|
39
|
+
process.stdout.write(question + " ")
|
|
40
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true })
|
|
41
|
+
rl._writeToOutput = (str) => {
|
|
42
|
+
// Mask the echoed token as asterisks; keep newlines/control output.
|
|
43
|
+
if (/[\r\n]/.test(str)) rl.output.write(str)
|
|
44
|
+
else rl.output.write("*".repeat(str.length))
|
|
45
|
+
}
|
|
46
|
+
rl.question("", (answer) => {
|
|
47
|
+
process.stdout.write("\n")
|
|
48
|
+
rl.close()
|
|
49
|
+
resolve(String(answer || "").trim())
|
|
50
|
+
})
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* @param {{token?: string, backendUrl?: string, log?: (s: string) => void}} opts
|
|
56
|
+
* @returns {Promise<number>} exit code
|
|
57
|
+
*/
|
|
58
|
+
async function runLogin(opts = {}) {
|
|
59
|
+
const log = opts.log || ((s) => process.stdout.write(s + "\n"))
|
|
60
|
+
const existing = auth.readConfig()
|
|
61
|
+
const backendUrl = String(
|
|
62
|
+
opts.backendUrl || auth.backendUrl(existing) || auth.DEFAULT_BACKEND_URL,
|
|
63
|
+
).replace(/\/+$/, "")
|
|
64
|
+
|
|
65
|
+
log("Elliot AI login")
|
|
66
|
+
log("─────────────────────────────")
|
|
67
|
+
log(
|
|
68
|
+
"Get a login token from the Elliot portal (or from `eai setup`'s own " +
|
|
69
|
+
"handoff), then paste it below. It is not echoed and never appears in " +
|
|
70
|
+
"your shell history.",
|
|
71
|
+
)
|
|
72
|
+
if (!existing) {
|
|
73
|
+
log(`Backend: ${backendUrl} (use --backend-url to target another environment)`)
|
|
74
|
+
}
|
|
75
|
+
log("")
|
|
76
|
+
|
|
77
|
+
const token = opts.token || (await promptHidden("Paste your login token:"))
|
|
78
|
+
if (!token) {
|
|
79
|
+
log("Login cancelled — no token entered.")
|
|
80
|
+
return 1
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Validate before any network call: the token must look like an Elliot
|
|
84
|
+
// access token carrying a tenant claim.
|
|
85
|
+
const claims = decodeJwtPayload(token) || {}
|
|
86
|
+
if (!claims.tenant_id) {
|
|
87
|
+
log(
|
|
88
|
+
"That does not look like a valid Elliot login token (no tenant). " +
|
|
89
|
+
"Copy the full token from the portal and try again.",
|
|
90
|
+
)
|
|
91
|
+
return 1
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
log("")
|
|
95
|
+
try {
|
|
96
|
+
// The exact provisioning path `eai setup` uses: cli-token exchange,
|
|
97
|
+
// /onboarding/config fetch, secure config writes, opencode.json
|
|
98
|
+
// materialisation, agent files. One implementation, both entry points.
|
|
99
|
+
const result = await runSetup({ token, backendUrl, log })
|
|
100
|
+
log("")
|
|
101
|
+
log("Logged in.")
|
|
102
|
+
log("Next: run `eai teams` to view your teams, `eai teams use \"<name>\"`")
|
|
103
|
+
log(" to pick a billing team, or `eai` to start with individual billing.")
|
|
104
|
+
return result ? 0 : 1
|
|
105
|
+
} catch (error) {
|
|
106
|
+
// Includes the fail-closed 503 from /onboarding/config when no budgeted
|
|
107
|
+
// credential resolves — the message from the backend is actionable and
|
|
108
|
+
// confirms no model request was configured.
|
|
109
|
+
log(String(error && error.message ? error.message : error))
|
|
110
|
+
return 1
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
module.exports = { runLogin, promptHidden }
|