@drip-apex/apex-cli 0.15.0 → 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 +35 -4
  2. package/dist/cli.js +484 -55
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -227,6 +227,8 @@ 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`. |
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`. |
230
232
  | `apex admin shops archive <shopId>` | Archive a shop and disable Apex delivery; use `--force` in non-interactive environments. |
231
233
  | `apex admin shops restore <shopId>` | Restore an archived shop while leaving its runtime kill switch unchanged. |
232
234
 
@@ -240,18 +242,19 @@ apex landing-pages create --name "Fall sale" --out ./fall-sale # or: pull <id>
240
242
  # edit ./fall-sale/index.html and its assets; context/ holds brand, catalog, offers and reviews
241
243
  apex landing-pages plan ./fall-sale # what would change
242
244
  apex landing-pages apply ./fall-sale --yes # new version + signed preview link (24 hours)
243
- apex landing-pages deploy ./fall-sale # live on the claimed host in apex-landing-page.json
245
+ apex landing-pages deploy ./fall-sale # live on <shop>.apex-page.com, or the host in apex-landing-page.json
244
246
  apex landing-pages rollback ./fall-sale --version 1
245
247
  apex landing-pages experiment ./fall-sale --control 1 --variant 2 # draft split; start it in the dashboard
246
248
  ```
247
249
 
248
250
  The folder holds the page files (index.html at the root, at most 300 files
249
- and 25 MB; html, css, js, json, svg, images, fonts, txt, xml, webmanifest),
251
+ and 100 MB; html, css, js, json, svg, images, fonts, txt, xml, webmanifest, mp4 and webm),
250
252
  `apex-landing-page.json` and the `context/` folder (each pull replaces its JSON
251
253
  files with the server's current set). Build with relative asset
252
254
  URLs (Vite `base: "./"`). `apex-landing-page.json` sets the name, slug, an
253
- claimed `host` (deploy needs one, or `--host`: claim it with
254
- `apex domains claim <host>`; Apex has no shared pages host yet), the `path`, whether Apex injects the shop snippet
255
+ optional `host`, a domain claimed with `apex domains claim <host>` (without
256
+ one, or `--host`, a deploy goes to the shop's own instant host
257
+ `<shop>.apex-page.com`), the `path` (default `/<slug>`), whether Apex injects the shop snippet
255
258
  (`snippet`, on by default), the `shop_hosts` that receive the visitor's identity
256
259
  on outbound links (default: the shop's install domains), the `goals`
257
260
  (key, name, type click, pageview or custom) and an optional `experiment`
@@ -522,3 +525,31 @@ are sent. Login/logout, tests, the mock transport, and runs with no resolvable c
522
525
  (`APEX_API_KEY` or `DRIP_API_KEY`) are recorded with credential kind `env` unless
523
526
  `APEX_TELEMETRY=0`. Set `APEX_TELEMETRY=0` to disable the beacon. The best-effort request has an
524
527
  800 ms timeout and never changes command output or exit status.
528
+
529
+ MP4 (`video/mp4`) and WebM (`video/webm`) videos are hosted directly by Apex.
530
+ Each version holds at most 300 files and 100 MB; each video is limited to 50 MB,
531
+ and other files to 3 MB. `apply` automatically obtains a 15-minute upload grant
532
+ for videos above 3 MB and sends resumable 8 MB parts straight to the Worker,
533
+ retrying failed parts with progress output. The Worker verifies the declared
534
+ size and SHA-256 before apply records the file. Pull and signed previews also
535
+ fetch large videos directly from the Worker. Video supports byte ranges,
536
+ including Safari's `bytes=0-1` probe; rollback preserves the older bytes.
537
+
538
+ Engineering account creation uses `apex login --user` credentials with `admin:write`
539
+ and a live platform role with onboarding write permission. Real customer accounts
540
+ are created in **Admin, Links**.
541
+
542
+ ```sh
543
+ apex admin customers create --name "Demo workspace" --category demo --reason "Workshop demo account" --confirm --json
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
546
+ ```
547
+
548
+ `--type` defaults to `company` (`company|agency`); `--plan` defaults to
549
+ `self_managed` (`self_managed|apex_managed|drip_managed`). Managed plans require
550
+ `--consultant <platform_access UUID>` and `--consultant-title <text>`.
551
+ An optional first store uses `--domain <host> --platform <shopify|shopware|woo|other>`
552
+ or `--claim-shop <UUID>`, exclusively. `woo` maps to `woocommerce` and `other` to
553
+ `custom`. `--owner <email>` sends an owner invitation and requires a store;
554
+ accounts without a store have no invitations. Output includes the organization ID,
555
+ store ID (or null), invitation results and partial delivery status.
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.0",
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,
@@ -8236,6 +8236,7 @@ function landingPagesApi(request2) {
8236
8236
  ),
8237
8237
  plan: (id, body5) => call("POST", pagePath(id, "filesystem/plan"), body5),
8238
8238
  upload: (id, files) => call("POST", pagePath(id, "filesystem/upload"), { files }),
8239
+ uploadGrant: (id, file) => call("POST", pagePath(id, "filesystem/upload-grant"), file),
8239
8240
  apply: (id, body5) => call("POST", pagePath(id, "filesystem/apply"), body5),
8240
8241
  deploy: (id, body5) => call("POST", pagePath(id, "deploy"), body5),
8241
8242
  versions: (id) => call("GET", pagePath(id, "versions")),
@@ -8261,6 +8262,53 @@ function landingPageBatches(items, size) {
8261
8262
  if (current.length) batches.push(current);
8262
8263
  return batches;
8263
8264
  }
8265
+ async function uploadLandingVideo(grant, body5, options = {}) {
8266
+ if (!Number.isSafeInteger(grant.part_bytes) || grant.part_bytes !== 8 * 1024 * 1024) throw new Error("Invalid video part size");
8267
+ const fetcher = options.fetch ?? fetch;
8268
+ const send3 = async (action78, method = "POST", part, bytes) => {
8269
+ const url = new URL(grant.upload_url);
8270
+ url.searchParams.set("action", action78);
8271
+ if (part !== void 0) url.searchParams.set("part", String(part));
8272
+ let error;
8273
+ for (let attempt = 0; attempt < 3; attempt++) {
8274
+ try {
8275
+ const response = await fetcher(url, { method, ...bytes ? { body: bytes } : {}, signal: AbortSignal.timeout(6e4) });
8276
+ if (!response.ok) {
8277
+ const message = await response.text();
8278
+ if (response.status < 500 && response.status !== 429) throw new Error(`Video upload refused (${response.status}): ${message}`);
8279
+ error = new Error(`Video upload failed (${response.status}): ${message}`);
8280
+ } else return await response.json();
8281
+ } catch (caught) {
8282
+ if (caught instanceof Error && caught.message.startsWith("Video upload refused")) throw caught;
8283
+ error = caught;
8284
+ }
8285
+ }
8286
+ throw error;
8287
+ };
8288
+ const started2 = await send3("start");
8289
+ if (!started2.complete) {
8290
+ for (let offset = 0, part = 1; offset < body5.byteLength; offset += grant.part_bytes, part++) {
8291
+ const bytes = body5.subarray(offset, Math.min(body5.byteLength, offset + grant.part_bytes));
8292
+ await send3("part", "PUT", part, bytes);
8293
+ options.progress?.(offset + bytes.byteLength, body5.byteLength);
8294
+ }
8295
+ const result = await send3("complete");
8296
+ if (!result.complete) throw new Error("Worker did not confirm the video upload");
8297
+ }
8298
+ }
8299
+ function validateLandingPageLocalFiles(files) {
8300
+ const allowed2 = new Set("html css js mjs json svg png jpg jpeg webp avif gif ico woff woff2 txt xml webmanifest mp4 webm".split(" "));
8301
+ if (files.length > 300) throw new Error("A bundle is limited to 300 files");
8302
+ let total = 0;
8303
+ for (const file of files) {
8304
+ const ext = file.path.split(".").at(-1)?.toLowerCase() ?? "";
8305
+ if (!allowed2.has(ext)) throw new Error(`File type is not allowed: ${file.path}`);
8306
+ const cap = ext === "mp4" || ext === "webm" ? 50 * 1024 * 1024 : LANDING_PAGE_BATCH_BYTES;
8307
+ if (!Number.isSafeInteger(file.bytes) || file.bytes < 0 || file.bytes > cap) throw new Error(`File exceeds ${cap} bytes: ${file.path}`);
8308
+ total += file.bytes;
8309
+ }
8310
+ if (total > 100 * 1024 * 1024) throw new Error("A bundle is limited to 100 MB");
8311
+ }
8264
8312
 
8265
8313
  // src/api.ts
8266
8314
  function simulateTriggers(creds, body5) {
@@ -8482,6 +8530,7 @@ function applyExperimentFilesystem(creds, id, body5) {
8482
8530
  `/api/v1/experiments/${id}/filesystem/apply`,
8483
8531
  {
8484
8532
  files: body5.files,
8533
+ ...body5.accept_qa_invalidation !== void 0 ? { accept_qa_invalidation: body5.accept_qa_invalidation } : {},
8485
8534
  ...body5.confirmTargetingReplace === true ? { confirmTargetingReplace: true } : {}
8486
8535
  }
8487
8536
  );
@@ -8698,7 +8747,7 @@ function testTargeting(creds, url, opts) {
8698
8747
  if (opts?.status) params.set("status", opts.status);
8699
8748
  return request(creds, "GET", `/api/v1/targeting/test?${params.toString()}`);
8700
8749
  }
8701
- function createSos(creds, body5) {
8750
+ function createSos(creds, body5, options = {}) {
8702
8751
  const idempotency_key = body5.idempotency_key ?? (0, import_node_crypto2.createHash)("sha256").update(
8703
8752
  [
8704
8753
  body5.type,
@@ -8706,11 +8755,29 @@ function createSos(creds, body5) {
8706
8755
  body5.description,
8707
8756
  body5.sub_class ?? "",
8708
8757
  body5.loom_url ?? "",
8758
+ ...options.attachmentDigests ?? [],
8709
8759
  String(Math.floor(Date.now() / 36e5))
8710
8760
  ].join("\0")
8711
8761
  ).digest("hex");
8712
8762
  return request(creds, "POST", "/api/v1/sos", { ...body5, idempotency_key });
8713
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
+ }
8714
8781
  function listMySos(creds) {
8715
8782
  return request(creds, "GET", "/api/v1/sos/mine");
8716
8783
  }
@@ -8813,6 +8880,17 @@ function markInboxRead(creds, channel) {
8813
8880
  function userCredentials(creds) {
8814
8881
  return { ...creds, apiKey: creds.userApiKey ?? "", baseUrl: creds.userBaseUrl ?? creds.baseUrl };
8815
8882
  }
8883
+ function createAdminCustomer(creds, body5) {
8884
+ return request(
8885
+ userCredentials(creds),
8886
+ "POST",
8887
+ "/api/v1/admin/customers",
8888
+ body5
8889
+ );
8890
+ }
8891
+ function setAdminCustomerCategory(creds, orgId, body5) {
8892
+ return request(userCredentials(creds), "PATCH", `/api/v1/admin/customers/${orgId}`, body5);
8893
+ }
8816
8894
  function getAdminSettings(creds) {
8817
8895
  return request(userCredentials(creds), "GET", "/api/v1/admin/settings");
8818
8896
  }
@@ -10451,14 +10529,31 @@ function printFilesystemPlan(plan2) {
10451
10529
  }
10452
10530
  console.log();
10453
10531
  console.log(`QA invalidated: ${plan2.invalidatesQa ? source_default.yellow("yes") : "no"}`);
10454
- 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"}`);
10455
10536
  console.log(`Precompile: ${plan2.precompileRequired ? "yes" : "no"}`);
10456
10537
  }
10538
+ function requiredQaAcknowledgment(plan2) {
10539
+ return "requiredAcknowledgment" in plan2 ? plan2.requiredAcknowledgment ?? null : plan2.invalidatesQa ? "accept-qa-invalidation" : null;
10540
+ }
10457
10541
  function blockingIssueSummary(issues) {
10458
10542
  if (!issues?.length) return "Experiment filesystem plan has errors.";
10459
10543
  const remaining = issues.length - 1;
10460
10544
  return `Experiment filesystem plan has errors. ${issues[0].message}${remaining ? ` and ${remaining} more` : ""}`;
10461
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
+ }
10462
10557
  async function getQaPreviewUrls(creds, experimentId) {
10463
10558
  const fallbackDashboardUrl = buildDashboardUrl(
10464
10559
  creds.baseUrl,
@@ -11834,7 +11929,7 @@ var planFilesystem = new Command("plan").description("Plan experiment filesystem
11834
11929
  folder,
11835
11930
  experimentId: session.experimentId,
11836
11931
  plan: res.data,
11837
- requiredAcknowledgment: res.data.invalidatesQa ? "accept-qa-invalidation" : null
11932
+ requiredAcknowledgment: requiredQaAcknowledgment(res.data)
11838
11933
  },
11839
11934
  () => printFilesystemPlan(res.data)
11840
11935
  );
@@ -11846,7 +11941,10 @@ var planFilesystem = new Command("plan").description("Plan experiment filesystem
11846
11941
  var applyFilesystem = new Command("apply").option(
11847
11942
  "--confirm-targeting-replace",
11848
11943
  "Confirm that attaching a Page may replace URL targeting authored through the API"
11849
- ).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) {
11850
11948
  const creds = requireCredentials();
11851
11949
  try {
11852
11950
  const folder = folderPath(folderArg);
@@ -11893,7 +11991,7 @@ var applyFilesystem = new Command("apply").option(
11893
11991
  why: "Retry with hosted asset upload."
11894
11992
  }
11895
11993
  ] : [],
11896
- requiredAcknowledgment: planRes.data.invalidatesQa ? "accept-qa-invalidation" : null
11994
+ requiredAcknowledgment: requiredQaAcknowledgment(planRes.data)
11897
11995
  });
11898
11996
  }
11899
11997
  if (planRes.data.changes.length === 0) {
@@ -11910,7 +12008,7 @@ var applyFilesystem = new Command("apply").option(
11910
12008
  synced: synced2,
11911
12009
  dashboardUrl: dashboardUrl2,
11912
12010
  plan: planRes.data,
11913
- requiredAcknowledgment: planRes.data.invalidatesQa ? "accept-qa-invalidation" : null,
12011
+ requiredAcknowledgment: requiredQaAcknowledgment(planRes.data),
11914
12012
  message: "No changes."
11915
12013
  },
11916
12014
  () => {
@@ -11922,23 +12020,23 @@ var applyFilesystem = new Command("apply").option(
11922
12020
  );
11923
12021
  return;
11924
12022
  }
11925
- if (planRes.data.invalidatesQa && !opts.force && !opts.acceptQaInvalidation) {
11926
- 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?`);
11927
12027
  if (ok === null) {
11928
- confirmationRequired(
11929
- this,
11930
- "Applying these changes invalidates QA approval and requires confirmation.",
11931
- `apex experiments apply ${folderArg} --accept-qa-invalidation`
11932
- );
12028
+ fail(this, refusal.message, EXIT_CODES.confirmationRequired, refusal.details);
11933
12029
  }
11934
12030
  if (!ok) {
11935
12031
  output(this, { aborted: true }, () => console.log("Aborted."));
11936
12032
  return;
11937
12033
  }
12034
+ acceptQaInvalidation = true;
11938
12035
  }
11939
12036
  const spinner = process.stderr.isTTY ? await createSpinner("Applying experiment filesystem...") : null;
11940
12037
  let res = await applyExperimentFilesystem(creds, session.experimentId, {
11941
12038
  files,
12039
+ accept_qa_invalidation: acceptQaInvalidation,
11942
12040
  ...opts.confirmTargetingReplace ? { confirmTargetingReplace: true } : {}
11943
12041
  });
11944
12042
  if (!res.ok && res.status === 409 && res.errorDetails?.code === "confirm_targeting_replace_required" && !opts.confirmTargetingReplace) {
@@ -11959,9 +12057,19 @@ var applyFilesystem = new Command("apply").option(
11959
12057
  }
11960
12058
  res = await applyExperimentFilesystem(creds, session.experimentId, {
11961
12059
  files,
12060
+ accept_qa_invalidation: acceptQaInvalidation,
11962
12061
  confirmTargetingReplace: true
11963
12062
  });
11964
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
+ }
11965
12073
  if (!res.ok || !res.data?.applied) {
11966
12074
  spinner?.fail("Failed to apply experiment filesystem");
11967
12075
  fail(this, res.error ?? "Apply failed", classifyStatus(res.status), {
@@ -12000,7 +12108,7 @@ var applyFilesystem = new Command("apply").option(
12000
12108
  experimentId: session.experimentId,
12001
12109
  applied: true,
12002
12110
  plan: planRes.data,
12003
- requiredAcknowledgment: planRes.data.invalidatesQa ? "accept-qa-invalidation" : null,
12111
+ requiredAcknowledgment: requiredQaAcknowledgment(planRes.data),
12004
12112
  result: res.data,
12005
12113
  synced,
12006
12114
  dashboardUrl,
@@ -13001,6 +13109,25 @@ var pushCommand = new Command("push").description("Push experiment definitions f
13001
13109
 
13002
13110
  // src/commands/preview.ts
13003
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
+ }
13004
13131
  var previewCommand = new Command("preview").description(
13005
13132
  "Open QA preview URL for an experiment variation; proxy previews return a QA run receipt"
13006
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(
@@ -13112,7 +13239,7 @@ var previewCommand = new Command("preview").description(
13112
13239
  variation = exp.variations.find((v) => !v.is_control) ?? exp.variations[0];
13113
13240
  }
13114
13241
  spinner.stop();
13115
- 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;
13116
13243
  if (!storeUrl) {
13117
13244
  fail(
13118
13245
  previewCommand,
@@ -13128,12 +13255,18 @@ var previewCommand = new Command("preview").description(
13128
13255
  'Invalid store URL. Pass an absolute URL like "https://example.com/products/item".'
13129
13256
  );
13130
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);
13131
13262
  output(
13132
13263
  previewCommand,
13133
13264
  {
13134
13265
  experimentId: exp.id,
13135
13266
  variationId: variation.id,
13136
- previewUrl: previewUrl.toString(),
13267
+ previewUrl: selectedPreviewUrl,
13268
+ expiresAt: signed?.signed_preview_expires_at ?? null,
13269
+ ...warning ? { warning } : {},
13137
13270
  ...!opts?.storeUrl && exp.preview_target ? { previewTarget: exp.preview_target } : {}
13138
13271
  },
13139
13272
  () => {
@@ -13149,14 +13282,16 @@ var previewCommand = new Command("preview").description(
13149
13282
  }
13150
13283
  }
13151
13284
  console.log();
13152
- 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}.`);
13153
13288
  console.log();
13154
13289
  }
13155
13290
  );
13156
13291
  if (opts?.open !== false && !shouldUseJson(previewCommand)) {
13157
13292
  try {
13158
13293
  const open3 = (await Promise.resolve().then(() => (init_open(), open_exports))).default;
13159
- await open3(previewUrl.toString());
13294
+ await open3(selectedPreviewUrl);
13160
13295
  console.log(source_default.dim("Opened in browser."));
13161
13296
  } catch {
13162
13297
  console.log(source_default.dim("Copy the URL above to preview."));
@@ -14143,9 +14278,7 @@ function commonCookieDomain(hostnames) {
14143
14278
  const suffix = suffixes[0];
14144
14279
  return suffix && suffixes.every((candidate) => candidate === suffix) ? `.${suffix}` : null;
14145
14280
  }
14146
- function defaultManifestPath() {
14147
- return import_node_path11.default.resolve(process.cwd(), ".apex", "tracking-manifest.json");
14148
- }
14281
+ var DEFAULT_MANIFEST_PATH = ".apex/tracking-manifest.json";
14149
14282
  function readManifest(filePath) {
14150
14283
  if (!import_node_fs13.default.existsSync(filePath)) throw new Error(`Manifest not found: ${filePath}`);
14151
14284
  const parsed = JSON.parse(import_node_fs13.default.readFileSync(filePath, "utf8"));
@@ -14221,7 +14354,7 @@ function buildManifest(urlArgs, opts) {
14221
14354
  };
14222
14355
  }
14223
14356
  function manifestPath(input) {
14224
- return import_node_path11.default.resolve(process.cwd(), input ?? defaultManifestPath());
14357
+ return import_node_path11.default.resolve(process.cwd(), input ?? DEFAULT_MANIFEST_PATH);
14225
14358
  }
14226
14359
  function printManifestPlan(manifest) {
14227
14360
  console.log(source_default.bold("Apex tracking ingestion plan"));
@@ -14249,7 +14382,11 @@ function printManifestPlan(manifest) {
14249
14382
  var trackingCommand = new Command("tracking").description(
14250
14383
  "Create Apex-first tracking ingestion manifests and QA handoffs"
14251
14384
  );
14252
- 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) {
14253
14390
  const manifest = buildManifest(urls, opts);
14254
14391
  const outPath = manifestPath(opts.out);
14255
14392
  writeManifest(outPath, manifest);
@@ -14265,12 +14402,20 @@ trackingCommand.command("inspect").description("Create a local tracking manifest
14265
14402
  }
14266
14403
  });
14267
14404
  });
14268
- 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) {
14269
14410
  const filePath = manifestPath(input);
14270
14411
  const manifest = readManifest(filePath);
14271
14412
  output(this, { path: filePath, manifest }, () => printManifestPlan(manifest));
14272
14413
  });
14273
- 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) {
14274
14419
  const filePath = manifestPath(input);
14275
14420
  const manifest = readManifest(filePath);
14276
14421
  output(this, { path: filePath, checks: manifest.qa.checks }, () => {
@@ -18484,16 +18629,21 @@ async function checkServedConfig(configUrl, workerBaseUrl, workerBaseSource) {
18484
18629
  message: "Worker served a recognized config document for this shop."
18485
18630
  };
18486
18631
  }
18487
- 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(
18488
18638
  "--worker-url <url>",
18489
18639
  "Apex Worker base URL that serves /config (defaults to the URL in the served snippet, then DRIP_WORKER_URL, then the production worker)"
18490
18640
  ).option("--environment <key>", "Environment key to request from the worker", "production").action(async function action65(opts) {
18491
18641
  const creds = requireCredentials();
18492
- 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;
18493
18643
  if (!shopId)
18494
18644
  fail(
18495
18645
  this,
18496
- "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",
18497
18647
  EXIT_CODES.validation,
18498
18648
  { field: "shop-id" }
18499
18649
  );
@@ -19533,6 +19683,7 @@ var test2 = new Command("test").description("Evaluate experiment URL targeting f
19533
19683
  var targetingCommand = new Command("targeting").description("Test experiment targeting").addCommand(test2);
19534
19684
 
19535
19685
  // src/commands/sos.ts
19686
+ var import_node_crypto7 = require("node:crypto");
19536
19687
  init_source();
19537
19688
  var import_node_fs24 = __toESM(require("node:fs"));
19538
19689
  var import_node_path21 = __toESM(require("node:path"));
@@ -19574,6 +19725,11 @@ var create4 = new Command("create").description("Create an Apex SOS ticket").opt
19574
19725
  "--type <type>",
19575
19726
  "full_handover|design|development|qa|config|bug|help (internal types require a DRIP account)",
19576
19727
  "qa"
19728
+ ).option(
19729
+ "--attach <file>",
19730
+ "Attach an image or video (repeatable, maximum 5)",
19731
+ (file, files) => [...files, file],
19732
+ []
19577
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) {
19578
19734
  if (!SOS_TYPES.has(opts.type)) {
19579
19735
  fail(
@@ -19585,16 +19741,76 @@ var create4 = new Command("create").description("Create an Apex SOS ticket").opt
19585
19741
  if (opts.experiment && opts.backlogItem) {
19586
19742
  fail(this, "Provide only one of --experiment or --backlog-item", 3);
19587
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
+ });
19588
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
+ }
19589
19800
  const description = readText(opts.description) ?? descriptionArg ?? `Apex CLI ${opts.type} SOS request${opts.loom ? ` with Loom evidence: ${opts.loom}` : "."}`;
19590
- const res = await createSos(creds, {
19591
- type: opts.type,
19592
- description,
19593
- experiment_id: opts.experiment ?? null,
19594
- backlog_item_id: opts.backlogItem ?? null,
19595
- sub_class: opts.subClass ?? null,
19596
- loom_url: opts.loom ?? null
19597
- });
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
+ );
19598
19814
  if (!res.ok || !res.data) {
19599
19815
  fail(
19600
19816
  this,
@@ -19899,6 +20115,7 @@ function optionSchema(option) {
19899
20115
  description: option.description,
19900
20116
  required: option.required,
19901
20117
  optional: option.optional,
20118
+ mandatory: Boolean(option.mandatory),
19902
20119
  variadic: option.variadic,
19903
20120
  ...option.defaultValue === void 0 ? {} : { defaultValue: option.defaultValue }
19904
20121
  };
@@ -19921,8 +20138,16 @@ function commandSchema(command) {
19921
20138
  };
19922
20139
  }
19923
20140
  function createSchemaCommand(program3) {
19924
- return new Command("schema").description("Print the current Apex CLI command and flag schema").option("--json", "Print JSON schema").action(function action78() {
19925
- 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
+ };
19926
20151
  output(this, schema, () => {
19927
20152
  process.stdout.write(`${JSON.stringify(schema, null, 2)}
19928
20153
  `);
@@ -20563,6 +20788,7 @@ var skillsCommand = new Command("skills").description("Print or install the Apex
20563
20788
  });
20564
20789
 
20565
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;
20566
20792
  function switchValue(value) {
20567
20793
  if (value === "on") return true;
20568
20794
  if (value === "off") return false;
@@ -20677,7 +20903,137 @@ var restore2 = new Command("restore").description("Restore an archived shop").ar
20677
20903
  );
20678
20904
  });
20679
20905
  var shops = new Command("shops").description("Archive and restore shops on the platform admin plane").addCommand(archive2).addCommand(restore2);
20680
- var adminCommand = new Command("admin").description("Operate allowlisted platform controls").addCommand(settingsGet).addCommand(quarantine).addCommand(shops);
20906
+ var create6 = new Command("create").description("Create an internal, demo or dogfood account").requiredOption("--name <text>").requiredOption("--category <category>", "internal, demo or dogfood").option("--type <type>", "company or agency", "company").option("--plan <plan>", "self_managed, apex_managed or drip_managed", "self_managed").option("--domain <host>").option("--platform <platform>", "shopify, shopware, woo or other").option("--claim-shop <uuid>").option("--owner <email>").option("--consultant <uuid>", "Platform access ID").option("--consultant-title <text>").requiredOption("--reason <text>", "Audit reason, 8 to 500 characters").option("--confirm", "Confirm account creation").option("--json", "Output JSON").action(async function(opts) {
20907
+ if (opts.category === "customer") fail(this, "Create customer accounts in Admin, Links", 3);
20908
+ if (!["internal", "demo", "dogfood"].includes(opts.category))
20909
+ fail(this, "--category must be internal, demo or dogfood", 3);
20910
+ if (!["company", "agency"].includes(opts.type))
20911
+ fail(this, "--type must be company or agency", 3);
20912
+ if (!["self_managed", "apex_managed", "drip_managed"].includes(opts.plan))
20913
+ fail(this, "Invalid --plan", 3);
20914
+ if (!opts.name.trim() || opts.name.trim().length > 60)
20915
+ fail(this, "--name must be 1 to 60 characters", 3);
20916
+ if (opts.reason.trim().length < 8 || opts.reason.trim().length > 500)
20917
+ fail(this, "--reason must be 8 to 500 characters", 3);
20918
+ if (opts.claimShop && !uuid.test(opts.claimShop)) fail(this, "--claim-shop must be a UUID", 3);
20919
+ if (opts.claimShop && (opts.domain || opts.platform))
20920
+ fail(this, "Use --claim-shop or --domain with --platform", 3);
20921
+ if (!!opts.domain !== !!opts.platform)
20922
+ fail(this, "--domain and --platform must be provided together", 3);
20923
+ if (opts.domain && !/^[a-z0-9-]+(\.[a-z0-9-]+)+$/i.test(opts.domain.trim()))
20924
+ fail(this, "--domain must be a hostname without a protocol or path", 3);
20925
+ const platforms = {
20926
+ shopify: "shopify",
20927
+ shopware: "shopware",
20928
+ woo: "woocommerce",
20929
+ other: "custom"
20930
+ };
20931
+ if (opts.platform && !Object.hasOwn(platforms, opts.platform))
20932
+ fail(this, "--platform must be shopify, shopware, woo or other", 3);
20933
+ if (opts.owner && !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(opts.owner.trim()))
20934
+ fail(this, "--owner must be an email address", 3);
20935
+ if (opts.owner && !opts.domain && !opts.claimShop)
20936
+ fail(this, "--owner requires a store for the invitation", 3);
20937
+ if (opts.plan !== "self_managed" && (!opts.consultant || !opts.consultantTitle?.trim()))
20938
+ fail(this, "Managed plans require --consultant and --consultant-title", 3);
20939
+ if (opts.consultant && !uuid.test(opts.consultant))
20940
+ fail(this, "--consultant must be a platform access UUID", 3);
20941
+ if (!!opts.consultant !== !!opts.consultantTitle?.trim())
20942
+ fail(this, "Provide --consultant and --consultant-title together", 3);
20943
+ if (opts.consultantTitle && opts.consultantTitle.trim().length > 240)
20944
+ fail(this, "--consultant-title must be at most 240 characters", 3);
20945
+ if (!opts.confirm) {
20946
+ const flags = [
20947
+ ["name", opts.name],
20948
+ ["category", opts.category],
20949
+ ["type", opts.type],
20950
+ ["plan", opts.plan],
20951
+ ["reason", opts.reason],
20952
+ ["domain", opts.domain],
20953
+ ["platform", opts.platform],
20954
+ ["claim-shop", opts.claimShop],
20955
+ ["owner", opts.owner],
20956
+ ["consultant", opts.consultant],
20957
+ ["consultant-title", opts.consultantTitle]
20958
+ ];
20959
+ const retry = flags.filter((entry) => entry[1] !== void 0).map(([flag, value]) => `--${flag} '${value.replaceAll("'", "'\\''")}'`).join(" ");
20960
+ confirmationRequired(
20961
+ this,
20962
+ "--confirm is required to create an account",
20963
+ `apex admin customers create ${retry} --confirm`
20964
+ );
20965
+ }
20966
+ const body5 = {
20967
+ name: opts.name.trim(),
20968
+ category: opts.category,
20969
+ type: opts.type,
20970
+ plan: opts.plan,
20971
+ reason: opts.reason.trim(),
20972
+ teammates: [],
20973
+ consultant: opts.consultant ? { platformAccessId: opts.consultant, title: opts.consultantTitle.trim() } : null,
20974
+ ...opts.claimShop ? { store: { claimShopId: opts.claimShop } } : opts.domain ? {
20975
+ store: {
20976
+ domain: opts.domain.trim().toLowerCase(),
20977
+ platform: platforms[opts.platform]
20978
+ }
20979
+ } : {},
20980
+ ...opts.owner ? { ownerEmail: opts.owner.trim() } : {}
20981
+ };
20982
+ const res = await createAdminCustomer(requireUserCredentials(), body5);
20983
+ if (!res.ok || !res.data)
20984
+ fail(
20985
+ this,
20986
+ res.error ?? "Failed to create account",
20987
+ classifyStatus(res.status),
20988
+ res.errorDetails
20989
+ );
20990
+ output(this, res.data, () => {
20991
+ const result = res.data.data;
20992
+ printTable(
20993
+ ["ORGANIZATION", "STORE", "INVITATIONS"],
20994
+ [[result.orgId, result.shopId ?? "-", String(result.invitations.length)]]
20995
+ );
20996
+ if (result.invitations.length)
20997
+ printTable(
20998
+ ["EMAIL", "ROLE", "STATUS", "NOTE"],
20999
+ result.invitations.map((i) => [i.email, i.role, i.status, i.note])
21000
+ );
21001
+ });
21002
+ });
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);
21036
+ var adminCommand = new Command("admin").description("Operate allowlisted platform controls").addCommand(settingsGet).addCommand(quarantine).addCommand(shops).addCommand(customers);
20681
21037
 
20682
21038
  // src/commands/membership.ts
20683
21039
  init_source();
@@ -20873,7 +21229,7 @@ var list13 = new Command("list").action(async function() {
20873
21229
  )
20874
21230
  );
20875
21231
  });
20876
- var create6 = new Command("create").requiredOption("--name <name>").requiredOption("--type <type>").option("--permissions <csv>").action(async function(opts) {
21232
+ var create7 = new Command("create").requiredOption("--name <name>").requiredOption("--type <type>").option("--permissions <csv>").action(async function(opts) {
20877
21233
  if (!["live_secret", "test_secret", "publishable"].includes(opts.type))
20878
21234
  throw new CliError(
20879
21235
  "--type must be live_secret, test_secret, or publishable",
@@ -20913,7 +21269,7 @@ var revoke = new Command("revoke").argument("<keyId>").action(async function(key
20913
21269
  );
20914
21270
  output(this, res.data, () => console.log(`Revoked ${keyId}`));
20915
21271
  });
20916
- var keysCommand = new Command("keys").description("Manage shop API keys in the current context").addCommand(list13).addCommand(create6).addCommand(revoke);
21272
+ var keysCommand = new Command("keys").description("Manage shop API keys in the current context").addCommand(list13).addCommand(create7).addCommand(revoke);
20917
21273
 
20918
21274
  // src/commands/prospect.ts
20919
21275
  init_source();
@@ -21172,7 +21528,7 @@ var list14 = new Command("list").alias("ls").description(
21172
21528
  const data = await loadWebhookIndex(this);
21173
21529
  output(this, data, () => printWebhooks(data.data));
21174
21530
  });
21175
- var create7 = new Command("create").description(`Create a webhook. Prints the signing secret once. Signature docs: ${SIGNATURE_DOC}`).requiredOption("--url <url>", "HTTPS callback URL").option(
21531
+ var create8 = new Command("create").description(`Create a webhook. Prints the signing secret once. Signature docs: ${SIGNATURE_DOC}`).requiredOption("--url <url>", "HTTPS callback URL").option(
21176
21532
  "--event <event>",
21177
21533
  "Webhook event to subscribe to; repeat for multiple events",
21178
21534
  (value, previous = []) => [...previous, value],
@@ -21280,7 +21636,7 @@ var test3 = new Command("test").description("Send a signed test event to a webho
21280
21636
  )
21281
21637
  );
21282
21638
  });
21283
- var webhooksCommand = new Command("webhooks").description("Manage shop webhooks").addCommand(list14).addCommand(create7).addCommand(update5).addCommand(remove3).addCommand(deliveries).addCommand(test3);
21639
+ var webhooksCommand = new Command("webhooks").description("Manage shop webhooks").addCommand(list14).addCommand(create8).addCommand(update5).addCommand(remove3).addCommand(deliveries).addCommand(test3);
21284
21640
 
21285
21641
  // src/commands/simulate.ts
21286
21642
  init_source();
@@ -21413,7 +21769,7 @@ var import_node_fs29 = __toESM(require("node:fs"));
21413
21769
  var import_node_path26 = __toESM(require("node:path"));
21414
21770
 
21415
21771
  // src/commands/landing-pages.ts
21416
- var import_node_crypto7 = __toESM(require("node:crypto"));
21772
+ var import_node_crypto8 = __toESM(require("node:crypto"));
21417
21773
  var import_node_fs28 = __toESM(require("node:fs"));
21418
21774
  var import_node_path25 = __toESM(require("node:path"));
21419
21775
  function apiFailure(command, response, fallback2) {
@@ -21427,7 +21783,7 @@ function apiFailure(command, response, fallback2) {
21427
21783
  }
21428
21784
  }
21429
21785
  function sha256(body5) {
21430
- return import_node_crypto7.default.createHash("sha256").update(body5).digest("hex");
21786
+ return import_node_crypto8.default.createHash("sha256").update(body5).digest("hex");
21431
21787
  }
21432
21788
  function markerPath(folder) {
21433
21789
  return import_node_path25.default.join(folder, LANDING_PAGE_CHECKOUT_MARKER);
@@ -21502,12 +21858,31 @@ function readCheckoutFiles(folder) {
21502
21858
  if (!isLandingPageCheckoutPath(relative)) continue;
21503
21859
  if (entry.isDirectory()) visit2(absolute);
21504
21860
  else if (entry.isFile()) {
21861
+ try {
21862
+ validateLandingPageLocalFiles([
21863
+ ...files,
21864
+ { path: relative, bytes: import_node_fs28.default.statSync(absolute).size, sha256: "" }
21865
+ ]);
21866
+ } catch (error) {
21867
+ throw new CliError(
21868
+ error instanceof Error ? error.message : "Invalid bundle",
21869
+ EXIT_CODES.validation
21870
+ );
21871
+ }
21505
21872
  const body5 = import_node_fs28.default.readFileSync(absolute);
21506
21873
  files.push({ path: relative, sha256: sha256(body5), bytes: body5.byteLength, body: body5 });
21507
21874
  }
21508
21875
  }
21509
21876
  };
21510
21877
  visit2(folder);
21878
+ try {
21879
+ validateLandingPageLocalFiles(files);
21880
+ } catch (error) {
21881
+ throw new CliError(
21882
+ error instanceof Error ? error.message : "Invalid bundle",
21883
+ EXIT_CODES.validation
21884
+ );
21885
+ }
21511
21886
  return files;
21512
21887
  }
21513
21888
  function readManifest2(command, folder) {
@@ -21564,7 +21939,7 @@ function printPlan(plan2) {
21564
21939
  );
21565
21940
  }
21566
21941
  console.log(
21567
- `Deploy target: ${plan2.deploy_target ?? "none yet: set host in apex-landing-page.json to a domain this shop holds"}`
21942
+ `Deploy target: ${plan2.deploy_target ?? "none: set host in apex-landing-page.json to a domain this shop holds"}`
21568
21943
  );
21569
21944
  }
21570
21945
  async function confirm2(message) {
@@ -21653,7 +22028,10 @@ async function pullCheckout(command, creds, id, folder, force = false) {
21653
22028
  const target = import_node_path25.default.resolve(folder, file.path);
21654
22029
  if (!target.startsWith(folder + import_node_path25.default.sep))
21655
22030
  fail(command, `Refusing to write outside the checkout: ${file.path}`);
21656
- const body5 = file.encoding === "base64" ? Buffer.from(file.content ?? "", "base64") : Buffer.from(file.content ?? "", "utf8");
22031
+ const download = file.download_url ? await fetch(file.download_url, { signal: AbortSignal.timeout(6e4) }) : null;
22032
+ if (download && !download.ok)
22033
+ fail(command, `Failed to download ${file.path}: ${download.status}`);
22034
+ const body5 = download ? Buffer.from(await download.arrayBuffer()) : file.encoding === "base64" ? Buffer.from(file.content ?? "", "base64") : Buffer.from(file.content ?? "", "utf8");
21657
22035
  if (sha256(body5) !== file.sha256)
21658
22036
  fail(command, `The server returned ${file.path} incomplete. Pull again.`);
21659
22037
  downloaded.push({ path: file.path, target, body: body5, sha256: file.sha256 });
@@ -21733,7 +22111,10 @@ async function pullCheckout(command, creds, id, folder, force = false) {
21733
22111
  notes: listing.data.notes ?? []
21734
22112
  };
21735
22113
  }
21736
- var create8 = 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) {
21737
22118
  const creds = requireCredentials();
21738
22119
  const created = await landingPages(creds).create({
21739
22120
  name: opts.name,
@@ -21811,7 +22192,24 @@ var apply = new Command("apply").description("Apply a checkout as a new version"
21811
22192
  }
21812
22193
  const required = new Set(planned.data.files.upload_required);
21813
22194
  const toUpload = files.filter((file) => required.has(file.path));
21814
- for (const batch of landingPageBatches(toUpload, (file) => file.bytes)) {
22195
+ for (const file of toUpload.filter((file2) => file2.bytes > LANDING_PAGE_BATCH_BYTES)) {
22196
+ const granted = await api.uploadGrant(marker.landingPageId, {
22197
+ path: file.path,
22198
+ bytes: file.bytes,
22199
+ sha256: file.sha256,
22200
+ base_version_id: planned.data.base_version_id
22201
+ });
22202
+ apiFailure(this, granted, "Failed to grant video upload");
22203
+ await uploadLandingVideo(granted.data, file.body, {
22204
+ progress: (uploaded, total) => {
22205
+ console.error(`Uploading ${file.path}: ${uploaded}/${total} bytes`);
22206
+ }
22207
+ });
22208
+ }
22209
+ for (const batch of landingPageBatches(
22210
+ toUpload.filter((file) => file.bytes <= LANDING_PAGE_BATCH_BYTES),
22211
+ (file) => file.bytes
22212
+ )) {
21815
22213
  const uploaded = await api.upload(
21816
22214
  marker.landingPageId,
21817
22215
  batch.map((file) => ({
@@ -21860,7 +22258,7 @@ var apply = new Command("apply").description("Apply a checkout as a new version"
21860
22258
  console.log(`Version ${applied.data.version_number}`);
21861
22259
  if (applied.data.preview_url) console.log(`Preview: ${applied.data.preview_url}`);
21862
22260
  console.log(
21863
- `Deploy target: ${applied.data.deploy_target ?? "none yet: set host in apex-landing-page.json to a domain this shop holds"}`
22261
+ `Deploy target: ${applied.data.deploy_target ?? "none: set host in apex-landing-page.json to a domain this shop holds"}`
21864
22262
  );
21865
22263
  });
21866
22264
  });
@@ -21888,9 +22286,11 @@ async function versionIdFor(command, creds, id, raw) {
21888
22286
  return match2.id;
21889
22287
  }
21890
22288
  var SPLIT_KEEPS_SERVING_HELP = "\nWhile an experiment splits this page, visitors keep getting its two arms and its goals stay as they are (new manifest goals are added). The deployed version serves when the experiment ends; its goal changes apply at the next deploy after that.\n";
21891
- var deploy = new Command("deploy").description("Deploy a version to a domain this shop holds").argument("[folder-or-id]", "Checkout folder or landing page id", ".").option(
22289
+ var deploy = new Command("deploy").description(
22290
+ "Deploy a version to the shop's instant host (<shop>.apex-page.com) or a domain this shop holds"
22291
+ ).argument("[folder-or-id]", "Checkout folder or landing page id", ".").option(
21892
22292
  "--host <host>",
21893
- "Domain claimed with apex domains claim (default: the manifest host; one of the two is required)"
22293
+ "Domain claimed with apex domains claim (default: the manifest host, else the shop's apex-page.com subdomain)"
21894
22294
  ).option("--path <path>", "Path on the host").option("--version <number>", "Version number (default: current)").action(async function(target, opts) {
21895
22295
  const creds = requireCredentials();
21896
22296
  const id = resolvePageId(target);
@@ -21974,7 +22374,7 @@ var open2 = new Command("open").description("Print the page's dashboard URL").ar
21974
22374
  output(this, { url }, () => console.log(url));
21975
22375
  });
21976
22376
  var landingPageWorkspaceCommands = [
21977
- create8,
22377
+ create9,
21978
22378
  pull,
21979
22379
  plan,
21980
22380
  apply,
@@ -22209,6 +22609,35 @@ program2.hook("preAction", (_thisCommand, actionCommand) => {
22209
22609
  process.on("beforeExit", () => {
22210
22610
  flushFallbackEnvelope();
22211
22611
  });
22212
- 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 () => {
22213
22642
  await finishInvocation({ exitCode: process.exitCode ?? 0 });
22214
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.0",
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,