nebulon-escrow-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.
@@ -0,0 +1,345 @@
1
+ const { prompt } = require("enquirer");
2
+ const { loadConfig } = require("../config");
3
+ const { ensureHostedSession } = require("../session");
4
+ const {
5
+ getInvites,
6
+ acceptInviteById,
7
+ declineInvite,
8
+ setContractKey,
9
+ getContractKeys,
10
+ getContract,
11
+ } = require("../hosted");
12
+ const { ensureContractKeypair } = require("../privacy");
13
+ const { successMessage } = require("../ui");
14
+
15
+ const formatCreatedAt = (value) => {
16
+ if (!value) {
17
+ return "n/a";
18
+ }
19
+ const date = new Date(value * 1000);
20
+ if (Number.isNaN(date.getTime())) {
21
+ return "n/a";
22
+ }
23
+ return date.toISOString().replace("T", " ").slice(0, 19);
24
+ };
25
+
26
+ const formatHandle = (handle, wallet) => {
27
+ if (handle) {
28
+ return `@${handle}`;
29
+ }
30
+ if (wallet) {
31
+ return `${wallet.slice(0, 4)}...${wallet.slice(-4)}`;
32
+ }
33
+ return "n/a";
34
+ };
35
+
36
+ const resolveOptions = (options) => {
37
+ if (!options) {
38
+ return {};
39
+ }
40
+ if (typeof options.opts === "function") {
41
+ return options.opts();
42
+ }
43
+ return options;
44
+ };
45
+
46
+ const printInvites = (invites, options = {}) => {
47
+ const resolved = resolveOptions(options);
48
+ const label = resolved.showAll ? "Nebulon Invites (All)" : "Nebulon Invites";
49
+ console.log(label);
50
+ if (!invites.length) {
51
+ console.log("No invites found.");
52
+ return;
53
+ }
54
+ console.log("# Type Status Role Contract ID From Date");
55
+ invites.forEach((invite, index) => {
56
+ const row = [
57
+ String(index + 1).padEnd(3, " "),
58
+ String(invite.type).padEnd(9, " "),
59
+ String(invite.status).padEnd(9, " "),
60
+ String(invite.role).padEnd(11, " "),
61
+ String(invite.contractId).padEnd(36, " "),
62
+ String(invite.counterparty || "n/a").padEnd(12, " "),
63
+ invite.createdAt,
64
+ ];
65
+ console.log(row.join(" "));
66
+ });
67
+ };
68
+
69
+ const runInviteList = async (filter, options = {}) => {
70
+ const config = loadConfig();
71
+ if (config.mode !== "hosted") {
72
+ console.error("Direct mode invites are not implemented yet.");
73
+ process.exit(1);
74
+ }
75
+ try {
76
+ await ensureHostedSession(config, { quiet: true, requireHandle: true });
77
+ } catch (error) {
78
+ console.error(error.message);
79
+ process.exit(1);
80
+ }
81
+
82
+ const result = await getInvites(config.backendUrl, config.auth.token);
83
+ const sent = Array.isArray(result.sent) ? result.sent : [];
84
+ const received = Array.isArray(result.received) ? result.received : [];
85
+ const resolved = resolveOptions(options);
86
+ let showAll = Boolean(resolved.showAll);
87
+ if (!showAll && Array.isArray(process.argv)) {
88
+ showAll = process.argv.includes("--show-all");
89
+ }
90
+ if (typeof filter === "string" && filter.trim().toLowerCase() === "--show-all") {
91
+ showAll = true;
92
+ filter = null;
93
+ }
94
+ const pendingOnly = !showAll;
95
+ const sentFiltered = pendingOnly
96
+ ? sent.filter((invite) => invite.status === "pending")
97
+ : sent;
98
+ const receivedFiltered = pendingOnly
99
+ ? received.filter((invite) => invite.status === "pending")
100
+ : received;
101
+
102
+ const sentRows = sentFiltered.map((invite) => ({
103
+ type: "sent",
104
+ status: invite.status,
105
+ role: invite.invitee_role || "n/a",
106
+ contractId: invite.contract_id,
107
+ counterparty: invite.invitee_handle
108
+ ? `@${invite.invitee_handle}`
109
+ : invite.invitee_wallet
110
+ ? formatHandle(null, invite.invitee_wallet)
111
+ : "link",
112
+ createdAt: formatCreatedAt(invite.created_at),
113
+ }));
114
+
115
+ const receivedRows = receivedFiltered.map((invite) => ({
116
+ type: "received",
117
+ status: invite.status,
118
+ role: invite.invitee_role || "n/a",
119
+ contractId: invite.contract_id,
120
+ counterparty: formatHandle(invite.issuer_handle, invite.issuer_wallet),
121
+ createdAt: formatCreatedAt(invite.created_at),
122
+ }));
123
+
124
+ let rows = [...receivedRows, ...sentRows];
125
+ if (filter === "sent") {
126
+ rows = sentRows;
127
+ } else if (filter === "received") {
128
+ rows = receivedRows;
129
+ }
130
+
131
+ printInvites(rows, options);
132
+ return { sent: sentFiltered, received: receivedFiltered, sentRows, receivedRows };
133
+ };
134
+
135
+ const resolveInviteSelection = (received, target) => {
136
+ const matchByIndex = Number.parseInt(target, 10);
137
+ if (!Number.isNaN(matchByIndex)) {
138
+ const idx = matchByIndex - 1;
139
+ if (idx >= 0 && idx < received.length) {
140
+ return received[idx];
141
+ }
142
+ return null;
143
+ }
144
+ return received.find(
145
+ (invite) =>
146
+ invite.contract_id === target || invite.id === target
147
+ );
148
+ };
149
+
150
+ const resolveInviteSelectionAll = (received, sent, target) => {
151
+ const combined = [...received, ...sent];
152
+ const matchByIndex = Number.parseInt(target, 10);
153
+ if (!Number.isNaN(matchByIndex)) {
154
+ const idx = matchByIndex - 1;
155
+ if (idx >= 0 && idx < combined.length) {
156
+ return combined[idx];
157
+ }
158
+ return null;
159
+ }
160
+ return combined.find(
161
+ (invite) =>
162
+ invite.contract_id === target || invite.id === target
163
+ );
164
+ };
165
+
166
+ const renderInviteDetails = (invite) => {
167
+ console.log("Invite details");
168
+ console.log(`Contract ID: ${invite.contract_id}`);
169
+ console.log(
170
+ `From: ${formatHandle(invite.issuer_handle, invite.issuer_wallet)}`
171
+ );
172
+ console.log(`Created: ${formatCreatedAt(invite.created_at)}`);
173
+ console.log("");
174
+ };
175
+
176
+ const renderInviteStatus = async (config, invite) => {
177
+ const detail = await getContract(
178
+ config.backendUrl,
179
+ config.auth.token,
180
+ invite.contract_id
181
+ );
182
+ const contract = detail.contract;
183
+ if (!contract) {
184
+ console.error("Contract not found.");
185
+ process.exit(1);
186
+ }
187
+ const customer = formatHandle(contract.client_handle, contract.client_wallet);
188
+ const provider = formatHandle(
189
+ contract.contractor_handle,
190
+ contract.contractor_wallet
191
+ );
192
+ const selfWallet = config.auth.wallet;
193
+ const role =
194
+ contract.client_wallet === selfWallet
195
+ ? "client"
196
+ : contract.contractor_wallet === selfWallet
197
+ ? "contractor"
198
+ : "other";
199
+ console.log(`Invite ID: ${invite.id}`);
200
+ console.log(`Customer: ${customer}${contract.client_wallet === selfWallet ? " (YOU)" : ""}`);
201
+ console.log(
202
+ `Service Provider: ${provider}${contract.contractor_wallet === selfWallet ? " (YOU)" : ""}`
203
+ );
204
+ console.log(`Role: ${role}`);
205
+ console.log("");
206
+ console.log(`Current status: ${invite.status}`);
207
+ };
208
+
209
+ const runInviteAction = async (target, action) => {
210
+ const config = loadConfig();
211
+ if (config.mode !== "hosted") {
212
+ console.error("Direct mode invites are not implemented yet.");
213
+ process.exit(1);
214
+ }
215
+ try {
216
+ await ensureHostedSession(config, { quiet: true, requireHandle: true });
217
+ } catch (error) {
218
+ console.error(error.message);
219
+ process.exit(1);
220
+ }
221
+
222
+ if (!target) {
223
+ console.log("Usage: nebulon invite <index|contractId> [accept|deny]");
224
+ return;
225
+ }
226
+
227
+ const result = await getInvites(config.backendUrl, config.auth.token);
228
+ const receivedAll = Array.isArray(result.received) ? result.received : [];
229
+ const sentAll = Array.isArray(result.sent) ? result.sent : [];
230
+ const receivedPending = receivedAll.filter((invite) => invite.status === "pending");
231
+ const actionKey = (action || "").toLowerCase();
232
+ const invite =
233
+ actionKey === "status"
234
+ ? resolveInviteSelectionAll(receivedAll, sentAll, target.trim())
235
+ : resolveInviteSelection(receivedPending, target.trim());
236
+ if (!invite) {
237
+ console.error("Invite not found.");
238
+ process.exit(1);
239
+ }
240
+
241
+ if (actionKey === "status") {
242
+ await renderInviteStatus(config, invite);
243
+ return;
244
+ }
245
+
246
+ renderInviteDetails(invite);
247
+
248
+ const normalizeDecision = (value) => {
249
+ if (!value) {
250
+ return null;
251
+ }
252
+ const lowered = value.trim().toLowerCase();
253
+ if (["accept", "a", "yes", "y"].includes(lowered)) {
254
+ return "accept";
255
+ }
256
+ if (["deny", "decline", "d", "no", "n"].includes(lowered)) {
257
+ return "deny";
258
+ }
259
+ return null;
260
+ };
261
+
262
+ let decision = normalizeDecision(action);
263
+ if (!decision) {
264
+ const answer = await prompt({
265
+ type: "input",
266
+ name: "decision",
267
+ message: "Do you want to accept this invite? (yes/no)",
268
+ initial: "yes",
269
+ validate: (value) =>
270
+ normalizeDecision(value) ? true : "Type yes or no.",
271
+ });
272
+ decision = normalizeDecision(answer.decision);
273
+ }
274
+
275
+ if (!decision) {
276
+ console.log("Usage: nebulon invite <index|contractId> [accept|deny]");
277
+ return;
278
+ }
279
+
280
+ try {
281
+ if (decision === "accept") {
282
+ const accepted = await acceptInviteById(
283
+ config.backendUrl,
284
+ config.auth.token,
285
+ { inviteId: invite.id }
286
+ );
287
+ console.log("Invite accepted.");
288
+ console.log(`Contract ID: ${accepted.contractId}`);
289
+ console.log(`Status: ${accepted.status}`);
290
+ // Fetch complete contract data to ensure we have escrow_pda
291
+ const contractDetail = await getContract(
292
+ config.backendUrl,
293
+ config.auth.token,
294
+ accepted.contractId
295
+ );
296
+ console.log("Setting up privacy keys...");
297
+ try {
298
+ const keypair = ensureContractKeypair(config, accepted.contractId);
299
+ await setContractKey(
300
+ config.backendUrl,
301
+ config.auth.token,
302
+ accepted.contractId,
303
+ keypair.publicKey
304
+ );
305
+ const keys = await getContractKeys(
306
+ config.backendUrl,
307
+ config.auth.token,
308
+ accepted.contractId
309
+ );
310
+ const count = Array.isArray(keys.keys) ? keys.keys.length : 0;
311
+ if (count > 1) {
312
+ console.log("Privacy key exchange complete.");
313
+ } else {
314
+ console.log("Privacy key registered. Awaiting counterparty.");
315
+ }
316
+ } catch (error) {
317
+ console.log("Privacy key setup pending. Try again later.");
318
+ }
319
+ successMessage("Invite accepted.");
320
+ return;
321
+ }
322
+
323
+ const declined = await declineInvite(
324
+ config.backendUrl,
325
+ config.auth.token,
326
+ { inviteId: invite.id }
327
+ );
328
+ console.log("Invite declined.");
329
+ console.log(`Contract ID: ${declined.contractId}`);
330
+ console.log(`Status: ${declined.status}`);
331
+ successMessage("Invite declined.");
332
+ } catch (error) {
333
+ if (error && error.message === "Not Found") {
334
+ console.error("Invite action failed: backend missing invite action routes.");
335
+ console.error("Restart the backend after updating to the latest code.");
336
+ process.exit(1);
337
+ }
338
+ throw error;
339
+ }
340
+ };
341
+
342
+ module.exports = {
343
+ runInviteList,
344
+ runInviteAction,
345
+ };
@@ -0,0 +1,320 @@
1
+ const { prompt } = require("enquirer");
2
+ const chalk = require("chalk");
3
+ const { loadConfig, saveConfig } = require("../config");
4
+ const { successMessage } = require("../ui");
5
+ const { loadWalletKeypair } = require("../wallets");
6
+ const { getChallenge, signChallenge, verify, me, checkHandle, updateHandle } = require("../hosted");
7
+
8
+ const HANDLE_REGEX = /^[a-z0-9._-]+$/;
9
+ const PFP_CHOICES = [
10
+ { name: "1 - galaxy", message: "1 - galaxy", value: "1" },
11
+ { name: "2 - alien", message: "2 - alien", value: "2" },
12
+ { name: "3 - star", message: "3 - star", value: "3" },
13
+ { name: "4 - moon", message: "4 - moon", value: "4" },
14
+ { name: "5 - spaceship", message: "5 - spaceship", value: "5" },
15
+ { name: "6 - glyph", message: "6 - glyph", value: "6" },
16
+ { name: "7 - saturn", message: "7 - saturn", value: "7" },
17
+ { name: "8 - singularity", message: "8 - singularity", value: "8" },
18
+ ];
19
+
20
+ const isPromptCancel = (error) => {
21
+ if (!error) {
22
+ return true;
23
+ }
24
+ if (typeof error === "string") {
25
+ return error.trim() === "";
26
+ }
27
+ const message = (error.message || "").toLowerCase();
28
+ return (
29
+ message.includes("cancel") ||
30
+ message.includes("aborted") ||
31
+ message.includes("terminated")
32
+ );
33
+ };
34
+
35
+ const nowSeconds = () => Math.floor(Date.now() / 1000);
36
+
37
+ const normalizeHandle = (value) =>
38
+ value.trim().toLowerCase().replace(/^@/, "");
39
+
40
+ const validateHandleLocally = (handle) => {
41
+ if (!handle) {
42
+ return { ok: false, reason: "missing" };
43
+ }
44
+ if (handle.length < 4 || handle.length > 16) {
45
+ return { ok: false, reason: "length" };
46
+ }
47
+ if (!HANDLE_REGEX.test(handle)) {
48
+ return { ok: false, reason: "characters" };
49
+ }
50
+ if (handle.startsWith(".") || handle.endsWith(".")) {
51
+ return { ok: false, reason: "dots" };
52
+ }
53
+ if (handle.includes("..")) {
54
+ return { ok: false, reason: "dots" };
55
+ }
56
+ return { ok: true, value: handle };
57
+ };
58
+
59
+ const handleReasonMessage = (reason) => {
60
+ switch (reason) {
61
+ case "missing":
62
+ return "ID is required.";
63
+ case "length":
64
+ return "Must be 4-16 characters.";
65
+ case "characters":
66
+ return "Only letters, numbers, dots, underscores, and hyphens.";
67
+ case "dots":
68
+ return "Cannot start/end with dots or have consecutive dots.";
69
+ case "taken":
70
+ return "This ID is already taken.";
71
+ case "reserved":
72
+ return "This ID is reserved.";
73
+ case "error":
74
+ return "Error checking availability.";
75
+ default:
76
+ return "Invalid Nebulon ID.";
77
+ }
78
+ };
79
+
80
+ const ensureAuthState = (config) => {
81
+ if (!config.auth) {
82
+ config.auth = {
83
+ token: null,
84
+ wallet: null,
85
+ handle: null,
86
+ role: null,
87
+ lastAuthAt: null,
88
+ };
89
+ }
90
+ };
91
+
92
+ const clearAuthState = (config) => {
93
+ config.auth = {
94
+ token: null,
95
+ wallet: null,
96
+ handle: null,
97
+ role: null,
98
+ lastAuthAt: null,
99
+ };
100
+ };
101
+
102
+ const validateHandleAvailability = async (baseUrl, handle) => {
103
+ const local = validateHandleLocally(handle);
104
+ if (!local.ok) {
105
+ return { ok: false, reason: local.reason };
106
+ }
107
+ const remote = await checkHandle(baseUrl, handle);
108
+ if (!remote.available) {
109
+ return { ok: false, reason: remote.reason || "taken" };
110
+ }
111
+ return { ok: true };
112
+ };
113
+
114
+ const promptForHandle = async (baseUrl) => {
115
+ const answer = await prompt({
116
+ type: "input",
117
+ name: "handle",
118
+ message: "Nebulon ID (optional)",
119
+ hint: "Leave blank to auto-generate",
120
+ validate: async (value) => {
121
+ const normalized = normalizeHandle(value || "");
122
+ if (!normalized) {
123
+ return true;
124
+ }
125
+ try {
126
+ const validation = await validateHandleAvailability(baseUrl, normalized);
127
+ if (!validation.ok) {
128
+ return handleReasonMessage(validation.reason);
129
+ }
130
+ } catch (error) {
131
+ return handleReasonMessage("error");
132
+ }
133
+ return true;
134
+ },
135
+ });
136
+ const normalized = normalizeHandle(answer.handle || "");
137
+ return normalized || null;
138
+ };
139
+
140
+ const promptForPfp = async () => {
141
+ const answer = await prompt({
142
+ type: "select",
143
+ name: "pfp",
144
+ message: "Select profile picture",
145
+ choices: PFP_CHOICES,
146
+ });
147
+ if (!answer.pfp) {
148
+ return null;
149
+ }
150
+ return answer.pfp.endsWith(".png") ? answer.pfp : `${answer.pfp}.png`;
151
+ };
152
+
153
+ const dumpActiveHandles = () => {
154
+ if (typeof process._getActiveHandles !== "function") {
155
+ return;
156
+ }
157
+ const handles = process._getActiveHandles();
158
+ const requests =
159
+ typeof process._getActiveRequests === "function"
160
+ ? process._getActiveRequests()
161
+ : [];
162
+
163
+ const summarize = (item) => {
164
+ if (!item) {
165
+ return "unknown";
166
+ }
167
+ if (item.constructor && item.constructor.name) {
168
+ return item.constructor.name;
169
+ }
170
+ return typeof item;
171
+ };
172
+
173
+ console.log("Debug: active handles:", handles.map(summarize).join(", "));
174
+ if (requests.length) {
175
+ console.log(
176
+ "Debug: active requests:",
177
+ requests.map(summarize).join(", ")
178
+ );
179
+ }
180
+ console.log(
181
+ `Debug: stdin paused=${process.stdin.isPaused()} readable=${process.stdin.readable}`
182
+ );
183
+ };
184
+
185
+ const finalizeLogin = (debug, exitCode = 0) => {
186
+ if (debug) {
187
+ dumpActiveHandles();
188
+ }
189
+ if (process.stdin.isTTY) {
190
+ try {
191
+ process.stdin.setRawMode(false);
192
+ } catch (error) {
193
+ // ignore
194
+ }
195
+ process.stdin.pause();
196
+ }
197
+ process.exit(exitCode);
198
+ };
199
+
200
+ const runLogin = async (options = {}) => {
201
+ const debug = Boolean(options.debug);
202
+ const config = loadConfig();
203
+ if (config.mode !== "hosted") {
204
+ console.log("Direct mode does not use hosted login.");
205
+ return;
206
+ }
207
+ if (!config.activeWallet) {
208
+ console.error("No active wallet. Run: nebulon init");
209
+ process.exit(1);
210
+ }
211
+ ensureAuthState(config);
212
+ const keypair = loadWalletKeypair(config, config.activeWallet);
213
+ const wallet = keypair.publicKey.toBase58();
214
+
215
+ try {
216
+ if (config.auth.token) {
217
+ try {
218
+ const profile = await me(config.backendUrl, config.auth.token);
219
+ config.auth.wallet = profile.wallet;
220
+ config.auth.handle = profile.nebulonId || profile.handle || null;
221
+ config.auth.role = profile.role || null;
222
+ } catch (error) {
223
+ clearAuthState(config);
224
+ }
225
+ }
226
+
227
+ console.log("Requesting challenge...");
228
+ const challenge = await getChallenge(config.backendUrl, wallet);
229
+ if (debug) {
230
+ console.log("Debug: challenge nonce", challenge.nonce);
231
+ }
232
+
233
+ console.log("Signing challenge...");
234
+ const signature = signChallenge(keypair, challenge.message);
235
+ if (debug) {
236
+ console.log("Debug: signature length", signature.length);
237
+ }
238
+ const result = await verify(config.backendUrl, {
239
+ wallet,
240
+ nonce: challenge.nonce,
241
+ signature,
242
+ });
243
+ if (debug) {
244
+ console.log("Debug: verify user", result.user);
245
+ }
246
+
247
+ config.auth.token = result.token;
248
+ config.auth.wallet = result.user.wallet;
249
+ config.auth.handle = result.user.handle || null;
250
+ config.auth.role = result.user.role || null;
251
+ config.auth.lastAuthAt = nowSeconds();
252
+ saveConfig(config);
253
+
254
+ if (!config.auth.handle) {
255
+ console.log("First-time setup detected.");
256
+ console.log(
257
+ "Choose a Nebulon ID (leave blank to auto-generate) and a profile picture."
258
+ );
259
+ let desiredHandle = options.handle ? normalizeHandle(options.handle) : null;
260
+ if (options.handle) {
261
+ const validation = await validateHandleAvailability(
262
+ config.backendUrl,
263
+ desiredHandle
264
+ );
265
+ if (!validation.ok) {
266
+ console.error(`Handle invalid: ${handleReasonMessage(validation.reason)}`);
267
+ process.exit(1);
268
+ }
269
+ } else {
270
+ try {
271
+ desiredHandle = await promptForHandle(config.backendUrl);
272
+ } catch (error) {
273
+ if (isPromptCancel(error)) {
274
+ console.log("Login canceled - no changes applied.");
275
+ return;
276
+ }
277
+ throw error;
278
+ }
279
+ }
280
+
281
+ let pfpChoice = null;
282
+ try {
283
+ pfpChoice = await promptForPfp();
284
+ } catch (error) {
285
+ if (isPromptCancel(error)) {
286
+ console.log("Login canceled - no changes applied.");
287
+ return;
288
+ }
289
+ throw error;
290
+ }
291
+
292
+ if (debug) {
293
+ console.log("Debug: handle update payload", {
294
+ handle: desiredHandle || null,
295
+ pfp: pfpChoice || null,
296
+ });
297
+ }
298
+ const update = await updateHandle(
299
+ config.backendUrl,
300
+ config.auth.token,
301
+ desiredHandle,
302
+ pfpChoice
303
+ );
304
+ if (debug) {
305
+ console.log("Debug: handle update response", update);
306
+ }
307
+ config.auth.handle = update.handle || desiredHandle;
308
+ saveConfig(config);
309
+ }
310
+
311
+ successMessage("Login successful.");
312
+ finalizeLogin(debug, 0);
313
+ } catch (error) {
314
+ throw new Error(error && error.message ? error.message : "Login failed.");
315
+ }
316
+ };
317
+
318
+ module.exports = {
319
+ runLogin,
320
+ };
@@ -0,0 +1,22 @@
1
+ const { loadConfig, saveConfig } = require("../config");
2
+
3
+ const runLogout = async () => {
4
+ const config = loadConfig();
5
+ if (!config.auth) {
6
+ console.log("Already logged out.");
7
+ return;
8
+ }
9
+ config.auth = {
10
+ token: null,
11
+ wallet: null,
12
+ handle: null,
13
+ role: null,
14
+ lastAuthAt: null,
15
+ };
16
+ saveConfig(config);
17
+ console.log("Logged out.");
18
+ };
19
+
20
+ module.exports = {
21
+ runLogout,
22
+ };