@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.
@@ -2,12 +2,12 @@ import { Command } from "commander";
2
2
  import { createServer } from "http";
3
3
  import { randomBytes, createHash } from "crypto";
4
4
  import { execSync } from "child_process";
5
- import { saveCredentials } from "../auth.js";
6
- import { printMessage } from "../output.js";
7
- const AUTH0_DOMAIN = "auth.enter.pro";
8
- const CLIENT_ID = "TZvnfnosSwQh9UmOTxmcWiIJ7CqFrsln";
5
+ import { saveCredentials, verifyAccessToken, OAUTH_TOKEN_URL, OAUTH_CLIENT_ID } from "../auth.js";
6
+ import { printResult, getFormat } from "../output.js";
7
+ const AUTH0_DOMAIN = "auth.converge.ai";
8
+ const CLIENT_ID = OAUTH_CLIENT_ID;
9
9
  const AUTHORIZE_URL = `https://${AUTH0_DOMAIN}/authorize`;
10
- const TOKEN_URL = `https://${AUTH0_DOMAIN}/oauth/token`;
10
+ const TOKEN_URL = OAUTH_TOKEN_URL;
11
11
  const AUDIENCE = "https://api.enter.pro";
12
12
  const SCOPE = "openid profile email offline_access";
13
13
  const REDIRECT_URI = "http://localhost:19820";
@@ -83,6 +83,7 @@ function startOAuthFlow() {
83
83
  }
84
84
  try {
85
85
  const tokens = await exchangeCodeForTokens(code, codeVerifier);
86
+ await verifyAccessToken(tokens.access_token);
86
87
  const expiresAt = new Date(Date.now() + tokens.expires_in * 1000).toISOString();
87
88
  saveCredentials({
88
89
  access_token: tokens.access_token,
@@ -93,7 +94,6 @@ function startOAuthFlow() {
93
94
  });
94
95
  res.writeHead(200, { "Content-Type": "text/html" });
95
96
  res.end(`<html><body><h2>Login Successful!</h2><p>You can close this window and return to the terminal.</p></body></html>`);
96
- printMessage("Login successful. Credentials saved.");
97
97
  clearTimeout(timeoutId);
98
98
  server.close();
99
99
  resolve();
@@ -117,8 +117,8 @@ function startOAuthFlow() {
117
117
  code_challenge_method: "S256",
118
118
  });
119
119
  const authUrl = `${AUTHORIZE_URL}?${params}`;
120
- printMessage("Opening browser for authentication...");
121
- printMessage(`If browser doesn't open, visit:\n${authUrl}\n`);
120
+ console.error("Opening browser for authentication...");
121
+ console.error(`If browser doesn't open, visit:\n${authUrl}\n`);
122
122
  openBrowser(authUrl);
123
123
  });
124
124
  timeoutId = setTimeout(() => {
@@ -130,14 +130,20 @@ function startOAuthFlow() {
130
130
  export const loginCmd = new Command("login")
131
131
  .description("Authenticate with Enter platform")
132
132
  .option("--api-key <key>", "Authenticate with a workspace API key instead of OAuth")
133
- .action(async (opts) => {
133
+ .action(async (opts, cmd) => {
134
134
  if (opts.apiKey) {
135
+ await verifyAccessToken(opts.apiKey);
135
136
  saveCredentials({
136
137
  access_token: opts.apiKey,
137
138
  token_type: "Bearer",
138
139
  });
139
- printMessage("API key saved. You are now authenticated.");
140
- return;
141
140
  }
142
- await startOAuthFlow();
141
+ else {
142
+ await startOAuthFlow();
143
+ }
144
+ const injected = Boolean(process.env.ENTER_API_KEY);
145
+ const message = injected
146
+ ? "Local credentials verified and saved. ENTER_API_KEY still takes precedence; this has not switched the active account. Change authentication in the host or unset ENTER_API_KEY to use the saved credentials."
147
+ : "Login successful. Credentials saved.";
148
+ printResult(getFormat(cmd), { credentials_saved: true, auth_source: injected ? "environment" : "local", ...(!injected ? { authenticated: true } : {}), message }, message);
143
149
  });
@@ -1,9 +1,13 @@
1
1
  import { Command } from "commander";
2
2
  import { clearCredentials } from "../auth.js";
3
- import { printMessage } from "../output.js";
3
+ import { printResult, getFormat } from "../output.js";
4
4
  export const logoutCmd = new Command("logout")
5
5
  .description("Clear stored credentials")
6
- .action(async () => {
6
+ .action(async (_opts, cmd) => {
7
7
  clearCredentials();
8
- printMessage("Logged out successfully.");
8
+ const injected = Boolean(process.env.ENTER_API_KEY);
9
+ const message = injected
10
+ ? "Local credentials cleared. ENTER_API_KEY still takes precedence; change or disconnect authentication in the host, or unset it in your shell, to switch accounts."
11
+ : "Stored credentials cleared. Logged out.";
12
+ printResult(getFormat(cmd), { logged_out: !injected, local_credentials_cleared: true, auth_source: injected ? "environment" : "none", message }, message);
9
13
  });
@@ -1,8 +1,12 @@
1
+ import { setTimeout as delay } from "node:timers/promises";
1
2
  import { Command } from "commander";
2
3
  import { writeFileSync } from "fs";
3
4
  import * as client from "../client.js";
4
5
  import { print, printMessage, printResult, printTable, pickList, getFormat } from "../output.js";
5
6
  import { pollUntil, TimeoutError } from "../poll.js";
7
+ import { reportThread, threadInteraction } from "./thread.js";
8
+ import { safeOutput } from "../safe-output.js";
9
+ import { RequestError } from "../errors.js";
6
10
  import { resolveLifecycleStatus } from "../lifecycle.js";
7
11
  export const projectCmd = new Command("project")
8
12
  .alias("proj")
@@ -61,7 +65,8 @@ projectCmd
61
65
  .action(async (id, _opts, cmd) => {
62
66
  const data = await client.get(`/v1/projects/${id}/detail`);
63
67
  const p = data;
64
- const lifecycle = resolveLifecycleStatus(p);
68
+ const project = unwrapProject(p);
69
+ const lifecycle = resolveLifecycleStatus(project);
65
70
  const enriched = { ...p, lifecycle_status: lifecycle };
66
71
  const format = getFormat(cmd);
67
72
  if (format !== "table") {
@@ -69,14 +74,14 @@ projectCmd
69
74
  return;
70
75
  }
71
76
  printTable(["Project ID", "Name", "Visibility", "Status", "Lifecycle", "Preview URL", "Workspace", "Updated"], [[
72
- String(p.project_id ?? ""),
73
- String(p.name ?? ""),
74
- String(p.visibility ?? ""),
75
- String(p.status ?? ""),
77
+ String(project.project_id ?? ""),
78
+ String(project.name ?? ""),
79
+ String(project.visibility ?? ""),
80
+ String(project.status ?? ""),
76
81
  lifecycle,
77
- String(p.preview_url ?? ""),
78
- String(p.workspace_id ?? ""),
79
- String(p.updated_at ?? ""),
82
+ String(project.preview_url ?? ""),
83
+ String(project.workspace_id ?? ""),
84
+ String(project.updated_at ?? ""),
80
85
  ]]);
81
86
  });
82
87
  projectCmd
@@ -89,6 +94,9 @@ projectCmd
89
94
  .option("--wait", "Wait until the first build completes")
90
95
  .option("--timeout <seconds>", "Timeout for --wait in seconds", "300")
91
96
  .action(async (id, opts, cmd) => {
97
+ const seconds = Number(opts.timeout);
98
+ if (opts.wait && (!Number.isFinite(seconds) || seconds <= 0 || seconds * 1000 > 2147483647))
99
+ throw new Error("--timeout must be a positive bounded number");
92
100
  const body = {};
93
101
  if (opts.name)
94
102
  body.name = opts.name;
@@ -99,38 +107,15 @@ projectCmd
99
107
  if (opts.planMode)
100
108
  body.plan_mode = true;
101
109
  const data = await client.post(`/v1/workspaces/${id}/projects`, body);
102
- if (!opts.wait) {
103
- print(getFormat(cmd), data);
104
- return;
105
- }
106
- const created = data;
110
+ const created = unwrapProject(data);
107
111
  const projectId = String(created.project_id ?? created.id ?? "");
108
- if (!projectId) {
109
- print(getFormat(cmd), data);
112
+ if (!projectId)
113
+ throw new Error("Creation response is missing project ID; creation outcome is unknown. Inspect project list before retrying.");
114
+ if (!opts.wait) {
115
+ print(getFormat(cmd), safeOutput({ ...data, submission_status: "accepted", ...threadInteraction(projectId) }));
110
116
  return;
111
117
  }
112
- console.error("Waiting for first build to complete...");
113
- const timeoutMs = parseInt(opts.timeout, 10) * 1000;
114
- const buildingStatuses = new Set(["initializing", "building"]);
115
- try {
116
- const result = await pollUntil(() => client.get(`/v1/projects/${projectId}/detail`), (d) => !buildingStatuses.has(String(d.status ?? "")), {
117
- intervalMs: 3000,
118
- timeoutMs,
119
- onTick: (elapsed) => {
120
- process.stderr.write(`\rWaiting... ${Math.round(elapsed / 1000)}s elapsed`);
121
- },
122
- });
123
- process.stderr.write("\n");
124
- const enriched = { ...result, lifecycle_status: resolveLifecycleStatus(result) };
125
- print(getFormat(cmd), enriched);
126
- }
127
- catch (err) {
128
- if (err instanceof TimeoutError) {
129
- console.error(`\nTimed out after ${opts.timeout}s. Project may still be building.`);
130
- process.exit(1);
131
- }
132
- throw err;
133
- }
118
+ await reportThread(projectId, { timeout: String(seconds), requireBuild: true, compact: true }, cmd, true);
134
119
  });
135
120
  projectCmd
136
121
  .command("rename <project_id> <new_name>")
@@ -150,83 +135,108 @@ projectCmd
150
135
  .command("download <project_id>")
151
136
  .description("Download project as zip file")
152
137
  .option("--out <path>", "Output file path")
153
- .action(async (id, opts) => {
138
+ .action(async (id, opts, cmd) => {
154
139
  const data = await client.getRaw(`/v1/projects/${id}/download`);
155
140
  const outPath = opts.out || `${id}.zip`;
156
- writeFileSync(outPath, Buffer.from(await data.arrayBuffer()));
157
- printMessage(`Project downloaded to ${outPath}`);
141
+ writeFileSync(outPath, Buffer.from(data));
142
+ printResult(getFormat(cmd), { path: outPath }, `Project downloaded to ${outPath}`);
158
143
  });
159
144
  projectCmd
160
145
  .command("publish <project_id>")
161
146
  .description("Publish a project (synchronous: waits for completion and verifies URL)")
162
147
  .option("--timeout <seconds>", "Timeout in seconds", "300")
163
148
  .action(async (id, opts, cmd) => {
164
- const initialDetail = await client.get(`/v1/projects/${id}/detail`);
165
- const initialProject = unwrapProject(initialDetail);
166
- const buildStatus = initialProject.build_status;
167
- const commitId = String(buildStatus?.commit_id ?? initialProject.commit ?? "");
168
- if (!commitId) {
169
- console.error("Error: project has no committed build yet (build_status.commit_id is empty).");
170
- console.error("Wait for the latest turn to finish, then retry.");
171
- process.exit(1);
172
- }
173
- await client.post(`/v1/projects/${id}/publish`);
174
- console.error("Waiting for publish to complete...");
175
- const timeoutMs = parseInt(opts.timeout, 10) * 1000;
149
+ const seconds = Number(opts.timeout);
150
+ if (!Number.isFinite(seconds) || seconds <= 0 || seconds * 1000 > 2147483647)
151
+ throw new Error("--timeout must be a positive bounded number");
152
+ const controller = new AbortController();
153
+ const timer = setTimeout(() => controller.abort(), seconds * 1000);
154
+ let submitted = false;
155
+ let published = false;
176
156
  try {
177
- const result = await pollUntil(() => client.get(`/v1/projects/${id}/detail`), (d) => {
157
+ const initialDetail = await client.get(`/v1/projects/${id}/detail`, undefined, controller.signal);
158
+ const initialProject = unwrapProject(initialDetail);
159
+ const buildStatus = initialProject.build_status;
160
+ const commitId = String(buildStatus?.commit_id ?? initialProject.commit ?? "");
161
+ if (!commitId) {
162
+ throw new Error("Project has no committed build yet. Wait for the latest turn to finish, then retry.");
163
+ }
164
+ const format = getFormat(cmd);
165
+ const human = format === "table";
166
+ controller.signal.throwIfAborted();
167
+ submitted = true;
168
+ await client.post(`/v1/projects/${id}/publish`, undefined, controller.signal);
169
+ if (human)
170
+ console.error("Waiting for publish to complete...");
171
+ const timeoutMs = seconds * 1000;
172
+ const result = await pollUntil(() => client.get(`/v1/projects/${id}/detail`, undefined, controller.signal), (d) => {
178
173
  const view = readPublishStatus(unwrapProject(d));
179
174
  // Done when the latest committed build has been published and nothing is queued.
180
175
  return view.lastPublishedCommit === commitId && view.unpublishedChanges === 0;
181
176
  }, {
177
+ signal: controller.signal,
182
178
  intervalMs: 3000,
183
179
  timeoutMs,
184
180
  onTick: (elapsed) => {
185
- process.stderr.write(`\rWaiting... ${Math.round(elapsed / 1000)}s elapsed`);
181
+ if (human)
182
+ process.stderr.write(`\rWaiting... ${Math.round(elapsed / 1000)}s elapsed`);
186
183
  },
187
184
  });
188
- process.stderr.write("\n");
185
+ if (human)
186
+ process.stderr.write("\n");
187
+ published = true;
189
188
  const project = unwrapProject(result);
190
189
  const view = readPublishStatus(project);
191
190
  const publishUrl = String(project.publish_url ?? "");
192
- const reachable = publishUrl ? await verifyUrlReachable(publishUrl) : false;
193
- if (publishUrl && !reachable) {
191
+ const reachable = publishUrl ? await verifyUrlReachable(publishUrl, 3, 2000, controller.signal) : false;
192
+ if (human && publishUrl && !reachable) {
194
193
  console.error(`Warning: ${publishUrl} did not return 200 within retries. The publish completed server-side but the URL may still be propagating.`);
195
194
  }
196
- printMessage(`Published commit ${commitId.slice(0, 7)} at ${view.lastPublishedAt}. URL: ${publishUrl}${reachable ? " (200 OK)" : ""}`);
197
- print(getFormat(cmd), { ...result, lifecycle_status: resolveLifecycleStatus(project) });
195
+ if (human)
196
+ printMessage(`Published commit ${commitId.slice(0, 7)} at ${view.lastPublishedAt}. URL: ${publishUrl}${reachable ? " (200 OK)" : ""}`);
197
+ print(format, { ...result, lifecycle_status: resolveLifecycleStatus(project), publish_reachable: reachable });
198
198
  }
199
199
  catch (err) {
200
- if (err instanceof TimeoutError) {
201
- console.error(`\nTimed out after ${opts.timeout}s. Publish may still be in progress.`);
202
- console.error(`Run "proj urls ${id}" to check current status.`);
203
- process.exit(1);
200
+ if (err instanceof TimeoutError || controller.signal.aborted) {
201
+ throw new RequestError("PUBLISH_TIMEOUT", `Publish observation exceeded its deadline. Check enter-cli project publish-status ${id} before retrying.`, false, submitted && !published);
204
202
  }
205
203
  throw err;
206
204
  }
205
+ finally {
206
+ clearTimeout(timer);
207
+ }
207
208
  });
208
209
  // HEAD-first URL probe with a small wall-clock budget. Falls back to GET when
209
210
  // HEAD isn't allowed (some hosts return 405). Used by `proj publish` (after
210
211
  // publish completes) and `proj urls` (to determine the recommended URL).
211
- async function verifyUrlReachable(url, attempts = 3, perAttemptMs = 2000) {
212
+ async function verifyUrlReachable(url, attempts = 3, perAttemptMs = 2000, signal) {
212
213
  for (let i = 0; i < attempts; i++) {
214
+ signal?.throwIfAborted();
213
215
  const ctrl = new AbortController();
216
+ const abort = () => ctrl.abort();
217
+ signal?.addEventListener("abort", abort, { once: true });
214
218
  const t = setTimeout(() => ctrl.abort(), perAttemptMs);
215
219
  try {
216
220
  let res = await fetch(url, { method: "HEAD", signal: ctrl.signal });
217
221
  // Some hosts reject HEAD (405 / 501) — retry once with GET on this attempt.
218
222
  if (res.status === 405 || res.status === 501) {
223
+ await res.body?.cancel();
219
224
  res = await fetch(url, { method: "GET", signal: ctrl.signal });
220
225
  }
221
- clearTimeout(t);
226
+ await res.body?.cancel();
222
227
  if (res.status === 200)
223
228
  return true;
224
229
  }
225
230
  catch {
231
+ signal?.throwIfAborted();
232
+ }
233
+ finally {
234
+ ctrl.abort();
226
235
  clearTimeout(t);
236
+ signal?.removeEventListener("abort", abort);
227
237
  }
228
238
  if (i < attempts - 1)
229
- await new Promise((r) => setTimeout(r, 1000));
239
+ await delay(1000, undefined, { signal });
230
240
  }
231
241
  return false;
232
242
  }
@@ -294,8 +304,13 @@ projectCmd
294
304
  .option("--workspace-id <id>", "Target workspace ID for remix")
295
305
  .action(async (id, opts, cmd) => {
296
306
  const body = {};
297
- if (opts.workspaceId)
298
- body.workspace_id = opts.workspaceId;
307
+ if (opts.workspaceId !== undefined) {
308
+ const workspaceId = Number(opts.workspaceId);
309
+ if (!/^\d+$/.test(opts.workspaceId) || !Number.isSafeInteger(workspaceId) || workspaceId <= 0) {
310
+ throw new Error("--workspace-id must be a positive safe integer");
311
+ }
312
+ body.workspace_id = workspaceId;
313
+ }
299
314
  const data = await client.post(`/v1/projects/${id}/remix`, body);
300
315
  print(getFormat(cmd), data);
301
316
  });
@@ -411,9 +426,9 @@ mcpCmd
411
426
  mcpCmd
412
427
  .command("delete <project_id> <server_id>")
413
428
  .description("Delete an MCP server")
414
- .action(async (id, serverId) => {
429
+ .action(async (id, serverId, _opts, cmd) => {
415
430
  await client.del(`/v1/projects/${id}/mcp/servers/${serverId}`);
416
- printMessage(`MCP server ${serverId} deleted.`);
431
+ printResult(getFormat(cmd), { deleted: true, server_id: serverId }, `MCP server ${serverId} deleted.`);
417
432
  });
418
433
  projectCmd.addCommand(mcpCmd);
419
434
  // Project skills subcommand group
@@ -0,0 +1,2 @@
1
+ import type { Command } from "commander";
2
+ export declare function registerThreadTasks(parent: Command): void;
@@ -0,0 +1,23 @@
1
+ import * as client from "../client.js";
2
+ import { getFormat, print, printTable } from "../output.js";
3
+ // Queue payloads can contain prompts, attachments and credentials. Expose only
4
+ // the metadata needed to diagnose accepted tasks that have no turn yet.
5
+ export function registerThreadTasks(parent) {
6
+ parent.command("tasks <project_id>")
7
+ .description("List queued/running tasks without exposing prompts or credentials")
8
+ .option("--chat-id <id>", "Scope the queue lookup to a chat")
9
+ .action(async (projectId, opts, cmd) => {
10
+ const data = await client.get(`/v1/projects/${projectId}/thread/tasks`, opts.chatId ? { chat_id: opts.chatId } : undefined);
11
+ if (!Array.isArray(data?.tasks))
12
+ throw new Error("Invalid task queue response: expected tasks array");
13
+ const tasks = data.tasks.map(task => Object.fromEntries(["id", "task_type", "task_status", "created_at", "project_id", "chat_id"]
14
+ .filter(key => typeof task[key] === "string")
15
+ .map(key => [key, task[key]])));
16
+ if (getFormat(cmd) === "table") {
17
+ printTable(["ID", "Type", "Status", "Created", "Chat"], tasks.map(task => ["id", "task_type", "task_status", "created_at", "chat_id"].map(key => String(task[key] ?? ""))));
18
+ }
19
+ else {
20
+ print(getFormat(cmd), { project_id: projectId, ...(opts.chatId ? { chat_id: opts.chatId } : {}), tasks, total: tasks.length });
21
+ }
22
+ });
23
+ }
@@ -1,2 +1,56 @@
1
1
  import { Command } from "commander";
2
2
  export declare const threadCmd: Command;
3
+ type InteractionOptions = {
4
+ taskId?: string;
5
+ chatId?: string;
6
+ turn?: number;
7
+ requireBuild?: boolean;
8
+ };
9
+ export declare function threadInteraction(id: string, { taskId, chatId, turn, requireBuild }?: InteractionOptions): {
10
+ workflow: {
11
+ actions?: {
12
+ id: unknown;
13
+ kind: unknown;
14
+ decision: string;
15
+ required_fields: {};
16
+ submit_command: unknown;
17
+ }[] | undefined;
18
+ build?: {
19
+ commit: {} | null;
20
+ matches_task: boolean;
21
+ success: {} | null;
22
+ } | undefined;
23
+ observation: {
24
+ timed_out: boolean;
25
+ retrying: boolean;
26
+ interrupted: boolean;
27
+ };
28
+ reason?: string | undefined;
29
+ state: string;
30
+ task: {
31
+ id: {} | null;
32
+ turn: {} | null;
33
+ chat_id: {} | null;
34
+ };
35
+ next_action: string;
36
+ };
37
+ monitoring_required: boolean;
38
+ wait_command: string;
39
+ watch_command: string;
40
+ status_command: string;
41
+ follow_up_command: string;
42
+ instructions: string;
43
+ };
44
+ type MonitorOptions = {
45
+ turn?: string;
46
+ taskId?: string;
47
+ chatId?: string;
48
+ cursor?: string;
49
+ timeout: string;
50
+ transport?: string;
51
+ full?: boolean;
52
+ compact?: boolean;
53
+ requireBuild?: boolean;
54
+ };
55
+ export declare function reportThread(id: string, opts: MonitorOptions, cmd: Command, wait: boolean, watch?: boolean): Promise<void>;
56
+ export {};