@clickraft/cli 0.12.0 → 0.13.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/cli.js CHANGED
@@ -58,6 +58,43 @@ var BrandModelSummarySchema = z2.object({
58
58
  var BrandModelsListResponseSchema = z2.object({
59
59
  brandModels: z2.array(BrandModelSummarySchema)
60
60
  }).strict();
61
+ var ApiError = class extends Error {
62
+ code;
63
+ meta;
64
+ cause;
65
+ constructor(code, message, meta, cause) {
66
+ super(message);
67
+ this.name = "ApiError";
68
+ this.code = code;
69
+ this.meta = meta;
70
+ this.cause = cause;
71
+ }
72
+ };
73
+ var MAX_ISSUES = 5;
74
+ function formatSchemaIssues(error) {
75
+ const issues = error.issues;
76
+ const shown = issues.slice(0, MAX_ISSUES).map(formatIssue);
77
+ const remaining = issues.length - shown.length;
78
+ const joined = shown.join("; ");
79
+ return remaining > 0 ? `${joined} (+${remaining} more)` : joined;
80
+ }
81
+ function formatIssue(issue) {
82
+ const path = formatPath(issue.path);
83
+ if (issue.code === "invalid_type") {
84
+ return `${path}: ${issue.code} (expected ${issue.expected}, received ${issue.received})`;
85
+ }
86
+ if (issue.code === "invalid_string") {
87
+ const validation = typeof issue.validation === "string" ? issue.validation : Object.keys(issue.validation)[0] ?? "string";
88
+ return `${path}: ${issue.code} (${validation})`;
89
+ }
90
+ return `${path}: ${issue.code}`;
91
+ }
92
+ function formatPath(path) {
93
+ if (path.length === 0) return "(root)";
94
+ return path.map(String).join(".");
95
+ }
96
+ var UUID_RE = /^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$/;
97
+ var uuidString = () => z3.string().regex(UUID_RE, "Must be a UUID.");
61
98
  var ProductImageSummarySchema = z3.object({
62
99
  id: z3.string().uuid(),
63
100
  url: z3.string(),
@@ -79,6 +116,65 @@ var ProductsListResponseSchema = z3.object({
79
116
  items: z3.array(ProductSummarySchema),
80
117
  nextCursor: z3.string().nullable()
81
118
  });
119
+ var MAX_PRODUCT_IMAGES = 10;
120
+ var MAX_PRODUCT_CREATE_BODY_BYTES = 32 * 1024;
121
+ var ProductCreateImageSchema = z3.object({
122
+ /**
123
+ * An image `library_items.id`. Uploads via `POST /assets` are one source;
124
+ * rows of kind 'generation' are equally admissible.
125
+ */
126
+ assetId: uuidString(),
127
+ altText: z3.string().trim().max(500).optional(),
128
+ isPrimary: z3.boolean().optional()
129
+ }).strict();
130
+ var ProductCreateRequestSchema = z3.object({
131
+ name: z3.string().trim().min(1).max(200),
132
+ description: z3.string().trim().max(5e3).optional(),
133
+ images: z3.array(ProductCreateImageSchema).min(1).max(MAX_PRODUCT_IMAGES)
134
+ }).strict().superRefine((v, ctx) => {
135
+ if (v.images.filter((i) => i.isPrimary).length > 1) {
136
+ ctx.addIssue({
137
+ code: z3.ZodIssueCode.custom,
138
+ path: ["images"],
139
+ message: "At most one image may be marked isPrimary."
140
+ });
141
+ }
142
+ });
143
+ function parseProductCreateRequest(input) {
144
+ const parsed = ProductCreateRequestSchema.safeParse(input);
145
+ if (!parsed.success) {
146
+ throw new ApiError(
147
+ "INPUT_INVALID_FORMAT",
148
+ `Invalid product create request: ${formatSchemaIssues(parsed.error)}`,
149
+ // `issues` is an array of { path, code, message }, which is the shape the
150
+ // MCP server's summarizeValidationDetails recognizes and renders.
151
+ { retryable: false, details: parsed.error.issues }
152
+ );
153
+ }
154
+ const request2 = parsed.data;
155
+ const seen = /* @__PURE__ */ new Set();
156
+ const duplicates = /* @__PURE__ */ new Set();
157
+ for (const img of request2.images) {
158
+ if (seen.has(img.assetId)) duplicates.add(img.assetId);
159
+ seen.add(img.assetId);
160
+ }
161
+ if (duplicates.size > 0) {
162
+ throw new ApiError(
163
+ "INPUT_INVALID_FORMAT",
164
+ `The same assetId appears more than once in images: ${[...duplicates].join(", ")}.`,
165
+ { retryable: false }
166
+ );
167
+ }
168
+ const bytes = Buffer.byteLength(JSON.stringify(request2), "utf8");
169
+ if (bytes > MAX_PRODUCT_CREATE_BODY_BYTES) {
170
+ throw new ApiError(
171
+ "INPUT_INVALID_FORMAT",
172
+ `Request body is ${bytes} bytes; the endpoint caps at ${MAX_PRODUCT_CREATE_BODY_BYTES}.`,
173
+ { retryable: false }
174
+ );
175
+ }
176
+ return request2;
177
+ }
82
178
  var TokenRowSchema = z4.object({
83
179
  id: z4.string().uuid(),
84
180
  organizationId: z4.string().uuid(),
@@ -318,21 +414,20 @@ var SERVER_ERROR_CODES = [
318
414
  "PRODUCT_SYNC_ERROR",
319
415
  "PRODUCT_DELETED_UPSTREAM",
320
416
  "PRODUCT_NO_PRIMARY_IMAGE",
321
- "PRODUCT_SOURCE_MANAGED"
417
+ "PRODUCT_SOURCE_MANAGED",
418
+ // Product create (`POST /api/agents/v1/products`). Without these five in the
419
+ // allow-list the HTTP client folds each one into INTERNAL_ERROR, which loses
420
+ // the server's message, the actionable `details.assetIds`, and the CLI exit
421
+ // code the failure should map to. STORAGE_UNAVAILABLE additionally loses its
422
+ // retryability, turning a transient 503 the server marked retryable into a
423
+ // one-shot failure.
424
+ "PRODUCT_IMAGE_ASSET_DUPLICATE",
425
+ "PRODUCT_IMAGE_ASSET_NOT_FOUND",
426
+ "PRODUCT_IMAGE_ASSET_NOT_IMAGE",
427
+ "PRODUCT_IMAGE_ASSET_INCOMPLETE",
428
+ "STORAGE_UNAVAILABLE"
322
429
  ];
323
430
  var KNOWN_SERVER_CODES = new Set(SERVER_ERROR_CODES);
324
- var ApiError = class extends Error {
325
- code;
326
- meta;
327
- cause;
328
- constructor(code, message, meta, cause) {
329
- super(message);
330
- this.name = "ApiError";
331
- this.code = code;
332
- this.meta = meta;
333
- this.cause = cause;
334
- }
335
- };
336
431
  async function longPoll(opts) {
337
432
  const now = opts.now ?? Date.now;
338
433
  const started = now();
@@ -383,29 +478,6 @@ function composeIterationSignal(userSignal, remainingMs) {
383
478
  const deadlineSignal = AbortSignal.timeout(Math.max(remainingMs, 0));
384
479
  return userSignal ? AbortSignal.any([userSignal, deadlineSignal]) : deadlineSignal;
385
480
  }
386
- var MAX_ISSUES = 5;
387
- function formatSchemaIssues(error) {
388
- const issues = error.issues;
389
- const shown = issues.slice(0, MAX_ISSUES).map(formatIssue);
390
- const remaining = issues.length - shown.length;
391
- const joined = shown.join("; ");
392
- return remaining > 0 ? `${joined} (+${remaining} more)` : joined;
393
- }
394
- function formatIssue(issue) {
395
- const path = formatPath(issue.path);
396
- if (issue.code === "invalid_type") {
397
- return `${path}: ${issue.code} (expected ${issue.expected}, received ${issue.received})`;
398
- }
399
- if (issue.code === "invalid_string") {
400
- const validation = typeof issue.validation === "string" ? issue.validation : Object.keys(issue.validation)[0] ?? "string";
401
- return `${path}: ${issue.code} (${validation})`;
402
- }
403
- return `${path}: ${issue.code}`;
404
- }
405
- function formatPath(path) {
406
- if (path.length === 0) return "(root)";
407
- return path.map(String).join(".");
408
- }
409
481
  function generateIdempotencyKey() {
410
482
  return randomUUID();
411
483
  }
@@ -1413,6 +1485,55 @@ var EXIT_CODE_MAP = {
1413
1485
  retryable: false,
1414
1486
  hint: "Product is managed by an external integration."
1415
1487
  },
1488
+ // --- 5 product create codes (`POST /products`) ------------------------------
1489
+ //
1490
+ // `lib/errors/app-error.ts` in the app repo annotates the four asset codes
1491
+ // "CLI exit 7". That is wrong and the app repo is being corrected: exit 7 is
1492
+ // the `conflict` category, reserved here for idempotency and canvas-rev
1493
+ // conflicts, and none of these are conflicts. Exit-code policy is CLI-side by
1494
+ // design (see the SDK's `errors/codes.ts` header), so the mapping below is the
1495
+ // authority.
1496
+ //
1497
+ // The three `usage` codes are all "the caller named something unusable", which
1498
+ // is exit 2 for MODEL_NOT_FOUND and WORKFLOW_NOT_FOUND already.
1499
+ PRODUCT_IMAGE_ASSET_DUPLICATE: {
1500
+ exitCode: 2,
1501
+ category: "usage",
1502
+ retryable: false,
1503
+ hint: "The same asset id appears more than once in the images list."
1504
+ },
1505
+ PRODUCT_IMAGE_ASSET_NOT_FOUND: {
1506
+ exitCode: 2,
1507
+ category: "usage",
1508
+ retryable: false,
1509
+ hint: "One or more asset ids do not exist in your organization. Upload them first, or check the ids."
1510
+ },
1511
+ PRODUCT_IMAGE_ASSET_NOT_IMAGE: {
1512
+ exitCode: 2,
1513
+ category: "usage",
1514
+ retryable: false,
1515
+ hint: "One or more referenced assets are not images."
1516
+ },
1517
+ // Deliberately NOT `usage`. The asset row exists and belongs to the caller;
1518
+ // its bytes never landed, which is an upload that half-completed on our side.
1519
+ // Retrying the create cannot help — the asset has to be uploaded again — so
1520
+ // this is runtime/non-retryable rather than a caller mistake.
1521
+ PRODUCT_IMAGE_ASSET_INCOMPLETE: {
1522
+ exitCode: 1,
1523
+ category: "runtime",
1524
+ retryable: false,
1525
+ hint: "An asset has no uploaded bytes. Re-run `clickraft upload` for it, then retry."
1526
+ },
1527
+ // `retryable: true` is load-bearing, not decorative: this table is the single
1528
+ // source of retryability for the SDK client via `isRetryableServerCode`, so
1529
+ // flipping it to false makes the CLI ignore a 503 the server explicitly marked
1530
+ // retryable and sent a Retry-After for.
1531
+ STORAGE_UNAVAILABLE: {
1532
+ exitCode: 6,
1533
+ category: "network",
1534
+ retryable: true,
1535
+ hint: "Object storage is temporarily unavailable. Retry with the same idempotency key."
1536
+ },
1416
1537
  // --- 4 CLI-synthetic codes --------------------------------------------------
1417
1538
  NETWORK_ERROR: {
1418
1539
  exitCode: 6,
@@ -1831,6 +1952,7 @@ async function loadConfig(options = {}) {
1831
1952
  let fileProfileName;
1832
1953
  let fileAccessToken;
1833
1954
  let fileApiBaseUrl;
1955
+ let fileScopes;
1834
1956
  if (needsFile) {
1835
1957
  const path = options.credentialsPath ?? getCredentialsPath();
1836
1958
  const file = await readCredentials(path);
@@ -1841,6 +1963,7 @@ async function loadConfig(options = {}) {
1841
1963
  if (profile) {
1842
1964
  fileAccessToken = profile.accessToken;
1843
1965
  fileApiBaseUrl = profile.apiBaseUrl;
1966
+ fileScopes = profile.scopes;
1844
1967
  }
1845
1968
  }
1846
1969
  }
@@ -1848,7 +1971,12 @@ async function loadConfig(options = {}) {
1848
1971
  accessToken: tokenFromFlagOrEnv ?? fileAccessToken ?? null,
1849
1972
  profileName: profileName ?? fileProfileName ?? DEFAULT_PROFILE_NAME,
1850
1973
  apiBaseUrl: apiBaseFromFlagOrEnv ?? fileApiBaseUrl ?? DEFAULT_API_BASE_URL,
1851
- cloudflareAccess
1974
+ cloudflareAccess,
1975
+ // Scopes describe the FILE's token. If the flag/env token won the
1976
+ // precedence ladder above, the scopes we happen to have on disk belong to a
1977
+ // different credential, so report `null` rather than something plausible
1978
+ // and wrong.
1979
+ scopes: tokenFromFlagOrEnv ? null : fileScopes ?? null
1852
1980
  };
1853
1981
  }
1854
1982
  function loadCloudflareAccess(env) {
@@ -2286,13 +2414,13 @@ var VALID_POSES = [
2286
2414
  "back",
2287
2415
  "approved"
2288
2416
  ];
2289
- var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
2417
+ var UUID_RE2 = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
2290
2418
  function parseBrandModelSpec(raw) {
2291
2419
  if (!raw) throw new ArgError("--brand-model value cannot be empty.");
2292
2420
  const colonIdx = raw.indexOf(":");
2293
2421
  const uuid = colonIdx >= 0 ? raw.slice(0, colonIdx) : raw;
2294
2422
  const poseRaw = colonIdx >= 0 ? raw.slice(colonIdx + 1) : void 0;
2295
- if (!UUID_RE.test(uuid)) {
2423
+ if (!UUID_RE2.test(uuid)) {
2296
2424
  throw new ArgError(
2297
2425
  `Invalid brand model ID: '${uuid}'. Expected a UUID (xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx).`
2298
2426
  );
@@ -2328,13 +2456,13 @@ function validateBrandModelSpecs(specs) {
2328
2456
  }
2329
2457
 
2330
2458
  // src/commands/generate/parse-product-spec.ts
2331
- var UUID_RE2 = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
2459
+ var UUID_RE3 = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
2332
2460
  function parseProductSpec(raw) {
2333
2461
  if (!raw) throw new ArgError("--product value cannot be empty.");
2334
2462
  const colonIdx = raw.indexOf(":");
2335
2463
  const productId = colonIdx >= 0 ? raw.slice(0, colonIdx) : raw;
2336
2464
  const imageIdRaw = colonIdx >= 0 ? raw.slice(colonIdx + 1) : void 0;
2337
- if (!UUID_RE2.test(productId)) {
2465
+ if (!UUID_RE3.test(productId)) {
2338
2466
  throw new ArgError(
2339
2467
  `Invalid product ID: '${productId}'. Expected a UUID (xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx).`
2340
2468
  );
@@ -2345,7 +2473,7 @@ function parseProductSpec(raw) {
2345
2473
  `Empty image ID for product ${productId.toLowerCase()}. Omit the colon or provide a valid UUID.`
2346
2474
  );
2347
2475
  }
2348
- if (!UUID_RE2.test(imageIdRaw)) {
2476
+ if (!UUID_RE3.test(imageIdRaw)) {
2349
2477
  throw new ArgError(
2350
2478
  `Invalid image ID: '${imageIdRaw}' for product ${productId.toLowerCase()}. Expected a UUID.`
2351
2479
  );
@@ -2440,7 +2568,41 @@ async function runDeviceFlow(opts) {
2440
2568
  }
2441
2569
  }
2442
2570
  async function requestDeviceAuthorization(opts) {
2443
- const body = new URLSearchParams({ client_id: opts.clientId, scope: opts.scope }).toString();
2571
+ try {
2572
+ return await attemptDeviceAuthorization(opts, opts.scope);
2573
+ } catch (err) {
2574
+ if (!(err instanceof ScopeRefusedError)) throw err;
2575
+ if (!opts.allowScopeDowngrade || opts.requiredScope === void 0) {
2576
+ throw new AuthError("AUTH_TOKEN_INVALID", scopeRefusalMessage(err.description));
2577
+ }
2578
+ opts.onScopeDowngrade?.({
2579
+ requested: opts.scope,
2580
+ retryingWith: opts.requiredScope,
2581
+ description: err.description
2582
+ });
2583
+ try {
2584
+ return await attemptDeviceAuthorization(opts, opts.requiredScope);
2585
+ } catch (retryErr) {
2586
+ if (retryErr instanceof ScopeRefusedError) {
2587
+ throw new AuthError("AUTH_TOKEN_INVALID", scopeRefusalMessage(retryErr.description));
2588
+ }
2589
+ throw retryErr;
2590
+ }
2591
+ }
2592
+ }
2593
+ function scopeRefusalMessage(description) {
2594
+ return `The authorization server refused the requested scopes${description ? `: ${description}` : "."} Re-run \`clickraft login\` without --scope to request the defaults.`;
2595
+ }
2596
+ var ScopeRefusedError = class extends Error {
2597
+ constructor(description) {
2598
+ super("invalid_scope");
2599
+ this.description = description;
2600
+ this.name = "ScopeRefusedError";
2601
+ }
2602
+ description;
2603
+ };
2604
+ async function attemptDeviceAuthorization(opts, scope) {
2605
+ const body = new URLSearchParams({ client_id: opts.clientId, scope }).toString();
2444
2606
  const headers = { "content-type": FORM_CONTENT_TYPE };
2445
2607
  if (opts.cloudflareAccess) {
2446
2608
  headers["cf-access-client-id"] = opts.cloudflareAccess.clientId;
@@ -2471,6 +2633,12 @@ async function requestDeviceAuthorization(opts) {
2471
2633
  }
2472
2634
  const text = await resp.body.text();
2473
2635
  if (resp.statusCode !== 200) {
2636
+ if (resp.statusCode === 400) {
2637
+ const maybe = DeviceTokenPollErrorSchema.safeParse(safeParseJson(text));
2638
+ if (maybe.success && maybe.data.error === "invalid_scope") {
2639
+ throw new ScopeRefusedError(maybe.data.error_description);
2640
+ }
2641
+ }
2474
2642
  const redacted = redactTokenLikeFields(truncate(text));
2475
2643
  throw new AuthError(
2476
2644
  "AUTH_TOKEN_INVALID",
@@ -2668,6 +2836,13 @@ function isAbortError2(err) {
2668
2836
  }
2669
2837
  return false;
2670
2838
  }
2839
+ function safeParseJson(text) {
2840
+ try {
2841
+ return JSON.parse(text);
2842
+ } catch {
2843
+ return void 0;
2844
+ }
2845
+ }
2671
2846
  function parseJson(text, label) {
2672
2847
  try {
2673
2848
  return JSON.parse(text);
@@ -2694,7 +2869,7 @@ function safeHeaders(headers) {
2694
2869
  var PLACEHOLDER_UUID = "00000000-0000-0000-0000-000000000000";
2695
2870
  var PLACEHOLDER_ORG_SLUG = "unknown";
2696
2871
  var CLIENT_ID = "clickraft-cli";
2697
- var DEFAULT_SCOPES = [
2872
+ var REQUIRED_SCOPES = [
2698
2873
  "templates:read",
2699
2874
  "generations:write",
2700
2875
  "generations:read",
@@ -2706,6 +2881,8 @@ var DEFAULT_SCOPES = [
2706
2881
  "assets:write",
2707
2882
  "balance:read"
2708
2883
  ];
2884
+ var OPTIONAL_SCOPES = ["products:write"];
2885
+ var DEFAULT_SCOPES = [...REQUIRED_SCOPES, ...OPTIONAL_SCOPES];
2709
2886
  async function login(options) {
2710
2887
  const config = await loadConfig({
2711
2888
  flagProfile: options.profile,
@@ -2724,12 +2901,19 @@ async function login(options) {
2724
2901
  );
2725
2902
  }
2726
2903
  }
2727
- const scope = options.scope?.trim() || DEFAULT_SCOPES.join(" ");
2904
+ const explicitScope = options.scope?.trim();
2905
+ const scope = explicitScope || DEFAULT_SCOPES.join(" ");
2728
2906
  const flow = options.runDeviceFlow ?? runDeviceFlow;
2729
2907
  const token = await flow({
2730
2908
  apiBaseUrl,
2731
2909
  clientId: CLIENT_ID,
2732
2910
  scope,
2911
+ requiredScope: REQUIRED_SCOPES.join(" "),
2912
+ // ONLY when the list is ours. An explicit --scope is a wholesale override,
2913
+ // and quietly requesting a different set than the caller asked for would
2914
+ // hand them a token whose powers they never chose and cannot see.
2915
+ allowScopeDowngrade: !explicitScope,
2916
+ onScopeDowngrade: options.onScopeDowngrade,
2733
2917
  onUserCode: options.onUserCode,
2734
2918
  onPoll: options.onPoll,
2735
2919
  signal: options.signal,
@@ -2951,6 +3135,71 @@ async function listNodes(opts) {
2951
3135
  return data;
2952
3136
  }
2953
3137
 
3138
+ // src/auth/scopes.ts
3139
+ var PRODUCTS_WRITE_SCOPE = "products:write";
3140
+ function assertScope(config, scope, hint) {
3141
+ if (config.scopes === null) return;
3142
+ if (config.scopes.includes(scope)) return;
3143
+ throw new ApiError("AUTH_TOKEN_SCOPE_INSUFFICIENT", hint, { retryable: false, hint });
3144
+ }
3145
+
3146
+ // src/commands/product/create.ts
3147
+ var IDEMPOTENCY_KEY_MAX = 255;
3148
+ async function createProduct(options) {
3149
+ if (options.idempotencyKey !== void 0) {
3150
+ const len = options.idempotencyKey.length;
3151
+ if (len < 1 || len > IDEMPOTENCY_KEY_MAX) {
3152
+ throw new ArgError(`--idempotency-key must be 1..${IDEMPOTENCY_KEY_MAX} characters.`);
3153
+ }
3154
+ }
3155
+ const config = await loadConfig({
3156
+ flagProfile: options.profile,
3157
+ flagApiBaseUrl: options.apiBaseUrl,
3158
+ flagToken: options.token,
3159
+ credentialsPath: options.credentialsPath
3160
+ });
3161
+ if (!config.accessToken) {
3162
+ throw new AuthError("AUTH_TOKEN_MISSING", `Not authenticated. Run \`clickraft login\` first.`);
3163
+ }
3164
+ assertScope(config, PRODUCTS_WRITE_SCOPE, options.scopeHint);
3165
+ const body = buildRequestBody2(options);
3166
+ const factory = options.apiClientFactory ?? ((apiBaseUrl, accessToken) => createApiClient({
3167
+ apiBaseUrl,
3168
+ accessToken,
3169
+ signal: options.signal,
3170
+ cloudflareAccess: config.cloudflareAccess
3171
+ }));
3172
+ const client = factory(config.apiBaseUrl, config.accessToken);
3173
+ return client.request({
3174
+ method: "POST",
3175
+ path: "/products",
3176
+ body,
3177
+ idempotencyKey: options.idempotencyKey,
3178
+ responseSchema: ProductSummarySchema,
3179
+ signal: options.signal
3180
+ });
3181
+ }
3182
+ function buildRequestBody2(options) {
3183
+ if (options.images.length === 0) {
3184
+ throw new ArgError("At least one --image is required.");
3185
+ }
3186
+ if (options.primary !== void 0) {
3187
+ const known = options.images.some((img) => img.assetId === options.primary);
3188
+ if (!known) {
3189
+ throw new ArgError(`--primary ${options.primary} is not among the --image assets provided.`);
3190
+ }
3191
+ }
3192
+ const images = options.images.map((img) => {
3193
+ const entry = { assetId: img.assetId };
3194
+ if (img.altText !== void 0) entry.altText = img.altText;
3195
+ if (options.primary === img.assetId) entry.isPrimary = true;
3196
+ return entry;
3197
+ });
3198
+ const raw = { name: options.name, images };
3199
+ if (options.description !== void 0) raw.description = options.description;
3200
+ return parseProductCreateRequest(raw);
3201
+ }
3202
+
2954
3203
  // src/commands/product/list.ts
2955
3204
  import "zod";
2956
3205
  var DEFAULT_LIMIT = 50;
@@ -2992,6 +3241,23 @@ async function listProducts(options) {
2992
3241
  };
2993
3242
  }
2994
3243
 
3244
+ // src/commands/product/parse-image-spec.ts
3245
+ function parseImageSpec(spec) {
3246
+ const trimmed = spec.trim();
3247
+ if (trimmed.length === 0) {
3248
+ throw new ArgError("--image requires a value of the form <assetId>[:<altText>].");
3249
+ }
3250
+ const colon = trimmed.indexOf(":");
3251
+ const assetId = (colon === -1 ? trimmed : trimmed.slice(0, colon)).trim();
3252
+ const rawAlt = colon === -1 ? void 0 : trimmed.slice(colon + 1).trim();
3253
+ if (!UUID_RE.test(assetId)) {
3254
+ throw new ArgError(
3255
+ `--image expects an asset id (UUID) before the colon, got "${assetId}". Use the assetId returned by \`clickraft upload\`.`
3256
+ );
3257
+ }
3258
+ return rawAlt ? { assetId, altText: rawAlt } : { assetId };
3259
+ }
3260
+
2995
3261
  // src/telemetry/detect.ts
2996
3262
  var AGENT_SIGNALS = [
2997
3263
  { envVar: "CLAUDE_CODE_SESSION_ID", agent: "claude-code" },
@@ -3565,6 +3831,21 @@ function renderProductsList(result, out) {
3565
3831
  out.write("\nMore results available. Use --cursor to fetch the next page.\n");
3566
3832
  }
3567
3833
  }
3834
+ function renderProductCreateResult(product, out) {
3835
+ const lines = [
3836
+ `Created product "${product.title}"`,
3837
+ ` ID: ${product.id}`,
3838
+ ` Source: ${product.sourceType}`,
3839
+ ` Images: ${product.images.length}`
3840
+ ];
3841
+ for (const img of product.images) {
3842
+ lines.push(
3843
+ ` ${img.isPrimary ? "*" : "-"} ${img.id}${img.altText ? ` ${img.altText}` : ""}`
3844
+ );
3845
+ }
3846
+ lines.push("", `Use it with: clickraft generate create --product ${product.id} ...`);
3847
+ out.write(lines.join("\n") + "\n");
3848
+ }
3568
3849
  function renderTemplatesList(result, out) {
3569
3850
  const { templates } = result;
3570
3851
  if (templates.length === 0) {
@@ -3943,6 +4224,18 @@ async function loginCommand(parsed, rc) {
3943
4224
  },
3944
4225
  onPoll: () => {
3945
4226
  rc.uiHooks?.onPoll?.();
4227
+ },
4228
+ // Always warn, including in --json mode, and always on stderr: the login
4229
+ // succeeded, so the JSON envelope on stdout stays a clean success, but the
4230
+ // token is quietly less capable than the one that was asked for. Silence
4231
+ // here is how a user ends up debugging a scope error days later.
4232
+ onScopeDowngrade: (info) => {
4233
+ rc.stderr.write(
4234
+ `Warning: the server refused one or more requested scopes${info.description ? ` (${info.description})` : ""}.
4235
+ Retrying with the required scopes only. This token will not be able to use commands that need the refused scopes.
4236
+ Re-run \`clickraft login --force-reauth\` once the server grants them.
4237
+ `
4238
+ );
3946
4239
  }
3947
4240
  });
3948
4241
  } finally {
@@ -4029,6 +4322,7 @@ function renderRootHelp(version) {
4029
4322
  " balance Show credit balance and rate limits",
4030
4323
  " brand-model list List available brand models",
4031
4324
  " product list List products in your organization",
4325
+ " product create Create a product from uploaded assets",
4032
4326
  " models list List available AI models",
4033
4327
  " nodes list List available workflow node types",
4034
4328
  " nodes describe Describe a node type's ports and fields",
@@ -4120,7 +4414,8 @@ function renderCommandHelp(command) {
4120
4414
  "Usage: clickraft product <subcommand> [options]",
4121
4415
  "",
4122
4416
  "Subcommands:",
4123
- " list List products in your organization",
4417
+ " list List products in your organization",
4418
+ " create Create a product from uploaded assets",
4124
4419
  ""
4125
4420
  ].join("\n");
4126
4421
  case "product list":
@@ -4141,6 +4436,34 @@ function renderCommandHelp(command) {
4141
4436
  " --json",
4142
4437
  ""
4143
4438
  ].join("\n");
4439
+ case "product create":
4440
+ return [
4441
+ "Usage: clickraft product create --name <name> --image <assetId>[:<altText>] [options]",
4442
+ "",
4443
+ "Create a product from images already uploaded to your Clickraft library.",
4444
+ "Upload them first with `clickraft upload`, which prints the assetId to use here.",
4445
+ "",
4446
+ "Options:",
4447
+ " --name <name> Product name (required, max 200 chars)",
4448
+ " --image <spec> <assetId>[:<altText>] (repeatable, 1-10 images, required)",
4449
+ " --description <text> Product description (max 5000 chars)",
4450
+ " --primary <assetId> Mark this image primary (default: the first --image)",
4451
+ " --idempotency-key <key> Reuse a key to make a retry safe across invocations",
4452
+ " --profile <name>",
4453
+ " --api-base-url <url>",
4454
+ " --token <token>",
4455
+ " --json",
4456
+ "",
4457
+ "Examples:",
4458
+ ' clickraft product create --name "Ceramic Mug" \\',
4459
+ " --image 1f7c1e8e-2a4b-4c9d-8e5f-3b2a1c0d9e8f",
4460
+ "",
4461
+ ' clickraft product create --name "Ceramic Mug" \\',
4462
+ ' --image "1f7c1e8e-2a4b-4c9d-8e5f-3b2a1c0d9e8f:Front view" \\',
4463
+ ' --image "2a8d2f9f-3b5c-4d0e-9f6a-4c3b2d1e0f9a:Side view" \\',
4464
+ " --primary 2a8d2f9f-3b5c-4d0e-9f6a-4c3b2d1e0f9a",
4465
+ ""
4466
+ ].join("\n");
4144
4467
  case "models":
4145
4468
  return [
4146
4469
  "Usage: clickraft models <subcommand> [options]",
@@ -4488,6 +4811,7 @@ function matchNewCommand(argv) {
4488
4811
  if (verb === "product") {
4489
4812
  const sub = argv[1];
4490
4813
  if (sub === "list") return { kind: "cmd", path: "product.list", verbTokenCount: 2 };
4814
+ if (sub === "create") return { kind: "cmd", path: "product.create", verbTokenCount: 2 };
4491
4815
  if (sub === void 0 || sub.startsWith("-")) return { kind: "sub-help", verb: "product" };
4492
4816
  return { kind: "unknown-sub", verb: "product", sub };
4493
4817
  }
@@ -4571,6 +4895,8 @@ async function runNewCommand(newCmd, options, stdout, stderr) {
4571
4895
  return await runTemplateFind(argvAfterVerb, ctx, rc);
4572
4896
  case "product.list":
4573
4897
  return await runProductList(argvAfterVerb, ctx, rc);
4898
+ case "product.create":
4899
+ return await runProductCreate(argvAfterVerb, ctx, rc);
4574
4900
  case "models.list":
4575
4901
  return await runModelsList(argvAfterVerb, ctx, rc);
4576
4902
  case "nodes.list":
@@ -4875,7 +5201,20 @@ function emitTemplateDetail(detail, ctx, rc) {
4875
5201
  }
4876
5202
  renderTemplateDetail(detail, rc.stdout);
4877
5203
  }
4878
- var PRODUCTS_SCOPE_HINT = "Your CLI token doesn't include products:read. Run `clickraft login` to re-authenticate with the new scope.";
5204
+ function scopeHintFor(scope) {
5205
+ return `Your CLI token doesn't include ${scope}. Run \`clickraft login --force-reauth\` to re-authenticate with the new scope.`;
5206
+ }
5207
+ async function withScopeHint(scope, fn) {
5208
+ try {
5209
+ return await fn();
5210
+ } catch (err) {
5211
+ if (err instanceof ApiError && err.code === "AUTH_TOKEN_SCOPE_INSUFFICIENT") {
5212
+ const hint = scopeHintFor(scope);
5213
+ throw new ApiError(err.code, hint, { ...err.meta, hint }, err.cause);
5214
+ }
5215
+ throw err;
5216
+ }
5217
+ }
4879
5218
  async function runProductList(argv, ctx, rc) {
4880
5219
  const parsed = parseArgs(argv, {
4881
5220
  string: [
@@ -4892,8 +5231,9 @@ async function runProductList(argv, ctx, rc) {
4892
5231
  if (limit !== void 0 && (limit < 1 || limit > 100)) {
4893
5232
  throw new ArgError("--limit must be between 1 and 100.");
4894
5233
  }
4895
- try {
4896
- const result = await listProducts({
5234
+ const result = await withScopeHint(
5235
+ "products:read",
5236
+ () => listProducts({
4897
5237
  cursor: parsed.string.cursor,
4898
5238
  limit,
4899
5239
  syncStatus: parsed.string["sync-status"],
@@ -4903,21 +5243,71 @@ async function runProductList(argv, ctx, rc) {
4903
5243
  apiBaseUrl: parsed.string["api-base-url"],
4904
5244
  token: parsed.string.token,
4905
5245
  signal: rc.signal
4906
- });
4907
- ctx.nextCursor = result.nextCursor ?? void 0;
4908
- emitProductList(result, ctx, rc);
5246
+ })
5247
+ );
5248
+ ctx.nextCursor = result.nextCursor ?? void 0;
5249
+ emitProductList(result, ctx, rc);
5250
+ return 0;
5251
+ }
5252
+ var PRODUCT_CREATE_NOT_ENABLED = "Product creation is not enabled on this deployment yet. The endpoint answers 404 while the feature flag is off.";
5253
+ function remapProductCreateError(err) {
5254
+ if (!(err instanceof ApiError)) return err;
5255
+ if (err.code === "NOT_FOUND") {
5256
+ return new ApiError(
5257
+ err.code,
5258
+ PRODUCT_CREATE_NOT_ENABLED,
5259
+ { ...err.meta, hint: PRODUCT_CREATE_NOT_ENABLED },
5260
+ err.cause
5261
+ );
5262
+ }
5263
+ if (err.code === "IDEMPOTENCY_KEY_CONFLICT") {
5264
+ const hint = /different token/i.test(err.message) ? "This idempotency key was used by a different token. Use a key of your own." : "This idempotency key was already used with a different request body. Use a new key.";
5265
+ return new ApiError(err.code, err.message, { ...err.meta, hint }, err.cause);
5266
+ }
5267
+ return err;
5268
+ }
5269
+ async function runProductCreate(argv, ctx, rc) {
5270
+ const parsed = parseArgs(argv, {
5271
+ string: [...COMMON_RICH_STRING_FLAGS, "name", "description", "primary", "idempotency-key"],
5272
+ boolean: [...COMMON_RICH_BOOL_FLAGS],
5273
+ array: ["image"]
5274
+ });
5275
+ const name = parsed.string.name;
5276
+ if (!name) throw new ArgError("--name is required.");
5277
+ const rawImages = parsed.array["image"] ?? [];
5278
+ if (rawImages.length === 0) throw new ArgError("At least one --image is required.");
5279
+ const images = rawImages.map(parseImageSpec);
5280
+ try {
5281
+ const product = await withScopeHint(
5282
+ "products:write",
5283
+ () => createProduct({
5284
+ name,
5285
+ description: parsed.string.description,
5286
+ images,
5287
+ primary: parsed.string.primary,
5288
+ idempotencyKey: parsed.string["idempotency-key"],
5289
+ profile: parsed.string.profile,
5290
+ apiBaseUrl: parsed.string["api-base-url"],
5291
+ token: parsed.string.token,
5292
+ signal: rc.signal,
5293
+ scopeHint: scopeHintFor("products:write")
5294
+ })
5295
+ );
5296
+ emitProductCreate(product, ctx, rc);
4909
5297
  return 0;
4910
5298
  } catch (err) {
4911
- if (err instanceof ApiError && err.code === "AUTH_TOKEN_SCOPE_INSUFFICIENT") {
4912
- throw new ApiError(
4913
- err.code,
4914
- PRODUCTS_SCOPE_HINT,
4915
- { ...err.meta, hint: PRODUCTS_SCOPE_HINT },
4916
- err.cause
4917
- );
4918
- }
4919
- throw err;
5299
+ throw remapProductCreateError(err);
5300
+ }
5301
+ }
5302
+ function emitProductCreate(product, ctx, rc) {
5303
+ if (rc.signal?.aborted) {
5304
+ throw new AuthError("AUTH_FLOW_CANCELLED", "Cancelled.");
5305
+ }
5306
+ if (rc.jsonMode) {
5307
+ rc.stdout.write(JSON.stringify(wrapSuccess(product, ctx)) + "\n");
5308
+ return;
4920
5309
  }
5310
+ renderProductCreateResult(product, rc.stdout);
4921
5311
  }
4922
5312
  function emitProductList(result, ctx, rc) {
4923
5313
  if (rc.signal?.aborted) {