@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 +11 -2
- package/README.md +11 -1
- package/dist/cli.js +633 -104
- package/package.json +1 -1
- package/skills/apex-cli/SKILL.md +11 -2
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
9531
|
+
const uploaded = {
|
|
9459
9532
|
id: asset.id,
|
|
9460
|
-
url: typeof asset.url === "string" ? asset.url :
|
|
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
|
-
|
|
9466
|
-
|
|
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
|
-
|
|
9685
|
-
|
|
9686
|
-
|
|
9687
|
-
|
|
9688
|
-
|
|
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(
|
|
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
|
-
|
|
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>",
|
|
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>",
|
|
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>",
|
|
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>",
|
|
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
|
|
9900
|
-
|
|
9901
|
-
|
|
9902
|
-
|
|
9903
|
-
|
|
9904
|
-
|
|
9905
|
-
|
|
9906
|
-
|
|
9907
|
-
|
|
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
|
-
|
|
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
|
-
).
|
|
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
|
-
|
|
9978
|
-
|
|
9979
|
-
|
|
9980
|
-
|
|
9981
|
-
|
|
9982
|
-
|
|
9983
|
-
|
|
9984
|
-
|
|
9985
|
-
|
|
9986
|
-
|
|
9987
|
-
|
|
9988
|
-
|
|
9989
|
-
|
|
9990
|
-
|
|
9991
|
-
pattern
|
|
9992
|
-
|
|
9993
|
-
|
|
9994
|
-
|
|
9995
|
-
|
|
9996
|
-
|
|
9997
|
-
|
|
9998
|
-
|
|
9999
|
-
|
|
10000
|
-
|
|
10001
|
-
|
|
10002
|
-
|
|
10003
|
-
|
|
10004
|
-
|
|
10005
|
-
|
|
10006
|
-
|
|
10007
|
-
|
|
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
|
-
() =>
|
|
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>",
|
|
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>",
|
|
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>",
|
|
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>",
|
|
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 =
|
|
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
|
-
() =>
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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>",
|
|
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
package/skills/apex-cli/SKILL.md
CHANGED
|
@@ -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.
|