@arcadiasystems/morse-cli 0.7.0 → 0.9.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 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.9.0] - 2026-09-10
8
+ ### Fixed (config state, found in a whole-package bug hunt)
9
+
10
+ - **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.
11
+ - **`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.
12
+ - **`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.
13
+ - **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.
14
+ - `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.
15
+ - `--max-tip` help text says it only applies alongside `--upload-relay`.
16
+
17
+
18
+ ### Fixed
19
+
20
+ - **`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.
21
+
22
+ 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".
23
+
24
+ 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.
25
+
26
+ 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.
27
+
28
+ ### Changed
29
+
30
+ - `--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.
31
+ - 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.
32
+
33
+ ## [0.8.0] - 2026-09-10
34
+
35
+ Usability pass. Nothing is renamed or removed; every existing spelling keeps working.
36
+
37
+ ### Added
38
+
39
+ - **Verb aliases, so the word you guess is the word that works.** The command surface had grown four inconsistencies where one concept had two names depending on the noun: fetching one thing was `get` on publication, entry and file but `show` on account; deleting was `delete` everywhere except `config remove`; transferring was `transfer-ownership` on publication and file but `transfer` on cap; and reading content was `entry read` but `file download`, despite both taking `--out` and `--via-aggregator`.
40
+
41
+ Each now accepts both spellings: `account show|get`, `publication get|show`, `entry get|show`, `file get|show`, `config remove|delete`, `cap transfer|transfer-ownership`, `entry read|download`, `file download|read`. Aliases, not renames, so nothing published breaks.
42
+ - **`entry list --all`** does what `entry scan` does. `scan` is accurate but undiscoverable; people look for `--all` and conclude pagination is unsupported. `scan` stays. Combining `--all` with `--limit` or `--cursor` is refused rather than silently ignoring one.
43
+ - **`account import` reads a key piped on stdin.** Previously it required an interactive terminal or `MORSE_PRIVATE_KEY`, so the obvious scripted form failed outright. Still never a flag: argv is visible in `ps` and shell history. The error when no key can be found now names all three routes.
44
+
7
45
  ## [0.7.0] - 2026-09-09
8
46
 
9
47
  ### Added
package/README.md CHANGED
@@ -6,7 +6,7 @@ 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.7.0. Mainnet and testnet are both supported for public content;
9
+ > Status: v0.9.0. Mainnet and testnet are both supported for public content;
10
10
  > the command surface is stable.
11
11
  >
12
12
  > **Encrypted commands are testnet-only.** `entry add-encrypted`, `entry
@@ -173,9 +173,9 @@ and `-C, --collection <name>`, both defaulting to the active context.
173
173
  | Command | Purpose |
174
174
  | --- | --- |
175
175
  | `config add <name> --network <net> [--rpc <url>] [--upload-relay <url\|auto>]` | Create or update a profile. |
176
+ | `config remove <name>` | Delete a profile. Also `config delete`. |
176
177
  | `config list` | List profiles; `*` marks the default. |
177
178
  | `config use <name>` | Set the default profile. |
178
- | `config remove <name>` | Delete a profile. |
179
179
  | `config path` | Print the config file path. |
180
180
 
181
181
  ### account
@@ -281,13 +281,13 @@ also returns the raw `sealIdPrefix` and `sealNonce` as hex, usable as `--prefix`
281
281
 
282
282
  `file list` reconstructs the file set from contract events. `RecipientFile`
283
283
  objects have no on-chain owner index for the shared case, so listing is
284
- event-derived, not a direct query. By default the command reads events via
285
- `suix_queryEvents` on the configured Sui RPC. That endpoint is **deprecated**
286
- (Mysten is sunsetting it), so listing may degrade or stop working on the public
287
- RPC over time; point `--indexer-url <url>` at any source that speaks
288
- `suix_queryEvents` (a self-hosted indexer, a third-party endpoint) to stay in
289
- control. Results are best-effort and eventually consistent (subject to indexer
290
- lag and retention). Summary rows omit `blobId`/`blobObjectId`; add `--hydrate`
284
+ event-derived, not a direct query. The command reads events from a Sui GraphQL
285
+ endpoint, defaulting to the canonical Mysten one for the network. It used to use
286
+ `suix_queryEvents` over JSON-RPC, which public fullnodes have since retired;
287
+ `@mysten/sui`'s gRPC client exposes no event API, so GraphQL is the transport
288
+ that remains. Point `--indexer-url <url>` at a different GraphQL endpoint (a
289
+ self-hosted indexer, a third-party endpoint) to stay in control. Results are
290
+ best-effort and eventually consistent (subject to indexer lag and retention). Summary rows omit `blobId`/`blobObjectId`; add `--hydrate`
291
291
  to fetch the full record per file (one read each) when you need them.
292
292
 
293
293
  ## Output and scripting
@@ -348,9 +348,29 @@ to fetch the full record per file (one read each) when you need them.
348
348
  an operator, use the SDK directly for now.
349
349
  - `localnet` is accepted as a network but has no canonical deployment, so it
350
350
  fails at startup unless you point the SDK at your own package and registry.
351
- - `file list` walks a JSON-RPC event query that public Sui fullnodes have
352
- retired, so it needs `--indexer-url` pointing at a source that serves the same
353
- query. This affects testnet as well as mainnet.
351
+ - `file list` reads events from a Sui GraphQL endpoint, defaulting to the
352
+ canonical one for the network. `--indexer-url` points it at a different
353
+ GraphQL endpoint. There is no canonical endpoint for localnet, so listing
354
+ there requires the flag.
355
+
356
+ ## Command spellings
357
+
358
+ Several commands accept two verbs for the same action, so whichever you reach
359
+ for works. These are aliases, not separate commands:
360
+
361
+ | Canonical | Also accepts |
362
+ | --- | --- |
363
+ | `account show` | `account get` |
364
+ | `publication get` | `publication show` |
365
+ | `entry get` | `entry show` |
366
+ | `file get` | `file show` |
367
+ | `config remove` | `config delete` |
368
+ | `cap transfer` | `cap transfer-ownership` |
369
+ | `entry read` | `entry download` |
370
+ | `file download` | `file read` |
371
+ | `entry scan` | `entry list --all` |
372
+
373
+ `publication` also answers to `pub`.
354
374
 
355
375
  ## When uploads fail: the upload relay
356
376
 
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.7.0",
151
+ version: "0.9.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.7.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,10 +220,37 @@ 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
 
227
+ // src/cli/io.ts
228
+ import { access, readFile, writeFile } from "node:fs/promises";
229
+ async function fileExists(path) {
230
+ try {
231
+ await access(path);
232
+ return true;
233
+ } catch {
234
+ return false;
235
+ }
236
+ }
237
+ async function readBytes(path) {
238
+ return new Uint8Array(await readFile(path));
239
+ }
240
+ async function readJson(path) {
241
+ return JSON.parse(await readFile(path, "utf8"));
242
+ }
243
+ async function writeFileContents(path, data) {
244
+ await writeFile(path, data);
245
+ }
246
+ async function readStdin() {
247
+ const chunks = [];
248
+ for await (const chunk of process.stdin) {
249
+ chunks.push(chunk);
250
+ }
251
+ return new Uint8Array(Buffer.concat(chunks));
252
+ }
253
+
227
254
  // src/cli/prompts.ts
228
255
  import * as readline from "node:readline";
229
256
  function isInteractive() {
@@ -487,33 +514,6 @@ function malformed(source, detail) {
487
514
  // src/config/store.ts
488
515
  import { chmod, mkdir, rename } from "node:fs/promises";
489
516
 
490
- // src/cli/io.ts
491
- import { access, readFile, writeFile } from "node:fs/promises";
492
- async function fileExists(path) {
493
- try {
494
- await access(path);
495
- return true;
496
- } catch {
497
- return false;
498
- }
499
- }
500
- async function readBytes(path) {
501
- return new Uint8Array(await readFile(path));
502
- }
503
- async function readJson(path) {
504
- return JSON.parse(await readFile(path, "utf8"));
505
- }
506
- async function writeFileContents(path, data) {
507
- await writeFile(path, data);
508
- }
509
- async function readStdin() {
510
- const chunks = [];
511
- for await (const chunk of process.stdin) {
512
- chunks.push(chunk);
513
- }
514
- return new Uint8Array(Buffer.concat(chunks));
515
- }
516
-
517
517
  // src/config/paths.ts
518
518
  import { homedir } from "node:os";
519
519
  import { join } from "node:path";
@@ -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 ?? existing?.network ?? "testnet");
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;
@@ -925,7 +925,7 @@ function registerAccountCommands(program) {
925
925
  account.command("list").description("List imported accounts").action(async (_options, command) => {
926
926
  await runAccountList(outputFor(command), globalOptions(command));
927
927
  });
928
- account.command("show").description("Print the active account address").action(async (_options, command) => {
928
+ account.command("show").alias("get").description("Print the active account address").action(async (_options, command) => {
929
929
  await runAccountShow(outputFor(command), globalOptions(command));
930
930
  });
931
931
  account.command("use <address>").description("Set the active account for the current profile").action(async (address, _options, command) => {
@@ -940,8 +940,15 @@ async function readSecretToImport(env, signal) {
940
940
  if (raw !== undefined && raw.length > 0) {
941
941
  return raw;
942
942
  }
943
+ if (!process.stdin.isTTY) {
944
+ const piped = new TextDecoder().decode(await readStdin()).trim();
945
+ if (piped.length > 0) {
946
+ return piped;
947
+ }
948
+ throw new UsageError("Cannot read a private key: pipe it on stdin, set MORSE_PRIVATE_KEY, or run in an interactive terminal.");
949
+ }
943
950
  if (!isInteractive()) {
944
- throw new UsageError("Cannot read a private key: set MORSE_PRIVATE_KEY or run in an interactive terminal.");
951
+ throw new UsageError("Cannot read a private key: pipe it on stdin, set MORSE_PRIVATE_KEY, or run in an interactive terminal.");
945
952
  }
946
953
  process.stderr.write(`Paste your Sui private key (starts with suiprivkey1...), then press Enter. Input is hidden.
947
954
  `);
@@ -981,7 +988,70 @@ import {
981
988
  RpcRecipientFilesReader
982
989
  } from "@arcadiasystems/morse-sdk";
983
990
  import { SuiGrpcClient } from "@mysten/sui/grpc";
984
- import { SuiJsonRpcClient } from "@mysten/sui/jsonRpc";
991
+
992
+ // src/cli/graphql-events.ts
993
+ function canonicalGraphqlUrl(network) {
994
+ if (network === "mainnet" || network === "testnet") {
995
+ return `https://graphql.${network}.sui.io/graphql`;
996
+ }
997
+ return;
998
+ }
999
+ var QUERY = `query Events($type: String!, $first: Int!, $after: String) {
1000
+ events(filter: { type: $type }, first: $first, after: $after) {
1001
+ pageInfo { hasNextPage endCursor }
1002
+ nodes { timestamp contents { json type { repr } } }
1003
+ }
1004
+ }`;
1005
+
1006
+ class GraphqlEventQuerier {
1007
+ #url;
1008
+ #fetch;
1009
+ constructor(url, fetchImpl = fetch) {
1010
+ this.#url = url;
1011
+ this.#fetch = fetchImpl;
1012
+ }
1013
+ async queryEvents(params) {
1014
+ const body = JSON.stringify({
1015
+ query: QUERY,
1016
+ variables: {
1017
+ type: params.query.MoveEventType,
1018
+ first: params.limit ?? 50,
1019
+ after: typeof params.cursor === "string" ? params.cursor : null
1020
+ }
1021
+ });
1022
+ let response;
1023
+ try {
1024
+ response = await this.#fetch(this.#url, {
1025
+ method: "POST",
1026
+ headers: { "content-type": "application/json" },
1027
+ body,
1028
+ ...params.signal === undefined ? {} : { signal: params.signal }
1029
+ });
1030
+ } catch (cause) {
1031
+ throw new CliError(`Could not reach the event source at ${this.#url}.`, ExitCode.Network, { cause });
1032
+ }
1033
+ if (!response.ok) {
1034
+ throw new CliError(`Event source at ${this.#url} returned HTTP ${response.status}.`, ExitCode.Network);
1035
+ }
1036
+ const payload = await response.json();
1037
+ if (payload.errors && payload.errors.length > 0) {
1038
+ throw new CliError(`Event source rejected the query: ${payload.errors[0]?.message ?? "unknown error"}`, ExitCode.Network);
1039
+ }
1040
+ const events = payload.data?.events;
1041
+ const nodes = events?.nodes ?? [];
1042
+ return {
1043
+ data: nodes.map((node) => ({
1044
+ type: node.contents?.type?.repr ?? "",
1045
+ parsedJson: node.contents?.json ?? {},
1046
+ timestampMs: node.timestamp == null ? null : String(Date.parse(node.timestamp) || 0)
1047
+ })),
1048
+ hasNextPage: Boolean(events?.pageInfo?.hasNextPage),
1049
+ nextCursor: events?.pageInfo?.endCursor ?? null
1050
+ };
1051
+ }
1052
+ }
1053
+
1054
+ // src/cli/context.ts
985
1055
  async function buildReadContext(command) {
986
1056
  const opts = globalOptions(command);
987
1057
  const output = outputFor(command);
@@ -1131,16 +1201,16 @@ async function buildFileListContext(command, opts = {}) {
1131
1201
  if (originPackageId === undefined) {
1132
1202
  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);
1133
1203
  }
1134
- const events = new SuiJsonRpcClient({
1135
- network: base.settings.network,
1136
- url: opts.indexerUrl ?? base.config.rpcUrl
1137
- });
1204
+ const indexerUrl = opts.indexerUrl ?? canonicalGraphqlUrl(base.settings.network);
1205
+ if (indexerUrl === undefined) {
1206
+ 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);
1207
+ }
1138
1208
  return {
1139
1209
  ...base,
1140
1210
  filesReader: RpcRecipientFilesReader.fromConfig(base.client, {
1141
1211
  packageId: base.config.packageId
1142
1212
  }),
1143
- events,
1213
+ events: new GraphqlEventQuerier(indexerUrl),
1144
1214
  eventTypes: buildRecipientFileEventTypes(originPackageId)
1145
1215
  };
1146
1216
  }
@@ -1502,7 +1572,7 @@ function registerCapCommands(program) {
1502
1572
  publicationOption(destroy).action(async (publisherCapId, options, command) => {
1503
1573
  await runCapDestroy(await buildWriteContext(command), publisherCapId, options, globalOptions(command));
1504
1574
  });
1505
- cap.command("transfer <publisherCapId> <recipient>").description("Transfer a PublisherCap object to another address").action(async (publisherCapId, recipient, _options, command) => {
1575
+ cap.command("transfer <publisherCapId> <recipient>").alias("transfer-ownership").description("Transfer a PublisherCap object to another address").action(async (publisherCapId, recipient, _options, command) => {
1506
1576
  await runCapTransfer(await buildWriteContext(command), publisherCapId, recipient, globalOptions(command));
1507
1577
  });
1508
1578
  }
@@ -1521,6 +1591,9 @@ async function runCollectionList(ctx, options) {
1521
1591
  collections: publication.collections
1522
1592
  });
1523
1593
  }
1594
+ function isActivePublication(ctx, target) {
1595
+ return ctx.settings.publication === target;
1596
+ }
1524
1597
  async function runCollectionCreate(ctx, name, options, gopts) {
1525
1598
  const id = await resolvePublication(ctx, options.publication);
1526
1599
  const storageMode = coerceStorageMode(options.mode);
@@ -1536,8 +1609,11 @@ async function runCollectionCreate(ctx, name, options, gopts) {
1536
1609
  storageMode,
1537
1610
  signal: ctx.signal
1538
1611
  });
1539
- await updateActiveProfile(gopts, { collection: name });
1540
- ctx.output.result(`Created collection "${name}". Selected as the active collection. (tx: ${result.digest})`, result);
1612
+ const selected = isActivePublication(ctx, id);
1613
+ if (selected) {
1614
+ await updateActiveProfile(gopts, { collection: name });
1615
+ }
1616
+ ctx.output.result(`Created collection "${name}".${selected ? " Selected as the active collection." : ""} (tx: ${result.digest})`, result);
1541
1617
  }
1542
1618
  async function runCollectionDelete(ctx, name, options, gopts) {
1543
1619
  const id = await resolvePublication(ctx, options.publication);
@@ -1559,7 +1635,7 @@ async function runCollectionDelete(ctx, name, options, gopts) {
1559
1635
  name,
1560
1636
  signal: ctx.signal
1561
1637
  });
1562
- if (ctx.settings.collection === name) {
1638
+ if (ctx.settings.collection === name && isActivePublication(ctx, id)) {
1563
1639
  await updateActiveProfile(gopts, { collection: undefined });
1564
1640
  }
1565
1641
  ctx.output.result(`Deleted collection "${name}". (tx: ${result.digest})`, result);
@@ -1599,6 +1675,7 @@ async function runConfigAdd(output, name, options) {
1599
1675
  const profiles = {
1600
1676
  ...cfg.profiles,
1601
1677
  [name]: {
1678
+ ...cfg.profiles[name],
1602
1679
  network,
1603
1680
  ...options.rpc === undefined ? {} : { rpc: options.rpc },
1604
1681
  ...options.uploadRelay === undefined ? {} : { uploadRelay: options.uploadRelay }
@@ -1620,11 +1697,19 @@ async function runConfigUse(output, name) {
1620
1697
  await saveConfig({ ...cfg, defaultProfile: name });
1621
1698
  output.result(`Default profile set to "${name}".`, { defaultProfile: name });
1622
1699
  }
1623
- async function runConfigRemove(output, name) {
1700
+ async function runConfigRemove(output, name, gopts = {}, signal) {
1624
1701
  const cfg = await loadConfig();
1625
1702
  requireProfile(cfg, name);
1626
1703
  const { [name]: _removed, ...rest } = cfg.profiles;
1627
1704
  const defaultProfile = cfg.defaultProfile === name ? Object.keys(rest)[0] ?? "default" : cfg.defaultProfile;
1705
+ const reassigns = cfg.defaultProfile === name && defaultProfile !== name;
1706
+ const proceed = await confirm(`Delete profile "${name}"?${reassigns ? ` The default becomes "${defaultProfile}".` : ""}`, {
1707
+ assumeYes: Boolean(gopts.yes),
1708
+ ...signal === undefined ? {} : { signal }
1709
+ });
1710
+ if (!proceed) {
1711
+ cancelled();
1712
+ }
1628
1713
  await saveConfig({ ...cfg, profiles: rest, defaultProfile });
1629
1714
  output.result(`Removed profile "${name}".`, {
1630
1715
  removed: name,
@@ -1646,8 +1731,8 @@ function registerConfigCommands(program) {
1646
1731
  config.command("use <name>").description("Set the default profile").action(async (name, _options, command) => {
1647
1732
  await runConfigUse(outputFor(command), name);
1648
1733
  });
1649
- config.command("remove <name>").description("Delete a profile").action(async (name, _options, command) => {
1650
- await runConfigRemove(outputFor(command), name);
1734
+ config.command("remove <name>").alias("delete").description("Delete a profile").action(async (name, _options, command) => {
1735
+ await runConfigRemove(outputFor(command), name, globalOptions(command), sigintSignal());
1651
1736
  });
1652
1737
  }
1653
1738
  function requireProfile(config, name) {
@@ -1850,6 +1935,12 @@ async function runEntryGet(ctx, entryId, options) {
1850
1935
  ctx.output.result(renderEntry(result), result);
1851
1936
  }
1852
1937
  async function runEntryList(ctx, options) {
1938
+ if (options.all) {
1939
+ if (options.limit !== undefined || options.cursor !== undefined) {
1940
+ throw new UsageError("--all cannot be combined with --limit or --cursor.");
1941
+ }
1942
+ return runEntryScan(ctx, options);
1943
+ }
1853
1944
  const id = await resolvePublication(ctx, options.publication);
1854
1945
  const collection = resolveCollection(ctx, options.collection);
1855
1946
  const page = await ctx.reader.listEntries(id, collection, {
@@ -1961,11 +2052,11 @@ async function runEntryRead(ctx, entryId, revisionId, options) {
1961
2052
  }
1962
2053
  function registerEntryCommands(program) {
1963
2054
  const entry = program.command("entry").description("Read, add, and delete entries in a collection");
1964
- const get = entry.command("get <entryId>").description("Fetch a single entry");
2055
+ const get = entry.command("get <entryId>").alias("show").description("Fetch a single entry");
1965
2056
  collectionOption(publicationOption(get)).action(async (entryId, options, command) => {
1966
2057
  await runEntryGet(await buildReadContext(command), entryId, options);
1967
2058
  });
1968
- const list = entry.command("list").description("List entries in a collection").option("--limit <n>", "Maximum results per page").option("--cursor <cursor>", "Continue from a previous page cursor").option("--drafts-only", "Show only entries with a pending (unpublished) draft");
2059
+ const list = entry.command("list").description("List entries in a collection").option("--limit <n>", "Maximum results per page").option("--cursor <cursor>", "Continue from a previous page cursor").option("--all", "Fetch every page (same as `entry scan`)").option("--drafts-only", "Show only entries with a pending (unpublished) draft");
1969
2060
  collectionOption(publicationOption(list)).action(async (options, command) => {
1970
2061
  await runEntryList(await buildReadContext(command), options);
1971
2062
  });
@@ -1981,7 +2072,7 @@ function registerEntryCommands(program) {
1981
2072
  collectionOption(publicationOption(publisherCapOption(remove))).action(async (entryId, options, command) => {
1982
2073
  await runEntryDelete(await buildWriteContext(command), entryId, options, globalOptions(command));
1983
2074
  });
1984
- const read = entry.command("read <entryId> [revisionIndex]").description("Fetch a public entry's content to stdout or a file").option("--out <path>", "Write content to a file instead of stdout");
2075
+ const read = entry.command("read <entryId> [revisionIndex]").alias("download").description("Fetch a public entry's content to stdout or a file").option("--out <path>", "Write content to a file instead of stdout");
1985
2076
  collectionOption(publicationOption(viaAggregatorOption(read))).action(async (entryId, revisionId, options, command) => {
1986
2077
  await runEntryRead(await buildReadContentContext(command, {
1987
2078
  viaAggregator: options.viaAggregator
@@ -2116,6 +2207,9 @@ async function runFileRegister(ctx, options) {
2116
2207
  throw new UsageError("Pass --public or --encrypted, not both.");
2117
2208
  }
2118
2209
  const recipients = (options.recipient ?? []).map((r) => toSuiAddress4(r));
2210
+ if (options.public && recipients.length > 0) {
2211
+ throw new UsageError("--recipient applies to encrypted files; a public file is readable by anyone.");
2212
+ }
2119
2213
  const blobId = toWalrusBlobId(options.blobId);
2120
2214
  const size = parseByteSize(options.size, "--size");
2121
2215
  const blobObjectId = options.blobObjectId === undefined ? undefined : toBlobObjectId(options.blobObjectId);
@@ -2411,13 +2505,13 @@ function registerFileCommands(program) {
2411
2505
  indexerUrl: options.indexerUrl
2412
2506
  }), options);
2413
2507
  });
2414
- const download = file.command("download [file]").description("Download a file's content; decrypts in place when encrypted").option("--out <path>", "Write content to a file instead of stdout").option("--share <string>", "Share string from upload (bundles file id, prefix, nonce)").option("--prefix <hex>", "Seal prefix (with --nonce) to decrypt").option("--nonce <hex>", "Seal nonce (with --prefix) to decrypt").option("--raw", "Write the raw (still-encrypted) bytes when no decrypt input is given");
2508
+ const download = file.command("download [file]").alias("read").description("Download a file's content; decrypts in place when encrypted").option("--out <path>", "Write content to a file instead of stdout").option("--share <string>", "Share string from upload (bundles file id, prefix, nonce)").option("--prefix <hex>", "Seal prefix (with --nonce) to decrypt").option("--nonce <hex>", "Seal nonce (with --prefix) to decrypt").option("--raw", "Write the raw (still-encrypted) bytes when no decrypt input is given");
2415
2509
  viaAggregatorOption(download).action(async (target, options, command) => {
2416
2510
  await runFileDownload(await buildFileDownloadContext(command, {
2417
2511
  viaAggregator: options.viaAggregator
2418
2512
  }), target, options);
2419
2513
  });
2420
- file.command("get <file>").description("Fetch a file's on-chain metadata").action(async (target, _options, command) => {
2514
+ file.command("get <file>").alias("show").description("Fetch a file's on-chain metadata").action(async (target, _options, command) => {
2421
2515
  await runFileGet(await buildFilesReadContext(command), target);
2422
2516
  });
2423
2517
  const register = file.command("register").description("Register on-chain metadata for a blob already on Walrus").requiredOption("--blob-id <id>", "Walrus content id").requiredOption("-n, --name <name>", "File name").requiredOption("--content-type <mime>", "MIME content type").requiredOption("--size <bytes>", "Plaintext byte length").option("--public", "Register a world-readable (unencrypted) file").option("--encrypted", "Register an encrypted file (needs --seal-prefix)").option("--seal-prefix <hex>", "Seal prefix the blob was encrypted under").option("--blob-object-id <id>", "On-chain Walrus Blob object id, if known");
@@ -2553,7 +2647,7 @@ async function runPublicationTransferOwnership(ctx, recipient, options, gopts) {
2553
2647
  }
2554
2648
  function registerPublicationCommands(program) {
2555
2649
  const publication = program.command("publication").alias("pub").description("Work with publications");
2556
- publication.command("get [publication]").description("Fetch a publication (slug or id; default: the active publication)").action(async (target, _options, command) => {
2650
+ publication.command("get [publication]").alias("show").description("Fetch a publication (slug or id; default: the active publication)").action(async (target, _options, command) => {
2557
2651
  await runPublicationGet(await buildReadContext(command), target);
2558
2652
  });
2559
2653
  publication.command("list [address]").description("List publications owned by an address (default: the active account)").option("--limit <n>", "Maximum results per page").option("--cursor <cursor>", "Continue from a previous page cursor").option("--ids-only", "Skip slug/name resolution (one RPC, no per-publication reads)").action(async (address, options, command) => {
@@ -43,6 +43,18 @@ Saved profile "testnet" (testnet).
43
43
 
44
44
  ## 2. Import your key
45
45
 
46
+ Three ways to give the CLI a key. It is never accepted as a flag, because argv
47
+ is visible in `ps` and shell history.
48
+
49
+ - **Interactive**: run `morse account import` and paste at the hidden prompt.
50
+ - **Piped**: `echo "$KEY" | morse account import`
51
+ - **No keystore at all**: export `MORSE_PRIVATE_KEY` and skip the import; every
52
+ command picks it up.
53
+
54
+ The first two store the key encrypted and need `MORSE_KEYSTORE_PASSWORD` (or an
55
+ interactive password prompt).
56
+
57
+
46
58
  `account import` prompts for the secret key and a keystore password, both hidden.
47
59
  The key is encrypted at rest (scrypt + AES-256-GCM) and never stored in plaintext.
48
60
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arcadiasystems/morse-cli",
3
- "version": "0.7.0",
3
+ "version": "0.9.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.7.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",