@allscale/cli 0.1.0 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +113 -41
- package/bin/dev.js +11 -1
- package/bin/run-oclif.js +216 -27
- package/bin/run.js +19 -1
- package/dist/commands/build-info.js +13 -13
- package/dist/commands/claim-link/claim.js +14 -14
- package/dist/commands/claim-link/create.js +40 -37
- package/dist/commands/claim-link/get.js +26 -15
- package/dist/commands/claim-link/list.js +15 -15
- package/dist/commands/claim-link/status.js +13 -13
- package/dist/commands/describe.js +13 -13
- package/dist/commands/device-login.js +23 -24
- package/dist/commands/invoice/get.js +15 -15
- package/dist/commands/invoice/list.js +16 -16
- package/dist/commands/invoice/pay.js +40 -41
- package/dist/commands/invoice/received.js +16 -16
- package/dist/commands/invoice/send.js +42 -34
- package/dist/commands/invoice/sent.js +15 -15
- package/dist/commands/invoice/update.js +17 -15
- package/dist/commands/logout.js +14 -14
- package/dist/commands/operations.js +13 -13
- package/dist/commands/otp-login.js +21 -20
- package/dist/commands/otp-send.js +13 -13
- package/dist/commands/payout/send.js +14 -14
- package/dist/commands/payout/status.js +15 -15
- package/dist/commands/scope.js +17 -16
- package/dist/commands/store/create.js +17 -17
- package/dist/commands/transaction/get.js +15 -15
- package/dist/commands/transaction/list.js +15 -15
- package/dist/commands/wallet/list.js +15 -15
- package/dist/commands/wallet/send.js +40 -43
- package/dist/commands/whoami.js +14 -14
- package/dist/hooks/version-suffix.js +1 -1
- package/dist/index.js +14 -14
- package/dist/lib/help.js +7 -2
- package/dist/lib/output/audit-lifecycle.js +7 -0
- package/oclif.manifest.json +123 -292
- package/package.json +4 -6
- package/dist/commands/claim-link/preview.js +0 -18
package/README.md
CHANGED
|
@@ -62,7 +62,8 @@ allscale whoami
|
|
|
62
62
|
# What capabilities did this CLI session get? (chosen on the approval screen)
|
|
63
63
|
allscale scope
|
|
64
64
|
|
|
65
|
-
# Create a store for your business — returns its
|
|
65
|
+
# Create a store for your business — returns its API secret on stdout (shown
|
|
66
|
+
# only once; it cannot be retrieved again, so save it now).
|
|
66
67
|
# `payout send` requires a live store credential from Payout onboarding.
|
|
67
68
|
allscale store create --name "My Shop"
|
|
68
69
|
|
|
@@ -101,12 +102,15 @@ allscale invoice send --to-email client@example.com \
|
|
|
101
102
|
--line "Discovery (4h)|4|25.00" --line "Implementation (10h)|10|25.00" \
|
|
102
103
|
--memo "Q2 engagement"
|
|
103
104
|
|
|
104
|
-
#
|
|
105
|
-
#
|
|
106
|
-
#
|
|
105
|
+
# Explicit --amount + a display-only line. With single-field --line, `--amount`
|
|
106
|
+
# is required (there is nothing to derive from). Note the ONE single-field line:
|
|
107
|
+
# each such line is worth the FULL --amount, so two of them would sum to 2 x
|
|
108
|
+
# --amount and print a totals-mismatch warning. Use the 3-field form above when
|
|
109
|
+
# you want several rows.
|
|
107
110
|
allscale invoice send --to-email client@example.com --amount 250 --wallet-id <wallet-id> \
|
|
108
|
-
--payment-type 2 --currency-label USDC --memo "January retainer"
|
|
109
|
-
--
|
|
111
|
+
--payment-type 2 --currency-label USDC --memo "January retainer" \
|
|
112
|
+
--due "$(node -p 'new Date(Date.now()+30*864e5).toISOString().slice(0,10)')" \
|
|
113
|
+
--line "Discovery + implementation"
|
|
110
114
|
|
|
111
115
|
# First-time invoice to a new email? Add --auto-create-contact to create the
|
|
112
116
|
# contact in one shot. Name defaults to the FULL email address (essentially
|
|
@@ -121,39 +125,44 @@ allscale invoice send --to-email newclient@example.com --amount 100 \
|
|
|
121
125
|
allscale invoice send --to-email billing@acme.com --amount 100 \
|
|
122
126
|
--wallet-id <wallet-id> --auto-create-contact --contact-name "Acme Inc"
|
|
123
127
|
|
|
124
|
-
# Send a stablecoin from your AllScale wallet to an external EVM address
|
|
128
|
+
# Send a stablecoin from your AllScale wallet to an external EVM address.
|
|
129
|
+
# The transfer is confirmed in the browser, which waits for the on-chain
|
|
130
|
+
# receipt; over SSH, open the printed URL on any device and paste the full
|
|
131
|
+
# result JSON back.
|
|
125
132
|
allscale wallet send --idempotency-key order-1042 \
|
|
126
133
|
--to 0xabc... --amount 0.1 --chain base --stable-coin USDC
|
|
127
134
|
|
|
128
|
-
# (`wallet send --headless` skips the browser, but it signs with the user token
|
|
129
|
-
# of a legacy JWT session — a credential this build's logins never mint, so it
|
|
130
|
-
# cannot succeed here. Use the browser-bridge form above.)
|
|
131
|
-
|
|
132
135
|
# Drop credentials
|
|
133
136
|
allscale logout
|
|
134
137
|
```
|
|
135
138
|
|
|
139
|
+
> **Windows note.** The invoice examples above compute the due date with POSIX
|
|
140
|
+
> command substitution. `sh`, `bash`, `zsh` and PowerShell all expand it;
|
|
141
|
+
> `cmd.exe` passes the token through literally, so the CLI receives the text
|
|
142
|
+
> itself and rejects it with a validation error. On `cmd.exe`, pass an explicit
|
|
143
|
+
> ISO date instead.
|
|
144
|
+
|
|
136
145
|
## Commands
|
|
137
146
|
|
|
138
147
|
### Auth
|
|
139
148
|
| Command | Description |
|
|
140
149
|
|---|---|
|
|
141
|
-
| `device-login` | **Recommended for AllScale Pay Accounts.** Opens the browser; use an existing web-app session or sign in there with **Passkey or Email**. On the Email path, enter the **Email OTP on the webpage**, not in the terminal, then approve the CLI. The backend mints a **scoped agent API key** — the credential issued by the current CLI login flows. Scopes chosen on the approval screen are **checked by the backend**. Scope controls reduce access but do not prevent credential compromise, misuse within a granted scope, configuration errors, or unauthorized actions by an otherwise authenticated caller. The key expires and does not refresh (re-run `device-login` when it does; the CLI prints the expiry). A **bare** `device-login` at a terminal first prints what it is about to do and asks to confirm; declining exits **12** (`user.cancelled`) having created nothing. The prompt is bare-invocation only — any flag (including `--yes`) skips it — and never appears for `--json`, a pipe, a sidecar, ordinary non-tty CI, or a redirected stderr. Automation that allocates a pty for all three streams *and* runs the command bare is indistinguishable from a human and is prompted; pass `--yes`. Polling stops at the pairing's expiry or **15 minutes**, whichever is earlier; the CLI prints that deadline before it starts waiting. |
|
|
150
|
+
| `device-login` | **Recommended for AllScale Pay Accounts.** Opens the browser; use an existing web-app session or sign in there with **Passkey or Email**. On the Email path, enter the **Email OTP on the webpage**, not in the terminal, then approve the CLI. The backend mints a **scoped agent API key** — the credential issued by the current CLI login flows. Scopes chosen on the approval screen are **checked by the backend**. Scope controls reduce access but do not prevent credential compromise, misuse within a granted scope, configuration errors, or unauthorized actions by an otherwise authenticated caller. The key expires and does not refresh (re-run `device-login` when it does; the CLI prints the expiry). A **bare** `device-login` at a terminal first prints what it is about to do and asks to confirm; declining exits **12** (`user.cancelled`) having created nothing. The prompt is bare-invocation only — any flag (including `--yes`) skips it — and never appears for `--json`, a pipe, a sidecar, ordinary non-tty CI, or a redirected stderr. Automation that allocates a pty for all three streams *and* runs the command bare is indistinguishable from a human and is prompted; pass `--yes`. Polling stops at the pairing's expiry or **15 minutes**, whichever is earlier; the CLI prints that deadline before it starts waiting. With a sidecar configured, pairing initiation also appends a sidecar-v2 `type: "device_authorization"` event containing `verification_url`, `verification_code`, and `polling_deadline_at`, so a supervisor need not parse stderr. |
|
|
142
151
|
| `otp-login` | **Terminal-only** Email-OTP path for headless / scripted shells. Works with **any account email** — external domains included, not just `@allscale.io` (the account must already exist — sign up in the web app first). Sends the code and reads it in the terminal (flag / env / stdin / interactive prompt), then mints the same **scoped agent API key** with the scopes passed via `--scopes` (required — there is deliberately no full-permission default). |
|
|
143
152
|
| `otp-send` | Granular: send the email OTP only (login only, any account email). Returns the otp_id; pair with `otp-login --otp-id <id> --otp <code>` for scripted flows. |
|
|
144
153
|
| `whoami` | Show the current authenticated identity. No network call. Agent-key sessions (the normal case) print the key-mode identity from the stored bundle: `credential: "agent_key"`, `business_id`, `user_type`, `scopes`, and the key expiry. |
|
|
145
154
|
| `scope` | Show the capabilities granted to the current CLI session (chosen on the browser approval screen at `device-login`, or via `--scopes` on `otp-login`). Scopes are **checked by the backend** for each request. Scope controls reduce access but do not prevent credential compromise, misuse within a granted scope, configuration errors, or unauthorized actions by an otherwise authenticated caller; the key expiry is shown. No network call. |
|
|
146
|
-
| `logout` | Drop cached session and store credentials for a profile. A bare logout requests a full plaintext + OS-keychain sweep and exits non-zero after file cleanup if the keychain cannot be inspected; `--insecure-storage` performs an explicit file-only cleanup and never probes keychain. |
|
|
155
|
+
| `logout` | Drop cached session and store credentials for a profile. A bare logout requests a full plaintext + OS-keychain sweep and exits non-zero after file cleanup if the keychain cannot be inspected; `--insecure-storage` performs an explicit file-only cleanup and never probes keychain. JSON output keeps the legacy session-token-only `removed` boolean and adds `removal_status`: `removed` when any session or store credential was deleted, `nothing_to_remove` only when a complete sweep found none, or `skipped` when a file-only sweep deleted nothing because keychain inspection was intentionally skipped. |
|
|
147
156
|
|
|
148
157
|
### Authorization model
|
|
149
158
|
|
|
150
159
|
One page, no archaeology:
|
|
151
160
|
|
|
152
161
|
- **The current CLI auth flows issue a scoped agent API key.** Both `device-login` (browser approval) and `otp-login` (terminal OTP, for headless/CI) mint one. There is no cookie mode and no full-permission default — every key carries an explicit scope set.
|
|
153
|
-
- **Scopes** are `<category>:<tier>` strings (e.g. `invoice:read_only`, `claim_link:all`), chosen on the browser approval screen (`device-login`) or via `--scopes` (`otp-login`). Requested scopes are clipped to your account's role at mint; the granted set is echoed back and recorded locally (`allscale scope` shows it without a network call). The backend **checks** the set on each request; an out-of-scope call is expected to fail with exit 6. Scope controls limit permitted call categories but do not verify the caller's authority, purpose, accuracy, legality, or safety.
|
|
162
|
+
- **Scopes** are `<category>:<tier>` strings (e.g. `invoice:read_only`, `claim_link:all`), chosen on the browser approval screen (`device-login`) or via `--scopes` (`otp-login`). Requested scopes are clipped to your account's role at mint; the granted set is echoed back and recorded locally (`allscale scope` shows it without a network call). The backend **checks** the set on each request; an out-of-scope call is expected to fail with exit 6. Scope controls limit permitted call categories but do not verify the caller's authority, purpose, accuracy, legality, or safety. Both commands check the *values* locally first: an unrecognised `<category>:<tier>` string fails with `input.invalid` (exit 2) before anything is sent — no OTP, and no pairing session — so a script can rely on one contract for the flag whichever login it uses.
|
|
154
163
|
- **Keys expire and do not refresh.** The CLI prints the expiry at login and in `scope` / `whoami`; re-run `device-login` or `otp-login` to mint a new key. Revoke keys in the AllScale web dashboard, then log in again for a fresh one — the CLI has no key-management command (see the Keys section).
|
|
155
|
-
- **Some actions stay human-only by design.** Two of them are not CLI commands at all — key management lives in the dashboard (see the Keys section) and payout authorization lives in Store Settings → Payout Authorization.
|
|
156
|
-
- **Password login is not
|
|
164
|
+
- **Some actions stay human-only by design.** Two of them are not CLI commands at all — key management lives in the dashboard (see the Keys section) and payout authorization lives in Store Settings → Payout Authorization. The browser-bridge flows (`wallet send`, `invoice pay`) work under an agent key: the human approves in the browser — note `invoice pay` needs **two** scopes, `invoice:all` to create, report and confirm the pay intent and `wallet:read_only` to look up the payer address. Neither LOGIN flow grants `invoice:all` by default: on `device-login` the **Invoices** toggle starts at Read, so tick its write tier on the approval screen (the **Wallets** toggle that grants `wallet:read_only` *is* on by default); with `otp-login`, pass BOTH `--scopes invoice:all --scopes wallet:read_only`.
|
|
165
|
+
- **Password login is not a public flow.** AllScale Pay Accounts are passkey-only and have no usable password; `device-login` and `otp-login` are the two ways in. Accounts specially provisioned with a password authenticate through an internal-only flow that mints the same scoped, expiring agent key (`--scopes` required, no full-permission default).
|
|
157
166
|
|
|
158
167
|
### Discovery
|
|
159
168
|
|
|
@@ -176,26 +185,25 @@ The CLI's user-facing surface matches the AllScale web app: what users call an *
|
|
|
176
185
|
| `invoice get <id>` | Fetch one invoice by id. Wraps `payment`. |
|
|
177
186
|
| `invoice sent` | Invoices where **you are the payer** — your business owes the money. Wraps `sender_payments` (the backend's `sender` is the payer). Invoices you issued with `invoice send` are **not** here; see `invoice received`. Same pagination defaults and the same `--status` / `--payment-type` / `--from` / `--to` filters as `invoice list`. **No `--to-email`**: an invoice's contact record belongs to the business that issued it, so your own contacts never appear on the invoices you owe — the flag is refused here with `input.invalid` rather than answering with a misleading empty page. |
|
|
178
187
|
| `invoice received` | Invoices where **you are the payee** — the money is owed to your business. Wraps `recipient_payments` (the backend's `recipient` is the payee). **Includes everything you issued with `invoice send`.** Same pagination defaults and the same `--status` / `--payment-type` / `--from` / `--to` / `--to-email` filters as `invoice list`; this is the command `--to-email` fits exactly, since every row here is one you issued. |
|
|
179
|
-
| `invoice send` | Issue an invoice by email. Resolves `--to-email` to a contact (auto-creates via `--auto-create-contact` if missing), renders the invoice HTML body locally, and submits `create_payment` through the authenticated session. USDT/USDC totals below 0.10 are rejected before contact or wallet lookup. Pass `--wallet-id` to pin receiving wallets (backend-resolved — works with an invoice-scope-only key), or omit it to auto-select from your wallets via the narrow `business_wallets` query (needs `wallet:read_only` on an agent key). **No browser ceremony** — the payer signs when paying; the inviter doesn't sign at issue. |
|
|
188
|
+
| `invoice send` | Issue an invoice by email. Resolves `--to-email` to a contact (auto-creates via `--auto-create-contact` if missing), renders the invoice HTML body locally, and submits `create_payment` through the authenticated session. The recipient does **not** need any prior relationship with you — any valid email can be invoiced — but auto-creating its contact record needs `contact:all` on an agent key, which the approval screen leaves **OFF** by default (contact defaults to read-only); the CLI detects a key that can't create contacts and fails fast with the re-login remediation instead of relaying the backend's scope refusal. USDT/USDC totals below 0.10 are rejected before contact or wallet lookup. Pass `--wallet-id` to pin receiving wallets (backend-resolved — works with an invoice-scope-only key), or omit it to auto-select from your wallets via the narrow `business_wallets` query (needs `wallet:read_only` on an agent key). **No browser ceremony** — the payer signs when paying; the inviter doesn't sign at issue. |
|
|
180
189
|
| `invoice update <id>` | Update an existing invoice. Wraps `update_payment`. |
|
|
181
|
-
| `invoice pay <id>` | Pay
|
|
182
|
-
| `transaction list` | Paginated transactions. `--scope mine\|business\|activities`. Wraps `my_transactions` / `business_transaction_records` / `activities`. Defaults to the first 50 rows; pass `--input` (mine/business) or `--limit N` (activities) to override, or `--all` to request one unpaginated response (still capped at 32 MiB by the CLI). |
|
|
190
|
+
| `invoice pay <id>` | Pay an invoice where **you are the payer**, from your AllScale wallet — list them with `invoice sent`, NOT `invoice received`. Resolves the invoice's destination + amount, moves the funds via the same signing path as `wallet send` (the transfer is confirmed in the browser at `/cli`), then reports and confirms the payment. **Needs TWO scopes** — `invoice:all` to create, report and confirm the pay intent, and `wallet:read_only` for the payer-address lookup (the narrow `business_wallets` query). Neither LOGIN flow grants `invoice:all` for you: on `device-login` the Invoices toggle starts at Read, so tick its write tier on the approval screen; on `otp-login` pass BOTH `--scopes invoice:all --scopes wallet:read_only`, because naming any scope turns every unnamed category off. |
|
|
191
|
+
| `transaction list` | Paginated transactions. `--scope mine\|business\|activities`. Wraps `my_transactions` / `business_transaction_records` / `activities`. Defaults to the first 50 rows; pass `--input` (mine/business) or `--limit N` (activities) to override, or `--all` to request one unpaginated response (still capped at 32 MiB by the CLI). Filter by time with `--from` / `--to` in every scope — the same **half-open** range as `invoice list` (`--from` inclusive, `--to` exclusive, same accepted datetime spellings), applied to `created_at` under `mine`/`business` and to `transaction_time` under `activities`. Under `--scope business`, additionally filter with `--direction` (`INFLOW`/`OUTFLOW`), `--transaction-type` (`PAYMENT`, `WITHDRAWAL`, `CLAIM_LINK`, …) and `--status` (`NEW`/`SUCCESS`/`FAILED`/`CONFIRMING` — transaction statuses, not invoice statuses); these map to `business_transaction_records`' typed GraphQL arguments, each accepting the enum name (case-insensitive) or a raw integer. The other scopes reject them with the typed alternative they do have (`activities` takes list-valued `directions` / `transaction_types` / `transaction_statuses` via `--input`). |
|
|
183
192
|
| `transaction get <id>` | Fetch one transaction by id. |
|
|
184
193
|
| `wallet list` | Your noncustodial wallets and per-coin balances via the narrow `business_wallets` query. Works with an agent key holding `wallet:read_only`; the owner is derived from the authenticated identity. |
|
|
185
|
-
| `wallet send` | Withdraw USDT/USDC from your AllScale wallet to an external EVM address. Registers a `withdraw_token` op with the broker, opens the browser at `/cli` for the passkey + Turnkey signing ceremony,
|
|
194
|
+
| `wallet send` | Withdraw USDT/USDC from your AllScale wallet to an external EVM address. Registers a `withdraw_token` op with the broker, opens the browser at `/cli` for the passkey + Turnkey signing ceremony, and succeeds only after the browser confirms the on-chain receipt — the receipt-aware result comes back through the loopback listener (a mined revert exits `12`, an unconfirmable receipt exits `9`). The transfer is always confirmed in the browser; over SSH, open the printed URL on any device and paste the full result JSON back. |
|
|
186
195
|
|
|
187
196
|
### Claim links
|
|
188
197
|
|
|
189
198
|
**Important:** The financial commands below are available only to eligible users in supported jurisdictions. Execution may be delayed, rejected, suspended, returned, or require additional information to comply with applicable law, sanctions, AML/CTF requirements, fraud controls, network conditions, or service-provider requirements.
|
|
190
199
|
|
|
191
|
-
Sender-side reads (`get` / `list`
|
|
200
|
+
Sender-side reads (`get` / `list`) need a CLI login and `claim_link:read_only` under an agent-key session. `claim-link create` needs `claim_link:all`: the CLI fixes the amount, stablecoin, chain, claim window, optional sender metadata, and caller-stable idempotency key before registering the `create_claim_link` broker op. The AllScale-hosted browser approval page shows that immutable intent as a read-only review, then performs creation, signing, and funding after the payer authorizes it; verify the exact AllScale origin before approval. It does not ask the payer to re-enter or edit the payment. Before opening the browser and again after any callback, cancellation, invalid receipt, or timeout, the CLI reconciles the complete intent through `claim_link_funding_by_idempotency_key`; the browser receipt is advisory. `status` and `claim` drive the public receiver-facing REST surface — no login; the bearer claim token IS the credential.
|
|
192
201
|
|
|
193
202
|
| Command | Description |
|
|
194
203
|
|---|---|
|
|
195
204
|
| `claim-link create` | Create and fund a fixed-intent Claim Link through an AllScale-hosted browser approval flow. Verify the exact AllScale origin before approval. Requires `--idempotency-key` (1–128 trimmed characters), `--amount` (exact decimal string, 0.1–10000, at most 6 effective decimal places), `--chain`, and agent-key scope `claim_link:all`; `--stable-coin` defaults to USDT and `--expires` to 14d. `--no-browser` skips only local auto-open: the CLI still prints the `/cli` URL and waits for callback/paste. A recovered key must match every immutable field; a pre-browser mismatch exits 2 as `claim_link.intent_conflict` without opening the browser. Success is emitted only when the authoritative lookup returns the same intent, a validated AllScale claim URL, and either `LINK_SENT`, `CLAIMING`, or `CLAIMED`, or returns `EXPIRED` or `CANCELLED` with a validated non-empty funding transaction hash. A signed/broadcast/unknown, still-pending, failed-reconciliation, post-browser intent race, or `EXPIRED`/`CANCELLED` result without that funding proof exits 9 as `claim_link.funding_ambiguous`; follow its retry metadata and never substitute a new key. Only an explicit cancellation before creation plus a successful authoritative miss exits 12 as `claim_link.browser_cancelled`. |
|
|
196
205
|
| `claim-link get <id>` | Fetch one of your own Claim Links by id. Wraps `claim_link`; a non-owned id is indistinguishable from an unknown one. Agent-key scope: `claim_link:read_only`. |
|
|
197
206
|
| `claim-link list` | Your own Claim Links, newest first. Wraps `my_claim_links`. Defaults to the first 50 rows; `--input` paginates and `--all` requests one unpaginated response (still capped at 32 MiB by the CLI); `--status` filters. Agent-key scope: `claim_link:read_only`. |
|
|
198
|
-
| `claim-link preview` | Fee breakdown for a would-be link without creating it. Wraps `claim_link_fee_preview`; mirrors create's asset validation. Agent-key scope: `claim_link:read_only`. |
|
|
199
207
|
| `claim-link status` | Public receiver-facing status snapshot for a bearer claim token/URL. Canonical URLs are parsed locally; a same-origin `/s/` URL is resolved through one credential-free 302. No login — the token is the credential. |
|
|
200
208
|
| `claim-link claim` | Claim a link from a raw token, canonical URL, or same-origin `/s/` short URL: Path A without AllScale account sign-in with `--to <0x address>` (no login), or `--to-wallet` into the authenticated business's AllScale wallet (cookie session). This does not make the transaction anonymous: network, request, compliance, and public blockchain data may still be processed or observable. Only the on-chain-proven `claimed` outcome exits 0; unavailable/expired links exit 12, while pending/unproven payouts exit 9 and must be reconciled before another claim. |
|
|
201
209
|
|
|
@@ -203,7 +211,7 @@ Sender-side reads (`get` / `list` / `preview`) need a CLI login and `claim_link:
|
|
|
203
211
|
|
|
204
212
|
| Command | Description |
|
|
205
213
|
|---|---|
|
|
206
|
-
| `store create` | Create a store for the logged-in business (no store key, no browser ceremony). Wraps `create_store`. Defaults `--business-id` from the session token — the JWT `user_id` for cookie sessions, or the owner business id stored at login for agent-key sessions (re-run `device-login` if an older agent-key login has none stored); `--live` creates a live store (default is sandbox). Returns the API key and
|
|
214
|
+
| `store create` | Create a store for the logged-in business (no store key, no browser ceremony). Wraps `create_store`. Defaults `--business-id` from the session token — the JWT `user_id` for cookie sessions, or the owner business id stored at login for agent-key sessions (re-run `device-login` if an older agent-key login has none stored); `--live` creates a live store (default is sandbox). Returns the API key and an API secret that is **shown only once** on stdout (it cannot be retrieved later — capture it now; the credential itself stays valid). This command does not enroll the store for Payout. Business must have checkout enabled; only one sandbox store is allowed per business. |
|
|
207
215
|
|
|
208
216
|
### Payout
|
|
209
217
|
|
|
@@ -236,6 +244,8 @@ The CLI mints a key for its own session when you log in (`device-login` / `otp-l
|
|
|
236
244
|
- **Default backend**: `https://app.allscale.io` (production). Published builds are configured for their bound AllScale environment. The current implementation accepts a credential-bearing api-base only for exact AllScale origins (plus an explicit loopback origin for development), and validates session credentials against the origin recorded at login. `payout send` uses a separate approved partner API origin selected with `--payout-api-base` / `ALLSCALE_PAYOUT_API_BASE`. Embedded URL userinfo (`user:password@host`) is rejected. Browser targets must be `https://`, or `http://` to loopback only. The current implementation is designed not to follow redirects when a request carries credentials; callers should still verify the destination origin and TLS before approving or transmitting sensitive data.
|
|
237
245
|
- **Sidecar event log** (opt-in, off by default): set **`ALLSCALE_OUTPUT_FILE_PATH=/path/to/run.ndjson`** to append a machine-readable NDJSON event stream for the run, or **`ALLSCALE_OUTPUT_FILE_DIRECTORY=/path/to/dir`** to give every run its own randomly named file in that directory. Events may cover the command, argv fields designed to redact specified authentication secrets, request URLs, responses, refreshes, bridge steps, and errors — intended for agent supervisors and orchestrators that need structured progress without parsing stdout. Redaction is not a guarantee that the log contains no sensitive information.
|
|
238
246
|
|
|
247
|
+
`device-login` writes its short-lived pairing code and URL as the sidecar-v2 `device_authorization` event before polling. Treat that event as sensitive and do not publish it to an untrusted log sink.
|
|
248
|
+
|
|
239
249
|
**Important:** Sidecar logs are designed to redact specified authentication secrets, but may still contain personal, business-confidential, and transaction data. Treat them as sensitive. Do not place them in shared directories or transmit them to third-party agents or support channels without review. Configure appropriate access controls, retention, and secure deletion.
|
|
240
250
|
|
|
241
251
|
On POSIX systems, the current implementation attempts to create files with mode 0600. A directory selected through `ALLSCALE_OUTPUT_FILE_DIRECTORY` is intended to be created owner-only; if it already grants group/other access, the CLI is designed to refuse writing rather than change a potentially shared directory such as `/tmp`. For an explicit `_PATH`, the current implementation does not change permissions on an existing parent directory, so choose a private parent yourself; it is designed to refuse an existing output file unless it is owner-only (rotate/remove an older loose file first). Sidecar output is currently disabled on Windows because Node's mode APIs cannot reliably enforce or verify owner-only NTFS ACLs. **It is still a file on disk that describes your session**: prefer `_DIRECTORY`, which generates an unpredictable filename, over a fixed `_PATH` on a shared machine. A `_PATH` that is a symlink is designed to be refused rather than written through. The file is append-only with no rotation and is currently capped at 64 MB, after which the sidecar is designed to disable itself for the run with a warning on stderr.
|
|
@@ -287,7 +297,7 @@ $ allscale whoami
|
|
|
287
297
|
{
|
|
288
298
|
"error": {
|
|
289
299
|
"code": "auth.no_token",
|
|
290
|
-
"message": "No tokens stored for profile 'default'. Run `allscale device-login --profile
|
|
300
|
+
"message": "No tokens stored for profile 'default'. Run `allscale device-login --profile default` (recommended), or use `allscale otp-login --profile default --email <you@example.com> --scopes <scope> --otp-stdin` for headless automation."
|
|
291
301
|
}
|
|
292
302
|
}
|
|
293
303
|
# exit code: 4
|
|
@@ -303,30 +313,92 @@ exactly one of them, and a mapping is never renumbered once shipped.
|
|
|
303
313
|
| `0` | success | — |
|
|
304
314
|
| `1` | generic / unexpected | `internal` |
|
|
305
315
|
| `2` | caller-actionable input or local limit — fix the invocation and re-run | `input.invalid`, `storage.unavailable`, `transport.response_too_large`, `auth.unknown_profile`, `claim_link.intent_conflict`, `signing.key_unavailable` |
|
|
306
|
-
| `3` | network / transport failure (**see the retry warning below**) | `transport.network`, `transport.timeout`, `auth.credential_changed`, `wallet.bridge_timeout` |
|
|
316
|
+
| `3` | network / transport failure (**see the retry warning below**) | `transport.network`, `transport.timeout`, `auth.credential_changed`, `wallet.bridge_timeout`, `wallet.bridge_op_expired` |
|
|
307
317
|
| `4` | not authenticated — run `allscale device-login` | `auth.no_token` |
|
|
308
318
|
| `5` | credential expired — re-authenticate | `auth.token_expired` |
|
|
309
|
-
| `6` | not fixable by this invocation: permission denied, or a capability gap | `auth.permission_denied`, `wallet.requires_browser_auth
|
|
319
|
+
| `6` | not fixable by this invocation: permission denied, or a capability gap | `auth.permission_denied`, `wallet.requires_browser_auth` |
|
|
310
320
|
| `7` | not found — the object you named does not exist | `not_found` |
|
|
311
321
|
| `8` | rate limited | `rate_limited` |
|
|
312
|
-
| `9` | backend internal, or an **ambiguous** mutation result | `backend.internal`, `claim.payout_ambiguous`, `claim_link.funding_ambiguous`, `wallet.bridge_invalid_response`, `wallet.
|
|
322
|
+
| `9` | backend internal, or an **ambiguous** mutation or transaction result | `backend.internal`, `claim.payout_ambiguous`, `claim_link.funding_ambiguous`, `wallet.bridge_invalid_response`, `wallet.transaction_status_unknown` |
|
|
313
323
|
| `10` | escape hatch disabled — export `ALLSCALE_ALLOW_RAW=1` | `raw.disabled` |
|
|
314
324
|
| `11` | this build's request signature was rejected — **upgrade the CLI** (re-login does not help) | `auth.signature_rejected` |
|
|
315
|
-
| `12` | did not complete; nothing was created | `claim.not_claimable`, `claim.expired`, `claim_link.browser_cancelled`, `user.cancelled` |
|
|
325
|
+
| `12` | did not complete; nothing was created, or an on-chain attempt reverted without transferring | `claim.not_claimable`, `claim.expired`, `claim_link.browser_cancelled`, `user.cancelled`, `wallet.transaction_reverted` |
|
|
316
326
|
|
|
317
327
|
> **An exit code is NOT a retry-safety signal. Branch on the `code` string when
|
|
318
328
|
> you need to decide whether re-running is safe.**
|
|
319
329
|
>
|
|
320
330
|
> Exit `3` is the trap: `transport.network` never reached the server, but
|
|
321
|
-
> `wallet.bridge_timeout`
|
|
322
|
-
> signed and the backend may already have
|
|
323
|
-
> `3` can **double-pay**. Exit `9` mixes a
|
|
324
|
-
> codes and `wallet.
|
|
325
|
-
>
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
331
|
+
> `wallet.bridge_timeout` and `wallet.bridge_op_expired` are **post-signing** —
|
|
332
|
+
> the browser may already have signed and the backend may already have
|
|
333
|
+
> broadcast. Retrying blindly on exit `3` can **double-pay**. Exit `9` mixes a
|
|
334
|
+
> plain 5xx with the `*_ambiguous` codes **and `wallet.transaction_status_unknown`**
|
|
335
|
+
> (a broadcast whose receipt never confirmed — funds may already have moved),
|
|
336
|
+
> all of which must be reconciled against the original idempotency key rather
|
|
337
|
+
> than retried. Exit `12` now also carries a post-signing code:
|
|
338
|
+
> `wallet.transaction_reverted` means the transaction was mined and reverted —
|
|
339
|
+
> nothing transferred, but gas was spent — so treat any retry as a new
|
|
340
|
+
> transfer decision.
|
|
341
|
+
|
|
342
|
+
> **`wallet.bridge_timeout` vs `wallet.bridge_op_expired`.** Both mean "no
|
|
343
|
+
> confirmation came back", but they need different follow-ups:
|
|
344
|
+
>
|
|
345
|
+
> - `wallet.bridge_timeout` — a deadline **the CLI itself** reached, which is not
|
|
346
|
+
> proof the link died. Which one it was decides what helps, and the message says
|
|
347
|
+
> so: your own `--bridge-timeout` running out while the link was still valid (a
|
|
348
|
+
> longer one genuinely helps next run); the link's own lifetime running out (a
|
|
349
|
+
> longer one cannot help, and this is the ONLY case the error message names — it
|
|
350
|
+
> tells you the link is expired or about to be, and that a longer wait is useless;
|
|
351
|
+
> the remedy itself comes later in the message, after the check-your-wallet
|
|
352
|
+
> warning); that same lifetime measured
|
|
353
|
+
> against your machine's clock, when the response carried no server timestamp to
|
|
354
|
+
> check it against (same remedy, but approximate — a fast local clock makes the
|
|
355
|
+
> wait short, a slow one makes it long); the fallback used when the server gave
|
|
356
|
+
> no usable expiry at all; or a clamp at Node's ~24.8-day timer limit.
|
|
357
|
+
> Apart from the lifetime case, these share one message, so don't try to tell
|
|
358
|
+
> them apart by parsing it — when the CLI shortens or ignores your value it says
|
|
359
|
+
> which constraint bound the wait in a `Note:` line on stderr as it starts.
|
|
360
|
+
> - `wallet.bridge_op_expired` — **the server** reported the operation behind the
|
|
361
|
+
> link as expired, so the link is definitively dead. **Raising
|
|
362
|
+
> `--bridge-timeout` cannot help** — it is capped at the link's remaining life
|
|
363
|
+
> precisely because the link cannot outlive its operation. A fresh link is the
|
|
364
|
+
> remedy, but check the wallet/transfer first: this error means the browser may
|
|
365
|
+
> already have signed, so the message puts that warning before the retry advice.
|
|
366
|
+
>
|
|
367
|
+
> The link's remaining life is normally measured against the **server's** clock,
|
|
368
|
+
> not your machine's — the CLI reads the server's own timestamp from the same
|
|
369
|
+
> response — so a wrong local clock cannot shorten your wait or turn a live link
|
|
370
|
+
> into a reported expiry. The CLI's own deadline is set slightly early on purpose,
|
|
371
|
+
> which is why reaching it reports a timeout — saying the link "has expired or is
|
|
372
|
+
> about to" — rather than asserting the link is gone; only the server does that.
|
|
373
|
+
>
|
|
374
|
+
> One exception, and the CLI tells you when you are in it: if something between you
|
|
375
|
+
> and the server strips the timestamp (some proxies do), the CLI has to fall back to
|
|
376
|
+
> your machine's clock, and says so in the note it prints. That reading is
|
|
377
|
+
> approximate in both directions — a fast clock makes the wait shorter than the
|
|
378
|
+
> link's real life, a slow one can make it longer — so it is treated as a bound,
|
|
379
|
+
> never as a fact, and it is additionally capped at 15 minutes so a badly wrong
|
|
380
|
+
> clock cannot buy an open-ended wait. If your waits look wrong, check the clock
|
|
381
|
+
> (`sntp`/`timedatectl`) before anything else.
|
|
382
|
+
>
|
|
383
|
+
> In both cases check the wallet/invoice first: the browser may have signed just
|
|
384
|
+
> before the deadline.
|
|
385
|
+
|
|
386
|
+
Exit `7` covers both a REST `404` and a GraphQL response whose backend code
|
|
387
|
+
means "no such object" — the two paths are deliberately aligned, so
|
|
388
|
+
`allscale transaction get <unknown-id>` and a login for an unregistered
|
|
389
|
+
account both exit `7`.
|
|
390
|
+
|
|
391
|
+
One documented asymmetry, because "not found" does not always describe an
|
|
392
|
+
object **you** named: on the login path, "this email has no AllScale account"
|
|
393
|
+
is exit `7`, but a data query failing because the signed-in account has no
|
|
394
|
+
business is exit `2` — there is no id you could supply to fix the second, so
|
|
395
|
+
sending you hunting for one would be wrong. Two further backend "not found"
|
|
396
|
+
conditions are likewise **not** exit `7` on the GraphQL path: an unknown agent
|
|
397
|
+
key and an un-provisioned Turnkey wallet sub-org both fall into the generic
|
|
398
|
+
exit `2` bucket there. (A *rejected* credential is a different thing and does
|
|
399
|
+
exit `5`, but that comes from a real `401`, not from translating a backend
|
|
400
|
+
"not found" code — so do not branch on exit 5 for these conditions on a
|
|
401
|
+
GraphQL call.)
|
|
330
402
|
|
|
331
403
|
**Under `--json`, stderr is parseable too — for `payout send` and
|
|
332
404
|
`device-login`.** On those, human progress prose is suppressed entirely and
|
|
@@ -416,9 +488,9 @@ Highest priority first:
|
|
|
416
488
|
2. `--profile <name>` flag → keychain entry for that profile. A name with no
|
|
417
489
|
entry in `~/.allscale/config.toml` errors with `auth.unknown_profile`
|
|
418
490
|
(exit 2) when no credential is found for it and at least one profile is
|
|
419
|
-
saved
|
|
420
|
-
not
|
|
421
|
-
`auth.no_token`.)
|
|
491
|
+
saved. The error names only the requested profile; saved profile names are
|
|
492
|
+
not disclosed. A typo'd profile is an input error, not a login prompt. (An
|
|
493
|
+
empty registry is a fresh machine: that stays `auth.no_token`.)
|
|
422
494
|
3. `ALLSCALE_PROFILE` env var → keychain entry (same `auth.unknown_profile`
|
|
423
495
|
rule as the flag).
|
|
424
496
|
4. Default profile from `~/.allscale/config.toml`
|
|
@@ -437,7 +509,7 @@ discriminator, not the exit number.
|
|
|
437
509
|
- `device-login` (device-pairing OAuth) / `otp-login` (terminal OTP for headless shells), and `wallet send` (Turnkey-direct EVM withdraw through the `/cli` bridge).
|
|
438
510
|
- Current CLI login flows issue scoped agent-key sessions: `device-login` and `otp-login` both mint an agent API key checked against its granted scopes; `scope` reports the granted set + key expiry.
|
|
439
511
|
- Agent-key sessions store the owner `business_id` at login, so `--business-id` auto-fills (no JWT needed) and `whoami` prints a key-mode identity instead of erroring.
|
|
440
|
-
- Store creation returns its
|
|
512
|
+
- Store creation returns its API secret (shown once, not retrievable later) through the structured stdout result.
|
|
441
513
|
|
|
442
514
|
## Security
|
|
443
515
|
|
package/bin/dev.js
CHANGED
|
@@ -7,5 +7,15 @@ require("ts-node/register");
|
|
|
7
7
|
const oclif = require("@oclif/core");
|
|
8
8
|
const { runOclif } = require("./run-oclif");
|
|
9
9
|
|
|
10
|
+
// The audit-lifecycle module from src/, resolvable because ts-node is registered
|
|
11
|
+
// above. Deliberately NOT dist/: a dev run must audit through the code it is
|
|
12
|
+
// actually executing, or a stale build would be doing the writing.
|
|
13
|
+
let audit;
|
|
14
|
+
try {
|
|
15
|
+
audit = require("../src/lib/output/audit-lifecycle");
|
|
16
|
+
} catch {
|
|
17
|
+
audit = undefined;
|
|
18
|
+
}
|
|
19
|
+
|
|
10
20
|
oclif.settings.debug = true;
|
|
11
|
-
void runOclif(oclif, { dir: __dirname });
|
|
21
|
+
void runOclif(oclif, { dir: __dirname, audit });
|
package/bin/run-oclif.js
CHANGED
|
@@ -15,41 +15,230 @@ function isUnknownCommandError(oclif, error) {
|
|
|
15
15
|
);
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
18
|
+
const UNKNOWN_COMMAND_MESSAGE =
|
|
19
|
+
"Unknown command. Run `allscale --help` to list available commands.";
|
|
20
|
+
|
|
21
|
+
// NOTE: this file ships VERBATIM in the npm tarball (`files: ["bin", …]`),
|
|
22
|
+
// unlike `src/`, which reaches users only as minified `dist/`. Its comments are
|
|
23
|
+
// therefore public artifact and must stay free of internal references — issue
|
|
24
|
+
// numbers, review shorthand, `docs/*.md` paths, design-doc sections. The
|
|
25
|
+
// publish gate enforces it (`scripts/public-content-policy.json`, tarball
|
|
26
|
+
// scope); explain the WHY here and leave the provenance to the commit history.
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The two OPT-IN machine signals: `--json` (honouring the `--` separator) and
|
|
30
|
+
* `ALLSCALE_CONTENT_TYPE=json`.
|
|
31
|
+
*
|
|
32
|
+
* Extracted because TWO callers below need it and they are deliberately
|
|
33
|
+
* DIFFERENT predicates — collapsing them would be a bug, duplicating the parsing
|
|
34
|
+
* would be the drift this PR keeps closing:
|
|
35
|
+
*
|
|
36
|
+
* - `machine` in `emitUnknownCommandError` — chooses the envelope's FORM
|
|
37
|
+
* (compact vs indented). Only the declared signals belong
|
|
38
|
+
* there: a caller who merely piped stdout did not ask for compact JSON.
|
|
39
|
+
* - `isMachineCallerLauncher` — decides WHETHER a human-facing warning is
|
|
40
|
+
* emitted at all, so it must also honour the non-opt-in signals (a
|
|
41
|
+
* configured sidecar, non-TTY streams) exactly as `isMachineCaller` does.
|
|
42
|
+
*
|
|
43
|
+
* The `ALLSCALE_` prefix is hard-coded, and cannot not be: `help.ts` resolves the
|
|
44
|
+
* same vars through `config.scopedEnvVar("CONTENT_TYPE")`, which derives the
|
|
45
|
+
* prefix from the bin name, but this file runs BEFORE oclif loads and has no
|
|
46
|
+
* config to ask. It matches today (`oclif.bin` is `allscale`), and
|
|
47
|
+
* `tests/tsup-entries.test.ts` pins that bin name with a message pointing here,
|
|
48
|
+
* so a rename turns a test red instead of silently desyncing this file.
|
|
49
|
+
*/
|
|
50
|
+
function declaredMachineOutput() {
|
|
27
51
|
const raw = process.argv.slice(2);
|
|
28
|
-
// After `--` everything is a passthrough operand, so a literal `--json`
|
|
29
|
-
//
|
|
52
|
+
// After `--` everything is a passthrough operand, so a literal `--json` there
|
|
53
|
+
// is data, not a flag.
|
|
30
54
|
const passThrough = raw.indexOf("--");
|
|
31
55
|
const json = raw.indexOf("--json");
|
|
32
|
-
|
|
33
|
-
//
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
56
|
+
if (json !== -1 && (passThrough === -1 || json < passThrough)) return true;
|
|
57
|
+
// Case-insensitive, matching oclif's own CONTENT_TYPE check and help.ts.
|
|
58
|
+
return (process.env.ALLSCALE_CONTENT_TYPE ?? "").toLowerCase() === "json";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Is this caller a machine? A plain-CommonJS mirror of `isMachineCaller`
|
|
63
|
+
* (`src/lib/output/machine-mode.ts`), which this file cannot import: it runs
|
|
64
|
+
* before oclif loads, from a `.js` launcher with no TS pipeline.
|
|
65
|
+
*
|
|
66
|
+
* ALL THREE of that helper's signals, not just the TTY one. An earlier version
|
|
67
|
+
* checked only `interactive` while its comment claimed parity — and the gap was
|
|
68
|
+
* trivially reachable: an engineer at a real terminal with
|
|
69
|
+
* `ALLSCALE_OUTPUT_FILE_PATH` set is a MACHINE by `isMachineCaller`
|
|
70
|
+
* (`sidecarConfigured()` is OR'd ahead of `interactive`), yet the TTY-only gate
|
|
71
|
+
* called them human and wrote to their stderr — and that population is the one
|
|
72
|
+
* most likely to be exercising this path.
|
|
73
|
+
*
|
|
74
|
+
* If `isMachineCaller` gains a fourth signal, this must gain it too;
|
|
75
|
+
* `tests/launcher-unknown-command.test.ts` pins each one.
|
|
76
|
+
*/
|
|
77
|
+
function isMachineCallerLauncher() {
|
|
78
|
+
if (declaredMachineOutput()) return true;
|
|
79
|
+
// Truthiness, matching `sidecarConfigured()` — an empty value means no sidecar
|
|
80
|
+
// to both, so gate and opener cannot drift.
|
|
81
|
+
if (
|
|
82
|
+
process.env.ALLSCALE_OUTPUT_FILE_PATH ||
|
|
83
|
+
process.env.ALLSCALE_OUTPUT_FILE_DIRECTORY
|
|
84
|
+
) {
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
const interactive =
|
|
88
|
+
process.stdin.isTTY === true && process.stdout.isTTY === true;
|
|
89
|
+
return !interactive;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* A thrown value's CLASS as a bounded, identifier-shaped token — never its
|
|
94
|
+
* message.
|
|
95
|
+
*
|
|
96
|
+
* Three guards, each closing a hole the first version left open while its comment
|
|
97
|
+
* claimed otherwise:
|
|
98
|
+
*
|
|
99
|
+
* - The read is WRAPPED. `e.name` is a property access, and a hostile or
|
|
100
|
+
* instrumented getter throws — inside the `catch (e)` that calls this, where
|
|
101
|
+
* nothing further would catch it, so the process would die on Node's default
|
|
102
|
+
* handler with a raw stack and exit 1 instead of the `{error}` envelope and
|
|
103
|
+
* exit 2. That is the failure mode this file already guards against twice.
|
|
104
|
+
* - The value is SHAPE-CHECKED, not merely type-checked. `typeof === "string"`
|
|
105
|
+
* bounds nothing: a thrown plain object, or a wrapped fs/Abort error, can carry
|
|
106
|
+
* a `.name` holding a path or other caller-influenced text — which would
|
|
107
|
+
* reopen on `.name` the very arbitrary-text leak that moving off `.message`
|
|
108
|
+
* was meant to close.
|
|
109
|
+
* - It is LENGTH-BOUNDED, so a pathological `name` cannot flood stderr.
|
|
110
|
+
*
|
|
111
|
+
* Anything failing those becomes the literal "Error", which loses nothing a
|
|
112
|
+
* reader needs: the actionable part is "the audit write failed", not the class.
|
|
113
|
+
*/
|
|
114
|
+
function errorClass(e) {
|
|
115
|
+
try {
|
|
116
|
+
const name = e && typeof e === "object" ? e.name : undefined;
|
|
117
|
+
if (typeof name === "string" && /^[A-Za-z][A-Za-z0-9_]{0,63}$/.test(name)) {
|
|
118
|
+
return name;
|
|
119
|
+
}
|
|
120
|
+
} catch {
|
|
121
|
+
// A throwing getter is precisely why this is wrapped.
|
|
122
|
+
}
|
|
123
|
+
return "Error";
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function warnAuditUnavailable(reason) {
|
|
127
|
+
// The gate call is INSIDE the try, for the same reason the `typeof` read below
|
|
128
|
+
// it is: it touches `process.argv`, two env vars, and two `.isTTY` properties,
|
|
129
|
+
// any of which could be an instrumented getter that throws. Called outside, a
|
|
130
|
+
// throw here would escape `emitUnknownCommandError` AND `runOclif`'s catch —
|
|
131
|
+
// and this function is invoked from that catch, so the second call would throw
|
|
132
|
+
// again with nothing left to catch it. That turns the FAIL-SOFT invariant this
|
|
133
|
+
// whole path is built on into the exact crash it promises to prevent.
|
|
134
|
+
//
|
|
135
|
+
// On a throw we treat the caller as a MACHINE (stay silent). Silence is the
|
|
136
|
+
// safe default: a spurious warning would corrupt the single-JSON-document
|
|
137
|
+
// contract for a consumer that may well be one, whereas a missing warning
|
|
138
|
+
// costs a human one diagnostic line on an already-broken build.
|
|
139
|
+
try {
|
|
140
|
+
if (isMachineCallerLauncher()) return;
|
|
141
|
+
} catch {
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
try {
|
|
145
|
+
process.stderr.write(
|
|
146
|
+
`[allscale] audit lifecycle unavailable (${reason}); the refusal was ` +
|
|
147
|
+
"reported but no audit events were written.\n",
|
|
148
|
+
);
|
|
149
|
+
} catch {
|
|
150
|
+
// Even stderr can fail in pathological cases; swallow.
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* A refusal here must write the same sidecar audit lifecycle every other refusal
|
|
156
|
+
* writes (`session-start` + `error` + `command-end ok:false`), or a supervisor
|
|
157
|
+
* consuming the stream cannot tell `allscale bogus` from a CLI that never
|
|
158
|
+
* started — the defect this module exists to close, at the one refusal shape
|
|
159
|
+
* that never reaches the help class.
|
|
160
|
+
*
|
|
161
|
+
* `audit` is INJECTED by the entrypoint rather than required here because this
|
|
162
|
+
* file is shared by two entries that disagree about where the compiled code
|
|
163
|
+
* lives: `bin/run.js` runs `dist/`, `bin/dev.js` runs `src/` through ts-node.
|
|
164
|
+
* Guessing (try dist, fall back to src) would silently audit a dev run through
|
|
165
|
+
* a stale `dist/` build, so each entry passes the form it is actually executing.
|
|
166
|
+
*
|
|
167
|
+
* FAIL-SOFT BY DESIGN: a missing or throwing audit module must never turn a
|
|
168
|
+
* clean refusal into a crash. The `{error}` envelope and exit code are the
|
|
169
|
+
* contract; the audit stream is an additional channel, and losing it is strictly
|
|
170
|
+
* better than losing the refusal itself.
|
|
171
|
+
*/
|
|
172
|
+
function emitUnknownCommandError(audit) {
|
|
173
|
+
// Match `serializeErrorEnvelope`'s mode-dependent rendering: compact for a
|
|
174
|
+
// machine reading stderr as one JSON object per line, indented for a human
|
|
175
|
+
// Only the DECLARED signals decide the FORM — a piped caller
|
|
176
|
+
// did not ask for compact JSON — which is why this uses
|
|
177
|
+
// `declaredMachineOutput()` and not the wider `isMachineCallerLauncher()` that
|
|
178
|
+
// gates the warning.
|
|
179
|
+
const machine = declaredMachineOutput();
|
|
41
180
|
process.stderr.write(
|
|
42
181
|
`${JSON.stringify(
|
|
43
|
-
{
|
|
44
|
-
error: {
|
|
45
|
-
code: "input.invalid",
|
|
46
|
-
message: "Unknown command. Run `allscale --help` to list available commands.",
|
|
47
|
-
},
|
|
48
|
-
},
|
|
182
|
+
{ error: { code: "input.invalid", message: UNKNOWN_COMMAND_MESSAGE } },
|
|
49
183
|
null,
|
|
50
184
|
machine ? undefined : 2,
|
|
51
185
|
)}\n`,
|
|
52
186
|
);
|
|
187
|
+
// FAIL-SOFT, BUT NOT SILENT — and the ABSENCE of the module is the case that
|
|
188
|
+
// actually matters, which is why it is tested for rather than caught.
|
|
189
|
+
//
|
|
190
|
+
// The entrypoint requires the audit module inside its own try/catch and passes
|
|
191
|
+
// `undefined` when that fails, so `audit?.auditRefusal?.(...)` would
|
|
192
|
+
// short-circuit and throw NOTHING: a catch block alone reports a broken
|
|
193
|
+
// `dist/` exactly never. That is the degraded state worth announcing — a
|
|
194
|
+
// shipped build with a stale or partial `dist/` silently reverting to "correct
|
|
195
|
+
// envelope, empty audit file", giving the supervisor no signal, which is the
|
|
196
|
+
// same defect returning one layer up. (Verified by deleting the
|
|
197
|
+
// built module: an earlier version of this code warned on zero of those runs.)
|
|
198
|
+
//
|
|
199
|
+
// `sidecar.ts` writes a one-line `[allscale] …` warning for every open/write
|
|
200
|
+
// failure it fails-soft on; a missing or unusable module is the same class of
|
|
201
|
+
// event, so it gets the same treatment and the same prefix.
|
|
202
|
+
// INSIDE the try, not above it. `typeof audit?.auditRefusal` reads a property,
|
|
203
|
+
// and a property read can throw — a getter on the module object would do it.
|
|
204
|
+
// Evaluated one line higher (as it was), that throw escaped
|
|
205
|
+
// emitUnknownCommandError AND runOclif's catch, so the process died on Node's
|
|
206
|
+
// default handler: a raw stack after the envelope and exit 1 instead of 2.
|
|
207
|
+
// Strictly worse than the silent stream this guard replaced, from the one line
|
|
208
|
+
// the fail-soft design forgot to cover. Not reachable through the plain data
|
|
209
|
+
// property `require()` yields today — which is exactly why it belongs inside
|
|
210
|
+
// the try rather than relying on that staying true.
|
|
211
|
+
try {
|
|
212
|
+
if (typeof audit?.auditRefusal === "function") {
|
|
213
|
+
// No command id: oclif could not resolve one, which is the whole reason we
|
|
214
|
+
// are here. rawArgv defaults to process.argv.slice(2) and is redacted
|
|
215
|
+
// inside the helper, so no secret reaches the file even though this call
|
|
216
|
+
// site is plain JS with no access to the redactor.
|
|
217
|
+
audit.auditRefusal({
|
|
218
|
+
code: "input.invalid",
|
|
219
|
+
message: UNKNOWN_COMMAND_MESSAGE,
|
|
220
|
+
exitCode: 2,
|
|
221
|
+
});
|
|
222
|
+
} else {
|
|
223
|
+
warnAuditUnavailable("the audit module did not load");
|
|
224
|
+
}
|
|
225
|
+
} catch (e) {
|
|
226
|
+
// A throw FROM the audit write (an unwritable sidecar path, say) is the same
|
|
227
|
+
// degradation from the caller's point of view, so it is reported the same
|
|
228
|
+
// way. It must never replace the refusal with a stack.
|
|
229
|
+
//
|
|
230
|
+
// SHAPE, NOT TEXT. Every `[allscale]` warning in sidecar.ts goes
|
|
231
|
+
// through `sanitizeDiagnosticString`, because free text can carry a
|
|
232
|
+
// credential-shaped value that key-name masking cannot reach — and the text
|
|
233
|
+
// available here is a filesystem error built from the caller-supplied
|
|
234
|
+
// ALLSCALE_OUTPUT_FILE_PATH. This launcher cannot import that scrubber (it
|
|
235
|
+
// runs before oclif loads — the same reason `auditRefusal` redacts argv
|
|
236
|
+
// internally), so the proportionate fix is not to reach for the scrubber but
|
|
237
|
+
// to stop passing arbitrary text through: report the error's CLASS only.
|
|
238
|
+
// Unreachable today, since initSidecar and writeSidecar both catch their own
|
|
239
|
+
// failures — which is precisely why it must not depend on that staying true.
|
|
240
|
+
warnAuditUnavailable(`the audit write threw ${errorClass(e)}`);
|
|
241
|
+
}
|
|
53
242
|
process.exitCode = 2;
|
|
54
243
|
}
|
|
55
244
|
|
|
@@ -59,7 +248,7 @@ async function runOclif(oclif, options = {}) {
|
|
|
59
248
|
await oclif.flush();
|
|
60
249
|
} catch (error) {
|
|
61
250
|
if (isUnknownCommandError(oclif, error)) {
|
|
62
|
-
emitUnknownCommandError();
|
|
251
|
+
emitUnknownCommandError(options.audit);
|
|
63
252
|
return;
|
|
64
253
|
}
|
|
65
254
|
await oclif.Errors.handle(error);
|
package/bin/run.js
CHANGED
|
@@ -3,4 +3,22 @@
|
|
|
3
3
|
const oclif = require("@oclif/core");
|
|
4
4
|
const { runOclif } = require("./run-oclif");
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
// The audit-lifecycle module, in the form THIS entry executes (compiled dist/).
|
|
7
|
+
// Injected rather than resolved inside run-oclif.js, which is shared with the
|
|
8
|
+
// ts-node dev entry — see the comment on emitUnknownCommandError. Wrapped
|
|
9
|
+
// because an unknown-command refusal must still work if dist/ is incomplete.
|
|
10
|
+
//
|
|
11
|
+
// This path only exists because `src/lib/output/audit-lifecycle.ts` is a declared
|
|
12
|
+
// tsup entry; tsup emits only declared entries, so without it the module lives
|
|
13
|
+
// inside other bundles and is unreachable by path. With `splitting: false` this
|
|
14
|
+
// bundle carries its own copy of the sidecar module state, which is correct here:
|
|
15
|
+
// an unknown command never constructs a Command, so nothing else has opened the
|
|
16
|
+
// stream, and this copy opens, writes, and closes it alone.
|
|
17
|
+
let audit;
|
|
18
|
+
try {
|
|
19
|
+
audit = require("../dist/lib/output/audit-lifecycle");
|
|
20
|
+
} catch {
|
|
21
|
+
audit = undefined;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
void runOclif(oclif, { dir: __dirname, audit });
|