feat: add delega init onboarding command#6
Conversation
Greptile SummaryThis PR adds Key observations:
Confidence Score: 3/5
Important Files Changed
Sequence DiagramsequenceDiagram
participant User
participant CLI as delega init
participant HostedAPI as api.delega.dev
participant Docker
participant LocalAPI as localhost:PORT
User->>CLI: npx delega init
alt Hosted path
CLI->>User: Prompt email
User->>CLI: email@example.com
CLI->>HostedAPI: POST /signup { email, name }
HostedAPI-->>CLI: 201 + agent.api_key
CLI->>User: Prompt 6-digit code (up to 3 attempts)
User->>CLI: 123456
CLI->>HostedAPI: POST /verify { email, code }
HostedAPI-->>CLI: 200 OK
CLI->>HostedAPI: POST /v1/tasks (demo task)
HostedAPI-->>CLI: 201 task
CLI->>CLI: persistApiKey() + saveConfig()
CLI->>User: Print API key box + MCP config
else Self-hosted path
CLI->>CLI: ensureDockerComposeInstalled()
CLI->>User: Prompt port [18890]
User->>CLI: port number
CLI->>CLI: writeComposeFile(port)
CLI->>Docker: docker compose up -d
Docker-->>CLI: containers starting (image pull if needed)
loop Poll /health (90s timeout, exp backoff)
CLI->>LocalAPI: GET /health
LocalAPI-->>CLI: 200 OK
end
CLI->>LocalAPI: POST /api/agents { name: "my-agent" }
LocalAPI-->>CLI: 201 + api_key
CLI->>LocalAPI: POST /api/tasks (demo task)
LocalAPI-->>CLI: 201 task
CLI->>CLI: persistApiKey() + saveConfig()
CLI->>User: Print API key box + MCP config
end
Last reviewed commit: "fix: Greptile round ..." |
- waitForHealthy: 5×2s (~8s) → exponential backoff up to 90s with progress indicator. First-time Docker pulls can take 30-120s. - Remove unused promptSecret function (login.ts has its own copy).
|
@greptile please re-review — fixed both P1 (health check timeout bumped to 90s with exponential backoff) and P2 (removed dead promptSecret function). |
…F handling
- P0: Include DELEGA_API_URL in MCP config when self-hosted so
users don't get a broken config pointing at the hosted API
- P2: Add AbortSignal.timeout(15s) to requestJson and health-check
fetch to prevent indefinite hangs on unresponsive servers
- P2: Handle EOF (Ctrl+D) in promptText via rl.on('close') to
reject with InitCancelledError instead of hanging
|
@greptile re-review — commit 3d70a44 fixes all 5 issues from your previous review. Please review the latest commit. |
…sive template copy
- OTP verification: retry up to 3 attempts on wrong code instead of
locking user out (account exists server-side, reinit gets 409)
- parsePort: reject non-digit input like '8080x' that parseInt silently
truncates; use regex + Number() for strict validation
- copy-templates.mjs: use fs.cpSync({recursive: true}) to handle
subdirectories instead of silently skipping them
|
@greptile re-review — commit 329fb19 fixes all 3 remaining issues: OTP retry loop, strict port validation, recursive template copy. |
- P1: Move createDemoTask BEFORE storeApiKey/saveApiConfig so nothing is persisted to disk until the API confirms connectivity. On failure, the API key is printed to stderr so the user can recover manually. - Self-reviewed: walked every failure path in init.ts. Network errors, timeouts, SIGINT, EOF, disk write failures — all leave recoverable state. No orphaned config, no lost keys.
|
@greptile re-review — commit 9544250 fixes P1 credential persistence ordering. Self-reviewed all failure paths. |
| console.log(chalk.dim("Creating your first admin agent...")); | ||
|
|
||
| const agent = await bootstrapLocalAgent(apiBaseUrl); | ||
| return finalizeSetup(rawApiUrl, agent.api_key as string, rawApiUrl); |
There was a problem hiding this comment.
Self-hosted "Dashboard" URL points to the raw API endpoint
finalizeSetup is called with rawApiUrl for both the apiUrl and dashboardUrl arguments (e.g. http://localhost:18890). This URL is then surfaced to the user in printSuccess as:
Dashboard: http://localhost:18890
But http://localhost:18890 is the API root, not a web dashboard. Opening that URL in a browser will return API JSON or a 404, which is confusing immediately after a successful setup.
Compare with the hosted path where dashboardUrl = HOSTED_DASHBOARD_URL = "https://delega.dev/dashboard", which is a real, specific path.
If the Docker container exposes a dashboard at a known sub-path (e.g. /dashboard), pass that path here. If there is no web UI in the self-hosted image, omit the dashboard line for self-hosted instead of showing a non-functional link:
| return finalizeSetup(rawApiUrl, agent.api_key as string, rawApiUrl); | |
| return finalizeSetup(rawApiUrl, agent.api_key as string, `${rawApiUrl}/dashboard`); |
- P1: Check `docker compose version` instead of `docker --version` so Linux users without Compose plugin get a correct error message - P2: Port prompt now retries on invalid input instead of aborting - P2: Dashboard URL omitted for self-hosted (no web UI in Docker image) - P2: Docker image pinned to v1.0.0 via DELEGA_DOCKER_TAG constant (template uses __DELEGA_VERSION__ placeholder)
|
@greptile re-review — commit 189c144 fixes all 4 issues: Compose plugin check, port retry loop, no dashboard URL for self-hosted, pinned Docker image tag. Full adversarial self-review completed. |
…or handling - P1: Bind Docker port to 127.0.0.1 only — prevents API exposure on all network interfaces - P2: copy-templates.mjs wraps cpSync in try/catch with descriptive error and process.exit(1)
|
@greptile re-review — commit bee195a. Docker port bound to 127.0.0.1, build script error handling added. |
| ): Promise<{ response: Response; data: T | ApiErrorResponse }> { | ||
| let response: Response; | ||
| try { | ||
| response = await fetch(url, { ...init, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }); |
There was a problem hiding this comment.
signal from caller is silently dropped
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) is placed after the ...init spread, so it unconditionally overwrites any signal property a caller passes in init. Currently no callers supply a signal, so there is no bug today — but if someone later needs to propagate a cancellation signal (e.g. an AbortController wired to Ctrl+C), it will be silently ignored and only the timeout will take effect.
Consider either combining the signals or documenting that caller-provided signals are not respected:
const callerSignal = (init as RequestInit & { signal?: AbortSignal }).signal;
const timeoutSignal = AbortSignal.timeout(FETCH_TIMEOUT_MS);
const signal = callerSignal
? AbortSignal.any([callerSignal, timeoutSignal])
: timeoutSignal;
response = await fetch(url, { ...init, signal });(AbortSignal.any is available in Node ≥ 20 — within the ">=18.0.0" range for projects that have already upgraded.)
| } from "../config.js"; | ||
| import { printBanner } from "../ui.js"; | ||
|
|
||
| const DELEGA_DOCKER_TAG = "1.0.0"; |
There was a problem hiding this comment.
Hardcoded
DELEGA_DOCKER_TAG risks version drift
DELEGA_DOCKER_TAG is a hardcoded string "1.0.0" with no tie to the CLI's own package.json version. When a future CLI release adds API fields or changes behaviour that requires a matching server update, both this constant and package.json must be bumped together in the same commit — but there is no build-time check to enforce that.
Consider reading the intended server version from package.json (or a dedicated field like "engineVersion") at build time and embedding it as a generated constant, so the two are always kept in sync automatically.
| const email = await promptText("Your email: "); | ||
| if (!email) { | ||
| throw new UserFacingError("Email is required."); | ||
| } |
There was a problem hiding this comment.
No client-side email format validation
Only emptiness is checked before sending the email to the API. An obviously malformed string like "not-an-email" or "@" passes through and produces whatever error message the server returns — which may be a raw validation message rather than a user-friendly hint.
Adding a minimal regex guard before the API call avoids a round-trip and gives the user a clear prompt to retry:
| const email = await promptText("Your email: "); | |
| if (!email) { | |
| throw new UserFacingError("Email is required."); | |
| } | |
| const email = await promptText("Your email: "); | |
| if (!email) { | |
| throw new UserFacingError("Email is required."); | |
| } | |
| if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { | |
| throw new UserFacingError("Please enter a valid email address."); | |
| } |
| while (Date.now() - startTime < maxWaitMs) { | ||
| attempt += 1; | ||
| try { | ||
| const response = await fetch(healthUrl, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }); | ||
| if (response.ok) { | ||
| return; | ||
| } | ||
| } catch { | ||
| // Keep polling until the service responds or we run out of time. | ||
| } |
There was a problem hiding this comment.
Actual maximum wait can exceed
maxWaitMs by up to FETCH_TIMEOUT_MS
The while condition guards loop entry (Date.now() - startTime < maxWaitMs), but the fetch call that immediately follows can itself block for up to FETCH_TIMEOUT_MS (15 s) beyond that deadline. In the worst case the function waits 90 s + 15 s = 105 s before throwing.
This is minor in practice, but the error message claims a 90-second timeout, which is misleading. The simplest fix is to check remaining time before issuing the fetch and break early if not enough time is left:
while (Date.now() - startTime < maxWaitMs) {
attempt += 1;
const remaining = maxWaitMs - (Date.now() - startTime);
try {
const response = await fetch(healthUrl, {
signal: AbortSignal.timeout(Math.min(FETCH_TIMEOUT_MS, remaining)),
});
if (response.ok) {
return;
}
} catch {
// Keep polling until the service responds or we run out of time.
}…ccess Default to 0.0.0.0 so other machines can reach the API. Added comment showing how to restrict to localhost if desired.
Interactive onboarding flow for npx delega init. Supports hosted (api.delega.dev) and self-hosted (Docker) paths. 30-second time-to-value: email signup → verify → agent created → demo task → MCP config. Closes #394.