@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 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.55"};
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 MAX_ATTEMPTS = 3;
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
- if (entry.isFile()) files.push(join(filePath, entry.name));
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 uploadOne(file, opts) {
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 <= MAX_ATTEMPTS) {
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 < MAX_ATTEMPTS) {
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
- results.push(await uploadOne(file, opts));
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.ok) {
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
- results: results.map((r) => ({ file: r.file, ok: r.ok, status: r.status, error: r.error ?? null }))
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 path = join(installerRoot, "sdlc", "files", "stacks", stack, "adapter.json");
1493
- const raw = await promises.readFile(path, "utf-8");
1494
- return JSON.parse(raw);
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
- await runUpdate({ version: resolvedVersion ?? CLI_VERSION, paths: resolvedPaths });
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>", "attached to metadata.branch").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(
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, auth state, config validity").action(runDoctor);
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
  });