Skip to content

Add CLI on top of config keys refactor - #6082

Draft
paullinator wants to merge 18 commits into
developfrom
paul/cli
Draft

Add CLI on top of config keys refactor#6082
paullinator wants to merge 18 commits into
developfrom
paul/cli

Conversation

@paullinator

@paullinator paullinator commented Jul 22, 2026

Copy link
Copy Markdown
Member

Summary

  • Adds a Node-safe engine-based Edge CLI with JSON REST API (edge-cli / engine).
  • Makes shared network/utils and exchange-rate core Node-loadable for the CLI.
  • Includes native Edge API HMAC signing (mobile + Node N-API) and config/keys split work already on this branch vs develop.
  • Follow-up fixups address review-code findings (edge-login races, infoServer interval stacking, stub signer fail-closed, testMode config, etc.).

Notes for reviewers

  • This PR currently stacks several related stacks vs develop (config/keys, native HMAC, Node-safe splits, CLI). It is intentionally draft-style until dependencies / base strategy are finalized; there is no future! pseudo-merge in the history.
  • CLI publish (publish:cli) is a placeholder until packaging/bin metadata is restored.
  • Production Node HMAC addon must be built from edgeKey.json (build:cli:native); stub builds are refused.

Test plan

  • npm run test:cli:node-safe
  • npm run build:cli / npm run build:cli:native (with edgeKey.json)
  • npm run test:cli:node-hmac (with edgeKey.json)
  • npm run test:cli / npm run test:cli:edge-login as applicable
  • npm test / tsc

@socket-security

socket-security Bot commented Jul 22, 2026

Copy link
Copy Markdown

@socket-security

socket-security Bot commented Jul 22, 2026

Copy link
Copy Markdown

Warning

Review the following alerts detected in dependencies.

According to your organization's Security Policy, it is recommended to resolve "Warn" alerts. Learn more about Socket for GitHub.

Action Severity Alert  (click "▶" to expand/collapse)
Warn Medium
Low adoption: npm babel-plugin-transform-fake-error-class

Location: Package overview

From: package-lock.jsonnpm/babel-plugin-transform-fake-error-class@1.0.0

ℹ Read more on: This package | This alert | What are unpopular packages?

Next steps: Take a moment to review the security alert above. Review the linked package source code to understand the potential risk. Ensure the package is not malicious before proceeding. If you're unsure how to proceed, reach out to your security team or ask the Socket team for help at support@socket.dev.

Suggestion: Unpopular packages may have less maintenance and contain other problems.

Mark the package as acceptable risk. To ignore this alert only in this pull request, reply with the comment @SocketSecurity ignore npm/babel-plugin-transform-fake-error-class@1.0.0. You can also ignore all packages with @SocketSecurity ignore-all. To ignore an alert for all future pull requests, use Socket's Dashboard to change the triage state of this alert.

Warn Medium
Low adoption: npm lib-cmdparse

Location: Package overview

From: package-lock.jsonnpm/lib-cmdparse@0.1.0

ℹ Read more on: This package | This alert | What are unpopular packages?

Next steps: Take a moment to review the security alert above. Review the linked package source code to understand the potential risk. Ensure the package is not malicious before proceeding. If you're unsure how to proceed, reach out to your security team or ask the Socket team for help at support@socket.dev.

Suggestion: Unpopular packages may have less maintenance and contain other problems.

Mark the package as acceptable risk. To ignore this alert only in this pull request, reply with the comment @SocketSecurity ignore npm/lib-cmdparse@0.1.0. You can also ignore all packages with @SocketSecurity ignore-all. To ignore an alert for all future pull requests, use Socket's Dashboard to change the triage state of this alert.

View full report

@paullinator
paullinator force-pushed the paul/cli branch 11 times, most recently from 67406a5 to 35eeb43 Compare August 8, 2026 07:06
@paullinator
paullinator force-pushed the paul/cli branch 5 times, most recently from 4ff7bae to 484bd6c Compare August 31, 2026 06:55
paullinator and others added 9 commits August 31, 2026 16:30
Apply strict-boolean, nullish, and return-type fixes in files leaving
the relaxed-rules list.
Single-flight the initial load and serialize every write through a
promise chain so overlapping patches cannot clobber each other or
blank on-disk fields. Adds keysCache fields for remote key fetch.
Replace the flat env.json/ENV singleton with config.json + keys.json and
runtime CONFIG, KEYS, globalKeys, and pluginMaps accessors. Partner
secrets live nested under globalKeys.
Boot from baked-in KEYS, then overlay a signed infoRollup appKeys payload and device
cache. Mutate KEYS and globalKeys in place and rebuild pluginMaps.
Print only LAYER-* overlay markers from the local info_keys seed, plus whether the native signer loaded, so device e2e can confirm remote key fetch without dumping secrets.
Plugins whose API keys are absent or malformed do not register with the
core, which leaves them out of `currencyConfig` and `swapConfig`. Diff the
plugin list we handed to `makeEdgeContext` against what the account came
back with, and show the missing plugin IDs in an error drop-down so a
misconfigured key surfaces instead of silently removing assets and
exchanges from the app.

Plugin loading happens once per core context, so this reports once per
session rather than on every login.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Drop RN from network/utils load paths via fiatConstants, lazy
locale boot, injected initInfoServer params, and configureNetwork.
Keep exchangeRates on network.fetchRates and utils.removeIsoPrefix;
inject only Airship showError via exchangeRatesGui at app start.
Two faults predate everything the CLI work builds on, and both had been
carried along unnoticed.

`keysStore.ts` imported `getOsVersion` from `./utils`, where it does not
live; it is in `./rnUtils`. That is one `tsc` error and a failing
`keysStore.test.ts`.

`typechain` emits `export * as factories from './factories'`, and the React
Native preset does not transform namespace re-exports. The plugin was already
a transitive dependency but never enabled, so `TransactionListTop` failed to
parse the moment `src/plugins/contracts` existed — which it does after any
`npm install`, since `prepare` generates it. Metro needs that transform as
much as jest does, so it belongs in the shared config.

104 suites and 782 tests pass, and `tsc` is clean. Every commit after this one
is verified against that.
`initLocale` reaches for `react-native-localize`, so nothing outside the app
could ask which locale to use. The decision itself is pure: read a tag from
argv, config or the environment, normalize it, and pick a language table.

`nodeLocale.ts` holds that decision with no React Native imports, and
`bootLocale.ts` holds the shared `LocaleSource` shape. `initLocale` keeps the
device lookup and defers to the same normalizer, so the GUI and any Node
caller resolve a locale the same way rather than approximately the same way.

Precedence is explicit and tested: an explicit tag, then config, then
`EDGE_CLI_LOCALE`, then `LC_ALL` / `LC_MESSAGES` / `LANG`, then `Intl`, then
`en-US`. `es_MX.UTF-8@euro` and `C` both resolve, which is what the POSIX
forms actually look like.

`env` is typed as the variables it reads rather than `NodeJS.ProcessEnv`,
which in this repo demands `NODE_ENV` and would make every caller invent one.
`CategoriesActions.ts` held five hundred lines deciding what a transaction
should be called: the category, the payee, the direction, and the label for
each action type. All of it is a pure function of the transaction, the wallet
and the account, but it sat behind Redux imports, so nothing outside the app
could ask the same question and get the same answer.

`src/util/txDisplay/` holds that logic now — `displayInfo` for the derivation,
`category` for the category strings, `txActionLabels` for the action names, and
`currencyCodes` for the ticker lookups. `CategoriesActions` re-exports what the
GUI already imported, so no scene changed.

The point is that two callers cannot drift. A transaction rendered in a list,
exported to CSV, or printed by a script now describes itself identically,
because it is the same code deciding.
Three small pieces of GUI state that any caller reading transactions needs,
and none of which had a reason to be Redux-only.

`exchangeDenom` picks the denomination a currency or token reports amounts in.
`DenominationSelectors` keeps its selector shape and calls it, so the two
cannot disagree about what a multiplier is.

`spamThreshold` decides which incoming transactions are dust worth hiding. The
GUI applies it to every list; a caller that reads the same wallet and does not
apply it sees a different set of transactions, which is the sort of difference
that looks like a bug in whichever one you did not write.

`localAccountSettings` reads the device-local settings file that holds the spam
filter toggle, and `LocalSettingsActions` reads through it rather than
duplicating the format.
`TransactionExportActions.tsx` was a five-hundred-line thunk that did four
separable jobs: fill in historical fiat values, render CSV, render QBO, and
render the Bitwave format. Only the last step needed React Native, and only
for writing the file.

`fillTxsFiat` asks the rates server what each transaction was worth on the day
it happened, which is the part that makes an export more than a dump of native
amounts. `txExport/format` renders the three formats. `exportTxInfo` holds the
Bitwave account mapping the exporter needs.

The thunk keeps the file-writing and the share sheet, and calls the same
renderers. `TransactionsExportScene` follows it. A test now covers `fillTxsFiat`
against a partial wallet, which is all it reads.

The formats matter here: an export that a person reconciles against their books
has to be byte-identical whichever tool produced it, and the only way to be
sure of that is for one renderer to produce both.
`SendScene2` saved a sent transaction and then attached its metadata, its
category, its notes and any swap details in a sequence that had to happen in a
particular order and had grown inline in the scene. A second caller that saved
a transaction and got the order wrong would produce a transaction that looks
right until someone exports it.

`txTagging/apply` holds that sequence. `SendScene2` calls it and loses two
dozen lines. The behaviour is unchanged, which is the point: the scene was the
only definition of what a correctly tagged transaction is, and now it is not
the only caller that can produce one.
@paullinator
paullinator force-pushed the paul/cli branch 4 times, most recently from a8819b9 to 60adf1e Compare September 3, 2026 05:13
A long-lived engine daemon owns the `EdgeContext` and answers a JSON REST API
over a Unix socket; the `edge-cli` binary is a thin one-shot client that spawns
the engine on demand and keeps a session id in `session.json` so commands
chain. `docs/EDGE_CLI.md` describes that architecture and deliberately
documents no endpoints — the reference is generated.

The point of this commit is the declaration format, so it carries thirteen
calls rather than all of them. Each is one `route({…})`: the core call it
fronts, the HTTP method and path, how it appears on the command line, cleaners
for the query, body and response, and its error codes. The prose lives inside
the declaration, beside the field it describes, and the JSDoc above carries
what belongs to the call as a whole.

Nothing is written twice. The command line, the help text, the OpenAPI
document and the HTML reference are all derived from these declarations, and
the derived artifacts are committed so a fresh clone needs no build step. Five
gates run in the pre-commit hook and reject the ways they could drift apart: a
route with no command, a handler reading a field its cleaner would strip, a
request parameter the core call does not have, a generated file that is stale,
and a command no test exercises.

The thirteen cover the shapes worth reviewing:

- no arguments, engine-local — `engine-status`, `engine-config`
- no arguments, reaching core — `local-users`, `fetch-login-messages`
- one named argument — `username-available`
- a body, and the session it establishes — `create-account`,
  `login-with-password`, `logout`
- a positional path parameter — `object-get`, `object-delete`
- a held-open stream — `subscribe`

Path parameters are base58 identifiers and nothing else, because base64 wallet
ids and free-text usernames contain `/` and cannot survive a URL unescaped.
Everything else is a named argument. A positional is declared once as an
ordinary field and the path is derived from it, so the two cannot disagree.

`--fake` serves an in-process `makeFakeEdgeWorld`, which is what lets the CLI
tests run in a hook with no network, no server and no API key.
The other hundred and four calls, in the format the previous commit
established: account and session management, credentials, 2FA and vouchers,
the data store, keys and wallets, tokens, URIs, transactions and their export,
the staged spend path, swaps, exchange rates, and the `$internalStuff` admin
calls.

Nothing here changes the framework. Every call is a `route({…})` of the same
shape, and the same five gates hold across all of them: 117 routes, 117 of 118
commands, 245 response fields described, 87 routes matching their core
signature or recording why they differ, and 114 of 118 commands exercised
offline against the fake world.

Four cannot be exercised in a hook and say so: the two rates calls, swap quotes
and payment-protocol requests each reach a third-party API that the fake world
does not intercept. They have their own `test:cli:network` script, alongside
the suites that need a real login server.

Where the API departs from `edge-core-js` it is recorded in `coreExtra` with
the reason — a wallet object that cannot cross HTTP as anything but an id, the
`to`/`amount` shorthand that expands into `spendTargets`, engine-side paging
and export on `get-transactions`. Anything not listed there fails the build.
There were three: one for the command table the CLI runs on, one for `help`,
and one for the HTML reference. They had drifted, so `local-settings`
documented `--spam-filter-on=<spamFilterOn>` in the reference while `help`
correctly said `--spam-filter-on=true|false`. The reference is the copy people
read, so it was the wrong one to be wrong.

`cliUsage.ts` is now the only place that decides how a command is typed, and
all three call it. The reference copy was the stalest: it still handled
`bodyFlag`, which no longer exists, and pushed `cli.positional` on top of the
path parameters, which would have printed the positional twice.

Two things the declarations already knew and none of the three said:

A required boolean reads `--paused=true|false`, because a bare flag cannot say
false. An optional one stays a switch, since absent already means "not sent".

A field declared with `asValue` renders its choices —
`--filter=active|archived|hidden|all` rather than `--filter=<value>`. The
allowed values are in the cleaner; there is no reason to make a reader guess
them or go looking.
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