@enter-pro/enter-cli 0.4.1 → 0.4.3
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 +167 -0
- package/dist/auth.d.ts +4 -0
- package/dist/auth.js +132 -6
- package/dist/client.d.ts +4 -6
- package/dist/client.js +97 -93
- package/dist/commands/config.js +5 -10
- package/dist/commands/domain.js +3 -6
- package/dist/commands/login.js +18 -12
- package/dist/commands/logout.js +7 -3
- package/dist/commands/project.js +85 -70
- package/dist/commands/thread-tasks.d.ts +2 -0
- package/dist/commands/thread-tasks.js +23 -0
- package/dist/commands/thread.d.ts +54 -0
- package/dist/commands/thread.js +547 -209
- package/dist/commands/whoami.js +1 -1
- package/dist/commands/workspace.js +7 -10
- package/dist/config.d.ts +0 -1
- package/dist/config.js +10 -4
- package/dist/errors.d.ts +8 -0
- package/dist/errors.js +29 -0
- package/dist/index.js +9 -2
- package/dist/output.d.ts +0 -16
- package/dist/output.js +0 -18
- package/dist/poll.d.ts +1 -0
- package/dist/poll.js +3 -1
- package/dist/safe-output.d.ts +6 -0
- package/dist/safe-output.js +29 -0
- package/dist/thread-events.d.ts +37 -0
- package/dist/thread-events.js +196 -0
- package/dist/workflow.d.ts +44 -0
- package/dist/workflow.js +34 -0
- package/package.json +22 -10
- package/scripts/install-hosts.mjs +29 -0
- package/skills/enter/SKILL.md +36 -0
- package/skills/enter/references/configuration.md +19 -0
package/README.md
CHANGED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# Enter CLI
|
|
2
|
+
|
|
3
|
+
CLI for Enter projects, tasks and approval cards. Node 18+ is required.
|
|
4
|
+
The [Enter Skill](skills/enter/SKILL.md) guides agents through the workflow;
|
|
5
|
+
command options are available through `enter-cli <command> --help`.
|
|
6
|
+
|
|
7
|
+
## Authentication and configuration
|
|
8
|
+
|
|
9
|
+
`enter-cli login` uses `auth.converge.ai` and the API at
|
|
10
|
+
`https://api.enter.pro/code/api` (not the web domain `enter.converge.ai`).
|
|
11
|
+
Log in again if credentials came from the former `auth.enter.pro` tenant.
|
|
12
|
+
OAuth and API-key login verify the new token against `/v1/users/info` before
|
|
13
|
+
saving it; verification failure preserves existing credentials. Expired local
|
|
14
|
+
OAuth credentials renew automatically. An already-sent renewal saves rotated
|
|
15
|
+
credentials before honoring cancellation, so the next invocation can still log in.
|
|
16
|
+
|
|
17
|
+
`ENTER_API_KEY` takes precedence over local credentials. `login` saves local
|
|
18
|
+
credentials but cannot switch an injected identity. `logout` clears local
|
|
19
|
+
credentials only and reports `logged_out: false, auth_source: environment` when
|
|
20
|
+
that variable remains set. Switch host-managed authentication in the host, or
|
|
21
|
+
unset the variable in your shell.
|
|
22
|
+
|
|
23
|
+
`config get/set/list` supports `api_url`, `base_path`, and `output` (`json`, `yaml`,
|
|
24
|
+
`table`). Environment overrides are `ENTER_API_URL`, `ENTER_BASE_PATH`, and
|
|
25
|
+
`ENTER_OUTPUT`. Workspace IDs are explicit command arguments; the unused
|
|
26
|
+
`default_workspace` setting is rejected and legacy entries are ignored.
|
|
27
|
+
|
|
28
|
+
## Submit, observe and deliver
|
|
29
|
+
|
|
30
|
+
```sh
|
|
31
|
+
enter-cli --output json thread chat PROJECT_ID --file requirement.txt
|
|
32
|
+
enter-cli --output json thread wait PROJECT_ID --task-id TASK_ID --timeout 10
|
|
33
|
+
enter-cli --output json thread status PROJECT_ID --task-id TASK_ID
|
|
34
|
+
enter-cli thread watch PROJECT_ID --task-id TASK_ID --timeout 60
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
`chat --stdin` also accepts multiline input. Submission returns immediately with
|
|
38
|
+
`task_id`, `submission_status: accepted`, and continuation commands. Follow-ups
|
|
39
|
+
can be submitted while Enter works; acceptance is not completion.
|
|
40
|
+
|
|
41
|
+
| Command | Output and default lifetime |
|
|
42
|
+
| --- | --- |
|
|
43
|
+
| `status` | One JSON/YAML snapshot, 30-second deadline |
|
|
44
|
+
| `wait` | One result on a card, terminal state or timeout; 10 seconds |
|
|
45
|
+
| `watch` | Changed snapshots as NDJSON, then `type: result`; 60 seconds |
|
|
46
|
+
|
|
47
|
+
Use one observer per task. In interactive agents, use the returned short wait,
|
|
48
|
+
handle incoming user messages, then continue the same task. Normal observation
|
|
49
|
+
timeout is not a reason to increase the wait or resubmit. Background watch is
|
|
50
|
+
appropriate only with completion notifications and non-blocking output collection;
|
|
51
|
+
do not immediately block on the job for minutes. CLI lifetime and host job-wait
|
|
52
|
+
limits are separate upper bounds, not measured elapsed time. Hosts own message
|
|
53
|
+
scheduling: CLI events cannot make a blocked host process new user input.
|
|
54
|
+
|
|
55
|
+
`--task-id` follows exactly the submitted task, including queue time. `--turn N`
|
|
56
|
+
selects a fixed turn instead; without either, observation pins the first turn.
|
|
57
|
+
`--chat-id` scopes lookup and continuation. An interjection may return an external
|
|
58
|
+
message ID that the backend cannot correlate to a turn: report `unknown`, inspect
|
|
59
|
+
messages and the resulting change, and do not substitute the newest turn.
|
|
60
|
+
|
|
61
|
+
| Status | Meaning |
|
|
62
|
+
| --- | --- |
|
|
63
|
+
| `idle` | No turn yet |
|
|
64
|
+
| `queued` | Selected task is queued |
|
|
65
|
+
| `unknown` | Missing turn, unavailable status or uncorrelated submission |
|
|
66
|
+
| `running` | Nonterminal turn with no pending action |
|
|
67
|
+
| `blocked` | Inspect `actions` for input or approval |
|
|
68
|
+
| `completed` | Turn completed; build and deployment are separate |
|
|
69
|
+
| `failed` | Turn failed, errored or was cancelled |
|
|
70
|
+
|
|
71
|
+
`project create --wait` uses the same observer and requires a matching successful
|
|
72
|
+
build. For existing tasks use `--require-build`; stale builds cannot satisfy it.
|
|
73
|
+
`build_matches_turn` checks commit identity. `status/wait` retain full metadata;
|
|
74
|
+
`--compact` selects monitoring fields. `watch` is compact unless `--full` is set.
|
|
75
|
+
The additive `workflow` projection supplies `state`, `task`, `next_action`,
|
|
76
|
+
`observation`, build matching and action descriptors; full card details remain in
|
|
77
|
+
`actions`. `use_existing_authorization` does not grant new permission.
|
|
78
|
+
|
|
79
|
+
Exit 0 means observation succeeded (including a blocked card), not build success.
|
|
80
|
+
Exit 2 means a wait timed out with work pending; exit 1 means failure, unknown
|
|
81
|
+
state or query failure. `query_timed_out` marks a query deadline; retained state
|
|
82
|
+
is the last complete snapshot. SIGINT/SIGTERM returns `interrupted: true` and
|
|
83
|
+
130/143 without cancelling remote work. A cancelled turn can still have a queued
|
|
84
|
+
backend operation; do not confuse it with completed cancellation or restoration.
|
|
85
|
+
|
|
86
|
+
Completed snapshots include `messages_command`. Use
|
|
87
|
+
`thread messages PROJECT_ID --turn N --text` to read Enter's delivery and validation
|
|
88
|
+
summary. Report build success, saved configuration and real-service validation
|
|
89
|
+
separately; do not replace Enter's validation with source keyword scans.
|
|
90
|
+
|
|
91
|
+
## Transport and errors
|
|
92
|
+
|
|
93
|
+
Observation uses authenticated WebSocket events, reconnects with `last_event_id`,
|
|
94
|
+
deduplicates replay, and re-reads authoritative HTTP state. Healthy streams have a
|
|
95
|
+
30-second safety refresh; unavailable streams fall back to 2-second HTTP checks.
|
|
96
|
+
Text/argument deltas do not trigger full queries; card persistence gets a short
|
|
97
|
+
reconciliation window. `--transport poll` forces HTTP, `--cursor` resumes events,
|
|
98
|
+
and `transport/transport_reason/cursor` identify progress. Stream 401/403 fails explicitly;
|
|
99
|
+
unsupported endpoints fall back without repeated reconnects. WebSockets respect
|
|
100
|
+
proxy variables and `NO_PROXY`; `NODE_USE_ENV_PROXY=0` disables their proxy use.
|
|
101
|
+
|
|
102
|
+
`thread messages PROJECT_ID --follow --timeout 60 --cursor EVENT_ID` emits raw
|
|
103
|
+
NDJSON events with diagnostics on stderr. It supports `--max-events`, crosses turn
|
|
104
|
+
boundaries, and does not synthesize HTTP state when the socket is unavailable.
|
|
105
|
+
|
|
106
|
+
`--request-timeout SECONDS` covers API requests and response bodies, including
|
|
107
|
+
downloads. Monitoring always has its own deadline. `project publish --timeout`
|
|
108
|
+
validates before requests and bounds lookup, submission, observation and URL
|
|
109
|
+
verification. After uncertain publication, query `project publish-status` first.
|
|
110
|
+
Only transient observation reads retry automatically; mutations never do.
|
|
111
|
+
`error.outcome_unknown: true` means inspect actual state before retrying a write.
|
|
112
|
+
|
|
113
|
+
JSON execution errors go to stderr; observation errors accompany the last
|
|
114
|
+
snapshot on stdout. Error fields are `code/message/retryable/outcome_unknown`.
|
|
115
|
+
Keep stdout and stderr separate. JSON/YAML confirmations are structured;
|
|
116
|
+
interactive login instructions use stderr. Known credential fields and encoded
|
|
117
|
+
tool arguments are redacted in monitoring, approval, events and verbose bodies;
|
|
118
|
+
ordinary content is not arbitrarily rewritten.
|
|
119
|
+
|
|
120
|
+
## Approval cards
|
|
121
|
+
|
|
122
|
+
Feature cards call the actual enable endpoint and verify action state; subscription
|
|
123
|
+
refusal or enable failure remains an error. Plans require user approval. Questions
|
|
124
|
+
carry their original text, options and selection mode: use that text as the answer
|
|
125
|
+
key, not a host question ID. `thread approve --help` describes `selected_options`
|
|
126
|
+
and `other_text`; skip only when the user requests it.
|
|
127
|
+
|
|
128
|
+
Configuration cards expose required fields. Reuse authorized input, ask only for
|
|
129
|
+
missing values, and use stdin from a secure input or user-provided file, or Enter's
|
|
130
|
+
form. OAuth configuration is saved before credential-free approval; after form
|
|
131
|
+
submission, check status because the form may already have approved the action.
|
|
132
|
+
Provider fields, Secret/Stripe inputs and examples are in
|
|
133
|
+
[configuration guidance](skills/enter/references/configuration.md).
|
|
134
|
+
Do not echo secrets or embed them in shell text/build prompts: stdin does not
|
|
135
|
+
protect earlier chat or tool logs. User-approved placeholders remain pending
|
|
136
|
+
integrations; mock configuration does not prove real OAuth login or payment.
|
|
137
|
+
|
|
138
|
+
## Development and host installation
|
|
139
|
+
|
|
140
|
+
```sh
|
|
141
|
+
npm ci
|
|
142
|
+
npm test
|
|
143
|
+
npm run build
|
|
144
|
+
npm pack
|
|
145
|
+
npm run install:hosts -- --package /absolute/path/enter-pro-enter-cli-0.4.3.tgz
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Tests use loopback HTTP/WebSocket fixtures and dummy credentials, not real cloud
|
|
149
|
+
provisioning or host UI acceptance. The installer puts the same package and Skill
|
|
150
|
+
into Codex/DSH, renders the wrapper, and records its SHA-256. `--host codex|dsh`
|
|
151
|
+
selects one host; `--home` supports isolated installs. No checkout is needed at runtime.
|
|
152
|
+
|
|
153
|
+
For manual simulation, run `npm run mock:serve`, then in another terminal:
|
|
154
|
+
|
|
155
|
+
```sh
|
|
156
|
+
npm run local:cli -- thread status cloud
|
|
157
|
+
npm run local:cli -- thread approve cloud cloud-action
|
|
158
|
+
npm run local:cli -- thread wait cloud --timeout 3
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
The server binds loopback port 43180 (`MOCK_ENTER_PORT` overrides it). The wrapper
|
|
162
|
+
forces loopback and a dummy key even if production environment variables exist.
|
|
163
|
+
Scenarios: `plan`, `cloud`, `ai`, `secret`, `questions`, `subscription`, `running`,
|
|
164
|
+
`failed`, `http-error`, `stalled`. Card IDs are `<scenario>-action`. Restart to reset;
|
|
165
|
+
unsupported routes fail, and request bodies/secrets are not retained. Only use
|
|
166
|
+
fake values. `.env.integration.example` lists real-test variables; its ignored
|
|
167
|
+
`.env.integration.local` counterpart is never loaded automatically.
|
package/dist/auth.d.ts
CHANGED
|
@@ -6,7 +6,11 @@ export interface Credentials {
|
|
|
6
6
|
expires_at?: string;
|
|
7
7
|
}
|
|
8
8
|
export declare function saveCredentials(creds: Credentials): void;
|
|
9
|
+
export declare function verifyAccessToken(token: string): Promise<void>;
|
|
9
10
|
export declare function loadCredentials(): Credentials | null;
|
|
10
11
|
export declare function clearCredentials(): void;
|
|
11
12
|
export declare function getToken(): string;
|
|
12
13
|
export declare function isAuthenticated(): boolean;
|
|
14
|
+
export declare const OAUTH_TOKEN_URL = "https://auth.converge.ai/oauth/token";
|
|
15
|
+
export declare const OAUTH_CLIENT_ID = "anCisSaaIA36fTZ2DUMiTMro3bYuptrf";
|
|
16
|
+
export declare function getValidToken(signal?: AbortSignal): Promise<string>;
|
package/dist/auth.js
CHANGED
|
@@ -1,14 +1,42 @@
|
|
|
1
|
-
import { readFileSync, writeFileSync, unlinkSync } from "fs";
|
|
1
|
+
import { readFileSync, writeFileSync, unlinkSync, mkdirSync, renameSync, rmdirSync } from "fs";
|
|
2
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
2
4
|
import { join } from "path";
|
|
3
|
-
import { configDir } from "./config.js";
|
|
5
|
+
import { baseURL, configDir } from "./config.js";
|
|
4
6
|
const CREDENTIALS_FILE = "credentials.json";
|
|
5
7
|
function credentialsPath() {
|
|
6
8
|
return join(configDir(), CREDENTIALS_FILE);
|
|
7
9
|
}
|
|
8
10
|
export function saveCredentials(creds) {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
+
mkdirSync(configDir(), { recursive: true, mode: 0o700 });
|
|
12
|
+
const temporary = `${credentialsPath()}.${randomUUID()}.tmp`;
|
|
13
|
+
try {
|
|
14
|
+
writeFileSync(temporary, JSON.stringify(creds, null, 2), { mode: 0o600 });
|
|
15
|
+
renameSync(temporary, credentialsPath());
|
|
16
|
+
}
|
|
17
|
+
finally {
|
|
18
|
+
try {
|
|
19
|
+
unlinkSync(temporary);
|
|
20
|
+
}
|
|
21
|
+
catch { /* already renamed */ }
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
// Validate the newly issued token, not an older saved token or ENTER_API_KEY.
|
|
25
|
+
// Do not persist it or claim login success until the configured API accepts it.
|
|
26
|
+
export async function verifyAccessToken(token) {
|
|
27
|
+
if (!token)
|
|
28
|
+
throw new Error("Login did not return an access token");
|
|
29
|
+
const response = await fetch(`${baseURL()}/v1/users/info`, {
|
|
30
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
31
|
+
signal: AbortSignal.timeout(15000),
|
|
11
32
|
});
|
|
33
|
+
if (!response.ok) {
|
|
34
|
+
throw new Error(`Login verification failed (HTTP ${response.status}); credentials were not saved`);
|
|
35
|
+
}
|
|
36
|
+
const result = await response.json();
|
|
37
|
+
if (result?.code !== 0) {
|
|
38
|
+
throw new Error("Login verification failed: API did not confirm authentication; credentials were not saved");
|
|
39
|
+
}
|
|
12
40
|
}
|
|
13
41
|
export function loadCredentials() {
|
|
14
42
|
try {
|
|
@@ -23,8 +51,9 @@ export function clearCredentials() {
|
|
|
23
51
|
try {
|
|
24
52
|
unlinkSync(credentialsPath());
|
|
25
53
|
}
|
|
26
|
-
catch {
|
|
27
|
-
|
|
54
|
+
catch (error) {
|
|
55
|
+
if (error.code !== "ENOENT")
|
|
56
|
+
throw error;
|
|
28
57
|
}
|
|
29
58
|
}
|
|
30
59
|
export function getToken() {
|
|
@@ -37,3 +66,100 @@ export function getToken() {
|
|
|
37
66
|
export function isAuthenticated() {
|
|
38
67
|
return getToken() !== "";
|
|
39
68
|
}
|
|
69
|
+
// Same OAuth client as interactive login. Environment keys (including Work's
|
|
70
|
+
// transparent placeholder) must never fall back to a personal OAuth identity.
|
|
71
|
+
export const OAUTH_TOKEN_URL = "https://auth.converge.ai/oauth/token";
|
|
72
|
+
export const OAUTH_CLIENT_ID = "anCisSaaIA36fTZ2DUMiTMro3bYuptrf";
|
|
73
|
+
let refreshing;
|
|
74
|
+
function needsRefresh(creds) {
|
|
75
|
+
return !!creds.expires_at && Date.parse(creds.expires_at) <= Date.now() + 60000;
|
|
76
|
+
}
|
|
77
|
+
async function refreshCredentials(signal) {
|
|
78
|
+
const lock = join(configDir(), "credentials-refresh.lock");
|
|
79
|
+
const deadline = Date.now() + 20000;
|
|
80
|
+
// Serialize separate CLI processes too: rotating refresh tokens are single use.
|
|
81
|
+
while (true) {
|
|
82
|
+
signal?.throwIfAborted();
|
|
83
|
+
try {
|
|
84
|
+
mkdirSync(lock, { mode: 0o700 });
|
|
85
|
+
break;
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
if (error.code !== "EEXIST")
|
|
89
|
+
throw error;
|
|
90
|
+
if (Date.now() >= deadline)
|
|
91
|
+
throw new Error("Another CLI is refreshing login. Retry after it finishes; if it crashed, remove ~/.enter/credentials-refresh.lock.");
|
|
92
|
+
await delay(100, undefined, { signal });
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
try {
|
|
96
|
+
const creds = loadCredentials();
|
|
97
|
+
if (!creds?.access_token)
|
|
98
|
+
return "";
|
|
99
|
+
if (!needsRefresh(creds))
|
|
100
|
+
return creds.access_token;
|
|
101
|
+
if (!creds.refresh_token)
|
|
102
|
+
throw new Error("Login expired. Run `enter-cli login` to sign in again.");
|
|
103
|
+
let response;
|
|
104
|
+
try {
|
|
105
|
+
response = await fetch(OAUTH_TOKEN_URL, {
|
|
106
|
+
method: "POST",
|
|
107
|
+
headers: { "Content-Type": "application/json" },
|
|
108
|
+
body: JSON.stringify({ grant_type: "refresh_token", client_id: OAUTH_CLIENT_ID, refresh_token: creds.refresh_token }),
|
|
109
|
+
signal: AbortSignal.timeout(15000),
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
throw new Error("Could not refresh login. Check connectivity and retry; saved credentials were preserved.");
|
|
114
|
+
}
|
|
115
|
+
if (!response.ok) {
|
|
116
|
+
if ([400, 401, 403].includes(response.status))
|
|
117
|
+
throw new Error("Login renewal was rejected. Run `enter-cli login` to sign in again.");
|
|
118
|
+
throw new Error(`Login renewal failed (HTTP ${response.status}). Retry later.`);
|
|
119
|
+
}
|
|
120
|
+
let tokens;
|
|
121
|
+
try {
|
|
122
|
+
tokens = await response.json();
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
throw new Error("Login renewal returned an invalid response.");
|
|
126
|
+
}
|
|
127
|
+
if (typeof tokens?.access_token !== "string" || !tokens.access_token ||
|
|
128
|
+
typeof tokens.expires_in !== "number" || !Number.isFinite(tokens.expires_in) || tokens.expires_in <= 0 ||
|
|
129
|
+
(tokens.refresh_token !== undefined && (typeof tokens.refresh_token !== "string" || !tokens.refresh_token))) {
|
|
130
|
+
throw new Error("Login renewal returned an invalid response.");
|
|
131
|
+
}
|
|
132
|
+
// Persist rotation immediately; an API outage after renewal must not discard
|
|
133
|
+
// the new refresh token and strand the next invocation with the consumed one.
|
|
134
|
+
saveCredentials({
|
|
135
|
+
...creds, access_token: tokens.access_token,
|
|
136
|
+
refresh_token: tokens.refresh_token ?? creds.refresh_token,
|
|
137
|
+
token_type: typeof tokens.token_type === "string" ? tokens.token_type : creds.token_type,
|
|
138
|
+
expires_at: new Date(Date.now() + tokens.expires_in * 1000).toISOString(),
|
|
139
|
+
});
|
|
140
|
+
return tokens.access_token;
|
|
141
|
+
}
|
|
142
|
+
finally {
|
|
143
|
+
rmdirSync(lock);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
export async function getValidToken(signal) {
|
|
147
|
+
signal?.throwIfAborted();
|
|
148
|
+
if (process.env.ENTER_API_KEY)
|
|
149
|
+
return process.env.ENTER_API_KEY;
|
|
150
|
+
const creds = loadCredentials();
|
|
151
|
+
if (!creds?.access_token)
|
|
152
|
+
return "";
|
|
153
|
+
if (!needsRefresh(creds))
|
|
154
|
+
return creds.access_token;
|
|
155
|
+
// Cancelable callers use the same filesystem lock without canceling another
|
|
156
|
+
// caller's renewal. Once sent, renewal must finish and persist token rotation.
|
|
157
|
+
if (signal) {
|
|
158
|
+
const token = await refreshCredentials(signal);
|
|
159
|
+
signal.throwIfAborted();
|
|
160
|
+
return token;
|
|
161
|
+
}
|
|
162
|
+
if (!refreshing)
|
|
163
|
+
refreshing = refreshCredentials().finally(() => { refreshing = undefined; });
|
|
164
|
+
return refreshing;
|
|
165
|
+
}
|
package/dist/client.d.ts
CHANGED
|
@@ -1,11 +1,9 @@
|
|
|
1
|
+
export declare function setRequestTimeout(seconds: number): void;
|
|
1
2
|
export declare function setVerbose(v: boolean): void;
|
|
2
|
-
export declare function get(path: string, params?: Record<string, string
|
|
3
|
-
export declare function post(path: string, body?: unknown): Promise<unknown>;
|
|
3
|
+
export declare function get(path: string, params?: Record<string, string>, signal?: AbortSignal): Promise<unknown>;
|
|
4
|
+
export declare function post(path: string, body?: unknown, signal?: AbortSignal): Promise<unknown>;
|
|
4
5
|
export declare function del(path: string): Promise<unknown>;
|
|
5
|
-
export declare function put(path: string, body?: unknown): Promise<unknown>;
|
|
6
6
|
export declare function patch(path: string, body?: unknown): Promise<unknown>;
|
|
7
7
|
export declare function workGet(path: string, params?: Record<string, string>): Promise<unknown>;
|
|
8
8
|
export declare function workPost(path: string, body?: unknown): Promise<unknown>;
|
|
9
|
-
export declare function
|
|
10
|
-
export declare function workDel(path: string): Promise<unknown>;
|
|
11
|
-
export declare function getRaw(path: string): Promise<Response>;
|
|
9
|
+
export declare function getRaw(path: string): Promise<ArrayBuffer>;
|
package/dist/client.js
CHANGED
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
import { baseURL, workURL } from "./config.js";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import { getValidToken } from "./auth.js";
|
|
3
|
+
import { safeOutput } from "./safe-output.js";
|
|
4
|
+
import { APIError, RequestError } from "./errors.js";
|
|
5
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
4
6
|
const CODE_SUCCESS = 0;
|
|
5
7
|
let verbose = false;
|
|
8
|
+
let requestTimeoutMs;
|
|
9
|
+
export function setRequestTimeout(seconds) {
|
|
10
|
+
if (!Number.isFinite(seconds) || seconds <= 0 || seconds > 2147483)
|
|
11
|
+
throw new Error("--request-timeout must be a positive number of seconds");
|
|
12
|
+
requestTimeoutMs = seconds * 1000;
|
|
13
|
+
}
|
|
6
14
|
export function setVerbose(v) {
|
|
7
15
|
verbose = v;
|
|
8
16
|
}
|
|
@@ -16,61 +24,111 @@ async function request(method, path, options, base) {
|
|
|
16
24
|
const headers = {
|
|
17
25
|
"Content-Type": "application/json",
|
|
18
26
|
};
|
|
19
|
-
const
|
|
20
|
-
if (token)
|
|
21
|
-
headers["Authorization"] = `Bearer ${token}`;
|
|
22
|
-
const init = { method, headers };
|
|
27
|
+
const init = { method, headers, signal: options?.signal };
|
|
23
28
|
if (options?.body !== undefined) {
|
|
24
29
|
init.body = JSON.stringify(options.body);
|
|
25
30
|
}
|
|
26
31
|
if (verbose) {
|
|
27
32
|
console.error(`> ${method} ${url}`);
|
|
28
33
|
if (options?.body)
|
|
29
|
-
console.error(`> Body: ${JSON.stringify(options.body)}`);
|
|
30
|
-
}
|
|
31
|
-
const resp = await fetch(url, init);
|
|
32
|
-
if (verbose) {
|
|
33
|
-
console.error(`< ${resp.status} ${resp.statusText}`);
|
|
34
|
-
}
|
|
35
|
-
if (resp.status === 401) {
|
|
36
|
-
throw new Error("Authentication required. Run `enter login` or set ENTER_API_KEY environment variable.");
|
|
34
|
+
console.error(`> Body: ${JSON.stringify(safeOutput(options.body, { redactIdentifiers: true }))}`);
|
|
37
35
|
}
|
|
38
|
-
|
|
39
|
-
|
|
36
|
+
// A single deadline covers retries and reading the response body as well as
|
|
37
|
+
// connecting. Never replay writes after an ambiguous transport failure.
|
|
38
|
+
const readOnly = method === "GET" || (method === "POST" && path.endsWith("/thread/actions"));
|
|
39
|
+
const retryRead = readOnly && /\/thread\/(turns|actions|messages|tasks)$/.test(path);
|
|
40
|
+
const controller = new AbortController();
|
|
41
|
+
const abort = () => controller.abort(options?.signal?.reason);
|
|
42
|
+
options?.signal?.addEventListener("abort", abort, { once: true });
|
|
43
|
+
if (options?.signal?.aborted)
|
|
44
|
+
abort();
|
|
45
|
+
const timer = requestTimeoutMs === undefined ? undefined : setTimeout(() => controller.abort(), requestTimeoutMs);
|
|
46
|
+
init.signal = controller.signal;
|
|
40
47
|
try {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
48
|
+
const token = await getValidToken(controller.signal);
|
|
49
|
+
if (token)
|
|
50
|
+
headers["Authorization"] = `Bearer ${token}`;
|
|
51
|
+
for (let attempt = 0;; attempt++) {
|
|
52
|
+
let resp;
|
|
53
|
+
let text;
|
|
54
|
+
try {
|
|
55
|
+
resp = await fetch(url, init);
|
|
56
|
+
if (options?.raw && resp.ok && !resp.headers.get("content-type")?.includes("application/json")) {
|
|
57
|
+
return await resp.arrayBuffer();
|
|
58
|
+
}
|
|
59
|
+
text = await resp.text();
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
if (options?.signal?.aborted)
|
|
63
|
+
throw error;
|
|
64
|
+
const timedOut = controller.signal.aborted;
|
|
65
|
+
const cause = error.cause?.code;
|
|
66
|
+
if (retryRead && !timedOut && attempt < 1) {
|
|
67
|
+
await delay(200, undefined, { signal: controller.signal });
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
throw new RequestError(timedOut ? "REQUEST_TIMEOUT" : "NETWORK_ERROR", timedOut ? "Enter request exceeded its deadline." : `Could not reach Enter${cause ? ` (${cause})` : ""}. Check connectivity and proxy settings.`, readOnly, !readOnly, cause);
|
|
71
|
+
}
|
|
72
|
+
if (verbose) {
|
|
73
|
+
console.error(`< ${resp.status} ${resp.statusText}`);
|
|
74
|
+
}
|
|
75
|
+
if (resp.status === 401) {
|
|
76
|
+
throw new Error("Authentication required. Run `enter-cli login` or set ENTER_API_KEY environment variable.");
|
|
77
|
+
}
|
|
78
|
+
if (retryRead && [502, 503, 504].includes(resp.status) && attempt < 1) {
|
|
79
|
+
await delay(200, undefined, { signal: controller.signal });
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
let apiResp;
|
|
83
|
+
try {
|
|
84
|
+
apiResp = JSON.parse(text);
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
if (resp.status >= 400) {
|
|
88
|
+
throw new RequestError(`HTTP_${resp.status}`, `Enter returned HTTP ${resp.status}.`, readOnly && resp.status >= 500, !readOnly && resp.status >= 500);
|
|
89
|
+
}
|
|
90
|
+
throw new RequestError("INVALID_RESPONSE", "Enter returned a non-JSON response.", readOnly, !readOnly);
|
|
91
|
+
}
|
|
92
|
+
if (apiResp.code === undefined) {
|
|
93
|
+
// Endpoint returns raw JSON without the standard {code,message,data} envelope.
|
|
94
|
+
// Surface the parsed body as-is.
|
|
95
|
+
if (resp.status >= 400) {
|
|
96
|
+
throw new RequestError(`HTTP_${resp.status}`, `Enter returned HTTP ${resp.status}.`, readOnly && resp.status >= 500, !readOnly && resp.status >= 500);
|
|
97
|
+
}
|
|
98
|
+
if (options?.raw)
|
|
99
|
+
throw new RequestError("INVALID_RESPONSE", "Expected a binary download, but Enter returned JSON.", true);
|
|
100
|
+
return apiResp;
|
|
101
|
+
}
|
|
102
|
+
if (resp.status >= 500)
|
|
103
|
+
throw new RequestError(apiResp.code ? String(apiResp.code) : `HTTP_${resp.status}`, `Enter returned HTTP ${resp.status}${apiResp.message ? `: ${apiResp.message}` : ""}.`, readOnly, !readOnly);
|
|
104
|
+
if (apiResp.code !== CODE_SUCCESS) {
|
|
105
|
+
throw new APIError(apiResp.code, apiResp.message ?? "", apiResp.detail ?? "");
|
|
106
|
+
}
|
|
107
|
+
if (options?.raw)
|
|
108
|
+
throw new RequestError("INVALID_RESPONSE", "Expected a binary download, but Enter returned JSON.", true);
|
|
109
|
+
return apiResp.data;
|
|
46
110
|
}
|
|
47
|
-
return JSON.parse(text);
|
|
48
111
|
}
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
if (resp.status >= 400) {
|
|
53
|
-
throw new Error(`HTTP ${resp.status}: ${text}`);
|
|
112
|
+
catch (error) {
|
|
113
|
+
if (controller.signal.aborted && !options?.signal?.aborted) {
|
|
114
|
+
throw new RequestError("REQUEST_TIMEOUT", "Enter request exceeded its deadline.", readOnly, !readOnly);
|
|
54
115
|
}
|
|
55
|
-
|
|
116
|
+
throw error;
|
|
56
117
|
}
|
|
57
|
-
|
|
58
|
-
|
|
118
|
+
finally {
|
|
119
|
+
clearTimeout(timer);
|
|
120
|
+
options?.signal?.removeEventListener("abort", abort);
|
|
59
121
|
}
|
|
60
|
-
return apiResp.data;
|
|
61
122
|
}
|
|
62
|
-
export async function get(path, params) {
|
|
63
|
-
return request("GET", path, { params });
|
|
123
|
+
export async function get(path, params, signal) {
|
|
124
|
+
return request("GET", path, { params, signal });
|
|
64
125
|
}
|
|
65
|
-
export async function post(path, body) {
|
|
66
|
-
return request("POST", path, { body });
|
|
126
|
+
export async function post(path, body, signal) {
|
|
127
|
+
return request("POST", path, { body, signal });
|
|
67
128
|
}
|
|
68
129
|
export async function del(path) {
|
|
69
130
|
return request("DELETE", path);
|
|
70
131
|
}
|
|
71
|
-
export async function put(path, body) {
|
|
72
|
-
return request("PUT", path, { body });
|
|
73
|
-
}
|
|
74
132
|
export async function patch(path, body) {
|
|
75
133
|
return request("PATCH", path, { body });
|
|
76
134
|
}
|
|
@@ -81,60 +139,6 @@ export async function workGet(path, params) {
|
|
|
81
139
|
export async function workPost(path, body) {
|
|
82
140
|
return request("POST", path, { body }, workURL());
|
|
83
141
|
}
|
|
84
|
-
export async function workPatch(path, body) {
|
|
85
|
-
return request("PATCH", path, { body }, workURL());
|
|
86
|
-
}
|
|
87
|
-
export async function workDel(path) {
|
|
88
|
-
return request("DELETE", path, {}, workURL());
|
|
89
|
-
}
|
|
90
142
|
export async function getRaw(path) {
|
|
91
|
-
|
|
92
|
-
const headers = {};
|
|
93
|
-
const token = getToken();
|
|
94
|
-
if (token)
|
|
95
|
-
headers["Authorization"] = `Bearer ${token}`;
|
|
96
|
-
if (verbose)
|
|
97
|
-
console.error(`> GET ${url}`);
|
|
98
|
-
const resp = await fetch(url, { headers });
|
|
99
|
-
if (verbose)
|
|
100
|
-
console.error(`< ${resp.status} ${resp.statusText}`);
|
|
101
|
-
if (resp.status === 401) {
|
|
102
|
-
throw new Error("Authentication required. Run `enter login` or set ENTER_API_KEY environment variable.");
|
|
103
|
-
}
|
|
104
|
-
// Successful binary response — caller consumes the body.
|
|
105
|
-
if (resp.ok) {
|
|
106
|
-
// But: a JSON error envelope can come back with status 200 too. Peek the
|
|
107
|
-
// content-type; only treat as raw if it's not JSON.
|
|
108
|
-
const ct = resp.headers.get("content-type") ?? "";
|
|
109
|
-
if (!ct.includes("application/json"))
|
|
110
|
-
return resp;
|
|
111
|
-
const text = await resp.text();
|
|
112
|
-
try {
|
|
113
|
-
const apiResp = JSON.parse(text);
|
|
114
|
-
if (apiResp.code !== 0) {
|
|
115
|
-
throw new APIError(apiResp.code, apiResp.message, apiResp.detail);
|
|
116
|
-
}
|
|
117
|
-
// code === 0 with JSON body on a binary endpoint shouldn't happen; surface as raw text
|
|
118
|
-
throw new Error(`Unexpected JSON response on binary endpoint: ${text.slice(0, 200)}`);
|
|
119
|
-
}
|
|
120
|
-
catch (err) {
|
|
121
|
-
if (err instanceof APIError)
|
|
122
|
-
throw err;
|
|
123
|
-
throw new Error(`Failed to parse JSON response: ${text.slice(0, 200)}`);
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
// Non-2xx — try to parse the API error envelope so callers can distinguish
|
|
127
|
-
// VIP_REQUIRED, NOT_FOUND, etc. Fall back to raw text if it isn't JSON.
|
|
128
|
-
const text = await resp.text();
|
|
129
|
-
try {
|
|
130
|
-
const apiResp = JSON.parse(text);
|
|
131
|
-
if (apiResp.code !== undefined) {
|
|
132
|
-
throw new APIError(apiResp.code, apiResp.message ?? "", apiResp.detail ?? "");
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
catch (err) {
|
|
136
|
-
if (err instanceof APIError)
|
|
137
|
-
throw err;
|
|
138
|
-
}
|
|
139
|
-
throw new Error(`HTTP ${resp.status}: ${text || resp.statusText}`);
|
|
143
|
+
return request("GET", path, { raw: true });
|
|
140
144
|
}
|
package/dist/commands/config.js
CHANGED
|
@@ -1,25 +1,20 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
2
|
import { setConfig, getConfig, allSettings } from "../config.js";
|
|
3
|
-
import { print,
|
|
3
|
+
import { print, printResult, printTable, getFormat } from "../output.js";
|
|
4
4
|
export const configCmd = new Command("config").description("Manage CLI configuration");
|
|
5
5
|
configCmd
|
|
6
6
|
.command("set <key> <value>")
|
|
7
7
|
.description("Set a configuration value")
|
|
8
|
-
.action(async (key, value) => {
|
|
8
|
+
.action(async (key, value, _opts, cmd) => {
|
|
9
9
|
setConfig(key, value);
|
|
10
|
-
|
|
10
|
+
printResult(getFormat(cmd), { key, value }, `Set ${key} = ${value}`);
|
|
11
11
|
});
|
|
12
12
|
configCmd
|
|
13
13
|
.command("get <key>")
|
|
14
14
|
.description("Get a configuration value")
|
|
15
|
-
.action(async (key) => {
|
|
15
|
+
.action(async (key, _opts, cmd) => {
|
|
16
16
|
const val = getConfig(key);
|
|
17
|
-
|
|
18
|
-
printMessage(`${key}: (not set)`);
|
|
19
|
-
}
|
|
20
|
-
else {
|
|
21
|
-
printMessage(`${key}: ${val}`);
|
|
22
|
-
}
|
|
17
|
+
printResult(getFormat(cmd), { key, value: val || null }, val ? `${key}: ${val}` : `${key}: (not set)`);
|
|
23
18
|
});
|
|
24
19
|
configCmd
|
|
25
20
|
.command("list")
|
package/dist/commands/domain.js
CHANGED
|
@@ -1,10 +1,7 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
2
|
import * as client from "../client.js";
|
|
3
|
-
import { print,
|
|
3
|
+
import { print, printResult, printTable, getFormat } from "../output.js";
|
|
4
4
|
export const domainCmd = new Command("domain").description("Manage project domains");
|
|
5
|
-
function getFormat(cmd) {
|
|
6
|
-
return cmd.optsWithGlobals().output || "json";
|
|
7
|
-
}
|
|
8
5
|
domainCmd
|
|
9
6
|
.command("list <project_id>")
|
|
10
7
|
.description("List project domains")
|
|
@@ -37,9 +34,9 @@ domainCmd
|
|
|
37
34
|
.command("remove <project_id>")
|
|
38
35
|
.description("Remove a custom domain from project")
|
|
39
36
|
.requiredOption("--domain <name>", "Domain name to remove")
|
|
40
|
-
.action(async (id, opts) => {
|
|
37
|
+
.action(async (id, opts, cmd) => {
|
|
41
38
|
await client.del(`/v1/projects/${id}/domain?domain=${opts.domain}`);
|
|
42
|
-
|
|
39
|
+
printResult(getFormat(cmd), { removed: true, domain: opts.domain }, `Domain "${opts.domain}" removed.`);
|
|
43
40
|
});
|
|
44
41
|
domainCmd
|
|
45
42
|
.command("refresh <project_id>")
|