@automatify-au/cli 0.1.14 → 0.1.15

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
@@ -77,15 +77,25 @@ Minimum:
77
77
  - `TESTOPS_FORGE_ENDPOINT` for commands that call Forge contracts (`doctor`, `ingest`, `run`, `sync`)
78
78
  - `TESTOPS_FORGE_AUTH_TOKEN` from the one-time token shown in Forge `Operations -> CLI access`
79
79
 
80
- Local config helper:
80
+ Local Jira auth helper on macOS/Windows:
81
+ ```bash
82
+ printf "%s" "$JIRA_API_TOKEN" | automatify testops auth login \
83
+ --base-url https://automatify-com-au.atlassian.net \
84
+ --jira-email you@example.com \
85
+ --token-stdin
86
+ automatify testops auth status
87
+ ```
88
+
89
+ On Linux/CI, keep `JIRA_API_TOKEN` in the environment and run `auth login` without `--token-stdin`; the CLI stores only non-secret Jira settings. Use `automatify testops auth logout` to remove local Jira and Forge credentials while preserving non-secret URL/email settings. Environment variables are not modified by logout.
90
+
91
+ Forge transport auth remains an advanced setting:
81
92
  ```bash
82
- automatify testops config set baseUrl https://automatify-com-au.atlassian.net
83
93
  automatify testops config set projectKey DEV
84
94
  automatify testops config set forgeEndpoint "<webtrigger-url>"
85
95
  printf "%s" "$TESTOPS_FORGE_AUTH_TOKEN" | automatify testops config set forgeAuthToken --stdin
86
96
  ```
87
97
 
88
- `forgeAuthToken` local storage uses macOS Keychain on macOS and current-user Windows DPAPI on Windows. Linux and CI should keep the token in `TESTOPS_FORGE_AUTH_TOKEN`. `.testops-cli.json` stores non-secret values plus, on Windows, only the DPAPI-protected blob. See [WINDOWS.md](./WINDOWS.md) for PowerShell setup and verification.
98
+ Jira and Forge secrets use macOS Keychain on macOS and current-user Windows DPAPI on Windows. Linux and CI keep secrets in `JIRA_API_TOKEN` and `TESTOPS_FORGE_AUTH_TOKEN`. Existing plaintext local tokens remain readable for compatibility and can be migrated with `automatify testops config migrate-secrets`. See [AUTH.md](./AUTH.md) for the normal Jira auth workflow and [WINDOWS.md](./WINDOWS.md) for PowerShell setup and verification.
89
99
 
90
100
  Optional:
91
101
  - `TESTOPS_FORGE_TIMEOUT_MS`
@@ -114,6 +124,10 @@ All TestOps commands are invoked as `automatify testops <command>`:
114
124
  - `automatify testops bdd features export --output-dir ./features-export --feature-id <FEATURE_ID>`
115
125
  - `runs`
116
126
  - `automatify testops runs show --id <RUN_ID>`
127
+ - `auth`
128
+ - `automatify testops auth login --base-url <JIRA_URL> --jira-email <EMAIL> --token-stdin`
129
+ - `automatify testops auth status`
130
+ - `automatify testops auth logout`
117
131
  - `config`
118
132
  - `automatify testops config show`
119
133
  - `automatify testops config validate`
@@ -10765,10 +10765,170 @@ var Priority;
10765
10765
 
10766
10766
  // src/config.ts
10767
10767
  var import_node_fs = require("node:fs");
10768
- var import_node_child_process = require("node:child_process");
10769
10768
  var import_node_path = __toESM(require("node:path"), 1);
10770
- var DEFAULT_CONFIG_FILENAME = ".testops-cli.json";
10769
+
10770
+ // src/secureStore.ts
10771
+ var import_node_child_process = require("node:child_process");
10771
10772
  var KEYCHAIN_SERVICE = "automatify-testops-cli";
10773
+ function errorText(error) {
10774
+ return error instanceof Error && error.message ? error.message : "unknown secure-storage error";
10775
+ }
10776
+ function windowsProtectScript() {
10777
+ return [
10778
+ "Add-Type -AssemblyName System.Security",
10779
+ "$plain = [Console]::In.ReadToEnd()",
10780
+ "$bytes = [Text.Encoding]::UTF8.GetBytes($plain)",
10781
+ "$cipher = [System.Security.Cryptography.ProtectedData]::Protect($bytes, $null, [System.Security.Cryptography.DataProtectionScope]::CurrentUser)",
10782
+ "[Console]::Out.Write([Convert]::ToBase64String($cipher))"
10783
+ ].join("; ");
10784
+ }
10785
+ function windowsUnprotectScript() {
10786
+ return [
10787
+ "Add-Type -AssemblyName System.Security",
10788
+ "$encoded = [Console]::In.ReadToEnd()",
10789
+ "$cipher = [Convert]::FromBase64String($encoded)",
10790
+ "$bytes = [System.Security.Cryptography.ProtectedData]::Unprotect($cipher, $null, [System.Security.Cryptography.DataProtectionScope]::CurrentUser)",
10791
+ "[Console]::Out.Write([Text.Encoding]::UTF8.GetString($bytes))"
10792
+ ].join("; ");
10793
+ }
10794
+ function quoteSecurityInteractiveArg(value) {
10795
+ if (/\r|\n|\0/.test(value)) {
10796
+ throw new Error("macOS Keychain values cannot contain newline or NUL characters.");
10797
+ }
10798
+ return `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
10799
+ }
10800
+ function macOSKeychainWriteInput(account, value) {
10801
+ return [
10802
+ "add-generic-password",
10803
+ "-U",
10804
+ "-s",
10805
+ quoteSecurityInteractiveArg(KEYCHAIN_SERVICE),
10806
+ "-a",
10807
+ quoteSecurityInteractiveArg(account),
10808
+ "-w",
10809
+ quoteSecurityInteractiveArg(value)
10810
+ ].join(" ") + "\n";
10811
+ }
10812
+ function readSecureSecret(locator, deps = {}) {
10813
+ const platform = deps.platform ?? process.platform;
10814
+ const run = deps.execFileSync ?? import_node_child_process.execFileSync;
10815
+ if (platform === "darwin") {
10816
+ if (!locator.account) {
10817
+ return locator.protectedValue ? {
10818
+ status: "error",
10819
+ message: "This config contains a Windows DPAPI value, but it is being read on macOS. Re-store the secret on this machine."
10820
+ } : { status: "missing" };
10821
+ }
10822
+ try {
10823
+ const value = run("security", ["find-generic-password", "-s", KEYCHAIN_SERVICE, "-a", locator.account, "-w"], {
10824
+ encoding: "utf8",
10825
+ stdio: ["ignore", "pipe", "pipe"]
10826
+ }).trim();
10827
+ return value ? { status: "available", value, source: "keychain" } : {
10828
+ status: "error",
10829
+ message: `macOS Keychain returned an empty value for account ${locator.account}. Re-store the secret.`
10830
+ };
10831
+ } catch (error) {
10832
+ return {
10833
+ status: "error",
10834
+ message: `Unable to read macOS Keychain account ${locator.account}: ${errorText(error)}. Re-store the secret.`
10835
+ };
10836
+ }
10837
+ }
10838
+ if (platform === "win32") {
10839
+ if (!locator.protectedValue) {
10840
+ return locator.account ? {
10841
+ status: "error",
10842
+ message: "This config references a macOS Keychain secret, but it is being read on Windows. Re-store the secret for the current Windows user."
10843
+ } : { status: "missing" };
10844
+ }
10845
+ try {
10846
+ const value = run("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", windowsUnprotectScript()], {
10847
+ input: locator.protectedValue,
10848
+ encoding: "utf8",
10849
+ stdio: ["pipe", "pipe", "pipe"]
10850
+ }).trim();
10851
+ return value ? { status: "available", value, source: "secure-store" } : {
10852
+ status: "error",
10853
+ message: "Windows DPAPI returned an empty value. Re-store the secret for the current Windows user."
10854
+ };
10855
+ } catch (error) {
10856
+ return {
10857
+ status: "error",
10858
+ message: `Unable to decrypt the Windows DPAPI value for the current user: ${errorText(error)}. Re-store the secret.`
10859
+ };
10860
+ }
10861
+ }
10862
+ if (locator.account || locator.protectedValue) {
10863
+ return {
10864
+ status: "error",
10865
+ message: "This config contains local secure-store metadata, but local secure storage is supported only on macOS and Windows. Use an environment variable on Linux/CI."
10866
+ };
10867
+ }
10868
+ return { status: "missing" };
10869
+ }
10870
+ function writeSecureSecret(account, value, deps = {}) {
10871
+ const platform = deps.platform ?? process.platform;
10872
+ const run = deps.execFileSync ?? import_node_child_process.execFileSync;
10873
+ if (platform === "darwin") {
10874
+ try {
10875
+ run("security", ["-q", "-i"], {
10876
+ input: macOSKeychainWriteInput(account, value),
10877
+ encoding: "utf8",
10878
+ stdio: ["pipe", "ignore", "pipe"]
10879
+ });
10880
+ } catch {
10881
+ throw new Error("Unable to store the secret in macOS Keychain.");
10882
+ }
10883
+ return { source: "keychain", account };
10884
+ }
10885
+ if (platform === "win32") {
10886
+ let protectedValue;
10887
+ try {
10888
+ protectedValue = run("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", windowsProtectScript()], {
10889
+ input: value,
10890
+ encoding: "utf8",
10891
+ stdio: ["pipe", "pipe", "pipe"]
10892
+ }).trim();
10893
+ } catch {
10894
+ throw new Error("Unable to protect the secret with Windows DPAPI for the current user.");
10895
+ }
10896
+ if (!protectedValue) {
10897
+ throw new Error("Windows DPAPI returned an empty protected value.");
10898
+ }
10899
+ return { source: "secure-store", protectedValue };
10900
+ }
10901
+ throw new Error(
10902
+ "Secure local secret storage is supported on macOS and Windows only. On Linux/CI, keep secrets in environment variables."
10903
+ );
10904
+ }
10905
+ function deleteSecureSecret(locator, deps = {}) {
10906
+ const platform = deps.platform ?? process.platform;
10907
+ const run = deps.execFileSync ?? import_node_child_process.execFileSync;
10908
+ if (platform === "darwin") {
10909
+ if (!locator.account) {
10910
+ return { status: "missing" };
10911
+ }
10912
+ try {
10913
+ run("security", ["delete-generic-password", "-s", KEYCHAIN_SERVICE, "-a", locator.account], {
10914
+ stdio: ["ignore", "ignore", "pipe"]
10915
+ });
10916
+ return { status: "deleted" };
10917
+ } catch (error) {
10918
+ return {
10919
+ status: "error",
10920
+ message: `Unable to delete macOS Keychain account ${locator.account}: ${errorText(error)}.`
10921
+ };
10922
+ }
10923
+ }
10924
+ if (platform === "win32") {
10925
+ return locator.protectedValue ? { status: "deleted" } : { status: "missing" };
10926
+ }
10927
+ return locator.account || locator.protectedValue ? { status: "deleted" } : { status: "missing" };
10928
+ }
10929
+
10930
+ // src/config.ts
10931
+ var DEFAULT_CONFIG_FILENAME = ".testops-cli.json";
10772
10932
  function parseFlags(args) {
10773
10933
  const flags = {};
10774
10934
  const unknownFlags = [];
@@ -10802,6 +10962,15 @@ function parseFlags(args) {
10802
10962
  function toStringValue(value) {
10803
10963
  return typeof value === "string" ? value.trim() : "";
10804
10964
  }
10965
+ function firstStringValue(...values) {
10966
+ for (const value of values) {
10967
+ const normalized = toStringValue(value);
10968
+ if (normalized) {
10969
+ return normalized;
10970
+ }
10971
+ }
10972
+ return "";
10973
+ }
10805
10974
  function readConfigFile(configPath) {
10806
10975
  if (!(0, import_node_fs.existsSync)(configPath)) {
10807
10976
  return {};
@@ -10814,6 +10983,27 @@ function readConfigFile(configPath) {
10814
10983
  return {};
10815
10984
  }
10816
10985
  }
10986
+ function readConfigFileForMutation(configPath) {
10987
+ if (!(0, import_node_fs.existsSync)(configPath)) {
10988
+ return { file: {} };
10989
+ }
10990
+ try {
10991
+ const raw = (0, import_node_fs.readFileSync)(configPath, "utf8");
10992
+ const parsed = JSON.parse(raw);
10993
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
10994
+ return {
10995
+ file: {},
10996
+ error: `Unable to update config at ${configPath}: existing config must contain a JSON object.`
10997
+ };
10998
+ }
10999
+ return { file: parsed };
11000
+ } catch {
11001
+ return {
11002
+ file: {},
11003
+ error: `Unable to update config at ${configPath}: existing config is not valid JSON. Fix or remove it before retrying.`
11004
+ };
11005
+ }
11006
+ }
10817
11007
  function writeConfigFile(configPath, file) {
10818
11008
  (0, import_node_fs.writeFileSync)(configPath, `${JSON.stringify(file, null, 2)}
10819
11009
  `, "utf8");
@@ -10833,63 +11023,46 @@ function readAuthMode(input) {
10833
11023
  function defaultKeychainAccount(configPath) {
10834
11024
  return import_node_path.default.resolve(configPath);
10835
11025
  }
10836
- function readKeychainSecret(account) {
10837
- if (!account || process.platform !== "darwin") {
10838
- return "";
11026
+ function defaultJiraApiTokenKeychainAccount(configPath) {
11027
+ return `${import_node_path.default.resolve(configPath)}:jiraApiToken`;
11028
+ }
11029
+ function valueFromPrecedence(key, flagValue, envValue, fileValue, fallback = "") {
11030
+ if (flagValue) {
11031
+ return { value: flagValue, source: "flag" };
10839
11032
  }
10840
- try {
10841
- return (0, import_node_child_process.execFileSync)("security", ["find-generic-password", "-s", KEYCHAIN_SERVICE, "-a", account, "-w"], {
10842
- encoding: "utf8",
10843
- stdio: ["ignore", "pipe", "ignore"]
10844
- }).trim();
10845
- } catch {
10846
- return "";
11033
+ if (envValue) {
11034
+ return { value: envValue, source: "env" };
10847
11035
  }
10848
- }
10849
- function setKeychainSecret(account, value) {
10850
- if (process.platform !== "darwin") {
10851
- throw new Error("Keychain-backed secrets are only supported on macOS.");
11036
+ if (fileValue) {
11037
+ return { value: fileValue, source: "file" };
10852
11038
  }
10853
- (0, import_node_child_process.execFileSync)("security", ["add-generic-password", "-U", "-s", KEYCHAIN_SERVICE, "-a", account, "-w", value], {
10854
- stdio: ["ignore", "ignore", "pipe"]
10855
- });
11039
+ if (fallback) {
11040
+ return { value: fallback, source: "default" };
11041
+ }
11042
+ return { value: "", source: "default" };
10856
11043
  }
10857
- function protectWindowsSecret(value) {
10858
- const script = [
10859
- "Add-Type -AssemblyName System.Security",
10860
- "$plain = [Console]::In.ReadToEnd()",
10861
- "$bytes = [Text.Encoding]::UTF8.GetBytes($plain)",
10862
- "$cipher = [System.Security.Cryptography.ProtectedData]::Protect($bytes, $null, [System.Security.Cryptography.DataProtectionScope]::CurrentUser)",
10863
- "[Console]::Out.Write([Convert]::ToBase64String($cipher))"
10864
- ].join("; ");
10865
- return (0, import_node_child_process.execFileSync)("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], {
10866
- input: value,
10867
- encoding: "utf8",
10868
- stdio: ["pipe", "pipe", "pipe"]
10869
- }).trim();
10870
- }
10871
- function unprotectWindowsSecret(protectedValue) {
10872
- if (!protectedValue || process.platform !== "win32") {
10873
- return "";
11044
+ function secureAccountForKey(key, file, configPath) {
11045
+ if (key === "forgeAuthToken") {
11046
+ return toStringValue(file.forgeAuthTokenKeychainAccount) || defaultKeychainAccount(configPath);
10874
11047
  }
10875
- const script = [
10876
- "Add-Type -AssemblyName System.Security",
10877
- "$encoded = [Console]::In.ReadToEnd()",
10878
- "$cipher = [Convert]::FromBase64String($encoded)",
10879
- "$bytes = [System.Security.Cryptography.ProtectedData]::Unprotect($cipher, $null, [System.Security.Cryptography.DataProtectionScope]::CurrentUser)",
10880
- "[Console]::Out.Write([Text.Encoding]::UTF8.GetString($bytes))"
10881
- ].join("; ");
10882
- try {
10883
- return (0, import_node_child_process.execFileSync)("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], {
10884
- input: protectedValue,
10885
- encoding: "utf8",
10886
- stdio: ["pipe", "pipe", "ignore"]
10887
- }).trim();
10888
- } catch {
10889
- return "";
11048
+ return toStringValue(file.jiraApiTokenKeychainAccount) || defaultJiraApiTokenKeychainAccount(configPath);
11049
+ }
11050
+ function secureLocatorForKey(key, file) {
11051
+ if (key === "forgeAuthToken") {
11052
+ return {
11053
+ account: toStringValue(file.forgeAuthTokenKeychainAccount) || void 0,
11054
+ protectedValue: toStringValue(file.forgeAuthTokenProtected) || void 0
11055
+ };
10890
11056
  }
11057
+ return {
11058
+ account: toStringValue(file.jiraApiTokenKeychainAccount) || void 0,
11059
+ protectedValue: toStringValue(file.jiraApiTokenProtected) || void 0
11060
+ };
10891
11061
  }
10892
- function valueFromPrecedence(key, flagValue, envValue, fileValue, fallback = "") {
11062
+ function secretEnvironmentName(key) {
11063
+ return key === "jiraApiToken" ? "JIRA_API_TOKEN" : "TESTOPS_FORGE_AUTH_TOKEN";
11064
+ }
11065
+ function resolveSecretValue(key, flagValue, envValue, fileValue, file) {
10893
11066
  if (flagValue) {
10894
11067
  return { value: flagValue, source: "flag" };
10895
11068
  }
@@ -10899,11 +11072,33 @@ function valueFromPrecedence(key, flagValue, envValue, fileValue, fallback = "")
10899
11072
  if (fileValue) {
10900
11073
  return { value: fileValue, source: "file" };
10901
11074
  }
10902
- if (fallback) {
10903
- return { value: fallback, source: "default" };
11075
+ const secure = readSecureSecret(secureLocatorForKey(key, file));
11076
+ if (secure.status === "available") {
11077
+ return { value: secure.value, source: secure.source };
11078
+ }
11079
+ if (secure.status === "error") {
11080
+ return { value: "", source: "default", error: `${key}: ${secure.message}` };
10904
11081
  }
10905
11082
  return { value: "", source: "default" };
10906
11083
  }
11084
+ function clearSecretFields(file, key) {
11085
+ if (key === "forgeAuthToken") {
11086
+ return {
11087
+ ...file,
11088
+ forgeAuthToken: void 0,
11089
+ TESTOPS_FORGE_AUTH_TOKEN: void 0,
11090
+ forgeAuthTokenKeychainAccount: void 0,
11091
+ forgeAuthTokenProtected: void 0
11092
+ };
11093
+ }
11094
+ return {
11095
+ ...file,
11096
+ jiraApiToken: void 0,
11097
+ JIRA_API_TOKEN: void 0,
11098
+ jiraApiTokenKeychainAccount: void 0,
11099
+ jiraApiTokenProtected: void 0
11100
+ };
11101
+ }
10907
11102
  function resolveCliConfig(args, env = process.env, cwd = process.cwd()) {
10908
11103
  const { flags, unknownFlags } = parseFlags(args);
10909
11104
  const configPathFlag = flags["--config"] ? import_node_path.default.resolve(cwd, flags["--config"]) : "";
@@ -10942,11 +11137,12 @@ function resolveCliConfig(args, env = process.env, cwd = process.cwd()) {
10942
11137
  toStringValue(env.JIRA_EMAIL),
10943
11138
  toStringValue(file.jiraEmail ?? file.JIRA_EMAIL)
10944
11139
  );
10945
- const jiraApiTokenResolved = valueFromPrecedence(
11140
+ const jiraApiTokenResolved = resolveSecretValue(
10946
11141
  "jiraApiToken",
10947
11142
  toStringValue(flags["--jira-api-token"]),
10948
11143
  toStringValue(env.JIRA_API_TOKEN),
10949
- toStringValue(file.jiraApiToken ?? file.JIRA_API_TOKEN)
11144
+ firstStringValue(file.jiraApiToken, file.JIRA_API_TOKEN),
11145
+ file
10950
11146
  );
10951
11147
  const forgeEndpointResolved = valueFromPrecedence(
10952
11148
  "forgeEndpoint",
@@ -10960,11 +11156,13 @@ function resolveCliConfig(args, env = process.env, cwd = process.cwd()) {
10960
11156
  "",
10961
11157
  toStringValue(file.forgeAuthTokenKeychainAccount)
10962
11158
  );
10963
- const forgeAuthTokenFromEnv = toStringValue(env.TESTOPS_FORGE_AUTH_TOKEN);
10964
- const forgeAuthTokenFromFile = toStringValue(file.forgeAuthToken ?? file.TESTOPS_FORGE_AUTH_TOKEN);
10965
- const forgeAuthTokenFromKeychain = forgeAuthTokenFromEnv || forgeAuthTokenFromFile || !forgeAuthTokenKeychainAccountResolved.value ? "" : readKeychainSecret(forgeAuthTokenKeychainAccountResolved.value);
10966
- const forgeAuthTokenFromWindowsStore = forgeAuthTokenFromEnv || forgeAuthTokenFromFile || forgeAuthTokenFromKeychain ? "" : unprotectWindowsSecret(toStringValue(file.forgeAuthTokenProtected));
10967
- const forgeAuthTokenResolved = forgeAuthTokenFromEnv ? { value: forgeAuthTokenFromEnv, source: "env" } : forgeAuthTokenFromFile ? { value: forgeAuthTokenFromFile, source: "file" } : forgeAuthTokenFromKeychain ? { value: forgeAuthTokenFromKeychain, source: "keychain" } : forgeAuthTokenFromWindowsStore ? { value: forgeAuthTokenFromWindowsStore, source: "secure-store" } : { value: "", source: "default" };
11159
+ const forgeAuthTokenResolved = resolveSecretValue(
11160
+ "forgeAuthToken",
11161
+ "",
11162
+ toStringValue(env.TESTOPS_FORGE_AUTH_TOKEN),
11163
+ firstStringValue(file.forgeAuthToken, file.TESTOPS_FORGE_AUTH_TOKEN),
11164
+ file
11165
+ );
10968
11166
  const config = {
10969
11167
  baseUrl: normalizeBaseUrl(baseUrlResolved.value),
10970
11168
  projectKey: projectKeyResolved.value,
@@ -10990,13 +11188,16 @@ function resolveCliConfig(args, env = process.env, cwd = process.cwd()) {
10990
11188
  configPath: configPathSource
10991
11189
  };
10992
11190
  const warnings = [];
11191
+ const errors = [jiraApiTokenResolved.error, forgeAuthTokenResolved.error].filter(
11192
+ (value) => Boolean(value)
11193
+ );
10993
11194
  if (unknownFlags.length > 0) {
10994
11195
  warnings.push(`Ignored unknown config flags: ${unknownFlags.join(", ")}`);
10995
11196
  }
10996
11197
  if (!(0, import_node_fs.existsSync)(configPath) && sources.configPath !== "default") {
10997
11198
  warnings.push(`Config file not found at ${configPath}; using env/flags/defaults.`);
10998
11199
  }
10999
- return { values: config, sources, warnings };
11200
+ return { values: config, sources, warnings, errors };
11000
11201
  }
11001
11202
  function maskSecret(value) {
11002
11203
  if (!value) {
@@ -11009,7 +11210,7 @@ function maskSecret(value) {
11009
11210
  }
11010
11211
  function validateResolvedConfig(resolution) {
11011
11212
  const { values } = resolution;
11012
- const errors = [];
11213
+ const errors = [...resolution.errors];
11013
11214
  const warnings = [...resolution.warnings];
11014
11215
  if (!values.baseUrl) {
11015
11216
  errors.push("Missing required setting: JIRA_BASE_URL (flag/env/file).");
@@ -11024,10 +11225,8 @@ function validateResolvedConfig(resolution) {
11024
11225
  if (!values.jiraApiToken) {
11025
11226
  errors.push("Auth mode api-token requires JIRA_API_TOKEN.");
11026
11227
  }
11027
- } else {
11028
- if (values.jiraApiToken || values.jiraEmail) {
11029
- warnings.push("JIRA_EMAIL/JIRA_API_TOKEN were provided but auth mode is 'none'.");
11030
- }
11228
+ } else if (values.jiraApiToken || values.jiraEmail) {
11229
+ warnings.push("JIRA_EMAIL/JIRA_API_TOKEN were provided but auth mode is 'none'.");
11031
11230
  }
11032
11231
  return { ok: errors.length === 0, errors, warnings };
11033
11232
  }
@@ -11090,53 +11289,33 @@ function applyConfigSet(configPath, rawKey, value) {
11090
11289
  ]
11091
11290
  };
11092
11291
  }
11093
- const file = readConfigFile(configPath);
11094
- if (key === "forgeAuthToken") {
11095
- if (process.platform === "win32") {
11096
- try {
11097
- const protectedValue = protectWindowsSecret(value);
11098
- writeConfigFile(configPath, {
11099
- ...file,
11100
- forgeAuthToken: void 0,
11101
- TESTOPS_FORGE_AUTH_TOKEN: void 0,
11102
- forgeAuthTokenKeychainAccount: void 0,
11103
- forgeAuthTokenProtected: protectedValue
11104
- });
11105
- return {
11106
- exitCode: ExitCode.Success,
11107
- stdout: ["Config updated: forgeAuthToken protected with Windows DPAPI for the current user."]
11108
- };
11109
- } catch (error) {
11110
- return {
11111
- exitCode: ExitCode.ValidationError,
11112
- stderr: [
11113
- `Unable to store forgeAuthToken securely: ${error instanceof Error ? error.message : "unknown secure-storage error"}`
11114
- ]
11115
- };
11116
- }
11117
- }
11118
- if (process.platform !== "darwin") {
11292
+ const mutable = readConfigFileForMutation(configPath);
11293
+ if (mutable.error) {
11294
+ return { exitCode: ExitCode.ValidationError, stderr: [mutable.error] };
11295
+ }
11296
+ const file = mutable.file;
11297
+ if (key === "forgeAuthToken" || key === "jiraApiToken") {
11298
+ const secretKey = key;
11299
+ const account = secureAccountForKey(secretKey, file, configPath);
11300
+ try {
11301
+ const stored = writeSecureSecret(account, value);
11302
+ const cleared = clearSecretFields(file, secretKey);
11303
+ const nextFile2 = stored.source === "keychain" ? secretKey === "forgeAuthToken" ? { ...cleared, forgeAuthTokenKeychainAccount: stored.account } : { ...cleared, jiraApiTokenKeychainAccount: stored.account } : secretKey === "forgeAuthToken" ? { ...cleared, forgeAuthTokenProtected: stored.protectedValue } : { ...cleared, jiraApiTokenProtected: stored.protectedValue };
11304
+ writeConfigFile(configPath, nextFile2);
11305
+ const backend = stored.source === "keychain" ? "macOS Keychain" : "Windows DPAPI for the current user";
11119
11306
  return {
11120
- exitCode: ExitCode.UsageError,
11307
+ exitCode: ExitCode.Success,
11308
+ stdout: [`Config updated: ${secretKey} stored securely with ${backend}.`]
11309
+ };
11310
+ } catch (error) {
11311
+ const unsupported = process.platform !== "darwin" && process.platform !== "win32";
11312
+ return {
11313
+ exitCode: unsupported ? ExitCode.UsageError : ExitCode.ValidationError,
11121
11314
  stderr: [
11122
- "Secure local forgeAuthToken storage is currently supported on macOS and Windows. On Linux/CI, set TESTOPS_FORGE_AUTH_TOKEN in the environment."
11315
+ `Unable to store ${secretKey} securely: ${error instanceof Error ? error.message : "unknown secure-storage error"} Use ${secretEnvironmentName(secretKey)} on Linux/CI.`
11123
11316
  ]
11124
11317
  };
11125
11318
  }
11126
- const account = toStringValue(file.forgeAuthTokenKeychainAccount) || defaultKeychainAccount(configPath);
11127
- setKeychainSecret(account, value);
11128
- const nextFile2 = {
11129
- ...file,
11130
- forgeAuthToken: void 0,
11131
- TESTOPS_FORGE_AUTH_TOKEN: void 0,
11132
- forgeAuthTokenProtected: void 0,
11133
- forgeAuthTokenKeychainAccount: account
11134
- };
11135
- writeConfigFile(configPath, nextFile2);
11136
- return {
11137
- exitCode: ExitCode.Success,
11138
- stdout: [`Config updated: forgeAuthToken stored in macOS Keychain (${account}).`]
11139
- };
11140
11319
  }
11141
11320
  const nextFile = {
11142
11321
  ...file,
@@ -11148,6 +11327,101 @@ function applyConfigSet(configPath, rawKey, value) {
11148
11327
  stdout: [`Config updated: ${rawKey}.`]
11149
11328
  };
11150
11329
  }
11330
+ function configFileKeysFor(key) {
11331
+ switch (key) {
11332
+ case "baseUrl":
11333
+ return ["baseUrl", "JIRA_BASE_URL"];
11334
+ case "projectKey":
11335
+ return ["projectKey", "JIRA_PROJECT_KEY"];
11336
+ case "issueKey":
11337
+ return ["issueKey", "JIRA_ISSUE_KEY"];
11338
+ case "authMode":
11339
+ return ["authMode", "TESTOPS_AUTH_MODE"];
11340
+ case "jiraEmail":
11341
+ return ["jiraEmail", "JIRA_EMAIL"];
11342
+ case "forgeEndpoint":
11343
+ return ["forgeEndpoint", "TESTOPS_FORGE_ENDPOINT"];
11344
+ default:
11345
+ return [key];
11346
+ }
11347
+ }
11348
+ function applyConfigUnset(configPath, rawKey) {
11349
+ const key = normalizeConfigSetKey(rawKey);
11350
+ if (!key) {
11351
+ return {
11352
+ exitCode: ExitCode.UsageError,
11353
+ stderr: ["Usage: automatify testops config unset <key>"]
11354
+ };
11355
+ }
11356
+ const mutable = readConfigFileForMutation(configPath);
11357
+ if (mutable.error) {
11358
+ return { exitCode: ExitCode.ValidationError, stderr: [mutable.error] };
11359
+ }
11360
+ const file = mutable.file;
11361
+ if (key === "forgeAuthToken" || key === "jiraApiToken") {
11362
+ const secretKey = key;
11363
+ const deleted = deleteSecureSecret(secureLocatorForKey(secretKey, file));
11364
+ if (deleted.status === "error") {
11365
+ return { exitCode: ExitCode.ValidationError, stderr: [deleted.message] };
11366
+ }
11367
+ writeConfigFile(configPath, clearSecretFields(file, secretKey));
11368
+ return { exitCode: ExitCode.Success, stdout: [`Config removed: ${secretKey}.`] };
11369
+ }
11370
+ const next = { ...file };
11371
+ for (const fileKey of configFileKeysFor(key)) {
11372
+ delete next[fileKey];
11373
+ }
11374
+ writeConfigFile(configPath, next);
11375
+ return { exitCode: ExitCode.Success, stdout: [`Config removed: ${rawKey}.`] };
11376
+ }
11377
+ function applyConfigMigrateSecrets(configPath) {
11378
+ const mutable = readConfigFileForMutation(configPath);
11379
+ if (mutable.error) {
11380
+ return { exitCode: ExitCode.ValidationError, stderr: [mutable.error] };
11381
+ }
11382
+ const file = mutable.file;
11383
+ const pending = [];
11384
+ const jiraApiToken = firstStringValue(file.jiraApiToken, file.JIRA_API_TOKEN);
11385
+ const forgeAuthToken = firstStringValue(file.forgeAuthToken, file.TESTOPS_FORGE_AUTH_TOKEN);
11386
+ if (jiraApiToken) {
11387
+ pending.push({ key: "jiraApiToken", value: jiraApiToken });
11388
+ }
11389
+ if (forgeAuthToken) {
11390
+ pending.push({ key: "forgeAuthToken", value: forgeAuthToken });
11391
+ }
11392
+ if (pending.length === 0) {
11393
+ return { exitCode: ExitCode.Success, stdout: ["No plaintext local secrets require migration."] };
11394
+ }
11395
+ const migrated = [];
11396
+ for (const item of pending) {
11397
+ const response = applyConfigSet(configPath, item.key, item.value);
11398
+ if (response.exitCode !== ExitCode.Success) {
11399
+ return {
11400
+ exitCode: response.exitCode,
11401
+ stdout: migrated.length > 0 ? [`Migrated before failure: ${migrated.join(", ")}.`] : void 0,
11402
+ stderr: response.stderr
11403
+ };
11404
+ }
11405
+ migrated.push(item.key);
11406
+ }
11407
+ return {
11408
+ exitCode: ExitCode.Success,
11409
+ stdout: [`Migrated plaintext secrets to secure storage: ${migrated.join(", ")}.`]
11410
+ };
11411
+ }
11412
+ function stripConfigPathArgs(args) {
11413
+ const operands = [];
11414
+ for (let i = 0; i < args.length; i += 1) {
11415
+ if (args[i] === "--config") {
11416
+ if (args[i + 1] && !args[i + 1].startsWith("--")) {
11417
+ i += 1;
11418
+ }
11419
+ continue;
11420
+ }
11421
+ operands.push(args[i]);
11422
+ }
11423
+ return operands;
11424
+ }
11151
11425
  function createConfigHandler(env = process.env, cwd = process.cwd()) {
11152
11426
  return (request) => {
11153
11427
  const [subcommand, ...subArgs] = request.args;
@@ -11156,7 +11430,11 @@ function createConfigHandler(env = process.env, cwd = process.cwd()) {
11156
11430
  if (subcommand === "show") {
11157
11431
  return {
11158
11432
  exitCode: ExitCode.Success,
11159
- stdout: [...toDisplayLines(resolution), ...validation.warnings.map((line) => `WARN: ${line}`)]
11433
+ stdout: [
11434
+ ...toDisplayLines(resolution),
11435
+ ...validation.warnings.map((line) => `WARN: ${line}`),
11436
+ ...resolution.errors.map((line) => `ERROR: ${line}`)
11437
+ ]
11160
11438
  };
11161
11439
  }
11162
11440
  if (subcommand === "validate") {
@@ -11177,17 +11455,25 @@ function createConfigHandler(env = process.env, cwd = process.cwd()) {
11177
11455
  };
11178
11456
  }
11179
11457
  if (subcommand === "set") {
11180
- const [key, ...valueParts] = subArgs;
11181
- const readValueFromStdin = key === "forgeAuthToken" && valueParts.length === 1 && valueParts[0] === "--stdin";
11458
+ const [key, ...valueParts] = stripConfigPathArgs(subArgs);
11459
+ const normalizedKey = normalizeConfigSetKey(key ?? "");
11460
+ const readValueFromStdin = (normalizedKey === "forgeAuthToken" || normalizedKey === "jiraApiToken") && valueParts.length === 1 && valueParts[0] === "--stdin";
11182
11461
  const value = readValueFromStdin ? (0, import_node_fs.readFileSync)(0, "utf8").trim() : valueParts.join(" ").trim();
11183
11462
  if (!key || !value) {
11184
11463
  return {
11185
11464
  exitCode: ExitCode.UsageError,
11186
- stderr: ["Usage: automatify testops config set <key> <value>"]
11465
+ stderr: ["Usage: automatify testops config set <key> <value|--stdin>"]
11187
11466
  };
11188
11467
  }
11189
11468
  return applyConfigSet(resolution.values.configPath, key, value);
11190
11469
  }
11470
+ if (subcommand === "unset") {
11471
+ const [key] = stripConfigPathArgs(subArgs);
11472
+ return applyConfigUnset(resolution.values.configPath, key ?? "");
11473
+ }
11474
+ if (subcommand === "migrate-secrets") {
11475
+ return applyConfigMigrateSecrets(resolution.values.configPath);
11476
+ }
11191
11477
  return {
11192
11478
  exitCode: ExitCode.UsageError,
11193
11479
  stderr: [`Unsupported config subcommand: ${subcommand}`]
@@ -13627,8 +13913,255 @@ function createAllureHandler(deps = {}) {
13627
13913
  };
13628
13914
  }
13629
13915
 
13630
- // src/bdd.ts
13916
+ // src/auth.ts
13631
13917
  var import_node_fs10 = require("node:fs");
13918
+ function parseAuthArgs(args, allowLoginFlags) {
13919
+ const configArgs = [];
13920
+ const unknown = [];
13921
+ let baseUrl = "";
13922
+ let jiraEmail = "";
13923
+ let tokenStdin = false;
13924
+ for (let i = 0; i < args.length; i += 1) {
13925
+ const token = args[i];
13926
+ if (token === "--config") {
13927
+ const value = args[i + 1];
13928
+ if (!value || value.startsWith("--")) {
13929
+ unknown.push(token);
13930
+ continue;
13931
+ }
13932
+ configArgs.push(token, value);
13933
+ i += 1;
13934
+ continue;
13935
+ }
13936
+ if (allowLoginFlags && token === "--base-url") {
13937
+ const value = args[i + 1];
13938
+ if (!value || value.startsWith("--")) {
13939
+ unknown.push(token);
13940
+ continue;
13941
+ }
13942
+ baseUrl = value.trim();
13943
+ i += 1;
13944
+ continue;
13945
+ }
13946
+ if (allowLoginFlags && token === "--jira-email") {
13947
+ const value = args[i + 1];
13948
+ if (!value || value.startsWith("--")) {
13949
+ unknown.push(token);
13950
+ continue;
13951
+ }
13952
+ jiraEmail = value.trim();
13953
+ i += 1;
13954
+ continue;
13955
+ }
13956
+ if (allowLoginFlags && token === "--token-stdin") {
13957
+ tokenStdin = true;
13958
+ continue;
13959
+ }
13960
+ unknown.push(token);
13961
+ }
13962
+ return { configArgs, baseUrl, jiraEmail, tokenStdin, unknown };
13963
+ }
13964
+ function runConfigMutation(handler, args, context) {
13965
+ const response = handler(
13966
+ {
13967
+ group: "config",
13968
+ args,
13969
+ rawArgs: ["config", ...args]
13970
+ },
13971
+ context
13972
+ );
13973
+ if (response instanceof Promise) {
13974
+ throw new Error("Config mutations must remain synchronous.");
13975
+ }
13976
+ return response;
13977
+ }
13978
+ function sourceLabel(source) {
13979
+ switch (source) {
13980
+ case "keychain":
13981
+ return "macOS Keychain";
13982
+ case "secure-store":
13983
+ return "Windows DPAPI";
13984
+ case "env":
13985
+ return "environment";
13986
+ case "file":
13987
+ return "legacy plaintext config";
13988
+ case "flag":
13989
+ return "command flag";
13990
+ default:
13991
+ return "not configured";
13992
+ }
13993
+ }
13994
+ function authState(parts) {
13995
+ const populated = parts.filter(Boolean).length;
13996
+ if (populated === 0) {
13997
+ return "not configured";
13998
+ }
13999
+ return populated === parts.length ? "configured" : "incomplete";
14000
+ }
14001
+ function createStatusResponse(args, env, cwd) {
14002
+ const parsed = parseAuthArgs(args, false);
14003
+ if (parsed.unknown.length > 0) {
14004
+ return {
14005
+ exitCode: ExitCode.UsageError,
14006
+ stderr: ["Unsupported auth status arguments. Only --config <path> is supported."]
14007
+ };
14008
+ }
14009
+ const resolution = resolveCliConfig(parsed.configArgs, env, cwd);
14010
+ const { values, sources } = resolution;
14011
+ const jiraState = authState([values.baseUrl, values.jiraEmail, values.jiraApiToken]);
14012
+ const forgeState = authState([values.forgeEndpoint, values.forgeAuthToken]);
14013
+ const stdout = [
14014
+ "Auth status (secret-safe):",
14015
+ ` Jira: ${jiraState}`,
14016
+ ` baseUrl: ${values.baseUrl || "<unset>"}`,
14017
+ ` email: ${values.jiraEmail || "<unset>"}`,
14018
+ ` apiToken: ${values.jiraApiToken ? `configured (${sourceLabel(sources.jiraApiToken)})` : "<unset>"}`,
14019
+ ` Forge transport: ${forgeState}`,
14020
+ ` endpoint: ${values.forgeEndpoint || "<unset>"}`,
14021
+ ` authToken: ${values.forgeAuthToken ? `configured (${sourceLabel(sources.forgeAuthToken)})` : "<unset>"}`,
14022
+ ` configPath: ${values.configPath}`
14023
+ ];
14024
+ if (resolution.errors.length > 0) {
14025
+ return {
14026
+ exitCode: ExitCode.ValidationError,
14027
+ stdout,
14028
+ stderr: resolution.errors.map((error) => `ERROR: ${error}`)
14029
+ };
14030
+ }
14031
+ return { exitCode: ExitCode.Success, stdout };
14032
+ }
14033
+ function createLoginResponse(args, env, cwd, readStdin, platform, context) {
14034
+ const parsed = parseAuthArgs(args, true);
14035
+ if (parsed.unknown.length > 0) {
14036
+ return {
14037
+ exitCode: ExitCode.UsageError,
14038
+ stderr: [
14039
+ "Unsupported auth login arguments. Use --base-url, --jira-email, --token-stdin, and optional --config only.",
14040
+ "Secrets must be provided through --token-stdin or environment variables, never as command-line values."
14041
+ ]
14042
+ };
14043
+ }
14044
+ const current = resolveCliConfig(parsed.configArgs, env, cwd);
14045
+ const baseUrl = parsed.baseUrl || env.JIRA_BASE_URL?.trim() || current.values.baseUrl;
14046
+ const jiraEmail = parsed.jiraEmail || env.JIRA_EMAIL?.trim() || current.values.jiraEmail;
14047
+ const stdinToken = parsed.tokenStdin ? readStdin().trim() : "";
14048
+ const effectiveToken = stdinToken || env.JIRA_API_TOKEN?.trim() || current.values.jiraApiToken;
14049
+ const missing = [];
14050
+ if (!baseUrl) missing.push("--base-url or JIRA_BASE_URL");
14051
+ if (!jiraEmail) missing.push("--jira-email or JIRA_EMAIL");
14052
+ if (!effectiveToken) missing.push("--token-stdin, JIRA_API_TOKEN, or an existing stored token");
14053
+ if (missing.length > 0) {
14054
+ return {
14055
+ exitCode: ExitCode.UsageError,
14056
+ stderr: [
14057
+ `Jira login is incomplete. Provide: ${missing.join(", ")}.`,
14058
+ "Secrets are never accepted as normal command-line values."
14059
+ ]
14060
+ };
14061
+ }
14062
+ if (parsed.tokenStdin && platform !== "darwin" && platform !== "win32") {
14063
+ return {
14064
+ exitCode: ExitCode.UsageError,
14065
+ stderr: [
14066
+ "Local secure secret storage is supported only on macOS and Windows.",
14067
+ "On Linux/CI, set JIRA_API_TOKEN in the environment and run auth login without --token-stdin."
14068
+ ]
14069
+ };
14070
+ }
14071
+ const configHandler = createConfigHandler(env, cwd);
14072
+ const configSuffix = parsed.configArgs;
14073
+ const steps = [];
14074
+ if (stdinToken) {
14075
+ steps.push({ label: "Jira API token", args: ["set", "jiraApiToken", stdinToken, ...configSuffix] });
14076
+ }
14077
+ steps.push(
14078
+ { label: "Jira base URL", args: ["set", "baseUrl", baseUrl, ...configSuffix] },
14079
+ { label: "Jira email", args: ["set", "jiraEmail", jiraEmail, ...configSuffix] },
14080
+ { label: "auth mode", args: ["set", "authMode", "api-token", ...configSuffix] }
14081
+ );
14082
+ const completed = [];
14083
+ for (const step of steps) {
14084
+ const response = runConfigMutation(configHandler, step.args, context);
14085
+ if (response.exitCode !== ExitCode.Success) {
14086
+ return {
14087
+ exitCode: response.exitCode,
14088
+ stdout: completed.length > 0 ? [`Configured before failure: ${completed.join(", ")}.`] : void 0,
14089
+ stderr: response.stderr
14090
+ };
14091
+ }
14092
+ completed.push(step.label);
14093
+ }
14094
+ const tokenSource = stdinToken ? platform === "darwin" ? "macOS Keychain" : "Windows DPAPI" : sourceLabel(
14095
+ current.sources.jiraApiToken === "default" && env.JIRA_API_TOKEN ? "env" : current.sources.jiraApiToken
14096
+ );
14097
+ return {
14098
+ exitCode: ExitCode.Success,
14099
+ stdout: [
14100
+ "Jira login configured.",
14101
+ ` baseUrl: ${baseUrl.replace(/\/$/, "")}`,
14102
+ ` email: ${jiraEmail}`,
14103
+ ` apiToken: configured (${tokenSource})`,
14104
+ "Run `automatify testops auth status` to verify the resolved credentials."
14105
+ ]
14106
+ };
14107
+ }
14108
+ function createLogoutResponse(args, env, cwd, context) {
14109
+ const parsed = parseAuthArgs(args, false);
14110
+ if (parsed.unknown.length > 0) {
14111
+ return {
14112
+ exitCode: ExitCode.UsageError,
14113
+ stderr: ["Unsupported auth logout arguments. Only --config <path> is supported."]
14114
+ };
14115
+ }
14116
+ const configHandler = createConfigHandler(env, cwd);
14117
+ const failures = [];
14118
+ const removed = [];
14119
+ for (const key of ["jiraApiToken", "forgeAuthToken"]) {
14120
+ const response = runConfigMutation(configHandler, ["unset", key, ...parsed.configArgs], context);
14121
+ if (response.exitCode === ExitCode.Success) {
14122
+ removed.push(key);
14123
+ } else {
14124
+ failures.push(...response.stderr ?? [`Unable to remove ${key}.`]);
14125
+ }
14126
+ }
14127
+ if (failures.length > 0) {
14128
+ return {
14129
+ exitCode: ExitCode.ValidationError,
14130
+ stdout: removed.length > 0 ? [`Removed before failure: ${removed.join(", ")}.`] : void 0,
14131
+ stderr: failures
14132
+ };
14133
+ }
14134
+ const stdout = ["Local Jira and Forge credentials removed.", "Non-secret URL/email settings were preserved."];
14135
+ if (env.JIRA_API_TOKEN || env.TESTOPS_FORGE_AUTH_TOKEN) {
14136
+ stdout.push("Environment-provided credentials are unchanged and may still resolve as configured.");
14137
+ }
14138
+ return { exitCode: ExitCode.Success, stdout };
14139
+ }
14140
+ function createAuthHandler(deps = {}) {
14141
+ const env = deps.env ?? process.env;
14142
+ const cwd = deps.cwd ?? process.cwd();
14143
+ const readStdin = deps.readStdin ?? (() => (0, import_node_fs10.readFileSync)(0, "utf8"));
14144
+ const platform = deps.platform ?? process.platform;
14145
+ return (request, context) => {
14146
+ const [subcommand, ...args] = request.args;
14147
+ if (subcommand === "status") {
14148
+ return createStatusResponse(args, env, cwd);
14149
+ }
14150
+ if (subcommand === "login") {
14151
+ return createLoginResponse(args, env, cwd, readStdin, platform, context);
14152
+ }
14153
+ if (subcommand === "logout") {
14154
+ return createLogoutResponse(args, env, cwd, context);
14155
+ }
14156
+ return {
14157
+ exitCode: ExitCode.UsageError,
14158
+ stderr: ["Unsupported auth subcommand. Use login, status, or logout."]
14159
+ };
14160
+ };
14161
+ }
14162
+
14163
+ // src/bdd.ts
14164
+ var import_node_fs11 = require("node:fs");
13632
14165
  var import_node_path11 = __toESM(require("node:path"), 1);
13633
14166
  var import_yazl = __toESM(require_yazl(), 1);
13634
14167
 
@@ -14053,7 +14586,7 @@ function toJsonExportManifestItems(items) {
14053
14586
  async function defaultCreateZipArchive(archivePath, entries) {
14054
14587
  await new Promise((resolve, reject) => {
14055
14588
  const zip = new import_yazl.ZipFile();
14056
- const output = zip.outputStream.pipe((0, import_node_fs10.createWriteStream)(archivePath));
14589
+ const output = zip.outputStream.pipe((0, import_node_fs11.createWriteStream)(archivePath));
14057
14590
  output.on("close", () => resolve());
14058
14591
  output.on("error", reject);
14059
14592
  zip.outputStream.on("error", reject);
@@ -14083,10 +14616,10 @@ function missingProjectResponse() {
14083
14616
  function createBddHandler(deps = {}) {
14084
14617
  const cwd = deps.cwd ?? process.cwd();
14085
14618
  const env = deps.env ?? process.env;
14086
- const mkdir = deps.mkdir ?? ((targetPath, options) => (0, import_node_fs10.mkdirSync)(targetPath, options));
14087
- const readFile = deps.readFile ?? ((filePath) => (0, import_node_fs10.readFileSync)(filePath, "utf8"));
14088
- const readDir = deps.readDir ?? ((dirPath) => (0, import_node_fs10.readdirSync)(dirPath));
14089
- const writeFile = deps.writeFile ?? ((filePath, content) => (0, import_node_fs10.writeFileSync)(filePath, content, "utf8"));
14619
+ const mkdir = deps.mkdir ?? ((targetPath, options) => (0, import_node_fs11.mkdirSync)(targetPath, options));
14620
+ const readFile = deps.readFile ?? ((filePath) => (0, import_node_fs11.readFileSync)(filePath, "utf8"));
14621
+ const readDir = deps.readDir ?? ((dirPath) => (0, import_node_fs11.readdirSync)(dirPath));
14622
+ const writeFile = deps.writeFile ?? ((filePath, content) => (0, import_node_fs11.writeFileSync)(filePath, content, "utf8"));
14090
14623
  const createZipArchive = deps.createZipArchive ?? defaultCreateZipArchive;
14091
14624
  return async (request, context) => {
14092
14625
  const [subcommand, ...restArgs] = request.args;
@@ -15179,9 +15712,10 @@ function resolveAllureJiraConfig(parsed, env, cwd) {
15179
15712
  const resolution = resolveCliConfig(pickConfigArgs5(parsed), env, cwd);
15180
15713
  const site = normalizeSite3(parsed.flags["--site"] ?? env.JIRA_SITE ?? resolution.values.baseUrl);
15181
15714
  const email = (parsed.flags["--email"] ?? resolution.values.jiraEmail).trim();
15182
- const apiToken = (parsed.flags["--api-token"] ?? resolution.values.jiraApiToken).trim();
15715
+ const directApiToken = (parsed.flags["--api-token"] ?? "").trim();
15716
+ const apiToken = (directApiToken || resolution.values.jiraApiToken).trim();
15183
15717
  const issueKey = (parsed.flags["--issue-key"] ?? "").trim();
15184
- const errors = [];
15718
+ const errors = directApiToken ? [] : resolution.errors.filter((line) => line.startsWith("jiraApiToken:"));
15185
15719
  if (!hasValue(site)) {
15186
15720
  errors.push("Missing Jira site. Pass --site, set JIRA_SITE/JIRA_BASE_URL, or configure baseUrl.");
15187
15721
  }
@@ -15420,7 +15954,7 @@ function createDoctorHandler(deps = {}) {
15420
15954
  }
15421
15955
 
15422
15956
  // src/ingestFeature.ts
15423
- var import_node_fs11 = require("node:fs");
15957
+ var import_node_fs12 = require("node:fs");
15424
15958
  var import_node_path12 = __toESM(require("node:path"), 1);
15425
15959
  var CONFIG_FLAGS6 = /* @__PURE__ */ new Set([
15426
15960
  "--config",
@@ -15507,8 +16041,8 @@ function normalizeError5(error) {
15507
16041
  return "Unknown ingest error.";
15508
16042
  }
15509
16043
  function createIngestFeatureHandler(deps = {}) {
15510
- const readFile = deps.readFile ?? ((filePath) => (0, import_node_fs11.readFileSync)(filePath, "utf8"));
15511
- const readStdin = deps.readStdin ?? (() => (0, import_node_fs11.readFileSync)(0, "utf8"));
16044
+ const readFile = deps.readFile ?? ((filePath) => (0, import_node_fs12.readFileSync)(filePath, "utf8"));
16045
+ const readStdin = deps.readStdin ?? (() => (0, import_node_fs12.readFileSync)(0, "utf8"));
15512
16046
  const cwd = deps.cwd ?? process.cwd();
15513
16047
  const env = deps.env ?? process.env;
15514
16048
  return async (request, context) => {
@@ -15639,7 +16173,7 @@ function createIngestFeatureHandler(deps = {}) {
15639
16173
  }
15640
16174
 
15641
16175
  // src/runUpload.ts
15642
- var import_node_fs12 = require("node:fs");
16176
+ var import_node_fs13 = require("node:fs");
15643
16177
  var import_node_path13 = __toESM(require("node:path"), 1);
15644
16178
  var STEP_RESULTS = [
15645
16179
  StepResult.Passed,
@@ -15781,8 +16315,8 @@ function summarizeRunResult(result) {
15781
16315
  return lines;
15782
16316
  }
15783
16317
  function createRunUploadHandler(deps = {}) {
15784
- const readFile = deps.readFile ?? ((filePath) => (0, import_node_fs12.readFileSync)(filePath, "utf8"));
15785
- const readStdin = deps.readStdin ?? (() => (0, import_node_fs12.readFileSync)(0, "utf8"));
16318
+ const readFile = deps.readFile ?? ((filePath) => (0, import_node_fs13.readFileSync)(filePath, "utf8"));
16319
+ const readStdin = deps.readStdin ?? (() => (0, import_node_fs13.readFileSync)(0, "utf8"));
15786
16320
  const cwd = deps.cwd ?? process.cwd();
15787
16321
  const env = deps.env ?? process.env;
15788
16322
  return async (request, context) => {
@@ -16127,7 +16661,7 @@ function createRunsHandler(deps = {}) {
16127
16661
  }
16128
16662
 
16129
16663
  // src/setup.ts
16130
- var import_node_fs14 = require("node:fs");
16664
+ var import_node_fs15 = require("node:fs");
16131
16665
  var import_node_path15 = __toESM(require("node:path"), 1);
16132
16666
 
16133
16667
  // src/githubSetupProvider.ts
@@ -16135,7 +16669,7 @@ var import_node_child_process4 = require("node:child_process");
16135
16669
 
16136
16670
  // src/setupPlan.ts
16137
16671
  var import_node_crypto = require("node:crypto");
16138
- var import_node_fs13 = require("node:fs");
16672
+ var import_node_fs14 = require("node:fs");
16139
16673
  var import_node_path14 = __toESM(require("node:path"), 1);
16140
16674
  var SETUP_PLAN_SCHEMA_VERSION = "automatify.testops.setup/v1";
16141
16675
  var SETUP_PLAN_KIND = "AutomatifyTestOpsSetupPlan";
@@ -16231,7 +16765,7 @@ function buildGitHubSetupPlan(input) {
16231
16765
  if (endpointSecretName === tokenSecretName) {
16232
16766
  throw new Error("callback endpoint and auth token secret names must be different.");
16233
16767
  }
16234
- const workflowContent = (0, import_node_fs13.readFileSync)(input.workflowSourcePath, "utf8");
16768
+ const workflowContent = (0, import_node_fs14.readFileSync)(input.workflowSourcePath, "utf8");
16235
16769
  if (!workflowContent.trim()) {
16236
16770
  throw new Error("workflow source file is empty.");
16237
16771
  }
@@ -17177,7 +17711,7 @@ function loadPlan(planPath, cwd) {
17177
17711
  const absolute = import_node_path15.default.resolve(cwd, planPath);
17178
17712
  let parsed;
17179
17713
  try {
17180
- parsed = JSON.parse((0, import_node_fs14.readFileSync)(absolute, "utf8"));
17714
+ parsed = JSON.parse((0, import_node_fs15.readFileSync)(absolute, "utf8"));
17181
17715
  } catch (error) {
17182
17716
  throw new SetupCommandError(
17183
17717
  "PLAN_READ_ERROR",
@@ -17416,7 +17950,7 @@ function resolveWorkflowTarget(plan, repoRoot) {
17416
17950
  function applyWorkflowFile(plan, repoRoot, dryRun) {
17417
17951
  const action = plan.actions.find((item) => item.scope === "workflow-file");
17418
17952
  const target = resolveWorkflowTarget(plan, repoRoot);
17419
- const sourceContent = (0, import_node_fs14.readFileSync)(plan.github.workflowFile.sourcePath, "utf8");
17953
+ const sourceContent = (0, import_node_fs15.readFileSync)(plan.github.workflowFile.sourcePath, "utf8");
17420
17954
  if (hashSetupContent(sourceContent) !== plan.github.workflowFile.sha256) {
17421
17955
  throw new SetupCommandError(
17422
17956
  "WORKFLOW_SOURCE_CHANGED",
@@ -17424,8 +17958,8 @@ function applyWorkflowFile(plan, repoRoot, dryRun) {
17424
17958
  ExitCode.ValidationError
17425
17959
  );
17426
17960
  }
17427
- const existed = (0, import_node_fs14.existsSync)(target);
17428
- const previousContent = existed ? (0, import_node_fs14.readFileSync)(target, "utf8") : void 0;
17961
+ const existed = (0, import_node_fs15.existsSync)(target);
17962
+ const previousContent = existed ? (0, import_node_fs15.readFileSync)(target, "utf8") : void 0;
17429
17963
  const matches = previousContent !== void 0 && hashSetupContent(previousContent) === plan.github.workflowFile.sha256;
17430
17964
  if (matches) {
17431
17965
  return {
@@ -17437,8 +17971,8 @@ function applyWorkflowFile(plan, repoRoot, dryRun) {
17437
17971
  };
17438
17972
  }
17439
17973
  if (!dryRun) {
17440
- (0, import_node_fs14.mkdirSync)(import_node_path15.default.dirname(target), { recursive: true });
17441
- (0, import_node_fs14.writeFileSync)(target, sourceContent, { encoding: "utf8", mode: 420 });
17974
+ (0, import_node_fs15.mkdirSync)(import_node_path15.default.dirname(target), { recursive: true });
17975
+ (0, import_node_fs15.writeFileSync)(target, sourceContent, { encoding: "utf8", mode: 420 });
17442
17976
  }
17443
17977
  return {
17444
17978
  id: action.id,
@@ -17608,14 +18142,14 @@ async function inspectPlan(plan, repoRoot, secrets, context, deps) {
17608
18142
  const checks = [];
17609
18143
  let forgeTransportFailure = false;
17610
18144
  const target = resolveWorkflowTarget(plan, repoRoot);
17611
- if (!(0, import_node_fs14.existsSync)(target)) {
18145
+ if (!(0, import_node_fs15.existsSync)(target)) {
17612
18146
  checks.push({
17613
18147
  id: "workflow-local",
17614
18148
  status: "fail",
17615
18149
  message: `Local workflow file ${plan.github.workflowFile.path} does not exist.`
17616
18150
  });
17617
18151
  } else {
17618
- const localHash = hashSetupContent((0, import_node_fs14.readFileSync)(target, "utf8"));
18152
+ const localHash = hashSetupContent((0, import_node_fs15.readFileSync)(target, "utf8"));
17619
18153
  checks.push({
17620
18154
  id: "workflow-local",
17621
18155
  status: localHash === plan.github.workflowFile.sha256 ? "pass" : "fail",
@@ -18626,6 +19160,12 @@ var COMMAND_REGISTRY = [
18626
19160
  subcommands: ["upload", "download", "open"],
18627
19161
  handler: createAllureHandler()
18628
19162
  },
19163
+ {
19164
+ name: "auth",
19165
+ description: "Secret-safe Jira authentication login, status, and logout commands",
19166
+ subcommands: ["login", "status", "logout"],
19167
+ handler: createAuthHandler()
19168
+ },
18629
19169
  {
18630
19170
  name: "auto",
18631
19171
  description: "Smart auto-ingestion command contract (MVP staged)",
@@ -18648,7 +19188,7 @@ var COMMAND_REGISTRY = [
18648
19188
  {
18649
19189
  name: "config",
18650
19190
  description: "Configuration and auth bootstrap commands",
18651
- subcommands: ["show", "validate", "set"],
19191
+ subcommands: ["show", "validate", "set", "unset", "migrate-secrets"],
18652
19192
  handler: createConfigHandler()
18653
19193
  },
18654
19194
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@automatify-au/cli",
3
- "version": "0.1.14",
3
+ "version": "0.1.15",
4
4
  "description": "Forge-first CLI for Automatify Jira TestOps",
5
5
  "type": "module",
6
6
  "bin": {