@ornncompute/cli 1.0.25 → 1.0.34

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.
Files changed (35) hide show
  1. package/README.md +76 -23
  2. package/package.json +1 -1
  3. package/src/admin-capacity-reservations.mjs +68 -43
  4. package/src/admin-enrollment.mjs +14 -13
  5. package/src/admin-node-lifecycle.mjs +10 -13
  6. package/src/admin-shell.mjs +146 -12
  7. package/src/catalog-shell.mjs +58 -45
  8. package/src/device-auth.mjs +1 -1
  9. package/src/evidence-commands.mjs +25 -11
  10. package/src/gpu-catalog.mjs +21 -0
  11. package/src/identity-file.mjs +28 -0
  12. package/src/kubernetes-commands.mjs +12 -6
  13. package/src/machine-reference.mjs +10 -2
  14. package/src/mount-commands.mjs +4 -2
  15. package/src/slurm-commands.mjs +11 -0
  16. package/src/storage-commands.mjs +27 -7
  17. package/src/vm-commands.mjs +42 -7
  18. package/vendor/capabilities/capabilities/admin-capacity-reservations.js +31 -61
  19. package/vendor/capabilities/capabilities/admin-enrollment.js +9 -9
  20. package/vendor/capabilities/capabilities/admin-nodes-lifecycle.js +6 -6
  21. package/vendor/capabilities/capabilities/admin.js +7 -7
  22. package/vendor/capabilities/capabilities/commerce-market.js +330 -0
  23. package/vendor/capabilities/capabilities/fleet.js +57 -21
  24. package/vendor/capabilities/capabilities/listings.js +137 -146
  25. package/vendor/capabilities/capabilities/market-shared.js +44 -0
  26. package/vendor/capabilities/capabilities/markets.js +449 -0
  27. package/vendor/capabilities/capabilities/networks.js +7 -0
  28. package/vendor/capabilities/capabilities/tokens.js +12 -9
  29. package/vendor/capabilities/capability.js +2 -1
  30. package/vendor/capabilities/cli-catalog.js +4 -2
  31. package/vendor/capabilities/cli-migrations.js +16 -43
  32. package/vendor/capabilities/cli.js +1 -1
  33. package/vendor/capabilities/spec-catalog.js +26 -3
  34. package/vendor/capabilities/supplier-directory.js +20 -0
  35. package/vendor/capabilities/capabilities/capacity-offers.js +0 -368
@@ -1,7 +1,7 @@
1
+ import { listSupplierOrganizations } from "../supplier-directory.js";
1
2
  import { z } from "zod";
2
3
  import { defineCapability, preview } from "../capability.js";
3
- import { bindHardwareFields, HARDWARE_LISTING_FIELDS, HARDWARE_LISTING_FILTERS, loadHardwareCatalog, projectCatalogTerms, } from "../spec-catalog.js";
4
- import { capacityOfferForecastVnext, executeCapacityOfferForecast } from "./capacity-offers.js";
4
+ import { bindHardwareFields, HARDWARE_LISTING_FIELDS, HARDWARE_LISTING_FILTERS, loadHardwareCatalog, projectCatalogTerms, resolveCatalogTerm, } from "../spec-catalog.js";
5
5
  function queryPath(path, params) {
6
6
  const parts = [];
7
7
  for (const [key, value] of Object.entries(params)) {
@@ -11,8 +11,8 @@ function queryPath(path, params) {
11
11
  return parts.length ? `${path}?${parts.join("&")}` : path;
12
12
  }
13
13
  export const listingCreateInput = z.object({
14
- operatorId: z.string().optional(),
15
- operator_id: z.string().optional(),
14
+ organizationId: z.string().optional(),
15
+ organization_id: z.string().optional(),
16
16
  facilityId: z.string().optional(),
17
17
  facility_id: z.string().optional(),
18
18
  kind: z.enum(["gpu", "cpu", "node", "link"]).optional(),
@@ -31,7 +31,7 @@ export const listingCreateInput = z.object({
31
31
  node_count: z.number().int().optional(),
32
32
  gpus_per_node: z.number().int().optional(),
33
33
  site_nickname: z.string().optional(),
34
- site_operator: z.string().optional(),
34
+ site_supplier: z.string().optional(),
35
35
  ram: z.string().optional(),
36
36
  storage: z.string().optional(),
37
37
  internet: z.string().optional(),
@@ -46,20 +46,24 @@ export const listingCreateInput = z.object({
46
46
  .describe("End of the availability window. YYYY-MM-DD or RFC3339."),
47
47
  available_from: z.string().optional().describe("Alias for available_start_at."),
48
48
  available_to: z.string().optional().describe("Alias for available_end_at."),
49
- buy_now_price_per_gpu_hour: z.number().optional(),
50
- cost_per_gpu_hour: z.number().optional(),
49
+ buy_now_price_per_gpu_hour: z
50
+ .number()
51
+ .optional()
52
+ .describe("Price buyers pay, USD per GPU-hour. The spread over cost_per_gpu_hour is Ornn's."),
53
+ cost_per_gpu_hour: z
54
+ .number()
55
+ .optional()
56
+ .describe("Supplier price, USD per GPU-hour. Defaults to the buy-now price (spread 0)."),
51
57
  deposit_percent: z.number().optional(),
58
+ down_payment_cents: z.number().int().nonnegative().optional(),
59
+ access_mode: z.enum(["bare-metal", "virtual-machine"]).optional(),
60
+ state: z.enum(["available", "internal"]).optional(),
52
61
  price_omitted: z.boolean().optional(),
53
62
  announce: z.boolean().optional(),
54
63
  text: z.string().optional(),
55
64
  confirm: z.boolean().optional(),
56
65
  });
57
66
  const catalogTermsInput = z.object({ kind: z.enum(["gpu", "cpu", "node", "link"]).optional() });
58
- export const listingForecastInput = z.object({
59
- $positionals: z.array(z.string()).max(0).default([]),
60
- limit: z.coerce.number().int().min(1).max(500).optional(),
61
- cursor: z.string().min(1).optional(),
62
- });
63
67
  const slackListingCreateParams = {
64
68
  type: "object",
65
69
  properties: {
@@ -70,7 +74,7 @@ const slackListingCreateParams = {
70
74
  node_count: { type: "integer" },
71
75
  gpus_per_node: { type: "integer" },
72
76
  site_nickname: { type: "string" },
73
- site_operator: { type: "string" },
77
+ site_supplier: { type: "string" },
74
78
  ram: { type: "string" },
75
79
  storage: { type: "string" },
76
80
  available_start_at: { type: "string" },
@@ -149,29 +153,19 @@ export const listListingsCapability = defineCapability({
149
153
  return ctx.fetch("/listings/available", { method: "GET", body: bound.payload });
150
154
  },
151
155
  });
152
- export const forecastListingsCapability = defineCapability({
153
- id: "listings.forecast",
154
- domain: "listings",
155
- roles: ["user"],
156
- description: "List the next 48 hourly availability buckets for customer-visible compute listings.",
157
- mutation: "read",
158
- http: { method: "GET", path: "/v1/workflows/commerce/listings/forecast" },
159
- cli: {
160
- command: ["capacity-offers", "forecast"],
161
- vnext: capacityOfferForecastVnext,
162
- },
163
- slack: false,
164
- input: listingForecastInput,
165
- execute: async (ctx, input) => {
166
- const parsed = listingForecastInput.parse(input);
167
- if (ctx.cli?.interaction)
168
- return executeCapacityOfferForecast(ctx, parsed);
169
- return ctx.fetch(queryPath("/v1/workflows/commerce/listings/forecast", {
170
- limit: parsed.limit?.toString(),
171
- cursor: parsed.cursor,
172
- }), { method: "GET" });
173
- },
174
- });
156
+ const MICRO_USD = 1_000_000;
157
+ function microUsd(dollars) {
158
+ return Math.round(dollars * MICRO_USD);
159
+ }
160
+ /**
161
+ * A primary listing's down payment in cents, from a percentage of the buyer
162
+ * total over the window: price (micro-USD per GPU-hour) x GPUs x hours.
163
+ */
164
+ export function primaryDownPaymentCents(terms) {
165
+ const hours = (Date.parse(terms.endsAt) - Date.parse(terms.startsAt)) / 3_600_000;
166
+ const totalCents = (terms.pricePerGpuHourMicroUsd * terms.nodeCount * terms.gpusPerNode * hours) / 10_000;
167
+ return Math.max(0, Math.round((totalCents * terms.percent) / 100));
168
+ }
175
169
  /** Commerce parses listing windows as RFC3339 timestamps, not bare dates. */
176
170
  function rfc3339(value) {
177
171
  const trimmed = value.trim();
@@ -199,18 +193,27 @@ const LISTING_CREATE_INPUT_ONLY_KEYS = [
199
193
  "site",
200
194
  "kind",
201
195
  "confirm",
202
- "operatorId",
203
- "operator_id",
196
+ "organizationId",
197
+ "organization_id",
198
+ "site_supplier",
204
199
  "facilityId",
205
200
  "facility_id",
201
+ "buy_now_price_per_gpu_hour",
202
+ "cost_per_gpu_hour",
203
+ "deposit_percent",
204
+ "down_payment_cents",
205
+ "access_mode",
206
+ "state",
207
+ "price_omitted",
206
208
  ];
207
209
  export const createListingCapability = defineCapability({
208
210
  id: "listings.create",
209
211
  domain: "listings",
210
212
  roles: ["admin"],
211
- description: "Create a marketplace listing. gpu_type, cpu, node_model, and fabric_type must exact-match an orchestrator catalog display name, canonical, or alias. CLI asks y/N. Slack never POSTs until a human replies confirm. MCP creates on this tool call.",
213
+ description: "Create a primary marketplace listing (admin). gpu_type, cpu, node_model, and fabric_type must exact-match an orchestrator catalog display name, canonical, or alias; gpu_type becomes the listing's GPU slug. " +
214
+ "cost_per_gpu_hour is the supplier price and buy_now_price_per_gpu_hour the price buyers pay; the difference is Ornn's spread. Nodes are attached afterwards. CLI asks y/N. Slack never POSTs until a human replies confirm. MCP creates on this tool call.",
212
215
  mutation: "preview-confirm",
213
- http: { method: "POST", path: "/inventory" },
216
+ http: { method: "POST", path: "/commerce/listings" },
214
217
  slack: { parameters: slackListingCreateParams },
215
218
  input: listingCreateInput,
216
219
  execute: async (ctx, input) => {
@@ -225,135 +228,123 @@ export const createListingCapability = defineCapability({
225
228
  const bound = bindHardwareFields(hardware.terms, normalized, HARDWARE_LISTING_FIELDS);
226
229
  if (!bound.ok)
227
230
  return bound;
228
- let operatorId = parsed.operatorId ?? parsed.operator_id;
231
+ let organizationId = parsed.organizationId ?? parsed.organization_id;
229
232
  let facilityId = parsed.facilityId ?? parsed.facility_id;
230
- if (ctx.confirmed || parsed.site_operator || parsed.site_nickname || parsed.site) {
231
- if (!operatorId) {
232
- try {
233
- const opsResp = (await ctx.fetch("/provisioning/operators", { method: "GET" }));
234
- const ops = opsResp?.operators ?? [];
235
- const query = (parsed.site_operator ?? parsed.site ?? "").trim().toLowerCase();
236
- if (query) {
237
- const match = ops.find((o) => o.operatorId.toLowerCase() === query ||
238
- o.slug.toLowerCase() === query ||
239
- o.displayName.toLowerCase() === query);
240
- if (match)
241
- operatorId = match.operatorId;
242
- }
243
- if (!operatorId && ops.length > 0) {
244
- operatorId = ops[0].operatorId;
245
- }
246
- }
247
- catch {
248
- // Ignore fetch failure (e.g. in unit tests)
249
- }
233
+ const supplierQuery = (parsed.site_supplier ?? "").trim().toLowerCase();
234
+ const facilityQuery = (parsed.site_nickname ?? parsed.site ?? "").trim().toLowerCase();
235
+ if (!organizationId && supplierQuery) {
236
+ const matches = (await listSupplierOrganizations(ctx.fetch)).filter((org) => org.id.toLowerCase() === supplierQuery || org.name.toLowerCase() === supplierQuery);
237
+ if (matches.length !== 1) {
238
+ return { ok: false, error: matches.length ? "ambiguous_supplier" : "supplier_not_found" };
250
239
  }
251
- if (!facilityId) {
252
- try {
253
- const facsResp = (await ctx.fetch("/provisioning/facilities", { method: "GET" }));
254
- const facs = facsResp?.facilities ?? [];
255
- const query = (parsed.site_nickname ?? parsed.site ?? "").trim().toLowerCase();
256
- if (query) {
257
- const match = facs.find((f) => f.facilityId.toLowerCase() === query ||
258
- f.slug.toLowerCase() === query ||
259
- f.displayName.toLowerCase() === query);
260
- if (match)
261
- facilityId = match.facilityId;
262
- }
263
- if (!facilityId && operatorId) {
264
- const match = facs.find((f) => f.operatorId === operatorId);
265
- if (match)
266
- facilityId = match.facilityId;
267
- }
268
- if (!facilityId && facs.length > 0) {
269
- facilityId = facs[0].facilityId;
240
+ organizationId = matches[0].id;
241
+ }
242
+ if ((facilityId && !organizationId) || (!facilityId && (facilityQuery || organizationId))) {
243
+ const matches = [];
244
+ const seenCursors = new Set();
245
+ let cursor;
246
+ do {
247
+ const response = (await ctx.fetch(queryPath("/provisioning/facilities", { cursor }), { method: "GET" }));
248
+ if (!Array.isArray(response.facilities))
249
+ throw new Error("invalid facility catalog");
250
+ matches.push(...response.facilities.filter((facility) => (!organizationId || (facility.organizationId ?? facility.organization_id) === organizationId) &&
251
+ (facilityId
252
+ ? (facility.facilityId ?? facility.id) === facilityId
253
+ : !facilityQuery || [facility.facilityId ?? facility.id, facility.slug, facility.displayName ?? facility.display_name]
254
+ .some((value) => value?.toLowerCase() === facilityQuery))));
255
+ cursor = response.nextCursor ?? undefined;
256
+ if (cursor) {
257
+ if (seenCursors.has(cursor) || seenCursors.size >= 49) {
258
+ throw new Error("facility catalog pagination did not complete");
270
259
  }
260
+ seenCursors.add(cursor);
271
261
  }
272
- catch {
273
- // Ignore fetch failure
274
- }
262
+ } while (cursor);
263
+ if (matches.length !== 1) {
264
+ return { ok: false, error: matches.length ? "ambiguous_facility" : "facility_not_found" };
275
265
  }
266
+ facilityId = matches[0].facilityId ?? matches[0].id;
267
+ organizationId ??= matches[0].organizationId ?? matches[0].organization_id;
268
+ if (!facilityId || !organizationId)
269
+ return { ok: false, error: "invalid_facility_owner" };
276
270
  }
277
271
  // Every window alias resolves to the same pair, and both are normalized:
278
- // commerce decodes startAt/endAt as time.Time, so a bare "2026-09-01"
272
+ // commerce decodes startsAt/endsAt as time.Time, so a bare "2026-09-01"
279
273
  // fails the whole request body rather than the one field.
280
- const startAt = rfc3339(parsed.available_start_at ??
274
+ const startsAt = rfc3339(parsed.available_start_at ??
281
275
  parsed.available_from ??
282
276
  parsed.starts_after ??
283
277
  new Date().toISOString());
284
- // `ends_before` is the window's closing bound and the counterpart of the
285
- // `starts_after` the opening bound already accepts. Only `ends_after` was
286
- // consulted here, so naming an end with `ends_before` was silently dropped
287
- // and endAt fell back to the +30d default.
288
- const endAt = rfc3339(parsed.available_end_at ??
278
+ const endsAt = rfc3339(parsed.available_end_at ??
289
279
  parsed.available_to ??
290
280
  parsed.ends_before ??
291
281
  parsed.ends_after ??
292
282
  new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString());
293
- const capacityNodes = parsed.node_count ?? parsed.min_nodes ?? 1;
283
+ const nodeCount = parsed.node_count ?? parsed.min_nodes ?? 1;
294
284
  const gpusPerNode = parsed.gpus_per_node ?? 8;
295
- const buyNowPrice = parsed.buy_now_price_per_gpu_hour;
296
- const costPrice = parsed.cost_per_gpu_hour;
297
- // `deposit_percent` and `price_omitted` were both declared on the schema and
298
- // then never read, so they rode into the body under their own names and
299
- // commerce ignored them: a deposit plan never took, and a listing whose
300
- // source quoted no price still got the invented 1.00 floor below.
301
- const depositPercent = parsed.deposit_percent;
302
- const priceOmitted = parsed.price_omitted === true;
303
- const floorRate = costPrice != null ? String(costPrice) : priceOmitted ? null : "1.00";
304
- const { buy_now_price_per_gpu_hour: _, cost_per_gpu_hour: __, deposit_percent: ___, price_omitted: ____, ...boundPayload } = bound.payload;
305
- const cleanPayload = Object.fromEntries(Object.entries(boundPayload).filter(([key]) => !LISTING_CREATE_INPUT_ONLY_KEYS.includes(key)));
306
- const payload = {
307
- backing_mode: "forward",
308
- ...cleanPayload,
309
- ...(operatorId ? { operatorId, operator_id: operatorId } : {}),
310
- ...(facilityId ? { facilityId, facility_id: facilityId } : {}),
311
- ...(operatorId
312
- ? {
313
- startAt,
314
- start_at: startAt,
315
- endAt,
316
- end_at: endAt,
317
- capacityNodes,
318
- capacity_nodes: capacityNodes,
285
+ const boundPayload = bound.payload;
286
+ const draft = Object.fromEntries(Object.entries(boundPayload).filter(([key]) => !LISTING_CREATE_INPUT_ONLY_KEYS.includes(key)));
287
+ const gpuSlug = normalized.gpu_type
288
+ ? resolveCatalogTerm(hardware.terms, "gpu", normalized.gpu_type).canonical
289
+ : null;
290
+ const buyerPrice = parsed.buy_now_price_per_gpu_hour;
291
+ const supplierPrice = parsed.cost_per_gpu_hour ?? buyerPrice;
292
+ const supplierMicro = supplierPrice != null ? microUsd(supplierPrice) : null;
293
+ const buyerMicro = buyerPrice != null ? microUsd(buyerPrice) : supplierMicro;
294
+ if (supplierMicro != null && buyerMicro != null && buyerMicro < supplierMicro) {
295
+ return { ok: false, error: "price_below_supplier_price" };
296
+ }
297
+ const downPaymentCents = parsed.down_payment_cents ??
298
+ (parsed.deposit_percent != null && buyerMicro != null
299
+ ? primaryDownPaymentCents({
300
+ pricePerGpuHourMicroUsd: buyerMicro,
301
+ nodeCount,
319
302
  gpusPerNode,
320
- gpus_per_node: gpusPerNode,
321
- currency: "USD",
322
- buyNowEnabled: buyNowPrice != null,
323
- buy_now_enabled: buyNowPrice != null,
324
- ...(buyNowPrice != null
325
- ? {
326
- buyNowRatePerGpuHour: String(buyNowPrice),
327
- buy_now_rate_per_gpu_hour: String(buyNowPrice),
328
- }
329
- : {}),
330
- ...(depositPercent != null
331
- ? {
332
- allowsDepositPlan: true,
333
- allows_deposit_plan: true,
334
- downPaymentPct: String(depositPercent),
335
- down_payment_pct: String(depositPercent),
336
- }
337
- : {}),
338
- bidEnabled: true,
339
- bid_enabled: true,
340
- // Commerce takes a null floor rate; only a stated cost becomes one.
341
- // The old 1.00 fallback published a floor nobody had quoted.
342
- ...(floorRate != null
343
- ? { floorRatePerGpuHour: floorRate, floor_rate_per_gpu_hour: floorRate }
344
- : {}),
345
- }
346
- : {}),
347
- };
303
+ startsAt,
304
+ endsAt,
305
+ percent: parsed.deposit_percent,
306
+ })
307
+ : 0);
308
+ const listing = organizationId && facilityId && gpuSlug && supplierMicro != null && buyerMicro != null
309
+ ? {
310
+ organizationId,
311
+ facilityId,
312
+ gpuSlug,
313
+ gpusPerNode,
314
+ accessMode: parsed.access_mode ?? "virtual-machine",
315
+ startsAt,
316
+ endsAt,
317
+ ...(typeof draft.cpu === "string" ? { cpu: draft.cpu } : {}),
318
+ ...(typeof draft.ram === "string" ? { ram: draft.ram } : {}),
319
+ ...(typeof draft.storage === "string" ? { storage: draft.storage } : {}),
320
+ ...(typeof draft.fabric_type === "string" ? { fabricType: draft.fabric_type } : {}),
321
+ ...(typeof draft.internet === "string" ? { internet: draft.internet } : {}),
322
+ ...(parsed.node_count ? { plannedNodeCount: parsed.node_count } : {}),
323
+ supplierPricePerGpuHourMicroUsd: supplierMicro,
324
+ spreadPerGpuHourMicroUsd: buyerMicro - supplierMicro,
325
+ downPaymentCents,
326
+ ...(parsed.state ? { state: parsed.state } : {}),
327
+ }
328
+ : null;
348
329
  if (!ctx.confirmed) {
349
- return preview("create_listing", payload);
330
+ return preview("create_listing", {
331
+ ...draft,
332
+ node_count: nodeCount,
333
+ gpus_per_node: gpusPerNode,
334
+ ...(listing ? { listing } : {}),
335
+ });
336
+ }
337
+ if (!listing) {
338
+ return {
339
+ ok: false,
340
+ error: !organizationId || !facilityId ? "facility_required" : gpuSlug ? "price_required" : "gpu_required",
341
+ };
350
342
  }
351
- return ctx.fetch("/inventory", { method: "POST", body: payload });
343
+ return ctx.fetch("/commerce/listings", { method: "POST", body: listing });
352
344
  },
353
345
  });
354
346
  export const listingCapabilities = [
355
347
  listCatalogTermsCapability,
356
348
  listListingsCapability,
357
- forecastListingsCapability,
358
349
  createListingCapability,
359
350
  ];
@@ -0,0 +1,44 @@
1
+ import { z } from "zod";
2
+ import { preview, } from "../capability.js";
3
+ /** CLI semantic arguments arrive as strings; MCP and Slack send JSON values. */
4
+ export const positionals = z.array(z.string()).max(0).default([]);
5
+ export const flag = z.preprocess((value) => (value === "true" ? true : value === "false" ? false : value), z.boolean());
6
+ export function seg(value) {
7
+ return encodeURIComponent(value);
8
+ }
9
+ export function query(params) {
10
+ const parts = Object.entries(params)
11
+ .filter(([, value]) => value !== undefined && value !== null && String(value).trim() !== "")
12
+ .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`);
13
+ return parts.length > 0 ? `?${parts.join("&")}` : "";
14
+ }
15
+ /** Without a CLI runtime or a supplied key, the transport mints one per write. */
16
+ export function idempotencyHeaders(ctx, supplied) {
17
+ const key = ctx.cli?.interaction?.idempotencyKey(supplied) ?? supplied?.trim();
18
+ return key ? { "Idempotency-Key": key } : undefined;
19
+ }
20
+ /**
21
+ * One consent gate for every surface: the CLI reviews the terms in the
22
+ * terminal (or takes `--yes`), MCP and Slack return a preview until the
23
+ * caller confirms. `null` means go ahead.
24
+ */
25
+ export async function consent(ctx, action, title, terms, payload) {
26
+ const interaction = ctx.cli?.interaction;
27
+ if (interaction) {
28
+ if (await interaction.review({ title, terms }))
29
+ return null;
30
+ return interaction.result({
31
+ data: null,
32
+ errors: [{ code: "action_declined", message: "No changes were made.", retryable: false }],
33
+ exitCode: 1,
34
+ outcome: { class: "ended", phase: "declined" },
35
+ presentation: { lines: ["No changes were made."] },
36
+ });
37
+ }
38
+ return ctx.confirmed ? null : preview(action, payload);
39
+ }
40
+ export function microUsdLabel(value) {
41
+ return typeof value === "number" && Number.isFinite(value)
42
+ ? `$${(value / 1_000_000).toFixed(2)} per GPU-hour`
43
+ : "market";
44
+ }