Skip to content

Latest commit

 

History

History
643 lines (534 loc) · 36 KB

ci.md

File metadata and controls

643 lines (534 loc) · 36 KB

ci — the appliance at the command line

Private & Confidential — Property of Lifescope Inc. Do not distribute.

Renamed. This binary was ci-import and this file was docs/cli/ci-import.md. Both names still work: ci-import is kept as an alias bin that prints a deprecation notice to stderr — so ci-import runs --json | jq keeps parsing — and then runs the identical program. Every subcommand, flag, output and exit code is unchanged; only the name is shorter. See "Where it lives" below.

CI-Import-Tools shipped a ci-import binary. When the importer was consolidated into CI-Server the MCP half came across — six tools in src/domains/tools/tools/Import*Tool.ts, served through the shared tool registry at @Controller('mcp') — and the command line did not. This is that command line, rebuilt against the domain as it now exists, and since grown past it.

The upstream repo is retired: it was renamed ZARCHIVED-CI-Import-Tools (the ZARCHIVED- prefix is the retirement signal — GitHub still reports isArchived: false for it). The import commands below drive CI-Server's own backend/apps/api/src/domains/import/.

It is not a revival of scripts/cli/ci-server.sh (PR #1420, reverted in 7ae057916). That was a hub-setup / portal-registration / app-lifecycle wizard and had nothing to do with importing.

The old name had stopped being true, which is why it changed. stats, doctor and backfill are appliance-wide, memory (see "Memory inference setup" below) configures Companion Memory's inference backend, and peers/grants/asks (see "Peers, grants and asks" below) put the interpersonal-ask feature — one Companion answering another's owner's question, under consent the owner grants in advance — at the command line too. Those three share this binary, this credential, and this output/error convention, but talk to the appliance over GraphQL rather than REST, which is why that section explains a second, small client rather than a second binary.


Quick start

export CI_SERVER_URL=http://localhost:3030
export CI_API_KEY=…            # web UI → Settings → API keys
yarn workspace @ci/cli dev datasources

Where it lives

The CLI is its own workspace, backend/apps/cli (@ci/cli), and its only dependency is commander. It used to be backend/apps/api/src/domains/import/cli and to ship as a bin from @ci/be-api, which meant obtaining it required compiling the whole NestJS application and its ~60 production dependencies. It does not import @ci/be-api or NestJS at all any more.

How Command
From a checkout yarn workspace @ci/cli dev <args> (tsx, no build)
Built here yarn workspace @ci/cli build → backend/apps/cli/dist/ci.cjs
Installed ci <args> — npm i -g @ci/cli

build bundles to one file with tsup, commander included, so dist/ci.cjs requires nothing but Node's own built-ins and runs anywhere node does — no monorepo, no node_modules, no install step. Two bins are declared: ci, and ci-import as the deprecated alias described at the top of this file.

Setting Flag Environment Default
Appliance URL --server CI_SERVER_URL http://localhost:3030
Credential --api-key CI_API_KEY, CI_SERVER_API_KEY none

Exit codes: 0 success, 2 a failure you can fix (bad path, missing key, unreachable server, unknown stage), 1 a failure the appliance owns (5xx, an unexpected exception — which also prints a stack). doctor overloads 1 with one more meaning: its gate tripped — see its section below.


It is an HTTP client, not an embedded server

Every command is a call to a running CI-Server. This is the single decision the rest of the design follows from, and the alternative — booting a Nest standalone context and driving ImportRunService in-process — is worse in three specific ways:

  1. It would run a second set of workers. An import is durable stages on the four per-kind pg-boss queues — import-unpack, import-parse, import-media, import-push (IMPORT_STAGE_QUEUE_BY_KIND in domains/import/constants.ts) — consumed by ImportStageListener. Registering ImportModule in a CLI process starts those listeners there too, racing the server's workers for the same stage claims. Going through the API means a run started here is the same ImportRunService.startRun call the web wizard makes, in the same process, against the same queues — not an equivalent one.
  2. It would need the appliance's credentials. Postgres, MinIO, Ollama. On a machine with several worktree stacks up, "which database am I on?" is answered by a port lookup that can silently fall back to a .env pointing somewhere else. An HTTP URL is unambiguous.
  3. It would resolve paths against the wrong filesystem. The API may be in a container; the CLI is on the host. Asking the server about a path keeps the answer honest — see the trust story below.

The trust story, decided rather than inherited

Upstream, this question did not exist: the importer ran on your own laptop under your own account, and "import this path" meant a path you already had. Here it is an appliance, and CI_IMPORT_ROOTS exists precisely so an API caller cannot turn "import" into a filesystem read primitive.

The CLI gets no exemption. It authenticates with an ordinary api key and has exactly the authority any other API client has. Concretely:

  • recognize <path> asks about the appliance's disk and stays inside the caller's own inbox subtree, plus CI_IMPORT_ROOTS when the key belongs to the appliance administrator. A household member's key never reaches the configured roots. A path outside them is refused with the remedy, not with a stack trace.
  • import <file> uploads the bytes — the same multipart route the browser uses — so it needs no configured root at all and works for any file this machine can read. That is the answer for most people, and it is strictly safer than widening the gate.
  • inbox consumes only from the caller's own subtree of the inbox, which the importer owns. The subtree is what stops one account importing — and thereby consuming — an archive another account dropped on the shared mount.

The one cost is that a 40GB archive already visible to the server still travels over HTTP rather than being read in place. Over localhost that is disk speed; for anything larger the inbox is the route, and it moves nothing at all.

--wait, and why the output looks like that

--wait prints a line when something changes — a stage starts, the percentage moves, the run closes — rather than redrawing on a timer. A redrawn progress bar becomes thousands of lines of escape codes in a CI log, and this reads the same piped as it does in a terminal.


Commands

Memory inference setup

memory is the first-run companion to the existing import and GraphQL surfaces. It configures or verifies the inference backend used by Companion Memory; it does not import records and it does not replace CI-Hub's app setup flow.

ci memory status
ci memory check
ci memory configure --backend ollama --port 11434
ci memory configure --backend openai-compatible --port 8000 --backend-api-key '…'
  • memory status [--json] prints the effective backend, endpoint, management mode, and whether a credential is configured. Backend credentials are write-only and never appear in output.
  • memory check [--json] asks the Memory API process to contact the current backend and list models. This catches the host-localhost / container-network mismatch a shell-side curl misses. It exits 2 when the backend cannot be reached or rejects the request.
  • memory configure --backend <ollama|openai-compatible> --port <port> [--backend-api-key <key>|--clear-api-key] [--json] updates an appliance-owned endpoint. --api-key remains the credential for the CI-Server API; --backend-api-key is the separate write-only inference credential. Hub/host-managed endpoints refuse this mutation with 409 so a CLI cannot quietly detach Memory from Hub.

Run memory check before selecting a model. A reachable server with no exposed models is not ready for chat. Full setup paths and troubleshooting are in Companion Memory setup.

mcp serve --stdio

Bridges newline-delimited JSON-RPC 2.0 on stdin/stdout to the appliance's Streamable HTTP /mcp endpoint (with automatic fallback to /api/mcp when behind a gateway prefix). Used by MCP hosts that spawn a local subprocess rather than speaking Streamable HTTP directly (Claude Desktop stdio connectors, Cursor, Goose, MCP Inspector).

CI_SERVER_URL=https://ci-memory.example.com CI_API_KEY=… ci mcp serve --stdio

Preserves the Mcp-Session-Id header across requests, unpacks both application/json and text/event-stream responses onto stdout, and sends a DELETE to close the remote MCP session when stdin closes. See docs/mcp/clients.md.

datasources [--catalog] [--json]

The registered importers — 71 of them (datasources/index.ts is the barrel; one registerDataSource call per file). Three registered ids differ from their filename and the id is what matters, because it is what staged_items.data_source records and what --only / --skip match on: applecontacts.ts registers apple_contacts, contactlist.ts registers contact_list, and googlevoice.ts registers google_voice. --catalog switches to the authored services list instead — several services share the generic parser, and one whose importer is not written yet still appears (ready blank) rather than being invisible.

recognize <path> [--json]

Asks every parser what it makes of a path, importing nothing. This is the one capability the REST surface did not have: everywhere else an archive is opaque bytes until a run unpacks it, so runs/:runId/recognized reports what the parse stage found — after the import.

Adding it meant one new endpoint, POST /import/recognize. It shares its implementation verbatim with the import.recognize_path MCP tool (domains/import/datasources/recognizePath.ts), so the two can never come to different conclusions about the same folder. POST rather than GET because the argument is a filesystem path, and a query string ends up in access logs.

import <path...> [--label] [--stages] [--wait] [--timeout] [--json]

Uploads archives from this machine and runs them through unpack → parse → media → push.

Per-path isolation, like the inbox batch: one unreadable archive records its error and its siblings still start. The exit code is non-zero if any named path failed, because you named each one.

A directory is refused with a pointer to the inbox — an upload is one set of bytes and a directory has none. This mirrors what import.import_path already does.

--stages warns when it omits media. That stage uploads archive bytes to the object store and stamps metadata.FileId; a run that parses photos without it leaves the scratch tree as the only copy, and the scratch tree is reclaimed with the run. It is a warning and not a refusal because "stage now, push later" is a legitimate request.

inbox [--start] [--only] [--skip] [--units] [--label] [--wait] [--timeout] [--json]

Scans this account's inbox subtree; with --start, imports it, smallest unit first.

The scan prints floors as floors. A walk that hit MAX_UNIT_SIZING_ENTRIES renders its size as ~3.0GB and its file count as 49788+, and a footer names the units that were cut. Recognition runs under one scan-wide deadline, so the biggest unit can come back with an empty parser list — which then matches no --only <source>, and the run reports success over a smaller corpus than you asked for. That case gets its own line naming the units. Both lines are silent when nothing was truncated.

The inbox is namespaced per user: CI_IMPORT_INBOX_DIR is a root, and each account owns one subtree under it, named after its account id (<root>/<account id>/), and reads and writes nothing else. Older versions named the folder after the email, which two addresses could share; the first boot after upgrading moves each account's old folder onto its id. Don't build the path yourself: ci inbox prints it. The folder the command prints is that subtree as the host sees it when the appliance runs in a container with CI_IMPORT_INBOX_HOST_DIR set (the scan's displayDir); without it, the API's own path (inboxDir). Inside it, the scan creates a drop folder per service (amazon/, google-takeout/, …); a unit dropped in one declares that service as the run's declared_sources. A path in the old flat layout is refused as "outside the configured import roots", which names the wrong variable — CI_IMPORT_ROOTS is a different setting for path-based imports, and adding to it will not help.

Deliberate divergence from upstream: there, a bare ci inbox imported everything it found. Here unpack acquires by rename, so starting a run takes the file out of the inbox and a mistake is not a re-run away. Scanning is the safe default; importing is a verb you type.

--only / --skip filter on the recognized data source (upstream's vocabulary); --units takes exact paths from a scan. A unit another run already holds is never offered.

runs [runId] [--limit] [--wait] [--timeout] [--json]

Runs and their stage bars. This is upstream's jobs, renamed to what the server calls it. The rename is not cosmetic: there are two progress surfaces here for two audiences — ci_import.import_runs in a vocabulary the person who uploaded the archive recognizes, and pg-boss under /jobs for whoever is asked why the queue is deep. This command is the first. The second is deliberately not wrapped.

push [--run <id>] [--dry-run] [--retry-failed] [--wait] [--timeout] [--json]

The wizard's Sync step: append a push stage to every run whose rows are staged and uncommitted. Same name as upstream's command, entirely different thing — see the drop table below. Exists for the case --stages creates. "Nothing to push" is a state, not an error.

--retry-failed (with --run <id>, repeatable) is the other door, for a run whose push already ran: POST /import/runs/:id/push/retry moves that run's failed rows — items and entities — back to pending and re-queues the run's own push stage, which then pushes those rows and nothing else. It also re-drives a push stage that ran and failed — with rows still pending (how a push ends when the database stays away past its wait, or crashes under one of its statements), or with every row settled (it died closing out); the table shows queued (stage re-run) for those. The plain push cannot do either: it only offers a run that never had a push stage. Refused per run (409), with the reason, and the others go ahead: a run with a stage still working; a run whose stage ahead of the push did not complete (cancelled mid-parse — retry that stage first); a push that never ran (cancelled before a worker claimed it). --wait follows the re-queued runs to close-out, which is where the run's completed/partial is recomputed. A push stopped by DatabaseCrashedDuringPushError ("the database crashed while this push was …; rows are left pending") is re-driven the same way, once the database is healthy. The job system never re-runs it on its own — the stage is recorded failed, and neither a pg-boss redelivery nor the recovery sweep touches a failed stage — so each crash costs exactly one write, and only a person re-driving it costs another. If the re-drive crashes the database again, that batch's data is the suspect (the worker's log names its staged rows), and re-driving again only crashes it again: isolating the row is manual for now. Explicit run ids only, by design, and no --dry-run — ci runs <id> --json shows the push stage's detail.failed without changing anything.

stats [--json]

ci.* row counts, search-index coverage and queue depth, and a rollup of this account's runs. Skipped rows are counted in their own column: a skip is a decided row, not a shortfall.

doctor [--list] [--check <id>] [--timeout <ms>] [--failures-only] [--json]

Twenty-three data-quality checks over what the importers actually wrote, via GET /import/doctor (and GET /import/doctor/checks for --list). Every other command answers "did the import run"; this is the only one that answers "did it write the right rows" — the distinction that matters, because every defect the 2026-08 QA pass found sat behind a push stage reporting 100%.

Running the checks takes the appliance administrator's key. Every check scans all accounts and returns a sample row from whichever one owns the defect, so a household member's key gets a 403, which the command reports as admin-only and exits 2. --list only reads the registry and works with any key.

Each check is a regression detector for a defect that really shipped. Where a check copies a live predicate, its mirrors field names the twin — nowadays a guard (push/inputs.ts isNotAPlaceName, push/canonical.ts destinationsFor, push/canonical.ts isSocialAffiliation, …) rather than a one-off backfill migration, since the guard is what stays. Only nine of the twenty-three name a twin at all — six a guard, one the migration that defined the time_source vocabulary, and the two cross-pipeline identity checks (identity-*) the registry marker and the conformance suite they watch — and mirrors is null for the other fourteen. Do not read a null as "pipeline check": the four pipeline-* checks are null because they watch a control plane rather than a data shape, but so are ten data-shape checks whose predicate copies no single reusable guard — events-dated-before-1990 has no guard to point at, and contact-cards-typed-as-concept deliberately matches the shape so it catches the next parser to make the mistake rather than the sources already fixed. The registry is src/domains/import/doctor/checks.ts; --list is the authoritative count.

  • --list prints the vocabulary and runs nothing; --list --json includes each check's full remedy.
  • --check <id> is repeatable and restricts the run. An unknown id is a 400 that names it and lists the valid ones, because the caller most likely to send one is an agent working from a half-remembered id.
  • --timeout <ms> is the per-statement cap, default 15,000, clamped to [100, 600000]. Zero would mean "no timeout" to Postgres, which is the opposite of what anyone types it for, hence the floor. Checks run sequentially on one pinned connection (statement_timeout is a session setting), so a slow check costs one check, not the suite.
  • --failures-only hides the clean rows. They are shown by default because "what ran" is half of what this command is for: a suite that printed only its failures would be indistinguishable from a suite that only ran the failing checks.
  • --json emits DoctorReport; stdout stays a single parseable document even when the gate trips, since the gate message goes to stderr.

Exit codes. 0 when every high check answered zero. 1 when any high check found rows, and also when any high check could not answer — a timeout or a database error. That second half is deliberate and is the part most likely to be argued with: a gate reading "I could not look" as "there is nothing there" passes exactly when the corpus got big enough for the checks to matter. Raise --timeout rather than ignoring it. 2 stays "you asked for something impossible": unknown check id, no api key, no appliance.

medium findings never gate. The split is about whether a machine can act on a finding without a human deciding first, not about how much it annoys you — future-dated events are the canonical medium, because a calendar is a list of things that have not happened yet. The second reason to make a check medium is that its floor is not zero on correct data: comms-missing-thread-id was downgraded from high for exactly that, because Apple's own chat.db holds genuinely unthreadable messages (37,181 of 432,173 on the reference corpus), and a high check that can never read zero would fail the gate forever.

Counts are global, not user-scoped, matching /api/maintenance/ingest-status and /search/coverage: the question is "is this appliance's data sound", and several of the tables carry rows with no owner that a user-scoped query would drop without saying so.

backfill [stages...] [--list] [--abort-on-error] [--json]

The post-import enrichment chain, via POST /api/maintenance/orchestrate. Fail-soft by default — a geocoder that cannot reach its provider should not cost you the embeddings.

Two implementation notes worth knowing:

  • The leg list is declared as a Record<OrchestrateStage, string>, so the compiler rejects a leg the orchestrator gained and this forgot, and a name misspelled here. The type is imported type-only, so the CLI never pulls the maintenance module graph at runtime.
  • It streams NDJSON rather than using the same endpoint's synchronous JSON. A search_index backfill runs for hours, and a plain fetch waiting on the first byte would die on undici's 300-second headers timeout. The stream writes headers immediately and heartbeats every ten seconds.

Peers, grants and asks — the memory surface (GraphQL)

Everything above is REST, because the import domain publishes REST. The interpersonal-ask feature (epic #1712 — one Companion answering another's owner's question, under standing consent the owner writes in advance) is GraphQL-only: myPeerLinks, createAskGrant, runAsk and the rest of it have no REST route at all, only resolvers on the schema at backend/apps/api/src/domains/graphql/__generated__/schema.graphql.

So this CLI carries a second, much smaller client — MemoryGraphQLClient in graphqlClient.ts — that speaks exactly that schema. It is not a codegen'd Apollo Client and does not try to be one: a script making one call and exiting has no session to cache across, so it is the same fetch + x-api-key shape as ImportApiClient, POSTing one fixed document per call to POST /graphql (the Apollo driver's own default path; no /api prefix — that prefix is a reverse-proxy convention for public ingress, and this CLI, like the REST client, talks to the appliance directly). Every response shape it returns is a plain restated interface (PeerLinkSummary, AskGrantSummary, AskSummary, …), never a type reached into from domains/graphql/types/* — the same "an HTTP client is entitled to the response body, not to the server's internal types" rule IngestStatus follows in client.ts.

A GraphQL-level refusal — a block grant, a malformed queryType, "only the responder may run this ask" — is a normal 200 with an errors[] array, not an HTTP failure. GraphQLApiError (which extends client.ts's ApiError, so main.ts's existing exit-code logic needs no new branch) turns the resolver's own message into exactly the sentence a REST 400/401/403/404 would have printed, verbatim — see formatError.ts for why that message is safe to show: a deliberate HttpException passes through unmasked, and only a truly unexpected server error is replaced with Internal server error.

peers list / peers add / peers update / peers revoke

Linked peer Companions and the trust tier that ceilings everything they can ever be told (policy.ts maps each tier to a maximum disclosure rung). There is no directory lookup — PeerLinkResolver's own docstring explains why (resolving a handle needs a Portal access token nothing in this request context holds) — so peers add <handle> <memoryEndpoint> takes the endpoint directly. New links default to stranger, the narrowest tier.

ci peers add alice@portal.example https://alice.example:3030
ci peers add alice@portal.example https://alice.example:3030 --tier trusted --display-name Alice
ci peers update <id> --tier family
ci peers list
ci peers revoke <id>

peers list shows revoked links too (status revoked) — "who did I used to share with" is a question myPeerLinks deliberately never filters out.

grants list / grants show / grants create / grants update / grants revoke / grants history

Standing consent — the Grant Manager's matrix, one row per (grantee, archetype) pair. <granteeHandleOrUserId> on grants create is resolved for you: if it matches one of your peer links' handles (peers list), the grant addresses that peer (granteePeerLinkId); otherwise it is passed through as a raw household-member user id (granteeId). That resolution is what keeps this CLI from ever being able to trip the server's "exactly one of granteeId / granteePeerLinkId" 400 — it can only ever construct one or the other.

ci grants create alice@portal.example content --mode auto --max-disclosure summary
ci grants create <userId> availability --mode review
ci grants list
ci grants show <id>
ci grants update <id> --mode block
ci grants update <id> --expires-at 2027-01-01T00:00:00.000Z
ci grants revoke <id>
ci grants history
ci grants history <id>

<queryType> is the ask archetype — relationship, content, availability, attribute or identity — and is deliberately not validated client-side; the server's vocabulary (asks/askInput.ts#parseGrantQueryType) is the one source of truth, and its BAD_USER_INPUT/BAD_REQUEST message already names the valid set, verbatim. --mode defaults to review on create (matching GrantService.create's own fallback) but is otherwise one of auto, review, block. --max-disclosure is the owner's own ceiling — none, boolean, count, summary, redacted, full — combined with the trust-tier ceiling by taking the lesser; it is never readable back through grants list/show today (AskGrantType does not expose scope on the schema), only writable.

grants update never widens what it does not touch. Every field the command was not given is left off the GraphQL request entirely — never sent as an explicit null — because the server tells "leave this dimension alone" (the key absent) from "clear it" (the key present as null) apart (UpdateAskGrantInput's own schema docstring explains the bug this protects against: a narrowing edit on one dimension used to silently delete another). Concretely: grants update <id> --mode block alone cannot un-set a ceiling an earlier grants create --max-disclosure summary wrote.

A change is a supersede, not an in-place edit (GrantService.supersede) — the old row closes with revocationReason: superseded and a new one replaces it, so the consent history survives. grants history shows the whole ledger, newest first; grants history <id> narrows to that one grant's own chain (itself, plus whatever it superseded, plus whatever superseded it) by walking supersedesGrantId client-side — there is no server-side "just this chain" filter, since myAskGrantHistory takes no id at all.

asks inbox / asks outbox / asks show / asks send / asks send-peer / asks approve / asks decline / asks answer / asks history

The ask lifecycle. asks send <recipient> <prompt> --query-type <type> is the local path (createAsk) — <recipient> is another account's opaque user id, Phase 0's intra-household scope (epic #1712). For a linked peer's own appliance — the federated half (epic #1725) — use asks send-peer <handle> <prompt> --archetype <a> --peer-access-token <token> instead: that is a different mutation (sendPeerAsk) with a different shape, needing a Portal-issued access token this box never mints (--peer-access-token / CI_PEER_ACCESS_TOKEN; the token itself has to come from wherever the Portal token exchange for this relationship actually happens — this CLI only carries it through).

ci asks inbox
ci asks outbox
ci asks show <id>
ci asks send <userId> "Do you have photos from the lake trip?" --query-type content --data-class photos
ci asks send-peer alice@portal.example "Are you free Friday?" --archetype availability --peer-access-token $TOKEN
ci asks approve <id>
ci asks decline <id>
ci asks answer <id>
ci asks answer <id> --redact entity_1,entity_2 --disclosure-form boolean
ci asks history
ci asks history --outcome declined,expired --limit 50

asks answer is runAsk — the mutation that actually PRODUCES a disclosure, running as the responder. --redact <ids> is the owner's "deny all but these" set (comma-separated entity ids, counted but never named to the asker on refusal); --disclosure-form can only ever narrow what the ask's archetype already implies (narrowerForm in the resolver) — passing a wider form than the archetype allows is safe by construction, since the server clamps it rather than the CLI needing to. Neither flag can be used to see more than the grant and trust tier already permit; they only ever remove.

asks inbox/asks outbox/asks history cannot tell "nothing here" from "the ask feature flag is off" — both read as empty, because the resolvers answer [] either way. Every mutation on this surface can, and does: a { success: false } from createAsk, createAskGrant, runAsk and friends is surfaced as its own clear error rather than an empty-looking success.

doctor --graphql

Extends the existing self-check with the other protocol this CLI speaks: runs { me } and reports who the api key resolves to. Any answer at all proves /graphql is reachable and the key authenticates there too; only an unreachable appliance or a rejected key fails it — the same split the REST doctor already makes, on the other transport.

ci doctor --graphql

Verified against a live appliance

The full cycle — peers add → grants create (review mode) → asks send → asks inbox → asks approve → asks answer → asks show → asks history — was run end to end against a running ci-server-desktop stack while this was built, along with the refusal paths: an unauthenticated key, a malformed queryType, a block-mode grant auto-declining an ask, and answering an already-declined ask. Every refusal came back as the resolver's own sentence, not a generic failure — see graphqlClient.ts's GraphQLApiError for the one wrinkle that surfaced doing this: a live appliance running an image built before formatError.ts's BAD_USER_INPUT remap landed answered BAD_REQUEST for the identical refusal, so both codes are treated as the same 400.


What was kept, what was dropped

Upstream command Here Why
recognize kept Still the read-before-you-commit question. Needed one new endpoint; shares code with the MCP tool.
import kept, reshaped Uploads and starts a run instead of importing synchronously. An import is now durable stages across a queue; a 40GB Takeout does not finish inside a call. --as <dataSource> is dropped — ParseStage recognizes per file inside the unpacked tree, and there is no "force this parser" path to expose. Upstream's --dry-run (parse, do not persist) splits in two: recognize writes nothing at all, and --stages unpack,parse stages rows without committing them.
inbox kept, safer default Scans by default; --start imports. The inbox consumes what it imports.
stats kept, re-aimed Upstream counted rows in its own SQLite mirror. There is no mirror; the same question is asked of the destination (ci.* counts, index coverage) plus a run rollup.
datasources kept Reads the in-memory registry through /import/sources. --catalog is new, and is the list the wizard picks over.
jobs kept as runs Renamed to the server's own vocabulary. pg-boss stays behind /jobs for operators.
backfill-contacts dropped → backfill Promoted null-type SQLite entities carrying a phone/email to contact. That table does not exist; the equivalent is the orchestrator's extract_people leg over ci.communications.
backfill-identity dropped → backfill Populated entities.identity_key in the mirror. Identity resolution now happens at push time in domains/entity-resolution; the repair leg is link_communications.
backfill-calendar dropped Re-parsed .ics files to fix metadata written by a specific pre-74ad479 importer bug in the mirror. The bug, the importer and the table are all gone. Re-importing the archive is the fix.
dedupe-media dropped Collapsed byte-identical media across sources in the mirror. ci.content deduplicates on content_sha256 at write time; there is no accumulated duplicate set to sweep.
query dropped Paged the mirror's items table. Querying the corpus is what search, GraphQL and the REST read surfaces are for; a CLI re-implementation would be a worse duplicate of all three.
export dropped NDJSON dump of the mirror. It existed to get data to CI-Server. Both ends are the same process. docs/api covers the portability surfaces.
push dropped as transport, reused as a stage Upstream's push was the HTTP transport shipping SQLite to a remote CI-Server — the largest thing consolidation deleted. The name is reused for the push stage, which is a different operation with the same word attached.
— --wait (new) Upstream's import was synchronous, so there was nothing to wait for.
— doctor (new) Upstream had no equivalent: it wrote to a local SQLite mirror it also owned, so "did the write land correctly" was not a question anyone could ask across a boundary. Here the destination is a shared ci.* corpus with guards and backfills of its own, and the checks are how those stay honest.

The through-line: everything that existed to manage the local mirror or the transport to CI-Server is gone, because the database is local now. What survives is the part that was always about a person's archive — what is in it, getting it in, and watching that happen.


Testing

backend/apps/cli/src/__tests__/ — every command, both clients' request building and error mapping, the NDJSON reassembly, the table renderer, and the wait loop, all against a stubbed fetch and a recording terminal. The peers/grants/asks commands each have their own __tests__/{peers,grants,asks}/*.test.ts, mirroring commands/{peers,grants,asks}/*.ts one file at a time; shared response fixtures (peerLinkFixture, grantFixture, askFixture, …) live in __tests__/support/fixtures.ts, typed as the real MemoryGraphQLClient response shapes so a fixture cannot silently drift from what the client actually promises. harness() in __tests__/support/harness.ts stubs both client and graphql the same way — throwing by name on any call a test did not stub.

The suite runs under vitest, not the API's jest — it moved with the code and did not need porting, because nothing in it mocks a module or a timer: the clients take an injected fetch, the commands an injected context, the wait loop an injected clock.

yarn workspace @ci/cli test

The wire types, and the follow-up they are waiting for

src/wire.ts restates the response bodies the CLI reads — the run and stage shapes, the inbox scan, the doctor report, the orchestrate summary and its stage vocabulary. They used to be imported from domains/import/{controllers,services,doctor} and from @ci/domains/maintenance; that is what tied this tool to the server's module graph. The old backfill test booted fifteen NestJS modules, a TypeORM entity set and the config loader in order to read seventeen string literals.

The file states the tradeoff it makes and names the route and server type behind every block, so drift is one grep away. The proper fix is a shared @ci/api-client package under backend/packages/, generated from the OpenAPI and GraphQL schemas the appliance already publishes — which is also where client.ts and graphqlClient.ts belong, since the web app's graphql-codegen setup would generate the GraphQL half outright. That was deliberately left out of the extraction: it is a second, larger change, and doing it at the same time would have meant reviewing a package move and a transport rewrite in one diff.