@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.
- package/dist/lib/ascii-art.d.ts +8 -0
- package/dist/lib/ascii-art.js +219 -0
- package/dist/lib/ascii-art.js.map +1 -0
- package/dist/lib/commands.js +616 -347
- package/dist/lib/commands.js.map +1 -1
- package/dist/lib/config-tracker.d.ts +41 -0
- package/dist/lib/config-tracker.js +58 -0
- package/dist/lib/config-tracker.js.map +1 -0
- package/dist/lib/config.d.ts +8 -0
- package/dist/lib/config.js.map +1 -1
- package/dist/lib/org-env-helper.d.ts +30 -0
- package/dist/lib/org-env-helper.js +78 -0
- package/dist/lib/org-env-helper.js.map +1 -0
- package/dist/lib/smart-prompt.d.ts +79 -0
- package/dist/lib/smart-prompt.js +131 -0
- package/dist/lib/smart-prompt.js.map +1 -0
- package/package.json +1 -1
package/dist/lib/commands.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
918
|
-
const
|
|
919
|
-
|
|
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[
|
|
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 = ['
|
|
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 '
|
|
1489
|
-
configuration = '
|
|
1490
|
-
logVerbose('No configuration specified, defaulting to
|
|
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 || '
|
|
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
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
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: '
|
|
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
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
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
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
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
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
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
|
-
//
|
|
1941
|
+
// Use smart prompts for Android credentials
|
|
1856
1942
|
const configKeystorePath = norrixConfig.android?.keystorePath;
|
|
1857
1943
|
const configKeyAlias = norrixConfig.android?.keyAlias;
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
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
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
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
|
-
//
|
|
1889
|
-
const
|
|
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
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
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
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
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 (
|
|
1950
|
-
|
|
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 (
|
|
1976
|
-
|
|
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
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
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
|
-
|
|
2005
|
-
|
|
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
|
-
|
|
2180
|
-
if (
|
|
2181
|
-
console.log(`
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
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.
|
|
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
|
-
|
|
2292
|
-
.filter((build) => build.status === 'success')
|
|
2293
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
2457
|
-
if (
|
|
2458
|
-
|
|
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:
|
|
2465
|
-
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
|
|
2473
|
-
generateCertificate
|
|
2474
|
-
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
|
-
|
|
2481
|
-
|
|
2482
|
-
|
|
2483
|
-
|
|
2484
|
-
|
|
2485
|
-
|
|
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:
|
|
2502
|
-
generateKeystore
|
|
2503
|
-
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
|
-
|
|
2558
|
-
if (
|
|
2559
|
-
console.log(`
|
|
2560
|
-
|
|
2561
|
-
|
|
2562
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3020
|
-
if (
|
|
3021
|
-
console.log(`
|
|
3022
|
-
|
|
3023
|
-
|
|
3024
|
-
|
|
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}`);
|