@neta-art/cohub-cli 2.0.0 → 2.2.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/dist/commands/run.js +3 -3
- package/dist/commands/space-commerce.d.ts +2 -0
- package/dist/commands/space-commerce.js +500 -0
- package/dist/commands/spaces.js +3 -0
- package/dist/commands/work-commerce.d.ts +2 -0
- package/dist/commands/work-commerce.js +226 -0
- package/dist/commands/works.js +2 -0
- package/dist/index.js +1 -0
- package/dist/output.js +14 -1
- package/package.json +2 -2
package/dist/commands/run.js
CHANGED
|
@@ -14,7 +14,7 @@ function shellJoin(values) {
|
|
|
14
14
|
}
|
|
15
15
|
function topLevelRunIndex(argv) {
|
|
16
16
|
for (let index = 0; index < argv.length; index += 1) {
|
|
17
|
-
const token = argv[index];
|
|
17
|
+
const token = argv[index] ?? "";
|
|
18
18
|
if (token === "--")
|
|
19
19
|
return -1;
|
|
20
20
|
if (token === "-s" || token === "--space") {
|
|
@@ -31,7 +31,7 @@ function topLevelRunIndex(argv) {
|
|
|
31
31
|
}
|
|
32
32
|
function parseSpaceId(tokens) {
|
|
33
33
|
for (let index = 0; index < tokens.length; index += 1) {
|
|
34
|
-
const token = tokens[index];
|
|
34
|
+
const token = tokens[index] ?? "";
|
|
35
35
|
if (token === "-s" || token === "--space") {
|
|
36
36
|
const value = tokens[index + 1];
|
|
37
37
|
if (!value)
|
|
@@ -55,7 +55,7 @@ function parseRunCliOptions(argv) {
|
|
|
55
55
|
let commandOption = null;
|
|
56
56
|
let positionalTokens = [];
|
|
57
57
|
for (let index = 0; index < afterRun.length; index += 1) {
|
|
58
|
-
const token = afterRun[index];
|
|
58
|
+
const token = afterRun[index] ?? "";
|
|
59
59
|
if (token === "-h" || token === "--help") {
|
|
60
60
|
printRunHelp();
|
|
61
61
|
process.exit(0);
|
|
@@ -0,0 +1,500 @@
|
|
|
1
|
+
import { createClient } from "../client.js";
|
|
2
|
+
import { error, handleHttp, json as outJson, jsonRequested, ok, table } from "../output.js";
|
|
3
|
+
import { resolveSpace } from "../space.js";
|
|
4
|
+
const PRODUCT_STATUSES = ["draft", "active"];
|
|
5
|
+
const PRODUCT_UPDATE_STATUSES = ["draft", "active", "archived"];
|
|
6
|
+
const PRODUCT_VISIBILITIES = ["public", "private"];
|
|
7
|
+
const BENEFIT_STATUSES = ["active", "archived"];
|
|
8
|
+
function requireText(value, label, flag) {
|
|
9
|
+
const text = value?.trim();
|
|
10
|
+
if (text)
|
|
11
|
+
return text;
|
|
12
|
+
return error(`Missing ${label}`, `Pass ${flag}.`);
|
|
13
|
+
}
|
|
14
|
+
function parseChoice(value, label, choices) {
|
|
15
|
+
if (value === undefined)
|
|
16
|
+
return undefined;
|
|
17
|
+
if (choices.includes(value))
|
|
18
|
+
return value;
|
|
19
|
+
return error(`Invalid ${label}`, `Use one of: ${choices.join(", ")}.`);
|
|
20
|
+
}
|
|
21
|
+
function parseInteger(value, label, options) {
|
|
22
|
+
if (value === undefined)
|
|
23
|
+
return options.fallback;
|
|
24
|
+
if (!/^-?\d+$/.test(value.trim()))
|
|
25
|
+
return error(`Invalid ${label}`, `${label} must be an integer.`);
|
|
26
|
+
const parsed = Number.parseInt(value, 10);
|
|
27
|
+
if (!Number.isSafeInteger(parsed))
|
|
28
|
+
return error(`Invalid ${label}`, `${label} must be a safe integer.`);
|
|
29
|
+
if (options.min !== undefined && parsed < options.min)
|
|
30
|
+
return error(`Invalid ${label}`, `${label} must be at least ${options.min}.`);
|
|
31
|
+
if (options.max !== undefined && parsed > options.max)
|
|
32
|
+
return error(`Invalid ${label}`, `${label} must be at most ${options.max}.`);
|
|
33
|
+
return parsed;
|
|
34
|
+
}
|
|
35
|
+
function parseAmountUsd(value) {
|
|
36
|
+
const text = requireText(value, "amount", "--amount-usd <amount>");
|
|
37
|
+
if (!/^(?:\d+|\d+\.\d{1,2}|\.\d{1,2})$/.test(text)) {
|
|
38
|
+
return error("Invalid amount", "--amount-usd must be a non-negative USD amount with at most 2 decimals.");
|
|
39
|
+
}
|
|
40
|
+
const amount = Number(text);
|
|
41
|
+
if (!Number.isFinite(amount))
|
|
42
|
+
return error("Invalid amount", "--amount-usd must be a finite USD amount.");
|
|
43
|
+
return amount;
|
|
44
|
+
}
|
|
45
|
+
function parseMetadataJson(value) {
|
|
46
|
+
if (value === undefined)
|
|
47
|
+
return undefined;
|
|
48
|
+
let parsed;
|
|
49
|
+
try {
|
|
50
|
+
parsed = JSON.parse(value);
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return error("Invalid metadata", "--metadata-json must be a JSON object.");
|
|
54
|
+
}
|
|
55
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
56
|
+
return error("Invalid metadata", "--metadata-json must be a JSON object.");
|
|
57
|
+
}
|
|
58
|
+
const metadata = {};
|
|
59
|
+
for (const [key, item] of Object.entries(parsed)) {
|
|
60
|
+
if (typeof item !== "string" && typeof item !== "number" && typeof item !== "boolean") {
|
|
61
|
+
return error("Invalid metadata", `Metadata value for "${key}" must be string, number, or boolean.`);
|
|
62
|
+
}
|
|
63
|
+
if (typeof item === "number" && !Number.isFinite(item)) {
|
|
64
|
+
return error("Invalid metadata", `Metadata value for "${key}" must be a finite number.`);
|
|
65
|
+
}
|
|
66
|
+
metadata[key] = item;
|
|
67
|
+
}
|
|
68
|
+
return metadata;
|
|
69
|
+
}
|
|
70
|
+
function compact(input) {
|
|
71
|
+
return Object.fromEntries(Object.entries(input).filter(([, value]) => value !== undefined));
|
|
72
|
+
}
|
|
73
|
+
function formatUsd(amount) {
|
|
74
|
+
return `$${amount.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
|
75
|
+
}
|
|
76
|
+
function formatMinorUsd(amountMinor) {
|
|
77
|
+
return formatUsd(amountMinor / 100);
|
|
78
|
+
}
|
|
79
|
+
function printProducts(products) {
|
|
80
|
+
table(products.map((product) => ({
|
|
81
|
+
key: product.key,
|
|
82
|
+
name: product.name,
|
|
83
|
+
status: product.status,
|
|
84
|
+
visibility: product.visibility,
|
|
85
|
+
price: formatUsd(product.pricing.amountUsd),
|
|
86
|
+
credits: product.display.creditsAmount ?? "",
|
|
87
|
+
})), [
|
|
88
|
+
{ key: "key", label: "Key" },
|
|
89
|
+
{ key: "name", label: "Name" },
|
|
90
|
+
{ key: "status", label: "Status" },
|
|
91
|
+
{ key: "visibility", label: "Visibility" },
|
|
92
|
+
{ key: "price", label: "Price" },
|
|
93
|
+
{ key: "credits", label: "Credits" },
|
|
94
|
+
]);
|
|
95
|
+
}
|
|
96
|
+
function printBenefits(benefits) {
|
|
97
|
+
table(benefits.map((benefit) => {
|
|
98
|
+
let detail;
|
|
99
|
+
if (benefit.type === "credits") {
|
|
100
|
+
const config = benefit.config;
|
|
101
|
+
detail = `${config.amount} credits${config.expiresInDays != null ? ` · ${config.expiresInDays}d` : ""}`;
|
|
102
|
+
}
|
|
103
|
+
else {
|
|
104
|
+
detail = JSON.stringify(benefit.config.metadata);
|
|
105
|
+
}
|
|
106
|
+
return {
|
|
107
|
+
key: benefit.key,
|
|
108
|
+
name: benefit.name,
|
|
109
|
+
type: benefit.type,
|
|
110
|
+
status: benefit.status,
|
|
111
|
+
detail,
|
|
112
|
+
};
|
|
113
|
+
}), [
|
|
114
|
+
{ key: "key", label: "Key" },
|
|
115
|
+
{ key: "name", label: "Name" },
|
|
116
|
+
{ key: "type", label: "Type" },
|
|
117
|
+
{ key: "status", label: "Status" },
|
|
118
|
+
{ key: "detail", label: "Detail" },
|
|
119
|
+
]);
|
|
120
|
+
}
|
|
121
|
+
function printOrders(orders) {
|
|
122
|
+
table(orders.map((order) => ({
|
|
123
|
+
id: order.id,
|
|
124
|
+
product: order.productKeySnapshot,
|
|
125
|
+
status: order.status,
|
|
126
|
+
amount: formatMinorUsd(order.amountSnapshot),
|
|
127
|
+
created: order.createdAt,
|
|
128
|
+
})), [
|
|
129
|
+
{ key: "id", label: "ID" },
|
|
130
|
+
{ key: "product", label: "Product" },
|
|
131
|
+
{ key: "status", label: "Status" },
|
|
132
|
+
{ key: "amount", label: "Amount" },
|
|
133
|
+
{ key: "created", label: "Created" },
|
|
134
|
+
]);
|
|
135
|
+
}
|
|
136
|
+
async function confirmDanger(opts, detail) {
|
|
137
|
+
if (opts.yes)
|
|
138
|
+
return;
|
|
139
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY)
|
|
140
|
+
return error("Confirmation required", `Pass --yes to ${detail}.`);
|
|
141
|
+
process.stdout.write(`${detail[0]?.toUpperCase() ?? "C"}${detail.slice(1)}? [y/N] `);
|
|
142
|
+
const chunks = [];
|
|
143
|
+
for await (const chunk of process.stdin) {
|
|
144
|
+
chunks.push(chunk);
|
|
145
|
+
break;
|
|
146
|
+
}
|
|
147
|
+
const answer = Buffer.concat(chunks).toString().trim().toLowerCase();
|
|
148
|
+
if (answer !== "y" && answer !== "yes")
|
|
149
|
+
return error("Cancelled");
|
|
150
|
+
}
|
|
151
|
+
function commerceClient(spacesCmd) {
|
|
152
|
+
const spaceId = resolveSpace(spacesCmd);
|
|
153
|
+
return { spaceId, commerce: createClient().space(spaceId).commerce };
|
|
154
|
+
}
|
|
155
|
+
export function registerSpaceCommerce(spacesCmd) {
|
|
156
|
+
const commerceCmd = spacesCmd
|
|
157
|
+
.command("commerce")
|
|
158
|
+
.description("Manage space commerce")
|
|
159
|
+
.addHelpText("after", `
|
|
160
|
+
Examples:
|
|
161
|
+
cohub -s <space-id> spaces commerce setup
|
|
162
|
+
cohub -s <space-id> spaces commerce products list
|
|
163
|
+
COHUB_SPACE_ID=<space-id> cohub spaces commerce orders list --limit 10
|
|
164
|
+
`);
|
|
165
|
+
commerceCmd
|
|
166
|
+
.command("setup")
|
|
167
|
+
.description("Initialize commerce for the target space")
|
|
168
|
+
.option("--json", "Output as JSON")
|
|
169
|
+
.action(async (opts) => {
|
|
170
|
+
const { commerce } = commerceClient(spacesCmd);
|
|
171
|
+
try {
|
|
172
|
+
const result = await commerce.setup();
|
|
173
|
+
if (jsonRequested(opts))
|
|
174
|
+
return outJson(result);
|
|
175
|
+
ok(`Commerce initialized: ${result.businessKey}`);
|
|
176
|
+
}
|
|
177
|
+
catch (e) {
|
|
178
|
+
handleHttp(e);
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
const productsCmd = commerceCmd
|
|
182
|
+
.command("products")
|
|
183
|
+
.description("Manage products")
|
|
184
|
+
.addHelpText("after", `
|
|
185
|
+
Examples:
|
|
186
|
+
cohub -s <space-id> spaces commerce products list
|
|
187
|
+
cohub -s <space-id> spaces commerce products create --name "Pro Pack" --amount-usd 9.99
|
|
188
|
+
`);
|
|
189
|
+
productsCmd
|
|
190
|
+
.command("list")
|
|
191
|
+
.description("List products")
|
|
192
|
+
.option("--json", "Output as JSON")
|
|
193
|
+
.action(async (opts) => {
|
|
194
|
+
const { commerce } = commerceClient(spacesCmd);
|
|
195
|
+
try {
|
|
196
|
+
const result = await commerce.listProducts();
|
|
197
|
+
if (jsonRequested(opts))
|
|
198
|
+
return outJson(result);
|
|
199
|
+
printProducts(result.products);
|
|
200
|
+
}
|
|
201
|
+
catch (e) {
|
|
202
|
+
handleHttp(e);
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
productsCmd
|
|
206
|
+
.command("create")
|
|
207
|
+
.description("Create a product")
|
|
208
|
+
.option("--product-key <key>", "Product key override")
|
|
209
|
+
.option("--name <name>", "Product name")
|
|
210
|
+
.option("--amount-usd <amount>", "One-time price in USD")
|
|
211
|
+
.option("--description <text>", "Product description")
|
|
212
|
+
.option("--status <status>", "Product status: draft, active")
|
|
213
|
+
.option("--visibility <visibility>", "Product visibility: public, private")
|
|
214
|
+
.option("--json", "Output as JSON")
|
|
215
|
+
.action(async (opts) => {
|
|
216
|
+
const input = {
|
|
217
|
+
key: opts.productKey?.trim() || undefined,
|
|
218
|
+
name: requireText(opts.name, "name", "--name <name>"),
|
|
219
|
+
description: opts.description,
|
|
220
|
+
amountUsd: parseAmountUsd(opts.amountUsd),
|
|
221
|
+
status: parseChoice(opts.status, "status", PRODUCT_STATUSES),
|
|
222
|
+
visibility: parseChoice(opts.visibility, "visibility", PRODUCT_VISIBILITIES),
|
|
223
|
+
};
|
|
224
|
+
const { commerce } = commerceClient(spacesCmd);
|
|
225
|
+
try {
|
|
226
|
+
const result = await commerce.createProduct(input);
|
|
227
|
+
if (jsonRequested(opts))
|
|
228
|
+
return outJson(result);
|
|
229
|
+
ok(`Product created: ${result.product.key}`);
|
|
230
|
+
printProducts([result.product]);
|
|
231
|
+
}
|
|
232
|
+
catch (e) {
|
|
233
|
+
handleHttp(e);
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
productsCmd
|
|
237
|
+
.command("update")
|
|
238
|
+
.description("Update a product")
|
|
239
|
+
.option("--product-key <key>", "Product key")
|
|
240
|
+
.option("--name <name>", "Product name")
|
|
241
|
+
.option("--description <text>", "Product description")
|
|
242
|
+
.option("--clear-description", "Clear the product description")
|
|
243
|
+
.option("--status <status>", "Product status: draft, active, archived")
|
|
244
|
+
.option("--visibility <visibility>", "Product visibility: public, private")
|
|
245
|
+
.option("--json", "Output as JSON")
|
|
246
|
+
.action(async (opts) => {
|
|
247
|
+
if (opts.description !== undefined && opts.clearDescription)
|
|
248
|
+
return error("Conflicting description", "Use either --description or --clear-description.");
|
|
249
|
+
const productKey = requireText(opts.productKey, "product key", "--product-key <key>");
|
|
250
|
+
const input = compact({
|
|
251
|
+
name: opts.name,
|
|
252
|
+
description: opts.clearDescription ? null : opts.description,
|
|
253
|
+
status: parseChoice(opts.status, "status", PRODUCT_UPDATE_STATUSES),
|
|
254
|
+
visibility: parseChoice(opts.visibility, "visibility", PRODUCT_VISIBILITIES),
|
|
255
|
+
});
|
|
256
|
+
if (Object.keys(input).length === 0)
|
|
257
|
+
return error("Nothing to update", "Pass --name, --description, --clear-description, --status, or --visibility.");
|
|
258
|
+
const { commerce } = commerceClient(spacesCmd);
|
|
259
|
+
try {
|
|
260
|
+
const result = await commerce.updateProduct(productKey, input);
|
|
261
|
+
if (jsonRequested(opts))
|
|
262
|
+
return outJson(result);
|
|
263
|
+
ok(`Product updated: ${result.product.key}`);
|
|
264
|
+
printProducts([result.product]);
|
|
265
|
+
}
|
|
266
|
+
catch (e) {
|
|
267
|
+
handleHttp(e);
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
productsCmd
|
|
271
|
+
.command("archive")
|
|
272
|
+
.description("Archive a product")
|
|
273
|
+
.option("--product-key <key>", "Product key")
|
|
274
|
+
.option("-y, --yes", "Confirm archive")
|
|
275
|
+
.option("--json", "Output as JSON")
|
|
276
|
+
.action(async (opts) => {
|
|
277
|
+
const productKey = requireText(opts.productKey, "product key", "--product-key <key>");
|
|
278
|
+
await confirmDanger(opts, `archive product "${productKey}"`);
|
|
279
|
+
const { commerce } = commerceClient(spacesCmd);
|
|
280
|
+
try {
|
|
281
|
+
const result = await commerce.updateProduct(productKey, { status: "archived" });
|
|
282
|
+
if (jsonRequested(opts))
|
|
283
|
+
return outJson(result);
|
|
284
|
+
ok(`Product archived: ${result.product.key}`);
|
|
285
|
+
}
|
|
286
|
+
catch (e) {
|
|
287
|
+
handleHttp(e);
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
const benefitsCmd = commerceCmd
|
|
291
|
+
.command("benefits")
|
|
292
|
+
.description("Manage benefits")
|
|
293
|
+
.addHelpText("after", `
|
|
294
|
+
Examples:
|
|
295
|
+
cohub -s <space-id> spaces commerce benefits list
|
|
296
|
+
cohub -s <space-id> spaces commerce benefits create --name "Premium Export" --metadata-json '{"limit":100}'
|
|
297
|
+
`);
|
|
298
|
+
benefitsCmd
|
|
299
|
+
.command("list")
|
|
300
|
+
.description("List benefits")
|
|
301
|
+
.option("--json", "Output as JSON")
|
|
302
|
+
.action(async (opts) => {
|
|
303
|
+
const { commerce } = commerceClient(spacesCmd);
|
|
304
|
+
try {
|
|
305
|
+
const result = await commerce.listBenefits();
|
|
306
|
+
if (jsonRequested(opts))
|
|
307
|
+
return outJson(result);
|
|
308
|
+
printBenefits(result.benefits);
|
|
309
|
+
}
|
|
310
|
+
catch (e) {
|
|
311
|
+
handleHttp(e);
|
|
312
|
+
}
|
|
313
|
+
});
|
|
314
|
+
benefitsCmd
|
|
315
|
+
.command("create")
|
|
316
|
+
.description("Create a benefit")
|
|
317
|
+
.option("--benefit-key <key>", "Benefit key override")
|
|
318
|
+
.option("--name <name>", "Benefit name")
|
|
319
|
+
.option("--description <text>", "Benefit description")
|
|
320
|
+
.option("--type <type>", "Benefit type: feature, credits (default: feature)")
|
|
321
|
+
.option("--amount <n>", "Credit amount (credits type only)")
|
|
322
|
+
.option("--expires-in-days <n>", "Credit expiry in days (credits type only)")
|
|
323
|
+
.option("--metadata-json <json>", "Benefit metadata JSON object (feature type only)")
|
|
324
|
+
.option("--json", "Output as JSON")
|
|
325
|
+
.action(async (opts) => {
|
|
326
|
+
const name = requireText(opts.name, "name", "--name <name>");
|
|
327
|
+
const type = parseChoice(opts.type, "type", ["feature", "credits"]) ?? "feature";
|
|
328
|
+
if (type === "credits") {
|
|
329
|
+
const amount = parseInteger(opts.amount, "amount", { fallback: 0, min: 1 });
|
|
330
|
+
if (amount < 1)
|
|
331
|
+
return error("Missing amount", "Pass --amount <n> with a positive integer for credits benefits.");
|
|
332
|
+
const input = {
|
|
333
|
+
key: opts.benefitKey?.trim() || undefined,
|
|
334
|
+
name,
|
|
335
|
+
description: opts.description,
|
|
336
|
+
type: "credits",
|
|
337
|
+
amount,
|
|
338
|
+
expiresInDays: opts.expiresInDays !== undefined
|
|
339
|
+
? parseInteger(opts.expiresInDays, "expires-in-days", { fallback: 0, min: 1 })
|
|
340
|
+
: undefined,
|
|
341
|
+
};
|
|
342
|
+
const { commerce } = commerceClient(spacesCmd);
|
|
343
|
+
try {
|
|
344
|
+
const result = await commerce.createBenefit(input);
|
|
345
|
+
if (jsonRequested(opts))
|
|
346
|
+
return outJson(result);
|
|
347
|
+
ok(`Benefit created: ${result.benefit.key}`);
|
|
348
|
+
printBenefits([result.benefit]);
|
|
349
|
+
}
|
|
350
|
+
catch (e) {
|
|
351
|
+
handleHttp(e);
|
|
352
|
+
}
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
const input = {
|
|
356
|
+
key: opts.benefitKey?.trim() || undefined,
|
|
357
|
+
name,
|
|
358
|
+
description: opts.description,
|
|
359
|
+
type: "feature",
|
|
360
|
+
metadata: parseMetadataJson(opts.metadataJson),
|
|
361
|
+
};
|
|
362
|
+
const { commerce } = commerceClient(spacesCmd);
|
|
363
|
+
try {
|
|
364
|
+
const result = await commerce.createBenefit(input);
|
|
365
|
+
if (jsonRequested(opts))
|
|
366
|
+
return outJson(result);
|
|
367
|
+
ok(`Benefit created: ${result.benefit.key}`);
|
|
368
|
+
printBenefits([result.benefit]);
|
|
369
|
+
}
|
|
370
|
+
catch (e) {
|
|
371
|
+
handleHttp(e);
|
|
372
|
+
}
|
|
373
|
+
});
|
|
374
|
+
benefitsCmd
|
|
375
|
+
.command("update")
|
|
376
|
+
.description("Update a benefit")
|
|
377
|
+
.option("--benefit-key <key>", "Benefit key")
|
|
378
|
+
.option("--name <name>", "Benefit name")
|
|
379
|
+
.option("--description <text>", "Benefit description")
|
|
380
|
+
.option("--clear-description", "Clear the benefit description")
|
|
381
|
+
.option("--status <status>", "Benefit status: active, archived")
|
|
382
|
+
.option("--metadata-json <json>", "Replace metadata with a JSON object")
|
|
383
|
+
.option("--json", "Output as JSON")
|
|
384
|
+
.action(async (opts) => {
|
|
385
|
+
if (opts.description !== undefined && opts.clearDescription)
|
|
386
|
+
return error("Conflicting description", "Use either --description or --clear-description.");
|
|
387
|
+
const benefitKey = requireText(opts.benefitKey, "benefit key", "--benefit-key <key>");
|
|
388
|
+
const input = compact({
|
|
389
|
+
name: opts.name,
|
|
390
|
+
description: opts.clearDescription ? null : opts.description,
|
|
391
|
+
status: parseChoice(opts.status, "status", BENEFIT_STATUSES),
|
|
392
|
+
metadata: parseMetadataJson(opts.metadataJson),
|
|
393
|
+
});
|
|
394
|
+
if (Object.keys(input).length === 0)
|
|
395
|
+
return error("Nothing to update", "Pass --name, --description, --clear-description, --status, or --metadata-json.");
|
|
396
|
+
const { commerce } = commerceClient(spacesCmd);
|
|
397
|
+
try {
|
|
398
|
+
const result = await commerce.updateBenefit(benefitKey, input);
|
|
399
|
+
if (jsonRequested(opts))
|
|
400
|
+
return outJson(result);
|
|
401
|
+
ok(`Benefit updated: ${result.benefit.key}`);
|
|
402
|
+
printBenefits([result.benefit]);
|
|
403
|
+
}
|
|
404
|
+
catch (e) {
|
|
405
|
+
handleHttp(e);
|
|
406
|
+
}
|
|
407
|
+
});
|
|
408
|
+
benefitsCmd
|
|
409
|
+
.command("archive")
|
|
410
|
+
.description("Archive a benefit")
|
|
411
|
+
.option("--benefit-key <key>", "Benefit key")
|
|
412
|
+
.option("-y, --yes", "Confirm archive")
|
|
413
|
+
.option("--json", "Output as JSON")
|
|
414
|
+
.action(async (opts) => {
|
|
415
|
+
const benefitKey = requireText(opts.benefitKey, "benefit key", "--benefit-key <key>");
|
|
416
|
+
await confirmDanger(opts, `archive benefit "${benefitKey}"`);
|
|
417
|
+
const { commerce } = commerceClient(spacesCmd);
|
|
418
|
+
try {
|
|
419
|
+
const result = await commerce.updateBenefit(benefitKey, { status: "archived" });
|
|
420
|
+
if (jsonRequested(opts))
|
|
421
|
+
return outJson(result);
|
|
422
|
+
ok(`Benefit archived: ${result.benefit.key}`);
|
|
423
|
+
}
|
|
424
|
+
catch (e) {
|
|
425
|
+
handleHttp(e);
|
|
426
|
+
}
|
|
427
|
+
});
|
|
428
|
+
commerceCmd
|
|
429
|
+
.command("bind")
|
|
430
|
+
.description("Bind a benefit to a product")
|
|
431
|
+
.option("--product-key <key>", "Product key")
|
|
432
|
+
.option("--benefit-key <key>", "Benefit key")
|
|
433
|
+
.option("--json", "Output as JSON")
|
|
434
|
+
.action(async (opts) => {
|
|
435
|
+
const input = {
|
|
436
|
+
productKey: requireText(opts.productKey, "product key", "--product-key <key>"),
|
|
437
|
+
benefitKey: requireText(opts.benefitKey, "benefit key", "--benefit-key <key>"),
|
|
438
|
+
};
|
|
439
|
+
const { commerce } = commerceClient(spacesCmd);
|
|
440
|
+
try {
|
|
441
|
+
const result = await commerce.bindProductBenefit(input);
|
|
442
|
+
if (jsonRequested(opts))
|
|
443
|
+
return outJson(result);
|
|
444
|
+
ok(`Benefit bound: ${input.benefitKey} -> ${input.productKey}`);
|
|
445
|
+
}
|
|
446
|
+
catch (e) {
|
|
447
|
+
handleHttp(e);
|
|
448
|
+
}
|
|
449
|
+
});
|
|
450
|
+
commerceCmd
|
|
451
|
+
.command("unbind")
|
|
452
|
+
.description("Unbind a benefit from a product")
|
|
453
|
+
.option("--product-key <key>", "Product key")
|
|
454
|
+
.option("--benefit-key <key>", "Benefit key")
|
|
455
|
+
.option("-y, --yes", "Confirm unbind")
|
|
456
|
+
.option("--json", "Output as JSON")
|
|
457
|
+
.action(async (opts) => {
|
|
458
|
+
const input = {
|
|
459
|
+
productKey: requireText(opts.productKey, "product key", "--product-key <key>"),
|
|
460
|
+
benefitKey: requireText(opts.benefitKey, "benefit key", "--benefit-key <key>"),
|
|
461
|
+
};
|
|
462
|
+
await confirmDanger(opts, `unbind benefit "${input.benefitKey}" from product "${input.productKey}"`);
|
|
463
|
+
const { commerce } = commerceClient(spacesCmd);
|
|
464
|
+
try {
|
|
465
|
+
const result = await commerce.unbindProductBenefit(input);
|
|
466
|
+
if (jsonRequested(opts))
|
|
467
|
+
return outJson(result);
|
|
468
|
+
ok(`Benefit unbound: ${input.benefitKey} -> ${input.productKey}`);
|
|
469
|
+
}
|
|
470
|
+
catch (e) {
|
|
471
|
+
handleHttp(e);
|
|
472
|
+
}
|
|
473
|
+
});
|
|
474
|
+
const ordersCmd = commerceCmd
|
|
475
|
+
.command("orders")
|
|
476
|
+
.description("Manage orders");
|
|
477
|
+
ordersCmd
|
|
478
|
+
.command("list")
|
|
479
|
+
.description("List recent orders")
|
|
480
|
+
.option("--page <page>", "Page number")
|
|
481
|
+
.option("--limit <limit>", "Page size, max 50")
|
|
482
|
+
.option("--json", "Output as JSON")
|
|
483
|
+
.action(async (opts) => {
|
|
484
|
+
const { commerce } = commerceClient(spacesCmd);
|
|
485
|
+
try {
|
|
486
|
+
const result = await commerce.listOrders({
|
|
487
|
+
page: parseInteger(opts.page, "page", { fallback: 1, min: 1 }),
|
|
488
|
+
limit: parseInteger(opts.limit, "limit", { fallback: 20, min: 1, max: 50 }),
|
|
489
|
+
});
|
|
490
|
+
if (jsonRequested(opts))
|
|
491
|
+
return outJson(result);
|
|
492
|
+
printOrders(result.orders);
|
|
493
|
+
if (result.pagination.hasMore)
|
|
494
|
+
console.log(`\nNext page: ${result.pagination.nextPage}`);
|
|
495
|
+
}
|
|
496
|
+
catch (e) {
|
|
497
|
+
handleHttp(e);
|
|
498
|
+
}
|
|
499
|
+
});
|
|
500
|
+
}
|
package/dist/commands/spaces.js
CHANGED
|
@@ -7,6 +7,7 @@ import { uploadAvatarAsset, uploadChatImageAsset } from "../avatar.js";
|
|
|
7
7
|
import { createClient } from "../client.js";
|
|
8
8
|
import { table, json as outJson, jsonRequested, ok, error, handleHttp } from "../output.js";
|
|
9
9
|
import { resolveSpace } from "../space.js";
|
|
10
|
+
import { registerSpaceCommerce } from "./space-commerce.js";
|
|
10
11
|
const cliEnv = resolveCohubEnvironment();
|
|
11
12
|
const defaultIdleTtlSeconds = cliEnv === "prod" ? 12 * 60 * 60 : 10 * 60;
|
|
12
13
|
const SPACE_ROLES = ["host", "builder", "guest"];
|
|
@@ -478,6 +479,8 @@ export function registerSpaces(program) {
|
|
|
478
479
|
registerMods(spacesCmd);
|
|
479
480
|
// ── spaces labels ──
|
|
480
481
|
registerLabels(spacesCmd);
|
|
482
|
+
// ── spaces commerce ──
|
|
483
|
+
registerSpaceCommerce(spacesCmd);
|
|
481
484
|
// ── spaces usage ──
|
|
482
485
|
spacesCmd
|
|
483
486
|
.command("usage [days]")
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { createClient } from "../client.js";
|
|
2
|
+
import { error, handleHttp, json as outJson, jsonRequested, ok, table } from "../output.js";
|
|
3
|
+
function collectOption(value, previous = []) {
|
|
4
|
+
return [...previous, value];
|
|
5
|
+
}
|
|
6
|
+
function requireText(value, label, flag) {
|
|
7
|
+
const text = value?.trim();
|
|
8
|
+
if (text)
|
|
9
|
+
return text;
|
|
10
|
+
return error(`Missing ${label}`, `Pass ${flag}.`);
|
|
11
|
+
}
|
|
12
|
+
function requireList(values, label, flag) {
|
|
13
|
+
const items = [...new Set((values ?? []).map((value) => value.trim()).filter(Boolean))];
|
|
14
|
+
if (items.length > 0)
|
|
15
|
+
return items;
|
|
16
|
+
return error(`Missing ${label}`, `Pass ${flag}.`);
|
|
17
|
+
}
|
|
18
|
+
function formatUsd(amount) {
|
|
19
|
+
return `$${amount.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
|
20
|
+
}
|
|
21
|
+
function formatMinorUsd(amountMinor) {
|
|
22
|
+
return formatUsd(amountMinor / 100);
|
|
23
|
+
}
|
|
24
|
+
function printProducts(products) {
|
|
25
|
+
table(products.map((product) => ({
|
|
26
|
+
key: product.key,
|
|
27
|
+
name: product.name,
|
|
28
|
+
status: product.status,
|
|
29
|
+
visibility: product.visibility,
|
|
30
|
+
price: formatUsd(product.pricing.amountUsd),
|
|
31
|
+
credits: product.display.creditsAmount ?? "",
|
|
32
|
+
})), [
|
|
33
|
+
{ key: "key", label: "Key" },
|
|
34
|
+
{ key: "name", label: "Name" },
|
|
35
|
+
{ key: "status", label: "Status" },
|
|
36
|
+
{ key: "visibility", label: "Visibility" },
|
|
37
|
+
{ key: "price", label: "Price" },
|
|
38
|
+
{ key: "credits", label: "Credits" },
|
|
39
|
+
]);
|
|
40
|
+
}
|
|
41
|
+
function printEntitlements(result) {
|
|
42
|
+
table(result.entitlements.map((item) => ({
|
|
43
|
+
benefitKey: item.benefitKey,
|
|
44
|
+
enabled: item.enabled ? "yes" : "no",
|
|
45
|
+
metadata: item.metadata ? JSON.stringify(item.metadata) : "",
|
|
46
|
+
})), [
|
|
47
|
+
{ key: "benefitKey", label: "Benefit" },
|
|
48
|
+
{ key: "enabled", label: "Enabled" },
|
|
49
|
+
{ key: "metadata", label: "Metadata" },
|
|
50
|
+
]);
|
|
51
|
+
console.log(`\nCredits available: ${result.credits.available} (net: ${result.credits.net})`);
|
|
52
|
+
}
|
|
53
|
+
function printOrder(order) {
|
|
54
|
+
table([{
|
|
55
|
+
id: order.id,
|
|
56
|
+
product: order.productKeySnapshot,
|
|
57
|
+
status: order.status,
|
|
58
|
+
amount: formatMinorUsd(order.amountSnapshot),
|
|
59
|
+
paid: formatMinorUsd(order.paidAmountSnapshot),
|
|
60
|
+
created: order.createdAt,
|
|
61
|
+
paidAt: order.paidAt ?? "",
|
|
62
|
+
}], [
|
|
63
|
+
{ key: "id", label: "ID" },
|
|
64
|
+
{ key: "product", label: "Product" },
|
|
65
|
+
{ key: "status", label: "Status" },
|
|
66
|
+
{ key: "amount", label: "Amount" },
|
|
67
|
+
{ key: "paid", label: "Paid" },
|
|
68
|
+
{ key: "created", label: "Created" },
|
|
69
|
+
{ key: "paidAt", label: "Paid At" },
|
|
70
|
+
]);
|
|
71
|
+
}
|
|
72
|
+
export function registerWorkCommerce(worksCmd) {
|
|
73
|
+
const commerceCmd = worksCmd
|
|
74
|
+
.command("commerce")
|
|
75
|
+
.description("Work commerce operations")
|
|
76
|
+
.addHelpText("after", `
|
|
77
|
+
Examples:
|
|
78
|
+
cohub works commerce products resolve --work-id <work-id> --product-key pro_pack
|
|
79
|
+
cohub works commerce entitlements --work-id <work-id>
|
|
80
|
+
cohub works commerce credits consume --work-id <work-id> --amount 100
|
|
81
|
+
`);
|
|
82
|
+
const productsCmd = commerceCmd
|
|
83
|
+
.command("products")
|
|
84
|
+
.description("Resolve commerce products");
|
|
85
|
+
productsCmd
|
|
86
|
+
.command("resolve")
|
|
87
|
+
.description("Resolve public products for a work")
|
|
88
|
+
.option("--work-id <id>", "Work ID")
|
|
89
|
+
.option("--product-key <key>", "Product key", collectOption)
|
|
90
|
+
.option("--json", "Output as JSON")
|
|
91
|
+
.action(async (opts) => {
|
|
92
|
+
const workId = requireText(opts.workId, "work ID", "--work-id <id>");
|
|
93
|
+
const productKeys = requireList(opts.productKey, "product key", "--product-key <key>");
|
|
94
|
+
const client = createClient();
|
|
95
|
+
try {
|
|
96
|
+
const result = await client.workCommerce.resolveProducts(workId, { productKeys });
|
|
97
|
+
if (jsonRequested(opts))
|
|
98
|
+
return outJson(result);
|
|
99
|
+
printProducts(result.products);
|
|
100
|
+
}
|
|
101
|
+
catch (e) {
|
|
102
|
+
handleHttp(e);
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
commerceCmd
|
|
106
|
+
.command("entitlements")
|
|
107
|
+
.description("Show viewer entitlements and credit balance for a work")
|
|
108
|
+
.option("--work-id <id>", "Work ID")
|
|
109
|
+
.option("--json", "Output as JSON")
|
|
110
|
+
.action(async (opts) => {
|
|
111
|
+
const workId = requireText(opts.workId, "work ID", "--work-id <id>");
|
|
112
|
+
const client = createClient();
|
|
113
|
+
try {
|
|
114
|
+
const result = await client.workCommerce.getEntitlements(workId);
|
|
115
|
+
if (jsonRequested(opts))
|
|
116
|
+
return outJson(result);
|
|
117
|
+
printEntitlements(result);
|
|
118
|
+
}
|
|
119
|
+
catch (e) {
|
|
120
|
+
handleHttp(e);
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
const creditsCmd = commerceCmd
|
|
124
|
+
.command("credits")
|
|
125
|
+
.description("Consume credits for a work");
|
|
126
|
+
creditsCmd
|
|
127
|
+
.command("consume")
|
|
128
|
+
.description("Consume credits for a work (self by default)")
|
|
129
|
+
.option("--work-id <id>", "Work ID")
|
|
130
|
+
.option("--amount <n>", "Positive integer credit amount")
|
|
131
|
+
.option("--operation-id <id>", "Idempotency key (generated when omitted)")
|
|
132
|
+
.option("--reason <text>", "Reason for the consumption")
|
|
133
|
+
.option("--json", "Output as JSON")
|
|
134
|
+
.action(async (opts) => {
|
|
135
|
+
const workId = requireText(opts.workId, "work ID", "--work-id <id>");
|
|
136
|
+
const amountText = requireText(opts.amount, "amount", "--amount <n>");
|
|
137
|
+
const amount = Number.parseInt(amountText, 10);
|
|
138
|
+
if (!Number.isSafeInteger(amount) || amount <= 0) {
|
|
139
|
+
return error("Invalid amount", "--amount must be a positive integer.");
|
|
140
|
+
}
|
|
141
|
+
const operationId = opts.operationId?.trim() || crypto.randomUUID();
|
|
142
|
+
const client = createClient();
|
|
143
|
+
try {
|
|
144
|
+
const result = await client.workCommerce.consumeCredits(workId, {
|
|
145
|
+
amount,
|
|
146
|
+
operationId,
|
|
147
|
+
reason: opts.reason,
|
|
148
|
+
});
|
|
149
|
+
if (jsonRequested(opts))
|
|
150
|
+
return outJson({ ...result, operationId });
|
|
151
|
+
ok(`Consumed ${result.amount} credits (operation: ${operationId})`);
|
|
152
|
+
table([{
|
|
153
|
+
status: result.status,
|
|
154
|
+
amount: result.amount,
|
|
155
|
+
remaining: result.remaining,
|
|
156
|
+
shortfall: result.shortfall ?? "",
|
|
157
|
+
}], [
|
|
158
|
+
{ key: "status", label: "Status" },
|
|
159
|
+
{ key: "amount", label: "Amount" },
|
|
160
|
+
{ key: "remaining", label: "Remaining" },
|
|
161
|
+
{ key: "shortfall", label: "Shortfall" },
|
|
162
|
+
]);
|
|
163
|
+
}
|
|
164
|
+
catch (e) {
|
|
165
|
+
handleHttp(e);
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
commerceCmd
|
|
169
|
+
.command("purchase")
|
|
170
|
+
.description("Create a work purchase checkout")
|
|
171
|
+
.option("--work-id <id>", "Work ID")
|
|
172
|
+
.option("--product-key <key>", "Product key")
|
|
173
|
+
.option("--json", "Output as JSON")
|
|
174
|
+
.action(async (opts) => {
|
|
175
|
+
const workId = requireText(opts.workId, "work ID", "--work-id <id>");
|
|
176
|
+
const productKey = requireText(opts.productKey, "product key", "--product-key <key>");
|
|
177
|
+
const client = createClient();
|
|
178
|
+
try {
|
|
179
|
+
const result = await client.workCommerce.purchase(workId, { productKey });
|
|
180
|
+
if (jsonRequested(opts))
|
|
181
|
+
return outJson(result);
|
|
182
|
+
ok(`Checkout created: ${result.checkout.orderId}`);
|
|
183
|
+
table([{
|
|
184
|
+
orderId: result.checkout.orderId,
|
|
185
|
+
productKey: result.checkout.productKey,
|
|
186
|
+
usable: result.checkout.checkoutUsable ? "yes" : "no",
|
|
187
|
+
status: result.checkout.status ?? "",
|
|
188
|
+
checkoutUrl: result.checkout.checkoutUrl ?? "",
|
|
189
|
+
message: result.checkout.message ?? "",
|
|
190
|
+
}], [
|
|
191
|
+
{ key: "orderId", label: "Order" },
|
|
192
|
+
{ key: "productKey", label: "Product" },
|
|
193
|
+
{ key: "usable", label: "Usable" },
|
|
194
|
+
{ key: "status", label: "Status" },
|
|
195
|
+
{ key: "checkoutUrl", label: "Checkout URL" },
|
|
196
|
+
{ key: "message", label: "Message" },
|
|
197
|
+
]);
|
|
198
|
+
}
|
|
199
|
+
catch (e) {
|
|
200
|
+
handleHttp(e);
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
const ordersCmd = commerceCmd
|
|
204
|
+
.command("orders")
|
|
205
|
+
.description("Look up commerce orders");
|
|
206
|
+
ordersCmd
|
|
207
|
+
.command("get")
|
|
208
|
+
.description("Show a work commerce order")
|
|
209
|
+
.option("--work-id <id>", "Work ID")
|
|
210
|
+
.option("--order-id <id>", "Order ID")
|
|
211
|
+
.option("--json", "Output as JSON")
|
|
212
|
+
.action(async (opts) => {
|
|
213
|
+
const workId = requireText(opts.workId, "work ID", "--work-id <id>");
|
|
214
|
+
const orderId = requireText(opts.orderId, "order ID", "--order-id <id>");
|
|
215
|
+
const client = createClient();
|
|
216
|
+
try {
|
|
217
|
+
const result = await client.workCommerce.getOrder(workId, orderId);
|
|
218
|
+
if (jsonRequested(opts))
|
|
219
|
+
return outJson(result);
|
|
220
|
+
printOrder(result.order);
|
|
221
|
+
}
|
|
222
|
+
catch (e) {
|
|
223
|
+
handleHttp(e);
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
}
|
package/dist/commands/works.js
CHANGED
|
@@ -2,6 +2,7 @@ import { HttpError } from "@neta-art/cohub";
|
|
|
2
2
|
import { createClient } from "../client.js";
|
|
3
3
|
import { error, handleHttp, json as outJson, jsonRequested, ok, table } from "../output.js";
|
|
4
4
|
import { resolveSpace } from "../space.js";
|
|
5
|
+
import { registerWorkCommerce } from "./work-commerce.js";
|
|
5
6
|
const WORK_STATUSES = ["published", "disabled"];
|
|
6
7
|
const WORK_VISIBILITIES = ["public", "space"];
|
|
7
8
|
const collectOption = (value, previous = []) => [...previous, value];
|
|
@@ -363,6 +364,7 @@ export function registerWorks(program) {
|
|
|
363
364
|
handleHttp(e);
|
|
364
365
|
}
|
|
365
366
|
});
|
|
367
|
+
registerWorkCommerce(worksCmd);
|
|
366
368
|
worksCmd
|
|
367
369
|
.command("rm <id>")
|
|
368
370
|
.alias("delete")
|
package/dist/index.js
CHANGED
|
@@ -44,6 +44,7 @@ Common commands:
|
|
|
44
44
|
cohub -s <space-id> spaces sessions turns ls <session-id>
|
|
45
45
|
cohub -s <space-id> spaces files ls
|
|
46
46
|
cohub -s <space-id> works publish demo --file dist/index.html
|
|
47
|
+
cohub -s <space-id> spaces commerce products list
|
|
47
48
|
cohub models ls
|
|
48
49
|
cohub models ls --model-type multimodal
|
|
49
50
|
cohub generate "A calm lake at sunrise" --model <model> --output lake.png
|
package/dist/output.js
CHANGED
|
@@ -85,15 +85,28 @@ function fetchFailureDetail(e) {
|
|
|
85
85
|
? `Network request failed (${parts.join(" · ")}). Check DNS/proxy/firewall settings and try again.`
|
|
86
86
|
: "Network request failed. Check DNS/proxy/firewall settings and try again.";
|
|
87
87
|
}
|
|
88
|
+
function errorPresentationFromHttpError(e) {
|
|
89
|
+
const httpError = e;
|
|
90
|
+
if (httpError.code === "space_commerce_not_initialized") {
|
|
91
|
+
return {
|
|
92
|
+
message: "space commerce is not initialized",
|
|
93
|
+
detail: "run `cohub -s <space-id> spaces commerce setup` first",
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
88
98
|
export function handleHttp(e) {
|
|
89
99
|
if (e instanceof Error && e.name === "AuthRequiredError") {
|
|
90
100
|
return error("not authenticated", "run `cohub auth login`");
|
|
91
101
|
}
|
|
92
102
|
const status = e.status;
|
|
93
103
|
const body = e.body;
|
|
94
|
-
const
|
|
104
|
+
const presentation = errorPresentationFromHttpError(e);
|
|
105
|
+
const message = presentation?.message ?? errorMessageFromBody(body) ?? (e instanceof Error ? e.message : String(e));
|
|
95
106
|
const fetchDetail = fetchFailureDetail(e);
|
|
96
107
|
const detailParts = [];
|
|
108
|
+
if (presentation?.detail)
|
|
109
|
+
detailParts.push(presentation.detail);
|
|
97
110
|
if (process.env.COHUB_DEBUG_ERRORS) {
|
|
98
111
|
if (status)
|
|
99
112
|
detailParts.push(`HTTP ${status}`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@neta-art/cohub-cli",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.2.0",
|
|
4
4
|
"description": "CLI for Cohub — spaces, sessions, and agent collaboration.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "UNLICENSED",
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
"@neta-art/generation": "^0.1.10",
|
|
17
17
|
"commander": "^14.0.3",
|
|
18
18
|
"sharp": "^0.34.5",
|
|
19
|
-
"@neta-art/cohub": "2.
|
|
19
|
+
"@neta-art/cohub": "2.2.0"
|
|
20
20
|
},
|
|
21
21
|
"publishConfig": {
|
|
22
22
|
"access": "public"
|