@arcadiasystems/morse-cli 0.8.0 → 0.10.0
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/CHANGELOG.md +38 -0
- package/README.md +55 -17
- package/dist/index.js +173 -23
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,44 @@ All notable changes to `@arcadiasystems/morse-cli` are documented here. The
|
|
|
4
4
|
format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this
|
|
5
5
|
project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
+
## [0.10.0] - 2026-09-10
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **Encrypted commands work on mainnet when you bring a Seal credential.** Mainnet pins no key servers because every operator there is commercial, and the CLI previously had no way to be pointed at one, so `entry add-encrypted`, `entry decrypt` and `file upload --encrypt` simply could not run there.
|
|
12
|
+
|
|
13
|
+
`MORSE_SEAL_API_KEY` supplies a credential for Mysten's committee, whose object id and aggregator the SDK already exports; `MORSE_SEAL_API_KEY_NAME` overrides the header for operators that want something other than `X-API-Key`. `MORSE_SEAL_KEY_SERVERS` takes a JSON array of `{ objectId, weight }` for a non-committee operator and wins when both are set. Neither is a flag: a credential in argv leaks through `ps` and shell history, the same reason the signing key is not one.
|
|
14
|
+
|
|
15
|
+
- **The credential is checked before anything is encrypted.** Seal reads key-server public keys from chain but fetches key shares from the operator, so a wrong credential would let `encrypt` succeed and fail only at `decrypt`, leaving the user paying to store content nobody can read. That exact shape shipped once as the mainnet Seal default, and an unchecked API key would have reintroduced it user-side. Only an explicit `401` or `403` refuses; any other outcome, including the check being unreachable, proceeds, because a flaky pre-flight must never block a working key.
|
|
16
|
+
|
|
17
|
+
Verified live: a full encrypt and decrypt round trip on testnet through `MORSE_SEAL_KEY_SERVERS` (the same code path a credential takes), and a bogus `MORSE_SEAL_API_KEY` refused against the real Mysten aggregator with the pre-flight message rather than producing unreadable ciphertext.
|
|
18
|
+
|
|
19
|
+
## [0.9.0] - 2026-09-10
|
|
20
|
+
### Fixed (config state, found in a whole-package bug hunt)
|
|
21
|
+
|
|
22
|
+
- **A one-off `--network` permanently rewrote the profile's stored network.** `updateActiveProfile` resolved the network the same way `resolveSettings` does (flag > env > stored > default) and then wrote the result back, even though the caller was only changing the account, publication or collection. So `morse --network testnet use --clear` against a mainnet profile left `"network": "testnet"` on disk, and every later command silently targeted the wrong chain with nothing said. Every active-state mutation was affected: `account import`, `account use`, `use`, `use --clear`, `publication create`, `publication delete`, `collection create`, `collection delete`. An existing profile now keeps its stored network; only a profile being created takes the resolved one. Flags and env vars are per-invocation overrides again, as `CLAUDE.md`'s precedence rule always said.
|
|
23
|
+
- **`config add` on an existing profile destroyed every field it was not given.** It replaced the profile object instead of merging, so `config add default --network testnet` on a profile that had an `rpc`, `uploadRelay`, `account`, `publication` or `collection` silently dropped all of them. The command has always been documented as "create or **update**". It merges now.
|
|
24
|
+
- **`config remove` deleted a profile with no confirmation.** Every other destructive command confirms and honours `--yes`; this one took no `GlobalOptions` at all, so there was not even a flag to skip. It deletes a profile plus its account, publication and collection links and can silently reassign the default, so it now says which profile and, when relevant, what the default becomes.
|
|
25
|
+
- **Collection selection ignored which publication a collection belonged to.** `collection create` selected the new collection as active even when created under an explicit `-P` pointing at some other publication, pairing the active publication with a collection it does not contain. `collection delete` had the mirror bug, clearing a still-valid active collection whenever a same-named one was deleted from an unrelated publication. Collection names are scoped per publication, so both now check the publication too. The existing test asserted the old behaviour and passed vacuously, with no profile written; it has been given a real one and joined by cases for both mismatch directions.
|
|
26
|
+
- `file register --public --recipient` is now refused, matching `file upload`. A public file is readable by anyone, so a recipient list on one is a contradiction rather than a no-op.
|
|
27
|
+
- `--max-tip` help text says it only applies alongside `--upload-relay`.
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
### Fixed
|
|
31
|
+
|
|
32
|
+
- **`morse file list` works again, on every network.** It walked `suix_queryEvents` over JSON-RPC, which public Sui fullnodes have retired: they answer every JSON-RPC method with "Method not found". The command has been failing on testnet and mainnet alike unless the user passed `--indexer-url` at a source that still spoke the old protocol.
|
|
33
|
+
|
|
34
|
+
gRPC is the documented replacement for most Sui reads, but `@mysten/sui`'s gRPC client exposes no event API at all, so it could not be the answer here. Events now come from a Sui GraphQL endpoint, defaulting to the canonical Mysten one for the network. `--indexer-url` still overrides it and now means "a Sui GraphQL endpoint".
|
|
35
|
+
|
|
36
|
+
The querier is hand-rolled over `fetch` against one query with one cursor, rather than pulling in a GraphQL client, keeping the dependency surface as-is. A GraphQL error or a non-2xx response raises a network `CliError` (exit 5) rather than an empty page, because reporting "No files" for a broken endpoint is the worst available answer.
|
|
37
|
+
|
|
38
|
+
Verified live: a file uploaded to testnet appears in `file list` with no `--indexer-url`, `--hydrate` and `--json` both work against it, and deleting the file reconciles it back out of the listing.
|
|
39
|
+
|
|
40
|
+
### Changed
|
|
41
|
+
|
|
42
|
+
- `--indexer-url` now expects a Sui GraphQL endpoint rather than a `suix_queryEvents` JSON-RPC source. Anyone relying on the old flag against a JSON-RPC indexer needs a GraphQL one instead; the previous behaviour could not have been working against a public fullnode regardless.
|
|
43
|
+
- Listing on localnet requires `--indexer-url`, since there is no canonical GraphQL endpoint for it. Previously it would have used the local RPC URL and failed.
|
|
44
|
+
|
|
7
45
|
## [0.8.0] - 2026-09-10
|
|
8
46
|
|
|
9
47
|
Usability pass. Nothing is renamed or removed; every existing spelling keeps working.
|
package/README.md
CHANGED
|
@@ -6,15 +6,14 @@ content entries from your terminal, signing with a locally encrypted key.
|
|
|
6
6
|
Content is stored on [Walrus](https://walrus.xyz); private entries are encrypted
|
|
7
7
|
with [Seal](https://github.com/MystenLabs/seal).
|
|
8
8
|
|
|
9
|
-
> Status: v0.
|
|
9
|
+
> Status: v0.10.0. Mainnet and testnet are both supported for public content;
|
|
10
10
|
> the command surface is stable.
|
|
11
11
|
>
|
|
12
|
-
> **Encrypted commands
|
|
13
|
-
>
|
|
14
|
-
>
|
|
15
|
-
>
|
|
16
|
-
>
|
|
17
|
-
> mainnet with an explanation.
|
|
12
|
+
> **Encrypted commands need Seal key servers.** Testnet has an open set, so
|
|
13
|
+
> they work there out of the box. Mainnet has none, because every mainnet Seal
|
|
14
|
+
> operator is commercial: supply your own credential via `MORSE_SEAL_API_KEY`
|
|
15
|
+
> (see [Seal on mainnet](#seal-on-mainnet)), or these commands exit 2 with an
|
|
16
|
+
> explanation.
|
|
18
17
|
>
|
|
19
18
|
> Everything that does not encrypt works on both networks, including
|
|
20
19
|
> `file upload --public`, `file download` of a public file, and the whole
|
|
@@ -114,6 +113,9 @@ Environment variables (override the config file, overridden by flags):
|
|
|
114
113
|
| `MORSE_NETWORK` | `--network` | `mainnet`, `testnet`, or `localnet`. |
|
|
115
114
|
| `MORSE_WALRUS_UPLOAD_RELAY` | `--upload-relay` | Upload through a Walrus relay instead of the direct fanout. `auto` picks the canonical relay for the network. |
|
|
116
115
|
| `MORSE_WALRUS_MAX_TIP` | `--max-tip` | Cap the relay's per-upload tip, in MIST. Defaults to 10000000 (0.01 SUI). |
|
|
116
|
+
| `MORSE_SEAL_API_KEY` | (none) | Credential for Mysten's Seal committee, enabling encrypted commands on mainnet. |
|
|
117
|
+
| `MORSE_SEAL_API_KEY_NAME` | (none) | Header name for that credential. Defaults to `X-API-Key`. |
|
|
118
|
+
| `MORSE_SEAL_KEY_SERVERS` | (none) | JSON array of `{ objectId, weight }` for a non-committee Seal operator. Wins over `MORSE_SEAL_API_KEY`. |
|
|
117
119
|
| `MORSE_RPC_URL` | `--rpc` | Sui RPC URL override. |
|
|
118
120
|
| `MORSE_ADDRESS` | (no flag) | Active account address, selecting which keystore to use. |
|
|
119
121
|
| `MORSE_PUBLICATION` | `-P, --publication` | Active publication id. |
|
|
@@ -281,13 +283,13 @@ also returns the raw `sealIdPrefix` and `sealNonce` as hex, usable as `--prefix`
|
|
|
281
283
|
|
|
282
284
|
`file list` reconstructs the file set from contract events. `RecipientFile`
|
|
283
285
|
objects have no on-chain owner index for the shared case, so listing is
|
|
284
|
-
event-derived, not a direct query.
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
lag and retention). Summary rows omit `blobId`/`blobObjectId`; add `--hydrate`
|
|
286
|
+
event-derived, not a direct query. The command reads events from a Sui GraphQL
|
|
287
|
+
endpoint, defaulting to the canonical Mysten one for the network. It used to use
|
|
288
|
+
`suix_queryEvents` over JSON-RPC, which public fullnodes have since retired;
|
|
289
|
+
`@mysten/sui`'s gRPC client exposes no event API, so GraphQL is the transport
|
|
290
|
+
that remains. Point `--indexer-url <url>` at a different GraphQL endpoint (a
|
|
291
|
+
self-hosted indexer, a third-party endpoint) to stay in control. Results are
|
|
292
|
+
best-effort and eventually consistent (subject to indexer lag and retention). Summary rows omit `blobId`/`blobObjectId`; add `--hydrate`
|
|
291
293
|
to fetch the full record per file (one read each) when you need them.
|
|
292
294
|
|
|
293
295
|
## Output and scripting
|
|
@@ -348,9 +350,10 @@ to fetch the full record per file (one read each) when you need them.
|
|
|
348
350
|
an operator, use the SDK directly for now.
|
|
349
351
|
- `localnet` is accepted as a network but has no canonical deployment, so it
|
|
350
352
|
fails at startup unless you point the SDK at your own package and registry.
|
|
351
|
-
- `file list`
|
|
352
|
-
|
|
353
|
-
|
|
353
|
+
- `file list` reads events from a Sui GraphQL endpoint, defaulting to the
|
|
354
|
+
canonical one for the network. `--indexer-url` points it at a different
|
|
355
|
+
GraphQL endpoint. There is no canonical endpoint for localnet, so listing
|
|
356
|
+
there requires the flag.
|
|
354
357
|
|
|
355
358
|
## Command spellings
|
|
356
359
|
|
|
@@ -371,6 +374,41 @@ for works. These are aliases, not separate commands:
|
|
|
371
374
|
|
|
372
375
|
`publication` also answers to `pub`.
|
|
373
376
|
|
|
377
|
+
## Seal on mainnet
|
|
378
|
+
|
|
379
|
+
Encrypted commands need Seal key servers. Testnet pins an open set, so they
|
|
380
|
+
work there with no setup. Mainnet pins none: every mainnet Seal operator is
|
|
381
|
+
commercial, so you bring your own.
|
|
382
|
+
|
|
383
|
+
The usual route is a credential for Mysten's decentralized committee, which
|
|
384
|
+
the CLI already knows the object id and aggregator for:
|
|
385
|
+
|
|
386
|
+
```sh
|
|
387
|
+
export MORSE_SEAL_API_KEY=your-key-here
|
|
388
|
+
morse --network mainnet file upload ./secret.pdf --encrypt -r 0xrecipient
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
For an operator that is not the committee, give the servers directly:
|
|
392
|
+
|
|
393
|
+
```sh
|
|
394
|
+
export MORSE_SEAL_KEY_SERVERS='[{"objectId":"0x...","weight":1}]'
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
Entries may carry `aggregatorUrl`, `apiKeyName` and `apiKey`; Seal requires
|
|
398
|
+
`aggregatorUrl` on a committee-mode server and forbids it on an independent
|
|
399
|
+
one. `MORSE_SEAL_KEY_SERVERS` takes precedence when both are set.
|
|
400
|
+
|
|
401
|
+
**Neither is a flag, deliberately.** A credential in argv leaks through `ps`
|
|
402
|
+
and shell history, the same reason the signing key is never a flag either.
|
|
403
|
+
|
|
404
|
+
Before encrypting anything, the CLI checks the credential against the operator
|
|
405
|
+
and refuses if it comes back `401` or `403`. This matters more than it looks:
|
|
406
|
+
Seal reads key-server public keys from chain but fetches key *shares* from the
|
|
407
|
+
operator, so a bad credential would let encryption succeed and fail only at
|
|
408
|
+
decryption, leaving you paying to store content nobody can ever read. Any other
|
|
409
|
+
outcome, including the check being offline, proceeds normally, because a flaky
|
|
410
|
+
check must never block a working key.
|
|
411
|
+
|
|
374
412
|
## When uploads fail: the upload relay
|
|
375
413
|
|
|
376
414
|
A Walrus upload pushes slivers to every storage node in the committee at once,
|
package/dist/index.js
CHANGED
|
@@ -148,7 +148,7 @@ import { Command } from "commander";
|
|
|
148
148
|
// package.json
|
|
149
149
|
var package_default = {
|
|
150
150
|
name: "@arcadiasystems/morse-cli",
|
|
151
|
-
version: "0.
|
|
151
|
+
version: "0.10.0",
|
|
152
152
|
description: "Command-line interface for the Morse decentralized CMS on Sui.",
|
|
153
153
|
license: "MIT",
|
|
154
154
|
type: "module",
|
|
@@ -203,7 +203,7 @@ var package_default = {
|
|
|
203
203
|
prepublishOnly: "bun run check && bun run build"
|
|
204
204
|
},
|
|
205
205
|
dependencies: {
|
|
206
|
-
"@arcadiasystems/morse-sdk": "^0.
|
|
206
|
+
"@arcadiasystems/morse-sdk": "^0.8.0",
|
|
207
207
|
"@mysten/seal": "1.1.3",
|
|
208
208
|
"@mysten/sui": "2.16.2",
|
|
209
209
|
"@mysten/walrus": "1.1.6",
|
|
@@ -220,7 +220,7 @@ var package_default = {
|
|
|
220
220
|
var DEFAULT_MAX_TIP_MIST = 1e7;
|
|
221
221
|
function buildProgram() {
|
|
222
222
|
const program = new Command;
|
|
223
|
-
program.name("morse").description("Command-line interface for the Morse decentralized CMS on Sui.").version(package_default.version, "-V, --version", "Print the version and exit").option("--network <network>", "Sui network: mainnet, testnet, or localnet [env: MORSE_NETWORK]").option("-p, --profile <name>", "Config profile to use [env: MORSE_PROFILE]").option("--rpc <url>", "Override the Sui RPC URL [env: MORSE_RPC_URL]").option("--upload-relay <url|auto>", "Upload Walrus blobs through a relay instead of fanning out to every storage node; 'auto' picks the canonical relay for the network. Costs a tip per upload [env: MORSE_WALRUS_UPLOAD_RELAY]").option("--max-tip <mist>", `Cap the per-upload relay tip, in MIST (default: ${DEFAULT_MAX_TIP_MIST}) [env: MORSE_WALRUS_MAX_TIP]`).option("--json", "Output machine-readable JSON on stdout").option("-q, --quiet", "Suppress progress and informational output").option("-y, --yes", "Assume yes for confirmation prompts").option("--debug", "Print stack traces on error").enablePositionalOptions().showHelpAfterError().exitOverride();
|
|
223
|
+
program.name("morse").description("Command-line interface for the Morse decentralized CMS on Sui.").version(package_default.version, "-V, --version", "Print the version and exit").option("--network <network>", "Sui network: mainnet, testnet, or localnet [env: MORSE_NETWORK]").option("-p, --profile <name>", "Config profile to use [env: MORSE_PROFILE]").option("--rpc <url>", "Override the Sui RPC URL [env: MORSE_RPC_URL]").option("--upload-relay <url|auto>", "Upload Walrus blobs through a relay instead of fanning out to every storage node; 'auto' picks the canonical relay for the network. Costs a tip per upload [env: MORSE_WALRUS_UPLOAD_RELAY]").option("--max-tip <mist>", `Cap the per-upload relay tip, in MIST (default: ${DEFAULT_MAX_TIP_MIST}). Only applies with --upload-relay [env: MORSE_WALRUS_MAX_TIP]`).option("--json", "Output machine-readable JSON on stdout").option("-q, --quiet", "Suppress progress and informational output").option("-y, --yes", "Assume yes for confirmation prompts").option("--debug", "Print stack traces on error").enablePositionalOptions().showHelpAfterError().exitOverride();
|
|
224
224
|
return program;
|
|
225
225
|
}
|
|
226
226
|
|
|
@@ -559,7 +559,7 @@ async function updateActiveProfile(opts, patch, env = process.env) {
|
|
|
559
559
|
const config = await loadConfig();
|
|
560
560
|
const profileName = opts.profile ?? env.MORSE_PROFILE ?? config.defaultProfile;
|
|
561
561
|
const existing = config.profiles[profileName];
|
|
562
|
-
const network = coerceNetwork(opts.network ?? env.MORSE_NETWORK ??
|
|
562
|
+
const network = existing?.network ?? coerceNetwork(opts.network ?? env.MORSE_NETWORK ?? "testnet");
|
|
563
563
|
const merged = { ...existing, network, ...patch };
|
|
564
564
|
const profiles = { ...config.profiles, [profileName]: merged };
|
|
565
565
|
const defaultProfile = Object.keys(config.profiles).length === 0 ? profileName : config.defaultProfile;
|
|
@@ -988,7 +988,136 @@ import {
|
|
|
988
988
|
RpcRecipientFilesReader
|
|
989
989
|
} from "@arcadiasystems/morse-sdk";
|
|
990
990
|
import { SuiGrpcClient } from "@mysten/sui/grpc";
|
|
991
|
-
|
|
991
|
+
|
|
992
|
+
// src/config/seal.ts
|
|
993
|
+
import { MAINNET_SEAL_COMMITTEE } from "@arcadiasystems/morse-sdk";
|
|
994
|
+
var DEFAULT_API_KEY_NAME = "X-API-Key";
|
|
995
|
+
function sealServersFromEnv(env = process.env) {
|
|
996
|
+
const explicit = env.MORSE_SEAL_KEY_SERVERS;
|
|
997
|
+
if (explicit !== undefined && explicit.length > 0) {
|
|
998
|
+
return { servers: parseKeyServers(explicit) };
|
|
999
|
+
}
|
|
1000
|
+
const apiKey = env.MORSE_SEAL_API_KEY;
|
|
1001
|
+
if (apiKey === undefined || apiKey.length === 0) {
|
|
1002
|
+
return;
|
|
1003
|
+
}
|
|
1004
|
+
const apiKeyName = env.MORSE_SEAL_API_KEY_NAME ?? DEFAULT_API_KEY_NAME;
|
|
1005
|
+
return {
|
|
1006
|
+
servers: MAINNET_SEAL_COMMITTEE.map((server) => ({
|
|
1007
|
+
...server,
|
|
1008
|
+
apiKeyName,
|
|
1009
|
+
apiKey
|
|
1010
|
+
})),
|
|
1011
|
+
...MAINNET_SEAL_COMMITTEE[0]?.aggregatorUrl === undefined ? {} : { verifyUrl: MAINNET_SEAL_COMMITTEE[0].aggregatorUrl },
|
|
1012
|
+
apiKeyName,
|
|
1013
|
+
apiKey
|
|
1014
|
+
};
|
|
1015
|
+
}
|
|
1016
|
+
async function assertSealCredentialAccepted(env, fetchImpl = fetch) {
|
|
1017
|
+
if (env.verifyUrl === undefined || env.apiKey === undefined) {
|
|
1018
|
+
return;
|
|
1019
|
+
}
|
|
1020
|
+
let status;
|
|
1021
|
+
try {
|
|
1022
|
+
const response = await fetchImpl(`${env.verifyUrl}/v1/service`, {
|
|
1023
|
+
headers: { [env.apiKeyName ?? DEFAULT_API_KEY_NAME]: env.apiKey }
|
|
1024
|
+
});
|
|
1025
|
+
status = response.status;
|
|
1026
|
+
} catch {
|
|
1027
|
+
return;
|
|
1028
|
+
}
|
|
1029
|
+
if (status === 401 || status === 403) {
|
|
1030
|
+
throw new UsageError(`The Seal operator at ${env.verifyUrl} rejected your credential (HTTP ${status}). Encrypting anyway would produce content you could never decrypt, so this stops here. Check MORSE_SEAL_API_KEY.`);
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
function parseKeyServers(raw) {
|
|
1034
|
+
let parsed;
|
|
1035
|
+
try {
|
|
1036
|
+
parsed = JSON.parse(raw);
|
|
1037
|
+
} catch (cause) {
|
|
1038
|
+
throw new UsageError(`MORSE_SEAL_KEY_SERVERS is not valid JSON: ${String(cause)}`);
|
|
1039
|
+
}
|
|
1040
|
+
if (!Array.isArray(parsed) || parsed.length === 0) {
|
|
1041
|
+
throw new UsageError('MORSE_SEAL_KEY_SERVERS must be a non-empty JSON array of { objectId, weight } entries, e.g. [{"objectId":"0x...","weight":1}].');
|
|
1042
|
+
}
|
|
1043
|
+
return parsed.map((entry, index) => {
|
|
1044
|
+
if (typeof entry !== "object" || entry === null) {
|
|
1045
|
+
throw new UsageError(`MORSE_SEAL_KEY_SERVERS[${index}] is not an object.`);
|
|
1046
|
+
}
|
|
1047
|
+
const { objectId, weight } = entry;
|
|
1048
|
+
if (typeof objectId !== "string" || !objectId.startsWith("0x")) {
|
|
1049
|
+
throw new UsageError(`MORSE_SEAL_KEY_SERVERS[${index}].objectId must be a 0x-prefixed object id.`);
|
|
1050
|
+
}
|
|
1051
|
+
if (typeof weight !== "number" || !Number.isInteger(weight) || weight < 1) {
|
|
1052
|
+
throw new UsageError(`MORSE_SEAL_KEY_SERVERS[${index}].weight must be a positive integer.`);
|
|
1053
|
+
}
|
|
1054
|
+
return entry;
|
|
1055
|
+
});
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
// src/cli/graphql-events.ts
|
|
1059
|
+
function canonicalGraphqlUrl(network) {
|
|
1060
|
+
if (network === "mainnet" || network === "testnet") {
|
|
1061
|
+
return `https://graphql.${network}.sui.io/graphql`;
|
|
1062
|
+
}
|
|
1063
|
+
return;
|
|
1064
|
+
}
|
|
1065
|
+
var QUERY = `query Events($type: String!, $first: Int!, $after: String) {
|
|
1066
|
+
events(filter: { type: $type }, first: $first, after: $after) {
|
|
1067
|
+
pageInfo { hasNextPage endCursor }
|
|
1068
|
+
nodes { timestamp contents { json type { repr } } }
|
|
1069
|
+
}
|
|
1070
|
+
}`;
|
|
1071
|
+
|
|
1072
|
+
class GraphqlEventQuerier {
|
|
1073
|
+
#url;
|
|
1074
|
+
#fetch;
|
|
1075
|
+
constructor(url, fetchImpl = fetch) {
|
|
1076
|
+
this.#url = url;
|
|
1077
|
+
this.#fetch = fetchImpl;
|
|
1078
|
+
}
|
|
1079
|
+
async queryEvents(params) {
|
|
1080
|
+
const body = JSON.stringify({
|
|
1081
|
+
query: QUERY,
|
|
1082
|
+
variables: {
|
|
1083
|
+
type: params.query.MoveEventType,
|
|
1084
|
+
first: params.limit ?? 50,
|
|
1085
|
+
after: typeof params.cursor === "string" ? params.cursor : null
|
|
1086
|
+
}
|
|
1087
|
+
});
|
|
1088
|
+
let response;
|
|
1089
|
+
try {
|
|
1090
|
+
response = await this.#fetch(this.#url, {
|
|
1091
|
+
method: "POST",
|
|
1092
|
+
headers: { "content-type": "application/json" },
|
|
1093
|
+
body,
|
|
1094
|
+
...params.signal === undefined ? {} : { signal: params.signal }
|
|
1095
|
+
});
|
|
1096
|
+
} catch (cause) {
|
|
1097
|
+
throw new CliError(`Could not reach the event source at ${this.#url}.`, ExitCode.Network, { cause });
|
|
1098
|
+
}
|
|
1099
|
+
if (!response.ok) {
|
|
1100
|
+
throw new CliError(`Event source at ${this.#url} returned HTTP ${response.status}.`, ExitCode.Network);
|
|
1101
|
+
}
|
|
1102
|
+
const payload = await response.json();
|
|
1103
|
+
if (payload.errors && payload.errors.length > 0) {
|
|
1104
|
+
throw new CliError(`Event source rejected the query: ${payload.errors[0]?.message ?? "unknown error"}`, ExitCode.Network);
|
|
1105
|
+
}
|
|
1106
|
+
const events = payload.data?.events;
|
|
1107
|
+
const nodes = events?.nodes ?? [];
|
|
1108
|
+
return {
|
|
1109
|
+
data: nodes.map((node) => ({
|
|
1110
|
+
type: node.contents?.type?.repr ?? "",
|
|
1111
|
+
parsedJson: node.contents?.json ?? {},
|
|
1112
|
+
timestampMs: node.timestamp == null ? null : String(Date.parse(node.timestamp) || 0)
|
|
1113
|
+
})),
|
|
1114
|
+
hasNextPage: Boolean(events?.pageInfo?.hasNextPage),
|
|
1115
|
+
nextCursor: events?.pageInfo?.endCursor ?? null
|
|
1116
|
+
};
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
// src/cli/context.ts
|
|
992
1121
|
async function buildReadContext(command) {
|
|
993
1122
|
const opts = globalOptions(command);
|
|
994
1123
|
const output = outputFor(command);
|
|
@@ -1051,11 +1180,12 @@ async function buildContentContext(command) {
|
|
|
1051
1180
|
walrus
|
|
1052
1181
|
};
|
|
1053
1182
|
}
|
|
1054
|
-
function
|
|
1055
|
-
|
|
1056
|
-
|
|
1183
|
+
function buildSeal(config, client) {
|
|
1184
|
+
const fromEnv = sealServersFromEnv();
|
|
1185
|
+
if (fromEnv === undefined && config.sealKeyServers.length === 0) {
|
|
1186
|
+
throw new CliError(`Encrypted commands are not available on ${config.network}: it pins no Seal key servers, because every operator there is commercial. Set MORSE_SEAL_API_KEY if you have a credential for Mysten's committee, or MORSE_SEAL_KEY_SERVERS to a JSON array for another operator. Otherwise use --network testnet.`, ExitCode.Usage);
|
|
1057
1187
|
}
|
|
1058
|
-
|
|
1188
|
+
return DefaultSealAdapter.fromMorseConfig(config, fromEnv === undefined ? {} : { serverConfigs: fromEnv.servers }, client);
|
|
1059
1189
|
}
|
|
1060
1190
|
function walrusWriteConfig(base, network) {
|
|
1061
1191
|
const host = resolveUploadRelay(base.settings.uploadRelay, network);
|
|
@@ -1070,6 +1200,10 @@ function walrusWriteConfig(base, network) {
|
|
|
1070
1200
|
}
|
|
1071
1201
|
async function buildEncryptContext(command) {
|
|
1072
1202
|
const ctx = await buildContentContext(command);
|
|
1203
|
+
const fromEnv = sealServersFromEnv();
|
|
1204
|
+
if (fromEnv !== undefined) {
|
|
1205
|
+
await assertSealCredentialAccepted(fromEnv);
|
|
1206
|
+
}
|
|
1073
1207
|
return { ...ctx, seal: lazySeal(ctx) };
|
|
1074
1208
|
}
|
|
1075
1209
|
function walrusReadAdapter(base, network, viaAggregator) {
|
|
@@ -1098,8 +1232,7 @@ async function buildDecryptContext(command, opts = {}) {
|
|
|
1098
1232
|
if (network === "localnet") {
|
|
1099
1233
|
throw new CliError("Seal decryption is not available on localnet. Use testnet or mainnet.", ExitCode.Usage);
|
|
1100
1234
|
}
|
|
1101
|
-
|
|
1102
|
-
const seal = DefaultSealAdapter.fromMorseConfig(base.config, {}, base.client);
|
|
1235
|
+
const seal = buildSeal(base.config, base.client);
|
|
1103
1236
|
return {
|
|
1104
1237
|
...base,
|
|
1105
1238
|
keypair,
|
|
@@ -1127,8 +1260,7 @@ async function buildFileDownloadContext(command, opts = {}) {
|
|
|
1127
1260
|
function lazySeal(base) {
|
|
1128
1261
|
let adapter;
|
|
1129
1262
|
return () => {
|
|
1130
|
-
|
|
1131
|
-
adapter ??= DefaultSealAdapter.fromMorseConfig(base.config, {}, base.client);
|
|
1263
|
+
adapter ??= buildSeal(base.config, base.client);
|
|
1132
1264
|
return adapter;
|
|
1133
1265
|
};
|
|
1134
1266
|
}
|
|
@@ -1138,16 +1270,16 @@ async function buildFileListContext(command, opts = {}) {
|
|
|
1138
1270
|
if (originPackageId === undefined) {
|
|
1139
1271
|
throw new CliError("File listing is unavailable on this network: no recipientFileEventOriginPackageId in the config. Use testnet, or supply a config that sets it.", ExitCode.Usage);
|
|
1140
1272
|
}
|
|
1141
|
-
const
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
}
|
|
1273
|
+
const indexerUrl = opts.indexerUrl ?? canonicalGraphqlUrl(base.settings.network);
|
|
1274
|
+
if (indexerUrl === undefined) {
|
|
1275
|
+
throw new CliError(`File listing has no canonical event source for ${base.settings.network}. Pass --indexer-url pointing at a Sui GraphQL endpoint.`, ExitCode.Usage);
|
|
1276
|
+
}
|
|
1145
1277
|
return {
|
|
1146
1278
|
...base,
|
|
1147
1279
|
filesReader: RpcRecipientFilesReader.fromConfig(base.client, {
|
|
1148
1280
|
packageId: base.config.packageId
|
|
1149
1281
|
}),
|
|
1150
|
-
events,
|
|
1282
|
+
events: new GraphqlEventQuerier(indexerUrl),
|
|
1151
1283
|
eventTypes: buildRecipientFileEventTypes(originPackageId)
|
|
1152
1284
|
};
|
|
1153
1285
|
}
|
|
@@ -1528,6 +1660,9 @@ async function runCollectionList(ctx, options) {
|
|
|
1528
1660
|
collections: publication.collections
|
|
1529
1661
|
});
|
|
1530
1662
|
}
|
|
1663
|
+
function isActivePublication(ctx, target) {
|
|
1664
|
+
return ctx.settings.publication === target;
|
|
1665
|
+
}
|
|
1531
1666
|
async function runCollectionCreate(ctx, name, options, gopts) {
|
|
1532
1667
|
const id = await resolvePublication(ctx, options.publication);
|
|
1533
1668
|
const storageMode = coerceStorageMode(options.mode);
|
|
@@ -1543,8 +1678,11 @@ async function runCollectionCreate(ctx, name, options, gopts) {
|
|
|
1543
1678
|
storageMode,
|
|
1544
1679
|
signal: ctx.signal
|
|
1545
1680
|
});
|
|
1546
|
-
|
|
1547
|
-
|
|
1681
|
+
const selected = isActivePublication(ctx, id);
|
|
1682
|
+
if (selected) {
|
|
1683
|
+
await updateActiveProfile(gopts, { collection: name });
|
|
1684
|
+
}
|
|
1685
|
+
ctx.output.result(`Created collection "${name}".${selected ? " Selected as the active collection." : ""} (tx: ${result.digest})`, result);
|
|
1548
1686
|
}
|
|
1549
1687
|
async function runCollectionDelete(ctx, name, options, gopts) {
|
|
1550
1688
|
const id = await resolvePublication(ctx, options.publication);
|
|
@@ -1566,7 +1704,7 @@ async function runCollectionDelete(ctx, name, options, gopts) {
|
|
|
1566
1704
|
name,
|
|
1567
1705
|
signal: ctx.signal
|
|
1568
1706
|
});
|
|
1569
|
-
if (ctx.settings.collection === name) {
|
|
1707
|
+
if (ctx.settings.collection === name && isActivePublication(ctx, id)) {
|
|
1570
1708
|
await updateActiveProfile(gopts, { collection: undefined });
|
|
1571
1709
|
}
|
|
1572
1710
|
ctx.output.result(`Deleted collection "${name}". (tx: ${result.digest})`, result);
|
|
@@ -1606,6 +1744,7 @@ async function runConfigAdd(output, name, options) {
|
|
|
1606
1744
|
const profiles = {
|
|
1607
1745
|
...cfg.profiles,
|
|
1608
1746
|
[name]: {
|
|
1747
|
+
...cfg.profiles[name],
|
|
1609
1748
|
network,
|
|
1610
1749
|
...options.rpc === undefined ? {} : { rpc: options.rpc },
|
|
1611
1750
|
...options.uploadRelay === undefined ? {} : { uploadRelay: options.uploadRelay }
|
|
@@ -1627,11 +1766,19 @@ async function runConfigUse(output, name) {
|
|
|
1627
1766
|
await saveConfig({ ...cfg, defaultProfile: name });
|
|
1628
1767
|
output.result(`Default profile set to "${name}".`, { defaultProfile: name });
|
|
1629
1768
|
}
|
|
1630
|
-
async function runConfigRemove(output, name) {
|
|
1769
|
+
async function runConfigRemove(output, name, gopts = {}, signal) {
|
|
1631
1770
|
const cfg = await loadConfig();
|
|
1632
1771
|
requireProfile(cfg, name);
|
|
1633
1772
|
const { [name]: _removed, ...rest } = cfg.profiles;
|
|
1634
1773
|
const defaultProfile = cfg.defaultProfile === name ? Object.keys(rest)[0] ?? "default" : cfg.defaultProfile;
|
|
1774
|
+
const reassigns = cfg.defaultProfile === name && defaultProfile !== name;
|
|
1775
|
+
const proceed = await confirm(`Delete profile "${name}"?${reassigns ? ` The default becomes "${defaultProfile}".` : ""}`, {
|
|
1776
|
+
assumeYes: Boolean(gopts.yes),
|
|
1777
|
+
...signal === undefined ? {} : { signal }
|
|
1778
|
+
});
|
|
1779
|
+
if (!proceed) {
|
|
1780
|
+
cancelled();
|
|
1781
|
+
}
|
|
1635
1782
|
await saveConfig({ ...cfg, profiles: rest, defaultProfile });
|
|
1636
1783
|
output.result(`Removed profile "${name}".`, {
|
|
1637
1784
|
removed: name,
|
|
@@ -1654,7 +1801,7 @@ function registerConfigCommands(program) {
|
|
|
1654
1801
|
await runConfigUse(outputFor(command), name);
|
|
1655
1802
|
});
|
|
1656
1803
|
config.command("remove <name>").alias("delete").description("Delete a profile").action(async (name, _options, command) => {
|
|
1657
|
-
await runConfigRemove(outputFor(command), name);
|
|
1804
|
+
await runConfigRemove(outputFor(command), name, globalOptions(command), sigintSignal());
|
|
1658
1805
|
});
|
|
1659
1806
|
}
|
|
1660
1807
|
function requireProfile(config, name) {
|
|
@@ -2129,6 +2276,9 @@ async function runFileRegister(ctx, options) {
|
|
|
2129
2276
|
throw new UsageError("Pass --public or --encrypted, not both.");
|
|
2130
2277
|
}
|
|
2131
2278
|
const recipients = (options.recipient ?? []).map((r) => toSuiAddress4(r));
|
|
2279
|
+
if (options.public && recipients.length > 0) {
|
|
2280
|
+
throw new UsageError("--recipient applies to encrypted files; a public file is readable by anyone.");
|
|
2281
|
+
}
|
|
2132
2282
|
const blobId = toWalrusBlobId(options.blobId);
|
|
2133
2283
|
const size = parseByteSize(options.size, "--size");
|
|
2134
2284
|
const blobObjectId = options.blobObjectId === undefined ? undefined : toBlobObjectId(options.blobObjectId);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arcadiasystems/morse-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "Command-line interface for the Morse decentralized CMS on Sui.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -55,7 +55,7 @@
|
|
|
55
55
|
"prepublishOnly": "bun run check && bun run build"
|
|
56
56
|
},
|
|
57
57
|
"dependencies": {
|
|
58
|
-
"@arcadiasystems/morse-sdk": "^0.
|
|
58
|
+
"@arcadiasystems/morse-sdk": "^0.8.0",
|
|
59
59
|
"@mysten/seal": "1.1.3",
|
|
60
60
|
"@mysten/sui": "2.16.2",
|
|
61
61
|
"@mysten/walrus": "1.1.6",
|