@arcadiasystems/morse-cli 0.1.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 +58 -0
- package/LICENSE +21 -0
- package/README.md +294 -0
- package/dist/index.js +1962 -0
- package/docs/QUICKSTART.md +205 -0
- package/package.json +64 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1962 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli/errors.ts
|
|
4
|
+
import {
|
|
5
|
+
ConfigurationError,
|
|
6
|
+
ContractAbortError,
|
|
7
|
+
formatUserMessage,
|
|
8
|
+
NotFoundError,
|
|
9
|
+
SealError,
|
|
10
|
+
TransportError,
|
|
11
|
+
UnauthorizedError,
|
|
12
|
+
UncertifiedBlobError,
|
|
13
|
+
ValidationError
|
|
14
|
+
} from "@arcadiasystems/morse-sdk";
|
|
15
|
+
import { CommanderError } from "commander";
|
|
16
|
+
|
|
17
|
+
// src/format/json.ts
|
|
18
|
+
function replacer(_key, value) {
|
|
19
|
+
if (typeof value === "bigint") {
|
|
20
|
+
return value.toString();
|
|
21
|
+
}
|
|
22
|
+
if (value instanceof Uint8Array) {
|
|
23
|
+
return `0x${Array.from(value, (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
|
|
24
|
+
}
|
|
25
|
+
return value;
|
|
26
|
+
}
|
|
27
|
+
function toJson(value) {
|
|
28
|
+
return JSON.stringify(value, replacer, 2);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// src/cli/exit-codes.ts
|
|
32
|
+
var ExitCode = {
|
|
33
|
+
Success: 0,
|
|
34
|
+
Generic: 1,
|
|
35
|
+
Usage: 2,
|
|
36
|
+
NotFound: 3,
|
|
37
|
+
Auth: 4,
|
|
38
|
+
Network: 5
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
// src/cli/errors.ts
|
|
42
|
+
class CliError extends Error {
|
|
43
|
+
exitCode;
|
|
44
|
+
constructor(message, exitCode = ExitCode.Generic, options) {
|
|
45
|
+
super(message, options);
|
|
46
|
+
this.name = "CliError";
|
|
47
|
+
this.exitCode = exitCode;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
class UsageError extends CliError {
|
|
52
|
+
constructor(message) {
|
|
53
|
+
super(message, ExitCode.Usage);
|
|
54
|
+
this.name = "UsageError";
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
function cancelled() {
|
|
58
|
+
throw new UsageError("Cancelled. Nothing was changed.");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
class KeystoreError extends CliError {
|
|
62
|
+
constructor(message, options) {
|
|
63
|
+
super(message, ExitCode.Auth, options);
|
|
64
|
+
this.name = "KeystoreError";
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
var AUTH_ABORT_REASONS = new Set([
|
|
68
|
+
"EUnauthorized",
|
|
69
|
+
"EPublisherCapRevoked",
|
|
70
|
+
"EPublisherCapWrongHolder"
|
|
71
|
+
]);
|
|
72
|
+
var COMMANDER_SUCCESS_CODES = new Set([
|
|
73
|
+
"commander.helpDisplayed",
|
|
74
|
+
"commander.help",
|
|
75
|
+
"commander.version"
|
|
76
|
+
]);
|
|
77
|
+
function resolveExitCode(err) {
|
|
78
|
+
if (err instanceof CliError) {
|
|
79
|
+
return err.exitCode;
|
|
80
|
+
}
|
|
81
|
+
if (err instanceof NotFoundError) {
|
|
82
|
+
return ExitCode.NotFound;
|
|
83
|
+
}
|
|
84
|
+
if (err instanceof UnauthorizedError) {
|
|
85
|
+
return ExitCode.Auth;
|
|
86
|
+
}
|
|
87
|
+
if (err instanceof ContractAbortError) {
|
|
88
|
+
return AUTH_ABORT_REASONS.has(err.reason) ? ExitCode.Auth : ExitCode.Generic;
|
|
89
|
+
}
|
|
90
|
+
if (err instanceof TransportError) {
|
|
91
|
+
return ExitCode.Network;
|
|
92
|
+
}
|
|
93
|
+
if (err instanceof SealError) {
|
|
94
|
+
return err.code === "no-access" ? ExitCode.Auth : ExitCode.Generic;
|
|
95
|
+
}
|
|
96
|
+
if (err instanceof ConfigurationError) {
|
|
97
|
+
return ExitCode.Usage;
|
|
98
|
+
}
|
|
99
|
+
if (err instanceof ValidationError) {
|
|
100
|
+
return ExitCode.Usage;
|
|
101
|
+
}
|
|
102
|
+
if (err instanceof UncertifiedBlobError) {
|
|
103
|
+
return ExitCode.Generic;
|
|
104
|
+
}
|
|
105
|
+
return ExitCode.Generic;
|
|
106
|
+
}
|
|
107
|
+
function describe(err) {
|
|
108
|
+
if (err instanceof CliError) {
|
|
109
|
+
return { title: "Error", description: err.message };
|
|
110
|
+
}
|
|
111
|
+
const formatted = formatUserMessage(err);
|
|
112
|
+
return { title: formatted.title, description: formatted.description };
|
|
113
|
+
}
|
|
114
|
+
function renderError(err, opts) {
|
|
115
|
+
const { title, description } = describe(err);
|
|
116
|
+
const exitCode = resolveExitCode(err);
|
|
117
|
+
if (opts.json) {
|
|
118
|
+
process.stderr.write(`${toJson({ error: { title, description, exitCode } })}
|
|
119
|
+
`);
|
|
120
|
+
} else {
|
|
121
|
+
process.stderr.write(`Error: ${description}
|
|
122
|
+
`);
|
|
123
|
+
}
|
|
124
|
+
if (opts.debug) {
|
|
125
|
+
writeTrace(err);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
function writeTrace(err) {
|
|
129
|
+
let current = err;
|
|
130
|
+
let prefix = "";
|
|
131
|
+
while (current instanceof Error && current.stack) {
|
|
132
|
+
process.stderr.write(`${prefix}${current.stack}
|
|
133
|
+
`);
|
|
134
|
+
prefix = "Caused by: ";
|
|
135
|
+
current = current.cause;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
function handleError(err, opts) {
|
|
139
|
+
if (err instanceof CommanderError) {
|
|
140
|
+
return COMMANDER_SUCCESS_CODES.has(err.code) ? ExitCode.Success : ExitCode.Usage;
|
|
141
|
+
}
|
|
142
|
+
renderError(err, opts);
|
|
143
|
+
return resolveExitCode(err);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// src/cli/program.ts
|
|
147
|
+
import { Command } from "commander";
|
|
148
|
+
// package.json
|
|
149
|
+
var package_default = {
|
|
150
|
+
name: "@arcadiasystems/morse-cli",
|
|
151
|
+
version: "0.1.0",
|
|
152
|
+
description: "Command-line interface for the Morse decentralized CMS on Sui.",
|
|
153
|
+
license: "MIT",
|
|
154
|
+
type: "module",
|
|
155
|
+
publishConfig: {
|
|
156
|
+
access: "public"
|
|
157
|
+
},
|
|
158
|
+
keywords: [
|
|
159
|
+
"sui",
|
|
160
|
+
"walrus",
|
|
161
|
+
"seal",
|
|
162
|
+
"cms",
|
|
163
|
+
"cli",
|
|
164
|
+
"decentralized",
|
|
165
|
+
"blockchain"
|
|
166
|
+
],
|
|
167
|
+
repository: {
|
|
168
|
+
type: "git",
|
|
169
|
+
url: "git+https://github.com/arcadiasystems/morse-dcms.git",
|
|
170
|
+
directory: "morse-cli"
|
|
171
|
+
},
|
|
172
|
+
homepage: "https://github.com/arcadiasystems/morse-dcms/tree/main/morse-cli#readme",
|
|
173
|
+
bugs: {
|
|
174
|
+
url: "https://github.com/arcadiasystems/morse-dcms/issues"
|
|
175
|
+
},
|
|
176
|
+
bin: {
|
|
177
|
+
morse: "./dist/index.js"
|
|
178
|
+
},
|
|
179
|
+
files: [
|
|
180
|
+
"dist",
|
|
181
|
+
"docs",
|
|
182
|
+
"README.md",
|
|
183
|
+
"LICENSE",
|
|
184
|
+
"CHANGELOG.md"
|
|
185
|
+
],
|
|
186
|
+
engines: {
|
|
187
|
+
node: ">=18",
|
|
188
|
+
bun: ">=1.2.0"
|
|
189
|
+
},
|
|
190
|
+
scripts: {
|
|
191
|
+
start: "bun src/index.ts",
|
|
192
|
+
build: "bun build src/index.ts --target node --packages external --outfile dist/index.js",
|
|
193
|
+
typecheck: "tsc --noEmit",
|
|
194
|
+
lint: "biome check .",
|
|
195
|
+
"lint:fix": "biome check --write .",
|
|
196
|
+
test: "bun test",
|
|
197
|
+
"test:coverage": "bun test --coverage",
|
|
198
|
+
prepublishOnly: "tsc --noEmit && biome check . && bun test && bun run build"
|
|
199
|
+
},
|
|
200
|
+
dependencies: {
|
|
201
|
+
"@arcadiasystems/morse-sdk": "^0.1.4",
|
|
202
|
+
"@mysten/seal": "1.1.3",
|
|
203
|
+
"@mysten/sui": "2.16.2",
|
|
204
|
+
"@mysten/walrus": "1.1.6",
|
|
205
|
+
commander: "^14.0.3"
|
|
206
|
+
},
|
|
207
|
+
devDependencies: {
|
|
208
|
+
"@biomejs/biome": "2.4.7",
|
|
209
|
+
"@types/bun": "latest",
|
|
210
|
+
typescript: "^5.6.0"
|
|
211
|
+
}
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
// src/cli/program.ts
|
|
215
|
+
function buildProgram() {
|
|
216
|
+
const program = new Command;
|
|
217
|
+
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();
|
|
218
|
+
return program;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// src/cli/prompts.ts
|
|
222
|
+
import * as readline from "node:readline";
|
|
223
|
+
function isInteractive() {
|
|
224
|
+
return Boolean(process.stdin.isTTY && process.stderr.isTTY);
|
|
225
|
+
}
|
|
226
|
+
function sigintSignal() {
|
|
227
|
+
const controller = new AbortController;
|
|
228
|
+
process.once("SIGINT", () => controller.abort());
|
|
229
|
+
return controller.signal;
|
|
230
|
+
}
|
|
231
|
+
function promptHidden(label, signal) {
|
|
232
|
+
return new Promise((resolve, reject) => {
|
|
233
|
+
const rl = readline.createInterface({
|
|
234
|
+
input: process.stdin,
|
|
235
|
+
output: process.stderr,
|
|
236
|
+
terminal: true
|
|
237
|
+
});
|
|
238
|
+
const onAbort = () => {
|
|
239
|
+
rl.close();
|
|
240
|
+
reject(new UsageError("Prompt aborted."));
|
|
241
|
+
};
|
|
242
|
+
bindAbort(signal, onAbort);
|
|
243
|
+
let muted = false;
|
|
244
|
+
const mutable = rl;
|
|
245
|
+
mutable._writeToOutput = (text) => {
|
|
246
|
+
if (!muted) {
|
|
247
|
+
process.stderr.write(text);
|
|
248
|
+
}
|
|
249
|
+
};
|
|
250
|
+
rl.question(label, (answer) => {
|
|
251
|
+
signal?.removeEventListener("abort", onAbort);
|
|
252
|
+
rl.close();
|
|
253
|
+
process.stderr.write(`
|
|
254
|
+
`);
|
|
255
|
+
resolve(answer);
|
|
256
|
+
});
|
|
257
|
+
muted = true;
|
|
258
|
+
rl.on("error", reject);
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
async function confirm(message, options = {}) {
|
|
262
|
+
if (options.assumeYes) {
|
|
263
|
+
return true;
|
|
264
|
+
}
|
|
265
|
+
if (!isInteractive()) {
|
|
266
|
+
throw new UsageError(`${message} Refusing to prompt in a non-interactive context; pass --yes to proceed.`);
|
|
267
|
+
}
|
|
268
|
+
const answer = await question(`${message} [y/N] `, options.signal);
|
|
269
|
+
return /^y(es)?$/i.test(answer.trim());
|
|
270
|
+
}
|
|
271
|
+
function question(prompt, signal) {
|
|
272
|
+
return new Promise((resolve, reject) => {
|
|
273
|
+
const rl = readline.createInterface({
|
|
274
|
+
input: process.stdin,
|
|
275
|
+
output: process.stderr
|
|
276
|
+
});
|
|
277
|
+
const onAbort = () => {
|
|
278
|
+
rl.close();
|
|
279
|
+
reject(new UsageError("Prompt aborted."));
|
|
280
|
+
};
|
|
281
|
+
bindAbort(signal, onAbort);
|
|
282
|
+
rl.question(prompt, (answer) => {
|
|
283
|
+
signal?.removeEventListener("abort", onAbort);
|
|
284
|
+
rl.close();
|
|
285
|
+
resolve(answer);
|
|
286
|
+
});
|
|
287
|
+
rl.on("error", reject);
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
function bindAbort(signal, onAbort) {
|
|
291
|
+
if (signal === undefined) {
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
if (signal.aborted) {
|
|
295
|
+
onAbort();
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// src/cli/output.ts
|
|
302
|
+
var RESET = "\x1B[0m";
|
|
303
|
+
var YELLOW = "33";
|
|
304
|
+
var DIM = "2";
|
|
305
|
+
|
|
306
|
+
class Output {
|
|
307
|
+
options;
|
|
308
|
+
constructor(options) {
|
|
309
|
+
this.options = options;
|
|
310
|
+
}
|
|
311
|
+
get isJson() {
|
|
312
|
+
return this.options.json;
|
|
313
|
+
}
|
|
314
|
+
result(human, data) {
|
|
315
|
+
if (this.options.json) {
|
|
316
|
+
process.stdout.write(`${toJson(data)}
|
|
317
|
+
`);
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
process.stdout.write(`${human}
|
|
321
|
+
`);
|
|
322
|
+
}
|
|
323
|
+
info(message) {
|
|
324
|
+
if (this.options.quiet || this.options.json) {
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
process.stderr.write(`${this.paint(message, DIM)}
|
|
328
|
+
`);
|
|
329
|
+
}
|
|
330
|
+
warn(message) {
|
|
331
|
+
if (this.options.quiet || this.options.json) {
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
process.stderr.write(`${this.paint(message, YELLOW)}
|
|
335
|
+
`);
|
|
336
|
+
}
|
|
337
|
+
paint(text, code) {
|
|
338
|
+
return this.options.color ? `\x1B[${code}m${text}${RESET}` : text;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
function resolveColor(json) {
|
|
342
|
+
if (json) {
|
|
343
|
+
return false;
|
|
344
|
+
}
|
|
345
|
+
if (process.env.NO_COLOR) {
|
|
346
|
+
return false;
|
|
347
|
+
}
|
|
348
|
+
if (process.env.FORCE_COLOR) {
|
|
349
|
+
return true;
|
|
350
|
+
}
|
|
351
|
+
return Boolean(process.stderr.isTTY);
|
|
352
|
+
}
|
|
353
|
+
function createOutput(opts) {
|
|
354
|
+
const json = Boolean(opts.json);
|
|
355
|
+
return new Output({
|
|
356
|
+
json,
|
|
357
|
+
quiet: Boolean(opts.quiet),
|
|
358
|
+
color: resolveColor(json)
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// src/cli/runtime.ts
|
|
363
|
+
function globalOptions(command) {
|
|
364
|
+
return command.optsWithGlobals();
|
|
365
|
+
}
|
|
366
|
+
function outputFor(command) {
|
|
367
|
+
return createOutput(globalOptions(command));
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// src/config/schema.ts
|
|
371
|
+
import { Network } from "@arcadiasystems/morse-sdk";
|
|
372
|
+
var CONFIG_VERSION = 1;
|
|
373
|
+
function emptyConfig() {
|
|
374
|
+
return { version: CONFIG_VERSION, defaultProfile: "default", profiles: {} };
|
|
375
|
+
}
|
|
376
|
+
var NETWORKS = new Set(Object.values(Network));
|
|
377
|
+
function isNetwork(value) {
|
|
378
|
+
return typeof value === "string" && NETWORKS.has(value);
|
|
379
|
+
}
|
|
380
|
+
function coerceNetwork(value) {
|
|
381
|
+
if (value === Network.Mainnet) {
|
|
382
|
+
throw new UsageError("Morse is not yet deployed on mainnet. Use testnet or localnet.");
|
|
383
|
+
}
|
|
384
|
+
if (isNetwork(value)) {
|
|
385
|
+
return value;
|
|
386
|
+
}
|
|
387
|
+
throw new UsageError(`Unknown network "${value}". Use one of: testnet, localnet.`);
|
|
388
|
+
}
|
|
389
|
+
function parseConfig(raw, source) {
|
|
390
|
+
if (!isRecord(raw)) {
|
|
391
|
+
throw malformed(source, "expected a JSON object");
|
|
392
|
+
}
|
|
393
|
+
const defaultProfile = raw.defaultProfile;
|
|
394
|
+
if (typeof defaultProfile !== "string") {
|
|
395
|
+
throw malformed(source, "defaultProfile must be a string");
|
|
396
|
+
}
|
|
397
|
+
const profilesRaw = raw.profiles;
|
|
398
|
+
if (!isRecord(profilesRaw)) {
|
|
399
|
+
throw malformed(source, "profiles must be an object");
|
|
400
|
+
}
|
|
401
|
+
const profiles = {};
|
|
402
|
+
for (const [name, value] of Object.entries(profilesRaw)) {
|
|
403
|
+
profiles[name] = parseProfile(name, value, source);
|
|
404
|
+
}
|
|
405
|
+
const version = typeof raw.version === "number" ? raw.version : CONFIG_VERSION;
|
|
406
|
+
return { version, defaultProfile, profiles };
|
|
407
|
+
}
|
|
408
|
+
function parseProfile(name, value, source) {
|
|
409
|
+
if (!isRecord(value)) {
|
|
410
|
+
throw malformed(source, `profile "${name}" must be an object`);
|
|
411
|
+
}
|
|
412
|
+
if (!isNetwork(value.network)) {
|
|
413
|
+
throw malformed(source, `profile "${name}" has an invalid network`);
|
|
414
|
+
}
|
|
415
|
+
const profile = {
|
|
416
|
+
network: value.network
|
|
417
|
+
};
|
|
418
|
+
if (typeof value.rpc === "string") {
|
|
419
|
+
profile.rpc = value.rpc;
|
|
420
|
+
}
|
|
421
|
+
if (typeof value.account === "string") {
|
|
422
|
+
profile.account = value.account;
|
|
423
|
+
}
|
|
424
|
+
if (typeof value.publication === "string") {
|
|
425
|
+
profile.publication = value.publication;
|
|
426
|
+
}
|
|
427
|
+
if (typeof value.collection === "string") {
|
|
428
|
+
profile.collection = value.collection;
|
|
429
|
+
}
|
|
430
|
+
return profile;
|
|
431
|
+
}
|
|
432
|
+
function isRecord(value) {
|
|
433
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
434
|
+
}
|
|
435
|
+
function malformed(source, detail) {
|
|
436
|
+
return new CliError(`Config file at ${source} is malformed: ${detail}.`, ExitCode.Generic);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// src/config/store.ts
|
|
440
|
+
import { chmod, mkdir, rename } from "node:fs/promises";
|
|
441
|
+
|
|
442
|
+
// src/cli/io.ts
|
|
443
|
+
import { access, readFile, writeFile } from "node:fs/promises";
|
|
444
|
+
async function fileExists(path) {
|
|
445
|
+
try {
|
|
446
|
+
await access(path);
|
|
447
|
+
return true;
|
|
448
|
+
} catch {
|
|
449
|
+
return false;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
async function readBytes(path) {
|
|
453
|
+
return new Uint8Array(await readFile(path));
|
|
454
|
+
}
|
|
455
|
+
async function readJson(path) {
|
|
456
|
+
return JSON.parse(await readFile(path, "utf8"));
|
|
457
|
+
}
|
|
458
|
+
async function writeFileContents(path, data) {
|
|
459
|
+
await writeFile(path, data);
|
|
460
|
+
}
|
|
461
|
+
async function readStdin() {
|
|
462
|
+
const chunks = [];
|
|
463
|
+
for await (const chunk of process.stdin) {
|
|
464
|
+
chunks.push(chunk);
|
|
465
|
+
}
|
|
466
|
+
return new Uint8Array(Buffer.concat(chunks));
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// src/config/paths.ts
|
|
470
|
+
import { homedir } from "node:os";
|
|
471
|
+
import { join } from "node:path";
|
|
472
|
+
function configDir() {
|
|
473
|
+
const xdg = process.env.XDG_CONFIG_HOME;
|
|
474
|
+
const base = xdg && xdg.length > 0 ? xdg : join(homedir(), ".config");
|
|
475
|
+
return join(base, "morse");
|
|
476
|
+
}
|
|
477
|
+
function configFilePath() {
|
|
478
|
+
return join(configDir(), "config.json");
|
|
479
|
+
}
|
|
480
|
+
function keystoreDir() {
|
|
481
|
+
return join(configDir(), "keystores");
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// src/config/store.ts
|
|
485
|
+
async function loadConfig() {
|
|
486
|
+
const path = configFilePath();
|
|
487
|
+
if (!await fileExists(path)) {
|
|
488
|
+
return emptyConfig();
|
|
489
|
+
}
|
|
490
|
+
let raw;
|
|
491
|
+
try {
|
|
492
|
+
raw = await readJson(path);
|
|
493
|
+
} catch (cause) {
|
|
494
|
+
throw new CliError(`Config file at ${path} is not valid JSON.`, ExitCode.Generic, { cause });
|
|
495
|
+
}
|
|
496
|
+
return parseConfig(raw, path);
|
|
497
|
+
}
|
|
498
|
+
async function saveConfig(config) {
|
|
499
|
+
const dir = configDir();
|
|
500
|
+
await mkdir(dir, { recursive: true, mode: 448 });
|
|
501
|
+
const path = configFilePath();
|
|
502
|
+
const tmp = `${path}.tmp-${process.pid}-${crypto.randomUUID()}`;
|
|
503
|
+
await writeFileContents(tmp, `${JSON.stringify(config, null, 2)}
|
|
504
|
+
`);
|
|
505
|
+
await chmod(tmp, 384);
|
|
506
|
+
await rename(tmp, path);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// src/config/active.ts
|
|
510
|
+
async function updateActiveProfile(opts, patch, env = process.env) {
|
|
511
|
+
const config = await loadConfig();
|
|
512
|
+
const profileName = opts.profile ?? env.MORSE_PROFILE ?? config.defaultProfile;
|
|
513
|
+
const existing = config.profiles[profileName];
|
|
514
|
+
const network = coerceNetwork(opts.network ?? env.MORSE_NETWORK ?? existing?.network ?? "testnet");
|
|
515
|
+
const merged = { ...existing, network, ...patch };
|
|
516
|
+
const profiles = { ...config.profiles, [profileName]: merged };
|
|
517
|
+
const defaultProfile = Object.keys(config.profiles).length === 0 ? profileName : config.defaultProfile;
|
|
518
|
+
await saveConfig({ ...config, profiles, defaultProfile });
|
|
519
|
+
return profileName;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// src/config/profile.ts
|
|
523
|
+
function resolveSettings(opts, config, env = process.env) {
|
|
524
|
+
const explicitProfile = opts.profile ?? env.MORSE_PROFILE;
|
|
525
|
+
const profileName = explicitProfile ?? config.defaultProfile;
|
|
526
|
+
const profile = config.profiles[profileName];
|
|
527
|
+
if (explicitProfile !== undefined && profile === undefined) {
|
|
528
|
+
throw new UsageError(`No profile named "${profileName}". Create it with: morse config add ${profileName} --network testnet`);
|
|
529
|
+
}
|
|
530
|
+
const networkValue = opts.network ?? env.MORSE_NETWORK ?? profile?.network ?? "testnet";
|
|
531
|
+
const network = coerceNetwork(networkValue);
|
|
532
|
+
const rpcUrl = opts.rpc ?? env.MORSE_RPC_URL ?? profile?.rpc;
|
|
533
|
+
const account = env.MORSE_ADDRESS ?? profile?.account;
|
|
534
|
+
const publication = env.MORSE_PUBLICATION ?? profile?.publication;
|
|
535
|
+
const collection = env.MORSE_COLLECTION ?? profile?.collection;
|
|
536
|
+
return { profileName, network, rpcUrl, account, publication, collection };
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// src/keystore/keystore.ts
|
|
540
|
+
import { chmod as chmod2, mkdir as mkdir2, readdir, rename as rename2, stat } from "node:fs/promises";
|
|
541
|
+
import { join as join2 } from "node:path";
|
|
542
|
+
import { toSuiAddress } from "@arcadiasystems/morse-sdk";
|
|
543
|
+
import { Ed25519Keypair } from "@mysten/sui/keypairs/ed25519";
|
|
544
|
+
|
|
545
|
+
// src/keystore/crypto.ts
|
|
546
|
+
import {
|
|
547
|
+
createCipheriv,
|
|
548
|
+
createDecipheriv,
|
|
549
|
+
randomBytes,
|
|
550
|
+
scryptSync
|
|
551
|
+
} from "node:crypto";
|
|
552
|
+
var DEFAULT_SCRYPT = { N: 1 << 17, r: 8, p: 1 };
|
|
553
|
+
var KEY_LENGTH = 32;
|
|
554
|
+
var SALT_LENGTH = 16;
|
|
555
|
+
var IV_LENGTH = 12;
|
|
556
|
+
function deriveKey(password, salt, params) {
|
|
557
|
+
const maxmem = 256 * params.N * params.r;
|
|
558
|
+
return scryptSync(password, salt, KEY_LENGTH, {
|
|
559
|
+
N: params.N,
|
|
560
|
+
r: params.r,
|
|
561
|
+
p: params.p,
|
|
562
|
+
maxmem
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
async function encryptSecret(plaintext, password, params = DEFAULT_SCRYPT) {
|
|
566
|
+
const salt = randomBytes(SALT_LENGTH);
|
|
567
|
+
const iv = randomBytes(IV_LENGTH);
|
|
568
|
+
const key = deriveKey(password, salt, params);
|
|
569
|
+
const cipher = createCipheriv("aes-256-gcm", key, iv);
|
|
570
|
+
const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
|
571
|
+
return {
|
|
572
|
+
kdfparams: params,
|
|
573
|
+
salt: salt.toString("base64"),
|
|
574
|
+
iv: iv.toString("base64"),
|
|
575
|
+
ciphertext: ciphertext.toString("base64"),
|
|
576
|
+
authTag: cipher.getAuthTag().toString("base64")
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
async function decryptSecret(payload, password) {
|
|
580
|
+
const salt = Buffer.from(payload.salt, "base64");
|
|
581
|
+
const iv = Buffer.from(payload.iv, "base64");
|
|
582
|
+
const authTag = Buffer.from(payload.authTag, "base64");
|
|
583
|
+
const ciphertext = Buffer.from(payload.ciphertext, "base64");
|
|
584
|
+
const key = deriveKey(password, salt, payload.kdfparams);
|
|
585
|
+
const decipher = createDecipheriv("aes-256-gcm", key, iv);
|
|
586
|
+
decipher.setAuthTag(authTag);
|
|
587
|
+
try {
|
|
588
|
+
const plaintext = Buffer.concat([
|
|
589
|
+
decipher.update(ciphertext),
|
|
590
|
+
decipher.final()
|
|
591
|
+
]);
|
|
592
|
+
return new Uint8Array(plaintext);
|
|
593
|
+
} catch (cause) {
|
|
594
|
+
throw new KeystoreError("Incorrect password or corrupted keystore.", {
|
|
595
|
+
cause
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// src/keystore/keystore.ts
|
|
601
|
+
var KEYSTORE_VERSION = 1;
|
|
602
|
+
function keystorePath(address) {
|
|
603
|
+
return join2(keystoreDir(), `${toSuiAddress(address)}.json`);
|
|
604
|
+
}
|
|
605
|
+
function keypairFromSecret(secret) {
|
|
606
|
+
try {
|
|
607
|
+
return Ed25519Keypair.fromSecretKey(secret);
|
|
608
|
+
} catch {
|
|
609
|
+
throw new UsageError("Invalid private key. Expected a Bech32 `suiprivkey1...` secret key.");
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
async function importKey(secret, password) {
|
|
613
|
+
const keypair = keypairFromSecret(secret);
|
|
614
|
+
const address = toSuiAddress(keypair.toSuiAddress());
|
|
615
|
+
const payload = await encryptSecret(new TextEncoder().encode(secret), password);
|
|
616
|
+
const file = {
|
|
617
|
+
version: KEYSTORE_VERSION,
|
|
618
|
+
address,
|
|
619
|
+
kdf: "scrypt",
|
|
620
|
+
cipher: "aes-256-gcm",
|
|
621
|
+
...payload
|
|
622
|
+
};
|
|
623
|
+
await writeKeystore(address, file);
|
|
624
|
+
return address;
|
|
625
|
+
}
|
|
626
|
+
async function unlockSecret(address, password) {
|
|
627
|
+
const file = await loadKeystore(address);
|
|
628
|
+
const bytes = await decryptSecret(file, password);
|
|
629
|
+
return new TextDecoder().decode(bytes);
|
|
630
|
+
}
|
|
631
|
+
async function listAddresses() {
|
|
632
|
+
let entries;
|
|
633
|
+
try {
|
|
634
|
+
entries = await readdir(keystoreDir());
|
|
635
|
+
} catch {
|
|
636
|
+
return [];
|
|
637
|
+
}
|
|
638
|
+
return entries.filter((name) => name.endsWith(".json")).map((name) => name.slice(0, -".json".length)).sort();
|
|
639
|
+
}
|
|
640
|
+
async function hasKeystore(address) {
|
|
641
|
+
return fileExists(keystorePath(address));
|
|
642
|
+
}
|
|
643
|
+
async function loadKeystore(address) {
|
|
644
|
+
const path = keystorePath(address);
|
|
645
|
+
if (!await fileExists(path)) {
|
|
646
|
+
throw new CliError(`No keystore for ${address}. Import it with: morse account import`, ExitCode.NotFound);
|
|
647
|
+
}
|
|
648
|
+
await assertSecurePermissions(path);
|
|
649
|
+
let raw;
|
|
650
|
+
try {
|
|
651
|
+
raw = await readJson(path);
|
|
652
|
+
} catch (cause) {
|
|
653
|
+
throw new KeystoreError(`Keystore at ${path} is not valid JSON.`, {
|
|
654
|
+
cause
|
|
655
|
+
});
|
|
656
|
+
}
|
|
657
|
+
return parseKeystore(raw, path);
|
|
658
|
+
}
|
|
659
|
+
async function writeKeystore(address, file) {
|
|
660
|
+
const dir = keystoreDir();
|
|
661
|
+
await mkdir2(dir, { recursive: true, mode: 448 });
|
|
662
|
+
const path = keystorePath(address);
|
|
663
|
+
const tmp = `${path}.tmp-${process.pid}-${crypto.randomUUID()}`;
|
|
664
|
+
await writeFileContents(tmp, `${JSON.stringify(file, null, 2)}
|
|
665
|
+
`);
|
|
666
|
+
await chmod2(tmp, 384);
|
|
667
|
+
await rename2(tmp, path);
|
|
668
|
+
}
|
|
669
|
+
async function assertSecurePermissions(path) {
|
|
670
|
+
const mode = (await stat(path)).mode & 511;
|
|
671
|
+
if ((mode & 63) !== 0) {
|
|
672
|
+
throw new KeystoreError(`Keystore ${path} is group/world-accessible (mode ${mode.toString(8)}). Run: chmod 600 ${path}`);
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
function parseKeystore(raw, path) {
|
|
676
|
+
if (typeof raw !== "object" || raw === null) {
|
|
677
|
+
throw new KeystoreError(`Keystore at ${path} is malformed.`);
|
|
678
|
+
}
|
|
679
|
+
const value = raw;
|
|
680
|
+
const required = ["address", "salt", "iv", "ciphertext", "authTag"];
|
|
681
|
+
for (const field of required) {
|
|
682
|
+
if (typeof value[field] !== "string") {
|
|
683
|
+
throw new KeystoreError(`Keystore at ${path} is missing or has an invalid "${field}".`);
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
if (value.version !== KEYSTORE_VERSION) {
|
|
687
|
+
throw new KeystoreError(`Keystore at ${path} has an unsupported version (expected ${KEYSTORE_VERSION}).`);
|
|
688
|
+
}
|
|
689
|
+
if (value.kdf !== "scrypt" || value.cipher !== "aes-256-gcm") {
|
|
690
|
+
throw new KeystoreError(`Keystore at ${path} uses an unsupported kdf or cipher.`);
|
|
691
|
+
}
|
|
692
|
+
const kdfparams = value.kdfparams;
|
|
693
|
+
if (typeof kdfparams !== "object" || kdfparams === null) {
|
|
694
|
+
throw new KeystoreError(`Keystore at ${path} is missing kdfparams.`);
|
|
695
|
+
}
|
|
696
|
+
assertScryptParams(kdfparams, path);
|
|
697
|
+
return raw;
|
|
698
|
+
}
|
|
699
|
+
var MAX_SCRYPT_N = 1 << 20;
|
|
700
|
+
var MAX_SCRYPT_RP = 64;
|
|
701
|
+
function assertScryptParams(params, path) {
|
|
702
|
+
const { N, r, p } = params;
|
|
703
|
+
const isPowerOfTwo = (n) => (n & n - 1) === 0;
|
|
704
|
+
if (typeof N !== "number" || !Number.isInteger(N) || N < 1 || N > MAX_SCRYPT_N || !isPowerOfTwo(N)) {
|
|
705
|
+
throw new KeystoreError(`Keystore at ${path} has an out-of-range kdfparams.N.`);
|
|
706
|
+
}
|
|
707
|
+
for (const [name, value] of [
|
|
708
|
+
["r", r],
|
|
709
|
+
["p", p]
|
|
710
|
+
]) {
|
|
711
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value < 1 || value > MAX_SCRYPT_RP) {
|
|
712
|
+
throw new KeystoreError(`Keystore at ${path} has an out-of-range kdfparams.${name}.`);
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
// src/keystore/source.ts
|
|
718
|
+
import { toSuiAddress as toSuiAddress2 } from "@arcadiasystems/morse-sdk";
|
|
719
|
+
|
|
720
|
+
// src/keystore/unlock.ts
|
|
721
|
+
var MIN_PASSWORD_LENGTH = 8;
|
|
722
|
+
async function resolvePassword(purpose, env = process.env, signal) {
|
|
723
|
+
const fromEnv = env.MORSE_KEYSTORE_PASSWORD;
|
|
724
|
+
if (fromEnv !== undefined && fromEnv.length > 0) {
|
|
725
|
+
return fromEnv;
|
|
726
|
+
}
|
|
727
|
+
if (!isInteractive()) {
|
|
728
|
+
throw new KeystoreError("No keystore password available. Set MORSE_KEYSTORE_PASSWORD or run in an interactive terminal.");
|
|
729
|
+
}
|
|
730
|
+
if (purpose === "unlock") {
|
|
731
|
+
return promptHidden("Keystore password (hidden): ", signal);
|
|
732
|
+
}
|
|
733
|
+
const first = await promptHidden(`New keystore password (hidden, min ${MIN_PASSWORD_LENGTH} chars): `, signal);
|
|
734
|
+
if (first.length < MIN_PASSWORD_LENGTH) {
|
|
735
|
+
throw new UsageError(`Password must be at least ${MIN_PASSWORD_LENGTH} characters.`);
|
|
736
|
+
}
|
|
737
|
+
const second = await promptHidden("Confirm password (hidden): ", signal);
|
|
738
|
+
if (first !== second) {
|
|
739
|
+
throw new UsageError("Passwords do not match.");
|
|
740
|
+
}
|
|
741
|
+
return first;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
// src/keystore/source.ts
|
|
745
|
+
async function resolveSigner(account, env = process.env, signal) {
|
|
746
|
+
const raw = env.MORSE_PRIVATE_KEY;
|
|
747
|
+
if (raw !== undefined && raw.length > 0) {
|
|
748
|
+
return toSigner(keypairFromSecret(raw));
|
|
749
|
+
}
|
|
750
|
+
if (account === undefined) {
|
|
751
|
+
throw new UsageError("No account selected. Import one with `morse account import`, select it with `morse account use <address>`, or set MORSE_PRIVATE_KEY.");
|
|
752
|
+
}
|
|
753
|
+
const password = await resolvePassword("unlock", env, signal);
|
|
754
|
+
const secret = await unlockSecret(account, password);
|
|
755
|
+
return toSigner(keypairFromSecret(secret));
|
|
756
|
+
}
|
|
757
|
+
function toSigner(keypair) {
|
|
758
|
+
return { keypair, address: toSuiAddress2(keypair.toSuiAddress()) };
|
|
759
|
+
}
|
|
760
|
+
function accountAddress(account, env = process.env) {
|
|
761
|
+
const raw = env.MORSE_PRIVATE_KEY;
|
|
762
|
+
if (raw !== undefined && raw.length > 0) {
|
|
763
|
+
return toSuiAddress2(keypairFromSecret(raw).toSuiAddress());
|
|
764
|
+
}
|
|
765
|
+
return account === undefined ? undefined : toSuiAddress2(account);
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
// src/commands/account.ts
|
|
769
|
+
function registerAccountCommands(program) {
|
|
770
|
+
const account = program.command("account").description("Import and manage encrypted signing keys");
|
|
771
|
+
account.command("import").description("Import a private key into an encrypted keystore").action(async (_options, command) => {
|
|
772
|
+
const opts = globalOptions(command);
|
|
773
|
+
const output = outputFor(command);
|
|
774
|
+
const signal = sigintSignal();
|
|
775
|
+
const secret = await readSecretToImport(process.env, signal);
|
|
776
|
+
const password = await resolvePassword("create", process.env, signal);
|
|
777
|
+
const address = await importKey(secret, password);
|
|
778
|
+
const profileName = await associateAccount(opts, address);
|
|
779
|
+
output.info(`Imported ${address} into keystore.`);
|
|
780
|
+
output.result(`Imported account ${address} (profile "${profileName}").`, {
|
|
781
|
+
address,
|
|
782
|
+
profile: profileName
|
|
783
|
+
});
|
|
784
|
+
});
|
|
785
|
+
account.command("list").description("List imported accounts").action(async (_options, command) => {
|
|
786
|
+
const output = outputFor(command);
|
|
787
|
+
const addresses = await listAddresses();
|
|
788
|
+
const active = await resolveActiveAccount(globalOptions(command));
|
|
789
|
+
if (addresses.length === 0) {
|
|
790
|
+
output.result("No accounts. Import one with: morse account import", {
|
|
791
|
+
active,
|
|
792
|
+
accounts: []
|
|
793
|
+
});
|
|
794
|
+
return;
|
|
795
|
+
}
|
|
796
|
+
const human = addresses.map((address) => `${address === active ? "*" : " "} ${address}`).join(`
|
|
797
|
+
`);
|
|
798
|
+
output.result(human, { active, accounts: addresses });
|
|
799
|
+
});
|
|
800
|
+
account.command("show").description("Print the active account address").action(async (_options, command) => {
|
|
801
|
+
const output = outputFor(command);
|
|
802
|
+
const active = await resolveActiveAccount(globalOptions(command));
|
|
803
|
+
if (active === undefined) {
|
|
804
|
+
throw new UsageError("No active account. Import one with `morse account import` or set MORSE_ADDRESS.");
|
|
805
|
+
}
|
|
806
|
+
output.result(active, { address: active });
|
|
807
|
+
});
|
|
808
|
+
account.command("use <address>").description("Set the active account for the current profile").action(async (address, _options, command) => {
|
|
809
|
+
const opts = globalOptions(command);
|
|
810
|
+
const output = outputFor(command);
|
|
811
|
+
if (!await hasKeystore(address)) {
|
|
812
|
+
throw new UsageError(`No keystore for ${address}. Import it first with: morse account import`);
|
|
813
|
+
}
|
|
814
|
+
const profileName = await associateAccount(opts, address);
|
|
815
|
+
output.result(`Active account for "${profileName}" set to ${address}.`, {
|
|
816
|
+
address,
|
|
817
|
+
profile: profileName
|
|
818
|
+
});
|
|
819
|
+
});
|
|
820
|
+
account.command("export <address>").description("Print a decrypted secret key (dangerous)").action(async (address, _options, command) => {
|
|
821
|
+
const output = outputFor(command);
|
|
822
|
+
if (output.isJson) {
|
|
823
|
+
throw new UsageError("account export is not available in --json mode.");
|
|
824
|
+
}
|
|
825
|
+
if (!isInteractive()) {
|
|
826
|
+
throw new UsageError("account export requires an interactive terminal.");
|
|
827
|
+
}
|
|
828
|
+
if (globalOptions(command).yes) {
|
|
829
|
+
throw new UsageError("account export does not accept --yes. Confirm interactively.");
|
|
830
|
+
}
|
|
831
|
+
const signal = sigintSignal();
|
|
832
|
+
const proceed = await confirm(`Reveal the secret key for ${address}? Anyone who sees it controls the account.`, { signal });
|
|
833
|
+
if (!proceed) {
|
|
834
|
+
cancelled();
|
|
835
|
+
}
|
|
836
|
+
const password = await resolvePassword("unlock", process.env, signal);
|
|
837
|
+
const secret = await unlockSecret(address, password);
|
|
838
|
+
process.stderr.write(`Warning: secret key follows. Handle it with care.
|
|
839
|
+
`);
|
|
840
|
+
process.stdout.write(`${secret}
|
|
841
|
+
`);
|
|
842
|
+
});
|
|
843
|
+
}
|
|
844
|
+
async function readSecretToImport(env, signal) {
|
|
845
|
+
const raw = env.MORSE_PRIVATE_KEY;
|
|
846
|
+
if (raw !== undefined && raw.length > 0) {
|
|
847
|
+
return raw;
|
|
848
|
+
}
|
|
849
|
+
if (!isInteractive()) {
|
|
850
|
+
throw new UsageError("Cannot read a private key: set MORSE_PRIVATE_KEY or run in an interactive terminal.");
|
|
851
|
+
}
|
|
852
|
+
process.stderr.write(`Paste your Sui private key (starts with suiprivkey1...), then press Enter. Input is hidden.
|
|
853
|
+
`);
|
|
854
|
+
const secret = await promptHidden("Private key (hidden): ", signal);
|
|
855
|
+
if (secret.length === 0) {
|
|
856
|
+
throw new UsageError("No private key entered.");
|
|
857
|
+
}
|
|
858
|
+
return secret;
|
|
859
|
+
}
|
|
860
|
+
async function associateAccount(opts, address) {
|
|
861
|
+
return updateActiveProfile(opts, { account: address });
|
|
862
|
+
}
|
|
863
|
+
async function resolveActiveAccount(opts) {
|
|
864
|
+
return accountAddress(resolveSettings(opts, await loadConfig()).account);
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
// src/commands/cap.ts
|
|
868
|
+
import {
|
|
869
|
+
destroyPublisherCap,
|
|
870
|
+
issuePublisherCap,
|
|
871
|
+
revokePublisherCap,
|
|
872
|
+
toPublisherCapId as toPublisherCapId2,
|
|
873
|
+
toSuiAddress as toSuiAddress3,
|
|
874
|
+
transferPublisherCap
|
|
875
|
+
} from "@arcadiasystems/morse-sdk";
|
|
876
|
+
|
|
877
|
+
// src/cli/context.ts
|
|
878
|
+
import {
|
|
879
|
+
DefaultSealAdapter,
|
|
880
|
+
DefaultWalrusReadAdapter,
|
|
881
|
+
DefaultWalrusWriteAdapter,
|
|
882
|
+
KeypairAdapter,
|
|
883
|
+
morseConfig,
|
|
884
|
+
RpcPublicationReader
|
|
885
|
+
} from "@arcadiasystems/morse-sdk";
|
|
886
|
+
import { SuiGrpcClient } from "@mysten/sui/grpc";
|
|
887
|
+
async function buildReadContext(command) {
|
|
888
|
+
const opts = globalOptions(command);
|
|
889
|
+
const output = outputFor(command);
|
|
890
|
+
const settings = resolveSettings(opts, await loadConfig());
|
|
891
|
+
const config = morseConfig({
|
|
892
|
+
network: settings.network,
|
|
893
|
+
...settings.rpcUrl === undefined ? {} : { rpcUrl: settings.rpcUrl }
|
|
894
|
+
});
|
|
895
|
+
const client = new SuiGrpcClient({
|
|
896
|
+
network: settings.network,
|
|
897
|
+
baseUrl: config.rpcUrl
|
|
898
|
+
});
|
|
899
|
+
const reader = RpcPublicationReader.fromMorseConfig(config, client);
|
|
900
|
+
return {
|
|
901
|
+
output,
|
|
902
|
+
settings,
|
|
903
|
+
config,
|
|
904
|
+
client,
|
|
905
|
+
reader,
|
|
906
|
+
ownerAddress: accountAddress(settings.account),
|
|
907
|
+
signal: sigintSignal()
|
|
908
|
+
};
|
|
909
|
+
}
|
|
910
|
+
async function buildSignedBase(command) {
|
|
911
|
+
const base = await buildReadContext(command);
|
|
912
|
+
const signer = await resolveSigner(base.settings.account, process.env, base.signal);
|
|
913
|
+
return { base, keypair: signer.keypair, address: signer.address };
|
|
914
|
+
}
|
|
915
|
+
async function buildWriteContext(command) {
|
|
916
|
+
const { base, keypair, address } = await buildSignedBase(command);
|
|
917
|
+
return {
|
|
918
|
+
...base,
|
|
919
|
+
adapter: new KeypairAdapter(keypair, base.client),
|
|
920
|
+
address
|
|
921
|
+
};
|
|
922
|
+
}
|
|
923
|
+
async function buildContentContext(command) {
|
|
924
|
+
const { base, keypair, address } = await buildSignedBase(command);
|
|
925
|
+
const network = base.settings.network;
|
|
926
|
+
if (network === "localnet") {
|
|
927
|
+
throw new CliError("Walrus content uploads are not available on localnet. Use testnet or mainnet.", ExitCode.Usage);
|
|
928
|
+
}
|
|
929
|
+
const adapter = new KeypairAdapter(keypair, base.client);
|
|
930
|
+
const walrus = DefaultWalrusWriteAdapter.fromConfig({ network, suiClient: base.client }, keypair);
|
|
931
|
+
return { ...base, adapter, address, walrus };
|
|
932
|
+
}
|
|
933
|
+
async function buildEncryptContext(command) {
|
|
934
|
+
const ctx = await buildContentContext(command);
|
|
935
|
+
const seal = DefaultSealAdapter.fromMorseConfig(ctx.config, {}, ctx.client);
|
|
936
|
+
return { ...ctx, seal };
|
|
937
|
+
}
|
|
938
|
+
async function buildReadContentContext(command) {
|
|
939
|
+
const base = await buildReadContext(command);
|
|
940
|
+
const network = base.settings.network;
|
|
941
|
+
if (network === "localnet") {
|
|
942
|
+
throw new CliError("Walrus reads are not available on localnet. Use testnet or mainnet.", ExitCode.Usage);
|
|
943
|
+
}
|
|
944
|
+
const walrusRead = DefaultWalrusReadAdapter.fromConfig({
|
|
945
|
+
network,
|
|
946
|
+
suiClient: base.client
|
|
947
|
+
});
|
|
948
|
+
return { ...base, walrusRead };
|
|
949
|
+
}
|
|
950
|
+
async function buildDecryptContext(command) {
|
|
951
|
+
const { base, keypair, address } = await buildSignedBase(command);
|
|
952
|
+
const network = base.settings.network;
|
|
953
|
+
if (network === "localnet") {
|
|
954
|
+
throw new CliError("Seal decryption is not available on localnet. Use testnet or mainnet.", ExitCode.Usage);
|
|
955
|
+
}
|
|
956
|
+
const seal = DefaultSealAdapter.fromMorseConfig(base.config, {}, base.client);
|
|
957
|
+
const walrusRead = DefaultWalrusReadAdapter.fromConfig({
|
|
958
|
+
network,
|
|
959
|
+
suiClient: base.client
|
|
960
|
+
});
|
|
961
|
+
return { ...base, keypair, address, seal, walrusRead };
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
// src/cli/target.ts
|
|
965
|
+
import { toPublicationId } from "@arcadiasystems/morse-sdk";
|
|
966
|
+
var OBJECT_ID = /^0x[0-9a-f]{1,64}$/i;
|
|
967
|
+
var SLUG_PAGE_LIMIT = 50;
|
|
968
|
+
async function resolvePublication(ctx, override) {
|
|
969
|
+
const value = override ?? ctx.settings.publication;
|
|
970
|
+
if (value === undefined) {
|
|
971
|
+
throw new UsageError("No publication selected. Pass --publication <slug|id> or run `morse use <slug|id>`.");
|
|
972
|
+
}
|
|
973
|
+
if (OBJECT_ID.test(value)) {
|
|
974
|
+
return toPublicationId(value.toLowerCase());
|
|
975
|
+
}
|
|
976
|
+
return resolveSlug(ctx, value);
|
|
977
|
+
}
|
|
978
|
+
function resolveCollection(ctx, override) {
|
|
979
|
+
const value = override ?? ctx.settings.collection;
|
|
980
|
+
if (value === undefined) {
|
|
981
|
+
throw new UsageError("No collection selected. Pass --collection <name> or run `morse use <slug|id> <collection>`.");
|
|
982
|
+
}
|
|
983
|
+
return value;
|
|
984
|
+
}
|
|
985
|
+
async function resolveSlug(ctx, slug) {
|
|
986
|
+
if (ctx.ownerAddress === undefined) {
|
|
987
|
+
throw new UsageError(`Cannot resolve the slug "${slug}" without an active account. Pass the publication id, or select an account.`);
|
|
988
|
+
}
|
|
989
|
+
ctx.output.info(`Resolving slug "${slug}" among owned publications...`);
|
|
990
|
+
let cursor;
|
|
991
|
+
do {
|
|
992
|
+
const page = await ctx.reader.listPublicationsOwnedBy(ctx.ownerAddress, {
|
|
993
|
+
limit: SLUG_PAGE_LIMIT,
|
|
994
|
+
signal: ctx.signal,
|
|
995
|
+
...cursor === undefined ? {} : { cursor }
|
|
996
|
+
});
|
|
997
|
+
for (const owned of page.results) {
|
|
998
|
+
const publication = await ctx.reader.getPublication(owned.publicationId, ctx.signal);
|
|
999
|
+
if (publication.slug === slug) {
|
|
1000
|
+
return publication.id;
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
cursor = page.nextCursor ?? undefined;
|
|
1004
|
+
} while (cursor !== undefined);
|
|
1005
|
+
throw new UsageError(`No publication with slug "${slug}" owned by the active account. Pass the publication id instead.`);
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
// src/format/ids.ts
|
|
1009
|
+
var HEAD = 6;
|
|
1010
|
+
var TAIL = 4;
|
|
1011
|
+
function shortId(id) {
|
|
1012
|
+
if (id.length <= HEAD + TAIL + 3) {
|
|
1013
|
+
return id;
|
|
1014
|
+
}
|
|
1015
|
+
return `${id.slice(0, HEAD)}...${id.slice(-TAIL)}`;
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
// src/format/render.ts
|
|
1019
|
+
function renderEnrichedPublicationList(items) {
|
|
1020
|
+
if (items.length === 0) {
|
|
1021
|
+
return "No publications owned by this address.";
|
|
1022
|
+
}
|
|
1023
|
+
return items.map((p) => `${p.slug} ${p.publicationId} ${p.name}`).join(`
|
|
1024
|
+
`);
|
|
1025
|
+
}
|
|
1026
|
+
function renderPublisherCapList(caps) {
|
|
1027
|
+
if (caps.length === 0) {
|
|
1028
|
+
return "No publisher caps held by this address.";
|
|
1029
|
+
}
|
|
1030
|
+
return caps.map((cap) => `${cap.id} publication ${shortId(cap.publicationId)} holder ${shortId(cap.holder)}`).join(`
|
|
1031
|
+
`);
|
|
1032
|
+
}
|
|
1033
|
+
function renderCollectionList(collections) {
|
|
1034
|
+
if (collections.length === 0) {
|
|
1035
|
+
return "No collections.";
|
|
1036
|
+
}
|
|
1037
|
+
return collections.map((c) => `${c.name} ${c.storageMode} (next entry id ${c.nextEntryId})`).join(`
|
|
1038
|
+
`);
|
|
1039
|
+
}
|
|
1040
|
+
function renderPublication(publication) {
|
|
1041
|
+
const collections = publication.collections.length === 0 ? "(none)" : publication.collections.map((c) => `${c.name} (${c.storageMode})`).join(", ");
|
|
1042
|
+
return [
|
|
1043
|
+
`${publication.name} (${shortId(publication.id)})`,
|
|
1044
|
+
`slug: ${publication.slug}`,
|
|
1045
|
+
`collections: ${collections}`
|
|
1046
|
+
].join(`
|
|
1047
|
+
`);
|
|
1048
|
+
}
|
|
1049
|
+
function renderPublicationList(items) {
|
|
1050
|
+
if (items.length === 0) {
|
|
1051
|
+
return "No publications owned by this address.";
|
|
1052
|
+
}
|
|
1053
|
+
return items.map((item) => `${item.publicationId} (owner cap ${shortId(item.ownerCapId)})`).join(`
|
|
1054
|
+
`);
|
|
1055
|
+
}
|
|
1056
|
+
function renderEntry(entry) {
|
|
1057
|
+
const lines = [
|
|
1058
|
+
`#${entry.id} ${entry.name}`,
|
|
1059
|
+
`publicHead: ${headLabel(entry.publicHead)} draftHead: ${headLabel(entry.draftHead)}`,
|
|
1060
|
+
`revisions: ${entry.revisions.length}`
|
|
1061
|
+
];
|
|
1062
|
+
for (const revision of entry.revisions) {
|
|
1063
|
+
lines.push(` ${renderRevisionLine(revision)}`);
|
|
1064
|
+
}
|
|
1065
|
+
return lines.join(`
|
|
1066
|
+
`);
|
|
1067
|
+
}
|
|
1068
|
+
function renderEntryList(entries) {
|
|
1069
|
+
if (entries.length === 0) {
|
|
1070
|
+
return "No entries in this collection.";
|
|
1071
|
+
}
|
|
1072
|
+
return entries.map((entry) => `#${entry.id} ${entry.name} (${entry.revisions.length} revisions)`).join(`
|
|
1073
|
+
`);
|
|
1074
|
+
}
|
|
1075
|
+
function headLabel(value) {
|
|
1076
|
+
return value === null ? "none" : String(value);
|
|
1077
|
+
}
|
|
1078
|
+
function renderRevisionLine(revision) {
|
|
1079
|
+
const ref = revision.blobRef.kind === "blob" ? `blob ${shortId(revision.blobRef.blobObjectId)}` : "quilt patch";
|
|
1080
|
+
const encrypted = revision.encrypted ? " encrypted" : "";
|
|
1081
|
+
return `[${revision.id}] ${revision.contentType} (${ref}) by ${shortId(revision.author)}${encrypted}`;
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
// src/commands/options.ts
|
|
1085
|
+
function publicationOption(command) {
|
|
1086
|
+
return command.option("-P, --publication <slug|id>", "Publication slug or id (default: the active publication)");
|
|
1087
|
+
}
|
|
1088
|
+
function collectionOption(command) {
|
|
1089
|
+
return command.option("-C, --collection <name>", "Collection name (default: the active collection)");
|
|
1090
|
+
}
|
|
1091
|
+
function publisherCapOption(command) {
|
|
1092
|
+
return command.option("--publisher-cap <id>", "PublisherCap ID (auto-resolved if omitted)");
|
|
1093
|
+
}
|
|
1094
|
+
function ownerCapOption(command) {
|
|
1095
|
+
return command.option("--owner-cap <id>", "OwnerCap ID (auto-resolved if omitted)");
|
|
1096
|
+
}
|
|
1097
|
+
function contentOptions(command) {
|
|
1098
|
+
return command.option("-f, --file <path>", "File to upload (or - for stdin)").option("--stdin", "Read content from stdin").option("--content-type <type>", "MIME content type (inferred from --file if omitted)").option("--epochs <n>", "Walrus storage epochs", "3");
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
// src/commands/resolve.ts
|
|
1102
|
+
import {
|
|
1103
|
+
toOwnerCapId,
|
|
1104
|
+
toPublisherCapId
|
|
1105
|
+
} from "@arcadiasystems/morse-sdk";
|
|
1106
|
+
var PAGE_LIMIT = 50;
|
|
1107
|
+
async function resolveOwnerCap(reader, address, publicationId, override, signal) {
|
|
1108
|
+
if (override !== undefined) {
|
|
1109
|
+
return toOwnerCapId(override);
|
|
1110
|
+
}
|
|
1111
|
+
let cursor;
|
|
1112
|
+
do {
|
|
1113
|
+
const page = await reader.listPublicationsOwnedBy(address, {
|
|
1114
|
+
limit: PAGE_LIMIT,
|
|
1115
|
+
signal,
|
|
1116
|
+
...cursor === undefined ? {} : { cursor }
|
|
1117
|
+
});
|
|
1118
|
+
const match = page.results.find((o) => o.publicationId === publicationId);
|
|
1119
|
+
if (match !== undefined) {
|
|
1120
|
+
return match.ownerCapId;
|
|
1121
|
+
}
|
|
1122
|
+
cursor = page.nextCursor ?? undefined;
|
|
1123
|
+
} while (cursor !== undefined);
|
|
1124
|
+
throw new CliError(`No OwnerCap for ${publicationId} held by ${address}. Pass --owner-cap, or check that the active account owns it.`, ExitCode.NotFound);
|
|
1125
|
+
}
|
|
1126
|
+
async function resolvePublisherCap(reader, address, publicationId, override, signal) {
|
|
1127
|
+
if (override !== undefined) {
|
|
1128
|
+
return toPublisherCapId(override);
|
|
1129
|
+
}
|
|
1130
|
+
let cursor;
|
|
1131
|
+
do {
|
|
1132
|
+
const page = await reader.listPublisherCapsOwnedBy(address, {
|
|
1133
|
+
limit: PAGE_LIMIT,
|
|
1134
|
+
signal,
|
|
1135
|
+
...cursor === undefined ? {} : { cursor }
|
|
1136
|
+
});
|
|
1137
|
+
const match = page.results.find((c) => c.publicationId === publicationId);
|
|
1138
|
+
if (match !== undefined) {
|
|
1139
|
+
return match.id;
|
|
1140
|
+
}
|
|
1141
|
+
cursor = page.nextCursor ?? undefined;
|
|
1142
|
+
} while (cursor !== undefined);
|
|
1143
|
+
throw new CliError(`No PublisherCap for ${publicationId} held by ${address}. Pass --publisher-cap, or check that the active account holds one.`, ExitCode.NotFound);
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
// src/commands/shared.ts
|
|
1147
|
+
import { StorageMode } from "@arcadiasystems/morse-sdk";
|
|
1148
|
+
var STORAGE_MODES = new Set(Object.values(StorageMode));
|
|
1149
|
+
function coerceStorageMode(value) {
|
|
1150
|
+
if (STORAGE_MODES.has(value)) {
|
|
1151
|
+
return value;
|
|
1152
|
+
}
|
|
1153
|
+
throw new UsageError(`--mode must be one of: ${Object.values(StorageMode).join(", ")}, got "${value}".`);
|
|
1154
|
+
}
|
|
1155
|
+
function parsePositiveInt(value, name) {
|
|
1156
|
+
const parsed = Number(value);
|
|
1157
|
+
if (!Number.isInteger(parsed) || parsed <= 0) {
|
|
1158
|
+
throw new UsageError(`${name} must be a positive integer, got "${value}".`);
|
|
1159
|
+
}
|
|
1160
|
+
return parsed;
|
|
1161
|
+
}
|
|
1162
|
+
function parseLimit(value) {
|
|
1163
|
+
return parsePositiveInt(value, "--limit");
|
|
1164
|
+
}
|
|
1165
|
+
function parseId(value, name) {
|
|
1166
|
+
const id = Number(value);
|
|
1167
|
+
if (!Number.isInteger(id) || id < 0) {
|
|
1168
|
+
throw new UsageError(`${name} must be a non-negative integer, got "${value}".`);
|
|
1169
|
+
}
|
|
1170
|
+
return id;
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
// src/commands/cap.ts
|
|
1174
|
+
function registerCapCommands(program) {
|
|
1175
|
+
const cap = program.command("cap").description("Manage PublisherCaps (write-access capabilities)");
|
|
1176
|
+
cap.command("list [address]").description("List publisher caps held by an address (default: the active account)").option("--limit <n>", "Maximum results per page").option("--cursor <cursor>", "Continue from a previous page cursor").action(async (address, options, command) => {
|
|
1177
|
+
const ctx = await buildReadContext(command);
|
|
1178
|
+
const holder = address === undefined ? ctx.ownerAddress : toSuiAddress3(address);
|
|
1179
|
+
if (holder === undefined) {
|
|
1180
|
+
throw new UsageError("No address given and no active account. Pass an address or import an account.");
|
|
1181
|
+
}
|
|
1182
|
+
const page = await ctx.reader.listPublisherCapsOwnedBy(holder, {
|
|
1183
|
+
signal: ctx.signal,
|
|
1184
|
+
...options.limit === undefined ? {} : { limit: parseLimit(options.limit) },
|
|
1185
|
+
...options.cursor === undefined ? {} : { cursor: options.cursor }
|
|
1186
|
+
});
|
|
1187
|
+
if (page.nextCursor !== null) {
|
|
1188
|
+
ctx.output.info(`More results: pass --cursor "${page.nextCursor}"`);
|
|
1189
|
+
}
|
|
1190
|
+
ctx.output.result(renderPublisherCapList(page.results), {
|
|
1191
|
+
results: page.results,
|
|
1192
|
+
nextCursor: page.nextCursor
|
|
1193
|
+
});
|
|
1194
|
+
});
|
|
1195
|
+
const issue = cap.command("issue <holder>").description("Issue a PublisherCap bound to an address");
|
|
1196
|
+
publicationOption(ownerCapOption(issue)).action(async (holder, options, command) => {
|
|
1197
|
+
const ctx = await buildWriteContext(command);
|
|
1198
|
+
const id = await resolvePublication(ctx, options.publication);
|
|
1199
|
+
const holderAddress = toSuiAddress3(holder);
|
|
1200
|
+
ctx.output.info("Resolving OwnerCap...");
|
|
1201
|
+
const ownerCapId = await resolveOwnerCap(ctx.reader, ctx.address, id, options.ownerCap, ctx.signal);
|
|
1202
|
+
const result = await issuePublisherCap(ctx.adapter, ctx.config, {
|
|
1203
|
+
publicationId: id,
|
|
1204
|
+
ownerCapId,
|
|
1205
|
+
holder: holderAddress,
|
|
1206
|
+
signal: ctx.signal
|
|
1207
|
+
});
|
|
1208
|
+
ctx.output.result(`Issued PublisherCap ${result.publisherCapId} to ${holderAddress}. (tx: ${result.digest})`, result);
|
|
1209
|
+
});
|
|
1210
|
+
const revoke = cap.command("revoke <publisherCapId>").description("Revoke a PublisherCap so it can no longer write");
|
|
1211
|
+
publicationOption(ownerCapOption(revoke)).action(async (publisherCapId, options, command) => {
|
|
1212
|
+
const ctx = await buildWriteContext(command);
|
|
1213
|
+
const id = await resolvePublication(ctx, options.publication);
|
|
1214
|
+
const capId = toPublisherCapId2(publisherCapId);
|
|
1215
|
+
const proceed = await confirm(`Revoke PublisherCap ${capId}? It can no longer be used to write.`, { assumeYes: Boolean(globalOptions(command).yes), signal: ctx.signal });
|
|
1216
|
+
if (!proceed) {
|
|
1217
|
+
cancelled();
|
|
1218
|
+
}
|
|
1219
|
+
ctx.output.info("Resolving OwnerCap...");
|
|
1220
|
+
const ownerCapId = await resolveOwnerCap(ctx.reader, ctx.address, id, options.ownerCap, ctx.signal);
|
|
1221
|
+
const result = await revokePublisherCap(ctx.adapter, ctx.config, {
|
|
1222
|
+
publicationId: id,
|
|
1223
|
+
ownerCapId,
|
|
1224
|
+
publisherCapId: capId,
|
|
1225
|
+
signal: ctx.signal
|
|
1226
|
+
});
|
|
1227
|
+
ctx.output.result(`Revoked PublisherCap ${capId}. (tx: ${result.digest})`, result);
|
|
1228
|
+
});
|
|
1229
|
+
const destroy = cap.command("destroy <publisherCapId>").description("Destroy a PublisherCap held by the active account");
|
|
1230
|
+
publicationOption(destroy).action(async (publisherCapId, options, command) => {
|
|
1231
|
+
const ctx = await buildWriteContext(command);
|
|
1232
|
+
const id = await resolvePublication(ctx, options.publication);
|
|
1233
|
+
const capId = toPublisherCapId2(publisherCapId);
|
|
1234
|
+
const proceed = await confirm(`Destroy PublisherCap ${capId}? This is permanent.`, { assumeYes: Boolean(globalOptions(command).yes), signal: ctx.signal });
|
|
1235
|
+
if (!proceed) {
|
|
1236
|
+
cancelled();
|
|
1237
|
+
}
|
|
1238
|
+
const result = await destroyPublisherCap(ctx.adapter, ctx.config, {
|
|
1239
|
+
publicationId: id,
|
|
1240
|
+
publisherCapId: capId,
|
|
1241
|
+
signal: ctx.signal
|
|
1242
|
+
});
|
|
1243
|
+
ctx.output.result(`Destroyed PublisherCap ${capId}. (tx: ${result.digest})`, result);
|
|
1244
|
+
});
|
|
1245
|
+
cap.command("transfer <publisherCapId> <recipient>").description("Transfer a PublisherCap object to another address").action(async (publisherCapId, recipient, _options, command) => {
|
|
1246
|
+
const ctx = await buildWriteContext(command);
|
|
1247
|
+
const capId = toPublisherCapId2(publisherCapId);
|
|
1248
|
+
const to = toSuiAddress3(recipient);
|
|
1249
|
+
const proceed = await confirm(`Transfer PublisherCap ${capId} to ${to}?`, {
|
|
1250
|
+
assumeYes: Boolean(globalOptions(command).yes),
|
|
1251
|
+
signal: ctx.signal
|
|
1252
|
+
});
|
|
1253
|
+
if (!proceed) {
|
|
1254
|
+
cancelled();
|
|
1255
|
+
}
|
|
1256
|
+
const result = await transferPublisherCap(ctx.adapter, ctx.config, {
|
|
1257
|
+
publisherCapId: capId,
|
|
1258
|
+
recipient: to,
|
|
1259
|
+
signal: ctx.signal
|
|
1260
|
+
});
|
|
1261
|
+
ctx.output.result(`Transferred PublisherCap ${capId} to ${to}. (tx: ${result.digest})`, result);
|
|
1262
|
+
});
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
// src/commands/collection.ts
|
|
1266
|
+
import { createCollection, deleteCollection } from "@arcadiasystems/morse-sdk";
|
|
1267
|
+
function registerCollectionCommands(program) {
|
|
1268
|
+
const collection = program.command("collection").description("Manage collections within a publication");
|
|
1269
|
+
const list = collection.command("list").description("List the collections in a publication");
|
|
1270
|
+
publicationOption(list).action(async (options, command) => {
|
|
1271
|
+
const ctx = await buildReadContext(command);
|
|
1272
|
+
const id = await resolvePublication(ctx, options.publication);
|
|
1273
|
+
const publication = await ctx.reader.getPublication(id, ctx.signal);
|
|
1274
|
+
ctx.output.result(renderCollectionList(publication.collections), {
|
|
1275
|
+
publication: id,
|
|
1276
|
+
collections: publication.collections
|
|
1277
|
+
});
|
|
1278
|
+
});
|
|
1279
|
+
const create = collection.command("create <name>").description("Create a collection and select it as the active collection").option("--mode <mode>", "Storage mode: blob or quilt", "blob");
|
|
1280
|
+
publicationOption(publisherCapOption(create)).action(async (name, options, command) => {
|
|
1281
|
+
const ctx = await buildWriteContext(command);
|
|
1282
|
+
const id = await resolvePublication(ctx, options.publication);
|
|
1283
|
+
const storageMode = coerceStorageMode(options.mode);
|
|
1284
|
+
const publisherCapId = await resolvePublisherCap(ctx.reader, ctx.address, id, options.publisherCap, ctx.signal);
|
|
1285
|
+
ctx.output.info(`Creating collection "${name}" (${storageMode})...`);
|
|
1286
|
+
const result = await createCollection(ctx.adapter, ctx.config, {
|
|
1287
|
+
publicationId: id,
|
|
1288
|
+
publisherCapId,
|
|
1289
|
+
name,
|
|
1290
|
+
storageMode,
|
|
1291
|
+
signal: ctx.signal
|
|
1292
|
+
});
|
|
1293
|
+
await updateActiveProfile(globalOptions(command), { collection: name });
|
|
1294
|
+
ctx.output.result(`Created collection "${name}". Selected as the active collection. (tx: ${result.digest})`, result);
|
|
1295
|
+
});
|
|
1296
|
+
const remove = collection.command("delete <name>").description("Delete an empty collection");
|
|
1297
|
+
publicationOption(publisherCapOption(remove)).action(async (name, options, command) => {
|
|
1298
|
+
const ctx = await buildWriteContext(command);
|
|
1299
|
+
const id = await resolvePublication(ctx, options.publication);
|
|
1300
|
+
const proceed = await confirm(`Delete collection "${name}" from ${shortId(id)}? It must be empty.`, { assumeYes: Boolean(globalOptions(command).yes), signal: ctx.signal });
|
|
1301
|
+
if (!proceed) {
|
|
1302
|
+
cancelled();
|
|
1303
|
+
}
|
|
1304
|
+
const publisherCapId = await resolvePublisherCap(ctx.reader, ctx.address, id, options.publisherCap, ctx.signal);
|
|
1305
|
+
const result = await deleteCollection(ctx.adapter, ctx.config, {
|
|
1306
|
+
publicationId: id,
|
|
1307
|
+
publisherCapId,
|
|
1308
|
+
name,
|
|
1309
|
+
signal: ctx.signal
|
|
1310
|
+
});
|
|
1311
|
+
if (ctx.settings.collection === name) {
|
|
1312
|
+
await updateActiveProfile(globalOptions(command), {
|
|
1313
|
+
collection: undefined
|
|
1314
|
+
});
|
|
1315
|
+
}
|
|
1316
|
+
ctx.output.result(`Deleted collection "${name}". (tx: ${result.digest})`, result);
|
|
1317
|
+
});
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
// src/commands/config.ts
|
|
1321
|
+
function registerConfigCommands(program) {
|
|
1322
|
+
const config = program.command("config").description("Manage profiles and CLI configuration");
|
|
1323
|
+
config.command("path").description("Print the config file path").action((_options, command) => {
|
|
1324
|
+
const path = configFilePath();
|
|
1325
|
+
outputFor(command).result(path, { path });
|
|
1326
|
+
});
|
|
1327
|
+
config.command("list").description("List profiles and show the default").action(async (_options, command) => {
|
|
1328
|
+
const cfg = await loadConfig();
|
|
1329
|
+
outputFor(command).result(renderProfiles(cfg), {
|
|
1330
|
+
defaultProfile: cfg.defaultProfile,
|
|
1331
|
+
profiles: cfg.profiles
|
|
1332
|
+
});
|
|
1333
|
+
});
|
|
1334
|
+
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) => {
|
|
1335
|
+
const network = coerceNetwork(options.network);
|
|
1336
|
+
const cfg = await loadConfig();
|
|
1337
|
+
const profiles = {
|
|
1338
|
+
...cfg.profiles,
|
|
1339
|
+
[name]: {
|
|
1340
|
+
network,
|
|
1341
|
+
...options.rpc === undefined ? {} : { rpc: options.rpc }
|
|
1342
|
+
}
|
|
1343
|
+
};
|
|
1344
|
+
const defaultProfile = Object.keys(cfg.profiles).length === 0 ? name : cfg.defaultProfile;
|
|
1345
|
+
await saveConfig({ ...cfg, profiles, defaultProfile });
|
|
1346
|
+
outputFor(command).result(`Saved profile "${name}" (${network}).`, {
|
|
1347
|
+
profile: name,
|
|
1348
|
+
network,
|
|
1349
|
+
rpc: options.rpc,
|
|
1350
|
+
default: defaultProfile === name
|
|
1351
|
+
});
|
|
1352
|
+
});
|
|
1353
|
+
config.command("use <name>").description("Set the default profile").action(async (name, _options, command) => {
|
|
1354
|
+
const cfg = await loadConfig();
|
|
1355
|
+
requireProfile(cfg, name);
|
|
1356
|
+
await saveConfig({ ...cfg, defaultProfile: name });
|
|
1357
|
+
outputFor(command).result(`Default profile set to "${name}".`, {
|
|
1358
|
+
defaultProfile: name
|
|
1359
|
+
});
|
|
1360
|
+
});
|
|
1361
|
+
config.command("remove <name>").description("Delete a profile").action(async (name, _options, command) => {
|
|
1362
|
+
const cfg = await loadConfig();
|
|
1363
|
+
requireProfile(cfg, name);
|
|
1364
|
+
const { [name]: _removed, ...rest } = cfg.profiles;
|
|
1365
|
+
const defaultProfile = cfg.defaultProfile === name ? Object.keys(rest)[0] ?? "default" : cfg.defaultProfile;
|
|
1366
|
+
await saveConfig({ ...cfg, profiles: rest, defaultProfile });
|
|
1367
|
+
outputFor(command).result(`Removed profile "${name}".`, {
|
|
1368
|
+
removed: name,
|
|
1369
|
+
defaultProfile,
|
|
1370
|
+
profiles: Object.keys(rest)
|
|
1371
|
+
});
|
|
1372
|
+
});
|
|
1373
|
+
}
|
|
1374
|
+
function requireProfile(config, name) {
|
|
1375
|
+
if (!(name in config.profiles)) {
|
|
1376
|
+
throw new UsageError(`No profile named "${name}". Create it with: morse config add ${name} --network testnet`);
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
function renderProfiles(config) {
|
|
1380
|
+
const names = Object.keys(config.profiles);
|
|
1381
|
+
if (names.length === 0) {
|
|
1382
|
+
return "No profiles configured. Add one with: morse config add <name> --network testnet";
|
|
1383
|
+
}
|
|
1384
|
+
return names.map((name) => {
|
|
1385
|
+
const profile = config.profiles[name];
|
|
1386
|
+
if (profile === undefined) {
|
|
1387
|
+
return name;
|
|
1388
|
+
}
|
|
1389
|
+
const marker = name === config.defaultProfile ? "*" : " ";
|
|
1390
|
+
const parts = [profile.network];
|
|
1391
|
+
if (profile.rpc !== undefined) {
|
|
1392
|
+
parts.push(profile.rpc);
|
|
1393
|
+
}
|
|
1394
|
+
if (profile.account !== undefined) {
|
|
1395
|
+
parts.push(profile.account);
|
|
1396
|
+
}
|
|
1397
|
+
return `${marker} ${name} ${parts.join(" ")}`;
|
|
1398
|
+
}).join(`
|
|
1399
|
+
`);
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
// src/commands/entry.ts
|
|
1403
|
+
import {
|
|
1404
|
+
addEntryFromBytes,
|
|
1405
|
+
deleteEntry
|
|
1406
|
+
} from "@arcadiasystems/morse-sdk";
|
|
1407
|
+
|
|
1408
|
+
// src/cli/input.ts
|
|
1409
|
+
import { extname } from "node:path";
|
|
1410
|
+
var EXTENSION_TYPES = {
|
|
1411
|
+
".txt": "text/plain",
|
|
1412
|
+
".md": "text/markdown",
|
|
1413
|
+
".html": "text/html",
|
|
1414
|
+
".json": "application/json",
|
|
1415
|
+
".csv": "text/csv",
|
|
1416
|
+
".png": "image/png",
|
|
1417
|
+
".jpg": "image/jpeg",
|
|
1418
|
+
".jpeg": "image/jpeg",
|
|
1419
|
+
".gif": "image/gif",
|
|
1420
|
+
".webp": "image/webp",
|
|
1421
|
+
".svg": "image/svg+xml",
|
|
1422
|
+
".pdf": "application/pdf"
|
|
1423
|
+
};
|
|
1424
|
+
var DEFAULT_CONTENT_TYPE = "application/octet-stream";
|
|
1425
|
+
async function readContentBytes(options) {
|
|
1426
|
+
const bytes = await readRaw(options);
|
|
1427
|
+
if (bytes.length === 0) {
|
|
1428
|
+
throw new UsageError("Content is empty; nothing to upload.");
|
|
1429
|
+
}
|
|
1430
|
+
return bytes;
|
|
1431
|
+
}
|
|
1432
|
+
async function readRaw(options) {
|
|
1433
|
+
if (options.stdin || options.file === "-") {
|
|
1434
|
+
return readStdin();
|
|
1435
|
+
}
|
|
1436
|
+
if (options.file === undefined) {
|
|
1437
|
+
throw new UsageError("Provide content with --file <path> or --stdin.");
|
|
1438
|
+
}
|
|
1439
|
+
if (!await fileExists(options.file)) {
|
|
1440
|
+
throw new UsageError(`File not found: ${options.file}`);
|
|
1441
|
+
}
|
|
1442
|
+
return readBytes(options.file);
|
|
1443
|
+
}
|
|
1444
|
+
function resolveContentType(explicit, file) {
|
|
1445
|
+
if (explicit !== undefined) {
|
|
1446
|
+
return explicit;
|
|
1447
|
+
}
|
|
1448
|
+
if (file !== undefined && file !== "-") {
|
|
1449
|
+
const type = EXTENSION_TYPES[extname(file).toLowerCase()];
|
|
1450
|
+
if (type !== undefined) {
|
|
1451
|
+
return type;
|
|
1452
|
+
}
|
|
1453
|
+
}
|
|
1454
|
+
return DEFAULT_CONTENT_TYPE;
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
// src/commands/encrypted.ts
|
|
1458
|
+
import {
|
|
1459
|
+
addEncryptedEntryFromBytes,
|
|
1460
|
+
buildPublisherSealId
|
|
1461
|
+
} from "@arcadiasystems/morse-sdk";
|
|
1462
|
+
import { SessionKey } from "@mysten/seal";
|
|
1463
|
+
var SESSION_KEY_TTL_MIN = 10;
|
|
1464
|
+
var SEAL_NONCE_BYTES = 16;
|
|
1465
|
+
function registerEncryptedEntryCommands(entry) {
|
|
1466
|
+
const add = entry.command("add-encrypted <name>").description("Encrypt a file or stdin with Seal and add it as a new entry");
|
|
1467
|
+
collectionOption(publicationOption(publisherCapOption(contentOptions(add)))).action(async (name, options, command) => {
|
|
1468
|
+
const ctx = await buildEncryptContext(command);
|
|
1469
|
+
const id = await resolvePublication(ctx, options.publication);
|
|
1470
|
+
const collection = resolveCollection(ctx, options.collection);
|
|
1471
|
+
const epochs = parsePositiveInt(options.epochs, "--epochs");
|
|
1472
|
+
const plaintext = await readContentBytes(options);
|
|
1473
|
+
const contentType = resolveContentType(options.contentType, options.stdin ? undefined : options.file);
|
|
1474
|
+
const publisherCapId = await resolvePublisherCap(ctx.reader, ctx.address, id, options.publisherCap, ctx.signal);
|
|
1475
|
+
const sealId = buildPublisherSealId(id, crypto.getRandomValues(new Uint8Array(SEAL_NONCE_BYTES)));
|
|
1476
|
+
ctx.output.info(`Encrypting and uploading ${plaintext.length} bytes...`);
|
|
1477
|
+
const result = await addEncryptedEntryFromBytes(ctx.adapter, ctx.config, {
|
|
1478
|
+
walrus: ctx.walrus,
|
|
1479
|
+
seal: ctx.seal,
|
|
1480
|
+
publicationId: id,
|
|
1481
|
+
publisherCapId,
|
|
1482
|
+
collectionName: collection,
|
|
1483
|
+
name,
|
|
1484
|
+
plaintext,
|
|
1485
|
+
contentType,
|
|
1486
|
+
sealId,
|
|
1487
|
+
upload: { epochs, deletable: true },
|
|
1488
|
+
signal: ctx.signal
|
|
1489
|
+
});
|
|
1490
|
+
ctx.output.result(`Added encrypted entry #${result.entryId} "${name}". (tx: ${result.digest})`, { ...result, sealId });
|
|
1491
|
+
});
|
|
1492
|
+
const decrypt = entry.command("decrypt <entryId> [revisionIndex]").description("Decrypt an encrypted revision by zero-based index (default: latest); signs a SessionKey with the active account").option("--out <path>", "Write plaintext to a file instead of stdout");
|
|
1493
|
+
collectionOption(publicationOption(publisherCapOption(decrypt))).action(async (entryId, revisionId, options, command) => {
|
|
1494
|
+
const ctx = await buildDecryptContext(command);
|
|
1495
|
+
if (ctx.output.isJson && options.out === undefined) {
|
|
1496
|
+
throw new UsageError("Decrypting to stdout is not supported in --json mode; pass --out <path>.");
|
|
1497
|
+
}
|
|
1498
|
+
const id = await resolvePublication(ctx, options.publication);
|
|
1499
|
+
const collection = resolveCollection(ctx, options.collection);
|
|
1500
|
+
const numericEntryId = parseId(entryId, "entryId");
|
|
1501
|
+
const entryData = await ctx.reader.getEntry(id, collection, numericEntryId, ctx.signal);
|
|
1502
|
+
if (revisionId === undefined && entryData.revisions.length === 0) {
|
|
1503
|
+
throw new UsageError(`Entry #${numericEntryId} has no revisions.`);
|
|
1504
|
+
}
|
|
1505
|
+
const revisionIndex = revisionId === undefined ? entryData.revisions.length - 1 : parseId(revisionId, "revision");
|
|
1506
|
+
const revision = entryData.revisions[revisionIndex];
|
|
1507
|
+
if (revision === undefined) {
|
|
1508
|
+
throw new UsageError(`Entry #${numericEntryId} has no revision at index ${revisionIndex}.`);
|
|
1509
|
+
}
|
|
1510
|
+
if (!revision.encrypted || revision.sealId === null) {
|
|
1511
|
+
throw new UsageError(`Revision #${revisionIndex} of entry #${numericEntryId} is not encrypted.`);
|
|
1512
|
+
}
|
|
1513
|
+
const publisherCapId = await resolvePublisherCap(ctx.reader, ctx.address, id, options.publisherCap, ctx.signal);
|
|
1514
|
+
ctx.output.info("Fetching ciphertext from Walrus...");
|
|
1515
|
+
const ciphertext = await ctx.walrusRead.readBlobRef(revision.blobRef, {
|
|
1516
|
+
signal: ctx.signal
|
|
1517
|
+
});
|
|
1518
|
+
ctx.output.info("Signing a SessionKey with the active account...");
|
|
1519
|
+
const sessionKey = await SessionKey.create({
|
|
1520
|
+
address: ctx.address,
|
|
1521
|
+
packageId: ctx.config.originalPackageId ?? ctx.config.packageId,
|
|
1522
|
+
ttlMin: SESSION_KEY_TTL_MIN,
|
|
1523
|
+
signer: ctx.keypair,
|
|
1524
|
+
suiClient: ctx.client
|
|
1525
|
+
});
|
|
1526
|
+
const plaintext = await ctx.seal.decrypt(ciphertext, {
|
|
1527
|
+
sessionKey,
|
|
1528
|
+
sealId: revision.sealId,
|
|
1529
|
+
publisherCapId
|
|
1530
|
+
});
|
|
1531
|
+
if (options.out !== undefined) {
|
|
1532
|
+
await writeFileContents(options.out, plaintext);
|
|
1533
|
+
ctx.output.result(`Wrote ${plaintext.length} bytes to ${options.out}.`, {
|
|
1534
|
+
entryId: numericEntryId,
|
|
1535
|
+
revisionId: revisionIndex,
|
|
1536
|
+
bytes: plaintext.length,
|
|
1537
|
+
contentType: revision.contentType,
|
|
1538
|
+
out: options.out
|
|
1539
|
+
});
|
|
1540
|
+
return;
|
|
1541
|
+
}
|
|
1542
|
+
process.stdout.write(plaintext);
|
|
1543
|
+
});
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
// src/commands/entry.ts
|
|
1547
|
+
function registerEntryCommands(program) {
|
|
1548
|
+
const entry = program.command("entry").description("Read, add, and delete entries in a collection");
|
|
1549
|
+
const get = entry.command("get <entryId>").description("Fetch a single entry");
|
|
1550
|
+
collectionOption(publicationOption(get)).action(async (entryId, options, command) => {
|
|
1551
|
+
const ctx = await buildReadContext(command);
|
|
1552
|
+
const id = await resolvePublication(ctx, options.publication);
|
|
1553
|
+
const collection = resolveCollection(ctx, options.collection);
|
|
1554
|
+
const result = await ctx.reader.getEntry(id, collection, parseId(entryId, "entryId"), ctx.signal);
|
|
1555
|
+
ctx.output.result(renderEntry(result), result);
|
|
1556
|
+
});
|
|
1557
|
+
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");
|
|
1558
|
+
collectionOption(publicationOption(list)).action(async (options, command) => {
|
|
1559
|
+
const ctx = await buildReadContext(command);
|
|
1560
|
+
const id = await resolvePublication(ctx, options.publication);
|
|
1561
|
+
const collection = resolveCollection(ctx, options.collection);
|
|
1562
|
+
const page = await ctx.reader.listEntries(id, collection, {
|
|
1563
|
+
signal: ctx.signal,
|
|
1564
|
+
...options.limit === undefined ? {} : { limit: parseLimit(options.limit) },
|
|
1565
|
+
...options.cursor === undefined ? {} : { cursor: options.cursor }
|
|
1566
|
+
});
|
|
1567
|
+
if (page.nextCursor !== null) {
|
|
1568
|
+
ctx.output.info(`More results: pass --cursor "${page.nextCursor}"`);
|
|
1569
|
+
}
|
|
1570
|
+
ctx.output.result(renderEntryList(page.results), {
|
|
1571
|
+
results: page.results,
|
|
1572
|
+
nextCursor: page.nextCursor
|
|
1573
|
+
});
|
|
1574
|
+
});
|
|
1575
|
+
const scan = entry.command("scan").description("List every entry in a collection (auto-paginated)");
|
|
1576
|
+
collectionOption(publicationOption(scan)).action(async (options, command) => {
|
|
1577
|
+
const ctx = await buildReadContext(command);
|
|
1578
|
+
const id = await resolvePublication(ctx, options.publication);
|
|
1579
|
+
const collection = resolveCollection(ctx, options.collection);
|
|
1580
|
+
const entries = [];
|
|
1581
|
+
for await (const item of ctx.reader.scanEntries(id, collection, {
|
|
1582
|
+
signal: ctx.signal
|
|
1583
|
+
})) {
|
|
1584
|
+
entries.push(item);
|
|
1585
|
+
}
|
|
1586
|
+
ctx.output.result(renderEntryList(entries), { results: entries });
|
|
1587
|
+
});
|
|
1588
|
+
const add = entry.command("add <name>").description("Upload content from a file or stdin and add it as a new entry");
|
|
1589
|
+
collectionOption(publicationOption(publisherCapOption(contentOptions(add)))).action(async (name, options, command) => {
|
|
1590
|
+
const ctx = await buildContentContext(command);
|
|
1591
|
+
const id = await resolvePublication(ctx, options.publication);
|
|
1592
|
+
const collection = resolveCollection(ctx, options.collection);
|
|
1593
|
+
const epochs = parsePositiveInt(options.epochs, "--epochs");
|
|
1594
|
+
const bytes = await readContentBytes(options);
|
|
1595
|
+
const contentType = resolveContentType(options.contentType, options.stdin ? undefined : options.file);
|
|
1596
|
+
const publisherCapId = await resolvePublisherCap(ctx.reader, ctx.address, id, options.publisherCap, ctx.signal);
|
|
1597
|
+
ctx.output.info(`Uploading ${bytes.length} bytes to Walrus...`);
|
|
1598
|
+
const result = await addEntryFromBytes(ctx.adapter, ctx.config, {
|
|
1599
|
+
walrus: ctx.walrus,
|
|
1600
|
+
publicationId: id,
|
|
1601
|
+
publisherCapId,
|
|
1602
|
+
collectionName: collection,
|
|
1603
|
+
name,
|
|
1604
|
+
bytes,
|
|
1605
|
+
contentType,
|
|
1606
|
+
upload: { epochs, deletable: true },
|
|
1607
|
+
signal: ctx.signal
|
|
1608
|
+
});
|
|
1609
|
+
const aggregator = ctx.config.walrusEndpoints.aggregator;
|
|
1610
|
+
const viewUrl = aggregator.length > 0 ? `${aggregator}/v1/blobs/${result.blobId}` : undefined;
|
|
1611
|
+
const human = viewUrl === undefined ? `Added entry #${result.entryId} "${name}". (tx: ${result.digest})` : `Added entry #${result.entryId} "${name}". (tx: ${result.digest})
|
|
1612
|
+
view: ${viewUrl}`;
|
|
1613
|
+
ctx.output.result(human, { ...result, viewUrl: viewUrl ?? null });
|
|
1614
|
+
});
|
|
1615
|
+
const remove = entry.command("delete <entryId>").description("Delete an entry and its revisions");
|
|
1616
|
+
collectionOption(publicationOption(publisherCapOption(remove))).action(async (entryId, options, command) => {
|
|
1617
|
+
const ctx = await buildWriteContext(command);
|
|
1618
|
+
const id = await resolvePublication(ctx, options.publication);
|
|
1619
|
+
const collection = resolveCollection(ctx, options.collection);
|
|
1620
|
+
const numericEntryId = parseId(entryId, "entryId");
|
|
1621
|
+
const proceed = await confirm(`Delete entry #${numericEntryId} from ${shortId(id)}/${collection}? This cannot be undone.`, { assumeYes: Boolean(globalOptions(command).yes), signal: ctx.signal });
|
|
1622
|
+
if (!proceed) {
|
|
1623
|
+
cancelled();
|
|
1624
|
+
}
|
|
1625
|
+
const publisherCapId = await resolvePublisherCap(ctx.reader, ctx.address, id, options.publisherCap, ctx.signal);
|
|
1626
|
+
const result = await deleteEntry(ctx.adapter, ctx.config, {
|
|
1627
|
+
publicationId: id,
|
|
1628
|
+
publisherCapId,
|
|
1629
|
+
collectionName: collection,
|
|
1630
|
+
entryId: numericEntryId,
|
|
1631
|
+
signal: ctx.signal
|
|
1632
|
+
});
|
|
1633
|
+
ctx.output.result(`Deleted entry #${numericEntryId}. (tx: ${result.digest})`, result);
|
|
1634
|
+
});
|
|
1635
|
+
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");
|
|
1636
|
+
collectionOption(publicationOption(read)).action(async (entryId, revisionId, options, command) => {
|
|
1637
|
+
const ctx = await buildReadContentContext(command);
|
|
1638
|
+
if (ctx.output.isJson && options.out === undefined) {
|
|
1639
|
+
throw new UsageError("Reading content to stdout is not supported in --json mode; pass --out <path>.");
|
|
1640
|
+
}
|
|
1641
|
+
const id = await resolvePublication(ctx, options.publication);
|
|
1642
|
+
const collection = resolveCollection(ctx, options.collection);
|
|
1643
|
+
const numericEntryId = parseId(entryId, "entryId");
|
|
1644
|
+
const entryData = await ctx.reader.getEntry(id, collection, numericEntryId, ctx.signal);
|
|
1645
|
+
if (revisionId === undefined && entryData.revisions.length === 0) {
|
|
1646
|
+
throw new UsageError(`Entry #${numericEntryId} has no revisions.`);
|
|
1647
|
+
}
|
|
1648
|
+
const revisionIndex = revisionId === undefined ? entryData.revisions.length - 1 : parseId(revisionId, "revision");
|
|
1649
|
+
const revision = entryData.revisions[revisionIndex];
|
|
1650
|
+
if (revision === undefined) {
|
|
1651
|
+
throw new UsageError(`Entry #${numericEntryId} has no revision at index ${revisionIndex}.`);
|
|
1652
|
+
}
|
|
1653
|
+
if (revision.encrypted) {
|
|
1654
|
+
throw new UsageError(`Revision #${revisionIndex} of entry #${numericEntryId} is encrypted; use \`morse entry decrypt\`.`);
|
|
1655
|
+
}
|
|
1656
|
+
ctx.output.info("Fetching content from Walrus...");
|
|
1657
|
+
const bytes = await ctx.walrusRead.readBlobRef(revision.blobRef, {
|
|
1658
|
+
signal: ctx.signal
|
|
1659
|
+
});
|
|
1660
|
+
if (options.out !== undefined) {
|
|
1661
|
+
await writeFileContents(options.out, bytes);
|
|
1662
|
+
ctx.output.result(`Wrote ${bytes.length} bytes to ${options.out}.`, {
|
|
1663
|
+
entryId: numericEntryId,
|
|
1664
|
+
revisionId: revisionIndex,
|
|
1665
|
+
bytes: bytes.length,
|
|
1666
|
+
contentType: revision.contentType,
|
|
1667
|
+
out: options.out
|
|
1668
|
+
});
|
|
1669
|
+
return;
|
|
1670
|
+
}
|
|
1671
|
+
process.stdout.write(bytes);
|
|
1672
|
+
});
|
|
1673
|
+
registerEncryptedEntryCommands(entry);
|
|
1674
|
+
}
|
|
1675
|
+
|
|
1676
|
+
// src/commands/publication.ts
|
|
1677
|
+
import {
|
|
1678
|
+
createPublication,
|
|
1679
|
+
deletePublication,
|
|
1680
|
+
toSuiAddress as toSuiAddress4,
|
|
1681
|
+
transferOwnership
|
|
1682
|
+
} from "@arcadiasystems/morse-sdk";
|
|
1683
|
+
function registerPublicationCommands(program) {
|
|
1684
|
+
const publication = program.command("publication").alias("pub").description("Work with publications");
|
|
1685
|
+
publication.command("get [publication]").description("Fetch a publication (slug or id; default: the active publication)").action(async (target, _options, command) => {
|
|
1686
|
+
const ctx = await buildReadContext(command);
|
|
1687
|
+
const id = await resolvePublication(ctx, target);
|
|
1688
|
+
const result = await ctx.reader.getPublication(id, ctx.signal);
|
|
1689
|
+
ctx.output.result(renderPublication(result), result);
|
|
1690
|
+
});
|
|
1691
|
+
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) => {
|
|
1692
|
+
const ctx = await buildReadContext(command);
|
|
1693
|
+
const owner = address === undefined ? ctx.ownerAddress : toSuiAddress4(address);
|
|
1694
|
+
if (owner === undefined) {
|
|
1695
|
+
throw new UsageError("No address given and no active account. Pass an address or import an account.");
|
|
1696
|
+
}
|
|
1697
|
+
const page = await ctx.reader.listPublicationsOwnedBy(owner, {
|
|
1698
|
+
signal: ctx.signal,
|
|
1699
|
+
...options.limit === undefined ? {} : { limit: parseLimit(options.limit) },
|
|
1700
|
+
...options.cursor === undefined ? {} : { cursor: options.cursor }
|
|
1701
|
+
});
|
|
1702
|
+
if (page.nextCursor !== null) {
|
|
1703
|
+
ctx.output.info(`More results: pass --cursor "${page.nextCursor}"`);
|
|
1704
|
+
}
|
|
1705
|
+
if (options.idsOnly) {
|
|
1706
|
+
ctx.output.result(renderPublicationList(page.results), {
|
|
1707
|
+
results: page.results,
|
|
1708
|
+
nextCursor: page.nextCursor
|
|
1709
|
+
});
|
|
1710
|
+
return;
|
|
1711
|
+
}
|
|
1712
|
+
const enriched = [];
|
|
1713
|
+
for (const owned of page.results) {
|
|
1714
|
+
const pub = await ctx.reader.getPublication(owned.publicationId, ctx.signal);
|
|
1715
|
+
enriched.push({
|
|
1716
|
+
slug: pub.slug,
|
|
1717
|
+
name: pub.name,
|
|
1718
|
+
publicationId: owned.publicationId,
|
|
1719
|
+
ownerCapId: owned.ownerCapId
|
|
1720
|
+
});
|
|
1721
|
+
}
|
|
1722
|
+
ctx.output.result(renderEnrichedPublicationList(enriched), {
|
|
1723
|
+
results: enriched,
|
|
1724
|
+
nextCursor: page.nextCursor
|
|
1725
|
+
});
|
|
1726
|
+
});
|
|
1727
|
+
publication.command("create").description("Create a publication and select it as the active publication").requiredOption("-n, --name <name>", "Publication name").requiredOption("-s, --slug <slug>", "URL slug: lowercase alphanumeric and hyphens, 1-64 chars").action(async (options, command) => {
|
|
1728
|
+
const ctx = await buildWriteContext(command);
|
|
1729
|
+
ctx.output.info(`Creating "${options.name}"...`);
|
|
1730
|
+
const result = await createPublication(ctx.adapter, ctx.config, {
|
|
1731
|
+
name: options.name,
|
|
1732
|
+
slug: options.slug,
|
|
1733
|
+
signal: ctx.signal
|
|
1734
|
+
});
|
|
1735
|
+
await updateActiveProfile(globalOptions(command), {
|
|
1736
|
+
publication: result.publicationId,
|
|
1737
|
+
collection: undefined
|
|
1738
|
+
});
|
|
1739
|
+
const human = [
|
|
1740
|
+
`Created "${options.name}" (${result.publicationId})`,
|
|
1741
|
+
` ownerCap: ${result.ownerCapId}`,
|
|
1742
|
+
` publisherCap: ${result.publisherCapId}`,
|
|
1743
|
+
` tx: ${result.digest}`,
|
|
1744
|
+
"Selected as the active publication."
|
|
1745
|
+
].join(`
|
|
1746
|
+
`);
|
|
1747
|
+
ctx.output.result(human, result);
|
|
1748
|
+
});
|
|
1749
|
+
publication.command("delete [publication]").description("Delete an empty publication (default: the active publication)").option("--owner-cap <id>", "OwnerCap ID (auto-resolved if omitted)").action(async (target, options, command) => {
|
|
1750
|
+
const ctx = await buildWriteContext(command);
|
|
1751
|
+
const id = await resolvePublication(ctx, target);
|
|
1752
|
+
const proceed = await confirm(`Delete publication ${shortId(id)}? This cannot be undone.`, {
|
|
1753
|
+
assumeYes: Boolean(globalOptions(command).yes),
|
|
1754
|
+
signal: ctx.signal
|
|
1755
|
+
});
|
|
1756
|
+
if (!proceed) {
|
|
1757
|
+
cancelled();
|
|
1758
|
+
}
|
|
1759
|
+
ctx.output.info("Resolving OwnerCap...");
|
|
1760
|
+
const ownerCapId = await resolveOwnerCap(ctx.reader, ctx.address, id, options.ownerCap, ctx.signal);
|
|
1761
|
+
ctx.output.info("Deleting...");
|
|
1762
|
+
const result = await deletePublication(ctx.reader, ctx.adapter, ctx.config, {
|
|
1763
|
+
publicationId: id,
|
|
1764
|
+
ownerCapId,
|
|
1765
|
+
signal: ctx.signal
|
|
1766
|
+
});
|
|
1767
|
+
if (ctx.settings.publication === id) {
|
|
1768
|
+
await updateActiveProfile(globalOptions(command), {
|
|
1769
|
+
publication: undefined,
|
|
1770
|
+
collection: undefined
|
|
1771
|
+
});
|
|
1772
|
+
}
|
|
1773
|
+
ctx.output.result(`Deleted ${id}. (tx: ${result.digest})`, result);
|
|
1774
|
+
});
|
|
1775
|
+
const transfer = publication.command("transfer-ownership <recipient>").description("Transfer a publication's OwnerCap to another address");
|
|
1776
|
+
publicationOption(ownerCapOption(transfer)).action(async (recipient, options, command) => {
|
|
1777
|
+
const ctx = await buildWriteContext(command);
|
|
1778
|
+
const id = await resolvePublication(ctx, options.publication);
|
|
1779
|
+
const to = toSuiAddress4(recipient);
|
|
1780
|
+
const proceed = await confirm(`Transfer ownership of ${shortId(id)} to ${to}? You will lose owner control.`, { assumeYes: Boolean(globalOptions(command).yes), signal: ctx.signal });
|
|
1781
|
+
if (!proceed) {
|
|
1782
|
+
cancelled();
|
|
1783
|
+
}
|
|
1784
|
+
ctx.output.info("Resolving OwnerCap...");
|
|
1785
|
+
const ownerCapId = await resolveOwnerCap(ctx.reader, ctx.address, id, options.ownerCap, ctx.signal);
|
|
1786
|
+
const result = await transferOwnership(ctx.adapter, ctx.config, {
|
|
1787
|
+
ownerCapId,
|
|
1788
|
+
recipient: to,
|
|
1789
|
+
signal: ctx.signal
|
|
1790
|
+
});
|
|
1791
|
+
ctx.output.result(`Transferred ownership of ${id} to ${to}. (tx: ${result.digest})`, result);
|
|
1792
|
+
});
|
|
1793
|
+
}
|
|
1794
|
+
|
|
1795
|
+
// src/commands/revision.ts
|
|
1796
|
+
import {
|
|
1797
|
+
appendDraftRevision,
|
|
1798
|
+
publishDirect,
|
|
1799
|
+
publishFromDraft
|
|
1800
|
+
} from "@arcadiasystems/morse-sdk";
|
|
1801
|
+
function revisionOptions(command) {
|
|
1802
|
+
return collectionOption(publicationOption(publisherCapOption(contentOptions(command))));
|
|
1803
|
+
}
|
|
1804
|
+
async function resolveTarget(command, options) {
|
|
1805
|
+
const ctx = await buildContentContext(command);
|
|
1806
|
+
const publicationId = await resolvePublication(ctx, options.publication);
|
|
1807
|
+
const collection = resolveCollection(ctx, options.collection);
|
|
1808
|
+
return { ctx, publicationId, collection };
|
|
1809
|
+
}
|
|
1810
|
+
async function prepareContent(target, options) {
|
|
1811
|
+
const { ctx, publicationId } = target;
|
|
1812
|
+
const epochs = parsePositiveInt(options.epochs, "--epochs");
|
|
1813
|
+
const bytes = await readContentBytes(options);
|
|
1814
|
+
const contentType = resolveContentType(options.contentType, options.stdin ? undefined : options.file);
|
|
1815
|
+
const publisherCapId = await resolvePublisherCap(ctx.reader, ctx.address, publicationId, options.publisherCap, ctx.signal);
|
|
1816
|
+
ctx.output.info(`Uploading ${bytes.length} bytes to Walrus...`);
|
|
1817
|
+
const upload = await ctx.walrus.uploadBlob(bytes, {
|
|
1818
|
+
epochs,
|
|
1819
|
+
deletable: true
|
|
1820
|
+
});
|
|
1821
|
+
ctx.output.info(`Blob uploaded (${upload.blobObjectId}). Submitting transaction...`);
|
|
1822
|
+
return { blobObjectId: upload.blobObjectId, contentType, publisherCapId };
|
|
1823
|
+
}
|
|
1824
|
+
function registerRevisionCommands(program) {
|
|
1825
|
+
const revision = program.command("revision").description("Append or publish revisions on an entry");
|
|
1826
|
+
revisionOptions(revision.command("publish-direct <entryId>").description("Upload content and append it as a public revision")).action(async (entryId, options, command) => {
|
|
1827
|
+
const target = await resolveTarget(command, options);
|
|
1828
|
+
const numericEntryId = parseId(entryId, "entryId");
|
|
1829
|
+
const prepared = await prepareContent(target, options);
|
|
1830
|
+
const result = await publishDirect(target.ctx.adapter, target.ctx.config, {
|
|
1831
|
+
publicationId: target.publicationId,
|
|
1832
|
+
publisherCapId: prepared.publisherCapId,
|
|
1833
|
+
collectionName: target.collection,
|
|
1834
|
+
entryId: numericEntryId,
|
|
1835
|
+
blobObjectId: prepared.blobObjectId,
|
|
1836
|
+
contentType: prepared.contentType,
|
|
1837
|
+
signal: target.ctx.signal
|
|
1838
|
+
});
|
|
1839
|
+
target.ctx.output.result(`Published revision #${result.revisionId} on entry #${numericEntryId}. (tx: ${result.digest})`, result);
|
|
1840
|
+
});
|
|
1841
|
+
revisionOptions(revision.command("append-draft <entryId>").description("Upload content and append it as a draft revision")).action(async (entryId, options, command) => {
|
|
1842
|
+
const target = await resolveTarget(command, options);
|
|
1843
|
+
const numericEntryId = parseId(entryId, "entryId");
|
|
1844
|
+
const prepared = await prepareContent(target, options);
|
|
1845
|
+
const result = await appendDraftRevision(target.ctx.adapter, target.ctx.config, {
|
|
1846
|
+
publicationId: target.publicationId,
|
|
1847
|
+
publisherCapId: prepared.publisherCapId,
|
|
1848
|
+
collectionName: target.collection,
|
|
1849
|
+
entryId: numericEntryId,
|
|
1850
|
+
blobObjectId: prepared.blobObjectId,
|
|
1851
|
+
contentType: prepared.contentType,
|
|
1852
|
+
signal: target.ctx.signal
|
|
1853
|
+
});
|
|
1854
|
+
target.ctx.output.result(`Appended draft revision #${result.revisionId} on entry #${numericEntryId}. (tx: ${result.digest})`, result);
|
|
1855
|
+
});
|
|
1856
|
+
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) => {
|
|
1857
|
+
const target = await resolveTarget(command, options);
|
|
1858
|
+
const numericEntryId = parseId(entryId, "entryId");
|
|
1859
|
+
const draftId = parseId(draftRevisionId, "draftRevisionId");
|
|
1860
|
+
const prepared = await prepareContent(target, options);
|
|
1861
|
+
const result = await publishFromDraft(target.ctx.adapter, target.ctx.config, {
|
|
1862
|
+
publicationId: target.publicationId,
|
|
1863
|
+
publisherCapId: prepared.publisherCapId,
|
|
1864
|
+
collectionName: target.collection,
|
|
1865
|
+
entryId: numericEntryId,
|
|
1866
|
+
draftRevisionId: draftId,
|
|
1867
|
+
blobObjectId: prepared.blobObjectId,
|
|
1868
|
+
contentType: prepared.contentType,
|
|
1869
|
+
signal: target.ctx.signal
|
|
1870
|
+
});
|
|
1871
|
+
target.ctx.output.result(`Published revision #${result.revisionId} from draft #${draftId} on entry #${numericEntryId}. (tx: ${result.digest})`, result);
|
|
1872
|
+
});
|
|
1873
|
+
}
|
|
1874
|
+
|
|
1875
|
+
// src/commands/use.ts
|
|
1876
|
+
function registerContextCommands(program) {
|
|
1877
|
+
program.command("use [publication] [collection]").description("Set the active publication (slug or id) and optional collection. Omitting the collection clears any active collection.").option("--clear", "Clear the active publication and collection").action(async (publication, collection, options, command) => {
|
|
1878
|
+
const opts = globalOptions(command);
|
|
1879
|
+
const output = outputFor(command);
|
|
1880
|
+
if (options.clear) {
|
|
1881
|
+
const profile2 = await updateActiveProfile(opts, {
|
|
1882
|
+
publication: undefined,
|
|
1883
|
+
collection: undefined
|
|
1884
|
+
});
|
|
1885
|
+
output.result(`Cleared the active publication and collection (profile "${profile2}").`, { profile: profile2, publication: null, collection: null });
|
|
1886
|
+
return;
|
|
1887
|
+
}
|
|
1888
|
+
if (publication === undefined) {
|
|
1889
|
+
throw new UsageError("Provide a publication (slug or id), or pass --clear.");
|
|
1890
|
+
}
|
|
1891
|
+
const ctx = await buildReadContext(command);
|
|
1892
|
+
const publicationId = await resolvePublication(ctx, publication);
|
|
1893
|
+
let collectionName;
|
|
1894
|
+
if (collection !== undefined) {
|
|
1895
|
+
const pub = await ctx.reader.getPublication(publicationId, ctx.signal);
|
|
1896
|
+
if (!pub.collections.some((c) => c.name === collection)) {
|
|
1897
|
+
throw new UsageError(`Publication ${publicationId} has no collection "${collection}".`);
|
|
1898
|
+
}
|
|
1899
|
+
collectionName = collection;
|
|
1900
|
+
}
|
|
1901
|
+
const profile = await updateActiveProfile(opts, {
|
|
1902
|
+
publication: publicationId,
|
|
1903
|
+
collection: collectionName
|
|
1904
|
+
});
|
|
1905
|
+
const human = collectionName === undefined ? `Active publication set to ${publicationId} (profile "${profile}").` : `Active publication set to ${publicationId}, collection "${collectionName}" (profile "${profile}").`;
|
|
1906
|
+
output.result(human, {
|
|
1907
|
+
profile,
|
|
1908
|
+
publication: publicationId,
|
|
1909
|
+
collection: collectionName ?? null
|
|
1910
|
+
});
|
|
1911
|
+
});
|
|
1912
|
+
program.command("status").description("Show the active profile, network, account, publication, and collection").action(async (_options, command) => {
|
|
1913
|
+
const output = outputFor(command);
|
|
1914
|
+
const settings = resolveSettings(globalOptions(command), await loadConfig());
|
|
1915
|
+
const account = accountAddress(settings.account);
|
|
1916
|
+
const human = [
|
|
1917
|
+
`profile: ${settings.profileName}`,
|
|
1918
|
+
`network: ${settings.network}`,
|
|
1919
|
+
`account: ${account ?? "(none)"}`,
|
|
1920
|
+
`publication: ${settings.publication ?? "(none)"}`,
|
|
1921
|
+
`collection: ${settings.collection ?? "(none)"}`
|
|
1922
|
+
].join(`
|
|
1923
|
+
`);
|
|
1924
|
+
output.result(human, {
|
|
1925
|
+
profile: settings.profileName,
|
|
1926
|
+
network: settings.network,
|
|
1927
|
+
account: account ?? null,
|
|
1928
|
+
publication: settings.publication ?? null,
|
|
1929
|
+
collection: settings.collection ?? null
|
|
1930
|
+
});
|
|
1931
|
+
});
|
|
1932
|
+
}
|
|
1933
|
+
|
|
1934
|
+
// src/commands/index.ts
|
|
1935
|
+
function registerCommands(program) {
|
|
1936
|
+
registerConfigCommands(program);
|
|
1937
|
+
registerAccountCommands(program);
|
|
1938
|
+
registerContextCommands(program);
|
|
1939
|
+
registerPublicationCommands(program);
|
|
1940
|
+
registerCollectionCommands(program);
|
|
1941
|
+
registerEntryCommands(program);
|
|
1942
|
+
registerRevisionCommands(program);
|
|
1943
|
+
registerCapCommands(program);
|
|
1944
|
+
}
|
|
1945
|
+
|
|
1946
|
+
// src/index.ts
|
|
1947
|
+
var program = buildProgram();
|
|
1948
|
+
registerCommands(program);
|
|
1949
|
+
if (process.argv.length <= 2) {
|
|
1950
|
+
program.outputHelp();
|
|
1951
|
+
process.exitCode = 0;
|
|
1952
|
+
} else {
|
|
1953
|
+
try {
|
|
1954
|
+
await program.parseAsync();
|
|
1955
|
+
} catch (err) {
|
|
1956
|
+
const opts = program.opts();
|
|
1957
|
+
process.exitCode = handleError(err, {
|
|
1958
|
+
json: Boolean(opts.json),
|
|
1959
|
+
debug: Boolean(opts.debug)
|
|
1960
|
+
});
|
|
1961
|
+
}
|
|
1962
|
+
}
|