@norrix/cli 0.0.49 → 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';
@@ -895,7 +898,7 @@ async function getProjectName() {
895
898
  throw new Error(`Failed to get project name: ${error.message}`);
896
899
  }
897
900
  }
898
- async function getLatestSuccessfulBuildForApp(appId, platform, verbose = false) {
901
+ async function getLatestSuccessfulBuildForApp(appId, platform, verbose = false, targetVersion) {
899
902
  try {
900
903
  const response = await axios.get(`${API_URL}/build`, {
901
904
  headers: await getAuthHeaders(),
@@ -907,23 +910,28 @@ async function getLatestSuccessfulBuildForApp(appId, platform, verbose = false)
907
910
  const matching = builds.filter((b) => {
908
911
  const bPlatform = (b.platform || '').toLowerCase();
909
912
  const bAppId = b.appId || b.bundleId || b.projectName;
910
- return (b.status === 'success' &&
913
+ // Base filters: status, platform, appId
914
+ const baseMatch = b.status === 'success' &&
911
915
  bPlatform === normalizedPlatform &&
912
- bAppId === appId);
916
+ bAppId === appId;
917
+ // If a target version is specified, also filter by version
918
+ // This enables targeting specific version series for OTA updates (e.g., hotfix for 1.1.0 while 2.0.0 exists)
919
+ if (targetVersion && baseMatch) {
920
+ return b.version === targetVersion;
921
+ }
922
+ return baseMatch;
913
923
  });
914
924
  if (!matching.length)
915
925
  return undefined;
926
+ // Sort by timestamp (newest first) to get the most recently created build.
927
+ // This is more reliable than buildNumber because build numbers can reset
928
+ // when project names change or when migrating between different naming schemes.
916
929
  matching.sort((a, b) => {
917
- const aNum = Number(a.buildNumber);
918
- const bNum = Number(b.buildNumber);
919
- if (!Number.isNaN(aNum) && !Number.isNaN(bNum) && aNum !== bNum) {
920
- return aNum - bNum;
921
- }
922
- const aCreated = a.createdAt ? new Date(a.createdAt).getTime() : 0;
923
- const bCreated = b.createdAt ? new Date(b.createdAt).getTime() : 0;
924
- return aCreated - bCreated;
930
+ const aCreated = a.timestamp ? new Date(a.timestamp).getTime() : 0;
931
+ const bCreated = b.timestamp ? new Date(b.timestamp).getTime() : 0;
932
+ return bCreated - aCreated; // Descending order (newest first)
925
933
  });
926
- return matching[matching.length - 1];
934
+ return matching[0]; // Return the newest build
927
935
  }
928
936
  catch (error) {
929
937
  if (verbose) {
@@ -1438,6 +1446,10 @@ export async function build(cliPlatformArg, cliConfigurationArg, cliDistribution
1438
1446
  }
1439
1447
  return;
1440
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();
1441
1453
  let spinner;
1442
1454
  let originalCwd;
1443
1455
  try {
@@ -1479,15 +1491,15 @@ export async function build(cliPlatformArg, cliConfigurationArg, cliDistribution
1479
1491
  platform = chosenPlatform;
1480
1492
  }
1481
1493
  // 2.1 Determine configuration (CLI arg preferred, then config, otherwise prompt)
1482
- const validConfigurations = ['debug', 'release'];
1494
+ const validConfigurations = ['release', 'debug'];
1483
1495
  let configuration = (cliConfigurationArg ||
1484
1496
  norrixConfig.defaultConfiguration ||
1485
1497
  '').toLowerCase();
1486
1498
  if (!validConfigurations.includes(configuration)) {
1487
1499
  if (opts.nonInteractive) {
1488
- // Default to 'debug' in non-interactive mode if not specified
1489
- configuration = 'debug';
1490
- 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);
1491
1503
  }
1492
1504
  else {
1493
1505
  const answer = await inquirer.prompt([
@@ -1496,7 +1508,7 @@ export async function build(cliPlatformArg, cliConfigurationArg, cliDistribution
1496
1508
  name: 'configuration',
1497
1509
  message: 'Build configuration:',
1498
1510
  choices: validConfigurations,
1499
- default: norrixConfig.defaultConfiguration || 'debug',
1511
+ default: norrixConfig.defaultConfiguration || 'release',
1500
1512
  },
1501
1513
  ]);
1502
1514
  configuration = answer.configuration;
@@ -1659,6 +1671,46 @@ export async function build(cliPlatformArg, cliConfigurationArg, cliDistribution
1659
1671
  buildNumber = cliBuildNumber;
1660
1672
  logVerbose(`Using build number from CLI flag: ${buildNumber}`, verbose);
1661
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
+ }
1662
1714
  else if (platform === 'android') {
1663
1715
  const inferredAndroid = androidVersionInfo;
1664
1716
  const inferredVersionCode = inferredAndroid.buildNumber;
@@ -1715,295 +1767,342 @@ export async function build(cliPlatformArg, cliConfigurationArg, cliDistribution
1715
1767
  const configAscKeyId = norrixConfig.ios?.ascApiKeyId;
1716
1768
  const configAscIssuerId = norrixConfig.ios?.ascIssuerId;
1717
1769
  const configAscKeyPath = norrixConfig.ios?.ascPrivateKeyPath;
1718
- const resolvedTeamId = trimString(opts.teamId) || configTeamId;
1719
- const resolvedP12Path = trimString(opts.p12Path) || configP12Path;
1720
- const resolvedP12Password = trimString(opts.p12Password);
1721
- const resolvedProfilePath = trimString(opts.profilePath) || configProfilePath;
1722
1770
  const resolvedAscKeyId = trimString(opts.ascKeyId) || configAscKeyId;
1723
- const resolvedAscIssuerId = trimString(opts.ascIssuerId) || configAscIssuerId;
1724
- const resolvedAscKeyPath = trimString(opts.ascKeyPath) || configAscKeyPath;
1725
- if (opts.nonInteractive) {
1726
- // Non-interactive mode: use CLI flags and config values only
1727
- iosCredentials = {
1728
- teamId: resolvedTeamId || undefined,
1729
- p12Base64: readOptionalFileAsBase64(resolvedP12Path),
1730
- p12Password: resolvedP12Password || undefined,
1731
- mobileprovisionBase64: readOptionalFileAsBase64(resolvedProfilePath),
1732
- ascApiKeyId: resolvedAscKeyId || undefined,
1733
- ascIssuerId: resolvedAscIssuerId || undefined,
1734
- ascPrivateKey: readOptionalFileAsBase64(resolvedAscKeyPath),
1735
- };
1736
- if (resolvedTeamId) {
1737
- console.log(`Using Apple Team ID: ${resolvedTeamId}`);
1738
- }
1739
- if (resolvedAscKeyId) {
1740
- console.log(`Using ASC API Key: ${resolvedAscKeyId}`);
1741
- }
1742
- }
1743
- else {
1744
- // Interactive mode: prompt for values with CLI/config as defaults
1745
- // Flow: Team ID -> ASC Key (recommended) -> If no ASC, then .p12/.mobileprovision
1746
- const iosAnswers = await inquirer.prompt([
1747
- {
1748
- type: 'input',
1749
- name: 'teamId',
1750
- message: 'Apple Developer Team ID (required for code signing, e.g. "ABC123XYZ"):',
1751
- default: resolvedTeamId || '',
1752
- validate: (input) => {
1753
- if (!input.trim()) {
1754
- return true; // Allow empty, workflow will try to proceed without it
1755
- }
1756
- if (/^[A-Z0-9]{10}$/.test(input.trim())) {
1757
- return true;
1758
- }
1759
- return 'Team ID should be 10 alphanumeric characters (e.g. "ABC123XYZ"). Leave empty to skip.';
1760
- },
1761
- },
1762
- // 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([
1763
1803
  {
1764
1804
  type: 'confirm',
1765
- name: 'useAscKey',
1805
+ name: 'useAscKeyAnswer',
1766
1806
  message: 'Use App Store Connect API Key for auto-provisioning? (recommended)',
1767
1807
  default: Boolean(resolvedAscKeyId) || true,
1768
1808
  },
1769
- {
1770
- type: 'input',
1771
- name: 'ascApiKeyId',
1772
- message: 'ASC API Key ID:',
1773
- default: resolvedAscKeyId || '',
1774
- when: (a) => a.useAscKey,
1775
- validate: (input) => {
1776
- if (!input.trim()) {
1777
- return 'API Key ID is required when using ASC Key';
1778
- }
1779
- return true;
1780
- },
1781
- },
1782
- {
1783
- type: 'input',
1784
- name: 'ascIssuerId',
1785
- message: 'ASC Issuer ID:',
1786
- default: resolvedAscIssuerId || '',
1787
- when: (a) => a.useAscKey,
1788
- validate: (input) => {
1789
- if (!input.trim()) {
1790
- return 'Issuer ID is required when using ASC Key';
1791
- }
1792
- return true;
1793
- },
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;
1794
1831
  },
1795
- {
1796
- type: 'input',
1797
- name: 'ascPrivateKeyPath',
1798
- message: 'Path to ASC private key .p8:',
1799
- default: resolvedAscKeyPath || '',
1800
- when: (a) => a.useAscKey,
1801
- validate: (input) => {
1802
- if (!input.trim()) {
1803
- return 'Path to .p8 key file is required when using ASC Key';
1804
- }
1805
- return true;
1806
- },
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;
1807
1848
  },
1808
- // Only ask for .p12/.mobileprovision if NOT using ASC Key
1809
- {
1810
- type: 'input',
1811
- name: 'p12Path',
1812
- message: 'Path to iOS .p12 certificate (optional):',
1813
- default: resolvedP12Path || '',
1814
- 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;
1815
1865
  },
1816
- {
1817
- 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({
1818
1888
  name: 'p12Password',
1819
1889
  message: 'Password for .p12 (if any):',
1820
- mask: '*',
1821
- default: '',
1822
- when: (a) => !a.useAscKey && a.p12Path,
1823
- },
1824
- {
1825
- type: 'input',
1826
- name: 'mobileprovisionPath',
1827
- message: 'Path to provisioning profile .mobileprovision (optional):',
1828
- default: resolvedProfilePath || '',
1829
- when: (a) => !a.useAscKey,
1830
- },
1831
- ]);
1832
- // Use resolved teamId from CLI/config, or from prompt
1833
- const finalTeamId = trimString(iosAnswers.teamId) || resolvedTeamId;
1834
- iosCredentials = {
1835
- teamId: finalTeamId || undefined,
1836
- p12Base64: readOptionalFileAsBase64(iosAnswers.p12Path),
1837
- p12Password: trimString(iosAnswers.p12Password),
1838
- mobileprovisionBase64: readOptionalFileAsBase64(iosAnswers.mobileprovisionPath),
1839
- ascApiKeyId: trimString(iosAnswers.ascApiKeyId),
1840
- ascIssuerId: trimString(iosAnswers.ascIssuerId),
1841
- ascPrivateKey: readOptionalFileAsBase64(iosAnswers.ascPrivateKeyPath),
1842
- // Track paths for config saving (not sent to API)
1843
- _p12Path: trimString(iosAnswers.p12Path),
1844
- _mobileprovisionPath: trimString(iosAnswers.mobileprovisionPath),
1845
- _ascApiKeyId: trimString(iosAnswers.ascApiKeyId),
1846
- _ascIssuerId: trimString(iosAnswers.ascIssuerId),
1847
- _ascPrivateKeyPath: trimString(iosAnswers.ascPrivateKeyPath),
1848
- };
1849
- if (finalTeamId) {
1850
- 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);
1851
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`);
1852
1938
  }
1853
1939
  }
1854
1940
  else if (platform === 'android') {
1855
- // Resolve values from: CLI flags > config file > environment variables
1941
+ // Use smart prompts for Android credentials
1856
1942
  const configKeystorePath = norrixConfig.android?.keystorePath;
1857
1943
  const configKeyAlias = norrixConfig.android?.keyAlias;
1858
- const resolvedKeystorePath = trimString(opts.keystorePath) ||
1859
- configKeystorePath ||
1860
- process.env.KEYSTORE_PATH;
1861
- const resolvedKeystorePassword = trimString(opts.keystorePassword) || process.env.KEYSTORE_PASSWORD;
1862
- const resolvedKeyAlias = trimString(opts.keyAlias) ||
1863
- configKeyAlias ||
1864
- process.env.KEYSTORE_ALIAS;
1865
- const resolvedKeyPassword = trimString(opts.keyPassword) ||
1866
- process.env.KEYSTORE_ALIAS_PASSWORD ||
1867
- process.env.KEY_PASSWORD;
1868
- const resolvedPlayJsonPath = trimString(opts.playJsonPath) ||
1869
- 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
1870
2001
  if (opts.nonInteractive) {
1871
- // Non-interactive mode: use CLI flags, config, and env vars only
1872
- // All values are optional - the cloud build will use org-level credentials if not provided
1873
- androidCredentials = {
1874
- keystoreBase64: readOptionalFileAsBase64(resolvedKeystorePath),
1875
- keystorePassword: resolvedKeystorePassword || undefined,
1876
- keyAlias: resolvedKeyAlias || undefined,
1877
- keyPassword: resolvedKeyPassword || undefined,
1878
- playServiceAccountJson: readOptionalFileAsBase64(resolvedPlayJsonPath),
1879
- };
1880
- if (resolvedKeystorePath) {
1881
- logVerbose(`Using keystore from: ${resolvedKeystorePath}`, verbose);
1882
- }
1883
- if (resolvedKeyAlias) {
1884
- logVerbose(`Using key alias: ${resolvedKeyAlias}`, verbose);
1885
- }
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
+ });
1886
2013
  }
1887
2014
  else {
1888
- // Interactive mode: prompt for values with CLI/config/env as defaults
1889
- const androidAnswers = await inquirer.prompt([
1890
- {
1891
- type: 'input',
1892
- name: 'keystorePath',
1893
- message: 'Path to Android keystore .jks/.keystore (optional):',
1894
- default: resolvedKeystorePath || '',
1895
- },
1896
- {
1897
- type: 'password',
1898
- name: 'keystorePassword',
1899
- message: 'Keystore password (optional):',
1900
- mask: '*',
1901
- default: '',
1902
- },
1903
- {
1904
- type: 'input',
1905
- name: 'keyAlias',
1906
- message: 'Key alias (optional):',
1907
- default: resolvedKeyAlias || '',
1908
- },
1909
- {
1910
- type: 'password',
1911
- name: 'keyPassword',
1912
- message: 'Key password (optional):',
1913
- mask: '*',
1914
- default: '',
1915
- },
2015
+ // In interactive mode, ask if they want to provide it first
2016
+ const { hasPlayJson } = await inquirer.prompt([
1916
2017
  {
1917
2018
  type: 'confirm',
1918
2019
  name: 'hasPlayJson',
1919
2020
  message: 'Provide Google Play service account JSON? (optional, for Play operations)',
1920
2021
  default: false,
1921
2022
  },
1922
- {
1923
- type: 'input',
1924
- name: 'playJsonPath',
1925
- message: 'Path to Google Play service account JSON (optional):',
1926
- default: '',
1927
- when: (a) => a.hasPlayJson,
1928
- },
1929
2023
  ]);
1930
- androidCredentials = {
1931
- keystoreBase64: readOptionalFileAsBase64(androidAnswers.keystorePath),
1932
- keystorePassword: trimString(androidAnswers.keystorePassword),
1933
- keyAlias: trimString(androidAnswers.keyAlias),
1934
- keyPassword: trimString(androidAnswers.keyPassword),
1935
- playServiceAccountJson: readOptionalFileAsBase64(androidAnswers.playJsonPath),
1936
- };
1937
- // Track Android paths for config saving
1938
- androidCredentials._keystorePath = trimString(androidAnswers.keystorePath);
1939
- 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
+ }
1940
2037
  }
1941
- }
1942
- // Offer to save config if no norrix.config.ts exists and we collected useful values
1943
- const appRoot = process.cwd();
1944
- if (!hasNorrixConfig(appRoot) && !opts.nonInteractive) {
1945
- // Collect saveable values
1946
- const saveableOptions = {
1947
- 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,
1948
2052
  };
1949
- if (platform === 'ios' && iosCredentials) {
1950
- if (iosCredentials.teamId) {
1951
- saveableOptions.teamId = iosCredentials.teamId;
1952
- }
1953
- if (distributionType) {
1954
- saveableOptions.distributionType = distributionType;
1955
- }
1956
- // Don't save actual credential file paths since they may contain secrets
1957
- // But save paths that users can re-use
1958
- if (iosCredentials._p12Path) {
1959
- saveableOptions.p12Path = iosCredentials._p12Path;
1960
- }
1961
- if (iosCredentials._mobileprovisionPath) {
1962
- saveableOptions.provisioningProfilePath = iosCredentials._mobileprovisionPath;
1963
- }
1964
- // Save ASC API Key details for future builds
1965
- if (iosCredentials._ascApiKeyId) {
1966
- saveableOptions.ascApiKeyId = iosCredentials._ascApiKeyId;
1967
- }
1968
- if (iosCredentials._ascIssuerId) {
1969
- saveableOptions.ascIssuerId = iosCredentials._ascIssuerId;
1970
- }
1971
- if (iosCredentials._ascPrivateKeyPath) {
1972
- saveableOptions.ascPrivateKeyPath = iosCredentials._ascPrivateKeyPath;
1973
- }
2053
+ if (keystorePathResult.value) {
2054
+ logVerbose(`Using keystore from: ${keystorePathResult.value}`, verbose);
1974
2055
  }
1975
- if (platform === 'android' && androidCredentials) {
1976
- if (androidCredentials._keystorePath) {
1977
- saveableOptions.keystorePath = androidCredentials._keystorePath;
1978
- }
1979
- if (androidCredentials._keyAlias) {
1980
- saveableOptions.keyAlias = androidCredentials._keyAlias;
1981
- }
2056
+ if (keystorePathResult.source === 'org-env') {
2057
+ console.log(`Using organization's Android credentials`);
1982
2058
  }
1983
- // Only offer to save if we have something useful
1984
- const hasSaveableValues = saveableOptions.teamId ||
1985
- saveableOptions.distributionType ||
1986
- saveableOptions.p12Path ||
1987
- saveableOptions.provisioningProfilePath ||
1988
- saveableOptions.keystorePath ||
1989
- saveableOptions.ascApiKeyId;
1990
- if (hasSaveableValues) {
1991
- const { shouldSave } = await inquirer.prompt([
1992
- {
1993
- type: 'confirm',
1994
- name: 'shouldSave',
1995
- message: 'Save these settings to norrix.config.ts for future builds?',
1996
- default: true,
1997
- },
1998
- ]);
1999
- if (shouldSave) {
2000
- try {
2001
- const savedPath = saveNorrixConfig(appRoot, saveableOptions);
2002
- 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);
2003
2092
  }
2004
- catch (saveError) {
2005
- 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);
2006
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}`);
2007
2106
  }
2008
2107
  }
2009
2108
  }
@@ -2176,12 +2275,15 @@ export async function build(cliPlatformArg, cliConfigurationArg, cliDistribution
2176
2275
  spinner.succeed('Build started successfully!');
2177
2276
  console.log('✅ Your app is being built on Norrix.');
2178
2277
  console.log(` Build ID: ${buildId}`);
2179
- console.log(` Backend API: ${API_URL}`);
2180
- if (process.env.NORRIX_API_URL) {
2181
- console.log(` NORRIX_API_URL: ${process.env.NORRIX_API_URL}`);
2182
- }
2183
- if (dispatchedEnv) {
2184
- 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
+ }
2185
2287
  }
2186
2288
  console.log(formatVersionBuildLine(recordedVersion, recordedBuildNumber));
2187
2289
  console.log(` Platform: ${platform}`);
@@ -2234,6 +2336,41 @@ export async function build(cliPlatformArg, cliConfigurationArg, cliDistribution
2234
2336
  }
2235
2337
  }
2236
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
+ }
2237
2374
  /**
2238
2375
  * Submit command implementation
2239
2376
  * Submits the built app to app stores via the Next.js API gateway
@@ -2270,6 +2407,10 @@ export async function submit(cliPlatformArg, cliTrackArg, verbose = false, optio
2270
2407
  }
2271
2408
  // Load Norrix config file from the resolved app directory
2272
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();
2273
2414
  if (verbose) {
2274
2415
  console.log(`[submit] Resolved workspace context: ${resolved.workspaceContext.type}`);
2275
2416
  console.log(`[submit] App root: ${resolved.workspaceContext.appRoot}`);
@@ -2277,8 +2418,15 @@ export async function submit(cliPlatformArg, cliTrackArg, verbose = false, optio
2277
2418
  if (norrixConfig.ios?.ascApiKeyId) {
2278
2419
  console.log(`[submit] Found ASC credentials in norrix.config.ts`);
2279
2420
  }
2421
+ if (availableOrgEnvVars.length > 0) {
2422
+ console.log(`[submit] Available org env vars: ${availableOrgEnvVars.join(', ')}`);
2423
+ }
2280
2424
  }
2281
- // 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
2282
2430
  spinner.text = 'Fetching available builds...';
2283
2431
  let availableBuilds = [];
2284
2432
  try {
@@ -2288,9 +2436,13 @@ export async function submit(cliPlatformArg, cliTrackArg, verbose = false, optio
2288
2436
  if (buildsResponse.data.builds &&
2289
2437
  Array.isArray(buildsResponse.data.builds)) {
2290
2438
  // Filter for successful builds
2291
- availableBuilds = buildsResponse.data.builds
2292
- .filter((build) => build.status === 'success')
2293
- .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) => {
2294
2446
  return {
2295
2447
  name: `${build.projectName} (${build.platform} - ${build.version} (${build.buildNumber}) - ${build.id})`,
2296
2448
  value: build.id,
@@ -2304,7 +2456,7 @@ export async function submit(cliPlatformArg, cliTrackArg, verbose = false, optio
2304
2456
  catch (error) {
2305
2457
  spinner.warn("Could not fetch builds. You'll need to enter the Build ID manually.");
2306
2458
  }
2307
- // 2. Gather submission details from user
2459
+ // 3. Gather submission details from user
2308
2460
  spinner.stop();
2309
2461
  // Ask for build ID
2310
2462
  let buildId;
@@ -2330,9 +2482,7 @@ export async function submit(cliPlatformArg, cliTrackArg, verbose = false, optio
2330
2482
  ]);
2331
2483
  buildId = buildIdAnswer.buildId;
2332
2484
  }
2333
- // Determine platform, preferring CLI arg, then build metadata, then prompt
2334
- let platform = (cliPlatformArg || '').toLowerCase();
2335
- const validPlatforms = ['android', 'ios', 'visionos'];
2485
+ // If platform wasn't specified, determine from selected build or prompt
2336
2486
  // Fallback to platform from selected build if CLI arg missing/invalid
2337
2487
  if (!validPlatforms.includes(platform)) {
2338
2488
  const selectedBuild = availableBuilds.find((build) => build.value === buildId);
@@ -2352,26 +2502,53 @@ export async function submit(cliPlatformArg, cliTrackArg, verbose = false, optio
2352
2502
  ]);
2353
2503
  platform = platformAnswer.platform;
2354
2504
  }
2355
- // Common configuration for both platforms (track)
2505
+ // Platform-specific track configuration
2356
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
2357
2530
  let track = (cliTrackArg || '').toLowerCase();
2358
- if (!validTracks.includes(track)) {
2531
+ if (!validTracks.includes(track) && !options?.nonInteractive) {
2359
2532
  const trackAnswer = await inquirer.prompt([
2360
2533
  {
2361
2534
  type: 'list',
2362
2535
  name: 'track',
2363
2536
  message: 'Select release track:',
2364
- choices: [
2365
- { name: 'Production', value: 'production' },
2366
- { name: 'Beta', value: 'beta' },
2367
- { name: 'Alpha', value: 'alpha' },
2368
- { name: 'Internal testing', value: 'internal' },
2369
- ],
2370
- default: 'production',
2537
+ choices: trackChoices,
2538
+ default: 'internal',
2371
2539
  },
2372
2540
  ]);
2373
2541
  track = trackAnswer.track;
2374
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
+ }
2375
2552
  // Platform-specific configuration
2376
2553
  let credentials = {};
2377
2554
  // For iOS, use config values or ask for App Store account
@@ -2410,35 +2587,49 @@ export async function submit(cliPlatformArg, cliTrackArg, verbose = false, optio
2410
2587
  };
2411
2588
  }
2412
2589
  else {
2413
- // Fall back to prompting for credentials
2590
+ // Fall back to prompting for credentials using smart prompts
2414
2591
  if (hasConfigAscCredentials && !fs.existsSync(configAscKeyPath)) {
2415
2592
  console.warn(`⚠️ ASC private key not found at: ${configAscKeyPath}`);
2416
2593
  }
2417
- 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([
2418
2627
  {
2419
2628
  type: 'input',
2420
2629
  name: 'appleId',
2421
2630
  message: 'App Store Connect username (optional):',
2422
2631
  default: '',
2423
2632
  },
2424
- {
2425
- type: 'input',
2426
- name: 'apiKeyPath',
2427
- message: 'Path to App Store Connect API key (optional):',
2428
- default: configAscKeyPath || '',
2429
- },
2430
- {
2431
- type: 'input',
2432
- name: 'ascKeyId',
2433
- message: 'App Store Connect API Key ID (optional):',
2434
- default: configAscKeyId || '',
2435
- },
2436
- {
2437
- type: 'input',
2438
- name: 'ascIssuerId',
2439
- message: 'App Store Connect Issuer ID (optional):',
2440
- default: configAscIssuerId || '',
2441
- },
2442
2633
  {
2443
2634
  type: 'confirm',
2444
2635
  name: 'generateCertificate',
@@ -2452,38 +2643,55 @@ export async function submit(cliPlatformArg, cliTrackArg, verbose = false, optio
2452
2643
  default: '',
2453
2644
  },
2454
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
+ }
2455
2655
  // Build iOS credentials if provided
2456
- const ascKeyPath = normalizePath(iosConfig.apiKeyPath);
2457
- if (iosConfig.ascKeyId &&
2458
- iosConfig.ascIssuerId &&
2656
+ const ascKeyPath = normalizePath(ascPrivateKeyPathResult.value);
2657
+ if (ascApiKeyIdResult.value &&
2658
+ ascIssuerIdResult.value &&
2459
2659
  ascKeyPath &&
2460
2660
  fs.existsSync(ascKeyPath)) {
2461
2661
  const ascPrivateKeyContent = fs.readFileSync(ascKeyPath, 'utf8');
2462
2662
  const ascPrivateKeyBase64 = Buffer.from(ascPrivateKeyContent).toString('base64');
2463
2663
  credentials.iosCredentials = {
2464
- ascApiKeyId: iosConfig.ascKeyId,
2465
- ascIssuerId: iosConfig.ascIssuerId,
2664
+ ascApiKeyId: ascApiKeyIdResult.value,
2665
+ ascIssuerId: ascIssuerIdResult.value,
2466
2666
  ascPrivateKey: ascPrivateKeyBase64,
2467
2667
  ...(configTeamId ? { teamId: configTeamId } : {}),
2668
+ ...(testflightGroups ? { testflightGroups: testflightGroups.join(',') } : {}),
2468
2669
  };
2469
2670
  }
2470
2671
  credentials = {
2471
2672
  ...credentials,
2472
- appleId: iosConfig.appleId,
2473
- generateCertificate: iosConfig.generateCertificate,
2474
- notes: iosConfig.notes,
2673
+ appleId,
2674
+ generateCertificate,
2675
+ notes,
2475
2676
  };
2476
2677
  }
2477
2678
  }
2478
2679
  // For Android, ask for Play Store account
2479
2680
  if (platform === 'android') {
2480
- const androidConfig = await inquirer.prompt([
2481
- {
2482
- type: 'input',
2483
- name: 'serviceAccountKeyPath',
2484
- message: 'Path to Google Play service account key (optional):',
2485
- default: '',
2486
- },
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([
2487
2695
  {
2488
2696
  type: 'confirm',
2489
2697
  name: 'generateKeystore',
@@ -2498,9 +2706,9 @@ export async function submit(cliPlatformArg, cliTrackArg, verbose = false, optio
2498
2706
  },
2499
2707
  ]);
2500
2708
  credentials = {
2501
- serviceAccountKeyPath: androidConfig.serviceAccountKeyPath,
2502
- generateKeystore: androidConfig.generateKeystore,
2503
- notes: androidConfig.notes,
2709
+ serviceAccountKeyPath: playServiceAccountJsonPathResult.value,
2710
+ generateKeystore,
2711
+ notes,
2504
2712
  };
2505
2713
  }
2506
2714
  spinner.start(`Submitting build to ${platform === 'android' ? 'Google Play' : 'App Store'}...`);
@@ -2554,17 +2762,72 @@ export async function submit(cliPlatformArg, cliTrackArg, verbose = false, optio
2554
2762
  }
2555
2763
  console.log('✅ Your app submission request was received.');
2556
2764
  console.log(` Submission ID: ${submitId}`);
2557
- console.log(` Backend API: ${API_URL}`);
2558
- if (process.env.NORRIX_API_URL) {
2559
- console.log(` NORRIX_API_URL: ${process.env.NORRIX_API_URL}`);
2560
- }
2561
- if (dispatchedEnv) {
2562
- 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
+ }
2563
2774
  }
2564
2775
  console.log(` Platform: ${platform === 'android' ? 'Google Play Store' : 'Apple App Store'}`);
2565
2776
  console.log(formatVersionBuildLine(submittedVersion, submittedBuildNumber));
2566
2777
  console.log(` Track: ${track}`);
2567
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
+ }
2568
2831
  // Restore original cwd if we changed it
2569
2832
  if (originalCwd && process.cwd() !== originalCwd) {
2570
2833
  process.chdir(originalCwd);
@@ -2868,7 +3131,10 @@ export async function update(cliPlatformArg, cliVersionArg, verbose = false, opt
2868
3131
  // the user that this OTA will require a new store binary and let
2869
3132
  // them decide whether to proceed or cancel.
2870
3133
  try {
2871
- const latestBuild = await getLatestSuccessfulBuildForApp(appId, platform, verbose);
3134
+ // When a specific version is provided, look for builds matching that version.
3135
+ // This enables targeting specific version series for OTA updates (e.g., hotfix for 1.1.0 while 2.0.0 exists)
3136
+ const latestBuild = await getLatestSuccessfulBuildForApp(appId, platform, verbose, version // Pass the target version to filter builds
3137
+ );
2872
3138
  if (latestBuild?.id) {
2873
3139
  const baseFp = await fetchBuildFingerprint(latestBuild.id, verbose);
2874
3140
  if (baseFp) {
@@ -3016,12 +3282,15 @@ export async function update(cliPlatformArg, cliVersionArg, verbose = false, opt
3016
3282
  spinner.succeed('Update published successfully!');
3017
3283
  console.log('✅ Your update has been published.');
3018
3284
  console.log(` Update ID: ${updateId}`);
3019
- console.log(` Backend API: ${API_URL}`);
3020
- if (process.env.NORRIX_API_URL) {
3021
- console.log(` NORRIX_API_URL: ${process.env.NORRIX_API_URL}`);
3022
- }
3023
- if (dispatchedEnv) {
3024
- 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
+ }
3025
3294
  }
3026
3295
  console.log(formatVersionBuildLine(recordedUpdateVersion, recordedUpdateBuildNumber));
3027
3296
  console.log(` You can check the status with: norrix update-status ${updateId}`);