Private & Confidential — Property of Lifescope Inc. Do not distribute.
Renamed. This binary was
ci-importand this file wasdocs/cli/ci-import.md. Both names still work:ci-importis kept as an alias bin that prints a deprecation notice to stderr — soci-import runs --json | jqkeeps 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.
export CI_SERVER_URL=http://localhost:3030
export CI_API_KEY=… # web UI → Settings → API keys
yarn workspace @ci/cli dev datasourcesThe 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.
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:
- 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_KINDindomains/import/constants.ts) — consumed byImportStageListener. RegisteringImportModulein 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 sameImportRunService.startRuncall the web wizard makes, in the same process, against the same queues — not an equivalent one. - 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
.envpointing somewhere else. An HTTP URL is unambiguous. - 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.
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, plusCI_IMPORT_ROOTSwhen 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.inboxconsumes 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 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.
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-sidecurlmisses. 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-keyremains the credential for the CI-Server API;--backend-api-keyis 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.
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 --stdioPreserves 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.
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.
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.
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.
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 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.
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.
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.
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.
--listprints the vocabulary and runs nothing;--list --jsonincludes each check's fullremedy.--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_timeoutis a session setting), so a slow check costs one check, not the suite.--failures-onlyhides 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.--jsonemitsDoctorReport; 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.
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_indexbackfill 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.
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.
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.
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 50asks 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.
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 --graphqlThe 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.
| 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.
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 testsrc/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.