@metasession.co/devaudit-cli 0.1.55 → 0.1.56
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/dist/index.js +159 -21
- package/dist/index.js.map +1 -1
- package/package.json +3 -2
- package/sdlc/files/ci/ci.yml.template +49 -8
- package/sdlc/files/ci/python/ci.yml.template +6 -2
package/dist/index.js
CHANGED
|
@@ -8,6 +8,7 @@ import envPaths from 'env-paths';
|
|
|
8
8
|
import { fileURLToPath, pathToFileURL } from 'url';
|
|
9
9
|
import { validateManifest } from '@metasession.co/devaudit-plugin-sdk';
|
|
10
10
|
import * as clack2 from '@clack/prompts';
|
|
11
|
+
import Ajv from 'ajv';
|
|
11
12
|
import { readdir, readFile, writeFile } from 'fs/promises';
|
|
12
13
|
|
|
13
14
|
var DEFAULT_OPTIONS = { json: false, verbose: false, noColor: false };
|
|
@@ -55,7 +56,7 @@ function emitJsonResult(payload) {
|
|
|
55
56
|
|
|
56
57
|
// package.json
|
|
57
58
|
var package_default = {
|
|
58
|
-
version: "0.1.
|
|
59
|
+
version: "0.1.56"};
|
|
59
60
|
|
|
60
61
|
// src/lib/version.ts
|
|
61
62
|
var CLI_VERSION = package_default.version;
|
|
@@ -594,27 +595,62 @@ async function runStatus(options) {
|
|
|
594
595
|
}
|
|
595
596
|
}
|
|
596
597
|
var RETRYABLE_STATUSES = /* @__PURE__ */ new Set([429, 500, 502, 503, 504]);
|
|
597
|
-
var
|
|
598
|
+
var DEFAULT_MAX_ATTEMPTS = 5;
|
|
598
599
|
var INITIAL_BACKOFF_MS = 1e3;
|
|
600
|
+
function maxAttempts() {
|
|
601
|
+
const raw = Number.parseInt(process.env["UPLOAD_MAX_ATTEMPTS"] ?? "", 10);
|
|
602
|
+
return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_MAX_ATTEMPTS;
|
|
603
|
+
}
|
|
599
604
|
async function collectFiles(filePath) {
|
|
600
605
|
const stat = await promises.stat(filePath);
|
|
601
606
|
if (stat.isFile()) return [filePath];
|
|
602
607
|
if (stat.isDirectory()) {
|
|
603
|
-
const entries = await promises.readdir(filePath, { withFileTypes: true });
|
|
604
608
|
const files = [];
|
|
609
|
+
const entries = await promises.readdir(filePath, { withFileTypes: true });
|
|
605
610
|
for (const entry of entries) {
|
|
606
|
-
|
|
611
|
+
const child = join(filePath, entry.name);
|
|
612
|
+
if (entry.isDirectory()) {
|
|
613
|
+
files.push(...await collectFiles(child));
|
|
614
|
+
} else if (entry.isFile()) {
|
|
615
|
+
files.push(child);
|
|
616
|
+
}
|
|
607
617
|
}
|
|
608
618
|
return files;
|
|
609
619
|
}
|
|
610
620
|
throw new Error(`${filePath} is neither a file nor a directory.`);
|
|
611
621
|
}
|
|
622
|
+
var STUB_BANNER = /STARTER TEMPLATE.+REPLACE BEFORE/;
|
|
623
|
+
function isUneditedStub(buf) {
|
|
624
|
+
return STUB_BANNER.test(buf.toString("utf-8"));
|
|
625
|
+
}
|
|
612
626
|
function delay(ms) {
|
|
613
627
|
return new Promise((res) => setTimeout(res, ms));
|
|
614
628
|
}
|
|
615
|
-
async function
|
|
629
|
+
async function probeBaseUrlDrift(baseUrl) {
|
|
630
|
+
try {
|
|
631
|
+
const probeUrl = `${baseUrl.replace(/\/$/, "")}/api/health`;
|
|
632
|
+
const controller = new AbortController();
|
|
633
|
+
const timer = setTimeout(() => controller.abort(), 1e4);
|
|
634
|
+
let res;
|
|
635
|
+
try {
|
|
636
|
+
res = await fetch(probeUrl, { method: "HEAD", redirect: "manual", signal: controller.signal });
|
|
637
|
+
} finally {
|
|
638
|
+
clearTimeout(timer);
|
|
639
|
+
}
|
|
640
|
+
if (res.status < 300 || res.status >= 400) return null;
|
|
641
|
+
const location = res.headers.get("location");
|
|
642
|
+
if (!location) return null;
|
|
643
|
+
const oldHost = new URL(baseUrl).host;
|
|
644
|
+
const newHost = new URL(location, baseUrl).host;
|
|
645
|
+
if (!newHost || oldHost === newHost) return null;
|
|
646
|
+
return `DEVAUDIT_BASE_URL host '${oldHost}' redirects to '${newHost}'. Rotate the DEVAUDIT_BASE_URL secret to the new host to avoid silent breakage (uploads still succeed this run). Ref: https://github.com/metasession-dev/DevAudit-Installer/issues/143`;
|
|
647
|
+
} catch {
|
|
648
|
+
return null;
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
async function uploadOne(file, buf, opts) {
|
|
652
|
+
const attempts = maxAttempts();
|
|
616
653
|
const form = new FormData();
|
|
617
|
-
const buf = await promises.readFile(file);
|
|
618
654
|
const blob = new Blob([new Uint8Array(buf)]);
|
|
619
655
|
form.set("file", blob, basename(file));
|
|
620
656
|
form.set("projectSlug", opts.projectSlug);
|
|
@@ -625,10 +661,14 @@ async function uploadOne(file, opts) {
|
|
|
625
661
|
if (opts.createReleaseIfMissing) form.set("createReleaseIfMissing", "true");
|
|
626
662
|
if (opts.environment) form.set("environment", opts.environment);
|
|
627
663
|
if (opts.evidenceCategory) form.set("evidenceCategory", opts.evidenceCategory);
|
|
664
|
+
if (opts.releaseBranch) form.set("releaseBranch", opts.releaseBranch);
|
|
665
|
+
if (opts.releaseTitle) form.set("releaseTitle", opts.releaseTitle);
|
|
666
|
+
if (opts.changeType) form.set("changeType", opts.changeType);
|
|
667
|
+
if (opts.gateStatus) form.set("gateStatus", opts.gateStatus);
|
|
628
668
|
const url = `${opts.baseUrl.replace(/\/$/, "")}/api/evidence/upload`;
|
|
629
669
|
let attempt = 1;
|
|
630
670
|
let backoff = INITIAL_BACKOFF_MS;
|
|
631
|
-
while (attempt <=
|
|
671
|
+
while (attempt <= attempts) {
|
|
632
672
|
const res = await fetch(url, {
|
|
633
673
|
method: "POST",
|
|
634
674
|
headers: { authorization: `Bearer ${opts.apiKey}` },
|
|
@@ -638,7 +678,7 @@ async function uploadOne(file, opts) {
|
|
|
638
678
|
const body = await res.json().catch(() => null);
|
|
639
679
|
return { file, ok: true, status: res.status, body };
|
|
640
680
|
}
|
|
641
|
-
if (RETRYABLE_STATUSES.has(res.status) && attempt <
|
|
681
|
+
if (RETRYABLE_STATUSES.has(res.status) && attempt < attempts) {
|
|
642
682
|
const retryAfter = Number.parseInt(res.headers.get("retry-after") ?? "", 10);
|
|
643
683
|
const wait = Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * 1e3 : backoff;
|
|
644
684
|
await delay(wait);
|
|
@@ -658,7 +698,12 @@ async function uploadEvidence(opts) {
|
|
|
658
698
|
}
|
|
659
699
|
const results = [];
|
|
660
700
|
for (const file of files) {
|
|
661
|
-
|
|
701
|
+
const buf = await promises.readFile(file);
|
|
702
|
+
if (isUneditedStub(buf)) {
|
|
703
|
+
results.push({ file, ok: true, status: 0, skipped: true });
|
|
704
|
+
continue;
|
|
705
|
+
}
|
|
706
|
+
results.push(await uploadOne(file, buf, opts));
|
|
662
707
|
}
|
|
663
708
|
return results;
|
|
664
709
|
}
|
|
@@ -670,8 +715,24 @@ function buildMetadata(options) {
|
|
|
670
715
|
if (options.gitSha) metadata["gitSha"] = options.gitSha;
|
|
671
716
|
if (options.ciRunId) metadata["ciRunId"] = options.ciRunId;
|
|
672
717
|
if (options.branch) metadata["branch"] = options.branch;
|
|
718
|
+
for (const kv of options.metaKeys ?? []) {
|
|
719
|
+
const eq = kv.indexOf("=");
|
|
720
|
+
metadata[kv.slice(0, eq)] = kv.slice(eq + 1);
|
|
721
|
+
}
|
|
673
722
|
return metadata;
|
|
674
723
|
}
|
|
724
|
+
function validateOptions(options) {
|
|
725
|
+
if (options.environment && !options.release) {
|
|
726
|
+
return "--environment requires --release (evidence without a release is orphaned)";
|
|
727
|
+
}
|
|
728
|
+
if (options.release && !options.category) {
|
|
729
|
+
return "--category is required when --release is specified (gate validation)";
|
|
730
|
+
}
|
|
731
|
+
for (const kv of options.metaKeys ?? []) {
|
|
732
|
+
if (!kv.includes("=")) return `--meta-key requires key=value (got: ${kv})`;
|
|
733
|
+
}
|
|
734
|
+
return null;
|
|
735
|
+
}
|
|
675
736
|
async function runDryRun(options, baseUrl) {
|
|
676
737
|
const log = logger();
|
|
677
738
|
const files = await collectFiles(options.filePath);
|
|
@@ -700,6 +761,12 @@ async function runPush(options) {
|
|
|
700
761
|
const log = logger();
|
|
701
762
|
const projectPath = resolve(process.cwd());
|
|
702
763
|
const baseUrl = options.baseUrl ?? process.env["DEVAUDIT_BASE_URL"] ?? DEFAULT_BASE_URL3;
|
|
764
|
+
const validationError = validateOptions(options);
|
|
765
|
+
if (validationError) {
|
|
766
|
+
if (isJsonMode()) emitJsonResult({ ok: false, reason: "invalid_arguments", message: validationError });
|
|
767
|
+
else log.error(validationError);
|
|
768
|
+
process.exit(2);
|
|
769
|
+
}
|
|
703
770
|
if (options.dryRun) {
|
|
704
771
|
await runDryRun(options, baseUrl);
|
|
705
772
|
return;
|
|
@@ -716,6 +783,8 @@ async function runPush(options) {
|
|
|
716
783
|
log.info(
|
|
717
784
|
`Uploading ${options.filePath} (project=${options.projectSlug} req=${options.requirementId} type=${options.evidenceType}) \u2192 ${baseUrl}`
|
|
718
785
|
);
|
|
786
|
+
const driftWarning = await probeBaseUrlDrift(baseUrl);
|
|
787
|
+
if (driftWarning) log.warn(driftWarning);
|
|
719
788
|
const plugins = options.plugins ?? (await discoverPlugins()).loaded;
|
|
720
789
|
if (plugins.length > 0) {
|
|
721
790
|
const ctx = await buildPluginContext({ projectPath });
|
|
@@ -733,12 +802,20 @@ async function runPush(options) {
|
|
|
733
802
|
...options.createReleaseIfMissing !== void 0 ? { createReleaseIfMissing: options.createReleaseIfMissing } : {},
|
|
734
803
|
...options.environment !== void 0 ? { environment: options.environment } : {},
|
|
735
804
|
...options.category !== void 0 ? { evidenceCategory: options.category } : {},
|
|
805
|
+
...options.branch !== void 0 ? { releaseBranch: options.branch } : {},
|
|
806
|
+
...options.releaseTitle !== void 0 ? { releaseTitle: options.releaseTitle } : {},
|
|
807
|
+
...options.changeType !== void 0 ? { changeType: options.changeType } : {},
|
|
808
|
+
...options.gateStatus !== void 0 ? { gateStatus: options.gateStatus } : {},
|
|
736
809
|
metadata
|
|
737
810
|
});
|
|
738
811
|
let okCount = 0;
|
|
739
812
|
let failCount = 0;
|
|
813
|
+
let skippedCount = 0;
|
|
740
814
|
for (const result of results) {
|
|
741
|
-
if (result.
|
|
815
|
+
if (result.skipped) {
|
|
816
|
+
skippedCount++;
|
|
817
|
+
log.warn(` \u2298 ${result.file} SKIPPED \u2014 unedited starter stub (replace the STARTER TEMPLATE banner to upload)`);
|
|
818
|
+
} else if (result.ok) {
|
|
742
819
|
okCount++;
|
|
743
820
|
log.success(` \u2713 ${result.file} (HTTP ${result.status})`);
|
|
744
821
|
} else {
|
|
@@ -747,7 +824,7 @@ async function runPush(options) {
|
|
|
747
824
|
}
|
|
748
825
|
}
|
|
749
826
|
log.log("");
|
|
750
|
-
log.info(`Uploaded: ${okCount} succeeded, ${failCount} failed.`);
|
|
827
|
+
log.info(`Uploaded: ${okCount} succeeded, ${failCount} failed, ${skippedCount} skipped.`);
|
|
751
828
|
if (plugins.length > 0) {
|
|
752
829
|
const ctx = await buildPluginContext({ projectPath });
|
|
753
830
|
await runHook(plugins, "afterPush", ctx);
|
|
@@ -757,7 +834,14 @@ async function runPush(options) {
|
|
|
757
834
|
ok: failCount === 0,
|
|
758
835
|
uploaded: okCount,
|
|
759
836
|
failed: failCount,
|
|
760
|
-
|
|
837
|
+
skipped: skippedCount,
|
|
838
|
+
results: results.map((r) => ({
|
|
839
|
+
file: r.file,
|
|
840
|
+
ok: r.ok,
|
|
841
|
+
status: r.status,
|
|
842
|
+
skipped: r.skipped ?? false,
|
|
843
|
+
error: r.error ?? null
|
|
844
|
+
}))
|
|
761
845
|
});
|
|
762
846
|
}
|
|
763
847
|
if (failCount > 0) process.exit(4);
|
|
@@ -1488,10 +1572,44 @@ async function configureBranchProtection(ctx, provider) {
|
|
|
1488
1572
|
message: `${result.message ?? "branch-protection apply failed"} \u2014 configure manually`
|
|
1489
1573
|
};
|
|
1490
1574
|
}
|
|
1575
|
+
var ajv = new Ajv({ allErrors: true, strict: false });
|
|
1576
|
+
var validatorCache = /* @__PURE__ */ new Map();
|
|
1577
|
+
async function getValidator(schemaPath) {
|
|
1578
|
+
const cached = validatorCache.get(schemaPath);
|
|
1579
|
+
if (cached) return cached;
|
|
1580
|
+
let schema;
|
|
1581
|
+
try {
|
|
1582
|
+
schema = JSON.parse(await promises.readFile(schemaPath, "utf-8"));
|
|
1583
|
+
} catch {
|
|
1584
|
+
return null;
|
|
1585
|
+
}
|
|
1586
|
+
delete schema["$id"];
|
|
1587
|
+
const validate = ajv.compile(schema);
|
|
1588
|
+
validatorCache.set(schemaPath, validate);
|
|
1589
|
+
return validate;
|
|
1590
|
+
}
|
|
1591
|
+
function formatErrors(validate) {
|
|
1592
|
+
return (validate.errors ?? []).map((e) => ` - ${e.instancePath === "" ? "(root)" : e.instancePath} ${e.message ?? "is invalid"}`).join("\n");
|
|
1593
|
+
}
|
|
1594
|
+
async function loadAdapter(adapterPath, schemaPath, kind) {
|
|
1595
|
+
const raw = await promises.readFile(adapterPath, "utf-8");
|
|
1596
|
+
let parsed;
|
|
1597
|
+
try {
|
|
1598
|
+
parsed = JSON.parse(raw);
|
|
1599
|
+
} catch (err) {
|
|
1600
|
+
throw new Error(`Invalid JSON in ${kind} adapter ${adapterPath}: ${err.message}`);
|
|
1601
|
+
}
|
|
1602
|
+
const validate = await getValidator(schemaPath);
|
|
1603
|
+
if (validate && !validate(parsed)) {
|
|
1604
|
+
throw new Error(`${kind} adapter ${adapterPath} failed schema validation:
|
|
1605
|
+
${formatErrors(validate)}`);
|
|
1606
|
+
}
|
|
1607
|
+
return parsed;
|
|
1608
|
+
}
|
|
1491
1609
|
async function loadStackAdapter(installerRoot, stack) {
|
|
1492
|
-
const
|
|
1493
|
-
const
|
|
1494
|
-
return
|
|
1610
|
+
const adapterPath = join(installerRoot, "sdlc", "files", "stacks", stack, "adapter.json");
|
|
1611
|
+
const schemaPath = join(installerRoot, "sdlc", "files", "stacks", "_schema", "adapter.schema.json");
|
|
1612
|
+
return loadAdapter(adapterPath, schemaPath, "Stack");
|
|
1495
1613
|
}
|
|
1496
1614
|
async function listStacks(installerRoot) {
|
|
1497
1615
|
const dir = join(installerRoot, "sdlc", "files", "stacks");
|
|
@@ -2438,8 +2556,6 @@ async function runJoinCommand(options) {
|
|
|
2438
2556
|
process.exit(1);
|
|
2439
2557
|
}
|
|
2440
2558
|
}
|
|
2441
|
-
|
|
2442
|
-
// src/commands/update.ts
|
|
2443
2559
|
async function runUpdate(options) {
|
|
2444
2560
|
const log = logger();
|
|
2445
2561
|
if (options.version) {
|
|
@@ -2449,6 +2565,14 @@ async function runUpdate(options) {
|
|
|
2449
2565
|
log.error("No project paths provided. Usage: devaudit update <version> <path> [path...]");
|
|
2450
2566
|
process.exit(2);
|
|
2451
2567
|
}
|
|
2568
|
+
if (options.dryRun) {
|
|
2569
|
+
log.warn("DRY RUN \u2014 no files will be written and no plugin hooks will fire");
|
|
2570
|
+
for (const projectPath of options.paths) {
|
|
2571
|
+
log.info(` [dry-run] would sync SDLC templates via syncProject() against ${resolve(projectPath)}`);
|
|
2572
|
+
}
|
|
2573
|
+
log.success("=== Dry run complete (no mutations performed) ===");
|
|
2574
|
+
return;
|
|
2575
|
+
}
|
|
2452
2576
|
const plugins = options.plugins ?? (await discoverPlugins()).loaded;
|
|
2453
2577
|
for (const projectPath of options.paths) {
|
|
2454
2578
|
if (plugins.length > 0) {
|
|
@@ -2775,7 +2899,7 @@ async function main(argv) {
|
|
|
2775
2899
|
});
|
|
2776
2900
|
program.command("update [version] [paths...]").description(
|
|
2777
2901
|
"Sync framework templates into existing consumer(s). Both args are optional: paths default to the current directory, and version is a cosmetic label (defaults to the running CLI version). So a bare `devaudit update` syncs the current project."
|
|
2778
|
-
).action(async (version, paths2) => {
|
|
2902
|
+
).action(async (version, paths2, cmd) => {
|
|
2779
2903
|
let resolvedVersion = version;
|
|
2780
2904
|
let resolvedPaths = paths2 ?? [];
|
|
2781
2905
|
const looksLikeVersion = (s) => /^v?\d+(\.\d+)/.test(s);
|
|
@@ -2784,9 +2908,19 @@ async function main(argv) {
|
|
|
2784
2908
|
resolvedVersion = void 0;
|
|
2785
2909
|
}
|
|
2786
2910
|
if (resolvedPaths.length === 0) resolvedPaths = ["."];
|
|
2787
|
-
|
|
2911
|
+
const globals = cmd.optsWithGlobals();
|
|
2912
|
+
await runUpdate({
|
|
2913
|
+
version: resolvedVersion ?? CLI_VERSION,
|
|
2914
|
+
paths: resolvedPaths,
|
|
2915
|
+
...globals.dryRun !== void 0 ? { dryRun: Boolean(globals.dryRun) } : {}
|
|
2916
|
+
});
|
|
2788
2917
|
});
|
|
2789
|
-
program.command("push <project-slug> <requirement-id> <evidence-type> <file>").description("Upload evidence file(s) to DevAudit (port of scripts/upload-evidence.sh)").option("--release <version>", "release version (e.g. v1.0.0)").option("--create-release-if-missing", "auto-create the release as 'draft' if absent").option("--environment <env>", "uat | production").option("--category <cat>", "ci_pipeline | local_dev | planning | test_report | security_scan | release_artifact").option("--git-sha <sha>", "attached to metadata.gitSha").option("--ci-run-id <id>", "attached to metadata.ciRunId").option("--branch <name>", "
|
|
2918
|
+
program.command("push <project-slug> <requirement-id> <evidence-type> <file>").description("Upload evidence file(s) to DevAudit (port of scripts/upload-evidence.sh)").option("--release <version>", "release version (e.g. v1.0.0)").option("--create-release-if-missing", "auto-create the release as 'draft' if absent").option("--environment <env>", "uat | production").option("--category <cat>", "ci_pipeline | local_dev | planning | test_report | security_scan | release_artifact").option("--git-sha <sha>", "attached to metadata.gitSha").option("--ci-run-id <id>", "attached to metadata.ciRunId").option("--branch <name>", "git branch \u2014 sent as releaseBranch + metadata.branch").option("--release-title <text>", "human title for the release row (releaseTitle; portal no-clobbers)").option("--change-type <type>", "conventional-commit prefix for the release row (changeType)").option("--gate-status <status>", "passed | failed | skipped (gateStatus)").option(
|
|
2919
|
+
"--meta-key <pair>",
|
|
2920
|
+
"repeatable key=value merged into the metadata JSON",
|
|
2921
|
+
(val, acc) => [...acc, val],
|
|
2922
|
+
[]
|
|
2923
|
+
).option("--base-url <url>", "override portal URL (defaults to DEVAUDIT_BASE_URL env or production)").option("--api-key <key>", "override DEVAUDIT_API_KEY env var").action(
|
|
2790
2924
|
async (projectSlug, requirementId, evidenceType, file, opts, cmd) => {
|
|
2791
2925
|
const globals = cmd.optsWithGlobals();
|
|
2792
2926
|
await runPush({
|
|
@@ -2801,6 +2935,10 @@ async function main(argv) {
|
|
|
2801
2935
|
...opts.gitSha !== void 0 ? { gitSha: opts.gitSha } : {},
|
|
2802
2936
|
...opts.ciRunId !== void 0 ? { ciRunId: opts.ciRunId } : {},
|
|
2803
2937
|
...opts.branch !== void 0 ? { branch: opts.branch } : {},
|
|
2938
|
+
...opts.releaseTitle !== void 0 ? { releaseTitle: opts.releaseTitle } : {},
|
|
2939
|
+
...opts.changeType !== void 0 ? { changeType: opts.changeType } : {},
|
|
2940
|
+
...opts.gateStatus !== void 0 ? { gateStatus: opts.gateStatus } : {},
|
|
2941
|
+
...opts.metaKey !== void 0 && opts.metaKey.length > 0 ? { metaKeys: opts.metaKey } : {},
|
|
2804
2942
|
...opts.baseUrl !== void 0 ? { baseUrl: opts.baseUrl } : {},
|
|
2805
2943
|
...opts.apiKey !== void 0 ? { apiKey: opts.apiKey } : {},
|
|
2806
2944
|
...globals.dryRun !== void 0 ? { dryRun: Boolean(globals.dryRun) } : {}
|
|
@@ -2813,7 +2951,7 @@ async function main(argv) {
|
|
|
2813
2951
|
const opts = cmd?.optsWithGlobals() ?? {};
|
|
2814
2952
|
await runBootstrapGovernance({ path, dryRun: opts.dryRun });
|
|
2815
2953
|
});
|
|
2816
|
-
program.command("doctor").description("Verify the local install: required tools on PATH,
|
|
2954
|
+
program.command("doctor").description("Verify the local install: required tools on PATH (node>=22, git, gh, jq, curl) + a release close-out drift check").action(runDoctor);
|
|
2817
2955
|
program.command("status [path]").description("Show the consumer project's framework state").action(async (path) => {
|
|
2818
2956
|
await runStatus({ path });
|
|
2819
2957
|
});
|