@arcadiasystems/morse-cli 0.4.2 → 0.6.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,87 @@ 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.6.0] - 2026-09-09
8
+
9
+ Mainnet support, consuming `@arcadiasystems/morse-sdk@0.5.0`. The Move contracts
10
+ went live on Sui mainnet on 2026-09-09; the CLI rejected `mainnet` outright until
11
+ now.
12
+
13
+ ### Added
14
+
15
+ - **`--network mainnet` works**, as do `MORSE_NETWORK=mainnet` and
16
+ `morse config add <name> --network mainnet`. `coerceNetwork` no longer
17
+ special-cases mainnet, and `localnet` is accepted too (it still fails at
18
+ startup unless you point the SDK at your own package and registry, which is
19
+ `morseConfig`'s error to raise, not the flag parser's).
20
+ - **Commands that spend gas now name the network they spent on.** The resolved
21
+ network appears as the first detail line under the result headline, and as a
22
+ `network` field in `--json` output. Applied on the write contexts only, so read
23
+ and list output is byte-for-byte unchanged. Nothing selects mainnet implicitly
24
+ (with no flag, env var, or profile the CLI targets testnet), so this is there
25
+ to make a mainnet spend visible after the fact rather than to gate it.
26
+
27
+ ### Fixed
28
+
29
+ - Status banner claimed v0.3.0, two releases stale, and said mainnet support was
30
+ pending.
31
+ - `--network` help text listed only testnet and localnet, in both the global
32
+ flag (`morse --help`) and `config add --network`, as did the `MORSE_NETWORK`
33
+ row of the README env table.
34
+ - Seven README links pointed into `examples/`, which is not in the package
35
+ `files` array, so they 404'd on the npm page. Now absolute GitHub URLs;
36
+ `docs/QUICKSTART.md` stays relative because `docs` does ship.
37
+ - Dead Walrus faucet link in `docs/QUICKSTART.md`. `docs.walrus.site` no longer
38
+ resolves; WAL acquisition now points at the working stake-wal.wal.app swap,
39
+ matching the README and morse-sdk.
40
+ - Known limitations now record that `file list` requires `--indexer-url`, since
41
+ public Sui fullnodes have retired the JSON-RPC event query it walks. This
42
+ affects testnet as well as mainnet and predates this release.
43
+
44
+ ### Known gaps
45
+
46
+ - `file list` is unusable without `--indexer-url` on every network, per above.
47
+ Porting the event source off `suix_queryEvents` is tracked separately.
48
+ - No mainnet run of the opt-in `bun run test:e2e` lifecycle; it stays pointed at
49
+ testnet, where it costs nothing real.
50
+ - The Move contracts remain unaudited.
51
+
52
+ ## [0.5.0] - 2026-06-09
53
+
54
+ Hardening pass from CLI testing: fail fast before paying for Walrus storage,
55
+ clearer errors on doomed operations, and accurate listings.
56
+
57
+ ### Added
58
+
59
+ - `revision publish-from-draft` now reuses the draft's already-uploaded content
60
+ by default (no re-upload, no extra WAL, and the published bytes match what was
61
+ reviewed). Pass `--file`/`--stdin` only to publish replacement content.
62
+ - `entry list` / `entry scan` accept `--drafts-only` to show just entries with a
63
+ pending (unpublished) draft. `entry list`/`entry get` now show a draft marker /
64
+ `pendingDraft` line.
65
+ - `file download --raw` writes the still-encrypted bytes when no decrypt input is
66
+ given.
67
+
68
+ ### Changed
69
+
70
+ - `file download` now refuses to write ciphertext when a file has recipients and
71
+ no `--share`/`--prefix`+`--nonce` is given; pass `--raw` to opt in. Previously
72
+ it warned and wrote the raw bytes.
73
+ - `entry add` verifies the collection exists (and is blob-mode) before uploading
74
+ to Walrus, so a missing or quilt collection fails without burning storage.
75
+ - `collection delete` checks the collection is empty up front and returns a clear
76
+ error instead of a raw transaction abort.
77
+ - `file list` now reconciles metadata-update and recipient events, so renames and
78
+ recipient counts are current without `--hydrate`.
79
+ - `collection create --mode quilt` warns that quilt collections cannot yet be
80
+ populated with `entry add`.
81
+
82
+ ### Fixed
83
+
84
+ - The `revision` commands refuse to append an unencrypted revision to an entry
85
+ that has encrypted revisions, instead of silently producing a mixed-encryption
86
+ entry.
87
+
7
88
  ## [0.4.2] - 2026-06-05
8
89
 
9
90
  ### Changed
package/README.md CHANGED
@@ -6,16 +6,24 @@ 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.3.0, targeting Sui testnet. The command surface is stable; mainnet
10
- > support lands when the contracts are frozen.
9
+ > Status: v0.6.0. Mainnet and testnet are both supported; the command surface is
10
+ > stable.
11
+ >
12
+ > **The Move contracts are unaudited.** On mainnet, gas and Walrus storage cost
13
+ > real SUI and WAL, there is no faucet, and deletions are irreversible. Nothing
14
+ > selects mainnet for you: with no `--network`, `MORSE_NETWORK`, or profile
15
+ > setting, the CLI targets testnet. Every command that spends gas prints the
16
+ > network it used, and `--json` output carries a `network` field.
11
17
 
12
18
  ## Requirements
13
19
 
14
20
  - [Node.js](https://nodejs.org) >= 18, or [Bun](https://bun.sh) >= 1.2. The
15
21
  published CLI runs under either.
16
- - A funded Sui testnet address for gas, and testnet WAL for Walrus storage when
17
- adding content. Get SUI from the [Sui faucet](https://faucet.sui.io/) and WAL
18
- from the [Walrus testnet faucet](https://docs.walrus.site/usage/web-tool.html).
22
+ - A funded Sui address for gas, and WAL for Walrus storage when adding content.
23
+ On testnet, get SUI from the [Sui faucet](https://faucet.sui.io/) and swap some
24
+ for WAL at [stake-wal.wal.app](https://stake-wal.wal.app/?network=testnet). On
25
+ mainnet there is no faucet: both have to be acquired, and every write spends
26
+ real value.
19
27
 
20
28
  ## Install
21
29
 
@@ -91,7 +99,7 @@ Environment variables (override the config file, overridden by flags):
91
99
  | Variable | Overrides | Notes |
92
100
  | --- | --- | --- |
93
101
  | `MORSE_PROFILE` | `--profile` | Profile to use. |
94
- | `MORSE_NETWORK` | `--network` | `testnet` or `localnet`. |
102
+ | `MORSE_NETWORK` | `--network` | `mainnet`, `testnet`, or `localnet`. |
95
103
  | `MORSE_RPC_URL` | `--rpc` | Sui RPC URL override. |
96
104
  | `MORSE_ADDRESS` | (no flag) | Active account address, selecting which keystore to use. |
97
105
  | `MORSE_PUBLICATION` | `-P, --publication` | Active publication id. |
@@ -123,7 +131,7 @@ Global options apply to every command and must appear before the subcommand
123
131
 
124
132
  | Global option | Purpose |
125
133
  | --- | --- |
126
- | `--network <testnet\|localnet>` | Network to target (default: testnet). |
134
+ | `--network <mainnet\|testnet\|localnet>` | Network to target (default: testnet). `localnet` needs a custom deployment. |
127
135
  | `-p, --profile <name>` | Config profile to use. |
128
136
  | `--rpc <url>` | Override the Sui RPC URL. |
129
137
  | `--json` | Machine-readable JSON on stdout. |
@@ -188,8 +196,8 @@ and `-C, --collection <name>`, both defaulting to the active context.
188
196
  | --- | --- |
189
197
  | `entry get <entryId> [-P …] [-C …]` | Fetch a single entry's metadata. |
190
198
  | `entry read <entryId> [revisionIndex] [--out <path>] [--via-aggregator] [-P …] [-C …]` | Fetch a public entry's content to stdout or a file. |
191
- | `entry list [-P …] [-C …]` | List entries (paginated). |
192
- | `entry scan [-P …] [-C …]` | List every entry (auto-paginated). |
199
+ | `entry list [--drafts-only] [-P …] [-C …]` | List entries (paginated); `--drafts-only` shows just entries with a pending draft. |
200
+ | `entry scan [--drafts-only] [-P …] [-C …]` | List every entry (auto-paginated). |
193
201
  | `entry add <name> --file <path> [-P …] [-C …]` | Upload content and add a new entry; prints a viewable link. |
194
202
  | `entry delete <entryId> [-P …] [-C …]` | Delete an entry. |
195
203
  | `entry add-encrypted <name> --file <path> [-P …] [-C …]` | Encrypt, upload, and add a new entry. |
@@ -212,7 +220,7 @@ commands; pure on-chain commands (publication, collection, cap) have no epochs.
212
220
  | --- | --- |
213
221
  | `revision publish-direct <entryId> --file <path> [-P …] [-C …]` | Upload content and append a public revision. |
214
222
  | `revision append-draft <entryId> --file <path> [-P …] [-C …]` | Upload content and append a draft revision. |
215
- | `revision publish-from-draft <entryId> <draftRevisionId> --file <path> [-P …] [-C …]` | Publish a new revision, referencing a draft. |
223
+ | `revision publish-from-draft <entryId> <draftRevisionId> [--file <path>] [-P …] [-C …]` | Publish a draft as a new revision. Reuses the draft's content by default; pass `--file`/`--stdin` to publish replacement content. |
216
224
 
217
225
  ### cap
218
226
 
@@ -238,7 +246,7 @@ There is no separate allowlist object.
238
246
  | --- | --- |
239
247
  | `file upload <path> --name <n> (--public \| --encrypt \| -r <addr>...) [--content-type <m>] [--epochs <n>]` | Upload to Walrus and register. `--public` is world-readable; `--encrypt` (or supplying `-r`) encrypts, and the sender is always a recipient. One of the three is required. |
240
248
  | `file register --blob-id <id> --name <n> --content-type <m> --size <bytes> (--public \| --encrypted --seal-prefix <hex>) [-r <addr>...] [--blob-object-id <id>]` | Register metadata for a blob already on Walrus. |
241
- | `file download [file] [--out <path>] [--share <s>] [--prefix <hex> --nonce <hex>] [--via-aggregator]` | Download content; decrypts in place with a share string (or prefix + nonce). |
249
+ | `file download [file] [--out <path>] [--share <s>] [--prefix <hex> --nonce <hex>] [--raw] [--via-aggregator]` | Download content; decrypts in place with a share string (or prefix + nonce). For an encrypted file with no decrypt input, it refuses unless `--raw` is given. |
242
250
  | `file list [--address <addr>] [--accessible] [--hydrate] [--limit <n>] [--indexer-url <url>]` | List files owned by (or, with `--accessible`, decryptable by) an address. |
243
251
  | `file get <file>` | Fetch a file's on-chain metadata and recipient list. |
244
252
  | `file update <file> --name <n> --content-type <m>` | Update name and MIME (owner only). |
@@ -290,13 +298,13 @@ to fetch the full record per file (one read each) when you need them.
290
298
  ## Examples
291
299
 
292
300
  - [docs/QUICKSTART.md](./docs/QUICKSTART.md): a full, copy-pasteable walkthrough.
293
- - Runnable shell recipes in [examples/](./examples/):
294
- - [`lifecycle.sh`](./examples/lifecycle.sh): create, add an entry, read, revise, tear down.
295
- - [`content.sh`](./examples/content.sh): upload an image and a post, publish a revision, fetch content back, get a link, remove a collection.
296
- - [`encrypt-decrypt.sh`](./examples/encrypt-decrypt.sh): encrypt with Seal and decrypt back.
297
- - [`delegation.sh`](./examples/delegation.sh): issue a PublisherCap to a delegate, then revoke it.
298
- - [`ci-noninteractive.sh`](./examples/ci-noninteractive.sh): env-var auth, `--yes`, and `--json` parsing.
299
- - [`files.sh`](./examples/files.sh): RecipientFile round-trip (upload an encrypted file, download/decrypt via its share string, manage recipients, plus a public file).
301
+ - Runnable shell recipes in [examples/](https://github.com/arcadiasystems/morse-dcms/tree/main/morse-cli/examples):
302
+ - [`lifecycle.sh`](https://github.com/arcadiasystems/morse-dcms/blob/main/morse-cli/examples/lifecycle.sh): create, add an entry, read, revise, tear down.
303
+ - [`content.sh`](https://github.com/arcadiasystems/morse-dcms/blob/main/morse-cli/examples/content.sh): upload an image and a post, publish a revision, fetch content back, get a link, remove a collection.
304
+ - [`encrypt-decrypt.sh`](https://github.com/arcadiasystems/morse-dcms/blob/main/morse-cli/examples/encrypt-decrypt.sh): encrypt with Seal and decrypt back.
305
+ - [`delegation.sh`](https://github.com/arcadiasystems/morse-dcms/blob/main/morse-cli/examples/delegation.sh): issue a PublisherCap to a delegate, then revoke it.
306
+ - [`ci-noninteractive.sh`](https://github.com/arcadiasystems/morse-dcms/blob/main/morse-cli/examples/ci-noninteractive.sh): env-var auth, `--yes`, and `--json` parsing.
307
+ - [`files.sh`](https://github.com/arcadiasystems/morse-dcms/blob/main/morse-cli/examples/files.sh): RecipientFile round-trip (upload an encrypted file, download/decrypt via its share string, manage recipients, plus a public file).
300
308
 
301
309
  ## Limitations
302
310
 
@@ -311,7 +319,19 @@ to fetch the full record per file (one read each) when you need them.
311
319
  to read through the Walrus aggregator HTTP service instead: more reliable when
312
320
  storage nodes are flaky (common on testnet), at the cost of trusting the
313
321
  aggregator's bytes (no client-side verification).
314
- - Mainnet is not yet deployed; use `testnet`.
322
+ - Encrypted entries cannot be revised. The `revision` commands and `entry add`
323
+ only produce unencrypted content, so the CLI refuses to append to an entry that
324
+ already has encrypted revisions (to avoid a mixed-encryption entry). Use
325
+ `entry add-encrypted` for new encrypted entries.
326
+ - `entry add` supports blob-mode collections only. Quilt collections can be
327
+ created but not yet populated through the CLI; `entry add` refuses them.
328
+ - There is no command to query Walrus blob storage expiry; `--epochs` sets the
329
+ lease at upload time, but remaining lease time is not exposed by the SDK.
330
+ - `localnet` is accepted as a network but has no canonical deployment, so it
331
+ fails at startup unless you point the SDK at your own package and registry.
332
+ - `file list` walks a JSON-RPC event query that public Sui fullnodes have
333
+ retired, so it needs `--indexer-url` pointing at a source that serves the same
334
+ query. This affects testnet as well as mainnet.
315
335
 
316
336
  ## Publishing
317
337
 
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.4.2",
151
+ version: "0.6.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.4.2",
206
+ "@arcadiasystems/morse-sdk": "^0.5.0",
207
207
  "@mysten/seal": "1.1.3",
208
208
  "@mysten/sui": "2.16.2",
209
209
  "@mysten/walrus": "1.1.6",
@@ -219,7 +219,7 @@ var package_default = {
219
219
  // src/cli/program.ts
220
220
  function buildProgram() {
221
221
  const program = new Command;
222
- 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: 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("--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();
222
+ 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("--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
223
  return program;
224
224
  }
225
225
 
@@ -316,14 +316,39 @@ class Output {
316
316
  get isJson() {
317
317
  return this.options.json;
318
318
  }
319
+ withNetwork(network) {
320
+ return new Output({ ...this.options, network });
321
+ }
319
322
  result(human, data) {
320
323
  if (this.options.json) {
321
- this.out(`${toJson(data)}
324
+ this.out(`${toJson(this.stampJson(data))}
322
325
  `);
323
326
  return;
324
327
  }
325
- this.out(`${human}
328
+ this.out(`${this.stampHuman(human)}
329
+ `);
330
+ }
331
+ stampJson(data) {
332
+ const network = this.options.network;
333
+ if (network === undefined || !isPlainObject(data) || "network" in data) {
334
+ return data;
335
+ }
336
+ return { network, ...data };
337
+ }
338
+ stampHuman(human) {
339
+ const network = this.options.network;
340
+ if (network === undefined) {
341
+ return human;
342
+ }
343
+ const line = ` network: ${network}`;
344
+ const breakAt = human.indexOf(`
326
345
  `);
346
+ if (breakAt === -1) {
347
+ return `${human}
348
+ ${line}`;
349
+ }
350
+ return `${human.slice(0, breakAt)}
351
+ ${line}${human.slice(breakAt)}`;
327
352
  }
328
353
  info(message) {
329
354
  if (this.options.quiet || this.options.json) {
@@ -369,6 +394,9 @@ function resolveColor(json) {
369
394
  }
370
395
  return Boolean(process.stderr.isTTY);
371
396
  }
397
+ function isPlainObject(value) {
398
+ return typeof value === "object" && value !== null && !Array.isArray(value);
399
+ }
372
400
  function createOutput(opts) {
373
401
  const json = Boolean(opts.json);
374
402
  return new Output({
@@ -397,13 +425,10 @@ function isNetwork(value) {
397
425
  return typeof value === "string" && NETWORKS.has(value);
398
426
  }
399
427
  function coerceNetwork(value) {
400
- if (value === Network.Mainnet) {
401
- throw new UsageError("Morse is not yet deployed on mainnet. Use testnet or localnet.");
402
- }
403
428
  if (isNetwork(value)) {
404
429
  return value;
405
430
  }
406
- throw new UsageError(`Unknown network "${value}". Use one of: testnet, localnet.`);
431
+ throw new UsageError(`Unknown network "${value}". Use one of: mainnet, testnet, localnet.`);
407
432
  }
408
433
  function parseConfig(raw, source) {
409
434
  if (!isRecord(raw)) {
@@ -945,6 +970,7 @@ async function buildWriteContext(command) {
945
970
  const { base, keypair, address } = await buildSignedBase(command);
946
971
  return {
947
972
  ...base,
973
+ output: base.output.withNetwork(base.settings.network),
948
974
  adapter: new KeypairAdapter(keypair, base.client),
949
975
  address
950
976
  };
@@ -966,7 +992,13 @@ async function buildContentContext(command) {
966
992
  }
967
993
  const adapter = new KeypairAdapter(keypair, base.client);
968
994
  const walrus = DefaultWalrusWriteAdapter.fromConfig({ network, suiClient: base.client }, keypair);
969
- return { ...base, adapter, address, walrus };
995
+ return {
996
+ ...base,
997
+ output: base.output.withNetwork(network),
998
+ adapter,
999
+ address,
1000
+ walrus
1001
+ };
970
1002
  }
971
1003
  async function buildEncryptContext(command) {
972
1004
  const ctx = await buildContentContext(command);
@@ -1136,11 +1168,18 @@ function renderPublicationList(items) {
1136
1168
  return items.map((item) => `${item.publicationId} (owner cap ${shortId(item.ownerCapId)})`).join(`
1137
1169
  `);
1138
1170
  }
1171
+ function hasPendingDraft(entry) {
1172
+ if (entry.draftHead === null) {
1173
+ return false;
1174
+ }
1175
+ return entry.publicHead === null || entry.draftHead > entry.publicHead;
1176
+ }
1139
1177
  function renderEntry(entry) {
1140
1178
  const lines = [
1141
1179
  `#${entry.id} ${entry.name}`,
1142
- `publicHead: ${headLabel(entry.publicHead)} draftHead: ${headLabel(entry.draftHead)}`,
1143
- `revisions: ${entry.revisions.length}`
1180
+ `publicHead: ${headLabel(entry.publicHead)} draftHead: ${headLabel(entry.draftHead)}`,
1181
+ `pendingDraft: ${hasPendingDraft(entry) ? "yes" : "no"}`,
1182
+ `revisions: ${entry.revisions.length}`
1144
1183
  ];
1145
1184
  for (const revision of entry.revisions) {
1146
1185
  lines.push(` ${renderRevisionLine(revision)}`);
@@ -1152,7 +1191,10 @@ function renderEntryList(entries) {
1152
1191
  if (entries.length === 0) {
1153
1192
  return "No entries in this collection.";
1154
1193
  }
1155
- return entries.map((entry) => `#${entry.id} ${entry.name} (${entry.revisions.length} revisions)`).join(`
1194
+ return entries.map((entry) => {
1195
+ const marker = hasPendingDraft(entry) ? " [draft]" : "";
1196
+ return `#${entry.id} ${entry.name} (${entry.revisions.length} revisions)${marker}`;
1197
+ }).join(`
1156
1198
  `);
1157
1199
  }
1158
1200
  function renderFileList(items) {
@@ -1397,7 +1439,11 @@ function registerCapCommands(program) {
1397
1439
  }
1398
1440
 
1399
1441
  // src/commands/collection.ts
1400
- import { createCollection, deleteCollection } from "@arcadiasystems/morse-sdk";
1442
+ import {
1443
+ createCollection,
1444
+ deleteCollection,
1445
+ StorageMode as StorageMode2
1446
+ } from "@arcadiasystems/morse-sdk";
1401
1447
  async function runCollectionList(ctx, options) {
1402
1448
  const id = await resolvePublication(ctx, options.publication);
1403
1449
  const publication = await ctx.reader.getPublication(id, ctx.signal);
@@ -1410,6 +1456,9 @@ async function runCollectionCreate(ctx, name, options, gopts) {
1410
1456
  const id = await resolvePublication(ctx, options.publication);
1411
1457
  const storageMode = coerceStorageMode(options.mode);
1412
1458
  const publisherCapId = await resolvePublisherCap(ctx.reader, ctx.address, id, options.publisherCap, ctx.signal);
1459
+ if (storageMode === StorageMode2.Quilt) {
1460
+ ctx.output.warn("Quilt collections cannot yet be populated with `morse entry add` (only blob collections are supported).");
1461
+ }
1413
1462
  ctx.output.info(`Creating collection "${name}" (${storageMode})...`);
1414
1463
  const result = await createCollection(ctx.adapter, ctx.config, {
1415
1464
  publicationId: id,
@@ -1423,6 +1472,13 @@ async function runCollectionCreate(ctx, name, options, gopts) {
1423
1472
  }
1424
1473
  async function runCollectionDelete(ctx, name, options, gopts) {
1425
1474
  const id = await resolvePublication(ctx, options.publication);
1475
+ const entries = await ctx.reader.listEntries(id, name, {
1476
+ limit: 1,
1477
+ signal: ctx.signal
1478
+ });
1479
+ if (entries.results.length > 0) {
1480
+ throw new UsageError(`Cannot delete collection "${name}": it still has entries. Delete them first.`);
1481
+ }
1426
1482
  const proceed = await confirm(`Delete collection "${name}" from ${shortId(id)}? It must be empty.`, { assumeYes: Boolean(gopts.yes), signal: ctx.signal });
1427
1483
  if (!proceed) {
1428
1484
  cancelled();
@@ -1512,7 +1568,7 @@ function registerConfigCommands(program) {
1512
1568
  config.command("list").description("List profiles and show the default").action(async (_options, command) => {
1513
1569
  await runConfigList(outputFor(command));
1514
1570
  });
1515
- config.command("add <name>").description("Create or update a profile").requiredOption("--network <network>", "Sui network: testnet or localnet").option("--rpc <url>", "RPC URL override for this profile").action(async (name, options, command) => {
1571
+ config.command("add <name>").description("Create or update a profile").requiredOption("--network <network>", "Sui network: mainnet, testnet, or localnet").option("--rpc <url>", "RPC URL override for this profile").action(async (name, options, command) => {
1516
1572
  await runConfigAdd(outputFor(command), name, options);
1517
1573
  });
1518
1574
  config.command("use <name>").description("Set the default profile").action(async (name, _options, command) => {
@@ -1553,7 +1609,8 @@ function renderProfiles(config) {
1553
1609
  // src/commands/entry.ts
1554
1610
  import {
1555
1611
  addEntryFromBytes,
1556
- deleteEntry
1612
+ deleteEntry,
1613
+ StorageMode as StorageMode3
1557
1614
  } from "@arcadiasystems/morse-sdk";
1558
1615
 
1559
1616
  // src/cli/input.ts
@@ -1701,6 +1758,16 @@ function registerEncryptedEntryCommands(entry) {
1701
1758
  }
1702
1759
 
1703
1760
  // src/commands/entry.ts
1761
+ async function assertEntryAddCollection(ctx, publicationId, collection) {
1762
+ const publication = await ctx.reader.getPublication(publicationId, ctx.signal);
1763
+ const found = publication.collections.find((c) => c.name === collection);
1764
+ if (found === undefined) {
1765
+ throw new UsageError(`Publication ${shortId(publicationId)} has no collection "${collection}".`);
1766
+ }
1767
+ if (found.storageMode === StorageMode3.Quilt) {
1768
+ throw new UsageError(`Collection "${collection}" uses quilt storage, which \`entry add\` does not support yet. Use a blob-mode collection.`);
1769
+ }
1770
+ }
1704
1771
  async function runEntryGet(ctx, entryId, options) {
1705
1772
  const id = await resolvePublication(ctx, options.publication);
1706
1773
  const collection = resolveCollection(ctx, options.collection);
@@ -1718,8 +1785,9 @@ async function runEntryList(ctx, options) {
1718
1785
  if (page.nextCursor !== null) {
1719
1786
  ctx.output.info(`More results: pass --cursor "${page.nextCursor}"`);
1720
1787
  }
1721
- ctx.output.result(renderEntryList(page.results), {
1722
- results: page.results,
1788
+ const results = options.draftsOnly ? page.results.filter(hasPendingDraft) : page.results;
1789
+ ctx.output.result(renderEntryList(results), {
1790
+ results,
1723
1791
  nextCursor: page.nextCursor
1724
1792
  });
1725
1793
  }
@@ -1730,7 +1798,9 @@ async function runEntryScan(ctx, options) {
1730
1798
  for await (const item of ctx.reader.scanEntries(id, collection, {
1731
1799
  signal: ctx.signal
1732
1800
  })) {
1733
- entries.push(item);
1801
+ if (!options.draftsOnly || hasPendingDraft(item)) {
1802
+ entries.push(item);
1803
+ }
1734
1804
  }
1735
1805
  ctx.output.result(renderEntryList(entries), { results: entries });
1736
1806
  }
@@ -1740,6 +1810,7 @@ async function runEntryAdd(ctx, name, options) {
1740
1810
  const epochs = parsePositiveInt(options.epochs, "--epochs");
1741
1811
  const bytes = await readContentBytes(options);
1742
1812
  const contentType = resolveContentType(options.contentType, options.stdin ? undefined : options.file);
1813
+ await assertEntryAddCollection(ctx, id, collection);
1743
1814
  const publisherCapId = await resolvePublisherCap(ctx.reader, ctx.address, id, options.publisherCap, ctx.signal);
1744
1815
  ctx.output.info(`Uploading ${bytes.length} bytes to Walrus...`);
1745
1816
  const result = await addEntryFromBytes(ctx.adapter, ctx.config, {
@@ -1819,11 +1890,11 @@ function registerEntryCommands(program) {
1819
1890
  collectionOption(publicationOption(get)).action(async (entryId, options, command) => {
1820
1891
  await runEntryGet(await buildReadContext(command), entryId, options);
1821
1892
  });
1822
- 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");
1893
+ 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");
1823
1894
  collectionOption(publicationOption(list)).action(async (options, command) => {
1824
1895
  await runEntryList(await buildReadContext(command), options);
1825
1896
  });
1826
- const scan = entry.command("scan").description("List every entry in a collection (auto-paginated)");
1897
+ const scan = entry.command("scan").description("List every entry in a collection (auto-paginated)").option("--drafts-only", "Show only entries with a pending (unpublished) draft");
1827
1898
  collectionOption(publicationOption(scan)).action(async (options, command) => {
1828
1899
  await runEntryScan(await buildReadContext(command), options);
1829
1900
  });
@@ -2078,7 +2149,7 @@ async function runFileUpload(ctx, path, options) {
2078
2149
  const upload = { epochs, deletable: true };
2079
2150
  const onProgress = uploadProgress(ctx.output);
2080
2151
  if (options.public) {
2081
- const result2 = await uploadRecipientFileFromBytes(ctx.adapter, ctx.config, {
2152
+ const result = await uploadRecipientFileFromBytes(ctx.adapter, ctx.config, {
2082
2153
  walrus: ctx.walrus,
2083
2154
  bytes,
2084
2155
  recipients: [],
@@ -2089,10 +2160,10 @@ async function runFileUpload(ctx, path, options) {
2089
2160
  onProgress
2090
2161
  });
2091
2162
  const aggregator = ctx.config.walrusEndpoints.aggregator;
2092
- const viewUrl = aggregator.length > 0 ? `${aggregator}/v1/blobs/${result2.blobId}` : undefined;
2093
- const human2 = viewUrl === undefined ? `Uploaded public file ${result2.fileId}. (tx: ${result2.digest})` : `Uploaded public file ${result2.fileId}. (tx: ${result2.digest})
2163
+ const viewUrl = aggregator.length > 0 ? `${aggregator}/v1/blobs/${result.blobId}` : undefined;
2164
+ const human = viewUrl === undefined ? `Uploaded public file ${result.fileId}. (tx: ${result.digest})` : `Uploaded public file ${result.fileId}. (tx: ${result.digest})
2094
2165
  view: ${viewUrl}`;
2095
- ctx.output.result(human2, { ...result2, viewUrl: viewUrl ?? null });
2166
+ ctx.output.result(human, { ...result, viewUrl: viewUrl ?? null });
2096
2167
  return;
2097
2168
  }
2098
2169
  const result = await uploadEncryptedRecipientFileFromBytes(ctx.adapter, ctx.config, {
@@ -2178,10 +2249,16 @@ async function runFileDownload(ctx, positional, options) {
2178
2249
  throw new UsageError("Downloading content to stdout is not supported in --json mode; pass --out <path>.");
2179
2250
  }
2180
2251
  const target = resolveDownloadTarget(positional, options);
2252
+ if (options.raw && target.decrypt !== undefined) {
2253
+ ctx.output.warn("--raw is ignored because a decrypt input (--share/--prefix/--nonce) was given; decrypting.");
2254
+ }
2181
2255
  const fileId = toRecipientFileId(target.fileId);
2182
2256
  const file = await ctx.filesReader.getRecipientFile(fileId, ctx.signal);
2183
2257
  if (target.decrypt === undefined && file.members.length > 0) {
2184
- ctx.output.warn("This file has a recipient list, so its bytes are likely encrypted. Pass --share (or --prefix and --nonce) to decrypt; writing the raw bytes as-is.");
2258
+ if (!options.raw) {
2259
+ throw new UsageError("This file has a recipient list, so its bytes are encrypted. Pass --share (or --prefix and --nonce) to decrypt, or --raw to write the raw ciphertext anyway.");
2260
+ }
2261
+ ctx.output.warn("Writing raw ciphertext (--raw); the output is unusable without decryption.");
2185
2262
  }
2186
2263
  ctx.output.info("Fetching content from Walrus...");
2187
2264
  const bytes = await ctx.walrusRead.readBlob(file.blobId, {
@@ -2216,6 +2293,7 @@ async function runFileList(ctx, options) {
2216
2293
  ctx.output.info("Fetching recipient and file events...");
2217
2294
  const events = await fetchEventStreams(ctx.events, [
2218
2295
  types.RecipientFileCreated,
2296
+ types.RecipientFileMetadataUpdated,
2219
2297
  types.RecipientAdded,
2220
2298
  types.RecipientRemoved,
2221
2299
  types.RecipientFileDeleted
@@ -2225,7 +2303,10 @@ async function runFileList(ctx, options) {
2225
2303
  ctx.output.info("Fetching file events...");
2226
2304
  const events = await fetchEventStreams(ctx.events, [
2227
2305
  types.RecipientFileCreated,
2306
+ types.RecipientFileMetadataUpdated,
2228
2307
  types.RecipientFileOwnershipTransferred,
2308
+ types.RecipientAdded,
2309
+ types.RecipientRemoved,
2229
2310
  types.RecipientFileDeleted
2230
2311
  ], ctx.signal);
2231
2312
  summaries = reconcileRecipientFilesOwnedBy(events, address, types);
@@ -2254,7 +2335,7 @@ function registerFileCommands(program) {
2254
2335
  indexerUrl: options.indexerUrl
2255
2336
  }), options);
2256
2337
  });
2257
- 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");
2338
+ 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");
2258
2339
  viaAggregatorOption(download).action(async (target, options, command) => {
2259
2340
  await runFileDownload(await buildFileDownloadContext(command, {
2260
2341
  viaAggregator: options.viaAggregator
@@ -2428,26 +2509,41 @@ async function resolveTarget(ctx, options) {
2428
2509
  const collection = resolveCollection(ctx, options.collection);
2429
2510
  return { publicationId, collection };
2430
2511
  }
2431
- async function prepareContent(ctx, publicationId, options) {
2512
+ async function loadWritableEntry(ctx, publicationId, collection, entryId) {
2513
+ const entry = await ctx.reader.getEntry(publicationId, collection, entryId, ctx.signal);
2514
+ if (entry.revisions.some((revision) => revision.encrypted)) {
2515
+ throw new UsageError(`Entry #${entryId} has encrypted revisions; the CLI cannot append an unencrypted revision to it. Encrypted revisions are not yet supported.`);
2516
+ }
2517
+ return entry;
2518
+ }
2519
+ async function readLocalContent(options) {
2432
2520
  const epochs = parsePositiveInt(options.epochs, "--epochs");
2433
2521
  const bytes = await readContentBytes(options);
2434
2522
  const contentType = resolveContentType(options.contentType, options.stdin ? undefined : options.file);
2435
- const publisherCapId = await resolvePublisherCap(ctx.reader, ctx.address, publicationId, options.publisherCap, ctx.signal);
2436
- ctx.output.info(`Uploading ${bytes.length} bytes to Walrus...`);
2437
- const upload = await ctx.walrus.uploadBlob(bytes, {
2438
- epochs,
2523
+ return { bytes, contentType, epochs };
2524
+ }
2525
+ async function uploadLocal(ctx, local) {
2526
+ ctx.output.info(`Uploading ${local.bytes.length} bytes to Walrus...`);
2527
+ const upload = await ctx.walrus.uploadBlob(local.bytes, {
2528
+ epochs: local.epochs,
2439
2529
  deletable: true
2440
2530
  });
2441
2531
  ctx.output.info(`Blob uploaded (${upload.blobObjectId}). Submitting transaction...`);
2442
- return { blobObjectId: upload.blobObjectId, contentType, publisherCapId };
2532
+ return { blobObjectId: upload.blobObjectId, contentType: local.contentType };
2533
+ }
2534
+ function capFor(ctx, publicationId, options) {
2535
+ return resolvePublisherCap(ctx.reader, ctx.address, publicationId, options.publisherCap, ctx.signal);
2443
2536
  }
2444
2537
  async function runRevisionPublishDirect(ctx, entryId, options) {
2445
2538
  const { publicationId, collection } = await resolveTarget(ctx, options);
2446
2539
  const numericEntryId = parseId(entryId, "entryId");
2447
- const prepared = await prepareContent(ctx, publicationId, options);
2540
+ const local = await readLocalContent(options);
2541
+ await loadWritableEntry(ctx, publicationId, collection, numericEntryId);
2542
+ const publisherCapId = await capFor(ctx, publicationId, options);
2543
+ const prepared = await uploadLocal(ctx, local);
2448
2544
  const result = await publishDirect(ctx.adapter, ctx.config, {
2449
2545
  publicationId,
2450
- publisherCapId: prepared.publisherCapId,
2546
+ publisherCapId,
2451
2547
  collectionName: collection,
2452
2548
  entryId: numericEntryId,
2453
2549
  blobObjectId: prepared.blobObjectId,
@@ -2459,10 +2555,13 @@ async function runRevisionPublishDirect(ctx, entryId, options) {
2459
2555
  async function runRevisionAppendDraft(ctx, entryId, options) {
2460
2556
  const { publicationId, collection } = await resolveTarget(ctx, options);
2461
2557
  const numericEntryId = parseId(entryId, "entryId");
2462
- const prepared = await prepareContent(ctx, publicationId, options);
2558
+ const local = await readLocalContent(options);
2559
+ await loadWritableEntry(ctx, publicationId, collection, numericEntryId);
2560
+ const publisherCapId = await capFor(ctx, publicationId, options);
2561
+ const prepared = await uploadLocal(ctx, local);
2463
2562
  const result = await appendDraftRevision(ctx.adapter, ctx.config, {
2464
2563
  publicationId,
2465
- publisherCapId: prepared.publisherCapId,
2564
+ publisherCapId,
2466
2565
  collectionName: collection,
2467
2566
  entryId: numericEntryId,
2468
2567
  blobObjectId: prepared.blobObjectId,
@@ -2471,14 +2570,31 @@ async function runRevisionAppendDraft(ctx, entryId, options) {
2471
2570
  });
2472
2571
  ctx.output.result(`Appended draft revision #${result.revisionId} on entry #${numericEntryId}. (tx: ${result.digest})`, result);
2473
2572
  }
2573
+ function reuseDraftBlob(entry, draftRevisionId) {
2574
+ const draft = entry.revisions.find((r) => r.id === draftRevisionId);
2575
+ if (draft === undefined) {
2576
+ throw new UsageError(`Entry #${entry.id} has no revision #${draftRevisionId} to publish.`);
2577
+ }
2578
+ if (draft.blobRef.kind !== "blob") {
2579
+ throw new UsageError(`Draft #${draftRevisionId} uses quilt storage, which cannot be reused; pass --file or --stdin to publish new content.`);
2580
+ }
2581
+ return {
2582
+ blobObjectId: draft.blobRef.blobObjectId,
2583
+ contentType: draft.contentType
2584
+ };
2585
+ }
2474
2586
  async function runRevisionPublishFromDraft(ctx, entryId, draftRevisionId, options) {
2475
2587
  const { publicationId, collection } = await resolveTarget(ctx, options);
2476
2588
  const numericEntryId = parseId(entryId, "entryId");
2477
2589
  const draftId = parseId(draftRevisionId, "draftRevisionId");
2478
- const prepared = await prepareContent(ctx, publicationId, options);
2590
+ const providedNewContent = options.file !== undefined || Boolean(options.stdin);
2591
+ const local = providedNewContent ? await readLocalContent(options) : undefined;
2592
+ const entry = await loadWritableEntry(ctx, publicationId, collection, numericEntryId);
2593
+ const publisherCapId = await capFor(ctx, publicationId, options);
2594
+ const prepared = local === undefined ? reuseDraftBlob(entry, draftId) : await uploadLocal(ctx, local);
2479
2595
  const result = await publishFromDraft(ctx.adapter, ctx.config, {
2480
2596
  publicationId,
2481
- publisherCapId: prepared.publisherCapId,
2597
+ publisherCapId,
2482
2598
  collectionName: collection,
2483
2599
  entryId: numericEntryId,
2484
2600
  draftRevisionId: draftId,
@@ -2496,7 +2612,7 @@ function registerRevisionCommands(program) {
2496
2612
  revisionOptions(revision.command("append-draft <entryId>").description("Upload content and append it as a draft revision")).action(async (entryId, options, command) => {
2497
2613
  await runRevisionAppendDraft(await buildContentContext(command), entryId, options);
2498
2614
  });
2499
- revisionOptions(revision.command("publish-from-draft <entryId> <draftRevisionId>").description("Upload content and publish it as a new revision, referencing a draft")).action(async (entryId, draftRevisionId, options, command) => {
2615
+ revisionOptions(revision.command("publish-from-draft <entryId> <draftRevisionId>").description("Publish a draft as a new revision (reuses the draft's content; pass --file/--stdin to publish replacement content)")).action(async (entryId, draftRevisionId, options, command) => {
2500
2616
  await runRevisionPublishFromDraft(await buildContentContext(command), entryId, draftRevisionId, options);
2501
2617
  });
2502
2618
  }
@@ -2504,11 +2620,11 @@ function registerRevisionCommands(program) {
2504
2620
  // src/commands/use.ts
2505
2621
  async function runUse(output, gopts, publication, collection, options, makeContext) {
2506
2622
  if (options.clear) {
2507
- const profile2 = await updateActiveProfile(gopts, {
2623
+ const profile = await updateActiveProfile(gopts, {
2508
2624
  publication: undefined,
2509
2625
  collection: undefined
2510
2626
  });
2511
- output.result(`Cleared the active publication and collection (profile "${profile2}").`, { profile: profile2, publication: null, collection: null });
2627
+ output.result(`Cleared the active publication and collection (profile "${profile}").`, { profile, publication: null, collection: null });
2512
2628
  return;
2513
2629
  }
2514
2630
  if (publication === undefined) {
@@ -5,14 +5,21 @@ Sui testnet, then tears it down. Every command is copy-pasteable; expected
5
5
  output is shown beneath each step. Replace IDs in later steps with the ones your
6
6
  own runs print.
7
7
 
8
+ The walkthrough uses testnet on purpose: it creates a publication, uploads
9
+ content to Walrus, and deletes it all again. On mainnet that sequence costs real
10
+ SUI and WAL. Once it works for you, the only change needed is the network on the
11
+ profile in step 1 (`--network mainnet`); every later command reads the network
12
+ from the profile and prints which one it spent on.
13
+
8
14
  ## 0. Prerequisites
9
15
 
10
16
  - Install [Bun](https://bun.sh) >= 1.2.
11
17
  - Have a Sui testnet keypair. If you use the Sui CLI, export one with
12
18
  `sui keytool export --key-identity <alias>` to get a `suiprivkey1...` string.
13
19
  - Fund the address with testnet SUI ([faucet](https://faucet.sui.io/)) for gas,
14
- and with WAL ([Walrus faucet](https://docs.walrus.site/usage/web-tool.html))
15
- for content uploads.
20
+ and with WAL by swapping some SUI at
21
+ [stake-wal.wal.app](https://stake-wal.wal.app/?network=testnet) for content
22
+ uploads.
16
23
 
17
24
  Install the CLI:
18
25
 
@@ -24,6 +31,8 @@ morse --version
24
31
  ## 1. Create a profile
25
32
 
26
33
  A profile pins the network. The first profile you add becomes the default.
34
+ `--network` takes `mainnet`, `testnet`, or `localnet`; with no profile, flag, or
35
+ `MORSE_NETWORK`, the CLI targets testnet.
27
36
 
28
37
  ```sh
29
38
  morse config add testnet --network testnet
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arcadiasystems/morse-cli",
3
- "version": "0.4.2",
3
+ "version": "0.6.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.4.2",
58
+ "@arcadiasystems/morse-sdk": "^0.5.0",
59
59
  "@mysten/seal": "1.1.3",
60
60
  "@mysten/sui": "2.16.2",
61
61
  "@mysten/walrus": "1.1.6",