@drip-apex/apex-cli 0.7.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -24,7 +24,6 @@ Use `apex` as the canonical command name. `drip` is a compatibility alias.
24
24
  "did you mean" hints — those are stderr diagnostics for humans; machine
25
25
  callers resolve names via `apex schema --json`.
26
26
 
27
-
28
27
  ## Zero-Experiment Shop Bootstrap
29
28
 
30
29
  CLI-only bootstrap is supported for a fresh shop before any experiments exist.
@@ -71,7 +70,6 @@ the first writes: `apex experiments create`, `apex goals create`,
71
70
  - Do not bypass the variation artifact persistence path with direct API writes.
72
71
  - Keep generated evidence outside `qa.json`; attach it through QA commands.
73
72
 
74
-
75
73
  ## Project code
76
74
 
77
75
  ```sh
@@ -106,3 +104,14 @@ and `countingType` equal `delivery_type` and `counting_type` from
106
104
  `apex experiments get --json`. Omitting or setting `countingType` to null preserves
107
105
  the stored value. Classification edits invalidate QA approval; paused and running
108
106
  experiments reject these metadata changes.
107
+
108
+ Experiment references accept an ID, slug, or test key (for example `BIO-085`). Test keys resolve within the selected shop; use `--shop <shop-id>` to change it. Bare lowercase references remain slugs.
109
+
110
+ Filesystem apply preserves authored local bytes by default. Use
111
+ `apex experiments apply <folder> --sync` to refresh them from stored server bytes.
112
+ The receipt's `files` contains SHA-256 digests for every pull path; `synced` reports
113
+ whether a full refresh succeeded. Server-owned QA and launch files are healed.
114
+ Create/get return `dashboard_url`; apply returns `dashboardUrl`. These links
115
+ switch the signed-in dashboard user's active shop before opening the experiment.
116
+
117
+ Filesystem apply caches uploaded asset URLs by byte SHA-256 in `.apex/experiment-filesystem.json`, so unchanged images reuse their URL across applies without rewriting authored files.
package/README.md CHANGED
@@ -268,7 +268,6 @@ Read the CLI reference on the docs site:
268
268
 
269
269
  Maintainers: see the [CLI release runbook](../../docs/cli-releases.md).
270
270
 
271
-
272
271
  ## Project code
273
272
 
274
273
  ```sh
@@ -303,3 +302,14 @@ and `countingType` equal `delivery_type` and `counting_type` from
303
302
  `apex experiments get --json`. Omitting or setting `countingType` to null preserves
304
303
  the stored value. Classification edits invalidate QA approval; paused and running
305
304
  experiments reject these metadata changes.
305
+
306
+ Experiment references accept an ID, slug, or test key (for example `BIO-085`). Test keys resolve within the selected shop; use `--shop <shop-id>` to change it. Bare lowercase references remain slugs.
307
+
308
+ Filesystem apply preserves authored local bytes by default. Use
309
+ `apex experiments apply <folder> --sync` to refresh them from stored server bytes.
310
+ The receipt's `files` contains SHA-256 digests for every pull path; `synced` reports
311
+ whether a full refresh succeeded. Server-owned QA and launch files are healed.
312
+ Create/get return `dashboard_url`; apply returns `dashboardUrl`. These links
313
+ switch the signed-in dashboard user's active shop before opening the experiment.
314
+
315
+ Filesystem apply caches uploaded asset URLs by byte SHA-256 in `.apex/experiment-filesystem.json`, so unchanged images reuse their URL across applies without rewriting authored files.
package/dist/cli.js CHANGED
@@ -7112,7 +7112,7 @@ var import_node_os3 = __toESM(require("node:os"));
7112
7112
  // package.json
7113
7113
  var package_default = {
7114
7114
  name: "@drip-apex/apex-cli",
7115
- version: "0.7.0",
7115
+ version: "0.8.1",
7116
7116
  license: "MIT",
7117
7117
  description: "Apex CLI for managing experiments, approved roadmap ideas, and draft workspaces.",
7118
7118
  private: false,
@@ -7310,6 +7310,15 @@ function classifyApiError(status2, details) {
7310
7310
  function confirmationRequired(command, message, retry) {
7311
7311
  return fail(command, message, EXIT_CODES.confirmationRequired, { retry });
7312
7312
  }
7313
+ var liveSpinners = /* @__PURE__ */ new Set();
7314
+ var exitFallback;
7315
+ function drainAndExit(code) {
7316
+ for (const spinner of liveSpinners) spinner.stop();
7317
+ process.exitCode = code;
7318
+ if (exitFallback) clearTimeout(exitFallback);
7319
+ exitFallback = setTimeout(() => process.exit(code), 500);
7320
+ exitFallback.unref();
7321
+ }
7313
7322
  async function createSpinner(message) {
7314
7323
  if (jsonMode || !process.stderr.isTTY) {
7315
7324
  return {
@@ -7328,7 +7337,20 @@ async function createSpinner(message) {
7328
7337
  };
7329
7338
  }
7330
7339
  const { default: ora2 } = await Promise.resolve().then(() => (init_ora(), ora_exports));
7331
- return ora2(message).start();
7340
+ const spinner = ora2(message);
7341
+ const start = spinner.start.bind(spinner);
7342
+ spinner.start = (...args) => {
7343
+ liveSpinners.add(spinner);
7344
+ return start(...args);
7345
+ };
7346
+ for (const method of ["stop", "succeed", "fail"]) {
7347
+ const original = spinner[method].bind(spinner);
7348
+ spinner[method] = (...args) => {
7349
+ liveSpinners.delete(spinner);
7350
+ return original(...args);
7351
+ };
7352
+ }
7353
+ return spinner.start();
7332
7354
  }
7333
7355
  async function maybeCheckForUpdate() {
7334
7356
  if (!process.stderr.isTTY || process.env.APEX_NO_UPDATE_CHECK === "1" || CLI_VERSION === "unknown")
@@ -7350,7 +7372,8 @@ async function maybeCheckForUpdate() {
7350
7372
  async function handleCliError(err, command) {
7351
7373
  if (err && typeof err === "object" && "exitCode" in err && err.exitCode === 0) {
7352
7374
  flushFallbackEnvelope();
7353
- process.exit(0);
7375
+ drainAndExit(0);
7376
+ return;
7354
7377
  }
7355
7378
  const commanderExitCode = err && typeof err === "object" && "exitCode" in err && typeof err.exitCode === "number" ? err.exitCode : null;
7356
7379
  const cliError = err instanceof CliError ? err : new CliError(
@@ -7371,7 +7394,7 @@ async function handleCliError(err, command) {
7371
7394
  `);
7372
7395
  }
7373
7396
  }
7374
- process.exit(cliError.exitCode);
7397
+ drainAndExit(cliError.exitCode);
7375
7398
  }
7376
7399
 
7377
7400
  // ../api-client/src/credentials.ts
@@ -8900,6 +8923,15 @@ var evidenceKinds = [
8900
8923
  "readiness"
8901
8924
  ];
8902
8925
 
8926
+ // src/lib/dashboard-url.ts
8927
+ function buildDashboardUrl(baseUrl, shopId, experimentId) {
8928
+ if (!shopId) return null;
8929
+ const url = new URL("/api/shops/switch", baseUrl);
8930
+ url.searchParams.set("shopId", shopId);
8931
+ url.searchParams.set("redirect", "/experiments/" + experimentId);
8932
+ return url.toString();
8933
+ }
8934
+
8903
8935
  // ../api-client/src/qa-preview-url.ts
8904
8936
  var FORCE_ALIAS_PARAM_NAMES = ["drip_experiment_id", "drip_experiment", "drip_variation_id", "drip_variation"];
8905
8937
  function applyQaPreviewParams(url, params) {
@@ -8937,6 +8969,22 @@ function buildQaPreviewUrl2(storeUrl, experimentId, variationId) {
8937
8969
  return url;
8938
8970
  }
8939
8971
 
8972
+ // src/lib/device-label.ts
8973
+ function deviceLabel(targeting) {
8974
+ if (!targeting || typeof targeting !== "object") return void 0;
8975
+ const device = targeting.deviceType;
8976
+ if (device === "desktop") return "Desktop only";
8977
+ if (device === "mobile") return "Mobile only";
8978
+ if (device === "tablet") return "Tablet only";
8979
+ return void 0;
8980
+ }
8981
+
8982
+ // src/lib/test-key.ts
8983
+ var TEST_KEY_RE = /^([A-Z0-9]{2,8})-(\d+)$/;
8984
+ function isTestKeyReference(value) {
8985
+ return value === value.toUpperCase() && TEST_KEY_RE.test(value.toUpperCase());
8986
+ }
8987
+
8940
8988
  // src/lib/effective-page-targeting.ts
8941
8989
  var ALL_PAGES_LABEL = "All pages";
8942
8990
  var CHECKOUT_LABEL = "Checkout";
@@ -9023,6 +9071,20 @@ function classifyIdeaReference(reference) {
9023
9071
  return { kind: "invalid", value: trimmed };
9024
9072
  }
9025
9073
 
9074
+ // ../api-client/src/experiment-create.ts
9075
+ function deriveStoreUrl(patterns) {
9076
+ for (const pattern of patterns) {
9077
+ const trimmed = pattern.trim();
9078
+ if (trimmed.includes("*")) continue;
9079
+ try {
9080
+ const url = new URL(trimmed);
9081
+ if (url.protocol === "http:" || url.protocol === "https:") return trimmed;
9082
+ } catch {
9083
+ }
9084
+ }
9085
+ return null;
9086
+ }
9087
+
9026
9088
  // src/lib/asset-upload.ts
9027
9089
  var import_node_crypto3 = __toESM(require("node:crypto"));
9028
9090
  var import_node_fs6 = __toESM(require("node:fs"));
@@ -9102,6 +9164,7 @@ function createAssetReferenceRewriter(options) {
9102
9164
  let pending = uploadsByHash.get(hash);
9103
9165
  if (!pending) {
9104
9166
  pending = options.upload(file).then((asset) => {
9167
+ if (asset === null) return null;
9105
9168
  if (!asset.id) throw new Error(`Asset upload for ${file.name} returned no ID.`);
9106
9169
  const url = deliveryUrl(options.baseUrl, asset);
9107
9170
  options.onUpload?.({ name: file.name, url, source });
@@ -9130,7 +9193,7 @@ function createAssetReferenceRewriter(options) {
9130
9193
  const hash = contentHash(bytes);
9131
9194
  const name = `inline-${hash.slice(0, 12)}${extensionForMimeType(mimeType)}`;
9132
9195
  const url = await uploadOnce({ name, bytes, type: mimeType }, "inline");
9133
- rewritten = rewritten.split(uri).join(url);
9196
+ if (url !== null) rewritten = rewritten.split(uri).join(url);
9134
9197
  }
9135
9198
  const localAssetUrl = async (reference) => {
9136
9199
  if (reference.startsWith("/") || reference.startsWith("data:") || reference.includes("://")) {
@@ -9217,16 +9280,22 @@ function formatDate(iso) {
9217
9280
  year: "numeric"
9218
9281
  });
9219
9282
  }
9220
- function printExperiment(exp) {
9283
+ function printExperiment(exp, dashboardUrl = null) {
9221
9284
  console.log();
9222
9285
  console.log(source_default.bold(exp.name));
9286
+ if (dashboardUrl) console.log(` Dashboard: ${dashboardUrl}`);
9223
9287
  console.log(` ID: ${source_default.dim(exp.id)}`);
9224
9288
  if (exp.slug) console.log(` Slug: ${source_default.cyan(exp.slug)}`);
9225
9289
  console.log(` Status: ${statusBadge(exp.status)}`);
9226
9290
  console.log(` Classification: ${exp.counting_type}`);
9227
9291
  console.log(` Traffic: ${(exp.traffic_allocation * 100).toFixed(0)}%`);
9292
+ console.log(` QA approved: ${exp.qa_approved_at ?? "none"}`);
9293
+ console.log(` Scheduled: ${exp.scheduled_start_at ?? "none"}`);
9294
+ console.log(` Started: ${exp.started_at ?? "none"}`);
9228
9295
  if (exp.targeting !== void 0) {
9229
9296
  console.log(` Targeting: ${effectivePageTargetingLabel(exp.targeting)}`);
9297
+ const device = deviceLabel(exp.targeting);
9298
+ if (device) console.log(` Device: ${device}`);
9230
9299
  }
9231
9300
  console.log(` Created: ${formatDate(exp.created_at)}`);
9232
9301
  if (exp.linked_idea) {
@@ -9450,21 +9519,26 @@ async function rewriteExperimentFilesystemAssets(input) {
9450
9519
  baseUrl: input.baseUrl,
9451
9520
  enabled: input.enabled,
9452
9521
  upload: async (file) => {
9522
+ const hash = import_node_crypto4.default.createHash("sha256").update(file.bytes).digest("hex");
9523
+ const cached = input.assets[hash];
9524
+ if (cached) return cached;
9525
+ if (input.upload === null) return null;
9453
9526
  const response = await input.upload(input.credentials, [file]);
9454
9527
  const asset = response.data?.assets?.[0];
9455
9528
  if (!response.ok || !asset?.id) {
9456
9529
  throw new Error(response.error ?? `Failed to upload ${file.name}.`);
9457
9530
  }
9458
- return {
9531
+ const uploaded = {
9459
9532
  id: asset.id,
9460
- url: typeof asset.url === "string" ? asset.url : void 0,
9461
- deliveryUrl: typeof asset.deliveryUrl === "string" ? asset.deliveryUrl : void 0,
9462
- delivery_url: typeof asset.delivery_url === "string" ? asset.delivery_url : void 0
9533
+ url: typeof asset.url === "string" ? asset.url : `${new URL(input.baseUrl).origin}/api/assets/${encodeURIComponent(asset.id)}/file`,
9534
+ deliveryUrl: typeof asset.deliveryUrl === "string" ? asset.deliveryUrl : typeof asset.delivery_url === "string" ? asset.delivery_url : void 0
9463
9535
  };
9464
- },
9465
- onUpload: ({ name, url }) => {
9466
- process.stderr.write(`Uploaded asset ${name} -> ${url}
9467
- `);
9536
+ input.assets[hash] = uploaded;
9537
+ process.stderr.write(
9538
+ `Uploaded asset ${file.name} -> ${uploaded.deliveryUrl ?? uploaded.url}
9539
+ `
9540
+ );
9541
+ return uploaded;
9468
9542
  }
9469
9543
  });
9470
9544
  return Promise.all(
@@ -9501,6 +9575,9 @@ function printFilesystemPlan(plan) {
9501
9575
  console.log(source_default.dim(" No changes."));
9502
9576
  } else {
9503
9577
  plan.changes.forEach((change) => {
9578
+ if (change.entity === "experiment.targeting" && Array.isArray(change.detail?.changedKeys)) {
9579
+ console.log(` Changed keys: ${change.detail.changedKeys.join(", ")}`);
9580
+ }
9504
9581
  const pathLabel = change.path ? source_default.dim(` ${change.path}`) : "";
9505
9582
  const qa3 = change.invalidatesQa ? source_default.yellow(" invalidates QA") : "";
9506
9583
  console.log(
@@ -9519,11 +9596,21 @@ function blockingIssueSummary(issues) {
9519
9596
  return `Experiment filesystem plan has errors. ${issues[0].message}${remaining ? ` and ${remaining} more` : ""}`;
9520
9597
  }
9521
9598
  async function getQaPreviewUrls(creds, experimentId) {
9599
+ const fallbackDashboardUrl = buildDashboardUrl(
9600
+ creds.baseUrl,
9601
+ creds.resolvedContext?.shopId,
9602
+ experimentId
9603
+ );
9522
9604
  try {
9523
9605
  const res = await getExperiment(creds, experimentId);
9606
+ const dashboardUrl = buildDashboardUrl(
9607
+ creds.baseUrl,
9608
+ res.data?.shop_id ?? creds.resolvedContext?.shopId,
9609
+ experimentId
9610
+ );
9524
9611
  const storeUrl = res.data?.store_url;
9525
9612
  if (!res.ok || !res.data || !storeUrl) {
9526
- return { previewUrls: [], unavailable: true };
9613
+ return { previewUrls: [], unavailable: true, dashboardUrl };
9527
9614
  }
9528
9615
  const experiment = res.data;
9529
9616
  const previewUrls = (experiment.variations ?? []).filter((variation) => !variation.is_control).map((variation) => ({
@@ -9531,9 +9618,9 @@ async function getQaPreviewUrls(creds, experimentId) {
9531
9618
  variationName: variation.name,
9532
9619
  url: buildQaPreviewUrl2(storeUrl, experiment.id, variation.id).toString()
9533
9620
  }));
9534
- return { previewUrls, unavailable: false };
9621
+ return { previewUrls, unavailable: false, dashboardUrl };
9535
9622
  } catch {
9536
- return { previewUrls: [], unavailable: true };
9623
+ return { previewUrls: [], unavailable: true, dashboardUrl: fallbackDashboardUrl };
9537
9624
  }
9538
9625
  }
9539
9626
  function printQaPreviewUrls(experimentId, result) {
@@ -9652,13 +9739,15 @@ async function resolveExperimentForFilesystemOrId(value) {
9652
9739
  return { experimentId: exp.id, folder: null, label: exp.name };
9653
9740
  }
9654
9741
  async function refreshExperimentFilesystemFolderIfNeeded(creds, folder, experimentId) {
9655
- if (!folder) return;
9742
+ if (!folder) return false;
9656
9743
  try {
9657
9744
  const refreshed = await getExperimentFilesystem(creds, experimentId);
9658
9745
  if (refreshed.ok && refreshed.data) {
9746
+ const assets = tryReadFilesystemSession(folder)?.assets;
9659
9747
  writeExperimentFilesystemFiles(folder, refreshed.data.files);
9660
9748
  writeFilesystemSession(folder, {
9661
9749
  schemaVersion: 1,
9750
+ ...assets ? { assets } : {},
9662
9751
  baseUrl: creds.baseUrl,
9663
9752
  experimentId,
9664
9753
  pulledAt: (/* @__PURE__ */ new Date()).toISOString(),
@@ -9666,11 +9755,13 @@ async function refreshExperimentFilesystemFolderIfNeeded(creds, folder, experime
9666
9755
  refreshed.data.files.map((file) => [file.path, { sha256: file.sha256 }])
9667
9756
  )
9668
9757
  });
9758
+ return true;
9669
9759
  }
9670
9760
  } catch {
9671
9761
  }
9762
+ return false;
9672
9763
  }
9673
- async function refreshServerOwnedFilesIfNeeded(creds, folder, experimentId) {
9764
+ async function refreshServerOwnedFilesIfNeeded(creds, folder, experimentId, updateManifest = true) {
9674
9765
  if (!folder) return;
9675
9766
  try {
9676
9767
  const refreshed = await getExperimentFilesystem(creds, experimentId);
@@ -9681,23 +9772,25 @@ async function refreshServerOwnedFilesIfNeeded(creds, folder, experimentId) {
9681
9772
  if (serverOwned.length === 0) return;
9682
9773
  const session = readFilesystemSession(folder);
9683
9774
  writeExperimentFilesystemFiles(folder, serverOwned);
9684
- writeFilesystemSession(folder, {
9685
- ...session,
9686
- files: {
9687
- ...session.files,
9688
- ...Object.fromEntries(serverOwned.map((file) => [file.path, { sha256: file.sha256 }]))
9689
- }
9690
- });
9775
+ if (updateManifest)
9776
+ writeFilesystemSession(folder, {
9777
+ ...session,
9778
+ files: {
9779
+ ...session.files,
9780
+ ...Object.fromEntries(serverOwned.map((file) => [file.path, { sha256: file.sha256 }]))
9781
+ }
9782
+ });
9691
9783
  } catch {
9692
9784
  }
9693
9785
  }
9786
+ var EXPERIMENT_REFERENCE_DESCRIPTION = "Experiment ID, slug, or test key (for example BIO-085)";
9694
9787
  async function resolveExperiment(idOrSlug) {
9695
9788
  const creds = requireCredentials();
9696
- if (/^[0-9a-f-]{36}$/i.test(idOrSlug)) {
9789
+ if (/^[0-9a-f-]{36}$/i.test(idOrSlug) || /^[1-9][0-9]{9}$/.test(idOrSlug) || isTestKeyReference(idOrSlug)) {
9697
9790
  const res2 = await getExperiment(creds, idOrSlug);
9698
9791
  if (res2.ok) return res2.data;
9699
9792
  throw new CliError(
9700
- res2.error ?? `Experiment "${idOrSlug}" not found`,
9793
+ res2.status === 404 && isTestKeyReference(idOrSlug) ? `Experiment "${idOrSlug}" not found in selected shop ${creds.resolvedContext?.shopId ?? "(API key shop)"}. Test keys resolve within the selected shop; use --shop <shop-id> to change it.` : res2.error ?? `Experiment "${idOrSlug}" not found`,
9701
9794
  classifyStatus(res2.status),
9702
9795
  res2.errorDetails
9703
9796
  );
@@ -9751,16 +9844,28 @@ var list = new Command("list").alias("ls").description("List experiments (archiv
9751
9844
  }
9752
9845
  });
9753
9846
  });
9754
- var get = new Command("get").description("Get experiment details by ID or slug").argument("<id-or-slug>", "Experiment ID or slug").action(async function action4(idOrSlug) {
9847
+ var get = new Command("get").description(
9848
+ "Get experiment details by ID or slug. JSON readback always carries started_at, scheduled_start_at and qa_approved_at as ISO-8601 UTC timestamps or null (all null on a fresh draft)."
9849
+ ).argument("<id-or-slug>", EXPERIMENT_REFERENCE_DESCRIPTION).action(async function action4(idOrSlug) {
9755
9850
  const spinner = await createSpinner("Fetching experiment...");
9756
9851
  const exp = await resolveExperiment(idOrSlug);
9757
9852
  spinner?.stop();
9758
9853
  if (!exp) {
9759
9854
  fail(this, `Experiment "${idOrSlug}" not found.`, EXIT_CODES.validation);
9760
9855
  }
9761
- output(this, { experiment: exp }, () => printExperiment(exp));
9856
+ const creds = requireCredentials();
9857
+ const dashboardUrl = buildDashboardUrl(
9858
+ creds.baseUrl,
9859
+ exp.shop_id ?? creds.resolvedContext?.shopId,
9860
+ exp.id
9861
+ );
9862
+ output(
9863
+ this,
9864
+ { experiment: exp, dashboard_url: dashboardUrl },
9865
+ () => printExperiment(exp, dashboardUrl)
9866
+ );
9762
9867
  });
9763
- var attachIdea = new Command("attach-idea").description("Attach an experiment to an idea").argument("<experiment>", "Experiment ID or slug").requiredOption("--idea <key>", "Idea UUID or Apex backlog key").option("--follow-up", "Attach as a follow-up test").action(async function action5(experimentRef, opts) {
9868
+ var attachIdea = new Command("attach-idea").description("Attach an experiment to an idea").argument("<experiment>", EXPERIMENT_REFERENCE_DESCRIPTION).requiredOption("--idea <key>", "Idea UUID or Apex backlog key").option("--follow-up", "Attach as a follow-up test").action(async function action5(experimentRef, opts) {
9764
9869
  const experiment = await resolveExperiment(experimentRef);
9765
9870
  if (!experiment) fail(this, `Experiment "${experimentRef}" not found.`, EXIT_CODES.validation);
9766
9871
  const classified = classifyIdeaReference(opts.idea);
@@ -9780,7 +9885,7 @@ var attachIdea = new Command("attach-idea").description("Attach an experiment to
9780
9885
  );
9781
9886
  output(this, res.data, () => printSuccess("Idea attached."));
9782
9887
  });
9783
- var detachIdea = new Command("detach-idea").description("Detach an experiment from its idea").argument("<experiment>", "Experiment ID or slug").action(async function action6(experimentRef) {
9888
+ var detachIdea = new Command("detach-idea").description("Detach an experiment from its idea").argument("<experiment>", EXPERIMENT_REFERENCE_DESCRIPTION).action(async function action6(experimentRef) {
9784
9889
  const experiment = await resolveExperiment(experimentRef);
9785
9890
  if (!experiment) fail(this, `Experiment "${experimentRef}" not found.`, EXIT_CODES.validation);
9786
9891
  const res = await detachExperimentIdea(requireCredentials(), experiment.id);
@@ -9793,7 +9898,7 @@ var detachIdea = new Command("detach-idea").description("Detach an experiment fr
9793
9898
  );
9794
9899
  output(this, res.data, () => printSuccess("Idea detached."));
9795
9900
  });
9796
- var results = new Command("results").description("Read experiment results and timeseries by ID or slug").argument("<id-or-slug>", "Experiment ID or slug").option("--currency <code>", "Currency code for revenue formatting").action(async function action7(idOrSlug, opts) {
9901
+ var results = new Command("results").description("Read experiment results and timeseries by ID or slug").argument("<id-or-slug>", EXPERIMENT_REFERENCE_DESCRIPTION).option("--currency <code>", "Currency code for revenue formatting").action(async function action7(idOrSlug, opts) {
9797
9902
  const creds = requireCredentials();
9798
9903
  const spinner = await createSpinner("Fetching experiment results...");
9799
9904
  try {
@@ -9843,7 +9948,7 @@ var DAY_NAMES = {
9843
9948
  var countingTypes = ["standard", "setup_test", "aa_test", "non_program"];
9844
9949
  var update = new Command("update").description("Update an experiment").addOption(
9845
9950
  new Option("--counting-type <type>", "Draft experiment classification").choices(countingTypes)
9846
- ).argument("<id-or-slug>", "Experiment ID or slug").option("--results-weekdays <days>", "Comma-separated weekdays").option("--results-timezone <timezone>", "IANA timezone for the results window").option("--clear-results-weekdays", "Clear the results weekday window").action(async function action8(reference, opts) {
9951
+ ).argument("<id-or-slug>", EXPERIMENT_REFERENCE_DESCRIPTION).option("--results-weekdays <days>", "Comma-separated weekdays").option("--results-timezone <timezone>", "IANA timezone for the results window").option("--clear-results-weekdays", "Clear the results weekday window").action(async function action8(reference, opts) {
9847
9952
  if (opts.clearResultsWeekdays && (opts.resultsWeekdays || opts.resultsTimezone)) {
9848
9953
  fail(
9849
9954
  this,
@@ -9896,32 +10001,240 @@ function parseHttpDestination(value) {
9896
10001
  }
9897
10002
  return trimmed;
9898
10003
  }
9899
- function sourceStoreUrl(sourceRules) {
9900
- for (const rule of sourceRules) {
9901
- if (rule.includes("*")) continue;
9902
- try {
9903
- const url = new URL(rule);
9904
- if (url.protocol === "http:" || url.protocol === "https:") {
9905
- return rule;
9906
- }
9907
- } catch {
10004
+ function readCreateJson(command, reference) {
10005
+ const file = reference.startsWith("@") ? reference.slice(1) : reference;
10006
+ try {
10007
+ const parsed = JSON.parse(import_node_fs7.default.readFileSync(import_node_path5.default.resolve(process.cwd(), file), "utf8"));
10008
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
10009
+ throw new Error("expected an object");
10010
+ return parsed;
10011
+ } catch (error) {
10012
+ fail(
10013
+ command,
10014
+ `Invalid audience JSON ${reference}: ${error instanceof Error ? error.message : String(error)}`,
10015
+ EXIT_CODES.validation
10016
+ );
10017
+ }
10018
+ }
10019
+ var GOAL_TYPES = ["click", "pageview", "custom", "revenue"];
10020
+ var GOAL_MEASUREMENTS = ["conversion", "value"];
10021
+ var GOAL_VALUE_UNITS = ["currency", "number", "duration_seconds", "percent"];
10022
+ var INLINE_GOAL_FIELDS = [
10023
+ "name",
10024
+ "type",
10025
+ "measurement",
10026
+ "value_property",
10027
+ "value_unit",
10028
+ "selector",
10029
+ "text_match",
10030
+ "url_pattern",
10031
+ "page_id",
10032
+ "event_name",
10033
+ "count_once"
10034
+ ];
10035
+ var GOAL_UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
10036
+ var GOAL_EVENT_NAME = /^[a-zA-Z0-9_.:-]{1,120}$/;
10037
+ var GOAL_VALUE_PROPERTY = /^[A-Za-z0-9_.-]{1,120}$/;
10038
+ function normalizeNewGoalKey(key) {
10039
+ const normalized = key.trim().toLowerCase().replace(/-/g, "_");
10040
+ return normalized === "page" ? "page_id" : normalized;
10041
+ }
10042
+ function validateNewGoal(command, value, body5) {
10043
+ const invalid = (reason) => fail(command, `Invalid --goal "new:${value}"; ${reason}`, EXIT_CODES.validation);
10044
+ const text = (field) => {
10045
+ const raw = body5[field];
10046
+ return typeof raw === "string" && raw.length > 0 ? raw : void 0;
10047
+ };
10048
+ for (const key of Object.keys(body5)) {
10049
+ if (INLINE_GOAL_FIELDS.includes(key)) continue;
10050
+ invalid(
10051
+ key === "capture" || key === "filter" ? `${key} is not expressible inline; create the goal with \`apex goals create\` and pass its id` : `unknown field "${key}"; inline fields are ${INLINE_GOAL_FIELDS.join(", ")}`
10052
+ );
10053
+ }
10054
+ const type = text("type");
10055
+ const name = text("name");
10056
+ if (!type || !name) invalid("type and name are required");
10057
+ if (!GOAL_TYPES.includes(type)) invalid(`type must be one of ${GOAL_TYPES.join(", ")}`);
10058
+ if (name.length > 255) invalid("name must be 255 characters or fewer");
10059
+ const measurement = text("measurement") ?? "conversion";
10060
+ if (!GOAL_MEASUREMENTS.includes(measurement))
10061
+ invalid(`measurement must be one of ${GOAL_MEASUREMENTS.join(", ")}`);
10062
+ if (body5.count_once !== void 0) {
10063
+ const flag = String(body5.count_once).toLowerCase();
10064
+ if (flag !== "true" && flag !== "false") invalid("count-once must be true or false");
10065
+ body5.count_once = flag === "true";
10066
+ }
10067
+ if (type === "click" && !text("selector") && !text("text_match"))
10068
+ invalid("click goals need selector=<css> or text-match=<text>");
10069
+ if (type === "pageview" && !text("page_id") && !text("url_pattern"))
10070
+ invalid("pageview goals need page=<page-id> or url-pattern=<pattern>");
10071
+ if (type === "custom" && !text("event_name")) invalid("custom goals need event-name=<name>");
10072
+ const eventName = text("event_name");
10073
+ if (eventName && !GOAL_EVENT_NAME.test(eventName))
10074
+ invalid("event-name must be 1-120 letters, numbers, dots, colons, underscores, or hyphens");
10075
+ if ((type === "custom" || type === "revenue") && text("page_id"))
10076
+ invalid(`page is not supported for ${type} goals`);
10077
+ const pageId = text("page_id");
10078
+ if (pageId && !GOAL_UUID.test(pageId))
10079
+ invalid("page must be a page UUID (list them with `apex pages list`)");
10080
+ const valueProperty = text("value_property");
10081
+ const valueUnit = text("value_unit");
10082
+ if (measurement !== "value" && (valueProperty || valueUnit))
10083
+ invalid("value-property and value-unit need measurement=value");
10084
+ if (type === "revenue" && valueProperty)
10085
+ invalid("revenue goals read the order revenue; value-property is not supported");
10086
+ if (valueProperty && !GOAL_VALUE_PROPERTY.test(valueProperty))
10087
+ invalid("value-property must be 1-120 letters, numbers, dots, underscores, or hyphens");
10088
+ if (valueUnit && !GOAL_VALUE_UNITS.includes(valueUnit))
10089
+ invalid(`value-unit must be one of ${GOAL_VALUE_UNITS.join(", ")}`);
10090
+ }
10091
+ function parseNewGoal(command, value) {
10092
+ const body5 = {};
10093
+ for (const field of value.split(",")) {
10094
+ const separator = field.indexOf("=");
10095
+ if (separator <= 0 || separator === field.length - 1) {
10096
+ fail(
10097
+ command,
10098
+ `Invalid --goal "new:${value}"; use new:type=<type>,selector=<css>,name=<text>`,
10099
+ EXIT_CODES.validation
10100
+ );
9908
10101
  }
10102
+ body5[normalizeNewGoalKey(field.slice(0, separator))] = field.slice(separator + 1).trim();
10103
+ }
10104
+ validateNewGoal(command, value, body5);
10105
+ return body5;
10106
+ }
10107
+ async function resolveNewGoalPages(command, creds, goals, interactive) {
10108
+ const pending = goals.filter((goal) => goal.body.type === "click" && !goal.body.page_id);
10109
+ if (pending.length === 0) return;
10110
+ if (!interactive) {
10111
+ fail(
10112
+ command,
10113
+ `Invalid --goal "${pending[0].flagValue}"; click goals are page-scoped in v1 \u2014 add page=<page-id> (see \`apex pages list\`)`,
10114
+ EXIT_CODES.validation
10115
+ );
10116
+ }
10117
+ const listed = await requestTargetingResource(creds, "GET", "pages", void 0, void 0, 100);
10118
+ if (!listed.ok || !listed.data)
10119
+ fail(command, listed.error ?? "Failed to list pages", classifyStatus(listed.status));
10120
+ const rows = listed.data.data;
10121
+ if (rows.length === 0)
10122
+ fail(
10123
+ command,
10124
+ "Click goals are page-scoped in v1 and this shop has no pages yet; create one with `apex pages create`.",
10125
+ EXIT_CODES.validation
10126
+ );
10127
+ printChoices("Pages (enter id/number)", rows);
10128
+ for (const goal of pending) {
10129
+ const answer = await askCreate(`Page for click goal "${String(goal.body.name)}": `);
10130
+ if (!answer)
10131
+ fail(
10132
+ command,
10133
+ `Invalid --goal "${goal.flagValue}"; click goals need a page`,
10134
+ EXIT_CODES.validation
10135
+ );
10136
+ const chosen = /^\d+$/.test(answer) && rows[Number(answer) - 1] ? rows[Number(answer) - 1].id : answer;
10137
+ if (!GOAL_UUID.test(chosen))
10138
+ fail(
10139
+ command,
10140
+ `Invalid page "${chosen}"; pick a listed number or a page UUID`,
10141
+ EXIT_CODES.validation
10142
+ );
10143
+ goal.body.page_id = chosen;
9909
10144
  }
9910
- return null;
9911
10145
  }
9912
- var create = new Command("create").description("Create a new experiment").addOption(
10146
+ async function askCreate(question) {
10147
+ const readline3 = await import("node:readline");
10148
+ const rl = readline3.createInterface({ input: process.stdin, output: process.stderr });
10149
+ return new Promise(
10150
+ (resolve2) => rl.question(question, (answer) => {
10151
+ rl.close();
10152
+ resolve2(answer.trim());
10153
+ })
10154
+ );
10155
+ }
10156
+ function printChoices(kind5, rows) {
10157
+ process.stderr.write(
10158
+ `${kind5}:
10159
+ ${rows.map((row, index) => ` ${index + 1}. ${row.name} (${row.id})`).join("\n")}
10160
+ `
10161
+ );
10162
+ }
10163
+ var create = new Command("create").description(
10164
+ "Create a complete experiment with page targeting, audiences, goals, and an optional QA preview URL. JSON readback always carries started_at, scheduled_start_at and qa_approved_at as ISO-8601 UTC timestamps or null (all null on a fresh draft)."
10165
+ ).addOption(
9913
10166
  new Option("--counting-type <type>", "Experiment classification").choices(countingTypes).default("standard")
9914
- ).requiredOption("--name <name>", "Experiment name").option("--slug <slug>", "URL-safe slug for GitOps").option("--description <desc>", "Description").option("--type <type>", "Experiment type: mutation or redirect", "mutation").option(
10167
+ ).option("--name <name>", "Experiment name").option("--slug <slug>", "URL-safe slug for GitOps").option("--description <desc>", "Description").option("--type <type>", "Experiment type: mutation or redirect", "mutation").option(
9915
10168
  "--source-url <pattern>",
9916
10169
  "Positive source URL rule (repeatable; accepts exact URLs, paths, or wildcards)",
9917
10170
  collectCreateOption,
9918
10171
  []
10172
+ ).option(
10173
+ "--page-url <pattern>",
10174
+ "Page URL targeting pattern (repeatable)",
10175
+ collectCreateOption,
10176
+ []
10177
+ ).option(
10178
+ "--audience <segment-id|new:@file.json>",
10179
+ "Audience segment (repeatable)",
10180
+ collectCreateOption,
10181
+ []
10182
+ ).option(
10183
+ "--goal <goal-id|new:fields>",
10184
+ "Goal id or new:type=...,selector=...,name=... (repeatable)",
10185
+ collectCreateOption,
10186
+ []
9919
10187
  ).option(
9920
10188
  "--destination <url>",
9921
10189
  "Redirect treatment destination (repeatable; creates one treatment per URL)",
9922
10190
  collectCreateOption,
9923
10191
  []
9924
- ).option("--traffic <pct>", "Traffic allocation (0-100)", "100").option("--idea <id>", "Idea UUID or Apex backlog key").option("--backlog-item <id>", "Alias for --idea").action(async function action9(opts) {
10192
+ ).option("--traffic <pct>", "Traffic allocation (0-100)", "100").option("--idea <id>", "Idea UUID or Apex backlog key").option("--backlog-item <id>", "Alias for --idea").option("--preview", "Print a QA preview URL for the first treatment").option("--yes", "Do not prompt for missing values").action(async function action9(opts) {
10193
+ const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY && !opts.yes);
10194
+ if (!opts.name && interactive) opts.name = await askCreate("Experiment name: ");
10195
+ if (!opts.name?.trim()) fail(this, "Missing required value: --name", EXIT_CODES.validation);
10196
+ if (opts.pageUrl.length === 0 && opts.sourceUrl.length === 0 && interactive) {
10197
+ const answer = await askCreate("Page URL (--page-url): ");
10198
+ if (answer) opts.pageUrl.push(answer);
10199
+ }
10200
+ const creds = requireCredentials();
10201
+ if (opts.audience.length === 0 && interactive) {
10202
+ const listed = await requestTargetingResource(
10203
+ creds,
10204
+ "GET",
10205
+ "segments",
10206
+ void 0,
10207
+ void 0,
10208
+ 100
10209
+ );
10210
+ if (!listed.ok || !listed.data)
10211
+ fail(this, listed.error ?? "Failed to list segments", classifyStatus(listed.status));
10212
+ const rows = listed.data.data;
10213
+ printChoices("Audiences (enter id/number, or none)", rows);
10214
+ const answer = await askCreate("Audience: ");
10215
+ if (answer && answer.toLowerCase() !== "none")
10216
+ opts.audience.push(
10217
+ /^\d+$/.test(answer) && rows[Number(answer) - 1] ? rows[Number(answer) - 1].id : answer
10218
+ );
10219
+ }
10220
+ if (opts.goal.length === 0 && interactive) {
10221
+ const listed = await listGoals(creds, { limit: 100 });
10222
+ if (!listed.ok || !listed.data)
10223
+ fail(this, listed.error ?? "Failed to list goals", classifyStatus(listed.status));
10224
+ const rows = listed.data.data;
10225
+ printChoices("Goals (enter ids/numbers separated by commas, or none)", rows);
10226
+ const answer = await askCreate("Goals: ");
10227
+ if (answer && answer.toLowerCase() !== "none")
10228
+ opts.goal.push(
10229
+ ...answer.split(",").map((part) => part.trim()).filter(Boolean).map(
10230
+ (part) => /^\d+$/.test(part) && rows[Number(part) - 1] ? rows[Number(part) - 1].id : part
10231
+ )
10232
+ );
10233
+ }
10234
+ if (this.getOptionValueSource("traffic") === "default" && interactive) {
10235
+ const answer = await askCreate("Traffic percentage [100]: ");
10236
+ if (answer) opts.traffic = answer;
10237
+ }
9925
10238
  if (opts.type !== "mutation" && opts.type !== "redirect") {
9926
10239
  fail(this, "--type must be mutation or redirect", EXIT_CODES.validation);
9927
10240
  }
@@ -9929,9 +10242,9 @@ var create = new Command("create").description("Create a new experiment").addOpt
9929
10242
  if (!Number.isFinite(traffic) || traffic < 0 || traffic > 100) {
9930
10243
  fail(this, "--traffic must be a number from 0 to 100", EXIT_CODES.validation);
9931
10244
  }
9932
- const sourceRules = opts.sourceUrl.map((value) => value.trim());
10245
+ const sourceRules = [...opts.sourceUrl, ...opts.pageUrl].map((value) => value.trim());
9933
10246
  if (sourceRules.some((value) => value.length === 0)) {
9934
- fail(this, "--source-url cannot be empty", EXIT_CODES.validation);
10247
+ fail(this, "--source-url/--page-url cannot be empty", EXIT_CODES.validation);
9935
10248
  }
9936
10249
  if (opts.type !== "redirect" && opts.destination.length > 0) {
9937
10250
  fail(this, "--destination is only valid with --type redirect", EXIT_CODES.validation);
@@ -9951,8 +10264,15 @@ var create = new Command("create").description("Create a new experiment").addOpt
9951
10264
  );
9952
10265
  }
9953
10266
  const destinations = opts.destination.map(parseHttpDestination);
10267
+ const previewStoreUrl = deriveStoreUrl(sourceRules);
10268
+ if (opts.preview && !previewStoreUrl) {
10269
+ fail(
10270
+ this,
10271
+ "--preview needs an absolute page URL: pass --page-url https://store.example/path (wildcard and path-only rules cannot seed the preview link), or create the experiment without --preview and run `apex preview <id> --store-url <url>`",
10272
+ EXIT_CODES.validation
10273
+ );
10274
+ }
9954
10275
  const armWeight = destinations.length > 0 ? 1 / (destinations.length + 1) : null;
9955
- const creds = requireCredentials();
9956
10276
  let backlogItemId;
9957
10277
  let ideaKey;
9958
10278
  if (opts.idea !== void 0 && opts.backlogItem !== void 0 && opts.idea.trim().toUpperCase() !== opts.backlogItem.trim().toUpperCase()) {
@@ -9973,47 +10293,151 @@ var create = new Command("create").description("Create a new experiment").addOpt
9973
10293
  fail(this, "--idea must be a UUID or Apex backlog key", EXIT_CODES.validation);
9974
10294
  }
9975
10295
  }
10296
+ const newGoals = /* @__PURE__ */ new Map();
10297
+ for (const [index, goal] of opts.goal.entries()) {
10298
+ if (goal.startsWith("new:")) newGoals.set(index, parseNewGoal(this, goal.slice(4)));
10299
+ }
10300
+ await resolveNewGoalPages(
10301
+ this,
10302
+ creds,
10303
+ [...newGoals].map(([index, body5]) => ({ flagValue: opts.goal[index], body: body5 })),
10304
+ interactive
10305
+ );
10306
+ const createdSegments = [];
10307
+ const createdGoals = [];
10308
+ const segmentIds = [];
10309
+ const goalIds = [];
10310
+ const partial = () => [
10311
+ ...createdSegments.map((item) => `segment ${item.id}`),
10312
+ ...createdGoals.map((item) => `goal ${item.id}`)
10313
+ ];
10314
+ for (const audience of opts.audience) {
10315
+ if (!audience.startsWith("new:")) {
10316
+ segmentIds.push(audience);
10317
+ continue;
10318
+ }
10319
+ const reference = audience.slice(4);
10320
+ if (!reference.startsWith("@"))
10321
+ fail(
10322
+ this,
10323
+ `Invalid --audience "${audience}"; new audiences use new:@file.json`,
10324
+ EXIT_CODES.validation
10325
+ );
10326
+ const response = await requestTargetingResource(
10327
+ creds,
10328
+ "POST",
10329
+ "segments",
10330
+ void 0,
10331
+ readCreateJson(this, reference)
10332
+ );
10333
+ if (!response.ok || !response.data)
10334
+ fail(
10335
+ this,
10336
+ `${response.error ?? "Failed to create audience"}${partial().length ? `; already created: ${partial().join(", ")}` : ""}`,
10337
+ classifyStatus(response.status)
10338
+ );
10339
+ const segment = response.data;
10340
+ createdSegments.push(segment);
10341
+ segmentIds.push(segment.id);
10342
+ }
10343
+ for (const [index, goal] of opts.goal.entries()) {
10344
+ const body5 = newGoals.get(index);
10345
+ if (!body5) {
10346
+ goalIds.push(goal);
10347
+ continue;
10348
+ }
10349
+ const response = await createGoal(creds, body5);
10350
+ if (!response.ok || !response.data)
10351
+ fail(
10352
+ this,
10353
+ `${response.error ?? "Failed to create goal"}${partial().length ? `; already created: ${partial().join(", ")}` : ""}`,
10354
+ classifyStatus(response.status)
10355
+ );
10356
+ createdGoals.push(response.data);
10357
+ goalIds.push(response.data.id);
10358
+ }
9976
10359
  const spinner = await createSpinner("Creating experiment...");
9977
- const res = await createExperiment(creds, {
9978
- name: opts.name,
9979
- slug: opts.slug ?? null,
9980
- description: opts.description ?? null,
9981
- delivery_type: opts.type,
9982
- counting_type: opts.countingType,
9983
- traffic_allocation: traffic / 100,
9984
- ...backlogItemId ? { backlog_item_id: backlogItemId } : {},
9985
- ...ideaKey ? { idea_key: ideaKey } : {},
9986
- ...sourceRules.length > 0 ? {
9987
- store_url: sourceStoreUrl(sourceRules),
9988
- targeting: {
9989
- url: sourceRules.map((pattern) => ({
9990
- type: "simple",
9991
- pattern,
9992
- include: true
9993
- }))
9994
- }
9995
- } : {},
9996
- ...armWeight !== null ? {
9997
- variations: [
9998
- {
9999
- name: "Control",
10000
- weight: armWeight,
10001
- is_control: true
10002
- },
10003
- ...destinations.map((redirectUrl, index) => ({
10004
- name: destinations.length === 1 ? "Treatment" : `Treatment ${index + 1}`,
10005
- weight: armWeight,
10006
- is_control: false,
10007
- redirect_url: redirectUrl
10008
- }))
10009
- ]
10010
- } : {}
10011
- });
10360
+ let res;
10361
+ if (segmentIds.length === 0 && goalIds.length === 0) {
10362
+ res = await createExperiment(creds, {
10363
+ name: opts.name,
10364
+ slug: opts.slug ?? null,
10365
+ description: opts.description ?? null,
10366
+ delivery_type: opts.type,
10367
+ counting_type: opts.countingType,
10368
+ traffic_allocation: traffic / 100,
10369
+ ...backlogItemId ? { backlog_item_id: backlogItemId } : {},
10370
+ ...ideaKey ? { idea_key: ideaKey } : {},
10371
+ ...sourceRules.length > 0 ? {
10372
+ store_url: previewStoreUrl,
10373
+ targeting: {
10374
+ url: sourceRules.map((pattern) => ({
10375
+ type: "simple",
10376
+ pattern,
10377
+ include: true
10378
+ }))
10379
+ }
10380
+ } : {},
10381
+ ...armWeight !== null ? {
10382
+ variations: [
10383
+ {
10384
+ name: "Control",
10385
+ weight: armWeight,
10386
+ is_control: true
10387
+ },
10388
+ ...destinations.map((redirectUrl, index) => ({
10389
+ name: destinations.length === 1 ? "Treatment" : `Treatment ${index + 1}`,
10390
+ weight: armWeight,
10391
+ is_control: false,
10392
+ redirect_url: redirectUrl
10393
+ }))
10394
+ ]
10395
+ } : {}
10396
+ });
10397
+ } else {
10398
+ res = await createExperiment(creds, {
10399
+ name: opts.name,
10400
+ slug: opts.slug ?? null,
10401
+ description: opts.description ?? null,
10402
+ delivery_type: opts.type,
10403
+ counting_type: opts.countingType,
10404
+ traffic_allocation: traffic / 100,
10405
+ ...backlogItemId ? { backlog_item_id: backlogItemId } : {},
10406
+ ...ideaKey ? { idea_key: ideaKey } : {},
10407
+ ...sourceRules.length > 0 ? {
10408
+ store_url: previewStoreUrl,
10409
+ targeting: {
10410
+ url: sourceRules.map((pattern) => ({
10411
+ type: "simple",
10412
+ pattern,
10413
+ include: true
10414
+ })),
10415
+ ...segmentIds.length ? { segments: segmentIds.map((id) => ({ id, include: true })) } : {}
10416
+ }
10417
+ } : segmentIds.length ? { targeting: { segments: segmentIds.map((id) => ({ id, include: true })) } } : {},
10418
+ ...goalIds.length ? { goal_ids: goalIds, primary_goal_id: goalIds[0] } : {},
10419
+ ...armWeight !== null ? {
10420
+ variations: [
10421
+ {
10422
+ name: "Control",
10423
+ weight: armWeight,
10424
+ is_control: true
10425
+ },
10426
+ ...destinations.map((redirectUrl, index) => ({
10427
+ name: destinations.length === 1 ? "Treatment" : `Treatment ${index + 1}`,
10428
+ weight: armWeight,
10429
+ is_control: false,
10430
+ redirect_url: redirectUrl
10431
+ }))
10432
+ ]
10433
+ } : {}
10434
+ });
10435
+ }
10012
10436
  if (!res.ok || !res.data) {
10013
10437
  spinner?.fail("Failed to create experiment");
10014
10438
  fail(
10015
10439
  this,
10016
- res.error ?? "Failed to create experiment",
10440
+ `${res.error ?? "Failed to create experiment"}${partial().length ? `; already created: ${partial().join(", ")}` : ""}`,
10017
10441
  classifyStatus(res.status),
10018
10442
  res.errorDetails
10019
10443
  );
@@ -10022,18 +10446,49 @@ var create = new Command("create").description("Create a new experiment").addOpt
10022
10446
  spinner?.succeed(
10023
10447
  outcome === "created" ? "Experiment created" : `Already linked \u2014 returning existing experiment ${res.data.test_key ?? res.data.slug ?? res.data.id}`
10024
10448
  );
10449
+ let previewUrl = null;
10450
+ let previewNote = null;
10451
+ if (opts.preview) {
10452
+ const variation = res.data.variations?.find((item) => !item.is_control);
10453
+ const storeUrl = res.data.store_url ?? previewStoreUrl;
10454
+ if (storeUrl && variation) {
10455
+ previewUrl = buildQaPreviewUrl2(storeUrl, res.data.id, variation.id).toString();
10456
+ } else {
10457
+ previewNote = `No preview link: the experiment has no ${variation ? "store URL" : "treatment variation"} yet. Add one, then run \`apex preview ${res.data.id}\`.`;
10458
+ }
10459
+ }
10025
10460
  output(
10026
10461
  this,
10027
10462
  {
10028
10463
  outcome,
10029
10464
  backlog_item_id: backlogItemId ?? null,
10030
10465
  idea_key: ideaKey ?? null,
10031
- experiment: res.data
10466
+ experiment: res.data,
10467
+ segments: createdSegments,
10468
+ goals: createdGoals,
10469
+ dashboard_url: buildDashboardUrl(
10470
+ creds.baseUrl,
10471
+ res.data?.shop_id ?? creds.resolvedContext?.shopId,
10472
+ res.data.id
10473
+ ),
10474
+ preview_url: previewUrl,
10475
+ preview_note: previewNote
10032
10476
  },
10033
- () => printExperiment(res.data)
10477
+ () => {
10478
+ printExperiment(
10479
+ res.data,
10480
+ buildDashboardUrl(
10481
+ creds.baseUrl,
10482
+ res.data?.shop_id ?? creds.resolvedContext?.shopId,
10483
+ res.data.id
10484
+ )
10485
+ );
10486
+ if (previewUrl) console.log(`Preview: ${previewUrl}`);
10487
+ if (previewNote) console.log(previewNote);
10488
+ }
10034
10489
  );
10035
10490
  });
10036
- var pause = new Command("pause").description("Pause a running experiment").argument("<id-or-slug>", "Experiment ID or slug").action(async function action10(idOrSlug) {
10491
+ var pause = new Command("pause").description("Pause a running experiment").argument("<id-or-slug>", EXPERIMENT_REFERENCE_DESCRIPTION).action(async function action10(idOrSlug) {
10037
10492
  const creds = requireCredentials();
10038
10493
  const spinner = await createSpinner("Pausing experiment...");
10039
10494
  const exp = await resolveExperiment(idOrSlug);
@@ -10055,7 +10510,7 @@ var pause = new Command("pause").description("Pause a running experiment").argum
10055
10510
  spinner.succeed(`Experiment "${exp.name}" is now ${statusBadge("paused")}`);
10056
10511
  });
10057
10512
  });
10058
- var complete = new Command("complete").description("Complete/stop an experiment").argument("<id-or-slug>", "Experiment ID or slug").action(async function action11(idOrSlug) {
10513
+ var complete = new Command("complete").description("Complete/stop an experiment").argument("<id-or-slug>", EXPERIMENT_REFERENCE_DESCRIPTION).action(async function action11(idOrSlug) {
10059
10514
  const creds = requireCredentials();
10060
10515
  const spinner = await createSpinner("Completing experiment...");
10061
10516
  const exp = await resolveExperiment(idOrSlug);
@@ -10077,7 +10532,7 @@ var complete = new Command("complete").description("Complete/stop an experiment"
10077
10532
  spinner.succeed(`Experiment "${exp.name}" is now ${statusBadge("completed")}`);
10078
10533
  });
10079
10534
  });
10080
- var del = new Command("delete").alias("rm").description("Delete an experiment").argument("<id-or-slug>", "Experiment ID or slug").option("-f, --force", "Skip confirmation").action(async function action12(idOrSlug, opts) {
10535
+ var del = new Command("delete").alias("rm").description("Delete an experiment").argument("<id-or-slug>", EXPERIMENT_REFERENCE_DESCRIPTION).option("-f, --force", "Skip confirmation").action(async function action12(idOrSlug, opts) {
10081
10536
  const creds = requireCredentials();
10082
10537
  const exp = await resolveExperiment(idOrSlug);
10083
10538
  if (!exp) {
@@ -10122,7 +10577,7 @@ var del = new Command("delete").alias("rm").description("Delete an experiment").
10122
10577
  () => spinner.succeed(`Experiment "${exp.name}" deleted.`)
10123
10578
  );
10124
10579
  });
10125
- var pullFilesystem = new Command("pull").description("Pull an experiment as an editable filesystem").argument("<id-or-slug>", "Experiment ID or slug").option("--out <path>", "Target folder").action(async function action13(idOrSlug, opts) {
10580
+ var pullFilesystem = new Command("pull").description("Pull an experiment as an editable filesystem").argument("<id-or-slug>", EXPERIMENT_REFERENCE_DESCRIPTION).option("--out <path>", "Target folder").action(async function action13(idOrSlug, opts) {
10126
10581
  const creds = requireCredentials();
10127
10582
  const spinner = await createSpinner("Pulling experiment filesystem...");
10128
10583
  try {
@@ -10168,7 +10623,15 @@ var planFilesystem = new Command("plan").description("Plan experiment filesystem
10168
10623
  try {
10169
10624
  const folder = folderPath(folderArg);
10170
10625
  const session = readFilesystemSession(folder);
10171
- const files = listExperimentFilesystemFiles(folder, session);
10626
+ const files = await rewriteExperimentFilesystemAssets({
10627
+ files: listExperimentFilesystemFiles(folder, session),
10628
+ folder,
10629
+ baseUrl: creds.baseUrl,
10630
+ enabled: true,
10631
+ upload: null,
10632
+ assets: session.assets ?? {},
10633
+ credentials: creds
10634
+ });
10172
10635
  const res = await planExperimentFilesystem(creds, session.experimentId, { files });
10173
10636
  if (!res.ok || !res.data) {
10174
10637
  fail(
@@ -10201,21 +10664,27 @@ var planFilesystem = new Command("plan").description("Plan experiment filesystem
10201
10664
  fail(this, error instanceof Error ? error.message : String(error));
10202
10665
  }
10203
10666
  });
10204
- var applyFilesystem = new Command("apply").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) {
10667
+ var applyFilesystem = new Command("apply").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) {
10205
10668
  const creds = requireCredentials();
10206
10669
  try {
10207
10670
  const folder = folderPath(folderArg);
10208
10671
  const session = readFilesystemSession(folder);
10209
10672
  if (opts.force)
10210
10673
  process.stderr.write("--force is deprecated; use --accept-qa-invalidation instead.\n");
10674
+ const assets = session.assets ?? {};
10211
10675
  const files = await rewriteExperimentFilesystemAssets({
10212
10676
  files: listExperimentFilesystemFiles(folder, session),
10213
10677
  folder,
10214
10678
  baseUrl: creds.baseUrl,
10215
10679
  enabled: opts.assetUpload !== false,
10216
10680
  upload: uploadAssets,
10681
+ assets,
10217
10682
  credentials: creds
10218
10683
  });
10684
+ if (Object.keys(assets).length > 0) {
10685
+ session.assets = assets;
10686
+ writeFilesystemSession(folder, session);
10687
+ }
10219
10688
  const planRes = await planExperimentFilesystem(creds, session.experimentId, { files });
10220
10689
  if (!planRes.ok || !planRes.data) {
10221
10690
  fail(
@@ -10240,17 +10709,28 @@ var applyFilesystem = new Command("apply").description("Apply experiment filesys
10240
10709
  });
10241
10710
  }
10242
10711
  if (planRes.data.changes.length === 0) {
10712
+ const synced2 = opts.sync ? await refreshExperimentFilesystemFolderIfNeeded(creds, folder, session.experimentId) : false;
10713
+ if (!synced2)
10714
+ await refreshServerOwnedFilesIfNeeded(creds, folder, session.experimentId, true);
10715
+ const { dashboardUrl: dashboardUrl2 } = await getQaPreviewUrls(creds, session.experimentId);
10243
10716
  output(
10244
10717
  this,
10245
10718
  {
10246
10719
  folder,
10247
10720
  experimentId: session.experimentId,
10248
10721
  applied: false,
10722
+ synced: synced2,
10723
+ dashboardUrl: dashboardUrl2,
10249
10724
  plan: planRes.data,
10250
10725
  requiredAcknowledgment: planRes.data.invalidatesQa ? "accept-qa-invalidation" : null,
10251
10726
  message: "No changes."
10252
10727
  },
10253
- () => printFilesystemPlan(planRes.data)
10728
+ () => {
10729
+ printFilesystemPlan(planRes.data);
10730
+ console.log(
10731
+ synced2 ? "Local files were synced." : opts.sync ? "Local file sync failed; retry --sync or apex experiments pull." : "Local authored files were left unchanged; use --sync or apex experiments pull to refresh."
10732
+ );
10733
+ }
10254
10734
  );
10255
10735
  return;
10256
10736
  }
@@ -10278,9 +10758,29 @@ var applyFilesystem = new Command("apply").description("Apply experiment filesys
10278
10758
  });
10279
10759
  }
10280
10760
  spinner?.succeed("Applied experiment filesystem");
10281
- await refreshExperimentFilesystemFolderIfNeeded(creds, folder, session.experimentId);
10761
+ const synced = opts.sync ? await refreshExperimentFilesystemFolderIfNeeded(creds, folder, session.experimentId) : false;
10762
+ if (!synced) {
10763
+ await refreshServerOwnedFilesIfNeeded(
10764
+ creds,
10765
+ folder,
10766
+ session.experimentId,
10767
+ !res.data?.receipt?.files
10768
+ );
10769
+ if (res.data?.receipt?.files) {
10770
+ writeFilesystemSession(folder, {
10771
+ ...session,
10772
+ files: {
10773
+ ...session.files,
10774
+ ...Object.fromEntries(
10775
+ res.data.receipt.files.map((file) => [file.path, { sha256: file.sha256 }])
10776
+ )
10777
+ }
10778
+ });
10779
+ }
10780
+ }
10781
+ const syncMessage = synced ? "Local files were synced." : opts.sync ? "Local file sync failed; retry --sync or apex experiments pull." : "Local authored files were left unchanged; use --sync or apex experiments pull to refresh." + (res.data?.receipt?.files ? "" : " No receipt file digests; authored-file manifest entries left unchanged.");
10282
10782
  const previewResult = await getQaPreviewUrls(creds, session.experimentId);
10283
- const { previewUrls } = previewResult;
10783
+ const { previewUrls, dashboardUrl } = previewResult;
10284
10784
  output(
10285
10785
  this,
10286
10786
  {
@@ -10290,6 +10790,8 @@ var applyFilesystem = new Command("apply").description("Apply experiment filesys
10290
10790
  plan: planRes.data,
10291
10791
  requiredAcknowledgment: planRes.data.invalidatesQa ? "accept-qa-invalidation" : null,
10292
10792
  result: res.data,
10793
+ synced,
10794
+ dashboardUrl,
10293
10795
  receipt: res.data?.receipt ?? null,
10294
10796
  previewUrls
10295
10797
  },
@@ -10298,6 +10800,7 @@ var applyFilesystem = new Command("apply").description("Apply experiment filesys
10298
10800
  const receipt = res.data?.receipt;
10299
10801
  if (receipt) {
10300
10802
  console.log("\nReceipt");
10803
+ if (dashboardUrl) console.log(` Dashboard: ${dashboardUrl}`);
10301
10804
  console.log(` Status: ${receipt.status}`);
10302
10805
  console.log(` Classification: ${receipt.delivery_type} / ${receipt.counting_type}`);
10303
10806
  console.log(` Traffic: ${(receipt.traffic_allocation * 100).toFixed(0)}%`);
@@ -10315,6 +10818,7 @@ var applyFilesystem = new Command("apply").description("Apply experiment filesys
10315
10818
  }
10316
10819
  }
10317
10820
  printQaPreviewUrls(session.experimentId, previewResult);
10821
+ console.log(syncMessage);
10318
10822
  }
10319
10823
  );
10320
10824
  } catch (error) {
@@ -10322,7 +10826,11 @@ var applyFilesystem = new Command("apply").description("Apply experiment filesys
10322
10826
  fail(this, error instanceof Error ? error.message : String(error));
10323
10827
  }
10324
10828
  });
10325
- var qaStatus = new Command("status").description("Show or create the latest experiment QA run").argument("[folder-or-id]", "Experiment filesystem folder, ID, or slug", ".").option("--json", "Print the QA run in the standard JSON envelope").action(async function action16(target) {
10829
+ var qaStatus = new Command("status").description("Show or create the latest experiment QA run").argument(
10830
+ "[folder-or-id]",
10831
+ "Experiment filesystem folder, ID, slug, or test key (for example BIO-085)",
10832
+ "."
10833
+ ).option("--json", "Print the QA run in the standard JSON envelope").action(async function action16(target) {
10326
10834
  const creds = requireCredentials();
10327
10835
  const spinner = await createSpinner("Fetching QA run...");
10328
10836
  try {
@@ -10346,7 +10854,11 @@ var qaStatus = new Command("status").description("Show or create the latest expe
10346
10854
  throw error;
10347
10855
  }
10348
10856
  });
10349
- var qaCheck = new Command("check").description("Update one QA checklist item with status, notes, and evidence").argument("[folder-or-id]", "Experiment filesystem folder, ID, or slug", ".").requiredOption("--item <id>", "Checklist item id").addOption(
10857
+ var qaCheck = new Command("check").description("Update one QA checklist item with status, notes, and evidence").argument(
10858
+ "[folder-or-id]",
10859
+ "Experiment filesystem folder, ID, slug, or test key (for example BIO-085)",
10860
+ "."
10861
+ ).requiredOption("--item <id>", "Checklist item id").addOption(
10350
10862
  qaChoice("--status <status>", "Checklist status", checklistStatuses).makeOptionMandatory()
10351
10863
  ).option("--notes <text>", "Checklist notes").option("--evidence <json-or-path>", "Evidence JSON object or @path/to/evidence.json").option("--summary", "Print a compact run and changed values").option("--json", "Print raw QA run JSON").action(async function action17(target, opts) {
10352
10864
  validateQaChoice(this, "--status", opts.status, checklistStatuses);
@@ -10390,7 +10902,11 @@ var qaCheck = new Command("check").description("Update one QA checklist item wit
10390
10902
  throw error;
10391
10903
  }
10392
10904
  });
10393
- var qaNotes = new Command("notes").description("Update QA run notes or acceptance criteria summary").argument("[folder-or-id]", "Experiment filesystem folder, ID, or slug", ".").option("--notes <text>", "QA run notes").option("--acceptance <text>", "Acceptance criteria summary").option("--summary", "Print a compact run and changed values").option("--json", "Print raw QA run JSON").action(async function action18(target, opts) {
10905
+ var qaNotes = new Command("notes").description("Update QA run notes or acceptance criteria summary").argument(
10906
+ "[folder-or-id]",
10907
+ "Experiment filesystem folder, ID, slug, or test key (for example BIO-085)",
10908
+ "."
10909
+ ).option("--notes <text>", "QA run notes").option("--acceptance <text>", "Acceptance criteria summary").option("--summary", "Print a compact run and changed values").option("--json", "Print raw QA run JSON").action(async function action18(target, opts) {
10394
10910
  const creds = requireCredentials();
10395
10911
  const spinner = await createSpinner("Updating QA notes...");
10396
10912
  try {
@@ -10435,7 +10951,11 @@ var qaNotes = new Command("notes").description("Update QA run notes or acceptanc
10435
10951
  throw error;
10436
10952
  }
10437
10953
  });
10438
- var qaApprove = new Command("approve").description("Approve QA and move the experiment to ready_to_launch; qa reopen returns it to QA").argument("[folder-or-id]", "Experiment filesystem folder, ID, or slug", ".").action(async function action19(target) {
10954
+ var qaApprove = new Command("approve").description("Approve QA and move the experiment to ready_to_launch; qa reopen returns it to QA").argument(
10955
+ "[folder-or-id]",
10956
+ "Experiment filesystem folder, ID, slug, or test key (for example BIO-085)",
10957
+ "."
10958
+ ).action(async function action19(target) {
10439
10959
  const creds = requireCredentials();
10440
10960
  const spinner = await createSpinner("Approving QA...");
10441
10961
  try {
@@ -10465,6 +10985,7 @@ var qaApprove = new Command("approve").description("Approve QA and move the expe
10465
10985
  {
10466
10986
  experimentId: resolved.experimentId,
10467
10987
  qaStatus: "done",
10988
+ experiment: res.data,
10468
10989
  experiment_status_before: before.status,
10469
10990
  experiment_status_after: res.data.status,
10470
10991
  transition
@@ -10478,7 +10999,11 @@ var qaApprove = new Command("approve").description("Approve QA and move the expe
10478
10999
  throw error;
10479
11000
  }
10480
11001
  });
10481
- var qaReopen = new Command("reopen").description("Reopen QA and clear scheduled launch state").argument("[folder-or-id]", "Experiment filesystem folder, ID, or slug", ".").action(async function action20(target) {
11002
+ var qaReopen = new Command("reopen").description("Reopen QA and clear scheduled launch state").argument(
11003
+ "[folder-or-id]",
11004
+ "Experiment filesystem folder, ID, slug, or test key (for example BIO-085)",
11005
+ "."
11006
+ ).action(async function action20(target) {
10482
11007
  const creds = requireCredentials();
10483
11008
  const spinner = await createSpinner("Reopening QA...");
10484
11009
  try {
@@ -10536,7 +11061,11 @@ function resolveEvidenceVariation(experiment, ref) {
10536
11061
  }
10537
11062
  throw new Error(`Unknown --variation value: ${ref}`);
10538
11063
  }
10539
- var qaEvidence = new Command("evidence").description("Attach screenshot jobs or upload typed QA evidence").argument("[folder-or-id]", "Experiment filesystem folder, ID, or slug", ".").option("--job <jobId>", "Attach a terminal screenshot job").option("--file <path>", "Upload an evidence image; repeat for more files", collectFile, []).option("--label <text>", "Evidence label").option("--device <device>", "desktop, mobile, tablet, or a raw device value").option("--variation <ref>", "Variation ID, slug, or name").addOption(qaChoice("--status <status>", "Evidence status", evidenceStatuses)).addOption(qaChoice("--kind <kind>", "Evidence kind", evidenceKinds).default("screenshot")).option("--records <json-or-@path>", "Typed evidence record array").option("--summary", "Print a compact run and newly added evidence").option("--json", "Print the QA run in the standard JSON envelope").action(async function action21(target, opts) {
11064
+ var qaEvidence = new Command("evidence").description("Attach screenshot jobs or upload typed QA evidence").argument(
11065
+ "[folder-or-id]",
11066
+ "Experiment filesystem folder, ID, slug, or test key (for example BIO-085)",
11067
+ "."
11068
+ ).option("--job <jobId>", "Attach a terminal screenshot job").option("--file <path>", "Upload an evidence image; repeat for more files", collectFile, []).option("--label <text>", "Evidence label").option("--device <device>", "desktop, mobile, tablet, or a raw device value").option("--variation <ref>", "Variation ID, slug, or name").addOption(qaChoice("--status <status>", "Evidence status", evidenceStatuses)).addOption(qaChoice("--kind <kind>", "Evidence kind", evidenceKinds).default("screenshot")).option("--records <json-or-@path>", "Typed evidence record array").option("--summary", "Print a compact run and newly added evidence").option("--json", "Print the QA run in the standard JSON envelope").action(async function action21(target, opts) {
10540
11069
  validateQaChoice(this, "--status", opts.status, evidenceStatuses);
10541
11070
  validateQaChoice(this, "--kind", opts.kind, evidenceKinds);
10542
11071
  const modes = Number(Boolean(opts.job)) + Number(opts.file.length > 0) + Number(Boolean(opts.records));
@@ -10642,7 +11171,7 @@ function screenshotTimeoutMessage(job, experimentRef, out, seconds, flags = {})
10642
11171
  }
10643
11172
  var screenshots = new Command("screenshots").description(
10644
11173
  "Force-assign every capture, including control, to its variation server-side for deterministic screenshots"
10645
- ).argument("<experimentRef>", "Experiment ID or slug").option("--url <target>", "Target URL override (not supported by the current v1 API)").option("--devices <list>", `Comma-separated devices (default: ${SCREENSHOT_DEVICES.join(",")})`).option(
11174
+ ).argument("<experimentRef>", EXPERIMENT_REFERENCE_DESCRIPTION).option("--url <target>", "Target URL override (not supported by the current v1 API)").option("--devices <list>", `Comma-separated devices (default: ${SCREENSHOT_DEVICES.join(",")})`).option(
10646
11175
  "--variations <list>",
10647
11176
  "Comma-separated treatment IDs, slugs, or names; control is always included and force-assigned"
10648
11177
  ).option("--out <dir>", "Download directory", ".").option("--job <id>", "Resume an existing screenshot job instead of creating one").option("--json", "Print the completed raw job payload with signed URLs").option("--no-attach-qa", "Do not attach the completed job to the QA run").option("--no-label-pairs", "Keep legacy screenshot filenames without before/after labels").option(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drip-apex/apex-cli",
3
- "version": "0.7.0",
3
+ "version": "0.8.1",
4
4
  "license": "MIT",
5
5
  "description": "Apex CLI for managing experiments, approved roadmap ideas, and draft workspaces.",
6
6
  "private": false,
@@ -13,7 +13,6 @@ apex schema --json
13
13
 
14
14
  Prefer JSON output. When stdout is piped, Apex emits JSON by default.
15
15
 
16
-
17
16
  ## Zero-Experiment Shop Bootstrap
18
17
 
19
18
  CLI-only bootstrap is supported for a fresh shop before any experiments exist.
@@ -64,7 +63,6 @@ Platform administrators can archive a mistakenly created shop with
64
63
  stop those tests rather than retrying or overriding the refusal. Restore leaves
65
64
  delivery controlled by the runtime kill switch.
66
65
 
67
-
68
66
  ## Project code
69
67
 
70
68
  ```sh
@@ -91,3 +89,14 @@ A successful write returns `saved`, its diff, backup path, current version, writ
91
89
  and precompile result. A failed precompile is a saved-write warning. All warnings remain
92
90
  in JSON and are also written to stderr. User keys resolve domains or pin UUID context;
93
91
  shop keys must match the requested identity before any backup or write.
92
+
93
+ Experiment references accept an ID, slug, or test key (for example `BIO-085`). Test keys resolve within the selected shop; use `--shop <shop-id>` to change it. Bare lowercase references remain slugs.
94
+
95
+ Filesystem apply preserves authored local bytes by default. Use
96
+ `apex experiments apply <folder> --sync` to refresh them from stored server bytes.
97
+ The receipt's `files` contains SHA-256 digests for every pull path; `synced` reports
98
+ whether a full refresh succeeded. Server-owned QA and launch files are healed.
99
+ Create/get return `dashboard_url`; apply returns `dashboardUrl`. These links
100
+ switch the signed-in dashboard user's active shop before opening the experiment.
101
+
102
+ Filesystem apply caches uploaded asset URLs by byte SHA-256 in `.apex/experiment-filesystem.json`, so unchanged images reuse their URL across applies without rewriting authored files.