@norrix/cli 0.0.50 → 0.0.51

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.
@@ -10,6 +10,9 @@ import archiver from 'archiver';
10
10
  import { configureAmplify, loadCliEnvFiles } from './amplify-config.js';
11
11
  import { computeFingerprint, embedFingerprintInNativeResources, embedVersionInNativeResources, embedNxConfigurationInNativeResources, } from './fingerprinting.js';
12
12
  import { loadNorrixConfig, hasNorrixConfig, saveNorrixConfig, } from './config.js';
13
+ import { fetchOrgEnvVarNames, getOrgEnvVarNamesForField, hasOrgEnvVar, } from './org-env-helper.js';
14
+ import { smartPrompt } from './smart-prompt.js';
15
+ import { ConfigValueTracker } from './config-tracker.js';
13
16
  import { detectWorkspaceContext, getNxProjectDependencies, getWorkspaceDependenciesFallback, createWorkspaceManifest, logWorkspaceContext, isAtWorkspaceRoot, discoverNativeScriptApps, getWorkspaceContextForApp, detectNxBuildConfigurations, } from './workspace.js';
14
17
  import { signIn as amplifySignIn, signOut as amplifySignOut, getCurrentUser, fetchAuthSession, } from 'aws-amplify/auth';
15
18
  import crypto from 'crypto';
@@ -1443,6 +1446,10 @@ export async function build(cliPlatformArg, cliConfigurationArg, cliDistribution
1443
1446
  }
1444
1447
  return;
1445
1448
  }
1449
+ // Fetch org environment variable names for smart prompting
1450
+ const availableOrgEnvVars = await fetchOrgEnvVarNames(API_URL, await getAuthHeaders({ includeOrg: true }), verbose);
1451
+ // Track user-provided values for potential config saving
1452
+ const configTracker = new ConfigValueTracker();
1446
1453
  let spinner;
1447
1454
  let originalCwd;
1448
1455
  try {
@@ -1484,15 +1491,15 @@ export async function build(cliPlatformArg, cliConfigurationArg, cliDistribution
1484
1491
  platform = chosenPlatform;
1485
1492
  }
1486
1493
  // 2.1 Determine configuration (CLI arg preferred, then config, otherwise prompt)
1487
- const validConfigurations = ['debug', 'release'];
1494
+ const validConfigurations = ['release', 'debug'];
1488
1495
  let configuration = (cliConfigurationArg ||
1489
1496
  norrixConfig.defaultConfiguration ||
1490
1497
  '').toLowerCase();
1491
1498
  if (!validConfigurations.includes(configuration)) {
1492
1499
  if (opts.nonInteractive) {
1493
- // Default to 'debug' in non-interactive mode if not specified
1494
- configuration = 'debug';
1495
- logVerbose('No configuration specified, defaulting to debug', verbose);
1500
+ // Default to 'release' in non-interactive mode if not specified
1501
+ configuration = 'release';
1502
+ logVerbose('No configuration specified, defaulting to release', verbose);
1496
1503
  }
1497
1504
  else {
1498
1505
  const answer = await inquirer.prompt([
@@ -1501,7 +1508,7 @@ export async function build(cliPlatformArg, cliConfigurationArg, cliDistribution
1501
1508
  name: 'configuration',
1502
1509
  message: 'Build configuration:',
1503
1510
  choices: validConfigurations,
1504
- default: norrixConfig.defaultConfiguration || 'debug',
1511
+ default: norrixConfig.defaultConfiguration || 'release',
1505
1512
  },
1506
1513
  ]);
1507
1514
  configuration = answer.configuration;
@@ -1664,6 +1671,46 @@ export async function build(cliPlatformArg, cliConfigurationArg, cliDistribution
1664
1671
  buildNumber = cliBuildNumber;
1665
1672
  logVerbose(`Using build number from CLI flag: ${buildNumber}`, verbose);
1666
1673
  }
1674
+ else if (platform === 'ios' || platform === 'visionos') {
1675
+ const inferredApple = appleVersionInfo;
1676
+ const inferredBuildNumber = inferredApple.buildNumber;
1677
+ if (inferredBuildNumber) {
1678
+ logVerbose(`Auto-detected iOS CFBundleVersion from Info.plist: ${inferredBuildNumber}`, verbose);
1679
+ }
1680
+ if (opts.nonInteractive) {
1681
+ // In non-interactive mode, use inferred or leave undefined for auto-increment
1682
+ buildNumber = inferredBuildNumber || undefined;
1683
+ if (buildNumber) {
1684
+ logVerbose(`Using auto-detected build number: ${buildNumber}`, verbose);
1685
+ }
1686
+ else {
1687
+ logVerbose('Build number will be auto-incremented', verbose);
1688
+ }
1689
+ }
1690
+ else {
1691
+ const { iosBuildNumber } = await inquirer.prompt([
1692
+ {
1693
+ type: 'input',
1694
+ name: 'iosBuildNumber',
1695
+ message: inferredBuildNumber
1696
+ ? `App build number, aka CFBundleVersion (${inferredBuildNumber} - enter to auto increment):`
1697
+ : 'App build number, aka CFBundleVersion (enter to auto increment):',
1698
+ default: inferredBuildNumber || '',
1699
+ validate: (input) => {
1700
+ const val = String(input).trim();
1701
+ if (val === '')
1702
+ return true; // allow blank → auto-increment
1703
+ // iOS CFBundleVersion can be numeric or semver-like (e.g., "1.0.0" or "123")
1704
+ if (/^[\d.]+$/.test(val)) {
1705
+ return true;
1706
+ }
1707
+ return 'Enter a numeric build number (e.g., "123" or "1.0.0"). Leave empty to auto-increment.';
1708
+ },
1709
+ },
1710
+ ]);
1711
+ buildNumber = String(iosBuildNumber).trim() || undefined;
1712
+ }
1713
+ }
1667
1714
  else if (platform === 'android') {
1668
1715
  const inferredAndroid = androidVersionInfo;
1669
1716
  const inferredVersionCode = inferredAndroid.buildNumber;
@@ -1720,295 +1767,342 @@ export async function build(cliPlatformArg, cliConfigurationArg, cliDistribution
1720
1767
  const configAscKeyId = norrixConfig.ios?.ascApiKeyId;
1721
1768
  const configAscIssuerId = norrixConfig.ios?.ascIssuerId;
1722
1769
  const configAscKeyPath = norrixConfig.ios?.ascPrivateKeyPath;
1723
- const resolvedTeamId = trimString(opts.teamId) || configTeamId;
1724
- const resolvedP12Path = trimString(opts.p12Path) || configP12Path;
1725
- const resolvedP12Password = trimString(opts.p12Password);
1726
- const resolvedProfilePath = trimString(opts.profilePath) || configProfilePath;
1727
1770
  const resolvedAscKeyId = trimString(opts.ascKeyId) || configAscKeyId;
1728
- const resolvedAscIssuerId = trimString(opts.ascIssuerId) || configAscIssuerId;
1729
- const resolvedAscKeyPath = trimString(opts.ascKeyPath) || configAscKeyPath;
1730
- if (opts.nonInteractive) {
1731
- // Non-interactive mode: use CLI flags and config values only
1732
- iosCredentials = {
1733
- teamId: resolvedTeamId || undefined,
1734
- p12Base64: readOptionalFileAsBase64(resolvedP12Path),
1735
- p12Password: resolvedP12Password || undefined,
1736
- mobileprovisionBase64: readOptionalFileAsBase64(resolvedProfilePath),
1737
- ascApiKeyId: resolvedAscKeyId || undefined,
1738
- ascIssuerId: resolvedAscIssuerId || undefined,
1739
- ascPrivateKey: readOptionalFileAsBase64(resolvedAscKeyPath),
1740
- };
1741
- if (resolvedTeamId) {
1742
- console.log(`Using Apple Team ID: ${resolvedTeamId}`);
1743
- }
1744
- if (resolvedAscKeyId) {
1745
- console.log(`Using ASC API Key: ${resolvedAscKeyId}`);
1746
- }
1747
- }
1748
- else {
1749
- // Interactive mode: prompt for values with CLI/config as defaults
1750
- // Flow: Team ID -> ASC Key (recommended) -> If no ASC, then .p12/.mobileprovision
1751
- const iosAnswers = await inquirer.prompt([
1752
- {
1753
- type: 'input',
1754
- name: 'teamId',
1755
- message: 'Apple Developer Team ID (required for code signing, e.g. "ABC123XYZ"):',
1756
- default: resolvedTeamId || '',
1757
- validate: (input) => {
1758
- if (!input.trim()) {
1759
- return true; // Allow empty, workflow will try to proceed without it
1760
- }
1761
- if (/^[A-Z0-9]{10}$/.test(input.trim())) {
1762
- return true;
1763
- }
1764
- return 'Team ID should be 10 alphanumeric characters (e.g. "ABC123XYZ"). Leave empty to skip.';
1765
- },
1766
- },
1767
- // ASC API Key is the recommended approach - ask first
1771
+ // Use smart prompts for credentials
1772
+ // Non-interactive mode will auto-select from CLI > org env > config > process env
1773
+ // Interactive mode will confirm org env and config values before using them
1774
+ // Team ID
1775
+ const teamIdResult = await smartPrompt({
1776
+ name: 'teamId',
1777
+ message: 'Apple Developer Team ID (required for code signing, e.g. "ABC123XYZ"):',
1778
+ cliValue: opts.teamId,
1779
+ configValue: configTeamId,
1780
+ orgEnvVarNames: getOrgEnvVarNamesForField('teamId'),
1781
+ availableOrgEnvVars,
1782
+ nonInteractive: opts.nonInteractive,
1783
+ validate: (input) => {
1784
+ if (!input.trim()) {
1785
+ return true; // Allow empty, workflow will try to proceed without it
1786
+ }
1787
+ if (/^[A-Z0-9]{10}$/.test(input.trim())) {
1788
+ return true;
1789
+ }
1790
+ return 'Team ID should be 10 alphanumeric characters (e.g. "ABC123XYZ"). Leave empty to skip.';
1791
+ },
1792
+ });
1793
+ configTracker.track('teamId', teamIdResult.value, teamIdResult.source);
1794
+ // ASC API Key is the recommended approach
1795
+ // Check if any ASC credentials are available in org env vars
1796
+ const hasAscOrgEnvVars = hasOrgEnvVar(getOrgEnvVarNamesForField('ascApiKeyId'), availableOrgEnvVars) ||
1797
+ hasOrgEnvVar(getOrgEnvVarNamesForField('ascIssuerId'), availableOrgEnvVars) ||
1798
+ hasOrgEnvVar(getOrgEnvVarNamesForField('ascPrivateKeyPath'), availableOrgEnvVars);
1799
+ let useAscKey = Boolean(resolvedAscKeyId) || teamIdResult.source === 'org-env' || hasAscOrgEnvVars;
1800
+ if (!opts.nonInteractive && teamIdResult.source !== 'org-env' && !hasAscOrgEnvVars) {
1801
+ // Ask if user wants to use ASC Key (only in interactive mode, and only if no org env vars)
1802
+ const { useAscKeyAnswer } = await inquirer.prompt([
1768
1803
  {
1769
1804
  type: 'confirm',
1770
- name: 'useAscKey',
1805
+ name: 'useAscKeyAnswer',
1771
1806
  message: 'Use App Store Connect API Key for auto-provisioning? (recommended)',
1772
1807
  default: Boolean(resolvedAscKeyId) || true,
1773
1808
  },
1774
- {
1775
- type: 'input',
1776
- name: 'ascApiKeyId',
1777
- message: 'ASC API Key ID:',
1778
- default: resolvedAscKeyId || '',
1779
- when: (a) => a.useAscKey,
1780
- validate: (input) => {
1781
- if (!input.trim()) {
1782
- return 'API Key ID is required when using ASC Key';
1783
- }
1784
- return true;
1785
- },
1786
- },
1787
- {
1788
- type: 'input',
1789
- name: 'ascIssuerId',
1790
- message: 'ASC Issuer ID:',
1791
- default: resolvedAscIssuerId || '',
1792
- when: (a) => a.useAscKey,
1793
- validate: (input) => {
1794
- if (!input.trim()) {
1795
- return 'Issuer ID is required when using ASC Key';
1796
- }
1797
- return true;
1798
- },
1809
+ ]);
1810
+ useAscKey = useAscKeyAnswer;
1811
+ }
1812
+ // Collect ASC credentials if using ASC Key
1813
+ let ascApiKeyIdResult;
1814
+ let ascIssuerIdResult;
1815
+ let ascPrivateKeyPathResult;
1816
+ if (useAscKey) {
1817
+ ascApiKeyIdResult = await smartPrompt({
1818
+ name: 'ascApiKeyId',
1819
+ message: 'App Store Connect (ASC) API Key ID:',
1820
+ cliValue: opts.ascKeyId,
1821
+ configValue: configAscKeyId,
1822
+ orgEnvVarNames: getOrgEnvVarNamesForField('ascApiKeyId'),
1823
+ availableOrgEnvVars,
1824
+ nonInteractive: opts.nonInteractive,
1825
+ required: true,
1826
+ validate: (input) => {
1827
+ if (!input.trim()) {
1828
+ return 'API Key ID is required when using App Store Connect (ASC) Key';
1829
+ }
1830
+ return true;
1799
1831
  },
1800
- {
1801
- type: 'input',
1802
- name: 'ascPrivateKeyPath',
1803
- message: 'Path to ASC private key .p8:',
1804
- default: resolvedAscKeyPath || '',
1805
- when: (a) => a.useAscKey,
1806
- validate: (input) => {
1807
- if (!input.trim()) {
1808
- return 'Path to .p8 key file is required when using ASC Key';
1809
- }
1810
- return true;
1811
- },
1832
+ });
1833
+ configTracker.track('ascApiKeyId', ascApiKeyIdResult.value, ascApiKeyIdResult.source);
1834
+ ascIssuerIdResult = await smartPrompt({
1835
+ name: 'ascIssuerId',
1836
+ message: 'App Store Connect (ASC) Issuer ID:',
1837
+ cliValue: opts.ascIssuerId,
1838
+ configValue: configAscIssuerId,
1839
+ orgEnvVarNames: getOrgEnvVarNamesForField('ascIssuerId'),
1840
+ availableOrgEnvVars,
1841
+ nonInteractive: opts.nonInteractive,
1842
+ required: true,
1843
+ validate: (input) => {
1844
+ if (!input.trim()) {
1845
+ return 'Issuer ID is required when using App Store Connect (ASC) Key';
1846
+ }
1847
+ return true;
1812
1848
  },
1813
- // Only ask for .p12/.mobileprovision if NOT using ASC Key
1814
- {
1815
- type: 'input',
1816
- name: 'p12Path',
1817
- message: 'Path to iOS .p12 certificate (optional):',
1818
- default: resolvedP12Path || '',
1819
- when: (a) => !a.useAscKey,
1849
+ });
1850
+ configTracker.track('ascIssuerId', ascIssuerIdResult.value, ascIssuerIdResult.source);
1851
+ ascPrivateKeyPathResult = await smartPrompt({
1852
+ name: 'ascPrivateKeyPath',
1853
+ message: 'Path to App Store Connect (ASC) private key .p8:',
1854
+ cliValue: opts.ascKeyPath,
1855
+ configValue: configAscKeyPath,
1856
+ orgEnvVarNames: getOrgEnvVarNamesForField('ascPrivateKeyPath'),
1857
+ availableOrgEnvVars,
1858
+ nonInteractive: opts.nonInteractive,
1859
+ required: true,
1860
+ validate: (input) => {
1861
+ if (!input.trim()) {
1862
+ return 'Path to .p8 key file is required when using App Store Connect (ASC) Key';
1863
+ }
1864
+ return true;
1820
1865
  },
1821
- {
1822
- type: 'password',
1866
+ });
1867
+ configTracker.track('ascPrivateKeyPath', ascPrivateKeyPathResult.value, ascPrivateKeyPathResult.source);
1868
+ }
1869
+ // Collect p12/mobileprovision credentials if NOT using ASC Key
1870
+ let p12PathResult;
1871
+ let p12PasswordResult;
1872
+ let mobileprovisionPathResult;
1873
+ if (!useAscKey) {
1874
+ p12PathResult = await smartPrompt({
1875
+ name: 'p12Path',
1876
+ message: 'Path to iOS .p12 certificate (optional):',
1877
+ cliValue: opts.p12Path,
1878
+ configValue: configP12Path,
1879
+ orgEnvVarNames: getOrgEnvVarNamesForField('p12Path'),
1880
+ availableOrgEnvVars,
1881
+ nonInteractive: opts.nonInteractive,
1882
+ required: false,
1883
+ });
1884
+ configTracker.track('p12Path', p12PathResult.value, p12PathResult.source);
1885
+ // Only prompt for p12 password if p12 path was provided
1886
+ if (p12PathResult.value) {
1887
+ p12PasswordResult = await smartPrompt({
1823
1888
  name: 'p12Password',
1824
1889
  message: 'Password for .p12 (if any):',
1825
- mask: '*',
1826
- default: '',
1827
- when: (a) => !a.useAscKey && a.p12Path,
1828
- },
1829
- {
1830
- type: 'input',
1831
- name: 'mobileprovisionPath',
1832
- message: 'Path to provisioning profile .mobileprovision (optional):',
1833
- default: resolvedProfilePath || '',
1834
- when: (a) => !a.useAscKey,
1835
- },
1836
- ]);
1837
- // Use resolved teamId from CLI/config, or from prompt
1838
- const finalTeamId = trimString(iosAnswers.teamId) || resolvedTeamId;
1839
- iosCredentials = {
1840
- teamId: finalTeamId || undefined,
1841
- p12Base64: readOptionalFileAsBase64(iosAnswers.p12Path),
1842
- p12Password: trimString(iosAnswers.p12Password),
1843
- mobileprovisionBase64: readOptionalFileAsBase64(iosAnswers.mobileprovisionPath),
1844
- ascApiKeyId: trimString(iosAnswers.ascApiKeyId),
1845
- ascIssuerId: trimString(iosAnswers.ascIssuerId),
1846
- ascPrivateKey: readOptionalFileAsBase64(iosAnswers.ascPrivateKeyPath),
1847
- // Track paths for config saving (not sent to API)
1848
- _p12Path: trimString(iosAnswers.p12Path),
1849
- _mobileprovisionPath: trimString(iosAnswers.mobileprovisionPath),
1850
- _ascApiKeyId: trimString(iosAnswers.ascApiKeyId),
1851
- _ascIssuerId: trimString(iosAnswers.ascIssuerId),
1852
- _ascPrivateKeyPath: trimString(iosAnswers.ascPrivateKeyPath),
1853
- };
1854
- if (finalTeamId) {
1855
- console.log(`Using Apple Team ID: ${finalTeamId}`);
1890
+ cliValue: opts.p12Password,
1891
+ configValue: undefined, // Don't save passwords in config
1892
+ orgEnvVarNames: getOrgEnvVarNamesForField('p12Password'),
1893
+ availableOrgEnvVars,
1894
+ nonInteractive: opts.nonInteractive,
1895
+ mask: true,
1896
+ required: false,
1897
+ });
1898
+ configTracker.track('p12Password', p12PasswordResult.value, p12PasswordResult.source);
1856
1899
  }
1900
+ mobileprovisionPathResult = await smartPrompt({
1901
+ name: 'provisioningProfilePath',
1902
+ message: 'Path to provisioning profile .mobileprovision (optional):',
1903
+ cliValue: opts.profilePath,
1904
+ configValue: configProfilePath,
1905
+ orgEnvVarNames: getOrgEnvVarNamesForField('provisioningProfilePath'),
1906
+ availableOrgEnvVars,
1907
+ nonInteractive: opts.nonInteractive,
1908
+ required: false,
1909
+ });
1910
+ configTracker.track('provisioningProfilePath', mobileprovisionPathResult.value, mobileprovisionPathResult.source);
1911
+ }
1912
+ // Build iosCredentials object
1913
+ // If value is undefined (org env var), don't include in API request
1914
+ iosCredentials = {
1915
+ teamId: teamIdResult.value || undefined,
1916
+ p12Base64: p12PathResult?.value ? readOptionalFileAsBase64(p12PathResult.value) : undefined,
1917
+ p12Password: p12PasswordResult?.value || undefined,
1918
+ mobileprovisionBase64: mobileprovisionPathResult?.value
1919
+ ? readOptionalFileAsBase64(mobileprovisionPathResult.value)
1920
+ : undefined,
1921
+ ascApiKeyId: ascApiKeyIdResult?.value || undefined,
1922
+ ascIssuerId: ascIssuerIdResult?.value || undefined,
1923
+ ascPrivateKey: ascPrivateKeyPathResult?.value
1924
+ ? readOptionalFileAsBase64(ascPrivateKeyPathResult.value)
1925
+ : undefined,
1926
+ // Track paths for config saving (not sent to API)
1927
+ _p12Path: p12PathResult?.value,
1928
+ _mobileprovisionPath: mobileprovisionPathResult?.value,
1929
+ _ascApiKeyId: ascApiKeyIdResult?.value,
1930
+ _ascIssuerId: ascIssuerIdResult?.value,
1931
+ _ascPrivateKeyPath: ascPrivateKeyPathResult?.value,
1932
+ };
1933
+ if (teamIdResult.value) {
1934
+ console.log(`Using Apple Team ID: ${teamIdResult.value}`);
1935
+ }
1936
+ if (teamIdResult.source === 'org-env') {
1937
+ console.log(`Using organization's iOS credentials`);
1857
1938
  }
1858
1939
  }
1859
1940
  else if (platform === 'android') {
1860
- // Resolve values from: CLI flags > config file > environment variables
1941
+ // Use smart prompts for Android credentials
1861
1942
  const configKeystorePath = norrixConfig.android?.keystorePath;
1862
1943
  const configKeyAlias = norrixConfig.android?.keyAlias;
1863
- const resolvedKeystorePath = trimString(opts.keystorePath) ||
1864
- configKeystorePath ||
1865
- process.env.KEYSTORE_PATH;
1866
- const resolvedKeystorePassword = trimString(opts.keystorePassword) || process.env.KEYSTORE_PASSWORD;
1867
- const resolvedKeyAlias = trimString(opts.keyAlias) ||
1868
- configKeyAlias ||
1869
- process.env.KEYSTORE_ALIAS;
1870
- const resolvedKeyPassword = trimString(opts.keyPassword) ||
1871
- process.env.KEYSTORE_ALIAS_PASSWORD ||
1872
- process.env.KEY_PASSWORD;
1873
- const resolvedPlayJsonPath = trimString(opts.playJsonPath) ||
1874
- process.env.PLAY_SERVICE_ACCOUNT_JSON_PATH;
1944
+ // Keystore path
1945
+ const keystorePathResult = await smartPrompt({
1946
+ name: 'keystorePath',
1947
+ message: 'Path to Android keystore .jks/.keystore (optional):',
1948
+ cliValue: opts.keystorePath,
1949
+ configValue: configKeystorePath,
1950
+ orgEnvVarNames: getOrgEnvVarNamesForField('keystorePath'),
1951
+ availableOrgEnvVars,
1952
+ processEnvNames: ['KEYSTORE_PATH'],
1953
+ nonInteractive: opts.nonInteractive,
1954
+ required: false,
1955
+ });
1956
+ configTracker.track('keystorePath', keystorePathResult.value, keystorePathResult.source);
1957
+ // Keystore password
1958
+ const keystorePasswordResult = await smartPrompt({
1959
+ name: 'keystorePassword',
1960
+ message: 'Keystore password (optional):',
1961
+ cliValue: opts.keystorePassword,
1962
+ configValue: undefined, // Don't save passwords in config
1963
+ orgEnvVarNames: getOrgEnvVarNamesForField('keystorePassword'),
1964
+ availableOrgEnvVars,
1965
+ processEnvNames: ['KEYSTORE_PASSWORD'],
1966
+ nonInteractive: opts.nonInteractive,
1967
+ mask: true,
1968
+ required: false,
1969
+ });
1970
+ configTracker.track('keystorePassword', keystorePasswordResult.value, keystorePasswordResult.source);
1971
+ // Key alias
1972
+ const keyAliasResult = await smartPrompt({
1973
+ name: 'keyAlias',
1974
+ message: 'Key alias (optional):',
1975
+ cliValue: opts.keyAlias,
1976
+ configValue: configKeyAlias,
1977
+ orgEnvVarNames: getOrgEnvVarNamesForField('keyAlias'),
1978
+ availableOrgEnvVars,
1979
+ processEnvNames: ['KEYSTORE_ALIAS'],
1980
+ nonInteractive: opts.nonInteractive,
1981
+ required: false,
1982
+ });
1983
+ configTracker.track('keyAlias', keyAliasResult.value, keyAliasResult.source);
1984
+ // Key password
1985
+ const keyPasswordResult = await smartPrompt({
1986
+ name: 'keyPassword',
1987
+ message: 'Key password (optional):',
1988
+ cliValue: opts.keyPassword,
1989
+ configValue: undefined, // Don't save passwords in config
1990
+ orgEnvVarNames: getOrgEnvVarNamesForField('keyPassword'),
1991
+ availableOrgEnvVars,
1992
+ processEnvNames: ['KEYSTORE_ALIAS_PASSWORD', 'KEY_PASSWORD'],
1993
+ nonInteractive: opts.nonInteractive,
1994
+ mask: true,
1995
+ required: false,
1996
+ });
1997
+ configTracker.track('keyPassword', keyPasswordResult.value, keyPasswordResult.source);
1998
+ // Google Play service account JSON
1999
+ let playJsonPathResult;
2000
+ // In non-interactive mode, check if value exists
1875
2001
  if (opts.nonInteractive) {
1876
- // Non-interactive mode: use CLI flags, config, and env vars only
1877
- // All values are optional - the cloud build will use org-level credentials if not provided
1878
- androidCredentials = {
1879
- keystoreBase64: readOptionalFileAsBase64(resolvedKeystorePath),
1880
- keystorePassword: resolvedKeystorePassword || undefined,
1881
- keyAlias: resolvedKeyAlias || undefined,
1882
- keyPassword: resolvedKeyPassword || undefined,
1883
- playServiceAccountJson: readOptionalFileAsBase64(resolvedPlayJsonPath),
1884
- };
1885
- if (resolvedKeystorePath) {
1886
- logVerbose(`Using keystore from: ${resolvedKeystorePath}`, verbose);
1887
- }
1888
- if (resolvedKeyAlias) {
1889
- logVerbose(`Using key alias: ${resolvedKeyAlias}`, verbose);
1890
- }
2002
+ playJsonPathResult = await smartPrompt({
2003
+ name: 'playServiceAccountJsonPath',
2004
+ message: 'Path to Google Play service account JSON (optional):',
2005
+ cliValue: opts.playJsonPath,
2006
+ configValue: undefined,
2007
+ orgEnvVarNames: getOrgEnvVarNamesForField('playServiceAccountJsonPath'),
2008
+ availableOrgEnvVars,
2009
+ processEnvNames: ['PLAY_SERVICE_ACCOUNT_JSON_PATH'],
2010
+ nonInteractive: true,
2011
+ required: false,
2012
+ });
1891
2013
  }
1892
2014
  else {
1893
- // Interactive mode: prompt for values with CLI/config/env as defaults
1894
- const androidAnswers = await inquirer.prompt([
1895
- {
1896
- type: 'input',
1897
- name: 'keystorePath',
1898
- message: 'Path to Android keystore .jks/.keystore (optional):',
1899
- default: resolvedKeystorePath || '',
1900
- },
1901
- {
1902
- type: 'password',
1903
- name: 'keystorePassword',
1904
- message: 'Keystore password (optional):',
1905
- mask: '*',
1906
- default: '',
1907
- },
1908
- {
1909
- type: 'input',
1910
- name: 'keyAlias',
1911
- message: 'Key alias (optional):',
1912
- default: resolvedKeyAlias || '',
1913
- },
1914
- {
1915
- type: 'password',
1916
- name: 'keyPassword',
1917
- message: 'Key password (optional):',
1918
- mask: '*',
1919
- default: '',
1920
- },
2015
+ // In interactive mode, ask if they want to provide it first
2016
+ const { hasPlayJson } = await inquirer.prompt([
1921
2017
  {
1922
2018
  type: 'confirm',
1923
2019
  name: 'hasPlayJson',
1924
2020
  message: 'Provide Google Play service account JSON? (optional, for Play operations)',
1925
2021
  default: false,
1926
2022
  },
1927
- {
1928
- type: 'input',
1929
- name: 'playJsonPath',
1930
- message: 'Path to Google Play service account JSON (optional):',
1931
- default: '',
1932
- when: (a) => a.hasPlayJson,
1933
- },
1934
2023
  ]);
1935
- androidCredentials = {
1936
- keystoreBase64: readOptionalFileAsBase64(androidAnswers.keystorePath),
1937
- keystorePassword: trimString(androidAnswers.keystorePassword),
1938
- keyAlias: trimString(androidAnswers.keyAlias),
1939
- keyPassword: trimString(androidAnswers.keyPassword),
1940
- playServiceAccountJson: readOptionalFileAsBase64(androidAnswers.playJsonPath),
1941
- };
1942
- // Track Android paths for config saving
1943
- androidCredentials._keystorePath = trimString(androidAnswers.keystorePath);
1944
- androidCredentials._keyAlias = trimString(androidAnswers.keyAlias);
2024
+ if (hasPlayJson) {
2025
+ playJsonPathResult = await smartPrompt({
2026
+ name: 'playServiceAccountJsonPath',
2027
+ message: 'Path to Google Play service account JSON (optional):',
2028
+ cliValue: opts.playJsonPath,
2029
+ configValue: undefined,
2030
+ orgEnvVarNames: getOrgEnvVarNamesForField('playServiceAccountJsonPath'),
2031
+ availableOrgEnvVars,
2032
+ processEnvNames: ['PLAY_SERVICE_ACCOUNT_JSON_PATH'],
2033
+ nonInteractive: false,
2034
+ required: false,
2035
+ });
2036
+ }
1945
2037
  }
1946
- }
1947
- // Offer to save config if no norrix.config.ts exists and we collected useful values
1948
- const appRoot = process.cwd();
1949
- if (!hasNorrixConfig(appRoot) && !opts.nonInteractive) {
1950
- // Collect saveable values
1951
- const saveableOptions = {
1952
- platform: platform,
2038
+ if (playJsonPathResult) {
2039
+ configTracker.track('playServiceAccountJsonPath', playJsonPathResult.value, playJsonPathResult.source);
2040
+ }
2041
+ // Build androidCredentials object
2042
+ androidCredentials = {
2043
+ keystoreBase64: keystorePathResult.value
2044
+ ? readOptionalFileAsBase64(keystorePathResult.value)
2045
+ : undefined,
2046
+ keystorePassword: keystorePasswordResult.value || undefined,
2047
+ keyAlias: keyAliasResult.value || undefined,
2048
+ keyPassword: keyPasswordResult.value || undefined,
2049
+ playServiceAccountJson: playJsonPathResult?.value
2050
+ ? readOptionalFileAsBase64(playJsonPathResult.value)
2051
+ : undefined,
1953
2052
  };
1954
- if (platform === 'ios' && iosCredentials) {
1955
- if (iosCredentials.teamId) {
1956
- saveableOptions.teamId = iosCredentials.teamId;
1957
- }
1958
- if (distributionType) {
1959
- saveableOptions.distributionType = distributionType;
1960
- }
1961
- // Don't save actual credential file paths since they may contain secrets
1962
- // But save paths that users can re-use
1963
- if (iosCredentials._p12Path) {
1964
- saveableOptions.p12Path = iosCredentials._p12Path;
1965
- }
1966
- if (iosCredentials._mobileprovisionPath) {
1967
- saveableOptions.provisioningProfilePath = iosCredentials._mobileprovisionPath;
1968
- }
1969
- // Save ASC API Key details for future builds
1970
- if (iosCredentials._ascApiKeyId) {
1971
- saveableOptions.ascApiKeyId = iosCredentials._ascApiKeyId;
1972
- }
1973
- if (iosCredentials._ascIssuerId) {
1974
- saveableOptions.ascIssuerId = iosCredentials._ascIssuerId;
1975
- }
1976
- if (iosCredentials._ascPrivateKeyPath) {
1977
- saveableOptions.ascPrivateKeyPath = iosCredentials._ascPrivateKeyPath;
1978
- }
2053
+ if (keystorePathResult.value) {
2054
+ logVerbose(`Using keystore from: ${keystorePathResult.value}`, verbose);
1979
2055
  }
1980
- if (platform === 'android' && androidCredentials) {
1981
- if (androidCredentials._keystorePath) {
1982
- saveableOptions.keystorePath = androidCredentials._keystorePath;
1983
- }
1984
- if (androidCredentials._keyAlias) {
1985
- saveableOptions.keyAlias = androidCredentials._keyAlias;
1986
- }
2056
+ if (keystorePathResult.source === 'org-env') {
2057
+ console.log(`Using organization's Android credentials`);
1987
2058
  }
1988
- // Only offer to save if we have something useful
1989
- const hasSaveableValues = saveableOptions.teamId ||
1990
- saveableOptions.distributionType ||
1991
- saveableOptions.p12Path ||
1992
- saveableOptions.provisioningProfilePath ||
1993
- saveableOptions.keystorePath ||
1994
- saveableOptions.ascApiKeyId;
1995
- if (hasSaveableValues) {
1996
- const { shouldSave } = await inquirer.prompt([
1997
- {
1998
- type: 'confirm',
1999
- name: 'shouldSave',
2000
- message: 'Save these settings to norrix.config.ts for future builds?',
2001
- default: true,
2002
- },
2003
- ]);
2004
- if (shouldSave) {
2005
- try {
2006
- const savedPath = saveNorrixConfig(appRoot, saveableOptions);
2007
- console.log(`✓ Configuration saved to ${path.basename(savedPath)}`);
2059
+ }
2060
+ // Offer to save config if user provided new values
2061
+ const appRoot = process.cwd();
2062
+ if (!opts.nonInteractive && configTracker.shouldOfferToSave()) {
2063
+ const configExists = hasNorrixConfig(appRoot);
2064
+ const { shouldSave } = await inquirer.prompt([
2065
+ {
2066
+ type: 'confirm',
2067
+ name: 'shouldSave',
2068
+ message: configExists
2069
+ ? 'Save new credential paths to norrix.config.ts?'
2070
+ : 'Save credential paths to norrix.config.ts for future builds?',
2071
+ default: true,
2072
+ },
2073
+ ]);
2074
+ if (shouldSave) {
2075
+ try {
2076
+ const userProvidedValues = configTracker.getUserProvidedValues();
2077
+ // Start with existing config values (if config exists)
2078
+ const existingConfig = configExists ? norrixConfig : {};
2079
+ const saveableOptions = {
2080
+ platform: platform,
2081
+ };
2082
+ // Merge: Start with existing config, then overlay user-provided values
2083
+ // iOS fields
2084
+ if (platform === 'ios') {
2085
+ saveableOptions.teamId = userProvidedValues.teamId || existingConfig.ios?.teamId;
2086
+ saveableOptions.ascApiKeyId = userProvidedValues.ascApiKeyId || existingConfig.ios?.ascApiKeyId;
2087
+ saveableOptions.ascIssuerId = userProvidedValues.ascIssuerId || existingConfig.ios?.ascIssuerId;
2088
+ saveableOptions.ascPrivateKeyPath = userProvidedValues.ascPrivateKeyPath || existingConfig.ios?.ascPrivateKeyPath;
2089
+ saveableOptions.p12Path = userProvidedValues.p12Path || existingConfig.ios?.p12Path;
2090
+ saveableOptions.provisioningProfilePath = userProvidedValues.provisioningProfilePath || existingConfig.ios?.provisioningProfilePath;
2091
+ saveableOptions.distributionType = (distributionType || existingConfig.ios?.distributionType);
2008
2092
  }
2009
- catch (saveError) {
2010
- console.warn(`Warning: Could not save config file: ${saveError.message}`);
2093
+ // Android fields
2094
+ if (platform === 'android') {
2095
+ saveableOptions.keystorePath = userProvidedValues.keystorePath || existingConfig.android?.keystorePath;
2096
+ saveableOptions.keyAlias = userProvidedValues.keyAlias || existingConfig.android?.keyAlias;
2097
+ saveableOptions.distributionType = (distributionType || existingConfig.android?.distributionType);
2011
2098
  }
2099
+ const savedPath = saveNorrixConfig(appRoot, saveableOptions);
2100
+ console.log(configExists
2101
+ ? `✓ Configuration updated in ${path.basename(savedPath)}`
2102
+ : `✓ Configuration saved to ${path.basename(savedPath)}`);
2103
+ }
2104
+ catch (saveError) {
2105
+ console.warn(`Warning: Could not save config file: ${saveError.message}`);
2012
2106
  }
2013
2107
  }
2014
2108
  }
@@ -2181,12 +2275,15 @@ export async function build(cliPlatformArg, cliConfigurationArg, cliDistribution
2181
2275
  spinner.succeed('Build started successfully!');
2182
2276
  console.log('✅ Your app is being built on Norrix.');
2183
2277
  console.log(` Build ID: ${buildId}`);
2184
- console.log(` Backend API: ${API_URL}`);
2185
- if (process.env.NORRIX_API_URL) {
2186
- console.log(` NORRIX_API_URL: ${process.env.NORRIX_API_URL}`);
2187
- }
2188
- if (dispatchedEnv) {
2189
- console.log(` Backend env: ${dispatchedEnv}`);
2278
+ // Only show backend details in dev mode
2279
+ if (CURRENT_ENV === 'dev') {
2280
+ console.log(` Backend API: ${API_URL}`);
2281
+ if (process.env.NORRIX_API_URL) {
2282
+ console.log(` NORRIX_API_URL: ${process.env.NORRIX_API_URL}`);
2283
+ }
2284
+ if (dispatchedEnv) {
2285
+ console.log(` Backend env: ${dispatchedEnv}`);
2286
+ }
2190
2287
  }
2191
2288
  console.log(formatVersionBuildLine(recordedVersion, recordedBuildNumber));
2192
2289
  console.log(` Platform: ${platform}`);
@@ -2239,6 +2336,41 @@ export async function build(cliPlatformArg, cliConfigurationArg, cliDistribution
2239
2336
  }
2240
2337
  }
2241
2338
  }
2339
+ /**
2340
+ * Prompt for TestFlight group names for external testing distribution.
2341
+ * Shows confirmation to use default group or enter custom group names.
2342
+ */
2343
+ async function promptForTestFlightGroups(configGroups) {
2344
+ const { useDefaultGroup } = await inquirer.prompt([
2345
+ {
2346
+ type: 'confirm',
2347
+ name: 'useDefaultGroup',
2348
+ message: `Use the default TestFlight group 'External Testers'?\n\n` +
2349
+ `When using External testing, make sure the group exists in App Store Connect.\n` +
2350
+ `Choose 'Y' if you have a group named 'External Testers' already created.\n` +
2351
+ `Choose 'N' to enter custom group names.`,
2352
+ default: true,
2353
+ },
2354
+ ]);
2355
+ if (useDefaultGroup) {
2356
+ return configGroups || ['External Testers'];
2357
+ }
2358
+ const { groupNames } = await inquirer.prompt([
2359
+ {
2360
+ type: 'input',
2361
+ name: 'groupNames',
2362
+ message: 'Please enter TestFlight group name(s) (comma-separated):',
2363
+ validate: (input) => {
2364
+ const trimmed = input.trim();
2365
+ return trimmed.length > 0 || 'At least one group name is required';
2366
+ },
2367
+ },
2368
+ ]);
2369
+ return groupNames
2370
+ .split(',')
2371
+ .map((g) => g.trim())
2372
+ .filter((g) => g.length > 0);
2373
+ }
2242
2374
  /**
2243
2375
  * Submit command implementation
2244
2376
  * Submits the built app to app stores via the Next.js API gateway
@@ -2275,6 +2407,10 @@ export async function submit(cliPlatformArg, cliTrackArg, verbose = false, optio
2275
2407
  }
2276
2408
  // Load Norrix config file from the resolved app directory
2277
2409
  const norrixConfig = await loadNorrixConfig(process.cwd());
2410
+ // Fetch org env vars for smart prompting
2411
+ const availableOrgEnvVars = await fetchOrgEnvVarNames(API_URL, await getAuthHeaders({ includeOrg: true }), verbose);
2412
+ // Track user-provided values for potential config saving
2413
+ const configTracker = new ConfigValueTracker();
2278
2414
  if (verbose) {
2279
2415
  console.log(`[submit] Resolved workspace context: ${resolved.workspaceContext.type}`);
2280
2416
  console.log(`[submit] App root: ${resolved.workspaceContext.appRoot}`);
@@ -2282,8 +2418,15 @@ export async function submit(cliPlatformArg, cliTrackArg, verbose = false, optio
2282
2418
  if (norrixConfig.ios?.ascApiKeyId) {
2283
2419
  console.log(`[submit] Found ASC credentials in norrix.config.ts`);
2284
2420
  }
2421
+ if (availableOrgEnvVars.length > 0) {
2422
+ console.log(`[submit] Available org env vars: ${availableOrgEnvVars.join(', ')}`);
2423
+ }
2285
2424
  }
2286
- // 1. Get available builds to show in prompt
2425
+ // 1. Determine platform from CLI arg first (to filter builds)
2426
+ const validPlatforms = ['android', 'ios', 'visionos'];
2427
+ let platform = (cliPlatformArg || '').toLowerCase();
2428
+ const platformSpecified = validPlatforms.includes(platform);
2429
+ // 2. Get available builds to show in prompt
2287
2430
  spinner.text = 'Fetching available builds...';
2288
2431
  let availableBuilds = [];
2289
2432
  try {
@@ -2293,9 +2436,13 @@ export async function submit(cliPlatformArg, cliTrackArg, verbose = false, optio
2293
2436
  if (buildsResponse.data.builds &&
2294
2437
  Array.isArray(buildsResponse.data.builds)) {
2295
2438
  // Filter for successful builds
2296
- availableBuilds = buildsResponse.data.builds
2297
- .filter((build) => build.status === 'success')
2298
- .map((build) => {
2439
+ let builds = buildsResponse.data.builds
2440
+ .filter((build) => build.status === 'success');
2441
+ // If platform was specified in CLI arg, filter to only that platform
2442
+ if (platformSpecified) {
2443
+ builds = builds.filter((build) => build.platform === platform);
2444
+ }
2445
+ availableBuilds = builds.map((build) => {
2299
2446
  return {
2300
2447
  name: `${build.projectName} (${build.platform} - ${build.version} (${build.buildNumber}) - ${build.id})`,
2301
2448
  value: build.id,
@@ -2309,7 +2456,7 @@ export async function submit(cliPlatformArg, cliTrackArg, verbose = false, optio
2309
2456
  catch (error) {
2310
2457
  spinner.warn("Could not fetch builds. You'll need to enter the Build ID manually.");
2311
2458
  }
2312
- // 2. Gather submission details from user
2459
+ // 3. Gather submission details from user
2313
2460
  spinner.stop();
2314
2461
  // Ask for build ID
2315
2462
  let buildId;
@@ -2335,9 +2482,7 @@ export async function submit(cliPlatformArg, cliTrackArg, verbose = false, optio
2335
2482
  ]);
2336
2483
  buildId = buildIdAnswer.buildId;
2337
2484
  }
2338
- // Determine platform, preferring CLI arg, then build metadata, then prompt
2339
- let platform = (cliPlatformArg || '').toLowerCase();
2340
- const validPlatforms = ['android', 'ios', 'visionos'];
2485
+ // If platform wasn't specified, determine from selected build or prompt
2341
2486
  // Fallback to platform from selected build if CLI arg missing/invalid
2342
2487
  if (!validPlatforms.includes(platform)) {
2343
2488
  const selectedBuild = availableBuilds.find((build) => build.value === buildId);
@@ -2357,26 +2502,53 @@ export async function submit(cliPlatformArg, cliTrackArg, verbose = false, optio
2357
2502
  ]);
2358
2503
  platform = platformAnswer.platform;
2359
2504
  }
2360
- // Common configuration for both platforms (track)
2505
+ // Platform-specific track configuration
2361
2506
  const validTracks = ['production', 'beta', 'alpha', 'internal'];
2507
+ let trackChoices;
2508
+ if (platform === 'ios' || platform === 'visionos') {
2509
+ trackChoices = [
2510
+ { name: 'Internal testing', value: 'internal' },
2511
+ { name: 'External testing', value: 'beta' },
2512
+ ];
2513
+ }
2514
+ else if (platform === 'android') {
2515
+ trackChoices = [
2516
+ { name: 'Internal testing', value: 'internal' },
2517
+ { name: 'Closed testing', value: 'alpha' },
2518
+ { name: 'Open testing', value: 'beta' },
2519
+ { name: 'Production', value: 'production' },
2520
+ ];
2521
+ }
2522
+ else {
2523
+ // Fallback for unknown platforms
2524
+ trackChoices = [
2525
+ { name: 'Internal testing', value: 'internal' },
2526
+ { name: 'External testing', value: 'beta' },
2527
+ ];
2528
+ }
2529
+ // Show track prompt if not provided via CLI
2362
2530
  let track = (cliTrackArg || '').toLowerCase();
2363
- if (!validTracks.includes(track)) {
2531
+ if (!validTracks.includes(track) && !options?.nonInteractive) {
2364
2532
  const trackAnswer = await inquirer.prompt([
2365
2533
  {
2366
2534
  type: 'list',
2367
2535
  name: 'track',
2368
2536
  message: 'Select release track:',
2369
- choices: [
2370
- { name: 'Production', value: 'production' },
2371
- { name: 'Beta', value: 'beta' },
2372
- { name: 'Alpha', value: 'alpha' },
2373
- { name: 'Internal testing', value: 'internal' },
2374
- ],
2375
- default: 'production',
2537
+ choices: trackChoices,
2538
+ default: 'internal',
2376
2539
  },
2377
2540
  ]);
2378
2541
  track = trackAnswer.track;
2379
2542
  }
2543
+ // Validate track for platform
2544
+ if ((platform === 'ios' || platform === 'visionos') && !['internal', 'beta'].includes(track)) {
2545
+ if (options?.nonInteractive) {
2546
+ throw new Error(`Invalid track '${track}' for iOS/visionOS. Use 'internal' or 'beta'.`);
2547
+ }
2548
+ // Fall back to internal if invalid
2549
+ console.warn(`⚠️ Track '${track}' not supported for iOS/visionOS. Using 'internal'.`);
2550
+ track = 'internal';
2551
+ }
2380
2552
  // Platform-specific configuration
2381
2553
  let credentials = {};
2382
2554
  // For iOS, use config values or ask for App Store account
@@ -2415,35 +2587,49 @@ export async function submit(cliPlatformArg, cliTrackArg, verbose = false, optio
2415
2587
  };
2416
2588
  }
2417
2589
  else {
2418
- // Fall back to prompting for credentials
2590
+ // Fall back to prompting for credentials using smart prompts
2419
2591
  if (hasConfigAscCredentials && !fs.existsSync(configAscKeyPath)) {
2420
2592
  console.warn(`⚠️ ASC private key not found at: ${configAscKeyPath}`);
2421
2593
  }
2422
- const iosConfig = await inquirer.prompt([
2594
+ // Use smart prompts for ASC credentials
2595
+ const ascApiKeyIdResult = await smartPrompt({
2596
+ name: 'ascApiKeyId',
2597
+ message: 'App Store Connect (ASC) API Key ID (optional):',
2598
+ configValue: configAscKeyId,
2599
+ orgEnvVarNames: getOrgEnvVarNamesForField('ascApiKeyId'),
2600
+ availableOrgEnvVars,
2601
+ nonInteractive: options?.nonInteractive,
2602
+ required: false,
2603
+ });
2604
+ configTracker.track('ascApiKeyId', ascApiKeyIdResult.value, ascApiKeyIdResult.source);
2605
+ const ascIssuerIdResult = await smartPrompt({
2606
+ name: 'ascIssuerId',
2607
+ message: 'App Store Connect (ASC) Issuer ID (optional):',
2608
+ configValue: configAscIssuerId,
2609
+ orgEnvVarNames: getOrgEnvVarNamesForField('ascIssuerId'),
2610
+ availableOrgEnvVars,
2611
+ nonInteractive: options?.nonInteractive,
2612
+ required: false,
2613
+ });
2614
+ configTracker.track('ascIssuerId', ascIssuerIdResult.value, ascIssuerIdResult.source);
2615
+ const ascPrivateKeyPathResult = await smartPrompt({
2616
+ name: 'ascPrivateKeyPath',
2617
+ message: 'Path to App Store Connect (ASC) API key (optional):',
2618
+ configValue: configAscKeyPath,
2619
+ orgEnvVarNames: getOrgEnvVarNamesForField('ascPrivateKeyPath'),
2620
+ availableOrgEnvVars,
2621
+ nonInteractive: options?.nonInteractive,
2622
+ required: false,
2623
+ });
2624
+ configTracker.track('ascPrivateKeyPath', ascPrivateKeyPathResult.value, ascPrivateKeyPathResult.source);
2625
+ // Non-credential prompts (username, generate certificate, notes)
2626
+ const { appleId, generateCertificate, notes } = await inquirer.prompt([
2423
2627
  {
2424
2628
  type: 'input',
2425
2629
  name: 'appleId',
2426
2630
  message: 'App Store Connect username (optional):',
2427
2631
  default: '',
2428
2632
  },
2429
- {
2430
- type: 'input',
2431
- name: 'apiKeyPath',
2432
- message: 'Path to App Store Connect API key (optional):',
2433
- default: configAscKeyPath || '',
2434
- },
2435
- {
2436
- type: 'input',
2437
- name: 'ascKeyId',
2438
- message: 'App Store Connect API Key ID (optional):',
2439
- default: configAscKeyId || '',
2440
- },
2441
- {
2442
- type: 'input',
2443
- name: 'ascIssuerId',
2444
- message: 'App Store Connect Issuer ID (optional):',
2445
- default: configAscIssuerId || '',
2446
- },
2447
2633
  {
2448
2634
  type: 'confirm',
2449
2635
  name: 'generateCertificate',
@@ -2457,38 +2643,55 @@ export async function submit(cliPlatformArg, cliTrackArg, verbose = false, optio
2457
2643
  default: '',
2458
2644
  },
2459
2645
  ]);
2646
+ // Collect TestFlight groups for external testing (beta track)
2647
+ let testflightGroups;
2648
+ if (track === 'beta' && !options?.nonInteractive) {
2649
+ testflightGroups = await promptForTestFlightGroups(norrixConfig.ios?.testflightGroups);
2650
+ }
2651
+ else if (track === 'beta') {
2652
+ // Non-interactive mode: use config groups or default
2653
+ testflightGroups = norrixConfig.ios?.testflightGroups || ['External Testers'];
2654
+ }
2460
2655
  // Build iOS credentials if provided
2461
- const ascKeyPath = normalizePath(iosConfig.apiKeyPath);
2462
- if (iosConfig.ascKeyId &&
2463
- iosConfig.ascIssuerId &&
2656
+ const ascKeyPath = normalizePath(ascPrivateKeyPathResult.value);
2657
+ if (ascApiKeyIdResult.value &&
2658
+ ascIssuerIdResult.value &&
2464
2659
  ascKeyPath &&
2465
2660
  fs.existsSync(ascKeyPath)) {
2466
2661
  const ascPrivateKeyContent = fs.readFileSync(ascKeyPath, 'utf8');
2467
2662
  const ascPrivateKeyBase64 = Buffer.from(ascPrivateKeyContent).toString('base64');
2468
2663
  credentials.iosCredentials = {
2469
- ascApiKeyId: iosConfig.ascKeyId,
2470
- ascIssuerId: iosConfig.ascIssuerId,
2664
+ ascApiKeyId: ascApiKeyIdResult.value,
2665
+ ascIssuerId: ascIssuerIdResult.value,
2471
2666
  ascPrivateKey: ascPrivateKeyBase64,
2472
2667
  ...(configTeamId ? { teamId: configTeamId } : {}),
2668
+ ...(testflightGroups ? { testflightGroups: testflightGroups.join(',') } : {}),
2473
2669
  };
2474
2670
  }
2475
2671
  credentials = {
2476
2672
  ...credentials,
2477
- appleId: iosConfig.appleId,
2478
- generateCertificate: iosConfig.generateCertificate,
2479
- notes: iosConfig.notes,
2673
+ appleId,
2674
+ generateCertificate,
2675
+ notes,
2480
2676
  };
2481
2677
  }
2482
2678
  }
2483
2679
  // For Android, ask for Play Store account
2484
2680
  if (platform === 'android') {
2485
- const androidConfig = await inquirer.prompt([
2486
- {
2487
- type: 'input',
2488
- name: 'serviceAccountKeyPath',
2489
- message: 'Path to Google Play service account key (optional):',
2490
- default: '',
2491
- },
2681
+ // Use smart prompt for service account key
2682
+ // Note: playServiceAccountJsonPath is not yet in NorrixAndroidConfig interface, so using undefined for now
2683
+ const playServiceAccountJsonPathResult = await smartPrompt({
2684
+ name: 'playServiceAccountJsonPath',
2685
+ message: 'Path to Google Play service account key (optional):',
2686
+ configValue: undefined, // Not yet supported in norrix.config.ts
2687
+ orgEnvVarNames: getOrgEnvVarNamesForField('playServiceAccountJsonPath'),
2688
+ availableOrgEnvVars,
2689
+ nonInteractive: options?.nonInteractive,
2690
+ required: false,
2691
+ });
2692
+ configTracker.track('playServiceAccountJsonPath', playServiceAccountJsonPathResult.value, playServiceAccountJsonPathResult.source);
2693
+ // Non-credential prompts
2694
+ const { generateKeystore, notes } = await inquirer.prompt([
2492
2695
  {
2493
2696
  type: 'confirm',
2494
2697
  name: 'generateKeystore',
@@ -2503,9 +2706,9 @@ export async function submit(cliPlatformArg, cliTrackArg, verbose = false, optio
2503
2706
  },
2504
2707
  ]);
2505
2708
  credentials = {
2506
- serviceAccountKeyPath: androidConfig.serviceAccountKeyPath,
2507
- generateKeystore: androidConfig.generateKeystore,
2508
- notes: androidConfig.notes,
2709
+ serviceAccountKeyPath: playServiceAccountJsonPathResult.value,
2710
+ generateKeystore,
2711
+ notes,
2509
2712
  };
2510
2713
  }
2511
2714
  spinner.start(`Submitting build to ${platform === 'android' ? 'Google Play' : 'App Store'}...`);
@@ -2559,17 +2762,72 @@ export async function submit(cliPlatformArg, cliTrackArg, verbose = false, optio
2559
2762
  }
2560
2763
  console.log('✅ Your app submission request was received.');
2561
2764
  console.log(` Submission ID: ${submitId}`);
2562
- console.log(` Backend API: ${API_URL}`);
2563
- if (process.env.NORRIX_API_URL) {
2564
- console.log(` NORRIX_API_URL: ${process.env.NORRIX_API_URL}`);
2565
- }
2566
- if (dispatchedEnv) {
2567
- console.log(` Backend env: ${dispatchedEnv}`);
2765
+ // Only show backend details in dev mode
2766
+ if (CURRENT_ENV === 'dev') {
2767
+ console.log(` Backend API: ${API_URL}`);
2768
+ if (process.env.NORRIX_API_URL) {
2769
+ console.log(` NORRIX_API_URL: ${process.env.NORRIX_API_URL}`);
2770
+ }
2771
+ if (dispatchedEnv) {
2772
+ console.log(` Backend env: ${dispatchedEnv}`);
2773
+ }
2568
2774
  }
2569
2775
  console.log(` Platform: ${platform === 'android' ? 'Google Play Store' : 'Apple App Store'}`);
2570
2776
  console.log(formatVersionBuildLine(submittedVersion, submittedBuildNumber));
2571
2777
  console.log(` Track: ${track}`);
2572
2778
  console.log(` You can check the status with: norrix submit-status ${submitId}`);
2779
+ // Offer to save config if user provided new values
2780
+ const appRoot = process.cwd();
2781
+ if (!options?.nonInteractive && configTracker.shouldOfferToSave()) {
2782
+ const configExists = hasNorrixConfig(appRoot);
2783
+ const { shouldSave } = await inquirer.prompt([
2784
+ {
2785
+ type: 'confirm',
2786
+ name: 'shouldSave',
2787
+ message: configExists
2788
+ ? 'Save new credential paths to norrix.config.ts?'
2789
+ : 'Save credential paths to norrix.config.ts for future submissions?',
2790
+ default: true,
2791
+ },
2792
+ ]);
2793
+ if (shouldSave) {
2794
+ try {
2795
+ const userProvidedValues = configTracker.getUserProvidedValues();
2796
+ // Start with existing config values (if config exists)
2797
+ const existingConfig = configExists ? norrixConfig : {};
2798
+ const saveableOptions = {
2799
+ platform: platform,
2800
+ };
2801
+ // Merge: Start with existing config, then overlay user-provided values
2802
+ // iOS fields
2803
+ if (platform === 'ios') {
2804
+ saveableOptions.ascApiKeyId = userProvidedValues.ascApiKeyId || existingConfig.ios?.ascApiKeyId;
2805
+ saveableOptions.ascIssuerId = userProvidedValues.ascIssuerId || existingConfig.ios?.ascIssuerId;
2806
+ saveableOptions.ascPrivateKeyPath = userProvidedValues.ascPrivateKeyPath || existingConfig.ios?.ascPrivateKeyPath;
2807
+ // Preserve other iOS config values
2808
+ saveableOptions.teamId = existingConfig.ios?.teamId;
2809
+ saveableOptions.distributionType = existingConfig.ios?.distributionType;
2810
+ saveableOptions.p12Path = existingConfig.ios?.p12Path;
2811
+ saveableOptions.provisioningProfilePath = existingConfig.ios?.provisioningProfilePath;
2812
+ }
2813
+ // Android fields
2814
+ if (platform === 'android') {
2815
+ // Note: playServiceAccountJsonPath not yet in config interface
2816
+ // Preserve existing Android config values
2817
+ saveableOptions.keystorePath = existingConfig.android?.keystorePath;
2818
+ saveableOptions.keyAlias = existingConfig.android?.keyAlias;
2819
+ saveableOptions.distributionType = existingConfig.android?.distributionType;
2820
+ }
2821
+ const savedPath = saveNorrixConfig(appRoot, saveableOptions);
2822
+ console.log(configExists
2823
+ ? `✓ Configuration updated in ${path.basename(savedPath)}`
2824
+ : `✓ Configuration saved to ${path.basename(savedPath)}`);
2825
+ }
2826
+ catch (saveError) {
2827
+ console.warn(`Warning: Could not save config file: ${saveError.message}`);
2828
+ }
2829
+ }
2830
+ }
2573
2831
  // Restore original cwd if we changed it
2574
2832
  if (originalCwd && process.cwd() !== originalCwd) {
2575
2833
  process.chdir(originalCwd);
@@ -3024,12 +3282,15 @@ export async function update(cliPlatformArg, cliVersionArg, verbose = false, opt
3024
3282
  spinner.succeed('Update published successfully!');
3025
3283
  console.log('✅ Your update has been published.');
3026
3284
  console.log(` Update ID: ${updateId}`);
3027
- console.log(` Backend API: ${API_URL}`);
3028
- if (process.env.NORRIX_API_URL) {
3029
- console.log(` NORRIX_API_URL: ${process.env.NORRIX_API_URL}`);
3030
- }
3031
- if (dispatchedEnv) {
3032
- console.log(` Backend env: ${dispatchedEnv}`);
3285
+ // Only show backend details in dev mode
3286
+ if (CURRENT_ENV === 'dev') {
3287
+ console.log(` Backend API: ${API_URL}`);
3288
+ if (process.env.NORRIX_API_URL) {
3289
+ console.log(` NORRIX_API_URL: ${process.env.NORRIX_API_URL}`);
3290
+ }
3291
+ if (dispatchedEnv) {
3292
+ console.log(` Backend env: ${dispatchedEnv}`);
3293
+ }
3033
3294
  }
3034
3295
  console.log(formatVersionBuildLine(recordedUpdateVersion, recordedUpdateBuildNumber));
3035
3296
  console.log(` You can check the status with: norrix update-status ${updateId}`);