@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 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
- writeFileSync(credentialsPath(), JSON.stringify(creds, null, 2), {
10
- mode: 0o600,
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
- // ignore if file doesn't exist
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>): Promise<unknown>;
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 workPatch(path: string, body?: unknown): Promise<unknown>;
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 { getToken } from "./auth.js";
3
- import { APIError } from "./errors.js";
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 token = getToken();
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
- const text = await resp.text();
39
- let apiResp;
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
- apiResp = JSON.parse(text);
42
- }
43
- catch {
44
- if (resp.status >= 400) {
45
- throw new Error(`HTTP ${resp.status}: ${text}`);
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
- if (apiResp.code === undefined) {
50
- // Endpoint returns raw JSON without the standard {code,message,data} envelope.
51
- // Surface the parsed body as-is.
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
- return apiResp;
116
+ throw error;
56
117
  }
57
- if (apiResp.code !== CODE_SUCCESS) {
58
- throw new APIError(apiResp.code, apiResp.message ?? "", apiResp.detail ?? "");
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
- const url = `${baseURL()}${path}`;
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
  }
@@ -1,25 +1,20 @@
1
1
  import { Command } from "commander";
2
2
  import { setConfig, getConfig, allSettings } from "../config.js";
3
- import { print, printMessage, printTable } from "../output.js";
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
- printMessage(`Set ${key} = ${value}`);
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
- if (!val) {
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")
@@ -1,10 +1,7 @@
1
1
  import { Command } from "commander";
2
2
  import * as client from "../client.js";
3
- import { print, printMessage, printTable } from "../output.js";
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
- printMessage(`Domain "${opts.domain}" removed.`);
39
+ printResult(getFormat(cmd), { removed: true, domain: opts.domain }, `Domain "${opts.domain}" removed.`);
43
40
  });
44
41
  domainCmd
45
42
  .command("refresh <project_id>")