@storewright/cli 0.14.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/README.md +19 -0
- package/VERSION +1 -0
- package/bin/storewright.mjs +62 -0
- package/contracts/action-registry.json +175 -0
- package/contracts/capability-registry.json +63 -0
- package/contracts/workflow-manifest.json +207 -0
- package/lib/cli/storewright-cli.mjs +259 -0
- package/lib/internal/launch-envelope.mjs +223 -0
- package/lib/internal/multi-agent-contracts.mjs +137 -0
- package/lib/internal/operation-ledger.mjs +190 -0
- package/lib/internal/pricing/default-preview-pricing.mjs +181 -0
- package/lib/internal/run-state-helpers.mjs +313 -0
- package/lib/internal/shopify-operation-adapter.mjs +456 -0
- package/package.json +38 -0
- package/schemas/action-registry.schema.json +11 -0
- package/schemas/agent-report.schema.json +14 -0
- package/schemas/approval-grant.schema.json +16 -0
- package/schemas/base-theme-report.schema.json +25 -0
- package/schemas/brand-identity.schema.json +142 -0
- package/schemas/capability-registry.schema.json +11 -0
- package/schemas/competitor-audit.schema.json +38 -0
- package/schemas/design-direction.schema.json +64 -0
- package/schemas/external-operation.schema.json +34 -0
- package/schemas/intake-blocked-report.schema.json +76 -0
- package/schemas/launch-envelope.schema.json +25 -0
- package/schemas/launch-readiness.schema.json +73 -0
- package/schemas/media-file-inspection-report.schema.json +223 -0
- package/schemas/media-manifest.schema.json +84 -0
- package/schemas/merchandising-brief.schema.json +27 -0
- package/schemas/normalized-product-catalog.schema.json +42 -0
- package/schemas/product-content-generation-input.schema.json +40 -0
- package/schemas/product-content-generation-output.schema.json +43 -0
- package/schemas/raw-product-candidates.schema.json +32 -0
- package/schemas/shopify-access-preflight-report.schema.json +213 -0
- package/schemas/shopify-content-sync-report.schema.json +190 -0
- package/schemas/shopify-media-map.schema.json +87 -0
- package/schemas/shopify-media-upload-report.schema.json +96 -0
- package/schemas/shopify-operation-request.schema.json +81 -0
- package/schemas/shopify-preflight-report.schema.json +187 -0
- package/schemas/store-blueprint.schema.json +112 -0
- package/schemas/store-content-generation-output.schema.json +102 -0
- package/schemas/store-intake.schema.json +205 -0
- package/schemas/store-ops-plan.schema.json +82 -0
- package/schemas/storefront-preview-review.schema.json +227 -0
- package/schemas/supplier-access-report.schema.json +36 -0
- package/schemas/supplier-extraction-report.schema.json +185 -0
- package/schemas/theme-build-report.schema.json +43 -0
- package/schemas/theme-code-change-summary.schema.json +65 -0
- package/schemas/theme-plan.schema.json +26 -0
- package/schemas/theme-push-report.schema.json +151 -0
- package/schemas/theme-workspace-validation-report.schema.json +61 -0
- package/schemas/workflow-manifest.schema.json +29 -0
- package/scripts/audit-run-state.mjs +472 -0
- package/scripts/execute-shopify-operation.mjs +190 -0
- package/scripts/generate-image-assets-openai.mjs +342 -0
- package/scripts/generate-media-assets.mjs +121 -0
- package/scripts/init-run-state.mjs +69 -0
- package/scripts/inspect-media-files.mjs +334 -0
- package/scripts/prepare-launch-envelope.mjs +47 -0
- package/scripts/shopify-access-preflight.mjs +432 -0
- package/scripts/upload-shopify-media.mjs +831 -0
- package/scripts/validate-agent-report.mjs +46 -0
- package/scripts/validate-artifact.mjs +196 -0
- package/scripts/validate-launch-envelope.mjs +50 -0
- package/scripts/validate-registries.mjs +50 -0
- package/scripts/validate-workflow-manifest.mjs +38 -0
- package/scripts/version.mjs +192 -0
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { readFile } from "node:fs/promises";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { pathToFileURL } from "node:url";
|
|
6
|
+
|
|
7
|
+
const expectedGateArtifacts = new Map([
|
|
8
|
+
["1 Intake", "intake-gate.json"],
|
|
9
|
+
["1A Shopify Access Preflight", "shopify-access-preflight-gate.json"],
|
|
10
|
+
["2 Product Source Normalize", "product-source-normalize-gate.json"],
|
|
11
|
+
["3 Supplier Extraction", "supplier-extraction-gate.json"],
|
|
12
|
+
["4 Product Content", "product-content-quality-report.json"],
|
|
13
|
+
["5 Merchandising Strategy", "merchandising-quality-report.json"],
|
|
14
|
+
["6 Brand Identity", "brand-identity-quality-report.json"],
|
|
15
|
+
["7 Store Content", "store-content-quality-report.json"],
|
|
16
|
+
["8 Design Direction", "design-quality-report.json"],
|
|
17
|
+
["9 Store Blueprint", "store-blueprint-quality-report.json"],
|
|
18
|
+
["10 Theme Build And Apply", "theme-quality-report.json"],
|
|
19
|
+
["11A Shopify Preflight Auth Scope", "shopify-preflight-gate.json"],
|
|
20
|
+
["11B Shopify Products Collections", "shopify-products-sync-gate.json"],
|
|
21
|
+
["11C Shopify Content Navigation", "shopify-content-sync-gate.json"],
|
|
22
|
+
["11D Shopify Theme Push", "theme-push-gate.json"],
|
|
23
|
+
["11E Shopify Store Ops Consolidation", "store-ops-quality-report.json"],
|
|
24
|
+
["12 Launch Readiness", "launch-readiness-gate.json"]
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
const gateStatuses = new Set(["pass", "warning", "repairable", "blocked", "fatal"]);
|
|
28
|
+
|
|
29
|
+
function parseArgs(argv) {
|
|
30
|
+
const options = {};
|
|
31
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
32
|
+
const arg = argv[index];
|
|
33
|
+
if (arg === "--run-dir") {
|
|
34
|
+
options.runDir = argv[index + 1];
|
|
35
|
+
index += 1;
|
|
36
|
+
} else if (arg === "--now") {
|
|
37
|
+
options.now = new Date(argv[index + 1]);
|
|
38
|
+
index += 1;
|
|
39
|
+
} else if (arg === "--json") {
|
|
40
|
+
options.json = true;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return options;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function readTextIfExists(path) {
|
|
47
|
+
if (!existsSync(path)) {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
return readFile(path, "utf8");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function readJsonIfExists(path) {
|
|
54
|
+
const text = await readTextIfExists(path);
|
|
55
|
+
if (text === null) {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
return JSON.parse(text);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function check(name, result, details) {
|
|
62
|
+
return { name, result, details };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function isPass(checkResult) {
|
|
66
|
+
return checkResult.result === "ok";
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function parseProgress(progress) {
|
|
70
|
+
const currentStage = progress.match(/^Current stage:\s*(.+)$/m)?.[1]?.trim() ?? null;
|
|
71
|
+
const lastUpdated = progress.match(/^Last updated:\s*(.+)$/m)?.[1]?.trim() ?? null;
|
|
72
|
+
const resumeInstructions = markdownSection(progress, "Resume Instructions").trim();
|
|
73
|
+
const rows = [];
|
|
74
|
+
|
|
75
|
+
for (const line of progress.split(/\r?\n/)) {
|
|
76
|
+
if (!line.startsWith("|")) {
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (/^\|\s*-+/.test(line) || /Stage\s*\|/.test(line)) {
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
const cells = line.split("|").slice(1, -1).map((cell) => cell.trim());
|
|
83
|
+
if (cells.length < 7) {
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
rows.push({
|
|
87
|
+
stage: cells[0],
|
|
88
|
+
workerProfile: cells[1],
|
|
89
|
+
executionMode: cells[2],
|
|
90
|
+
status: cells[3],
|
|
91
|
+
gate: cells[4],
|
|
92
|
+
outputs: parseArtifactList(cells[5]),
|
|
93
|
+
notes: cells[6]
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return { currentStage, lastUpdated, resumeInstructions, rows };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function markdownSection(markdown, heading) {
|
|
101
|
+
const pattern = new RegExp(`^## ${escapeRegExp(heading)}\\s*$`, "m");
|
|
102
|
+
const match = markdown.match(pattern);
|
|
103
|
+
if (!match) {
|
|
104
|
+
return "";
|
|
105
|
+
}
|
|
106
|
+
const start = match.index + match[0].length;
|
|
107
|
+
const nextHeading = markdown.slice(start).match(/^## /m);
|
|
108
|
+
const end = nextHeading ? start + nextHeading.index : markdown.length;
|
|
109
|
+
return markdown.slice(start, end);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function escapeRegExp(value) {
|
|
113
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function parseArtifactList(value) {
|
|
117
|
+
if (!value || ["-", "none", "n/a"].includes(value.trim().toLowerCase())) {
|
|
118
|
+
return [];
|
|
119
|
+
}
|
|
120
|
+
return value
|
|
121
|
+
.split(/,\s*/)
|
|
122
|
+
.map((entry) => entry.replace(/[`*]/g, "").trim())
|
|
123
|
+
.map((entry) => entry.match(/[A-Za-z0-9._/-]+\.(?:json|md)$/)?.[0] ?? entry)
|
|
124
|
+
.filter((entry) => /^[A-Za-z0-9._/-]+\.(?:json|md)$/.test(entry));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function hasGateArtifact(row) {
|
|
128
|
+
return row.outputs.some((artifact) => artifact.endsWith("-gate.json") || artifact.endsWith("quality-report.json"));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function hasGateStatus(row) {
|
|
132
|
+
return gateStatuses.has(row.gate.toLowerCase());
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function summaryArtifactNames(summary) {
|
|
136
|
+
const names = [];
|
|
137
|
+
for (const key of ["artifacts", "keyArtifacts", "artifactPaths"]) {
|
|
138
|
+
const value = summary?.[key];
|
|
139
|
+
if (!Array.isArray(value)) {
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
for (const item of value) {
|
|
143
|
+
if (typeof item === "string") {
|
|
144
|
+
names.push(item);
|
|
145
|
+
} else if (item && typeof item === "object") {
|
|
146
|
+
names.push(item.path ?? item.file ?? item.filename ?? item.name);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return new Set(names.filter(Boolean).map((name) => name.replace(/^.*\//, "")));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function summaryStageRows(summary) {
|
|
154
|
+
if (Array.isArray(summary?.stageSummary)) {
|
|
155
|
+
return summary.stageSummary;
|
|
156
|
+
}
|
|
157
|
+
if (Array.isArray(summary?.stages)) {
|
|
158
|
+
return summary.stages;
|
|
159
|
+
}
|
|
160
|
+
return [];
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function progressLastUpdatedCheck(parsedProgress, now) {
|
|
164
|
+
const value = parsedProgress.lastUpdated;
|
|
165
|
+
if (!value) {
|
|
166
|
+
return check("progress-last-updated", "error", "PROGRESS.md is missing Last updated.");
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const timestamp = new Date(value);
|
|
170
|
+
if (Number.isNaN(timestamp.getTime())) {
|
|
171
|
+
return check("progress-last-updated", "error", `PROGRESS.md Last updated is not parseable: ${value}`);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const maxFutureSkewMs = 60_000;
|
|
175
|
+
if (timestamp.getTime() > now.getTime() + maxFutureSkewMs) {
|
|
176
|
+
return check(
|
|
177
|
+
"progress-last-updated",
|
|
178
|
+
"error",
|
|
179
|
+
`PROGRESS.md Last updated ${value} is later than audit time ${now.toISOString()}.`
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return check("progress-last-updated", "ok", `Last updated ${value} is not in the future.`);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function progressOutputArtifactsCheck(runDir, parsedProgress) {
|
|
187
|
+
const missing = [];
|
|
188
|
+
for (const row of parsedProgress.rows) {
|
|
189
|
+
for (const output of row.outputs) {
|
|
190
|
+
if (!existsSync(join(runDir, output))) {
|
|
191
|
+
missing.push(`${row.stage}: ${output}`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (missing.length) {
|
|
197
|
+
return check("progress-output-artifacts", "error", `Missing output artifacts: ${missing.join(", ")}`);
|
|
198
|
+
}
|
|
199
|
+
return check("progress-output-artifacts", "ok", "All PROGRESS.md output artifacts exist.");
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async function progressGateArtifactsCheck(runDir, parsedProgress) {
|
|
203
|
+
const missing = [];
|
|
204
|
+
const omitted = [];
|
|
205
|
+
|
|
206
|
+
for (const row of parsedProgress.rows.filter(hasGateStatus)) {
|
|
207
|
+
const expectedArtifact = expectedGateArtifacts.get(row.stage);
|
|
208
|
+
if (!expectedArtifact) {
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
if (!row.outputs.includes(expectedArtifact)) {
|
|
212
|
+
omitted.push(`${row.stage}: ${expectedArtifact}`);
|
|
213
|
+
}
|
|
214
|
+
if (!existsSync(join(runDir, expectedArtifact))) {
|
|
215
|
+
missing.push(`${row.stage}: ${expectedArtifact}`);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (missing.length || omitted.length) {
|
|
220
|
+
const details = [];
|
|
221
|
+
if (missing.length) {
|
|
222
|
+
details.push(`Missing gate artifacts: ${missing.join(", ")}`);
|
|
223
|
+
}
|
|
224
|
+
if (omitted.length) {
|
|
225
|
+
details.push(`PROGRESS.md omits gate artifacts: ${omitted.join(", ")}`);
|
|
226
|
+
}
|
|
227
|
+
return check("progress-gate-artifacts", "error", details.join("; "));
|
|
228
|
+
}
|
|
229
|
+
return check("progress-gate-artifacts", "ok", "Expected Lead-authored gate artifacts exist and are listed in PROGRESS.md.");
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async function workerRunningWaitStateCheck(runDir, parsedProgress) {
|
|
233
|
+
const currentRow = parsedProgress.rows.find((row) => (
|
|
234
|
+
row.stage === parsedProgress.currentStage && row.status.toLowerCase() === "in-progress"
|
|
235
|
+
));
|
|
236
|
+
|
|
237
|
+
if (!currentRow) {
|
|
238
|
+
return check("worker-running-wait-state", "ok", "No current in-progress worker row is active.");
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const launchEnvelope = currentRow.outputs.find((output) => output.endsWith("control/launch-envelope.json"));
|
|
242
|
+
if (!launchEnvelope) {
|
|
243
|
+
if (currentRow.executionMode === "worker-running") {
|
|
244
|
+
return check("worker-running-wait-state", "error", "Current worker-running row is missing a launch envelope.");
|
|
245
|
+
}
|
|
246
|
+
return check("worker-running-wait-state", "ok", "Current in-progress row does not advertise a launch envelope.");
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (!existsSync(join(runDir, launchEnvelope))) {
|
|
250
|
+
return check("worker-running-wait-state", "error", `Current worker launch envelope is missing: ${launchEnvelope}`);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const resume = parsedProgress.resumeInstructions;
|
|
254
|
+
const hasActiveWorker = /Active worker:\s*\S+/i.test(resume);
|
|
255
|
+
const waitsForNotification = /subagent_notification/i.test(resume);
|
|
256
|
+
const respawnsSameWorker = new RegExp(`spawn\\s+${escapeRegExp(currentRow.workerProfile)}\\b`, "i").test(resume);
|
|
257
|
+
|
|
258
|
+
if (currentRow.executionMode !== "worker-running") {
|
|
259
|
+
return check(
|
|
260
|
+
"worker-running-wait-state",
|
|
261
|
+
"error",
|
|
262
|
+
`Current stage has launch envelope ${launchEnvelope} but execution mode is ${currentRow.executionMode}, not worker-running.`
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
if (!hasActiveWorker || !waitsForNotification || respawnsSameWorker) {
|
|
266
|
+
const failures = [];
|
|
267
|
+
if (!hasActiveWorker) {
|
|
268
|
+
failures.push("missing Active worker resume field");
|
|
269
|
+
}
|
|
270
|
+
if (!waitsForNotification) {
|
|
271
|
+
failures.push("resume instructions do not wait for subagent_notification or worker handoff");
|
|
272
|
+
}
|
|
273
|
+
if (respawnsSameWorker) {
|
|
274
|
+
failures.push("resume instructions tell Lead to spawn the same worker again");
|
|
275
|
+
}
|
|
276
|
+
return check("worker-running-wait-state", "error", failures.join("; "));
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return check("worker-running-wait-state", "ok", `Waiting for active worker handoff from ${currentRow.workerProfile}.`);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async function gateArtifactStatusCheck(runDir, parsedProgress) {
|
|
283
|
+
const mismatches = [];
|
|
284
|
+
|
|
285
|
+
for (const row of parsedProgress.rows) {
|
|
286
|
+
const gateArtifacts = row.outputs.filter((artifact) => artifact.endsWith("-gate.json"));
|
|
287
|
+
for (const artifact of gateArtifacts) {
|
|
288
|
+
const gate = await readJsonIfExists(join(runDir, artifact));
|
|
289
|
+
const gateStatus = gate?.qualityGate?.status;
|
|
290
|
+
if (gateStatus && row.gate && !["-", "n/a"].includes(row.gate.toLowerCase()) && gateStatus !== row.gate) {
|
|
291
|
+
mismatches.push(`${artifact}: PROGRESS gate=${row.gate}, artifact status=${gateStatus}`);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (mismatches.length) {
|
|
297
|
+
return check("gate-artifact-status", "error", `Gate status mismatches: ${mismatches.join(", ")}`);
|
|
298
|
+
}
|
|
299
|
+
return check("gate-artifact-status", "ok", "Gate artifact statuses agree with PROGRESS.md.");
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function blockedGateUserActionErrors(artifact, qualityGate) {
|
|
303
|
+
const errors = [];
|
|
304
|
+
if (qualityGate.userActionRequired !== true) {
|
|
305
|
+
errors.push("userActionRequired must be true");
|
|
306
|
+
}
|
|
307
|
+
for (const field of ["blockers", "missingInputs", "questionsForUser", "actionsForUser"]) {
|
|
308
|
+
if (!Array.isArray(qualityGate[field])) {
|
|
309
|
+
errors.push(`${field} must be an array`);
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
for (const [index, item] of qualityGate[field].entries()) {
|
|
313
|
+
if (typeof item !== "string" || item.trim() === "") {
|
|
314
|
+
errors.push(`${field}[${index}] must be a non-empty string`);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
const questionCount = nonEmptyStringCount(qualityGate.questionsForUser);
|
|
319
|
+
const actionCount = nonEmptyStringCount(qualityGate.actionsForUser);
|
|
320
|
+
if (questionCount + actionCount === 0) {
|
|
321
|
+
errors.push("questionsForUser or actionsForUser must be non-empty");
|
|
322
|
+
}
|
|
323
|
+
for (const field of ["resumeCondition", "resumeStage"]) {
|
|
324
|
+
if (!qualityGate[field] || typeof qualityGate[field] !== "string") {
|
|
325
|
+
errors.push(`${field} must be a non-empty string`);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return errors.length ? `${artifact}: ${errors.join(", ")}` : "";
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function nonEmptyStringCount(value) {
|
|
332
|
+
return Array.isArray(value) ? value.filter((item) => typeof item === "string" && item.trim() !== "").length : 0;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function isGateArtifact(artifact) {
|
|
336
|
+
return artifact.endsWith("-gate.json") || artifact.endsWith("quality-report.json");
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
async function blockedGateUserActionCheck(runDir, parsedProgress) {
|
|
340
|
+
const failures = [];
|
|
341
|
+
for (const row of parsedProgress.rows) {
|
|
342
|
+
for (const artifact of row.outputs.filter(isGateArtifact)) {
|
|
343
|
+
const value = await readJsonIfExists(join(runDir, artifact));
|
|
344
|
+
const qualityGate = value?.qualityGate;
|
|
345
|
+
if (qualityGate?.status !== "blocked") {
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
const error = blockedGateUserActionErrors(artifact, qualityGate);
|
|
349
|
+
if (error) {
|
|
350
|
+
failures.push(error);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
if (failures.length) {
|
|
356
|
+
return check("blocked-gate-user-action", "error", `Blocked gate user-action fields are invalid: ${failures.join("; ")}`);
|
|
357
|
+
}
|
|
358
|
+
return check("blocked-gate-user-action", "ok", "Blocked gate artifacts include user-action fields when required.");
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
async function runSummaryStageGatesCheck(runDir, parsedProgress) {
|
|
362
|
+
const summary = await readJsonIfExists(join(runDir, "run-summary.json"));
|
|
363
|
+
if (!summary) {
|
|
364
|
+
return check("run-summary-stage-gates", "ok", "run-summary.json is absent.");
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const summaryRows = summaryStageRows(summary);
|
|
368
|
+
const mismatches = [];
|
|
369
|
+
for (const row of parsedProgress.rows.filter(hasGateArtifact)) {
|
|
370
|
+
const summaryRow = summaryRows.find((candidate) => candidate.stage === row.stage || candidate.name === row.stage);
|
|
371
|
+
const summaryGate = summaryRow?.gate ?? summaryRow?.qualityGate?.status;
|
|
372
|
+
if (summaryGate && summaryGate !== row.gate) {
|
|
373
|
+
mismatches.push(`${row.stage}: PROGRESS gate=${row.gate}, summary gate=${summaryGate}`);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
if (mismatches.length) {
|
|
378
|
+
return check("run-summary-stage-gates", "error", `Run summary gate mismatches: ${mismatches.join(", ")}`);
|
|
379
|
+
}
|
|
380
|
+
return check("run-summary-stage-gates", "ok", "Run summary stage gates do not contradict PROGRESS.md.");
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
async function runSummaryArtifactsCheck(runDir, parsedProgress) {
|
|
384
|
+
const summary = await readJsonIfExists(join(runDir, "run-summary.json"));
|
|
385
|
+
if (!summary) {
|
|
386
|
+
return check("run-summary-artifacts", "ok", "run-summary.json is absent.");
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const summaryArtifacts = summaryArtifactNames(summary);
|
|
390
|
+
if (summaryArtifacts.size === 0) {
|
|
391
|
+
return check("run-summary-artifacts", "ok", "run-summary.json does not expose an artifact list.");
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const expected = parsedProgress.rows.flatMap((row) => row.outputs).filter((artifact) => existsSync(join(runDir, artifact)));
|
|
395
|
+
const missing = expected.filter((artifact) => !summaryArtifacts.has(artifact.replace(/^.*\//, "")));
|
|
396
|
+
|
|
397
|
+
if (missing.length) {
|
|
398
|
+
return check("run-summary-artifacts", "error", `run-summary.json omits artifacts present in PROGRESS.md: ${missing.join(", ")}`);
|
|
399
|
+
}
|
|
400
|
+
return check("run-summary-artifacts", "ok", "run-summary.json artifact list matches PROGRESS.md outputs.");
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
async function runSummaryMarkdownCheck(runDir) {
|
|
404
|
+
const markdown = await readTextIfExists(join(runDir, "run-summary.md"));
|
|
405
|
+
if (markdown === null) {
|
|
406
|
+
return check("run-summary-markdown", "ok", "run-summary.md is absent.");
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const launchGateExists = existsSync(join(runDir, "launch-readiness-gate.json"));
|
|
410
|
+
const staleGateClaim = /no gate artifact was written|not-written-by-worker/i.test(markdown);
|
|
411
|
+
if (launchGateExists && staleGateClaim) {
|
|
412
|
+
return check("run-summary-markdown", "error", "run-summary.md claims no gate was written although launch-readiness-gate.json exists.");
|
|
413
|
+
}
|
|
414
|
+
if (launchGateExists && !/launch-readiness-gate\.json/.test(markdown)) {
|
|
415
|
+
return check("run-summary-markdown", "error", "run-summary.md omits launch-readiness-gate.json.");
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
return check("run-summary-markdown", "ok", "run-summary.md does not contain stale gate claims.");
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
export async function auditRunState({ runDir, now = new Date() } = {}) {
|
|
422
|
+
if (!runDir) {
|
|
423
|
+
throw new Error("--run-dir is required");
|
|
424
|
+
}
|
|
425
|
+
const progressPath = join(runDir, "PROGRESS.md");
|
|
426
|
+
const progress = await readTextIfExists(progressPath);
|
|
427
|
+
if (progress === null) {
|
|
428
|
+
return {
|
|
429
|
+
result: "error",
|
|
430
|
+
runDir,
|
|
431
|
+
checks: [check("progress-exists", "error", "PROGRESS.md is required.")]
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const parsedProgress = parseProgress(progress);
|
|
436
|
+
const checks = [
|
|
437
|
+
check("progress-exists", "ok", "PROGRESS.md exists."),
|
|
438
|
+
await progressLastUpdatedCheck(parsedProgress, now),
|
|
439
|
+
await progressOutputArtifactsCheck(runDir, parsedProgress),
|
|
440
|
+
await progressGateArtifactsCheck(runDir, parsedProgress),
|
|
441
|
+
await workerRunningWaitStateCheck(runDir, parsedProgress),
|
|
442
|
+
await gateArtifactStatusCheck(runDir, parsedProgress),
|
|
443
|
+
await blockedGateUserActionCheck(runDir, parsedProgress),
|
|
444
|
+
await runSummaryStageGatesCheck(runDir, parsedProgress),
|
|
445
|
+
await runSummaryArtifactsCheck(runDir, parsedProgress),
|
|
446
|
+
await runSummaryMarkdownCheck(runDir)
|
|
447
|
+
];
|
|
448
|
+
|
|
449
|
+
return {
|
|
450
|
+
result: checks.every(isPass) ? "ok" : "error",
|
|
451
|
+
runDir,
|
|
452
|
+
checks
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
async function main() {
|
|
457
|
+
try {
|
|
458
|
+
const options = parseArgs(process.argv.slice(2));
|
|
459
|
+
const result = await auditRunState(options);
|
|
460
|
+
console.log(JSON.stringify(result, null, 2));
|
|
461
|
+
if (result.result !== "ok") {
|
|
462
|
+
process.exitCode = 1;
|
|
463
|
+
}
|
|
464
|
+
} catch (error) {
|
|
465
|
+
console.error(error.message);
|
|
466
|
+
process.exitCode = 1;
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
471
|
+
await main();
|
|
472
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { isAbsolute, join, normalize } from "node:path";
|
|
4
|
+
import { pathToFileURL } from "node:url";
|
|
5
|
+
|
|
6
|
+
import { executeShopifyOperation } from "../lib/internal/shopify-operation-adapter.mjs";
|
|
7
|
+
|
|
8
|
+
export function parseShopifyOperationArgs(argv) {
|
|
9
|
+
const options = { validateOnly: false };
|
|
10
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
11
|
+
const arg = argv[index];
|
|
12
|
+
if (arg === "--input") {
|
|
13
|
+
const value = argv[index + 1];
|
|
14
|
+
if (!value || value.startsWith("--")) throw new Error("--input requires a value");
|
|
15
|
+
options.inputPath = value;
|
|
16
|
+
index += 1;
|
|
17
|
+
} else if (arg === "--run-dir") {
|
|
18
|
+
const value = argv[index + 1];
|
|
19
|
+
if (!value || value.startsWith("--")) throw new Error("--run-dir requires a value");
|
|
20
|
+
options.runDir = value;
|
|
21
|
+
index += 1;
|
|
22
|
+
} else if (arg === "--validate-only") {
|
|
23
|
+
options.validateOnly = true;
|
|
24
|
+
} else {
|
|
25
|
+
throw new Error(`Unknown argument: ${arg}`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return options;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function readJson(path) {
|
|
32
|
+
return JSON.parse(await readFile(path, "utf8"));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function isNonEmptyString(value) {
|
|
36
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function hasRelativeSafePath(value) {
|
|
40
|
+
if (!isNonEmptyString(value) || isAbsolute(value)) return false;
|
|
41
|
+
const normalized = normalize(value);
|
|
42
|
+
return normalized !== "." && !normalized.startsWith("..");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const allowedRequestFields = new Set([
|
|
46
|
+
"schemaVersion",
|
|
47
|
+
"runDir",
|
|
48
|
+
"stageId",
|
|
49
|
+
"attemptId",
|
|
50
|
+
"launchEnvelopePath",
|
|
51
|
+
"envelope",
|
|
52
|
+
"actionId",
|
|
53
|
+
"launchEnvelopeSha256",
|
|
54
|
+
"grantedScopes",
|
|
55
|
+
"approvalGrantPath",
|
|
56
|
+
"operationKey",
|
|
57
|
+
"targetStore",
|
|
58
|
+
"apiVersion",
|
|
59
|
+
"requestFingerprint",
|
|
60
|
+
"naturalResourceKey",
|
|
61
|
+
"providerIdempotencySupported",
|
|
62
|
+
"resourceType",
|
|
63
|
+
"executorInput"
|
|
64
|
+
]);
|
|
65
|
+
|
|
66
|
+
const allowedExecutorFieldsByKind = {
|
|
67
|
+
"admin-graphql": new Set(["kind", "operationName", "query", "variables"]),
|
|
68
|
+
"theme-push-unpublished": new Set(["kind", "themeWorkspace", "themeId", "themeName", "nodelete"])
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export function validateShopifyOperationRequest(request) {
|
|
72
|
+
const errors = [];
|
|
73
|
+
if (!request || typeof request !== "object") {
|
|
74
|
+
return { ok: false, errors: ["Request must be an object"] };
|
|
75
|
+
}
|
|
76
|
+
for (const field of Object.keys(request)) {
|
|
77
|
+
if (!allowedRequestFields.has(field)) errors.push(`Unsupported request field: ${field}`);
|
|
78
|
+
}
|
|
79
|
+
if (request.schemaVersion !== "1.0.0") errors.push("schemaVersion must be 1.0.0");
|
|
80
|
+
if (Object.hasOwn(request, "actionRegistry")) errors.push("actionRegistry override is forbidden");
|
|
81
|
+
if (!request.envelope && !request.launchEnvelopePath) errors.push("Request must include launchEnvelopePath or envelope");
|
|
82
|
+
if (request.launchEnvelopePath && !hasRelativeSafePath(request.launchEnvelopePath)) {
|
|
83
|
+
errors.push("launchEnvelopePath must be a relative path inside runDir");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
for (const field of [
|
|
87
|
+
"runDir",
|
|
88
|
+
"stageId",
|
|
89
|
+
"attemptId",
|
|
90
|
+
"actionId",
|
|
91
|
+
"launchEnvelopeSha256",
|
|
92
|
+
"operationKey",
|
|
93
|
+
"targetStore",
|
|
94
|
+
"apiVersion",
|
|
95
|
+
"requestFingerprint",
|
|
96
|
+
"naturalResourceKey",
|
|
97
|
+
"resourceType"
|
|
98
|
+
]) {
|
|
99
|
+
if (!isNonEmptyString(request[field])) errors.push(`${field} is required`);
|
|
100
|
+
}
|
|
101
|
+
if (!Array.isArray(request.grantedScopes)) errors.push("grantedScopes must be an array");
|
|
102
|
+
if (typeof request.providerIdempotencySupported !== "boolean") {
|
|
103
|
+
errors.push("providerIdempotencySupported must be boolean");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const executorInput = request.executorInput;
|
|
107
|
+
if (!executorInput || typeof executorInput !== "object") {
|
|
108
|
+
errors.push("executorInput is required");
|
|
109
|
+
} else if (executorInput.kind === "admin-graphql") {
|
|
110
|
+
for (const field of Object.keys(executorInput)) {
|
|
111
|
+
if (!allowedExecutorFieldsByKind["admin-graphql"].has(field)) errors.push(`Unsupported executorInput field: ${field}`);
|
|
112
|
+
}
|
|
113
|
+
if (!isNonEmptyString(executorInput.operationName)) errors.push("executorInput.operationName is required");
|
|
114
|
+
if (!isNonEmptyString(executorInput.query)) errors.push("executorInput.query is required");
|
|
115
|
+
if (
|
|
116
|
+
executorInput.variables !== undefined
|
|
117
|
+
&& (executorInput.variables === null || typeof executorInput.variables !== "object" || Array.isArray(executorInput.variables))
|
|
118
|
+
) {
|
|
119
|
+
errors.push("executorInput.variables must be an object when provided");
|
|
120
|
+
}
|
|
121
|
+
} else if (executorInput.kind === "theme-push-unpublished") {
|
|
122
|
+
for (const field of Object.keys(executorInput)) {
|
|
123
|
+
if (!allowedExecutorFieldsByKind["theme-push-unpublished"].has(field)) errors.push(`Unsupported executorInput field: ${field}`);
|
|
124
|
+
}
|
|
125
|
+
if (!isNonEmptyString(executorInput.themeWorkspace)) errors.push("executorInput.themeWorkspace is required");
|
|
126
|
+
} else {
|
|
127
|
+
errors.push("executorInput.kind must be admin-graphql or theme-push-unpublished");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return { ok: errors.length === 0, errors };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export async function validateShopifyOperationRequestFile({ inputPath, runDir }) {
|
|
134
|
+
if (!inputPath) {
|
|
135
|
+
throw new Error("Missing --input Shopify operation request JSON");
|
|
136
|
+
}
|
|
137
|
+
const request = await readJson(inputPath);
|
|
138
|
+
const resolvedRunDir = runDir ?? request.runDir;
|
|
139
|
+
return validateShopifyOperationRequest({ ...request, runDir: resolvedRunDir });
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export async function executeShopifyOperationRequest({
|
|
143
|
+
packageRoot = join(import.meta.dirname, ".."),
|
|
144
|
+
inputPath,
|
|
145
|
+
runDir,
|
|
146
|
+
runCommand
|
|
147
|
+
}) {
|
|
148
|
+
if (!inputPath) {
|
|
149
|
+
throw new Error("Missing --input Shopify operation request JSON");
|
|
150
|
+
}
|
|
151
|
+
const request = await readJson(inputPath);
|
|
152
|
+
const resolvedRunDir = runDir ?? request.runDir;
|
|
153
|
+
const requestForValidation = { ...request, runDir: resolvedRunDir };
|
|
154
|
+
const validation = validateShopifyOperationRequest(requestForValidation);
|
|
155
|
+
if (!validation.ok) {
|
|
156
|
+
throw new Error(validation.errors.join("\n"));
|
|
157
|
+
}
|
|
158
|
+
const actionRegistry = await readJson(join(packageRoot, "contracts", "action-registry.json"));
|
|
159
|
+
const envelope = request.envelope
|
|
160
|
+
?? await readJson(join(resolvedRunDir, request.launchEnvelopePath));
|
|
161
|
+
|
|
162
|
+
return executeShopifyOperation({
|
|
163
|
+
...request,
|
|
164
|
+
runDir: resolvedRunDir,
|
|
165
|
+
actionRegistry,
|
|
166
|
+
envelope,
|
|
167
|
+
runCommand
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function main() {
|
|
172
|
+
try {
|
|
173
|
+
const options = parseShopifyOperationArgs(process.argv.slice(2));
|
|
174
|
+
if (options.validateOnly) {
|
|
175
|
+
const validation = await validateShopifyOperationRequestFile(options);
|
|
176
|
+
console.log(JSON.stringify(validation, null, 2));
|
|
177
|
+
if (!validation.ok) process.exitCode = 1;
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
const result = await executeShopifyOperationRequest(options);
|
|
181
|
+
console.log(JSON.stringify(result, null, 2));
|
|
182
|
+
} catch (error) {
|
|
183
|
+
console.error(error.message);
|
|
184
|
+
process.exitCode = 1;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
189
|
+
await main();
|
|
190
|
+
}
|