@arcadiasystems/morse-cli 0.8.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,32 @@ 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
+
7
33
  ## [0.8.0] - 2026-09-10
8
34
 
9
35
  Usability pass. Nothing is renamed or removed; every existing spelling keeps working.
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.8.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
@@ -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,10 @@ 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.
354
355
 
355
356
  ## Command spellings
356
357
 
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.8.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,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 ?? 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;
@@ -988,7 +988,70 @@ import {
988
988
  RpcRecipientFilesReader
989
989
  } from "@arcadiasystems/morse-sdk";
990
990
  import { SuiGrpcClient } from "@mysten/sui/grpc";
991
- 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
992
1055
  async function buildReadContext(command) {
993
1056
  const opts = globalOptions(command);
994
1057
  const output = outputFor(command);
@@ -1138,16 +1201,16 @@ async function buildFileListContext(command, opts = {}) {
1138
1201
  if (originPackageId === undefined) {
1139
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);
1140
1203
  }
1141
- const events = new SuiJsonRpcClient({
1142
- network: base.settings.network,
1143
- url: opts.indexerUrl ?? base.config.rpcUrl
1144
- });
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
+ }
1145
1208
  return {
1146
1209
  ...base,
1147
1210
  filesReader: RpcRecipientFilesReader.fromConfig(base.client, {
1148
1211
  packageId: base.config.packageId
1149
1212
  }),
1150
- events,
1213
+ events: new GraphqlEventQuerier(indexerUrl),
1151
1214
  eventTypes: buildRecipientFileEventTypes(originPackageId)
1152
1215
  };
1153
1216
  }
@@ -1528,6 +1591,9 @@ async function runCollectionList(ctx, options) {
1528
1591
  collections: publication.collections
1529
1592
  });
1530
1593
  }
1594
+ function isActivePublication(ctx, target) {
1595
+ return ctx.settings.publication === target;
1596
+ }
1531
1597
  async function runCollectionCreate(ctx, name, options, gopts) {
1532
1598
  const id = await resolvePublication(ctx, options.publication);
1533
1599
  const storageMode = coerceStorageMode(options.mode);
@@ -1543,8 +1609,11 @@ async function runCollectionCreate(ctx, name, options, gopts) {
1543
1609
  storageMode,
1544
1610
  signal: ctx.signal
1545
1611
  });
1546
- await updateActiveProfile(gopts, { collection: name });
1547
- 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);
1548
1617
  }
1549
1618
  async function runCollectionDelete(ctx, name, options, gopts) {
1550
1619
  const id = await resolvePublication(ctx, options.publication);
@@ -1566,7 +1635,7 @@ async function runCollectionDelete(ctx, name, options, gopts) {
1566
1635
  name,
1567
1636
  signal: ctx.signal
1568
1637
  });
1569
- if (ctx.settings.collection === name) {
1638
+ if (ctx.settings.collection === name && isActivePublication(ctx, id)) {
1570
1639
  await updateActiveProfile(gopts, { collection: undefined });
1571
1640
  }
1572
1641
  ctx.output.result(`Deleted collection "${name}". (tx: ${result.digest})`, result);
@@ -1606,6 +1675,7 @@ async function runConfigAdd(output, name, options) {
1606
1675
  const profiles = {
1607
1676
  ...cfg.profiles,
1608
1677
  [name]: {
1678
+ ...cfg.profiles[name],
1609
1679
  network,
1610
1680
  ...options.rpc === undefined ? {} : { rpc: options.rpc },
1611
1681
  ...options.uploadRelay === undefined ? {} : { uploadRelay: options.uploadRelay }
@@ -1627,11 +1697,19 @@ async function runConfigUse(output, name) {
1627
1697
  await saveConfig({ ...cfg, defaultProfile: name });
1628
1698
  output.result(`Default profile set to "${name}".`, { defaultProfile: name });
1629
1699
  }
1630
- async function runConfigRemove(output, name) {
1700
+ async function runConfigRemove(output, name, gopts = {}, signal) {
1631
1701
  const cfg = await loadConfig();
1632
1702
  requireProfile(cfg, name);
1633
1703
  const { [name]: _removed, ...rest } = cfg.profiles;
1634
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
+ }
1635
1713
  await saveConfig({ ...cfg, profiles: rest, defaultProfile });
1636
1714
  output.result(`Removed profile "${name}".`, {
1637
1715
  removed: name,
@@ -1654,7 +1732,7 @@ function registerConfigCommands(program) {
1654
1732
  await runConfigUse(outputFor(command), name);
1655
1733
  });
1656
1734
  config.command("remove <name>").alias("delete").description("Delete a profile").action(async (name, _options, command) => {
1657
- await runConfigRemove(outputFor(command), name);
1735
+ await runConfigRemove(outputFor(command), name, globalOptions(command), sigintSignal());
1658
1736
  });
1659
1737
  }
1660
1738
  function requireProfile(config, name) {
@@ -2129,6 +2207,9 @@ async function runFileRegister(ctx, options) {
2129
2207
  throw new UsageError("Pass --public or --encrypted, not both.");
2130
2208
  }
2131
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
+ }
2132
2213
  const blobId = toWalrusBlobId(options.blobId);
2133
2214
  const size = parseByteSize(options.size, "--size");
2134
2215
  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.8.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",