@automatify-au/cli 0.1.12 → 0.1.13

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 CHANGED
@@ -261,7 +261,7 @@ Current authentication boundary:
261
261
  - Setup does not turn this into an all-user auth model. Customer-admin packaging and any future user-level authentication remain separate work.
262
262
  - The same callback key may be stored as a GitHub Actions repository secret only under the explicit `github-secrets` apply scope.
263
263
 
264
- Azure DevOps follow-up is deliberately outside this stage. The next adapter should reuse the same plan/apply/doctor orchestration and mutation scopes, map Azure organization/project/pipeline/ref fields into the existing `azureDevops` Forge profile config, manage callback pipeline variables through stdin/safe references, inspect pipeline metadata without queueing a run, and preserve the same confirmation/idempotency/redaction rules.
264
+ Azure DevOps setup uses the same plan/apply/doctor safety boundary. Generate a plan with `--provider azure-devops`, mapping `--organization`, `--azure-project`, `--pipeline-id`, and `--ref` into the existing `azureDevops` Forge profile. Callback pipeline variables are accepted only through stdin or `--secret-ref key=env:NAME`; dry-run, explicit approval, idempotency, and redaction are identical to GitHub setup. Doctor inspects pipeline metadata and variable names only; it never queues a pipeline run.
265
265
 
266
266
  ## Allure Evidence for Jira
267
267
 
@@ -16668,18 +16668,137 @@ var GitHubSetupProvider = class {
16668
16668
  }
16669
16669
  };
16670
16670
 
16671
+ // src/azureDevOpsSetupProvider.ts
16672
+ var isRecord3 = (v) => Boolean(v) && typeof v === "object" && !Array.isArray(v);
16673
+ async function adoGet(fetchImpl, url, token) {
16674
+ const response = await fetchImpl(url, { headers: { accept: "application/json", ...token ? { authorization: `Basic ${Buffer.from(`:${token}`).toString("base64")}` } : {} } });
16675
+ const text = await response.text();
16676
+ let body = {};
16677
+ try {
16678
+ body = text ? JSON.parse(text) : {};
16679
+ } catch {
16680
+ }
16681
+ return { ok: response.ok, status: response.status, body };
16682
+ }
16683
+ async function setAzurePipelineVariableWithRest(input, fetchImpl) {
16684
+ const base = `https://dev.azure.com/${encodeURIComponent(input.organization)}/${encodeURIComponent(input.project)}/_apis/build/definitions/${encodeURIComponent(input.pipelineId)}`;
16685
+ const auth = { authorization: `Basic ${Buffer.from(`:${input.token}`).toString("base64")}` };
16686
+ const current = await adoGet(fetchImpl, `${base}?api-version=7.1`, input.token);
16687
+ if (!current.ok || !isRecord3(current.body)) throw new Error(`Azure DevOps build definition could not be read (HTTP ${current.status}).`);
16688
+ const variables = isRecord3(current.body.variables) ? { ...current.body.variables } : {};
16689
+ variables[input.name] = { value: input.value, isSecret: true };
16690
+ const query = new URLSearchParams({
16691
+ "api-version": "7.1",
16692
+ secretsSourceDefinitionId: String(current.body.id),
16693
+ secretsSourceDefinitionRevision: String(current.body.revision)
16694
+ });
16695
+ const response = await fetchImpl(`${base}?${query.toString()}`, { method: "PUT", headers: { ...auth, "content-type": "application/json", accept: "application/json" }, body: JSON.stringify({ ...current.body, variables }) });
16696
+ if (!response.ok) throw new Error(`Azure DevOps pipeline variable update failed (HTTP ${response.status}).`);
16697
+ }
16698
+ var AzureDevOpsSetupProvider = class {
16699
+ name = "azure-devops";
16700
+ fetchImpl;
16701
+ setter;
16702
+ constructor(deps = {}) {
16703
+ this.fetchImpl = deps.fetchImpl ?? fetch;
16704
+ this.setter = deps.setPipelineVariable ?? ((input) => setAzurePipelineVariableWithRest(input, this.fetchImpl));
16705
+ }
16706
+ async inspectExecutionDefinition(plan, token) {
16707
+ const a = plan.azureDevOps;
16708
+ const url = `https://dev.azure.com/${encodeURIComponent(a.organization)}/${encodeURIComponent(a.project)}/_apis/build/definitions/${encodeURIComponent(a.pipelineId)}?api-version=${encodeURIComponent(a.apiVersion)}`;
16709
+ const result = await adoGet(this.fetchImpl, url, token);
16710
+ if (!result.ok) return { found: false, message: result.status === 404 ? "Azure DevOps pipeline was not found." : `Azure DevOps pipeline metadata could not be verified (HTTP ${result.status}).` };
16711
+ const body = isRecord3(result.body) ? result.body : {};
16712
+ const name = typeof body.name === "string" ? body.name : void 0;
16713
+ return { found: true, active: true, path: name, message: `Azure DevOps pipeline ${name ?? a.pipelineId} is reachable. Inspection never queues a run.` };
16714
+ }
16715
+ async inspectCallbackSecretMetadata(plan, token) {
16716
+ if (!token) return { verified: false, names: /* @__PURE__ */ new Set(), message: "Azure DevOps pipeline variable metadata was not verified because azureDevOpsAdminToken was not provided. Secret values are never readable." };
16717
+ const a = plan.azureDevOps;
16718
+ const url = `https://dev.azure.com/${encodeURIComponent(a.organization)}/${encodeURIComponent(a.project)}/_apis/build/definitions/${encodeURIComponent(a.pipelineId)}?api-version=${encodeURIComponent(a.apiVersion)}`;
16719
+ const result = await adoGet(this.fetchImpl, url, token);
16720
+ if (!result.ok) throw new Error(`Azure DevOps pipeline metadata request failed with HTTP ${result.status}.`);
16721
+ const variables = isRecord3(result.body) && isRecord3(result.body.variables) ? result.body.variables : {};
16722
+ return { verified: true, names: new Set(Object.keys(variables)), message: `Verified ${Object.keys(variables).length} Azure DevOps pipeline variable name(s); secret values remain unreadable.` };
16723
+ }
16724
+ async setCallbackSecret(plan, secret, value, adminToken) {
16725
+ if (!this.setter) throw new Error("Azure DevOps pipeline variable setter is not configured; use the safe Azure CLI adapter.");
16726
+ await this.setter({ organization: plan.azureDevOps.organization, project: plan.azureDevOps.project, pipelineId: plan.azureDevOps.pipelineId, name: secret.repositorySecretName, value, token: adminToken });
16727
+ }
16728
+ };
16729
+
16730
+ // src/azureDevOpsSetupPlan.ts
16731
+ var import_node_crypto2 = require("node:crypto");
16732
+ var digest = (v) => `sha256:${(0, import_node_crypto2.createHash)("sha256").update(JSON.stringify(v)).digest("hex")}`;
16733
+ var required = (value, field) => {
16734
+ const result = value.trim();
16735
+ if (!result) throw new Error(`${field} is required.`);
16736
+ return result;
16737
+ };
16738
+ var variableName = (value, field) => {
16739
+ const result = required(value, field).toUpperCase();
16740
+ if (!/^[A-Z_][A-Z0-9_]*$/.test(result)) throw new Error(`${field} is invalid.`);
16741
+ return result;
16742
+ };
16743
+ function buildAzureDevOpsSetupPlan(input) {
16744
+ const projectKey = required(input.projectKey, "project key").toUpperCase();
16745
+ if (!/^[A-Z][A-Z0-9_]{1,31}$/.test(projectKey)) throw new Error("project key is invalid.");
16746
+ const organization = required(input.organization, "organization");
16747
+ const azureProject = required(input.azureProject, "Azure project");
16748
+ const pipelineId = required(input.pipelineId, "pipeline id");
16749
+ if (!/^\d+$/.test(pipelineId)) throw new Error("pipeline id must be numeric.");
16750
+ const ref = required(input.ref, "ref");
16751
+ const apiVersion = required(input.apiVersion ?? "7.1", "api version");
16752
+ const profileLabel = required(input.profileLabel, "profile label");
16753
+ const endpointName = variableName(input.callbackEndpointVariableName, "callback endpoint variable name");
16754
+ const tokenName = variableName(input.callbackAuthTokenVariableName, "callback auth token variable name");
16755
+ if (endpointName === tokenName) throw new Error("callback variable names must be distinct.");
16756
+ const requiredSecrets = [{ repositorySecretName: endpointName, valueKey: "callbackEndpoint", purpose: "Forge callback endpoint pipeline variable." }, { repositorySecretName: tokenName, valueKey: "callbackAuthToken", purpose: "Forge callback auth token pipeline variable." }];
16757
+ const scopes = ["provider-secrets", "jira-profile", ...input.setProjectDefault ? ["project-default"] : []];
16758
+ const actions = scopes.map((scope) => ({ id: scope === "provider-secrets" ? "set-provider-secrets" : scope === "jira-profile" ? "upsert-jira-profile" : "set-project-default", scope, summary: `Apply ${scope} for Azure DevOps.`, mutates: "jira" }));
16759
+ const withoutId = { schemaVersion: "automatify.testops.setup/v1", kind: "AutomatifyTestOpsSetupPlan", provider: "azure-devops", project: { key: projectKey }, azureDevOps: { organization, project: azureProject, pipelineId, ref, apiVersion, requiredSecrets, guidance: { authentication: "Use an Azure DevOps PAT with least-privilege pipeline read/manage-variable access.", permissions: ["Pipelines: read for doctor; manage variables only for explicit provider-secrets apply."], remoteChangesExcluded: ["accounts", "credentials", "commits", "pull requests", "pipeline runs"] } }, jiraProfile: { label: profileLabel, enabled: input.enabled, provider: "azureDevops", config: { organization, project: azureProject, pipelineId, apiVersion, bodyTemplate: JSON.stringify({ resources: { repositories: { self: { refName: ref } } } }) }, setProjectDefault: input.setProjectDefault }, actions, smokeValidation: [{ id: "pipeline-content", verifies: "Azure DevOps pipeline metadata without queueing a run.", triggersExternalRun: false }, { id: "pipeline-variable-metadata", verifies: "Required variable names; values remain unreadable.", triggersExternalRun: false }, { id: "jira-profile", verifies: "Forge automation profile and optional default.", triggersExternalRun: false }], rollback: actions.map((a) => ({ actionId: a.id, strategy: "Restore the previous state manually; secret values cannot be read back.", automatic: false })), followUp: { azureDevOps: "Azure DevOps setup is implemented through this provider-neutral plan/apply/doctor boundary." } };
16760
+ return { ...withoutId, planId: digest(withoutId) };
16761
+ }
16762
+ function validateAzureDevOpsSetupPlan(value) {
16763
+ const errors = [];
16764
+ const record = value;
16765
+ const exact = (v, keys) => Boolean(v && typeof v === "object" && !Array.isArray(v) && Object.keys(v).sort().join() === [...keys].sort().join());
16766
+ if (!exact(value, ["schemaVersion", "kind", "planId", "provider", "project", "azureDevOps", "jiraProfile", "actions", "smokeValidation", "rollback", "followUp"])) errors.push("Plan has missing or unsupported top-level fields.");
16767
+ if (record?.schemaVersion !== "automatify.testops.setup/v1" || record?.kind !== "AutomatifyTestOpsSetupPlan" || record?.provider !== "azure-devops") errors.push("Plan schema, kind, or provider is invalid.");
16768
+ if (!exact(record?.project, ["key"]) || !/^[A-Z][A-Z0-9_]{1,31}$/.test(record?.project?.key ?? "")) errors.push("project.key is invalid.");
16769
+ const a = record?.azureDevOps;
16770
+ if (!exact(a, ["organization", "project", "pipelineId", "ref", "apiVersion", "requiredSecrets", "guidance"]) || ![a?.organization, a?.project, a?.ref, a?.apiVersion].every((x) => typeof x === "string" && x.trim()) || !/^\d+$/.test(a?.pipelineId ?? "")) errors.push("azureDevOps has an invalid shape or fields.");
16771
+ if (!Array.isArray(a?.requiredSecrets) || a.requiredSecrets.length !== 2 || new Set(a.requiredSecrets.map((s) => s.repositorySecretName)).size !== 2 || new Set(a.requiredSecrets.map((s) => s.valueKey)).size !== 2 || !a.requiredSecrets.some((s) => s.valueKey === "callbackEndpoint") || !a.requiredSecrets.some((s) => s.valueKey === "callbackAuthToken") || !a.requiredSecrets.every((s) => exact(s, ["repositorySecretName", "valueKey", "purpose"]) && /^[A-Z_][A-Z0-9_]*$/.test(s.repositorySecretName) && s.purpose)) errors.push("azureDevOps.requiredSecrets is invalid.");
16772
+ const p = record?.jiraProfile;
16773
+ if (!exact(p, ["label", "enabled", "provider", "config", "setProjectDefault"]) || p?.provider !== "azureDevops" || !exact(p?.config, ["organization", "project", "pipelineId", "apiVersion", "bodyTemplate"]) || p.config.organization !== a?.organization || p.config.project !== a?.project || p.config.pipelineId !== a?.pipelineId || p.config.apiVersion !== a?.apiVersion) errors.push("jiraProfile/config is invalid or inconsistent.");
16774
+ const ids = p?.setProjectDefault ? ["set-provider-secrets", "upsert-jira-profile", "set-project-default"] : ["set-provider-secrets", "upsert-jira-profile"];
16775
+ const scopes = p?.setProjectDefault ? ["provider-secrets", "jira-profile", "project-default"] : ["provider-secrets", "jira-profile"];
16776
+ if (!Array.isArray(record?.actions) || record.actions.map((x) => x.id).join() !== ids.join() || !record.actions.every((x, i) => exact(x, ["id", "scope", "summary", "mutates"]) && x.scope === scopes[i] && x.mutates === "jira")) errors.push("actions are invalid.");
16777
+ const smokeIds = ["pipeline-content", "pipeline-variable-metadata", "jira-profile"];
16778
+ if (!Array.isArray(record?.smokeValidation) || record.smokeValidation.length !== 3 || !record.smokeValidation.every((x, i) => exact(x, ["id", "verifies", "triggersExternalRun"]) && x.id === smokeIds[i] && x.triggersExternalRun === false)) errors.push("smokeValidation is invalid.");
16779
+ if (!Array.isArray(record?.rollback) || record.rollback.length !== ids.length || !record.rollback.every((x, i) => exact(x, ["actionId", "strategy", "automatic"]) && x.actionId === ids[i] && x.automatic === false)) errors.push("rollback is invalid.");
16780
+ if (errors.length === 0) {
16781
+ const { planId, ...rest } = record;
16782
+ if (planId !== digest(rest)) errors.push("planId does not match the plan contents.");
16783
+ }
16784
+ return errors.length ? { ok: false, errors } : { ok: true, errors, plan: value };
16785
+ }
16786
+
16671
16787
  // src/setup.ts
16672
16788
  var APPLY_RESULT_SCHEMA_VERSION = "automatify.testops.setup.apply/v1";
16673
16789
  var DOCTOR_RESULT_SCHEMA_VERSION = "automatify.testops.setup.doctor/v1";
16674
16790
  var SECRET_KEYS = [
16675
16791
  "githubProviderToken",
16676
16792
  "githubAdminToken",
16793
+ "azureDevOpsProviderToken",
16794
+ "azureDevOpsAdminToken",
16677
16795
  "callbackEndpoint",
16678
16796
  "callbackAuthToken"
16679
16797
  ];
16680
16798
  var MUTATION_SCOPES = [
16681
16799
  "workflow-file",
16682
16800
  "github-secrets",
16801
+ "provider-secrets",
16683
16802
  "jira-profile",
16684
16803
  "project-default"
16685
16804
  ];
@@ -16704,7 +16823,13 @@ var PLAN_VALUE_FLAGS = /* @__PURE__ */ new Set([
16704
16823
  "--workflow-source",
16705
16824
  "--profile-label",
16706
16825
  "--callback-endpoint-secret-name",
16707
- "--callback-auth-token-secret-name"
16826
+ "--callback-auth-token-secret-name",
16827
+ "--organization",
16828
+ "--azure-project",
16829
+ "--pipeline-id",
16830
+ "--api-version",
16831
+ "--callback-endpoint-variable-name",
16832
+ "--callback-auth-token-variable-name"
16708
16833
  ]);
16709
16834
  var APPLY_VALUE_FLAGS = /* @__PURE__ */ new Set(["--plan", "--repo-root"]);
16710
16835
  var APPLY_BOOL_FLAGS = /* @__PURE__ */ new Set([
@@ -16815,7 +16940,7 @@ function loadPlan(planPath, cwd) {
16815
16940
  ExitCode.ValidationError
16816
16941
  );
16817
16942
  }
16818
- const validation = validateSetupPlan(parsed);
16943
+ const validation = isAzurePlanValue(parsed) ? validateAzureDevOpsSetupPlan(parsed) : validateSetupPlan(parsed);
16819
16944
  if (!validation.ok || !validation.plan) {
16820
16945
  throw new SetupCommandError(
16821
16946
  "PLAN_VALIDATION_ERROR",
@@ -16825,6 +16950,9 @@ function loadPlan(planPath, cwd) {
16825
16950
  }
16826
16951
  return validation.plan;
16827
16952
  }
16953
+ function isAzurePlanValue(value) {
16954
+ return Boolean(value && typeof value === "object" && value.provider === "azure-devops");
16955
+ }
16828
16956
  function isSecretKey(value) {
16829
16957
  return SECRET_KEYS.includes(value);
16830
16958
  }
@@ -17320,7 +17448,27 @@ function planCommand(args, deps) {
17320
17448
  if (parsed.errors.length > 0) {
17321
17449
  return jsonResponse(errorPayload("USAGE_ERROR", "Invalid setup plan arguments.", parsed.errors), ExitCode.UsageError);
17322
17450
  }
17323
- if ((parsed.flags["--provider"] ?? "github-actions") !== "github-actions") {
17451
+ const provider = parsed.flags["--provider"] ?? "github-actions";
17452
+ if (provider === "azure-devops") {
17453
+ try {
17454
+ return jsonResponse(buildAzureDevOpsSetupPlan({
17455
+ projectKey: parsed.flags["--project-key"] ?? "",
17456
+ organization: parsed.flags["--organization"] ?? "",
17457
+ azureProject: parsed.flags["--azure-project"] ?? "",
17458
+ pipelineId: parsed.flags["--pipeline-id"] ?? "",
17459
+ ref: parsed.flags["--ref"] ?? "main",
17460
+ apiVersion: parsed.flags["--api-version"],
17461
+ profileLabel: parsed.flags["--profile-label"] ?? "Azure DevOps",
17462
+ enabled: !parsed.boolFlags.has("--disabled"),
17463
+ setProjectDefault: parsed.boolFlags.has("--set-default"),
17464
+ callbackEndpointVariableName: parsed.flags["--callback-endpoint-variable-name"] ?? "TESTOPS_FORGE_ENDPOINT",
17465
+ callbackAuthTokenVariableName: parsed.flags["--callback-auth-token-variable-name"] ?? "TESTOPS_FORGE_AUTH_TOKEN"
17466
+ }));
17467
+ } catch (error) {
17468
+ return jsonResponse(errorPayload("VALIDATION_ERROR", safeErrorMessage(error)), ExitCode.ValidationError);
17469
+ }
17470
+ }
17471
+ if (provider !== "github-actions") {
17324
17472
  return jsonResponse(
17325
17473
  errorPayload("VALIDATION_ERROR", "Only github-actions is implemented in this stage; Azure DevOps is the documented next adapter."),
17326
17474
  ExitCode.ValidationError
@@ -17369,8 +17517,11 @@ async function applyCommand(args, context, deps) {
17369
17517
  const actionResults = [];
17370
17518
  try {
17371
17519
  const plan = loadPlan(planPath, deps.cwd);
17372
- const scopes = approvedScopes(plan, parsed, dryRun);
17373
17520
  secrets = await resolveSecrets(parsed, deps);
17521
+ if (plan.provider === "azure-devops") {
17522
+ return await applyAzureCommand(plan, parsed, context, deps, secrets);
17523
+ }
17524
+ const scopes = approvedScopes(plan, parsed, dryRun);
17374
17525
  const repoRoot = import_node_path15.default.resolve(deps.cwd, parsed.flags["--repo-root"] ?? ".");
17375
17526
  const rotateSecrets = parsed.boolFlags.has("--rotate-secrets");
17376
17527
  const rotateProviderToken = parsed.boolFlags.has("--rotate-provider-token");
@@ -17469,6 +17620,40 @@ async function applyCommand(args, context, deps) {
17469
17620
  );
17470
17621
  }
17471
17622
  }
17623
+ async function applyAzureCommand(plan, parsed, context, deps, secrets) {
17624
+ const dryRun = parsed.boolFlags.has("--dry-run");
17625
+ const scopes = approvedScopes(plan, parsed, dryRun);
17626
+ const profiles = requireServiceData(await context.invokeForgeContract("listScenarioAutomationProfiles", { context: { projectKey: plan.project.key } }), "listScenarioAutomationProfiles");
17627
+ const current = profiles.find((item) => item.label === plan.jiraProfile.label);
17628
+ if (!dryRun && scopes.includes("provider-secrets") && !secrets.values.azureDevOpsAdminToken) throw new SetupCommandError("SECRET_INPUT_REQUIRED", "azureDevOpsAdminToken is required for the approved Azure DevOps pipeline-variable scope.", ExitCode.ValidationError);
17629
+ const results = [];
17630
+ const secretNames = scopes.includes("provider-secrets") && secrets.values.azureDevOpsAdminToken ? (await deps.azureProvider.inspectCallbackSecretMetadata(plan, secrets.values.azureDevOpsAdminToken)).names : void 0;
17631
+ let appliedProfile = current;
17632
+ for (const action of plan.actions) {
17633
+ if (!scopes.includes(action.scope)) {
17634
+ results.push({ id: action.id, scope: action.scope, status: "skipped", message: "Action was not in the explicit approval scope.", rollback: { available: false, guidance: "No mutation occurred." } });
17635
+ continue;
17636
+ }
17637
+ if (action.scope === "provider-secrets") {
17638
+ const pending = plan.azureDevOps.requiredSecrets.filter((item) => parsed.boolFlags.has("--rotate-secrets") || !secretNames?.has(item.repositorySecretName));
17639
+ if (!dryRun && pending.some((item) => !secrets.values[item.valueKey])) throw new SetupCommandError("SECRET_INPUT_REQUIRED", "Azure DevOps callback secrets are required for approved pipeline variable changes.", ExitCode.ValidationError);
17640
+ if (!dryRun) for (const item of pending) await deps.azureProvider.setCallbackSecret(plan, item, secrets.values[item.valueKey], secrets.values.azureDevOpsAdminToken);
17641
+ results.push({ id: action.id, scope: action.scope, status: dryRun ? "planned" : pending.length ? "updated" : "skipped", message: "Azure DevOps pipeline variable names are managed without printing secret values.", rollback: { available: false, guidance: rollbackFor(plan, action.id) } });
17642
+ } else if (action.scope === "jira-profile") {
17643
+ const rotateProvider = parsed.boolFlags.has("--rotate-provider-token");
17644
+ if (!dryRun && (!current?.hasSecret || rotateProvider) && !secrets.values.azureDevOpsProviderToken) throw new SetupCommandError("SECRET_INPUT_REQUIRED", "azureDevOpsProviderToken is required to create or rotate the Azure provider PAT.", ExitCode.ValidationError);
17645
+ const profile = dryRun ? current ?? { id: "<created-by-forge>", projectKey: plan.project.key, label: plan.jiraProfile.label, provider: "azureDevops", authType: "basicPat", method: "POST", endpointSummary: "<computed-by-forge>", config: plan.jiraProfile.config, enabled: plan.jiraProfile.enabled, hasSecret: false, createdAt: "", updatedAt: "" } : requireServiceData(await context.invokeForgeContract("upsertScenarioAutomationProfile", { context: { projectKey: plan.project.key }, input: { profileId: current?.id, label: plan.jiraProfile.label, provider: "azureDevops", config: plan.jiraProfile.config, enabled: plan.jiraProfile.enabled, secretToken: !current?.hasSecret || rotateProvider ? secrets.values.azureDevOpsProviderToken : void 0 } }), "upsertScenarioAutomationProfile");
17646
+ appliedProfile = profile;
17647
+ results.push({ id: action.id, scope: action.scope, status: dryRun ? "planned" : current ? "updated" : "created", message: "Azure DevOps Jira automation profile applied through the existing Forge contract.", rollback: { available: !dryRun, guidance: rollbackFor(plan, action.id) } });
17648
+ } else if (action.scope === "project-default" && appliedProfile && !dryRun) {
17649
+ requireServiceData(await context.invokeForgeContract("setScenarioAutomationDefaultProfile", { context: { projectKey: plan.project.key }, profileId: appliedProfile.id }), "setScenarioAutomationDefaultProfile");
17650
+ results.push({ id: action.id, scope: action.scope, status: "updated", message: "Set the Azure DevOps Jira automation profile as project default.", rollback: { available: true, guidance: rollbackFor(plan, action.id) } });
17651
+ } else if (action.scope === "project-default") {
17652
+ results.push({ id: action.id, scope: action.scope, status: "planned", message: "Set the Azure DevOps Jira automation profile as project default.", rollback: { available: false, guidance: rollbackFor(plan, action.id) } });
17653
+ }
17654
+ }
17655
+ return jsonResponse({ schemaVersion: APPLY_RESULT_SCHEMA_VERSION, planId: plan.planId, provider: plan.provider, status: dryRun ? "dry-run" : "applied", dryRun, confirmed: !dryRun, approvedScopes: scopes, actions: results, smokeValidation: { status: "not-run", externalRunTriggered: false } });
17656
+ }
17472
17657
  async function doctorCommand(args, context, deps) {
17473
17658
  const parsed = parseArgs10(
17474
17659
  args,
@@ -17490,6 +17675,22 @@ async function doctorCommand(args, context, deps) {
17490
17675
  try {
17491
17676
  const plan = loadPlan(planPath, deps.cwd);
17492
17677
  secrets = await resolveSecrets(parsed, deps);
17678
+ if (plan.provider === "azure-devops") {
17679
+ const checks = [];
17680
+ const inspection = await deps.azureProvider.inspectExecutionDefinition(plan, secrets.values.azureDevOpsProviderToken ?? secrets.values.azureDevOpsAdminToken);
17681
+ checks.push({ id: "pipeline-azure", status: inspection.found ? "pass" : "fail", message: inspection.message });
17682
+ const variables = await deps.azureProvider.inspectCallbackSecretMetadata(plan, secrets.values.azureDevOpsAdminToken);
17683
+ const missing = plan.azureDevOps.requiredSecrets.filter((item) => !variables.names.has(item.repositorySecretName));
17684
+ checks.push({ id: "callback-secret-metadata", status: variables.verified && missing.length === 0 ? "pass" : variables.verified ? "fail" : "warn", message: variables.message });
17685
+ const profiles = requireServiceData(await context.invokeForgeContract("listScenarioAutomationProfiles", { context: { projectKey: plan.project.key } }), "listScenarioAutomationProfiles");
17686
+ const profile = profiles.find((item) => item.label === plan.jiraProfile.label);
17687
+ const config = profile?.config;
17688
+ const matches = profile?.provider === "azureDevops" && profile.enabled === plan.jiraProfile.enabled && profile.hasSecret && config?.organization === plan.jiraProfile.config.organization && config?.project === plan.jiraProfile.config.project && config?.pipelineId === plan.jiraProfile.config.pipelineId && config?.apiVersion === plan.jiraProfile.config.apiVersion && config?.bodyTemplate === plan.jiraProfile.config.bodyTemplate;
17689
+ checks.push({ id: "jira-profile", status: matches ? "pass" : "fail", message: matches ? "Jira automation profile fields match and Forge reports a stored provider secret." : "Jira automation profile is missing, differs, or lacks provider secret metadata." });
17690
+ if (plan.jiraProfile.setProjectDefault) checks.push({ id: "jira-project-default", status: profile?.isProjectDefault ? "pass" : "fail", message: profile?.isProjectDefault ? "Jira automation profile is the project default." : "Jira automation profile is not the project default." });
17691
+ const status = doctorStatus(checks);
17692
+ return jsonResponse({ schemaVersion: DOCTOR_RESULT_SCHEMA_VERSION, planId: plan.planId, provider: plan.provider, status, exitCode: status === "fail" ? ExitCode.ValidationError : ExitCode.Success, externalRunTriggered: false, checks });
17693
+ }
17493
17694
  const repoRoot = import_node_path15.default.resolve(deps.cwd, parsed.flags["--repo-root"] ?? ".");
17494
17695
  const summary = await inspectPlan(plan, repoRoot, secrets, context, deps);
17495
17696
  return jsonResponse(summary, summary.exitCode);
@@ -17505,7 +17706,8 @@ function createSetupHandler(overrides = {}) {
17505
17706
  cwd: overrides.cwd ?? process.cwd(),
17506
17707
  env: overrides.env ?? process.env,
17507
17708
  readStdin: overrides.readStdin ?? defaultReadStdin,
17508
- githubProvider: overrides.githubProvider ?? new GitHubSetupProvider(overrides.githubProviderDeps)
17709
+ githubProvider: overrides.githubProvider ?? new GitHubSetupProvider(overrides.githubProviderDeps),
17710
+ azureProvider: overrides.azureProvider ?? new AzureDevOpsSetupProvider()
17509
17711
  };
17510
17712
  return async (request, context) => {
17511
17713
  const [subcommand, ...args] = request.args;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@automatify-au/cli",
3
- "version": "0.1.12",
3
+ "version": "0.1.13",
4
4
  "description": "Forge-first CLI for Automatify Jira TestOps",
5
5
  "type": "module",
6
6
  "bin": {