@drip-apex/apex-cli 0.15.1 → 0.15.2

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 (3) hide show
  1. package/README.md +2 -0
  2. package/dist/cli.js +278 -45
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -227,6 +227,7 @@ command inventory:
227
227
 
228
228
  | Command | Purpose |
229
229
  | --- | --- |
230
+ | `apex admin customers set-category <org-id>` | Change an existing account to internal, demo, dogfood or customer with a platform user key. Requires an 8 to 500 character `--reason` and `--confirm`. |
230
231
  | `apex admin customers create` | Create internal, demo or dogfood accounts with a platform user key. Requires `--name`, `--category`, an 8 to 500 character `--reason`, and `--confirm`. |
231
232
  | `apex admin shops archive <shopId>` | Archive a shop and disable Apex delivery; use `--force` in non-interactive environments. |
232
233
  | `apex admin shops restore <shopId>` | Restore an archived shop while leaving its runtime kill switch unchanged. |
@@ -541,6 +542,7 @@ are created in **Admin, Links**.
541
542
  ```sh
542
543
  apex admin customers create --name "Demo workspace" --category demo --reason "Workshop demo account" --confirm --json
543
544
  apex admin customers create --name "Dogfood store" --category dogfood --domain dogfood.example.com --platform woo --owner owner@example.com --reason "Engineering dogfood account" --confirm
545
+ apex admin customers set-category 22222222-2222-4222-8222-222222222222 --category internal --reason "Engineering dev store" --confirm --json
544
546
  ```
545
547
 
546
548
  `--type` defaults to `company` (`company|agency`); `--plan` defaults to
package/dist/cli.js CHANGED
@@ -7325,7 +7325,7 @@ async function checkForUpdate(current) {
7325
7325
  // package.json
7326
7326
  var package_default = {
7327
7327
  name: "@drip-apex/apex-cli",
7328
- version: "0.15.1",
7328
+ version: "0.15.2",
7329
7329
  license: "MIT",
7330
7330
  description: "Apex CLI for managing experiments, approved roadmap ideas, and draft workspaces.",
7331
7331
  private: false,
@@ -8530,6 +8530,7 @@ function applyExperimentFilesystem(creds, id, body5) {
8530
8530
  `/api/v1/experiments/${id}/filesystem/apply`,
8531
8531
  {
8532
8532
  files: body5.files,
8533
+ ...body5.accept_qa_invalidation !== void 0 ? { accept_qa_invalidation: body5.accept_qa_invalidation } : {},
8533
8534
  ...body5.confirmTargetingReplace === true ? { confirmTargetingReplace: true } : {}
8534
8535
  }
8535
8536
  );
@@ -8746,7 +8747,7 @@ function testTargeting(creds, url, opts) {
8746
8747
  if (opts?.status) params.set("status", opts.status);
8747
8748
  return request(creds, "GET", `/api/v1/targeting/test?${params.toString()}`);
8748
8749
  }
8749
- function createSos(creds, body5) {
8750
+ function createSos(creds, body5, options = {}) {
8750
8751
  const idempotency_key = body5.idempotency_key ?? (0, import_node_crypto2.createHash)("sha256").update(
8751
8752
  [
8752
8753
  body5.type,
@@ -8754,11 +8755,29 @@ function createSos(creds, body5) {
8754
8755
  body5.description,
8755
8756
  body5.sub_class ?? "",
8756
8757
  body5.loom_url ?? "",
8758
+ ...options.attachmentDigests ?? [],
8757
8759
  String(Math.floor(Date.now() / 36e5))
8758
8760
  ].join("\0")
8759
8761
  ).digest("hex");
8760
8762
  return request(creds, "POST", "/api/v1/sos", { ...body5, idempotency_key });
8761
8763
  }
8764
+ function createSosUploadGrants(creds, files) {
8765
+ return request(
8766
+ creds,
8767
+ "POST",
8768
+ "/api/v1/sos/attachment-uploads",
8769
+ { files }
8770
+ );
8771
+ }
8772
+ async function uploadSosAttachment(signedUrl, contentType, bytes) {
8773
+ const response = await fetch(signedUrl, {
8774
+ method: "PUT",
8775
+ headers: { "Content-Type": contentType },
8776
+ body: bytes,
8777
+ signal: AbortSignal.timeout(12e4)
8778
+ });
8779
+ if (!response.ok) throw new Error(`Attachment upload failed (${response.status})`);
8780
+ }
8762
8781
  function listMySos(creds) {
8763
8782
  return request(creds, "GET", "/api/v1/sos/mine");
8764
8783
  }
@@ -8869,6 +8888,9 @@ function createAdminCustomer(creds, body5) {
8869
8888
  body5
8870
8889
  );
8871
8890
  }
8891
+ function setAdminCustomerCategory(creds, orgId, body5) {
8892
+ return request(userCredentials(creds), "PATCH", `/api/v1/admin/customers/${orgId}`, body5);
8893
+ }
8872
8894
  function getAdminSettings(creds) {
8873
8895
  return request(userCredentials(creds), "GET", "/api/v1/admin/settings");
8874
8896
  }
@@ -10507,14 +10529,31 @@ function printFilesystemPlan(plan2) {
10507
10529
  }
10508
10530
  console.log();
10509
10531
  console.log(`QA invalidated: ${plan2.invalidatesQa ? source_default.yellow("yes") : "no"}`);
10510
- console.log(`Acknowledgment: ${plan2.invalidatesQa ? "--accept-qa-invalidation" : "none"}`);
10532
+ console.log(
10533
+ `QA approval: ${!plan2.qaApproval ? "unknown (older server)" : plan2.qaApproval.present ? `present${plan2.qaApproval.approvedAt ? ` (approved ${plan2.qaApproval.approvedAt})` : ""}` : "none"}`
10534
+ );
10535
+ console.log(`Acknowledgment: ${requiredQaAcknowledgment(plan2) ?? "none"}`);
10511
10536
  console.log(`Precompile: ${plan2.precompileRequired ? "yes" : "no"}`);
10512
10537
  }
10538
+ function requiredQaAcknowledgment(plan2) {
10539
+ return "requiredAcknowledgment" in plan2 ? plan2.requiredAcknowledgment ?? null : plan2.invalidatesQa ? "accept-qa-invalidation" : null;
10540
+ }
10513
10541
  function blockingIssueSummary(issues) {
10514
10542
  if (!issues?.length) return "Experiment filesystem plan has errors.";
10515
10543
  const remaining = issues.length - 1;
10516
10544
  return `Experiment filesystem plan has errors. ${issues[0].message}${remaining ? ` and ${remaining} more` : ""}`;
10517
10545
  }
10546
+ function qaApprovalRefusal(approvedAt, folderArg) {
10547
+ return {
10548
+ message: `Applying these changes revokes the QA approval from ${approvedAt ?? "an unspecified time"}; pass --accept-qa-invalidation to confirm.`,
10549
+ details: {
10550
+ reason: "qa_approval_present",
10551
+ qa_approved_at: approvedAt,
10552
+ required_acknowledgment: "accept-qa-invalidation",
10553
+ retry: `apex experiments apply ${folderArg} --accept-qa-invalidation`
10554
+ }
10555
+ };
10556
+ }
10518
10557
  async function getQaPreviewUrls(creds, experimentId) {
10519
10558
  const fallbackDashboardUrl = buildDashboardUrl(
10520
10559
  creds.baseUrl,
@@ -11890,7 +11929,7 @@ var planFilesystem = new Command("plan").description("Plan experiment filesystem
11890
11929
  folder,
11891
11930
  experimentId: session.experimentId,
11892
11931
  plan: res.data,
11893
- requiredAcknowledgment: res.data.invalidatesQa ? "accept-qa-invalidation" : null
11932
+ requiredAcknowledgment: requiredQaAcknowledgment(res.data)
11894
11933
  },
11895
11934
  () => printFilesystemPlan(res.data)
11896
11935
  );
@@ -11902,7 +11941,10 @@ var planFilesystem = new Command("plan").description("Plan experiment filesystem
11902
11941
  var applyFilesystem = new Command("apply").option(
11903
11942
  "--confirm-targeting-replace",
11904
11943
  "Confirm that attaching a Page may replace URL targeting authored through the API"
11905
- ).option("--sync", "Refresh local authored files with the stored server bytes").description("Apply experiment filesystem changes to Apex").argument("[folder]", "Experiment filesystem folder", ".").option("--accept-qa-invalidation", "Acknowledge that applying changes invalidates QA approval").addOption(new Option("-f, --force", "Deprecated alias for --accept-qa-invalidation").hideHelp()).option("--no-asset-upload", "Keep inline and local image references unchanged").action(async function action15(folderArg, opts) {
11944
+ ).option("--sync", "Refresh local authored files with the stored server bytes").description("Apply experiment filesystem changes to Apex").argument("[folder]", "Experiment filesystem folder", ".").option(
11945
+ "--accept-qa-invalidation",
11946
+ "Confirm an apply that revokes an existing QA approval (required only when the experiment holds one)"
11947
+ ).addOption(new Option("-f, --force", "Deprecated alias for --accept-qa-invalidation").hideHelp()).option("--no-asset-upload", "Keep inline and local image references unchanged").action(async function action15(folderArg, opts) {
11906
11948
  const creds = requireCredentials();
11907
11949
  try {
11908
11950
  const folder = folderPath(folderArg);
@@ -11949,7 +11991,7 @@ var applyFilesystem = new Command("apply").option(
11949
11991
  why: "Retry with hosted asset upload."
11950
11992
  }
11951
11993
  ] : [],
11952
- requiredAcknowledgment: planRes.data.invalidatesQa ? "accept-qa-invalidation" : null
11994
+ requiredAcknowledgment: requiredQaAcknowledgment(planRes.data)
11953
11995
  });
11954
11996
  }
11955
11997
  if (planRes.data.changes.length === 0) {
@@ -11966,7 +12008,7 @@ var applyFilesystem = new Command("apply").option(
11966
12008
  synced: synced2,
11967
12009
  dashboardUrl: dashboardUrl2,
11968
12010
  plan: planRes.data,
11969
- requiredAcknowledgment: planRes.data.invalidatesQa ? "accept-qa-invalidation" : null,
12011
+ requiredAcknowledgment: requiredQaAcknowledgment(planRes.data),
11970
12012
  message: "No changes."
11971
12013
  },
11972
12014
  () => {
@@ -11978,23 +12020,23 @@ var applyFilesystem = new Command("apply").option(
11978
12020
  );
11979
12021
  return;
11980
12022
  }
11981
- if (planRes.data.invalidatesQa && !opts.force && !opts.acceptQaInvalidation) {
11982
- const ok = await confirmApply("This apply invalidates QA approval. Continue?");
12023
+ let acceptQaInvalidation = Boolean(opts.force || opts.acceptQaInvalidation);
12024
+ if (requiredQaAcknowledgment(planRes.data) && !acceptQaInvalidation) {
12025
+ const refusal = qaApprovalRefusal(planRes.data.qaApproval?.approvedAt ?? null, folderArg);
12026
+ const ok = await confirmApply(`${refusal.message} Continue?`);
11983
12027
  if (ok === null) {
11984
- confirmationRequired(
11985
- this,
11986
- "Applying these changes invalidates QA approval and requires confirmation.",
11987
- `apex experiments apply ${folderArg} --accept-qa-invalidation`
11988
- );
12028
+ fail(this, refusal.message, EXIT_CODES.confirmationRequired, refusal.details);
11989
12029
  }
11990
12030
  if (!ok) {
11991
12031
  output(this, { aborted: true }, () => console.log("Aborted."));
11992
12032
  return;
11993
12033
  }
12034
+ acceptQaInvalidation = true;
11994
12035
  }
11995
12036
  const spinner = process.stderr.isTTY ? await createSpinner("Applying experiment filesystem...") : null;
11996
12037
  let res = await applyExperimentFilesystem(creds, session.experimentId, {
11997
12038
  files,
12039
+ accept_qa_invalidation: acceptQaInvalidation,
11998
12040
  ...opts.confirmTargetingReplace ? { confirmTargetingReplace: true } : {}
11999
12041
  });
12000
12042
  if (!res.ok && res.status === 409 && res.errorDetails?.code === "confirm_targeting_replace_required" && !opts.confirmTargetingReplace) {
@@ -12015,9 +12057,19 @@ var applyFilesystem = new Command("apply").option(
12015
12057
  }
12016
12058
  res = await applyExperimentFilesystem(creds, session.experimentId, {
12017
12059
  files,
12060
+ accept_qa_invalidation: acceptQaInvalidation,
12018
12061
  confirmTargetingReplace: true
12019
12062
  });
12020
12063
  }
12064
+ if (!res.ok && res.status === 409 && res.errorDetails?.code === "qa_acknowledgment_required") {
12065
+ spinner?.stop();
12066
+ const approvedAt = res.errorDetails.qa_approved_at;
12067
+ const refusal = qaApprovalRefusal(
12068
+ typeof approvedAt === "string" ? approvedAt : null,
12069
+ folderArg
12070
+ );
12071
+ fail(this, refusal.message, EXIT_CODES.confirmationRequired, refusal.details);
12072
+ }
12021
12073
  if (!res.ok || !res.data?.applied) {
12022
12074
  spinner?.fail("Failed to apply experiment filesystem");
12023
12075
  fail(this, res.error ?? "Apply failed", classifyStatus(res.status), {
@@ -12056,7 +12108,7 @@ var applyFilesystem = new Command("apply").option(
12056
12108
  experimentId: session.experimentId,
12057
12109
  applied: true,
12058
12110
  plan: planRes.data,
12059
- requiredAcknowledgment: planRes.data.invalidatesQa ? "accept-qa-invalidation" : null,
12111
+ requiredAcknowledgment: requiredQaAcknowledgment(planRes.data),
12060
12112
  result: res.data,
12061
12113
  synced,
12062
12114
  dashboardUrl,
@@ -13057,6 +13109,25 @@ var pushCommand = new Command("push").description("Push experiment definitions f
13057
13109
 
13058
13110
  // src/commands/preview.ts
13059
13111
  init_source();
13112
+ async function signedPreviewFromReadiness(creds, experimentId, variationId, targetUrl) {
13113
+ try {
13114
+ const client = createHttpClient(creds, {
13115
+ onNetworkError: (info) => {
13116
+ throw new Error(info.cause);
13117
+ }
13118
+ });
13119
+ const query = new URLSearchParams({ variation_id: variationId, target_url: targetUrl });
13120
+ const response = await client.request(
13121
+ "GET",
13122
+ `/api/v1/experiments/${encodeURIComponent(experimentId)}/readiness?${query}`,
13123
+ void 0,
13124
+ 5e3
13125
+ );
13126
+ return response.ok ? response.data?.data : void 0;
13127
+ } catch {
13128
+ return void 0;
13129
+ }
13130
+ }
13060
13131
  var previewCommand = new Command("preview").description(
13061
13132
  "Open QA preview URL for an experiment variation; proxy previews return a QA run receipt"
13062
13133
  ).argument("<id-or-slug>", "Experiment ID or slug").argument("[variation-id]", "Variation ID (defaults to first non-control)").option("--store-url <url>", "Override experiment store_url (target website URL)").option("--proxy", "Create an artifact-injecting proxy preview").option("--ttl <hours>", "Proxy preview lifetime in hours (1-168)").option("--no-open", "Print URL instead of opening browser").action(
@@ -13168,7 +13239,7 @@ var previewCommand = new Command("preview").description(
13168
13239
  variation = exp.variations.find((v) => !v.is_control) ?? exp.variations[0];
13169
13240
  }
13170
13241
  spinner.stop();
13171
- const storeUrl = opts?.storeUrl ?? exp.preview_target?.url ?? exp.store_url;
13242
+ const storeUrl = opts?.storeUrl ?? (!variation.is_control && variation.redirect_url?.trim() ? variation.redirect_url : null) ?? exp.preview_target?.url ?? exp.store_url;
13172
13243
  if (!storeUrl) {
13173
13244
  fail(
13174
13245
  previewCommand,
@@ -13184,12 +13255,18 @@ var previewCommand = new Command("preview").description(
13184
13255
  'Invalid store URL. Pass an absolute URL like "https://example.com/products/item".'
13185
13256
  );
13186
13257
  }
13258
+ const signed = await signedPreviewFromReadiness(creds, exp.id, variation.id, storeUrl);
13259
+ const selectedPreviewUrl = signed?.signed_preview_url ?? previewUrl.toString();
13260
+ const warning = signed?.signed_preview_url ? null : "Test images may not load on this unsigned link. Open Apex \u2192 QA for a signed link.";
13261
+ if (warning) console.error(warning);
13187
13262
  output(
13188
13263
  previewCommand,
13189
13264
  {
13190
13265
  experimentId: exp.id,
13191
13266
  variationId: variation.id,
13192
- previewUrl: previewUrl.toString(),
13267
+ previewUrl: selectedPreviewUrl,
13268
+ expiresAt: signed?.signed_preview_expires_at ?? null,
13269
+ ...warning ? { warning } : {},
13193
13270
  ...!opts?.storeUrl && exp.preview_target ? { previewTarget: exp.preview_target } : {}
13194
13271
  },
13195
13272
  () => {
@@ -13205,14 +13282,16 @@ var previewCommand = new Command("preview").description(
13205
13282
  }
13206
13283
  }
13207
13284
  console.log();
13208
- console.log(source_default.cyan(previewUrl.toString()));
13285
+ console.log(source_default.cyan(selectedPreviewUrl));
13286
+ if (signed?.signed_preview_expires_at)
13287
+ console.log(`Valid until ${signed.signed_preview_expires_at}.`);
13209
13288
  console.log();
13210
13289
  }
13211
13290
  );
13212
13291
  if (opts?.open !== false && !shouldUseJson(previewCommand)) {
13213
13292
  try {
13214
13293
  const open3 = (await Promise.resolve().then(() => (init_open(), open_exports))).default;
13215
- await open3(previewUrl.toString());
13294
+ await open3(selectedPreviewUrl);
13216
13295
  console.log(source_default.dim("Opened in browser."));
13217
13296
  } catch {
13218
13297
  console.log(source_default.dim("Copy the URL above to preview."));
@@ -14199,9 +14278,7 @@ function commonCookieDomain(hostnames) {
14199
14278
  const suffix = suffixes[0];
14200
14279
  return suffix && suffixes.every((candidate) => candidate === suffix) ? `.${suffix}` : null;
14201
14280
  }
14202
- function defaultManifestPath() {
14203
- return import_node_path11.default.resolve(process.cwd(), ".apex", "tracking-manifest.json");
14204
- }
14281
+ var DEFAULT_MANIFEST_PATH = ".apex/tracking-manifest.json";
14205
14282
  function readManifest(filePath) {
14206
14283
  if (!import_node_fs13.default.existsSync(filePath)) throw new Error(`Manifest not found: ${filePath}`);
14207
14284
  const parsed = JSON.parse(import_node_fs13.default.readFileSync(filePath, "utf8"));
@@ -14277,7 +14354,7 @@ function buildManifest(urlArgs, opts) {
14277
14354
  };
14278
14355
  }
14279
14356
  function manifestPath(input) {
14280
- return import_node_path11.default.resolve(process.cwd(), input ?? defaultManifestPath());
14357
+ return import_node_path11.default.resolve(process.cwd(), input ?? DEFAULT_MANIFEST_PATH);
14281
14358
  }
14282
14359
  function printManifestPlan(manifest) {
14283
14360
  console.log(source_default.bold("Apex tracking ingestion plan"));
@@ -14305,7 +14382,11 @@ function printManifestPlan(manifest) {
14305
14382
  var trackingCommand = new Command("tracking").description(
14306
14383
  "Create Apex-first tracking ingestion manifests and QA handoffs"
14307
14384
  );
14308
- trackingCommand.command("inspect").description("Create a local tracking manifest from funnel URLs").argument("<urls...>", "Landing, storefront, checkout, or confirmation URLs").option("--shop-id <id>", "Apex shop ID for snippet generation").option("--endpoint <url>", "Apex Worker endpoint", "https://events.drip-apex.com").option("--out <path>", "Manifest output path", defaultManifestPath()).action(function(urls, opts) {
14385
+ trackingCommand.command("inspect").description("Create a local tracking manifest from funnel URLs").argument("<urls...>", "Landing, storefront, checkout, or confirmation URLs").option("--shop-id <id>", "Apex shop ID for snippet generation").option("--endpoint <url>", "Apex Worker endpoint", "https://events.drip-apex.com").option(
14386
+ "--out <path>",
14387
+ "Manifest output path, relative to the current directory",
14388
+ DEFAULT_MANIFEST_PATH
14389
+ ).action(function(urls, opts) {
14309
14390
  const manifest = buildManifest(urls, opts);
14310
14391
  const outPath = manifestPath(opts.out);
14311
14392
  writeManifest(outPath, manifest);
@@ -14321,12 +14402,20 @@ trackingCommand.command("inspect").description("Create a local tracking manifest
14321
14402
  }
14322
14403
  });
14323
14404
  });
14324
- trackingCommand.command("plan").description("Print the implementation plan from a tracking manifest").argument("[manifest]", "Tracking manifest path", defaultManifestPath()).action(function(input) {
14405
+ trackingCommand.command("plan").description("Print the implementation plan from a tracking manifest").argument(
14406
+ "[manifest]",
14407
+ "Tracking manifest path, relative to the current directory",
14408
+ DEFAULT_MANIFEST_PATH
14409
+ ).action(function(input) {
14325
14410
  const filePath = manifestPath(input);
14326
14411
  const manifest = readManifest(filePath);
14327
14412
  output(this, { path: filePath, manifest }, () => printManifestPlan(manifest));
14328
14413
  });
14329
- trackingCommand.command("qa").description("Print the QA checklist for a tracking manifest").argument("[manifest]", "Tracking manifest path", defaultManifestPath()).action(function(input) {
14414
+ trackingCommand.command("qa").description("Print the QA checklist for a tracking manifest").argument(
14415
+ "[manifest]",
14416
+ "Tracking manifest path, relative to the current directory",
14417
+ DEFAULT_MANIFEST_PATH
14418
+ ).action(function(input) {
14330
14419
  const filePath = manifestPath(input);
14331
14420
  const manifest = readManifest(filePath);
14332
14421
  output(this, { path: filePath, checks: manifest.qa.checks }, () => {
@@ -18540,16 +18629,21 @@ async function checkServedConfig(configUrl, workerBaseUrl, workerBaseSource) {
18540
18629
  message: "Worker served a recognized config document for this shop."
18541
18630
  };
18542
18631
  }
18543
- var installCommand = new Command("install").description("Check Apex storefront installation").command("check").description("Verify snippet, served config, and recent tracking for the current shop").option("--url <url>", "Check one explicit bound storefront URL").option("--shop-id <id>", "Apex shop ID to verify (falls back to DRIP_SHOP_ID)").option(
18632
+ var installCommand = new Command("install").description("Check Apex storefront installation").command("check").description(
18633
+ "Verify snippet, served config, and recent tracking using --shop-id, the selected shop, or DRIP_SHOP_ID"
18634
+ ).option("--url <url>", "Check one explicit bound storefront URL").option(
18635
+ "--shop-id <id>",
18636
+ "Apex shop ID to verify (falls back to --shop/apex use, then DRIP_SHOP_ID)"
18637
+ ).option(
18544
18638
  "--worker-url <url>",
18545
18639
  "Apex Worker base URL that serves /config (defaults to the URL in the served snippet, then DRIP_WORKER_URL, then the production worker)"
18546
18640
  ).option("--environment <key>", "Environment key to request from the worker", "production").action(async function action65(opts) {
18547
18641
  const creds = requireCredentials();
18548
- const shopId = opts.shopId?.trim() || process.env.DRIP_SHOP_ID?.trim() || null;
18642
+ const shopId = opts.shopId?.trim() || resolveContext(creds).shopId?.trim() || process.env.DRIP_SHOP_ID?.trim() || null;
18549
18643
  if (!shopId)
18550
18644
  fail(
18551
18645
  this,
18552
- "Shop ID is required: pass --shop-id <id> or set DRIP_SHOP_ID",
18646
+ "Shop ID is required: pass --shop-id <id>, select a shop with --shop <id> or apex use, or set DRIP_SHOP_ID",
18553
18647
  EXIT_CODES.validation,
18554
18648
  { field: "shop-id" }
18555
18649
  );
@@ -19589,6 +19683,7 @@ var test2 = new Command("test").description("Evaluate experiment URL targeting f
19589
19683
  var targetingCommand = new Command("targeting").description("Test experiment targeting").addCommand(test2);
19590
19684
 
19591
19685
  // src/commands/sos.ts
19686
+ var import_node_crypto7 = require("node:crypto");
19592
19687
  init_source();
19593
19688
  var import_node_fs24 = __toESM(require("node:fs"));
19594
19689
  var import_node_path21 = __toESM(require("node:path"));
@@ -19630,6 +19725,11 @@ var create4 = new Command("create").description("Create an Apex SOS ticket").opt
19630
19725
  "--type <type>",
19631
19726
  "full_handover|design|development|qa|config|bug|help (internal types require a DRIP account)",
19632
19727
  "qa"
19728
+ ).option(
19729
+ "--attach <file>",
19730
+ "Attach an image or video (repeatable, maximum 5)",
19731
+ (file, files) => [...files, file],
19732
+ []
19633
19733
  ).option("--loom <url>", "Loom URL with reproduction or QA context").option("--experiment <id>", "Experiment ID").option("--backlog-item <id>", "Backlog item ID (internal SOS only)").option("--sub-class <text>", "SOS sub-classification").option("--description <text-or-@file>", "Description text or @path").argument("[description]", "Description text").action(async function action68(descriptionArg, opts) {
19634
19734
  if (!SOS_TYPES.has(opts.type)) {
19635
19735
  fail(
@@ -19641,16 +19741,76 @@ var create4 = new Command("create").description("Create an Apex SOS ticket").opt
19641
19741
  if (opts.experiment && opts.backlogItem) {
19642
19742
  fail(this, "Provide only one of --experiment or --backlog-item", 3);
19643
19743
  }
19744
+ const mediaTypes = {
19745
+ ".png": "image/png",
19746
+ ".jpg": "image/jpeg",
19747
+ ".jpeg": "image/jpeg",
19748
+ ".webp": "image/webp",
19749
+ ".gif": "image/gif",
19750
+ ".mp4": "video/mp4",
19751
+ ".webm": "video/webm",
19752
+ ".mov": "video/quicktime"
19753
+ };
19754
+ if (opts.attach.length > 5) fail(this, "Attach no more than 5 files", 3);
19755
+ const files = opts.attach.map((file) => {
19756
+ const filename = import_node_path21.default.basename(file);
19757
+ const content_type = mediaTypes[import_node_path21.default.extname(file).toLowerCase()];
19758
+ if (!content_type) fail(this, "Unsupported attachment type", 3);
19759
+ let size;
19760
+ try {
19761
+ size = import_node_fs24.default.statSync(file).size;
19762
+ } catch {
19763
+ fail(this, `Attachment not found: ${file}`, 3);
19764
+ }
19765
+ const cap = (content_type.startsWith("video/") ? 50 : 10) * 1024 * 1024;
19766
+ if (size <= 0 || size > cap) fail(this, "Attachment exceeds its size limit or is empty", 3);
19767
+ let bytes;
19768
+ try {
19769
+ bytes = import_node_fs24.default.readFileSync(file);
19770
+ } catch {
19771
+ fail(this, `Attachment not found: ${file}`, 3);
19772
+ }
19773
+ return { filename, content_type, size, bytes };
19774
+ });
19644
19775
  const creds = requireCredentials();
19776
+ const attachmentPaths = [];
19777
+ const attachmentDigests = [];
19778
+ if (files.length) {
19779
+ const grants = await createSosUploadGrants(
19780
+ creds,
19781
+ files.map(({ filename, content_type, size }) => ({ filename, content_type, size }))
19782
+ );
19783
+ if (!grants.ok || !grants.data)
19784
+ fail(
19785
+ this,
19786
+ grants.error ?? "Failed to get upload grants",
19787
+ classifyStatus(grants.status),
19788
+ grants.errorDetails
19789
+ );
19790
+ if (grants.data.data.uploads.length !== files.length)
19791
+ fail(this, "Invalid upload grant response", 1);
19792
+ for (const [index, file] of files.entries()) {
19793
+ const grant = grants.data.data.uploads[index];
19794
+ const bytes = file.bytes;
19795
+ await uploadSosAttachment(grant.signed_url, file.content_type, bytes);
19796
+ attachmentPaths.push(grant.path);
19797
+ attachmentDigests.push((0, import_node_crypto7.createHash)("sha256").update(bytes).digest("hex"));
19798
+ }
19799
+ }
19645
19800
  const description = readText(opts.description) ?? descriptionArg ?? `Apex CLI ${opts.type} SOS request${opts.loom ? ` with Loom evidence: ${opts.loom}` : "."}`;
19646
- const res = await createSos(creds, {
19647
- type: opts.type,
19648
- description,
19649
- experiment_id: opts.experiment ?? null,
19650
- backlog_item_id: opts.backlogItem ?? null,
19651
- sub_class: opts.subClass ?? null,
19652
- loom_url: opts.loom ?? null
19653
- });
19801
+ const res = await createSos(
19802
+ creds,
19803
+ {
19804
+ ...attachmentPaths.length ? { attachment_paths: attachmentPaths } : {},
19805
+ type: opts.type,
19806
+ description,
19807
+ experiment_id: opts.experiment ?? null,
19808
+ backlog_item_id: opts.backlogItem ?? null,
19809
+ sub_class: opts.subClass ?? null,
19810
+ loom_url: opts.loom ?? null
19811
+ },
19812
+ { attachmentDigests }
19813
+ );
19654
19814
  if (!res.ok || !res.data) {
19655
19815
  fail(
19656
19816
  this,
@@ -19955,6 +20115,7 @@ function optionSchema(option) {
19955
20115
  description: option.description,
19956
20116
  required: option.required,
19957
20117
  optional: option.optional,
20118
+ mandatory: Boolean(option.mandatory),
19958
20119
  variadic: option.variadic,
19959
20120
  ...option.defaultValue === void 0 ? {} : { defaultValue: option.defaultValue }
19960
20121
  };
@@ -19977,8 +20138,16 @@ function commandSchema(command) {
19977
20138
  };
19978
20139
  }
19979
20140
  function createSchemaCommand(program3) {
19980
- return new Command("schema").description("Print the current Apex CLI command and flag schema").option("--json", "Print JSON schema").action(function action78() {
19981
- const schema = commandSchema(program3);
20141
+ return new Command("schema").description("Print the current Apex CLI command, flag, and mandatory-option schema").option("--json", "Print JSON schema").action(function action78() {
20142
+ const schema = {
20143
+ schemaVersion: 2,
20144
+ contract: {
20145
+ optionValueArity: "Options' required and optional fields describe whether the option's value is required or optional when the flag is given.",
20146
+ mandatoryOptions: "An option's mandatory field is true only when the flag itself must be passed.",
20147
+ requiredArguments: "An argument's required field means the positional is mandatory."
20148
+ },
20149
+ ...commandSchema(program3)
20150
+ };
19982
20151
  output(this, schema, () => {
19983
20152
  process.stdout.write(`${JSON.stringify(schema, null, 2)}
19984
20153
  `);
@@ -20619,6 +20788,7 @@ var skillsCommand = new Command("skills").description("Print or install the Apex
20619
20788
  });
20620
20789
 
20621
20790
  // src/commands/admin.ts
20791
+ var uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
20622
20792
  function switchValue(value) {
20623
20793
  if (value === "on") return true;
20624
20794
  if (value === "off") return false;
@@ -20745,7 +20915,6 @@ var create6 = new Command("create").description("Create an internal, demo or dog
20745
20915
  fail(this, "--name must be 1 to 60 characters", 3);
20746
20916
  if (opts.reason.trim().length < 8 || opts.reason.trim().length > 500)
20747
20917
  fail(this, "--reason must be 8 to 500 characters", 3);
20748
- const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
20749
20918
  if (opts.claimShop && !uuid.test(opts.claimShop)) fail(this, "--claim-shop must be a UUID", 3);
20750
20919
  if (opts.claimShop && (opts.domain || opts.platform))
20751
20920
  fail(this, "Use --claim-shop or --domain with --platform", 3);
@@ -20831,7 +21000,39 @@ var create6 = new Command("create").description("Create an internal, demo or dog
20831
21000
  );
20832
21001
  });
20833
21002
  });
20834
- var customers = new Command("customers").description("Create engineering accounts; customer accounts use Admin, Links").addCommand(create6);
21003
+ var setCategory = new Command("set-category").description("Change an existing account's category with an audit reason").argument("<org-id>", "Organization UUID").requiredOption("--category <category>", "internal, demo, dogfood or customer").requiredOption("--reason <text>", "Audit reason, 8 to 500 characters").option("--confirm", "Confirm category change").option("--json", "Output JSON").action(async function(orgId, opts) {
21004
+ if (!uuid.test(orgId)) fail(this, "<org-id> must be a UUID", 3);
21005
+ if (!["internal", "demo", "dogfood", "customer"].includes(opts.category))
21006
+ fail(this, "--category must be internal, demo, dogfood or customer", 3);
21007
+ const reason2 = opts.reason.trim();
21008
+ if (reason2.length < 8 || reason2.length > 500)
21009
+ fail(this, "--reason must be 8 to 500 characters", 3);
21010
+ if (!opts.confirm) {
21011
+ const quote = (value) => `'${value.replaceAll("'", "'\\''")}'`;
21012
+ confirmationRequired(
21013
+ this,
21014
+ "--confirm is required to change an account category",
21015
+ `apex admin customers set-category ${orgId} --category ${quote(opts.category)} --reason ${quote(reason2)} --confirm`
21016
+ );
21017
+ }
21018
+ const body5 = {
21019
+ category: opts.category,
21020
+ reason: reason2
21021
+ };
21022
+ const res = await setAdminCustomerCategory(requireUserCredentials(), orgId, body5);
21023
+ if (!res.ok || !res.data)
21024
+ fail(
21025
+ this,
21026
+ res.error ?? "Failed to change account category",
21027
+ classifyStatus(res.status),
21028
+ res.errorDetails
21029
+ );
21030
+ output(this, res.data, () => {
21031
+ const result = res.data.data;
21032
+ printTable(["RESULT"], [[`Customer ${result.customerId} moved to ${result.category}`]]);
21033
+ });
21034
+ });
21035
+ var customers = new Command("customers").description("Create engineering accounts and manage existing account categories").addCommand(create6).addCommand(setCategory);
20835
21036
  var adminCommand = new Command("admin").description("Operate allowlisted platform controls").addCommand(settingsGet).addCommand(quarantine).addCommand(shops).addCommand(customers);
20836
21037
 
20837
21038
  // src/commands/membership.ts
@@ -21568,7 +21769,7 @@ var import_node_fs29 = __toESM(require("node:fs"));
21568
21769
  var import_node_path26 = __toESM(require("node:path"));
21569
21770
 
21570
21771
  // src/commands/landing-pages.ts
21571
- var import_node_crypto7 = __toESM(require("node:crypto"));
21772
+ var import_node_crypto8 = __toESM(require("node:crypto"));
21572
21773
  var import_node_fs28 = __toESM(require("node:fs"));
21573
21774
  var import_node_path25 = __toESM(require("node:path"));
21574
21775
  function apiFailure(command, response, fallback2) {
@@ -21582,7 +21783,7 @@ function apiFailure(command, response, fallback2) {
21582
21783
  }
21583
21784
  }
21584
21785
  function sha256(body5) {
21585
- return import_node_crypto7.default.createHash("sha256").update(body5).digest("hex");
21786
+ return import_node_crypto8.default.createHash("sha256").update(body5).digest("hex");
21586
21787
  }
21587
21788
  function markerPath(folder) {
21588
21789
  return import_node_path25.default.join(folder, LANDING_PAGE_CHECKOUT_MARKER);
@@ -21910,7 +22111,10 @@ async function pullCheckout(command, creds, id, folder, force = false) {
21910
22111
  notes: listing.data.notes ?? []
21911
22112
  };
21912
22113
  }
21913
- var create9 = new Command("create").description("Create a landing page to build outside Apex").requiredOption("--name <name>", "Page name").option("--slug <slug>", "URL slug (default: from the name)").option("--idea <key>", "Idea key or id the page is built for").option("--out <folder>", "Also pull the new page into this folder").action(async function(opts) {
22114
+ var create9 = new Command("create").description("Create a landing page to build outside Apex").requiredOption("--name <name>", "Page name").option(
22115
+ "--slug <slug>",
22116
+ "URL slug (default: from the name; -2, -3 added only if the shop already uses it)"
22117
+ ).option("--idea <key>", "Idea key or id the page is built for").option("--out <folder>", "Also pull the new page into this folder").action(async function(opts) {
21914
22118
  const creds = requireCredentials();
21915
22119
  const created = await landingPages(creds).create({
21916
22120
  name: opts.name,
@@ -22405,6 +22609,35 @@ program2.hook("preAction", (_thisCommand, actionCommand) => {
22405
22609
  process.on("beforeExit", () => {
22406
22610
  flushFallbackEnvelope();
22407
22611
  });
22408
- maybeCheckForUpdate().then(() => program2.parseAsync(process.argv)).then(async () => {
22612
+ function isExplicitJsonVersionRequest(args) {
22613
+ let hasJson = false;
22614
+ let hasVersion = false;
22615
+ for (let index = 0; index < args.length; index++) {
22616
+ const arg = args[index];
22617
+ if (arg === "--") break;
22618
+ if (arg === "--json") {
22619
+ hasJson = true;
22620
+ } else if (arg === "--version" || arg === "-V") {
22621
+ hasVersion = true;
22622
+ } else if (arg === "--org" || arg === "--shop") {
22623
+ const value = args[++index];
22624
+ if (!value || value.startsWith("-")) return false;
22625
+ } else if (arg.startsWith("--org=") || arg.startsWith("--shop=")) {
22626
+ const value = arg.slice(arg.indexOf("=") + 1);
22627
+ if (!value || value.startsWith("-")) return false;
22628
+ } else {
22629
+ return false;
22630
+ }
22631
+ }
22632
+ return hasJson && hasVersion;
22633
+ }
22634
+ var explicitJsonVersionRequest = isExplicitJsonVersionRequest(process.argv.slice(2));
22635
+ maybeCheckForUpdate().then(() => {
22636
+ if (explicitJsonVersionRequest) {
22637
+ writeEnvelope({ name: "@drip-apex/apex-cli", version: CLI_VERSION }, "version");
22638
+ return;
22639
+ }
22640
+ return program2.parseAsync(process.argv);
22641
+ }).then(async () => {
22409
22642
  await finishInvocation({ exitCode: process.exitCode ?? 0 });
22410
22643
  }).catch((err) => handleCliError(err, program2));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drip-apex/apex-cli",
3
- "version": "0.15.1",
3
+ "version": "0.15.2",
4
4
  "license": "MIT",
5
5
  "description": "Apex CLI for managing experiments, approved roadmap ideas, and draft workspaces.",
6
6
  "private": false,