Skip to content

feat: add delega init onboarding command#6

Merged
ryanmcmillan merged 8 commits intomainfrom
feature/npx-init
Mar 21, 2026
Merged

feat: add delega init onboarding command#6
ryanmcmillan merged 8 commits intomainfrom
feature/npx-init

Conversation

@ryanmcmillan
Copy link
Member

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.

@greptile-apps
Copy link

greptile-apps bot commented Mar 21, 2026

Greptile Summary

This PR adds delega init, a 30-second interactive onboarding command that supports both the hosted (api.delega.dev) path (email signup → verification → agent creation → demo task) and a self-hosted Docker path (port selection → docker-compose.yml generation → health polling → local agent bootstrap → demo task). The command is well-structured and thoughtfully handles many edge cases: interactive-terminal enforcement, SIGINT/EOF cancellation, Docker Compose plugin detection, exponential-backoff health polling, per-request fetch timeouts, and retry loops for bad port and verification-code input.

Key observations:

  • Several issues raised in earlier rounds have been resolved: the health-check timeout is now a 90-second exponential-backoff loop; the build script handles template copy failures gracefully; docker compose version is checked instead of just docker --version; the self-hosted MCP config now includes DELEGA_API_URL; and credentials are no longer persisted before the demo task succeeds.
  • requestJson unconditionally overwrites any signal field in the caller-provided RequestInit with AbortSignal.timeout(…), so a future caller passing a cancellation signal would have it silently ignored.
  • DELEGA_DOCKER_TAG is hardcoded to "1.0.0" with no programmatic link to the package version, creating a risk of version drift across CLI releases.
  • No client-side email format check is performed before the signup API call, meaning obviously invalid input results in a raw server error rather than an immediate helpful message.
  • waitForHealthy can exceed its stated 90-second deadline by up to FETCH_TIMEOUT_MS (15 s) because the guard is evaluated before issuing each fetch, not clamped to the remaining budget.

Confidence Score: 3/5

  • Safe to merge with minor issues; no critical bugs or security problems introduced.
  • The PR is a large new feature (665 lines) with solid overall structure. All previously-raised critical issues (health-check timeout, partial-setup on failure, MCP config, Docker Compose check) appear addressed. Remaining issues are style-level: a silently-dropped abort signal in requestJson, a hardcoded Docker image version constant, missing email validation, and a minor health-check timing overshoot. None of these will cause failures on the happy path.
  • src/commands/init.ts warrants the most attention — it is the entire feature and contains all of the noted issues.

Important Files Changed

Filename Overview
src/commands/init.ts Large new 665-line file implementing the interactive onboarding flow. Well structured overall; many potential edge cases are handled (TTY check, timeouts, Docker Compose check, retry loops for port input and verification codes). Minor issues remain: requestJson silently drops any caller-provided abort signal, DELEGA_DOCKER_TAG is hardcoded without a tie to the package version, no client-side email validation, and the health-check loop can technically run ~15 s past its stated 90-second deadline.
scripts/copy-templates.mjs Short build script that copies src/templates/ into dist/templates/ after TypeScript compilation. Includes a try/catch with a descriptive error message and process.exit(1) on failure. Safe to merge.
src/templates/docker-compose.yml New Docker Compose template. Port is bound to 127.0.0.1 (loopback only — good), image tag is a placeholder __DELEGA_VERSION__ (resolved at runtime to DELEGA_DOCKER_TAG), and a named volume is used for persistence. No significant issues.
src/index.ts One-line change to register initCommand. No issues.
package.json Build script extended to run scripts/copy-templates.mjs after tsc. Straightforward and correct.

Sequence Diagram

sequenceDiagram
    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
Loading

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).
@ryanmcmillan
Copy link
Member Author

@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
@ryanmcmillan
Copy link
Member Author

@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
@ryanmcmillan
Copy link
Member Author

@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.
@ryanmcmillan
Copy link
Member Author

@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);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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:

Suggested change
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)
@ryanmcmillan
Copy link
Member Author

@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)
@ryanmcmillan
Copy link
Member Author

@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) });
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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";
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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.

Comment on lines +469 to +472
const email = await promptText("Your email: ");
if (!email) {
throw new UserFacingError("Email is required.");
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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:

Suggested change
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.");
}

Comment on lines +384 to +393
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.
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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.
@ryanmcmillan ryanmcmillan merged commit c9c8a3b into main Mar 21, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant