@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 +11 -2
- package/README.md +11 -1
- package/dist/cli.js +609 -103
- 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.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
|
-
|
|
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,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
|
-
|
|
9516
|
+
const uploaded = {
|
|
9459
9517
|
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
|
|
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
|
-
|
|
9466
|
-
|
|
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
|
-
|
|
9685
|
-
|
|
9686
|
-
|
|
9687
|
-
|
|
9688
|
-
|
|
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>",
|
|
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
|
-
|
|
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>",
|
|
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>",
|
|
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>",
|
|
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>",
|
|
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
|
|
9900
|
-
|
|
9901
|
-
|
|
9902
|
-
|
|
9903
|
-
|
|
9904
|
-
|
|
9905
|
-
|
|
9906
|
-
|
|
9907
|
-
|
|
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
|
-
|
|
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
|
-
).
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
() =>
|
|
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>",
|
|
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>",
|
|
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>",
|
|
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>",
|
|
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 =
|
|
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
|
-
() =>
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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>",
|
|
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
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.
|