@elliotsystems/eai 0.1.4 → 0.1.7

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/lib/gdrive.js ADDED
@@ -0,0 +1,238 @@
1
+ "use strict"
2
+
3
+ /**
4
+ * `eai connect|status|disconnect gdrive` — this person's own Google Drive.
5
+ *
6
+ * PERSONAL, not the team's. The Elliot CLI is an individual's tool: whichever
7
+ * Elliot teams you belong to — none, one, ten — the Drive reached here is the
8
+ * Google account YOU connect. Team Drive is a WebChat capability with its own
9
+ * grants, and the two never resolve to each other.
10
+ *
11
+ * ## Why this polls instead of running a loopback server
12
+ *
13
+ * The obvious CLI OAuth shape is a `127.0.0.1` listener that Google redirects
14
+ * to. It does not fit here, and the reason is not preference: the Google OAuth
15
+ * client is registered against ELLIOT's callback URL, so redirecting to a
16
+ * loopback port would need every deployment's Google client re-registered with
17
+ * a new URI, and `http://127.0.0.1:<random>` cannot be pre-registered because
18
+ * the port is not known in advance.
19
+ *
20
+ * So the browser half completes entirely server-side — Google redirects to the
21
+ * Elliot callback that already exists and already handles state, nonce and
22
+ * encryption — and this polls `GET /connectors/me/gdrive` to learn when it
23
+ * landed. The CLI therefore never touches the authorization code or the token,
24
+ * which is a stronger position than the loopback design would have given: there
25
+ * is no moment where a credential passes through this process at all.
26
+ *
27
+ * ## What this file is careful about
28
+ *
29
+ * No dependencies, matching the rest of the launcher. No secret is ever printed
30
+ * — not the token, not the state, not the JWT. The authorization URL IS shown,
31
+ * because a user whose browser failed to open needs it and it carries no
32
+ * credential of ours: it is a Google consent link bound to a signed, expiring,
33
+ * single-use state.
34
+ */
35
+
36
+ const fs = require("fs")
37
+ const os = require("os")
38
+ const path = require("path")
39
+ const childProcess = require("child_process")
40
+
41
+ /** How long to wait for the browser half before giving up. */
42
+ const POLL_TIMEOUT_MS = 5 * 60 * 1000
43
+ /** Gap between polls. Two seconds is invisible to a person and gentle on the
44
+ * backend for a flow that takes tens of seconds of human time. */
45
+ const POLL_INTERVAL_MS = 2000
46
+
47
+ /** The provider this file speaks. */
48
+ const PROVIDER = "gdrive"
49
+
50
+ /** Accepted spellings, because `google-drive` is what people type. */
51
+ const ALIASES = new Set(["gdrive", "google-drive", "googledrive", "drive"])
52
+
53
+ function elliotConfigPath() {
54
+ return path.join(os.homedir(), ".elliot", "config.json")
55
+ }
56
+
57
+ function readConfig() {
58
+ try {
59
+ const parsed = JSON.parse(fs.readFileSync(elliotConfigPath(), "utf8"))
60
+ return parsed && typeof parsed === "object" ? parsed : null
61
+ } catch {
62
+ return null
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Open a URL in the user's browser. Best-effort by design.
68
+ *
69
+ * A failure here is not a failure of the command: the URL is printed either
70
+ * way, and a user on a headless box pasting it into another machine's browser
71
+ * is a supported path rather than a workaround.
72
+ */
73
+ function openBrowser(url, spawn = childProcess.spawn) {
74
+ const [cmd, args] =
75
+ process.platform === "win32"
76
+ ? ["cmd", ["/c", "start", "", url]]
77
+ : process.platform === "darwin"
78
+ ? ["open", [url]]
79
+ : ["xdg-open", [url]]
80
+ try {
81
+ const child = spawn(cmd, args, { stdio: "ignore", detached: true })
82
+ child.on("error", () => {})
83
+ child.unref()
84
+ return true
85
+ } catch {
86
+ return false
87
+ }
88
+ }
89
+
90
+ /** An authenticated call to the Elliot API, or a clear error. */
91
+ async function api(config, method, endpoint, fetchImpl = fetch) {
92
+ const base = String(config.backend_url || "").replace(/\/+$/, "")
93
+ if (!base) throw new Error("no backend_url in ~/.elliot/config.json")
94
+ if (!config.jwt_token) {
95
+ throw new Error("not signed in — run the setup command from the portal")
96
+ }
97
+ const res = await fetchImpl(`${base}${endpoint}`, {
98
+ method,
99
+ headers: { Authorization: `Bearer ${config.jwt_token}` },
100
+ })
101
+ if (res.status === 401) {
102
+ throw new Error("your Elliot session has expired — run `eai setup` again")
103
+ }
104
+ if (!res.ok) {
105
+ // The body is deliberately NOT interpolated: a connector error can name a
106
+ // provider URL, and this string is printed to a terminal and pasted into
107
+ // issues. The status is enough to act on.
108
+ throw new Error(`Elliot returned ${res.status} for ${method} ${endpoint}`)
109
+ }
110
+ return res.json()
111
+ }
112
+
113
+ async function status(opts = {}) {
114
+ const log = opts.log || ((s) => process.stdout.write(s + "\n"))
115
+ const config = opts.config !== undefined ? opts.config : readConfig()
116
+ if (!config) {
117
+ log("Not configured. Copy the setup command from the Elliot portal first.")
118
+ return 1
119
+ }
120
+
121
+ let info
122
+ try {
123
+ info = await api(config, "GET", `/connectors/me/${PROVIDER}`, opts.fetch)
124
+ } catch (error) {
125
+ log(`Google Drive: could not check (${error.message})`)
126
+ return 1
127
+ }
128
+
129
+ log("")
130
+ log("Google Drive")
131
+ if (!info.connected) {
132
+ log(" Status: Not connected")
133
+ log(" Connect: eai connect gdrive")
134
+ return 0
135
+ }
136
+ log(" Status: Connected")
137
+ // The account address, never a credential. It is what makes "connected" a
138
+ // useful answer — a person with two Google accounts needs to know which one.
139
+ if (info.account) log(` Account: ${info.account}`)
140
+ log(" Scope: your own My Drive and any Shared Drives Google gives you")
141
+ return 0
142
+ }
143
+
144
+ async function connect(opts = {}) {
145
+ const log = opts.log || ((s) => process.stdout.write(s + "\n"))
146
+ const config = opts.config !== undefined ? opts.config : readConfig()
147
+ if (!config) {
148
+ log("Not configured. Copy the setup command from the Elliot portal first.")
149
+ return 1
150
+ }
151
+
152
+ let start
153
+ try {
154
+ start = await api(
155
+ config, "POST", `/connectors/me/${PROVIDER}/authorize`, opts.fetch
156
+ )
157
+ } catch (error) {
158
+ log(`Could not start Google authorization: ${error.message}`)
159
+ return 1
160
+ }
161
+ const url = start && start.authorization_url
162
+ if (!url) {
163
+ log("Elliot did not return an authorization URL.")
164
+ return 1
165
+ }
166
+
167
+ log("")
168
+ log("Opening Google authorization in your browser...")
169
+ const opened = openBrowser(url, opts.spawn)
170
+ if (!opened) log("Could not open a browser automatically.")
171
+ log("")
172
+ log("If the browser does not open, visit:")
173
+ log(` ${url}`)
174
+ log("")
175
+ log("Waiting for Google authorization...")
176
+
177
+ const deadline = Date.now() + (opts.timeoutMs || POLL_TIMEOUT_MS)
178
+ const wait = opts.sleep || ((ms) => new Promise((r) => setTimeout(r, ms)))
179
+ while (Date.now() < deadline) {
180
+ await wait(opts.intervalMs || POLL_INTERVAL_MS)
181
+ let info
182
+ try {
183
+ info = await api(config, "GET", `/connectors/me/${PROVIDER}`, opts.fetch)
184
+ } catch {
185
+ // A transient failure mid-flow must not abandon a connection the user is
186
+ // in the middle of authorizing. Keep polling until the deadline.
187
+ continue
188
+ }
189
+ if (info.connected) {
190
+ log("")
191
+ log("✓ Google Drive connected.")
192
+ if (info.account) log(` Account: ${info.account}`)
193
+ log("")
194
+ log("Run `eai` and ask for a file — for example:")
195
+ log(" \"search my Google Drive for the architecture document\"")
196
+ return 0
197
+ }
198
+ }
199
+
200
+ log("")
201
+ log("Timed out waiting for Google authorization.")
202
+ log("If you completed it in the browser, check with: eai status gdrive")
203
+ return 1
204
+ }
205
+
206
+ async function disconnect(opts = {}) {
207
+ const log = opts.log || ((s) => process.stdout.write(s + "\n"))
208
+ const config = opts.config !== undefined ? opts.config : readConfig()
209
+ if (!config) {
210
+ log("Not configured. Copy the setup command from the Elliot portal first.")
211
+ return 1
212
+ }
213
+ try {
214
+ await api(config, "DELETE", `/connectors/me/${PROVIDER}`, opts.fetch)
215
+ } catch (error) {
216
+ log(`Could not disconnect Google Drive: ${error.message}`)
217
+ return 1
218
+ }
219
+ log("Google Drive disconnected. Your team's Drive is unaffected.")
220
+ return 0
221
+ }
222
+
223
+ /** True when `name` is how someone spells Google Drive. */
224
+ function isGdrive(name) {
225
+ return ALIASES.has(String(name || "").toLowerCase())
226
+ }
227
+
228
+ module.exports = {
229
+ connect,
230
+ disconnect,
231
+ status,
232
+ isGdrive,
233
+ openBrowser,
234
+ readConfig,
235
+ elliotConfigPath,
236
+ PROVIDER,
237
+ ALIASES,
238
+ }
package/lib/logout.js ADDED
@@ -0,0 +1,73 @@
1
+ "use strict"
2
+
3
+ /**
4
+ * `eai logout` — disconnect this machine from its Elliot workspace.
5
+ *
6
+ * Migrated from `elliot-ai logout` as that CLI is retired. Without it the only
7
+ * way to leave a workspace was to know that `~/.elliot/config.json` exists and
8
+ * delete it by hand — which is also how people end up deleting the wrong thing.
9
+ *
10
+ * It removes ONLY that file. `~/.config/opencode/opencode.json` is left alone
11
+ * on purpose: it is the user's own config, carrying their MCP servers, theme
12
+ * and anything else they put there, and a logout that silently discarded it
13
+ * would be destroying work the user never offered up. The provider block left
14
+ * behind is inert without a token.
15
+ *
16
+ * Confirmation is required and defaults to NO, because this is irreversible
17
+ * from the CLI's side: recovering means fetching a fresh token from the portal.
18
+ */
19
+
20
+ const fs = require("fs")
21
+ const readline = require("readline")
22
+
23
+ const { elliotConfigPath } = require("./setup.js")
24
+ const { readElliotConfig } = require("./status.js")
25
+
26
+ /** Ask once on the TTY. Any answer but y/yes is a no, including empty. */
27
+ function confirm(question) {
28
+ return new Promise((resolve) => {
29
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
30
+ rl.question(question, (answer) => {
31
+ rl.close()
32
+ const normalised = String(answer || "").trim().toLowerCase()
33
+ resolve(normalised === "y" || normalised === "yes")
34
+ })
35
+ })
36
+ }
37
+
38
+ /**
39
+ * @param {{log?: (s: string) => void, confirm?: (q: string) => Promise<boolean>, force?: boolean}} [opts]
40
+ * @returns {Promise<number>} the process exit code
41
+ */
42
+ async function runLogout(opts = {}) {
43
+ const log = opts.log || ((s) => process.stdout.write(s + "\n"))
44
+ const ask = opts.confirm || confirm
45
+ const file = elliotConfigPath()
46
+
47
+ const config = readElliotConfig()
48
+ if (!config) {
49
+ // Not an error: "make this machine logged out" is already true, and exiting
50
+ // non-zero would fail a script that is just ensuring a clean state.
51
+ log("Already logged out — no Elliot configuration on this machine.")
52
+ return 0
53
+ }
54
+
55
+ const where = config.org_name || config.tenant_id || "this workspace"
56
+ if (!opts.force && !(await ask(`Log out of ${where}? [y/N] `))) {
57
+ log("Cancelled.")
58
+ return 0
59
+ }
60
+
61
+ try {
62
+ fs.rmSync(file, { force: true })
63
+ } catch (error) {
64
+ log(`Could not remove ${file}: ${error.message}`)
65
+ return 1
66
+ }
67
+
68
+ log(`Removed ${file}`)
69
+ log("Logged out. Copy the setup command from the Elliot portal to reconnect.")
70
+ return 0
71
+ }
72
+
73
+ module.exports = { runLogout, confirm }