@drip-apex/apex-cli 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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.0",
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,12 @@ function buildQaPreviewUrl2(storeUrl, experimentId, variationId) {
8937
8969
  return url;
8938
8970
  }
8939
8971
 
8972
+ // src/lib/test-key.ts
8973
+ var TEST_KEY_RE = /^([A-Z0-9]{2,8})-(\d+)$/;
8974
+ function isTestKeyReference(value) {
8975
+ return value === value.toUpperCase() && TEST_KEY_RE.test(value.toUpperCase());
8976
+ }
8977
+
8940
8978
  // src/lib/effective-page-targeting.ts
8941
8979
  var ALL_PAGES_LABEL = "All pages";
8942
8980
  var CHECKOUT_LABEL = "Checkout";
@@ -9023,6 +9061,20 @@ function classifyIdeaReference(reference) {
9023
9061
  return { kind: "invalid", value: trimmed };
9024
9062
  }
9025
9063
 
9064
+ // ../api-client/src/experiment-create.ts
9065
+ function deriveStoreUrl(patterns) {
9066
+ for (const pattern of patterns) {
9067
+ const trimmed = pattern.trim();
9068
+ if (trimmed.includes("*")) continue;
9069
+ try {
9070
+ const url = new URL(trimmed);
9071
+ if (url.protocol === "http:" || url.protocol === "https:") return trimmed;
9072
+ } catch {
9073
+ }
9074
+ }
9075
+ return null;
9076
+ }
9077
+
9026
9078
  // src/lib/asset-upload.ts
9027
9079
  var import_node_crypto3 = __toESM(require("node:crypto"));
9028
9080
  var import_node_fs6 = __toESM(require("node:fs"));
@@ -9102,6 +9154,7 @@ function createAssetReferenceRewriter(options) {
9102
9154
  let pending = uploadsByHash.get(hash);
9103
9155
  if (!pending) {
9104
9156
  pending = options.upload(file).then((asset) => {
9157
+ if (asset === null) return null;
9105
9158
  if (!asset.id) throw new Error(`Asset upload for ${file.name} returned no ID.`);
9106
9159
  const url = deliveryUrl(options.baseUrl, asset);
9107
9160
  options.onUpload?.({ name: file.name, url, source });
@@ -9130,7 +9183,7 @@ function createAssetReferenceRewriter(options) {
9130
9183
  const hash = contentHash(bytes);
9131
9184
  const name = `inline-${hash.slice(0, 12)}${extensionForMimeType(mimeType)}`;
9132
9185
  const url = await uploadOnce({ name, bytes, type: mimeType }, "inline");
9133
- rewritten = rewritten.split(uri).join(url);
9186
+ if (url !== null) rewritten = rewritten.split(uri).join(url);
9134
9187
  }
9135
9188
  const localAssetUrl = async (reference) => {
9136
9189
  if (reference.startsWith("/") || reference.startsWith("data:") || reference.includes("://")) {
@@ -9217,9 +9270,10 @@ function formatDate(iso) {
9217
9270
  year: "numeric"
9218
9271
  });
9219
9272
  }
9220
- function printExperiment(exp) {
9273
+ function printExperiment(exp, dashboardUrl = null) {
9221
9274
  console.log();
9222
9275
  console.log(source_default.bold(exp.name));
9276
+ if (dashboardUrl) console.log(` Dashboard: ${dashboardUrl}`);
9223
9277
  console.log(` ID: ${source_default.dim(exp.id)}`);
9224
9278
  if (exp.slug) console.log(` Slug: ${source_default.cyan(exp.slug)}`);
9225
9279
  console.log(` Status: ${statusBadge(exp.status)}`);
@@ -9450,21 +9504,26 @@ async function rewriteExperimentFilesystemAssets(input) {
9450
9504
  baseUrl: input.baseUrl,
9451
9505
  enabled: input.enabled,
9452
9506
  upload: async (file) => {
9507
+ const hash = import_node_crypto4.default.createHash("sha256").update(file.bytes).digest("hex");
9508
+ const cached = input.assets[hash];
9509
+ if (cached) return cached;
9510
+ if (input.upload === null) return null;
9453
9511
  const response = await input.upload(input.credentials, [file]);
9454
9512
  const asset = response.data?.assets?.[0];
9455
9513
  if (!response.ok || !asset?.id) {
9456
9514
  throw new Error(response.error ?? `Failed to upload ${file.name}.`);
9457
9515
  }
9458
- return {
9516
+ const uploaded = {
9459
9517
  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
9518
+ url: typeof asset.url === "string" ? asset.url : `${new URL(input.baseUrl).origin}/api/assets/${encodeURIComponent(asset.id)}/file`,
9519
+ deliveryUrl: typeof asset.deliveryUrl === "string" ? asset.deliveryUrl : typeof asset.delivery_url === "string" ? asset.delivery_url : void 0
9463
9520
  };
9464
- },
9465
- onUpload: ({ name, url }) => {
9466
- process.stderr.write(`Uploaded asset ${name} -> ${url}
9467
- `);
9521
+ input.assets[hash] = uploaded;
9522
+ process.stderr.write(
9523
+ `Uploaded asset ${file.name} -> ${uploaded.deliveryUrl ?? uploaded.url}
9524
+ `
9525
+ );
9526
+ return uploaded;
9468
9527
  }
9469
9528
  });
9470
9529
  return Promise.all(
@@ -9519,11 +9578,21 @@ function blockingIssueSummary(issues) {
9519
9578
  return `Experiment filesystem plan has errors. ${issues[0].message}${remaining ? ` and ${remaining} more` : ""}`;
9520
9579
  }
9521
9580
  async function getQaPreviewUrls(creds, experimentId) {
9581
+ const fallbackDashboardUrl = buildDashboardUrl(
9582
+ creds.baseUrl,
9583
+ creds.resolvedContext?.shopId,
9584
+ experimentId
9585
+ );
9522
9586
  try {
9523
9587
  const res = await getExperiment(creds, experimentId);
9588
+ const dashboardUrl = buildDashboardUrl(
9589
+ creds.baseUrl,
9590
+ res.data?.shop_id ?? creds.resolvedContext?.shopId,
9591
+ experimentId
9592
+ );
9524
9593
  const storeUrl = res.data?.store_url;
9525
9594
  if (!res.ok || !res.data || !storeUrl) {
9526
- return { previewUrls: [], unavailable: true };
9595
+ return { previewUrls: [], unavailable: true, dashboardUrl };
9527
9596
  }
9528
9597
  const experiment = res.data;
9529
9598
  const previewUrls = (experiment.variations ?? []).filter((variation) => !variation.is_control).map((variation) => ({
@@ -9531,9 +9600,9 @@ async function getQaPreviewUrls(creds, experimentId) {
9531
9600
  variationName: variation.name,
9532
9601
  url: buildQaPreviewUrl2(storeUrl, experiment.id, variation.id).toString()
9533
9602
  }));
9534
- return { previewUrls, unavailable: false };
9603
+ return { previewUrls, unavailable: false, dashboardUrl };
9535
9604
  } catch {
9536
- return { previewUrls: [], unavailable: true };
9605
+ return { previewUrls: [], unavailable: true, dashboardUrl: fallbackDashboardUrl };
9537
9606
  }
9538
9607
  }
9539
9608
  function printQaPreviewUrls(experimentId, result) {
@@ -9652,13 +9721,15 @@ async function resolveExperimentForFilesystemOrId(value) {
9652
9721
  return { experimentId: exp.id, folder: null, label: exp.name };
9653
9722
  }
9654
9723
  async function refreshExperimentFilesystemFolderIfNeeded(creds, folder, experimentId) {
9655
- if (!folder) return;
9724
+ if (!folder) return false;
9656
9725
  try {
9657
9726
  const refreshed = await getExperimentFilesystem(creds, experimentId);
9658
9727
  if (refreshed.ok && refreshed.data) {
9728
+ const assets = tryReadFilesystemSession(folder)?.assets;
9659
9729
  writeExperimentFilesystemFiles(folder, refreshed.data.files);
9660
9730
  writeFilesystemSession(folder, {
9661
9731
  schemaVersion: 1,
9732
+ ...assets ? { assets } : {},
9662
9733
  baseUrl: creds.baseUrl,
9663
9734
  experimentId,
9664
9735
  pulledAt: (/* @__PURE__ */ new Date()).toISOString(),
@@ -9666,11 +9737,13 @@ async function refreshExperimentFilesystemFolderIfNeeded(creds, folder, experime
9666
9737
  refreshed.data.files.map((file) => [file.path, { sha256: file.sha256 }])
9667
9738
  )
9668
9739
  });
9740
+ return true;
9669
9741
  }
9670
9742
  } catch {
9671
9743
  }
9744
+ return false;
9672
9745
  }
9673
- async function refreshServerOwnedFilesIfNeeded(creds, folder, experimentId) {
9746
+ async function refreshServerOwnedFilesIfNeeded(creds, folder, experimentId, updateManifest = true) {
9674
9747
  if (!folder) return;
9675
9748
  try {
9676
9749
  const refreshed = await getExperimentFilesystem(creds, experimentId);
@@ -9681,23 +9754,25 @@ async function refreshServerOwnedFilesIfNeeded(creds, folder, experimentId) {
9681
9754
  if (serverOwned.length === 0) return;
9682
9755
  const session = readFilesystemSession(folder);
9683
9756
  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
- });
9757
+ if (updateManifest)
9758
+ writeFilesystemSession(folder, {
9759
+ ...session,
9760
+ files: {
9761
+ ...session.files,
9762
+ ...Object.fromEntries(serverOwned.map((file) => [file.path, { sha256: file.sha256 }]))
9763
+ }
9764
+ });
9691
9765
  } catch {
9692
9766
  }
9693
9767
  }
9768
+ var EXPERIMENT_REFERENCE_DESCRIPTION = "Experiment ID, slug, or test key (for example BIO-085)";
9694
9769
  async function resolveExperiment(idOrSlug) {
9695
9770
  const creds = requireCredentials();
9696
- if (/^[0-9a-f-]{36}$/i.test(idOrSlug)) {
9771
+ if (/^[0-9a-f-]{36}$/i.test(idOrSlug) || /^[1-9][0-9]{9}$/.test(idOrSlug) || isTestKeyReference(idOrSlug)) {
9697
9772
  const res2 = await getExperiment(creds, idOrSlug);
9698
9773
  if (res2.ok) return res2.data;
9699
9774
  throw new CliError(
9700
- res2.error ?? `Experiment "${idOrSlug}" not found`,
9775
+ 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
9776
  classifyStatus(res2.status),
9702
9777
  res2.errorDetails
9703
9778
  );
@@ -9751,16 +9826,26 @@ var list = new Command("list").alias("ls").description("List experiments (archiv
9751
9826
  }
9752
9827
  });
9753
9828
  });
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) {
9829
+ var get = new Command("get").description("Get experiment details by ID or slug").argument("<id-or-slug>", EXPERIMENT_REFERENCE_DESCRIPTION).action(async function action4(idOrSlug) {
9755
9830
  const spinner = await createSpinner("Fetching experiment...");
9756
9831
  const exp = await resolveExperiment(idOrSlug);
9757
9832
  spinner?.stop();
9758
9833
  if (!exp) {
9759
9834
  fail(this, `Experiment "${idOrSlug}" not found.`, EXIT_CODES.validation);
9760
9835
  }
9761
- output(this, { experiment: exp }, () => printExperiment(exp));
9836
+ const creds = requireCredentials();
9837
+ const dashboardUrl = buildDashboardUrl(
9838
+ creds.baseUrl,
9839
+ exp.shop_id ?? creds.resolvedContext?.shopId,
9840
+ exp.id
9841
+ );
9842
+ output(
9843
+ this,
9844
+ { experiment: exp, dashboard_url: dashboardUrl },
9845
+ () => printExperiment(exp, dashboardUrl)
9846
+ );
9762
9847
  });
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) {
9848
+ 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
9849
  const experiment = await resolveExperiment(experimentRef);
9765
9850
  if (!experiment) fail(this, `Experiment "${experimentRef}" not found.`, EXIT_CODES.validation);
9766
9851
  const classified = classifyIdeaReference(opts.idea);
@@ -9780,7 +9865,7 @@ var attachIdea = new Command("attach-idea").description("Attach an experiment to
9780
9865
  );
9781
9866
  output(this, res.data, () => printSuccess("Idea attached."));
9782
9867
  });
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) {
9868
+ var detachIdea = new Command("detach-idea").description("Detach an experiment from its idea").argument("<experiment>", EXPERIMENT_REFERENCE_DESCRIPTION).action(async function action6(experimentRef) {
9784
9869
  const experiment = await resolveExperiment(experimentRef);
9785
9870
  if (!experiment) fail(this, `Experiment "${experimentRef}" not found.`, EXIT_CODES.validation);
9786
9871
  const res = await detachExperimentIdea(requireCredentials(), experiment.id);
@@ -9793,7 +9878,7 @@ var detachIdea = new Command("detach-idea").description("Detach an experiment fr
9793
9878
  );
9794
9879
  output(this, res.data, () => printSuccess("Idea detached."));
9795
9880
  });
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) {
9881
+ 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
9882
  const creds = requireCredentials();
9798
9883
  const spinner = await createSpinner("Fetching experiment results...");
9799
9884
  try {
@@ -9843,7 +9928,7 @@ var DAY_NAMES = {
9843
9928
  var countingTypes = ["standard", "setup_test", "aa_test", "non_program"];
9844
9929
  var update = new Command("update").description("Update an experiment").addOption(
9845
9930
  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) {
9931
+ ).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
9932
  if (opts.clearResultsWeekdays && (opts.resultsWeekdays || opts.resultsTimezone)) {
9848
9933
  fail(
9849
9934
  this,
@@ -9896,32 +9981,238 @@ function parseHttpDestination(value) {
9896
9981
  }
9897
9982
  return trimmed;
9898
9983
  }
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 {
9984
+ function readCreateJson(command, reference) {
9985
+ const file = reference.startsWith("@") ? reference.slice(1) : reference;
9986
+ try {
9987
+ const parsed = JSON.parse(import_node_fs7.default.readFileSync(import_node_path5.default.resolve(process.cwd(), file), "utf8"));
9988
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
9989
+ throw new Error("expected an object");
9990
+ return parsed;
9991
+ } catch (error) {
9992
+ fail(
9993
+ command,
9994
+ `Invalid audience JSON ${reference}: ${error instanceof Error ? error.message : String(error)}`,
9995
+ EXIT_CODES.validation
9996
+ );
9997
+ }
9998
+ }
9999
+ var GOAL_TYPES = ["click", "pageview", "custom", "revenue"];
10000
+ var GOAL_MEASUREMENTS = ["conversion", "value"];
10001
+ var GOAL_VALUE_UNITS = ["currency", "number", "duration_seconds", "percent"];
10002
+ var INLINE_GOAL_FIELDS = [
10003
+ "name",
10004
+ "type",
10005
+ "measurement",
10006
+ "value_property",
10007
+ "value_unit",
10008
+ "selector",
10009
+ "text_match",
10010
+ "url_pattern",
10011
+ "page_id",
10012
+ "event_name",
10013
+ "count_once"
10014
+ ];
10015
+ 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;
10016
+ var GOAL_EVENT_NAME = /^[a-zA-Z0-9_.:-]{1,120}$/;
10017
+ var GOAL_VALUE_PROPERTY = /^[A-Za-z0-9_.-]{1,120}$/;
10018
+ function normalizeNewGoalKey(key) {
10019
+ const normalized = key.trim().toLowerCase().replace(/-/g, "_");
10020
+ return normalized === "page" ? "page_id" : normalized;
10021
+ }
10022
+ function validateNewGoal(command, value, body5) {
10023
+ const invalid = (reason) => fail(command, `Invalid --goal "new:${value}"; ${reason}`, EXIT_CODES.validation);
10024
+ const text = (field) => {
10025
+ const raw = body5[field];
10026
+ return typeof raw === "string" && raw.length > 0 ? raw : void 0;
10027
+ };
10028
+ for (const key of Object.keys(body5)) {
10029
+ if (INLINE_GOAL_FIELDS.includes(key)) continue;
10030
+ invalid(
10031
+ 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(", ")}`
10032
+ );
10033
+ }
10034
+ const type = text("type");
10035
+ const name = text("name");
10036
+ if (!type || !name) invalid("type and name are required");
10037
+ if (!GOAL_TYPES.includes(type)) invalid(`type must be one of ${GOAL_TYPES.join(", ")}`);
10038
+ if (name.length > 255) invalid("name must be 255 characters or fewer");
10039
+ const measurement = text("measurement") ?? "conversion";
10040
+ if (!GOAL_MEASUREMENTS.includes(measurement))
10041
+ invalid(`measurement must be one of ${GOAL_MEASUREMENTS.join(", ")}`);
10042
+ if (body5.count_once !== void 0) {
10043
+ const flag = String(body5.count_once).toLowerCase();
10044
+ if (flag !== "true" && flag !== "false") invalid("count-once must be true or false");
10045
+ body5.count_once = flag === "true";
10046
+ }
10047
+ if (type === "click" && !text("selector") && !text("text_match"))
10048
+ invalid("click goals need selector=<css> or text-match=<text>");
10049
+ if (type === "pageview" && !text("page_id") && !text("url_pattern"))
10050
+ invalid("pageview goals need page=<page-id> or url-pattern=<pattern>");
10051
+ if (type === "custom" && !text("event_name")) invalid("custom goals need event-name=<name>");
10052
+ const eventName = text("event_name");
10053
+ if (eventName && !GOAL_EVENT_NAME.test(eventName))
10054
+ invalid("event-name must be 1-120 letters, numbers, dots, colons, underscores, or hyphens");
10055
+ if ((type === "custom" || type === "revenue") && text("page_id"))
10056
+ invalid(`page is not supported for ${type} goals`);
10057
+ const pageId = text("page_id");
10058
+ if (pageId && !GOAL_UUID.test(pageId))
10059
+ invalid("page must be a page UUID (list them with `apex pages list`)");
10060
+ const valueProperty = text("value_property");
10061
+ const valueUnit = text("value_unit");
10062
+ if (measurement !== "value" && (valueProperty || valueUnit))
10063
+ invalid("value-property and value-unit need measurement=value");
10064
+ if (type === "revenue" && valueProperty)
10065
+ invalid("revenue goals read the order revenue; value-property is not supported");
10066
+ if (valueProperty && !GOAL_VALUE_PROPERTY.test(valueProperty))
10067
+ invalid("value-property must be 1-120 letters, numbers, dots, underscores, or hyphens");
10068
+ if (valueUnit && !GOAL_VALUE_UNITS.includes(valueUnit))
10069
+ invalid(`value-unit must be one of ${GOAL_VALUE_UNITS.join(", ")}`);
10070
+ }
10071
+ function parseNewGoal(command, value) {
10072
+ const body5 = {};
10073
+ for (const field of value.split(",")) {
10074
+ const separator = field.indexOf("=");
10075
+ if (separator <= 0 || separator === field.length - 1) {
10076
+ fail(
10077
+ command,
10078
+ `Invalid --goal "new:${value}"; use new:type=<type>,selector=<css>,name=<text>`,
10079
+ EXIT_CODES.validation
10080
+ );
9908
10081
  }
10082
+ body5[normalizeNewGoalKey(field.slice(0, separator))] = field.slice(separator + 1).trim();
9909
10083
  }
9910
- return null;
10084
+ validateNewGoal(command, value, body5);
10085
+ return body5;
10086
+ }
10087
+ async function resolveNewGoalPages(command, creds, goals, interactive) {
10088
+ const pending = goals.filter((goal) => goal.body.type === "click" && !goal.body.page_id);
10089
+ if (pending.length === 0) return;
10090
+ if (!interactive) {
10091
+ fail(
10092
+ command,
10093
+ `Invalid --goal "${pending[0].flagValue}"; click goals are page-scoped in v1 \u2014 add page=<page-id> (see \`apex pages list\`)`,
10094
+ EXIT_CODES.validation
10095
+ );
10096
+ }
10097
+ const listed = await requestTargetingResource(creds, "GET", "pages", void 0, void 0, 100);
10098
+ if (!listed.ok || !listed.data)
10099
+ fail(command, listed.error ?? "Failed to list pages", classifyStatus(listed.status));
10100
+ const rows = listed.data.data;
10101
+ if (rows.length === 0)
10102
+ fail(
10103
+ command,
10104
+ "Click goals are page-scoped in v1 and this shop has no pages yet; create one with `apex pages create`.",
10105
+ EXIT_CODES.validation
10106
+ );
10107
+ printChoices("Pages (enter id/number)", rows);
10108
+ for (const goal of pending) {
10109
+ const answer = await askCreate(`Page for click goal "${String(goal.body.name)}": `);
10110
+ if (!answer)
10111
+ fail(
10112
+ command,
10113
+ `Invalid --goal "${goal.flagValue}"; click goals need a page`,
10114
+ EXIT_CODES.validation
10115
+ );
10116
+ const chosen = /^\d+$/.test(answer) && rows[Number(answer) - 1] ? rows[Number(answer) - 1].id : answer;
10117
+ if (!GOAL_UUID.test(chosen))
10118
+ fail(
10119
+ command,
10120
+ `Invalid page "${chosen}"; pick a listed number or a page UUID`,
10121
+ EXIT_CODES.validation
10122
+ );
10123
+ goal.body.page_id = chosen;
10124
+ }
10125
+ }
10126
+ async function askCreate(question) {
10127
+ const readline3 = await import("node:readline");
10128
+ const rl = readline3.createInterface({ input: process.stdin, output: process.stderr });
10129
+ return new Promise(
10130
+ (resolve2) => rl.question(question, (answer) => {
10131
+ rl.close();
10132
+ resolve2(answer.trim());
10133
+ })
10134
+ );
10135
+ }
10136
+ function printChoices(kind5, rows) {
10137
+ process.stderr.write(
10138
+ `${kind5}:
10139
+ ${rows.map((row, index) => ` ${index + 1}. ${row.name} (${row.id})`).join("\n")}
10140
+ `
10141
+ );
9911
10142
  }
9912
10143
  var create = new Command("create").description("Create a new experiment").addOption(
9913
10144
  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(
10145
+ ).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
10146
  "--source-url <pattern>",
9916
10147
  "Positive source URL rule (repeatable; accepts exact URLs, paths, or wildcards)",
9917
10148
  collectCreateOption,
9918
10149
  []
10150
+ ).option(
10151
+ "--page-url <pattern>",
10152
+ "Page URL targeting pattern (repeatable)",
10153
+ collectCreateOption,
10154
+ []
10155
+ ).option(
10156
+ "--audience <segment-id|new:@file.json>",
10157
+ "Audience segment (repeatable)",
10158
+ collectCreateOption,
10159
+ []
10160
+ ).option(
10161
+ "--goal <goal-id|new:fields>",
10162
+ "Goal id or new:type=...,selector=...,name=... (repeatable)",
10163
+ collectCreateOption,
10164
+ []
9919
10165
  ).option(
9920
10166
  "--destination <url>",
9921
10167
  "Redirect treatment destination (repeatable; creates one treatment per URL)",
9922
10168
  collectCreateOption,
9923
10169
  []
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) {
10170
+ ).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) {
10171
+ const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY && !opts.yes);
10172
+ if (!opts.name && interactive) opts.name = await askCreate("Experiment name: ");
10173
+ if (!opts.name?.trim()) fail(this, "Missing required value: --name", EXIT_CODES.validation);
10174
+ if (opts.pageUrl.length === 0 && opts.sourceUrl.length === 0 && interactive) {
10175
+ const answer = await askCreate("Page URL (--page-url): ");
10176
+ if (answer) opts.pageUrl.push(answer);
10177
+ }
10178
+ const creds = requireCredentials();
10179
+ if (opts.audience.length === 0 && interactive) {
10180
+ const listed = await requestTargetingResource(
10181
+ creds,
10182
+ "GET",
10183
+ "segments",
10184
+ void 0,
10185
+ void 0,
10186
+ 100
10187
+ );
10188
+ if (!listed.ok || !listed.data)
10189
+ fail(this, listed.error ?? "Failed to list segments", classifyStatus(listed.status));
10190
+ const rows = listed.data.data;
10191
+ printChoices("Audiences (enter id/number, or none)", rows);
10192
+ const answer = await askCreate("Audience: ");
10193
+ if (answer && answer.toLowerCase() !== "none")
10194
+ opts.audience.push(
10195
+ /^\d+$/.test(answer) && rows[Number(answer) - 1] ? rows[Number(answer) - 1].id : answer
10196
+ );
10197
+ }
10198
+ if (opts.goal.length === 0 && interactive) {
10199
+ const listed = await listGoals(creds, { limit: 100 });
10200
+ if (!listed.ok || !listed.data)
10201
+ fail(this, listed.error ?? "Failed to list goals", classifyStatus(listed.status));
10202
+ const rows = listed.data.data;
10203
+ printChoices("Goals (enter ids/numbers separated by commas, or none)", rows);
10204
+ const answer = await askCreate("Goals: ");
10205
+ if (answer && answer.toLowerCase() !== "none")
10206
+ opts.goal.push(
10207
+ ...answer.split(",").map((part) => part.trim()).filter(Boolean).map(
10208
+ (part) => /^\d+$/.test(part) && rows[Number(part) - 1] ? rows[Number(part) - 1].id : part
10209
+ )
10210
+ );
10211
+ }
10212
+ if (this.getOptionValueSource("traffic") === "default" && interactive) {
10213
+ const answer = await askCreate("Traffic percentage [100]: ");
10214
+ if (answer) opts.traffic = answer;
10215
+ }
9925
10216
  if (opts.type !== "mutation" && opts.type !== "redirect") {
9926
10217
  fail(this, "--type must be mutation or redirect", EXIT_CODES.validation);
9927
10218
  }
@@ -9929,9 +10220,9 @@ var create = new Command("create").description("Create a new experiment").addOpt
9929
10220
  if (!Number.isFinite(traffic) || traffic < 0 || traffic > 100) {
9930
10221
  fail(this, "--traffic must be a number from 0 to 100", EXIT_CODES.validation);
9931
10222
  }
9932
- const sourceRules = opts.sourceUrl.map((value) => value.trim());
10223
+ const sourceRules = [...opts.sourceUrl, ...opts.pageUrl].map((value) => value.trim());
9933
10224
  if (sourceRules.some((value) => value.length === 0)) {
9934
- fail(this, "--source-url cannot be empty", EXIT_CODES.validation);
10225
+ fail(this, "--source-url/--page-url cannot be empty", EXIT_CODES.validation);
9935
10226
  }
9936
10227
  if (opts.type !== "redirect" && opts.destination.length > 0) {
9937
10228
  fail(this, "--destination is only valid with --type redirect", EXIT_CODES.validation);
@@ -9951,8 +10242,15 @@ var create = new Command("create").description("Create a new experiment").addOpt
9951
10242
  );
9952
10243
  }
9953
10244
  const destinations = opts.destination.map(parseHttpDestination);
10245
+ const previewStoreUrl = deriveStoreUrl(sourceRules);
10246
+ if (opts.preview && !previewStoreUrl) {
10247
+ fail(
10248
+ this,
10249
+ "--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>`",
10250
+ EXIT_CODES.validation
10251
+ );
10252
+ }
9954
10253
  const armWeight = destinations.length > 0 ? 1 / (destinations.length + 1) : null;
9955
- const creds = requireCredentials();
9956
10254
  let backlogItemId;
9957
10255
  let ideaKey;
9958
10256
  if (opts.idea !== void 0 && opts.backlogItem !== void 0 && opts.idea.trim().toUpperCase() !== opts.backlogItem.trim().toUpperCase()) {
@@ -9973,47 +10271,151 @@ var create = new Command("create").description("Create a new experiment").addOpt
9973
10271
  fail(this, "--idea must be a UUID or Apex backlog key", EXIT_CODES.validation);
9974
10272
  }
9975
10273
  }
10274
+ const newGoals = /* @__PURE__ */ new Map();
10275
+ for (const [index, goal] of opts.goal.entries()) {
10276
+ if (goal.startsWith("new:")) newGoals.set(index, parseNewGoal(this, goal.slice(4)));
10277
+ }
10278
+ await resolveNewGoalPages(
10279
+ this,
10280
+ creds,
10281
+ [...newGoals].map(([index, body5]) => ({ flagValue: opts.goal[index], body: body5 })),
10282
+ interactive
10283
+ );
10284
+ const createdSegments = [];
10285
+ const createdGoals = [];
10286
+ const segmentIds = [];
10287
+ const goalIds = [];
10288
+ const partial = () => [
10289
+ ...createdSegments.map((item) => `segment ${item.id}`),
10290
+ ...createdGoals.map((item) => `goal ${item.id}`)
10291
+ ];
10292
+ for (const audience of opts.audience) {
10293
+ if (!audience.startsWith("new:")) {
10294
+ segmentIds.push(audience);
10295
+ continue;
10296
+ }
10297
+ const reference = audience.slice(4);
10298
+ if (!reference.startsWith("@"))
10299
+ fail(
10300
+ this,
10301
+ `Invalid --audience "${audience}"; new audiences use new:@file.json`,
10302
+ EXIT_CODES.validation
10303
+ );
10304
+ const response = await requestTargetingResource(
10305
+ creds,
10306
+ "POST",
10307
+ "segments",
10308
+ void 0,
10309
+ readCreateJson(this, reference)
10310
+ );
10311
+ if (!response.ok || !response.data)
10312
+ fail(
10313
+ this,
10314
+ `${response.error ?? "Failed to create audience"}${partial().length ? `; already created: ${partial().join(", ")}` : ""}`,
10315
+ classifyStatus(response.status)
10316
+ );
10317
+ const segment = response.data;
10318
+ createdSegments.push(segment);
10319
+ segmentIds.push(segment.id);
10320
+ }
10321
+ for (const [index, goal] of opts.goal.entries()) {
10322
+ const body5 = newGoals.get(index);
10323
+ if (!body5) {
10324
+ goalIds.push(goal);
10325
+ continue;
10326
+ }
10327
+ const response = await createGoal(creds, body5);
10328
+ if (!response.ok || !response.data)
10329
+ fail(
10330
+ this,
10331
+ `${response.error ?? "Failed to create goal"}${partial().length ? `; already created: ${partial().join(", ")}` : ""}`,
10332
+ classifyStatus(response.status)
10333
+ );
10334
+ createdGoals.push(response.data);
10335
+ goalIds.push(response.data.id);
10336
+ }
9976
10337
  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
- });
10338
+ let res;
10339
+ if (segmentIds.length === 0 && goalIds.length === 0) {
10340
+ res = await createExperiment(creds, {
10341
+ name: opts.name,
10342
+ slug: opts.slug ?? null,
10343
+ description: opts.description ?? null,
10344
+ delivery_type: opts.type,
10345
+ counting_type: opts.countingType,
10346
+ traffic_allocation: traffic / 100,
10347
+ ...backlogItemId ? { backlog_item_id: backlogItemId } : {},
10348
+ ...ideaKey ? { idea_key: ideaKey } : {},
10349
+ ...sourceRules.length > 0 ? {
10350
+ store_url: previewStoreUrl,
10351
+ targeting: {
10352
+ url: sourceRules.map((pattern) => ({
10353
+ type: "simple",
10354
+ pattern,
10355
+ include: true
10356
+ }))
10357
+ }
10358
+ } : {},
10359
+ ...armWeight !== null ? {
10360
+ variations: [
10361
+ {
10362
+ name: "Control",
10363
+ weight: armWeight,
10364
+ is_control: true
10365
+ },
10366
+ ...destinations.map((redirectUrl, index) => ({
10367
+ name: destinations.length === 1 ? "Treatment" : `Treatment ${index + 1}`,
10368
+ weight: armWeight,
10369
+ is_control: false,
10370
+ redirect_url: redirectUrl
10371
+ }))
10372
+ ]
10373
+ } : {}
10374
+ });
10375
+ } else {
10376
+ res = await createExperiment(creds, {
10377
+ name: opts.name,
10378
+ slug: opts.slug ?? null,
10379
+ description: opts.description ?? null,
10380
+ delivery_type: opts.type,
10381
+ counting_type: opts.countingType,
10382
+ traffic_allocation: traffic / 100,
10383
+ ...backlogItemId ? { backlog_item_id: backlogItemId } : {},
10384
+ ...ideaKey ? { idea_key: ideaKey } : {},
10385
+ ...sourceRules.length > 0 ? {
10386
+ store_url: previewStoreUrl,
10387
+ targeting: {
10388
+ url: sourceRules.map((pattern) => ({
10389
+ type: "simple",
10390
+ pattern,
10391
+ include: true
10392
+ })),
10393
+ ...segmentIds.length ? { segments: segmentIds.map((id) => ({ id, include: true })) } : {}
10394
+ }
10395
+ } : segmentIds.length ? { targeting: { segments: segmentIds.map((id) => ({ id, include: true })) } } : {},
10396
+ ...goalIds.length ? { goal_ids: goalIds, primary_goal_id: goalIds[0] } : {},
10397
+ ...armWeight !== null ? {
10398
+ variations: [
10399
+ {
10400
+ name: "Control",
10401
+ weight: armWeight,
10402
+ is_control: true
10403
+ },
10404
+ ...destinations.map((redirectUrl, index) => ({
10405
+ name: destinations.length === 1 ? "Treatment" : `Treatment ${index + 1}`,
10406
+ weight: armWeight,
10407
+ is_control: false,
10408
+ redirect_url: redirectUrl
10409
+ }))
10410
+ ]
10411
+ } : {}
10412
+ });
10413
+ }
10012
10414
  if (!res.ok || !res.data) {
10013
10415
  spinner?.fail("Failed to create experiment");
10014
10416
  fail(
10015
10417
  this,
10016
- res.error ?? "Failed to create experiment",
10418
+ `${res.error ?? "Failed to create experiment"}${partial().length ? `; already created: ${partial().join(", ")}` : ""}`,
10017
10419
  classifyStatus(res.status),
10018
10420
  res.errorDetails
10019
10421
  );
@@ -10022,18 +10424,49 @@ var create = new Command("create").description("Create a new experiment").addOpt
10022
10424
  spinner?.succeed(
10023
10425
  outcome === "created" ? "Experiment created" : `Already linked \u2014 returning existing experiment ${res.data.test_key ?? res.data.slug ?? res.data.id}`
10024
10426
  );
10427
+ let previewUrl = null;
10428
+ let previewNote = null;
10429
+ if (opts.preview) {
10430
+ const variation = res.data.variations?.find((item) => !item.is_control);
10431
+ const storeUrl = res.data.store_url ?? previewStoreUrl;
10432
+ if (storeUrl && variation) {
10433
+ previewUrl = buildQaPreviewUrl2(storeUrl, res.data.id, variation.id).toString();
10434
+ } else {
10435
+ previewNote = `No preview link: the experiment has no ${variation ? "store URL" : "treatment variation"} yet. Add one, then run \`apex preview ${res.data.id}\`.`;
10436
+ }
10437
+ }
10025
10438
  output(
10026
10439
  this,
10027
10440
  {
10028
10441
  outcome,
10029
10442
  backlog_item_id: backlogItemId ?? null,
10030
10443
  idea_key: ideaKey ?? null,
10031
- experiment: res.data
10444
+ experiment: res.data,
10445
+ segments: createdSegments,
10446
+ goals: createdGoals,
10447
+ dashboard_url: buildDashboardUrl(
10448
+ creds.baseUrl,
10449
+ res.data?.shop_id ?? creds.resolvedContext?.shopId,
10450
+ res.data.id
10451
+ ),
10452
+ preview_url: previewUrl,
10453
+ preview_note: previewNote
10032
10454
  },
10033
- () => printExperiment(res.data)
10455
+ () => {
10456
+ printExperiment(
10457
+ res.data,
10458
+ buildDashboardUrl(
10459
+ creds.baseUrl,
10460
+ res.data?.shop_id ?? creds.resolvedContext?.shopId,
10461
+ res.data.id
10462
+ )
10463
+ );
10464
+ if (previewUrl) console.log(`Preview: ${previewUrl}`);
10465
+ if (previewNote) console.log(previewNote);
10466
+ }
10034
10467
  );
10035
10468
  });
10036
- var pause = new Command("pause").description("Pause a running experiment").argument("<id-or-slug>", "Experiment ID or slug").action(async function action10(idOrSlug) {
10469
+ var pause = new Command("pause").description("Pause a running experiment").argument("<id-or-slug>", EXPERIMENT_REFERENCE_DESCRIPTION).action(async function action10(idOrSlug) {
10037
10470
  const creds = requireCredentials();
10038
10471
  const spinner = await createSpinner("Pausing experiment...");
10039
10472
  const exp = await resolveExperiment(idOrSlug);
@@ -10055,7 +10488,7 @@ var pause = new Command("pause").description("Pause a running experiment").argum
10055
10488
  spinner.succeed(`Experiment "${exp.name}" is now ${statusBadge("paused")}`);
10056
10489
  });
10057
10490
  });
10058
- var complete = new Command("complete").description("Complete/stop an experiment").argument("<id-or-slug>", "Experiment ID or slug").action(async function action11(idOrSlug) {
10491
+ var complete = new Command("complete").description("Complete/stop an experiment").argument("<id-or-slug>", EXPERIMENT_REFERENCE_DESCRIPTION).action(async function action11(idOrSlug) {
10059
10492
  const creds = requireCredentials();
10060
10493
  const spinner = await createSpinner("Completing experiment...");
10061
10494
  const exp = await resolveExperiment(idOrSlug);
@@ -10077,7 +10510,7 @@ var complete = new Command("complete").description("Complete/stop an experiment"
10077
10510
  spinner.succeed(`Experiment "${exp.name}" is now ${statusBadge("completed")}`);
10078
10511
  });
10079
10512
  });
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) {
10513
+ 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
10514
  const creds = requireCredentials();
10082
10515
  const exp = await resolveExperiment(idOrSlug);
10083
10516
  if (!exp) {
@@ -10122,7 +10555,7 @@ var del = new Command("delete").alias("rm").description("Delete an experiment").
10122
10555
  () => spinner.succeed(`Experiment "${exp.name}" deleted.`)
10123
10556
  );
10124
10557
  });
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) {
10558
+ 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
10559
  const creds = requireCredentials();
10127
10560
  const spinner = await createSpinner("Pulling experiment filesystem...");
10128
10561
  try {
@@ -10168,7 +10601,15 @@ var planFilesystem = new Command("plan").description("Plan experiment filesystem
10168
10601
  try {
10169
10602
  const folder = folderPath(folderArg);
10170
10603
  const session = readFilesystemSession(folder);
10171
- const files = listExperimentFilesystemFiles(folder, session);
10604
+ const files = await rewriteExperimentFilesystemAssets({
10605
+ files: listExperimentFilesystemFiles(folder, session),
10606
+ folder,
10607
+ baseUrl: creds.baseUrl,
10608
+ enabled: true,
10609
+ upload: null,
10610
+ assets: session.assets ?? {},
10611
+ credentials: creds
10612
+ });
10172
10613
  const res = await planExperimentFilesystem(creds, session.experimentId, { files });
10173
10614
  if (!res.ok || !res.data) {
10174
10615
  fail(
@@ -10201,21 +10642,27 @@ var planFilesystem = new Command("plan").description("Plan experiment filesystem
10201
10642
  fail(this, error instanceof Error ? error.message : String(error));
10202
10643
  }
10203
10644
  });
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) {
10645
+ 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
10646
  const creds = requireCredentials();
10206
10647
  try {
10207
10648
  const folder = folderPath(folderArg);
10208
10649
  const session = readFilesystemSession(folder);
10209
10650
  if (opts.force)
10210
10651
  process.stderr.write("--force is deprecated; use --accept-qa-invalidation instead.\n");
10652
+ const assets = session.assets ?? {};
10211
10653
  const files = await rewriteExperimentFilesystemAssets({
10212
10654
  files: listExperimentFilesystemFiles(folder, session),
10213
10655
  folder,
10214
10656
  baseUrl: creds.baseUrl,
10215
10657
  enabled: opts.assetUpload !== false,
10216
10658
  upload: uploadAssets,
10659
+ assets,
10217
10660
  credentials: creds
10218
10661
  });
10662
+ if (Object.keys(assets).length > 0) {
10663
+ session.assets = assets;
10664
+ writeFilesystemSession(folder, session);
10665
+ }
10219
10666
  const planRes = await planExperimentFilesystem(creds, session.experimentId, { files });
10220
10667
  if (!planRes.ok || !planRes.data) {
10221
10668
  fail(
@@ -10240,17 +10687,28 @@ var applyFilesystem = new Command("apply").description("Apply experiment filesys
10240
10687
  });
10241
10688
  }
10242
10689
  if (planRes.data.changes.length === 0) {
10690
+ const synced2 = opts.sync ? await refreshExperimentFilesystemFolderIfNeeded(creds, folder, session.experimentId) : false;
10691
+ if (!synced2)
10692
+ await refreshServerOwnedFilesIfNeeded(creds, folder, session.experimentId, true);
10693
+ const { dashboardUrl: dashboardUrl2 } = await getQaPreviewUrls(creds, session.experimentId);
10243
10694
  output(
10244
10695
  this,
10245
10696
  {
10246
10697
  folder,
10247
10698
  experimentId: session.experimentId,
10248
10699
  applied: false,
10700
+ synced: synced2,
10701
+ dashboardUrl: dashboardUrl2,
10249
10702
  plan: planRes.data,
10250
10703
  requiredAcknowledgment: planRes.data.invalidatesQa ? "accept-qa-invalidation" : null,
10251
10704
  message: "No changes."
10252
10705
  },
10253
- () => printFilesystemPlan(planRes.data)
10706
+ () => {
10707
+ printFilesystemPlan(planRes.data);
10708
+ console.log(
10709
+ 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."
10710
+ );
10711
+ }
10254
10712
  );
10255
10713
  return;
10256
10714
  }
@@ -10278,9 +10736,29 @@ var applyFilesystem = new Command("apply").description("Apply experiment filesys
10278
10736
  });
10279
10737
  }
10280
10738
  spinner?.succeed("Applied experiment filesystem");
10281
- await refreshExperimentFilesystemFolderIfNeeded(creds, folder, session.experimentId);
10739
+ const synced = opts.sync ? await refreshExperimentFilesystemFolderIfNeeded(creds, folder, session.experimentId) : false;
10740
+ if (!synced) {
10741
+ await refreshServerOwnedFilesIfNeeded(
10742
+ creds,
10743
+ folder,
10744
+ session.experimentId,
10745
+ !res.data?.receipt?.files
10746
+ );
10747
+ if (res.data?.receipt?.files) {
10748
+ writeFilesystemSession(folder, {
10749
+ ...session,
10750
+ files: {
10751
+ ...session.files,
10752
+ ...Object.fromEntries(
10753
+ res.data.receipt.files.map((file) => [file.path, { sha256: file.sha256 }])
10754
+ )
10755
+ }
10756
+ });
10757
+ }
10758
+ }
10759
+ 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
10760
  const previewResult = await getQaPreviewUrls(creds, session.experimentId);
10283
- const { previewUrls } = previewResult;
10761
+ const { previewUrls, dashboardUrl } = previewResult;
10284
10762
  output(
10285
10763
  this,
10286
10764
  {
@@ -10290,6 +10768,8 @@ var applyFilesystem = new Command("apply").description("Apply experiment filesys
10290
10768
  plan: planRes.data,
10291
10769
  requiredAcknowledgment: planRes.data.invalidatesQa ? "accept-qa-invalidation" : null,
10292
10770
  result: res.data,
10771
+ synced,
10772
+ dashboardUrl,
10293
10773
  receipt: res.data?.receipt ?? null,
10294
10774
  previewUrls
10295
10775
  },
@@ -10298,6 +10778,7 @@ var applyFilesystem = new Command("apply").description("Apply experiment filesys
10298
10778
  const receipt = res.data?.receipt;
10299
10779
  if (receipt) {
10300
10780
  console.log("\nReceipt");
10781
+ if (dashboardUrl) console.log(` Dashboard: ${dashboardUrl}`);
10301
10782
  console.log(` Status: ${receipt.status}`);
10302
10783
  console.log(` Classification: ${receipt.delivery_type} / ${receipt.counting_type}`);
10303
10784
  console.log(` Traffic: ${(receipt.traffic_allocation * 100).toFixed(0)}%`);
@@ -10315,6 +10796,7 @@ var applyFilesystem = new Command("apply").description("Apply experiment filesys
10315
10796
  }
10316
10797
  }
10317
10798
  printQaPreviewUrls(session.experimentId, previewResult);
10799
+ console.log(syncMessage);
10318
10800
  }
10319
10801
  );
10320
10802
  } catch (error) {
@@ -10322,7 +10804,11 @@ var applyFilesystem = new Command("apply").description("Apply experiment filesys
10322
10804
  fail(this, error instanceof Error ? error.message : String(error));
10323
10805
  }
10324
10806
  });
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) {
10807
+ var qaStatus = new Command("status").description("Show or create the latest experiment QA run").argument(
10808
+ "[folder-or-id]",
10809
+ "Experiment filesystem folder, ID, slug, or test key (for example BIO-085)",
10810
+ "."
10811
+ ).option("--json", "Print the QA run in the standard JSON envelope").action(async function action16(target) {
10326
10812
  const creds = requireCredentials();
10327
10813
  const spinner = await createSpinner("Fetching QA run...");
10328
10814
  try {
@@ -10346,7 +10832,11 @@ var qaStatus = new Command("status").description("Show or create the latest expe
10346
10832
  throw error;
10347
10833
  }
10348
10834
  });
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(
10835
+ var qaCheck = new Command("check").description("Update one QA checklist item with status, notes, and evidence").argument(
10836
+ "[folder-or-id]",
10837
+ "Experiment filesystem folder, ID, slug, or test key (for example BIO-085)",
10838
+ "."
10839
+ ).requiredOption("--item <id>", "Checklist item id").addOption(
10350
10840
  qaChoice("--status <status>", "Checklist status", checklistStatuses).makeOptionMandatory()
10351
10841
  ).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
10842
  validateQaChoice(this, "--status", opts.status, checklistStatuses);
@@ -10390,7 +10880,11 @@ var qaCheck = new Command("check").description("Update one QA checklist item wit
10390
10880
  throw error;
10391
10881
  }
10392
10882
  });
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) {
10883
+ var qaNotes = new Command("notes").description("Update QA run notes or acceptance criteria summary").argument(
10884
+ "[folder-or-id]",
10885
+ "Experiment filesystem folder, ID, slug, or test key (for example BIO-085)",
10886
+ "."
10887
+ ).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
10888
  const creds = requireCredentials();
10395
10889
  const spinner = await createSpinner("Updating QA notes...");
10396
10890
  try {
@@ -10435,7 +10929,11 @@ var qaNotes = new Command("notes").description("Update QA run notes or acceptanc
10435
10929
  throw error;
10436
10930
  }
10437
10931
  });
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) {
10932
+ var qaApprove = new Command("approve").description("Approve QA and move the experiment to ready_to_launch; qa reopen returns it to QA").argument(
10933
+ "[folder-or-id]",
10934
+ "Experiment filesystem folder, ID, slug, or test key (for example BIO-085)",
10935
+ "."
10936
+ ).action(async function action19(target) {
10439
10937
  const creds = requireCredentials();
10440
10938
  const spinner = await createSpinner("Approving QA...");
10441
10939
  try {
@@ -10478,7 +10976,11 @@ var qaApprove = new Command("approve").description("Approve QA and move the expe
10478
10976
  throw error;
10479
10977
  }
10480
10978
  });
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) {
10979
+ var qaReopen = new Command("reopen").description("Reopen QA and clear scheduled launch state").argument(
10980
+ "[folder-or-id]",
10981
+ "Experiment filesystem folder, ID, slug, or test key (for example BIO-085)",
10982
+ "."
10983
+ ).action(async function action20(target) {
10482
10984
  const creds = requireCredentials();
10483
10985
  const spinner = await createSpinner("Reopening QA...");
10484
10986
  try {
@@ -10536,7 +11038,11 @@ function resolveEvidenceVariation(experiment, ref) {
10536
11038
  }
10537
11039
  throw new Error(`Unknown --variation value: ${ref}`);
10538
11040
  }
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) {
11041
+ var qaEvidence = new Command("evidence").description("Attach screenshot jobs or upload typed QA evidence").argument(
11042
+ "[folder-or-id]",
11043
+ "Experiment filesystem folder, ID, slug, or test key (for example BIO-085)",
11044
+ "."
11045
+ ).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
11046
  validateQaChoice(this, "--status", opts.status, evidenceStatuses);
10541
11047
  validateQaChoice(this, "--kind", opts.kind, evidenceKinds);
10542
11048
  const modes = Number(Boolean(opts.job)) + Number(opts.file.length > 0) + Number(Boolean(opts.records));
@@ -10642,7 +11148,7 @@ function screenshotTimeoutMessage(job, experimentRef, out, seconds, flags = {})
10642
11148
  }
10643
11149
  var screenshots = new Command("screenshots").description(
10644
11150
  "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(
11151
+ ).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
11152
  "--variations <list>",
10647
11153
  "Comma-separated treatment IDs, slugs, or names; control is always included and force-assigned"
10648
11154
  ).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.0",
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.