@elliotsystems/eai 0.1.3 → 0.1.5
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 +93 -54
- package/bin/eai +154 -0
- package/lib/connect.js +205 -0
- package/lib/gdrive.js +238 -0
- package/lib/logout.js +73 -0
- package/lib/resolve.js +9 -15
- package/lib/setup.js +600 -0
- package/lib/stage.js +45 -4
- package/lib/status.js +133 -0
- package/lib/token-env.js +53 -17
- package/lib/update-check.js +27 -0
- package/lib/update.js +303 -0
- package/package.json +2 -4
- package/plugin/opencode-plugin/src/classifier.ts +2 -1
- package/plugin/opencode-plugin/src/elliot-auth.ts +56 -104
- package/plugin/opencode-plugin/src/identity.ts +69 -0
- package/plugin/opencode-plugin/src/index.ts +13 -28
- package/plugin/opencode-plugin/src/mcp-keys.ts +128 -1
- package/plugin/opencode-plugin/src/policy.ts +36 -4
- package/plugin/opencode-plugin/src/proxy-provider.ts +124 -72
- package/plugin/opencode-plugin/src/scope.ts +2 -1
- package/plugin/opencode-plugin/src/tui/knowledge.ts +9 -3
package/lib/status.js
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"use strict"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `eai status` — is this machine configured, can it reach the backend, and
|
|
5
|
+
* which connectors are live.
|
|
6
|
+
*
|
|
7
|
+
* Migrated from `elliot-ai status` as that CLI is retired. It is the one
|
|
8
|
+
* question `eai` could not answer about itself: the launcher knows where its
|
|
9
|
+
* config is, but a user staring at a failing session had no way to tell a
|
|
10
|
+
* missing token from an unreachable backend from a connector that was never
|
|
11
|
+
* authorised.
|
|
12
|
+
*
|
|
13
|
+
* Three facts, in the order they can fail:
|
|
14
|
+
*
|
|
15
|
+
* 1. config present — nothing else is meaningful without it
|
|
16
|
+
* 2. backend reachable — /health, so a network or DNS fault is named as one
|
|
17
|
+
* 3. connectors — /connectors/<tenant>, the thing people actually ask
|
|
18
|
+
*
|
|
19
|
+
* Each step degrades rather than aborting: an unreachable backend still prints
|
|
20
|
+
* the workspace block, because "which workspace am I in" is answerable offline
|
|
21
|
+
* and is half of what the command is for.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const fs = require("fs")
|
|
25
|
+
|
|
26
|
+
const { elliotConfigPath } = require("./setup.js")
|
|
27
|
+
|
|
28
|
+
/** Read ~/.elliot/config.json, or null when absent/unreadable. */
|
|
29
|
+
function readElliotConfig() {
|
|
30
|
+
try {
|
|
31
|
+
const parsed = JSON.parse(fs.readFileSync(elliotConfigPath(), "utf8"))
|
|
32
|
+
return parsed && typeof parsed === "object" ? parsed : null
|
|
33
|
+
} catch {
|
|
34
|
+
return null
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* `/health`, with a timeout.
|
|
40
|
+
*
|
|
41
|
+
* Without one this hangs on a black-holed connection for the OS default, and a
|
|
42
|
+
* status command that hangs is worse than one that says "offline" — the user is
|
|
43
|
+
* running it precisely because something is already wrong.
|
|
44
|
+
*/
|
|
45
|
+
async function checkBackendHealth(backendUrl, timeoutMs = 5000) {
|
|
46
|
+
const controller = new AbortController()
|
|
47
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
|
48
|
+
try {
|
|
49
|
+
const res = await fetch(`${String(backendUrl).replace(/\/+$/, "")}/health`, {
|
|
50
|
+
signal: controller.signal,
|
|
51
|
+
})
|
|
52
|
+
return res.ok
|
|
53
|
+
} catch {
|
|
54
|
+
return false
|
|
55
|
+
} finally {
|
|
56
|
+
clearTimeout(timer)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Connector rows for this tenant, or [] when they cannot be fetched. */
|
|
61
|
+
async function fetchConnectors(config, timeoutMs = 8000) {
|
|
62
|
+
const controller = new AbortController()
|
|
63
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
|
64
|
+
try {
|
|
65
|
+
const base = String(config.backend_url || "").replace(/\/+$/, "")
|
|
66
|
+
const res = await fetch(`${base}/connectors/${config.tenant_id}`, {
|
|
67
|
+
headers: { Authorization: `Bearer ${config.jwt_token}` },
|
|
68
|
+
signal: controller.signal,
|
|
69
|
+
})
|
|
70
|
+
if (!res.ok) return []
|
|
71
|
+
const body = await res.json()
|
|
72
|
+
if (Array.isArray(body)) return body
|
|
73
|
+
return Array.isArray(body && body.connectors) ? body.connectors : []
|
|
74
|
+
} catch {
|
|
75
|
+
return []
|
|
76
|
+
} finally {
|
|
77
|
+
clearTimeout(timer)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Title-case a provider id for display, without assuming it is non-empty. */
|
|
82
|
+
function displayName(provider) {
|
|
83
|
+
const name = String(provider || "unknown")
|
|
84
|
+
return name.charAt(0).toUpperCase() + name.slice(1)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* @param {{log?: (s: string) => void, config?: object|null}} [opts]
|
|
89
|
+
* @returns {Promise<number>} the process exit code
|
|
90
|
+
*/
|
|
91
|
+
async function runStatus(opts = {}) {
|
|
92
|
+
const log = opts.log || ((s) => process.stdout.write(s + "\n"))
|
|
93
|
+
const config = opts.config !== undefined ? opts.config : readElliotConfig()
|
|
94
|
+
|
|
95
|
+
if (!config) {
|
|
96
|
+
log("Not configured. Copy the setup command from the Elliot portal and run it.")
|
|
97
|
+
return 1
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
log("")
|
|
101
|
+
log("Elliot-AI status")
|
|
102
|
+
log("─────────────────────────────")
|
|
103
|
+
log(`Workspace: ${config.org_name || "(unknown)"}`)
|
|
104
|
+
// The id, not just the name: it is the value the portal URLs and the setup
|
|
105
|
+
// command both carry, so it is what makes the CLI and the web app comparable
|
|
106
|
+
// at a glance.
|
|
107
|
+
log(`Workspace ID: ${config.tenant_id || "(unknown)"}`)
|
|
108
|
+
if (config.stack) log(`Stack: ${config.stack}`)
|
|
109
|
+
log(`Backend: ${config.backend_url || "(unset)"}`)
|
|
110
|
+
|
|
111
|
+
const healthy = config.backend_url ? await checkBackendHealth(config.backend_url) : false
|
|
112
|
+
log(`Reachable: ${healthy ? "yes" : "no"}`)
|
|
113
|
+
|
|
114
|
+
log("─────────────────────────────")
|
|
115
|
+
log("Connectors:")
|
|
116
|
+
const connectors = healthy ? await fetchConnectors(config) : []
|
|
117
|
+
if (!connectors.length) {
|
|
118
|
+
// Deliberately not "no connectors": an unreachable backend and an
|
|
119
|
+
// unconfigured workspace produce the same empty list, and saying which is
|
|
120
|
+
// which is the whole value of the line.
|
|
121
|
+
log(healthy ? " (none reported)" : " (backend unreachable — cannot tell)")
|
|
122
|
+
} else {
|
|
123
|
+
for (const c of connectors) {
|
|
124
|
+
const state = c.status === "connected" ? "connected" : c.status || "unknown"
|
|
125
|
+
log(` ${displayName(c.provider).padEnd(12)} ${state}`)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
log("")
|
|
129
|
+
|
|
130
|
+
return healthy ? 0 : 1
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
module.exports = { runStatus, readElliotConfig, checkBackendHealth, fetchConnectors, displayName }
|
package/lib/token-env.js
CHANGED
|
@@ -1,14 +1,22 @@
|
|
|
1
1
|
"use strict"
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Inject
|
|
5
|
-
* inherits, so its `elliot` provider (configured by `
|
|
6
|
-
* `apiKey: "{env:ELLIOT_TOKEN}"`) authenticates against
|
|
4
|
+
* Inject LiteLLM's shared master key into the environment the OpenCode
|
|
5
|
+
* runtime inherits, so its `elliot` provider (configured by `eai setup` to
|
|
6
|
+
* use `apiKey: "{env:ELLIOT_TOKEN}"`) authenticates against LiteLLM directly.
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
8
|
+
* The env var name (ELLIOT_TOKEN) is unchanged, but what it holds is not: it
|
|
9
|
+
* used to be the user's own per-instance JWT (`jwt_token`), revocable and
|
|
10
|
+
* scoped to them; it is now `litellm_master_key`, ONE secret shared by the
|
|
11
|
+
* whole gateway (LiteLLM is deliberately DB-less — no per-tenant credential
|
|
12
|
+
* is possible without one). `eai setup` writes it into
|
|
13
|
+
* ~/.elliot/config.json's `litellm_master_key` field verbatim, since the
|
|
14
|
+
* backend's `/onboarding/config` response includes it directly for exactly
|
|
15
|
+
* this purpose.
|
|
16
|
+
*
|
|
17
|
+
* ~/.elliot/config.json is the single source of truth; `eai setup` refreshes
|
|
18
|
+
* it there. We only READ it here — no network, no writes, so this adds no
|
|
19
|
+
* latency and cannot corrupt the config.
|
|
12
20
|
*
|
|
13
21
|
* Entirely best-effort: a missing/unreadable/corrupt config, or an env that
|
|
14
22
|
* already sets ELLIOT_TOKEN, leaves things exactly as they were. The launcher's
|
|
@@ -20,27 +28,55 @@ const os = require("os")
|
|
|
20
28
|
const path = require("path")
|
|
21
29
|
|
|
22
30
|
/**
|
|
31
|
+
* TWO CREDENTIALS, TWO HOPS. They are not interchangeable and the difference
|
|
32
|
+
* is the whole reason both exist:
|
|
33
|
+
*
|
|
34
|
+
* ELLIOT_TOKEN the LiteLLM key. Authenticates this machine TO the
|
|
35
|
+
* gateway, and is consumed there — LiteLLM strips it.
|
|
36
|
+
* Used by the model path and as the MCP entry's
|
|
37
|
+
* `Authorization`.
|
|
38
|
+
* ELLIOT_MCP_TOKEN the Elliot MCP credential. LiteLLM forwards it as
|
|
39
|
+
* Elliot's own `Authorization`, and it is what carries
|
|
40
|
+
* tenant, user and the CLI surface. Minted by
|
|
41
|
+
* `/onboarding/config` with the MCP audience.
|
|
42
|
+
*
|
|
43
|
+
* Putting the MCP credential in `Authorization` would authenticate it to
|
|
44
|
+
* LiteLLM as an unknown key; putting the LiteLLM key in `x-mcp-auth` would
|
|
45
|
+
* reach Elliot as a credential it cannot decode. Each is useless in the
|
|
46
|
+
* other's place, which is why they are separate variables rather than one.
|
|
47
|
+
*
|
|
23
48
|
* @param {NodeJS.ProcessEnv} env the environment to mutate (defaults to process.env)
|
|
24
49
|
* @returns {boolean} true iff ELLIOT_TOKEN was set from the config file
|
|
25
50
|
*/
|
|
26
51
|
function injectToken(env) {
|
|
27
52
|
const target = env || process.env
|
|
28
53
|
|
|
29
|
-
|
|
30
|
-
// `eai` at a specific token without touching the config file.
|
|
31
|
-
if (target.ELLIOT_TOKEN) return false
|
|
32
|
-
|
|
54
|
+
let config = null
|
|
33
55
|
try {
|
|
34
56
|
const configPath = path.join(os.homedir(), ".elliot", "config.json")
|
|
35
|
-
|
|
36
|
-
const config = JSON.parse(raw)
|
|
37
|
-
if (config && typeof config.jwt_token === "string" && config.jwt_token) {
|
|
38
|
-
target.ELLIOT_TOKEN = config.jwt_token
|
|
39
|
-
return true
|
|
40
|
-
}
|
|
57
|
+
config = JSON.parse(fs.readFileSync(configPath, "utf-8"))
|
|
41
58
|
} catch {
|
|
42
59
|
// No config yet (setup not run), unreadable, or malformed — the runtime
|
|
43
60
|
// will simply have no elliot token, which is the pre-existing behaviour.
|
|
61
|
+
return false
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Set INDEPENDENTLY of ELLIOT_TOKEN below. An explicit gateway key in the
|
|
65
|
+
// environment is a deliberate override of the gateway hop and says nothing
|
|
66
|
+
// about Elliot identity, so it must not also suppress the MCP credential —
|
|
67
|
+
// doing so would leave MCP silently unauthenticated whenever someone pointed
|
|
68
|
+
// `eai` at a different LiteLLM key.
|
|
69
|
+
if (!target.ELLIOT_MCP_TOKEN && config && typeof config.mcp_token === "string" && config.mcp_token) {
|
|
70
|
+
target.ELLIOT_MCP_TOKEN = config.mcp_token
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// An explicit ELLIOT_TOKEN in the environment wins — it lets a caller point
|
|
74
|
+
// `eai` at a specific token without touching the config file.
|
|
75
|
+
if (target.ELLIOT_TOKEN) return false
|
|
76
|
+
|
|
77
|
+
if (config && typeof config.litellm_master_key === "string" && config.litellm_master_key) {
|
|
78
|
+
target.ELLIOT_TOKEN = config.litellm_master_key
|
|
79
|
+
return true
|
|
44
80
|
}
|
|
45
81
|
return false
|
|
46
82
|
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"use strict"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The detached half of the update notification.
|
|
5
|
+
*
|
|
6
|
+
* Spawned by `maybeNotify`, at most once a day, with stdio ignored and the
|
|
7
|
+
* handle unref'd — so it cannot print anything, cannot hold the parent open,
|
|
8
|
+
* and cannot fail in a way the user sees. Its only job is to leave the answer
|
|
9
|
+
* in the cache for the NEXT run to report.
|
|
10
|
+
*
|
|
11
|
+
* It is a separate file rather than an inline `-e` script so that the code is
|
|
12
|
+
* readable, testable and visible in a stack trace, and so a future change does
|
|
13
|
+
* not have to be escaped through a command line.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const { latestPublished, readState, writeState, notificationsDisabled } = require("./update.js")
|
|
17
|
+
|
|
18
|
+
if (notificationsDisabled(process.env)) process.exit(0)
|
|
19
|
+
|
|
20
|
+
latestPublished()
|
|
21
|
+
.then((latest) => {
|
|
22
|
+
if (latest) writeState({ ...readState(), latest, checkedAt: Date.now() })
|
|
23
|
+
})
|
|
24
|
+
.catch(() => {
|
|
25
|
+
// Silence is the contract. The parent already stamped checkedAt, so a
|
|
26
|
+
// failure here just means the answer is refreshed tomorrow instead.
|
|
27
|
+
})
|
package/lib/update.js
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
"use strict"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `eai update` — move this machine onto the current published CLI.
|
|
5
|
+
*
|
|
6
|
+
* ## Why this exists rather than "just run npm i -g"
|
|
7
|
+
*
|
|
8
|
+
* Because the user does not necessarily know how `eai` got onto their machine,
|
|
9
|
+
* and the right command differs: a global npm install, a pnpm or bun global, or
|
|
10
|
+
* a `node_modules` inside a project are four different updates, and running the
|
|
11
|
+
* wrong one either fails confusingly or installs a SECOND copy that shadows the
|
|
12
|
+
* first. Onboarding is one `npm i -g @elliotsystems/eai`, so the common case is
|
|
13
|
+
* easy; the point of this command is that the uncommon cases say what to do
|
|
14
|
+
* instead of failing.
|
|
15
|
+
*
|
|
16
|
+
* ## It updates the CLI, which brings the runtime with it
|
|
17
|
+
*
|
|
18
|
+
* `opencode-ai` is a dependency of this package, pinned to an exact version.
|
|
19
|
+
* So the OpenCode runtime a user has is decided by the EAI version they have,
|
|
20
|
+
* and npm fetches the matching runtime as part of the install. There is
|
|
21
|
+
* deliberately no separate "update OpenCode" path: the two moving
|
|
22
|
+
* independently on a user's machine is the drift this whole mechanism exists
|
|
23
|
+
* to prevent, and `check_opencode_versions.mjs` enforces the same pairing in
|
|
24
|
+
* CI.
|
|
25
|
+
*
|
|
26
|
+
* ## Nothing here hardcodes a version
|
|
27
|
+
*
|
|
28
|
+
* The published version is asked of the registry, and the installed one is read
|
|
29
|
+
* from the package. A literal in this file would be a third source of truth
|
|
30
|
+
* that goes stale on the next release and keeps looking authoritative.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
const childProcess = require("child_process")
|
|
34
|
+
const fs = require("fs")
|
|
35
|
+
const os = require("os")
|
|
36
|
+
const path = require("path")
|
|
37
|
+
|
|
38
|
+
const PACKAGE_NAME = "@elliotsystems/eai"
|
|
39
|
+
|
|
40
|
+
/** The npm registry's own metadata endpoint for the published version. */
|
|
41
|
+
const REGISTRY_URL = `https://registry.npmjs.org/${PACKAGE_NAME}/latest`
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Run a package-manager command, portably and without a deprecation warning.
|
|
45
|
+
*
|
|
46
|
+
* Windows needs a shell: npm, pnpm and bun are all `.cmd` shims there, and
|
|
47
|
+
* execFile cannot run a `.cmd` directly — it throws ENOENT, which silently
|
|
48
|
+
* made every Windows user's global install look like "not a global install".
|
|
49
|
+
*
|
|
50
|
+
* But `shell: true` TOGETHER WITH an args array triggers Node 24's DEP0190
|
|
51
|
+
* ("arguments are not escaped, only concatenated"), which printed a security
|
|
52
|
+
* warning on every `eai update` run. Passing one pre-joined command string
|
|
53
|
+
* instead does the same work without the warning, because the concatenation
|
|
54
|
+
* is then explicitly ours rather than something Node did behind our back.
|
|
55
|
+
*
|
|
56
|
+
* That is only safe because every command and argument here is a literal from
|
|
57
|
+
* the table in detectInstall. Nothing user-supplied is ever interpolated, and
|
|
58
|
+
* nothing in this file should start doing so.
|
|
59
|
+
*/
|
|
60
|
+
function runTool(bin, args, options) {
|
|
61
|
+
if (process.platform === "win32") {
|
|
62
|
+
return childProcess.execSync([bin, ...args].join(" "), { ...options, shell: undefined })
|
|
63
|
+
}
|
|
64
|
+
return childProcess.execFileSync(bin, args, options)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* How was this copy installed?
|
|
69
|
+
*
|
|
70
|
+
* Decided from the path the package actually sits at, not from a guess: an
|
|
71
|
+
* `npm root -g` that matches means a global install, and anything else is
|
|
72
|
+
* reported rather than acted on. Updating the wrong tree is how a user ends up
|
|
73
|
+
* with two `eai` binaries and no idea which one runs.
|
|
74
|
+
*/
|
|
75
|
+
function detectInstall(packageRoot) {
|
|
76
|
+
const managers = [
|
|
77
|
+
{ name: "npm", probe: ["npm", ["root", "-g"]], update: "npm install -g " + PACKAGE_NAME + "@latest" },
|
|
78
|
+
{ name: "pnpm", probe: ["pnpm", ["root", "-g"]], update: "pnpm add -g " + PACKAGE_NAME + "@latest" },
|
|
79
|
+
{ name: "bun", probe: ["bun", ["pm", "bin", "-g"]], update: "bun add -g " + PACKAGE_NAME + "@latest" },
|
|
80
|
+
]
|
|
81
|
+
const resolved = (() => {
|
|
82
|
+
try {
|
|
83
|
+
return fs.realpathSync(packageRoot)
|
|
84
|
+
} catch {
|
|
85
|
+
return packageRoot
|
|
86
|
+
}
|
|
87
|
+
})()
|
|
88
|
+
|
|
89
|
+
for (const m of managers) {
|
|
90
|
+
let root
|
|
91
|
+
try {
|
|
92
|
+
root = runTool(m.probe[0], m.probe[1], {
|
|
93
|
+
encoding: "utf8",
|
|
94
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
95
|
+
timeout: 15000,
|
|
96
|
+
// REQUIRED on Windows, and its absence is not a degraded probe — it is
|
|
97
|
+
// a total one. `npm`, `pnpm` and `bun` are all `.cmd` shims there, and
|
|
98
|
+
// execFileSync cannot execute a `.cmd` without a shell: it throws
|
|
99
|
+
// ENOENT, the catch below skips the manager, every manager is skipped,
|
|
100
|
+
// and `eai update` tells every Windows user their global install is
|
|
101
|
+
// "not a global install" and refuses. Windows is the platform
|
|
102
|
+
// publish-eai.yml validates on.
|
|
103
|
+
//
|
|
104
|
+
}).trim()
|
|
105
|
+
} catch {
|
|
106
|
+
continue // manager not installed; not an error, just not this one
|
|
107
|
+
}
|
|
108
|
+
if (!root) continue
|
|
109
|
+
let realRoot
|
|
110
|
+
try {
|
|
111
|
+
realRoot = fs.realpathSync(root)
|
|
112
|
+
} catch {
|
|
113
|
+
realRoot = root
|
|
114
|
+
}
|
|
115
|
+
if (resolved.startsWith(realRoot + path.sep) || resolved.startsWith(root + path.sep)) {
|
|
116
|
+
return { kind: "global", manager: m.name, command: m.update }
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return { kind: "unknown", manager: null, command: null }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The published version, or null.
|
|
124
|
+
*
|
|
125
|
+
* Deliberately quiet on failure. An offline machine, a proxy or a registry
|
|
126
|
+
* blip must not turn `eai update` into an error — and must REALLY not turn a
|
|
127
|
+
* background check (see maybeNotify) into one.
|
|
128
|
+
*/
|
|
129
|
+
async function latestPublished(timeoutMs = 5000) {
|
|
130
|
+
const controller = new AbortController()
|
|
131
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
|
132
|
+
try {
|
|
133
|
+
const response = await fetch(REGISTRY_URL, {
|
|
134
|
+
signal: controller.signal,
|
|
135
|
+
// `application/vnd.npm.install-v1+json` returns an abbreviated document.
|
|
136
|
+
// Smaller and, more to the point, it is the contract npm itself uses, so
|
|
137
|
+
// it is the least likely shape to change under us.
|
|
138
|
+
headers: { accept: "application/vnd.npm.install-v1+json, application/json" },
|
|
139
|
+
})
|
|
140
|
+
if (!response.ok) return null
|
|
141
|
+
const body = await response.json()
|
|
142
|
+
return typeof body?.version === "string" ? body.version : null
|
|
143
|
+
} catch {
|
|
144
|
+
return null
|
|
145
|
+
} finally {
|
|
146
|
+
clearTimeout(timer)
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function compareSemver(a, b) {
|
|
151
|
+
const parse = (v) => String(v).split("-")[0].split(".").map((n) => Number(n) || 0)
|
|
152
|
+
const [x, y] = [parse(a), parse(b)]
|
|
153
|
+
return x[0] - y[0] || x[1] - y[1] || x[2] - y[2]
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** `eai update`. */
|
|
157
|
+
async function runUpdate({ packageRoot, version, argv = [] } = {}) {
|
|
158
|
+
const check = argv.includes("--check")
|
|
159
|
+
const latest = await latestPublished()
|
|
160
|
+
|
|
161
|
+
if (!latest) {
|
|
162
|
+
process.stderr.write(
|
|
163
|
+
"eai update: could not reach the npm registry.\n" +
|
|
164
|
+
"Nothing was changed. Check your connection or proxy and try again.\n",
|
|
165
|
+
)
|
|
166
|
+
return 1
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
process.stdout.write(`installed: ${version}\npublished: ${latest}\n`)
|
|
170
|
+
|
|
171
|
+
if (compareSemver(latest, version) <= 0) {
|
|
172
|
+
process.stdout.write("\nAlready up to date.\n")
|
|
173
|
+
return 0
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const install = detectInstall(packageRoot)
|
|
177
|
+
if (check) {
|
|
178
|
+
process.stdout.write(`\nUpdate available. Run: ${install.command || "eai update"}\n`)
|
|
179
|
+
return 0
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (install.kind !== "global") {
|
|
183
|
+
// Not a failure — a refusal to guess. Installing globally over a local
|
|
184
|
+
// checkout or a project dependency would leave two copies and no signal
|
|
185
|
+
// about which one the shell resolves.
|
|
186
|
+
process.stderr.write(
|
|
187
|
+
`\neai update: this copy of ${PACKAGE_NAME} is not a global install.\n` +
|
|
188
|
+
`It is at: ${packageRoot}\n` +
|
|
189
|
+
"Update it the way it was installed — or install globally with:\n" +
|
|
190
|
+
` npm install -g ${PACKAGE_NAME}@latest\n`,
|
|
191
|
+
)
|
|
192
|
+
return 1
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
process.stdout.write(`\nUpdating via ${install.manager}: ${install.command}\n\n`)
|
|
196
|
+
// One command string, not (bin, args) — see runTool above for why the args
|
|
197
|
+
// form prints a DEP0190 security warning on every Windows run.
|
|
198
|
+
const result = process.platform === "win32"
|
|
199
|
+
? childProcess.spawnSync(install.command, { stdio: "inherit", shell: true })
|
|
200
|
+
: childProcess.spawnSync(install.command.split(" ")[0], install.command.split(" ").slice(1), { stdio: "inherit" })
|
|
201
|
+
if (result.status !== 0) {
|
|
202
|
+
process.stderr.write(
|
|
203
|
+
`\neai update: ${install.manager} exited ${result.status}.\n` +
|
|
204
|
+
"On a permissions error, either re-run with the privileges that install needs,\n" +
|
|
205
|
+
"or point npm at a user-writable prefix (`npm config set prefix`).\n",
|
|
206
|
+
)
|
|
207
|
+
return result.status || 1
|
|
208
|
+
}
|
|
209
|
+
process.stdout.write(`\nUpdated to ${latest}. Run \`eai --version\` to confirm.\n`)
|
|
210
|
+
return 0
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ── update notification ────────────────────────────────────────────────────
|
|
214
|
+
//
|
|
215
|
+
// A CLI that silently goes stale is a CLI whose users report bugs that were
|
|
216
|
+
// fixed months ago. But a version check on the hot path is worse than the
|
|
217
|
+
// problem: it adds latency to every launch, it fails on an offline machine,
|
|
218
|
+
// and a nag on every single run gets ignored and then resented.
|
|
219
|
+
//
|
|
220
|
+
// So the notice is: throttled to once a day, written to STDERR so it can never
|
|
221
|
+
// corrupt piped output, never blocking (the check runs against a cached answer
|
|
222
|
+
// from a PREVIOUS run, never a live request on this one), and switchable off.
|
|
223
|
+
|
|
224
|
+
const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000
|
|
225
|
+
|
|
226
|
+
function stateFile() {
|
|
227
|
+
return path.join(os.homedir(), ".elliot", "update-check.json")
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function readState() {
|
|
231
|
+
try {
|
|
232
|
+
return JSON.parse(fs.readFileSync(stateFile(), "utf8")) || {}
|
|
233
|
+
} catch {
|
|
234
|
+
return {}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function writeState(state) {
|
|
239
|
+
try {
|
|
240
|
+
fs.mkdirSync(path.dirname(stateFile()), { recursive: true })
|
|
241
|
+
fs.writeFileSync(stateFile(), JSON.stringify(state))
|
|
242
|
+
} catch {
|
|
243
|
+
// A read-only or full home directory must not break the CLI over a
|
|
244
|
+
// convenience feature. Losing the throttle means re-checking tomorrow.
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function notificationsDisabled(env) {
|
|
249
|
+
// Two switches on purpose. ELLIOT_NO_UPDATE_CHECK is ours and explicit;
|
|
250
|
+
// CI is honoured because a version nag in build logs is pure noise and
|
|
251
|
+
// nobody sets a flag they do not know exists.
|
|
252
|
+
return Boolean(env.ELLIOT_NO_UPDATE_CHECK || env.CI || env.NO_UPDATE_NOTIFIER)
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Print a notice if a newer version was seen EARLIER, then refresh the cache in
|
|
257
|
+
* the background.
|
|
258
|
+
*
|
|
259
|
+
* The ordering is the whole design. This run reports what the last run already
|
|
260
|
+
* learned, so the common path does no network I/O at all and costs a file read.
|
|
261
|
+
* The refresh is detached and unref'd, so the process exits without waiting on
|
|
262
|
+
* it, and a hung registry delays nothing.
|
|
263
|
+
*/
|
|
264
|
+
function maybeNotify({ version, env = process.env, now = Date.now() } = {}) {
|
|
265
|
+
if (notificationsDisabled(env)) return
|
|
266
|
+
const state = readState()
|
|
267
|
+
|
|
268
|
+
if (state.latest && compareSemver(state.latest, version) > 0) {
|
|
269
|
+
process.stderr.write(
|
|
270
|
+
`\nA newer Elliot CLI is available: ${version} → ${state.latest}\n` +
|
|
271
|
+
"Run `eai update` to upgrade. (`ELLIOT_NO_UPDATE_CHECK=1` silences this.)\n\n",
|
|
272
|
+
)
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (now - (state.checkedAt || 0) < CHECK_INTERVAL_MS) return
|
|
276
|
+
// Stamp BEFORE spawning. If the check fails or the machine is offline, the
|
|
277
|
+
// throttle still holds and we do not spawn a doomed probe on every run.
|
|
278
|
+
writeState({ ...state, checkedAt: now })
|
|
279
|
+
try {
|
|
280
|
+
const child = childProcess.spawn(
|
|
281
|
+
process.execPath,
|
|
282
|
+
[path.join(__dirname, "update-check.js")],
|
|
283
|
+
{ detached: true, stdio: "ignore", env },
|
|
284
|
+
)
|
|
285
|
+
child.unref()
|
|
286
|
+
} catch {
|
|
287
|
+
// Best effort, always.
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
module.exports = {
|
|
292
|
+
PACKAGE_NAME,
|
|
293
|
+
runUpdate,
|
|
294
|
+
latestPublished,
|
|
295
|
+
compareSemver,
|
|
296
|
+
detectInstall,
|
|
297
|
+
maybeNotify,
|
|
298
|
+
notificationsDisabled,
|
|
299
|
+
stateFile,
|
|
300
|
+
readState,
|
|
301
|
+
writeState,
|
|
302
|
+
CHECK_INTERVAL_MS,
|
|
303
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@elliotsystems/eai",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
4
4
|
"description": "Elliot AI CLI entry point (eai) - launches the Elliot runtime with arguments forwarded verbatim.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"eai": "./bin/eai"
|
|
@@ -13,11 +13,9 @@
|
|
|
13
13
|
"README.md"
|
|
14
14
|
],
|
|
15
15
|
"scripts": {
|
|
16
|
+
"pretest": "node scripts/bundle.js",
|
|
16
17
|
"test": "node --test test/*.test.js",
|
|
17
|
-
"vendor": "node scripts/vendor.js",
|
|
18
|
-
"assemble": "node scripts/assemble.js",
|
|
19
18
|
"publish:npm": "node scripts/publish.js",
|
|
20
|
-
"build:runtime": "bun run --cwd ../opencode/packages/opencode script/build.ts --skip-embed-web-ui",
|
|
21
19
|
"bundle": "node scripts/bundle.js",
|
|
22
20
|
"prepack": "node scripts/bundle.js",
|
|
23
21
|
"verify": "node scripts/verify-package.js"
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Shell-command classification — the policy intelligence glob patterns cannot express.
|
|
3
3
|
*
|
|
4
|
-
* Ported (adapted, not copied wholesale) from
|
|
4
|
+
* Ported (adapted, not copied wholesale) from the retired elliot-ai CLI's
|
|
5
|
+
* `src/tools/bash-policy.ts`,
|
|
5
6
|
* which is pure and dependency-free. Three things here are beyond what OpenCode's
|
|
6
7
|
* native `permission.bash` globs can represent, which is exactly why this file exists:
|
|
7
8
|
*
|