@norrix/cli 0.0.24 → 0.0.25

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.
@@ -9,6 +9,8 @@ import archiver from 'archiver';
9
9
  // import FormData from 'form-data';
10
10
  import { configureAmplify, loadCliEnvFiles } from './amplify-config.js';
11
11
  import { computeFingerprint, writeRuntimeFingerprintFile } from './fingerprinting.js';
12
+ import { loadNorrixConfig, hasNorrixConfig, saveNorrixConfig } from './config.js';
13
+ import { detectWorkspaceContext, getNxProjectDependencies, getWorkspaceDependenciesFallback, createWorkspaceManifest, logWorkspaceContext, isAtWorkspaceRoot, discoverNativeScriptApps, getWorkspaceContextForApp, } from './workspace.js';
12
14
  import { signIn as amplifySignIn, signOut as amplifySignOut, getCurrentUser, fetchAuthSession, } from 'aws-amplify/auth';
13
15
  import crypto from 'crypto';
14
16
  import { Amplify } from 'aws-amplify';
@@ -635,20 +637,106 @@ function getAndroidVersionFromAppGradle() {
635
637
  }
636
638
  }
637
639
  /**
638
- * Creates a zip file of the current directory (NativeScript project)
640
+ * Resolve workspace context for a command.
641
+ *
642
+ * When running from workspace root:
643
+ * - If --project is provided, use that project
644
+ * - Otherwise, discover NativeScript apps and prompt for selection
645
+ *
646
+ * When running from within an app directory:
647
+ * - Use the current directory's context
648
+ *
649
+ * @param projectArg - Optional project name from --project flag
650
+ * @param spinner - Optional spinner to stop before prompting
651
+ */
652
+ async function resolveWorkspaceContext(projectArg, spinner) {
653
+ const originalCwd = process.cwd();
654
+ // If we're at workspace root (has nx.json but no nativescript.config)
655
+ if (isAtWorkspaceRoot()) {
656
+ // If --project was provided, use it
657
+ if (projectArg) {
658
+ const ctx = getWorkspaceContextForApp(projectArg);
659
+ if (!ctx) {
660
+ const apps = discoverNativeScriptApps();
661
+ const appNames = apps.map((a) => a.name).join(', ');
662
+ throw new Error(`Project '${projectArg}' not found in workspace. Available NativeScript apps: ${appNames || 'none'}`);
663
+ }
664
+ // Change to the app directory for subsequent operations
665
+ process.chdir(ctx.appRoot);
666
+ return { workspaceContext: ctx, originalCwd };
667
+ }
668
+ // Discover NativeScript apps and prompt for selection
669
+ const apps = discoverNativeScriptApps();
670
+ if (apps.length === 0) {
671
+ throw new Error('No NativeScript apps found in this workspace. ' +
672
+ 'Run this command from within a NativeScript app directory, or ensure your apps have nativescript.config.ts files.');
673
+ }
674
+ if (apps.length === 1) {
675
+ // Only one app, use it automatically
676
+ if (spinner)
677
+ spinner.stop();
678
+ console.log(`Found NativeScript app: ${apps[0].name} (${apps[0].path})`);
679
+ const ctx = getWorkspaceContextForApp(apps[0].name);
680
+ if (ctx) {
681
+ process.chdir(ctx.appRoot);
682
+ return { workspaceContext: ctx, originalCwd };
683
+ }
684
+ }
685
+ // Multiple apps - stop spinner before prompting
686
+ if (spinner)
687
+ spinner.stop();
688
+ // Multiple apps - prompt for selection
689
+ const { selectedApp } = await inquirer.prompt([
690
+ {
691
+ type: 'list',
692
+ name: 'selectedApp',
693
+ message: 'Select a NativeScript app:',
694
+ choices: apps.map((app) => ({
695
+ name: `${app.name} (${app.path})`,
696
+ value: app.name,
697
+ })),
698
+ pageSize: 15, // Show more items to avoid scroll issues
699
+ loop: false, // Don't loop back to start when at end
700
+ },
701
+ ]);
702
+ const ctx = getWorkspaceContextForApp(selectedApp);
703
+ if (!ctx) {
704
+ throw new Error(`Failed to get context for selected app: ${selectedApp}`);
705
+ }
706
+ process.chdir(ctx.appRoot);
707
+ return { workspaceContext: ctx, originalCwd };
708
+ }
709
+ // Not at workspace root - use current directory
710
+ const ctx = detectWorkspaceContext();
711
+ return { workspaceContext: ctx, originalCwd };
712
+ }
713
+ /**
714
+ * Creates a zip file of the current directory (NativeScript project).
715
+ * For Nx workspaces, this includes the app, dependent libs, and workspace config files.
716
+ * For standalone projects, this zips the current directory.
639
717
  */
640
- async function zipProject(projectName, isUpdate = false) {
718
+ async function zipProject(projectName, isUpdate = false, verbose = false) {
719
+ const workspaceCtx = detectWorkspaceContext();
720
+ if (workspaceCtx.type === 'nx') {
721
+ return zipWorkspaceProject(projectName, workspaceCtx, isUpdate, verbose);
722
+ }
723
+ else {
724
+ return zipStandaloneProject(projectName, workspaceCtx, isUpdate);
725
+ }
726
+ }
727
+ /**
728
+ * Zip a standalone NativeScript project (original behavior)
729
+ */
730
+ async function zipStandaloneProject(projectName, workspaceCtx, isUpdate = false) {
641
731
  return new Promise((resolve, reject) => {
642
732
  const outputPath = path.join(process.cwd(), `${projectName}.zip`);
643
733
  const output = fs.createWriteStream(outputPath);
644
734
  const archive = archiver('zip', {
645
- zlib: { level: 9 }, // Compression level
735
+ zlib: { level: 9 },
646
736
  });
647
- // Listen for all archive data to be written
648
737
  output.on('close', () => {
649
- resolve(outputPath);
738
+ resolve({ zipPath: outputPath, workspaceContext: workspaceCtx });
650
739
  });
651
- // Listen for warnings and errors
652
740
  archive.on('warning', (err) => {
653
741
  if (err.code === 'ENOENT') {
654
742
  console.warn('Archive warning:', err);
@@ -660,9 +748,8 @@ async function zipProject(projectName, isUpdate = false) {
660
748
  archive.on('error', (err) => {
661
749
  reject(err);
662
750
  });
663
- // Pipe archive data to the file
664
751
  archive.pipe(output);
665
- // Determine the primary app directory (prefer appPath from NativeScript config)
752
+ // Determine the primary app directory
666
753
  const nsAppPath = getNativeScriptAppPath();
667
754
  const nsAppDir = nsAppPath
668
755
  ? path.join(process.cwd(), nsAppPath)
@@ -682,18 +769,12 @@ async function zipProject(projectName, isUpdate = false) {
682
769
  }
683
770
  else {
684
771
  console.warn('Warning: app directory not found in the project root');
685
- const checked = [nsAppDir, srcDir, appDir].filter(Boolean).join(', ');
686
- console.warn(`Checked locations: ${checked}`);
687
- console.log('Creating an empty app directory in the zip');
688
772
  }
689
- // For both builds/updates, include App_Resources and exclude node_modules.
690
- // For OTA updates specifically, we also exclude the runtime fingerprint
691
- // file, since it reflects the native store binary, not the OTA payload.
692
773
  const ignorePatterns = [
693
- 'node_modules/**', // Always exclude node_modules
694
- '*.zip', // Exclude existing zip files
695
- 'platforms/**', // Exclude platform-specific directories
696
- 'hooks/**', // Exclude hooks directory
774
+ 'node_modules/**',
775
+ '*.zip',
776
+ 'platforms/**',
777
+ 'hooks/**',
697
778
  ];
698
779
  if (isUpdate) {
699
780
  ignorePatterns.push('**/assets/norrix.fingerprint.json');
@@ -702,7 +783,132 @@ async function zipProject(projectName, isUpdate = false) {
702
783
  cwd: process.cwd(),
703
784
  ignore: ignorePatterns,
704
785
  });
705
- // Finalize the archive
786
+ archive.finalize();
787
+ });
788
+ }
789
+ /**
790
+ * Zip an Nx workspace project including the app and its dependencies
791
+ */
792
+ async function zipWorkspaceProject(projectName, workspaceCtx, isUpdate = false, verbose = false) {
793
+ return new Promise((resolve, reject) => {
794
+ const outputPath = path.join(workspaceCtx.appRoot, `${projectName}.zip`);
795
+ const output = fs.createWriteStream(outputPath);
796
+ const archive = archiver('zip', {
797
+ zlib: { level: 9 },
798
+ });
799
+ output.on('close', () => {
800
+ resolve({ zipPath: outputPath, workspaceContext: workspaceCtx });
801
+ });
802
+ archive.on('warning', (err) => {
803
+ if (err.code === 'ENOENT') {
804
+ if (verbose)
805
+ console.warn('Archive warning:', err);
806
+ }
807
+ else {
808
+ reject(err);
809
+ }
810
+ });
811
+ archive.on('error', (err) => {
812
+ reject(err);
813
+ });
814
+ archive.pipe(output);
815
+ logWorkspaceContext(workspaceCtx, verbose);
816
+ // Get workspace dependencies using Nx CLI (preferred) or fallback
817
+ let deps;
818
+ if (workspaceCtx.projectName) {
819
+ deps = getNxProjectDependencies(workspaceCtx.projectName, workspaceCtx.workspaceRoot, verbose);
820
+ }
821
+ if (!deps) {
822
+ if (verbose) {
823
+ console.log('[workspace] Using fallback dependency detection');
824
+ }
825
+ deps = getWorkspaceDependenciesFallback(workspaceCtx, verbose);
826
+ }
827
+ // Create manifest for CI
828
+ const manifest = createWorkspaceManifest(workspaceCtx, deps);
829
+ archive.append(JSON.stringify(manifest, null, 2), {
830
+ name: '.norrix/manifest.json',
831
+ });
832
+ // Base ignore patterns for the entire workspace
833
+ const ignorePatterns = [
834
+ '**/node_modules/**',
835
+ '**/*.zip',
836
+ '**/platforms/**',
837
+ '**/dist/**',
838
+ '**/.git/**',
839
+ '**/hooks/**',
840
+ // Exclude other apps (not the current one)
841
+ 'apps/**',
842
+ ];
843
+ if (isUpdate) {
844
+ ignorePatterns.push('**/assets/norrix.fingerprint.json');
845
+ }
846
+ // 1. Add the app itself at its relative workspace path
847
+ console.log(`Adding app: ${workspaceCtx.relativeAppPath}`);
848
+ archive.directory(workspaceCtx.appRoot, workspaceCtx.relativeAppPath, (entry) => {
849
+ // Filter out node_modules, platforms, etc.
850
+ if (entry.name.includes('node_modules') ||
851
+ entry.name.includes('platforms') ||
852
+ entry.name.endsWith('.zip')) {
853
+ return false;
854
+ }
855
+ if (isUpdate && entry.name.includes('norrix.fingerprint.json')) {
856
+ return false;
857
+ }
858
+ return entry;
859
+ });
860
+ // 2. Add dependent libs
861
+ if (deps.libPaths.length > 0) {
862
+ console.log(`Adding ${deps.libPaths.length} library dependencies`);
863
+ for (const libPath of deps.libPaths) {
864
+ const absoluteLibPath = path.join(workspaceCtx.workspaceRoot, libPath);
865
+ if (fs.existsSync(absoluteLibPath)) {
866
+ if (verbose) {
867
+ console.log(` - ${libPath}`);
868
+ }
869
+ archive.directory(absoluteLibPath, libPath, (entry) => {
870
+ if (entry.name.includes('node_modules')) {
871
+ return false;
872
+ }
873
+ return entry;
874
+ });
875
+ }
876
+ }
877
+ }
878
+ // 3. Add root config files
879
+ console.log('Adding workspace root configuration files');
880
+ for (const configFile of deps.rootConfigs) {
881
+ const configPath = path.join(workspaceCtx.workspaceRoot, configFile);
882
+ if (fs.existsSync(configPath)) {
883
+ if (verbose) {
884
+ console.log(` - ${configFile}`);
885
+ }
886
+ archive.file(configPath, { name: configFile });
887
+ }
888
+ }
889
+ // 4. Add tools directory if it exists and is referenced
890
+ for (const toolPath of deps.toolPaths) {
891
+ const absoluteToolPath = path.join(workspaceCtx.workspaceRoot, toolPath);
892
+ if (fs.existsSync(absoluteToolPath) && fs.statSync(absoluteToolPath).isDirectory()) {
893
+ console.log(`Adding tools: ${toolPath}`);
894
+ archive.directory(absoluteToolPath, toolPath, (entry) => {
895
+ if (entry.name.includes('node_modules')) {
896
+ return false;
897
+ }
898
+ return entry;
899
+ });
900
+ }
901
+ }
902
+ // 5. Add asset paths if they exist
903
+ for (const assetPath of deps.assetPaths) {
904
+ const absoluteAssetPath = path.join(workspaceCtx.workspaceRoot, assetPath);
905
+ if (fs.existsSync(absoluteAssetPath) && fs.statSync(absoluteAssetPath).isDirectory()) {
906
+ if (verbose) {
907
+ console.log(`Adding assets: ${assetPath}`);
908
+ }
909
+ archive.directory(absoluteAssetPath, assetPath);
910
+ }
911
+ }
706
912
  archive.finalize();
707
913
  });
708
914
  }
@@ -710,16 +916,33 @@ async function zipProject(projectName, isUpdate = false) {
710
916
  * Build command implementation
711
917
  * Uploads project to S3 and triggers build via the Next.js API gateway -> WarpBuild
712
918
  */
713
- export async function build(cliPlatformArg, cliConfigurationArg, cliDistributionArg, verbose = false) {
919
+ export async function build(cliPlatformArg, cliConfigurationArg, cliDistributionArg, verbose = false, options // string for backwards compatibility with old projectArg
920
+ ) {
921
+ // Normalize options - support both new object and legacy string projectArg
922
+ const opts = typeof options === 'string' ? { project: options } : (options || {});
714
923
  ensureInitialized();
715
924
  let spinner;
925
+ let originalCwd;
716
926
  try {
717
927
  spinner = ora('Preparing app for building...');
718
928
  spinner.start();
929
+ // 0. Resolve workspace context (handles --project and prompting)
930
+ const resolved = await resolveWorkspaceContext(opts.project, spinner);
931
+ originalCwd = resolved.originalCwd;
932
+ const workspaceCtx = resolved.workspaceContext;
933
+ // Restart spinner after potential prompts
934
+ if (!spinner.isSpinning) {
935
+ spinner.start('Preparing app for building...');
936
+ }
937
+ // Load Norrix config file if present
938
+ const norrixConfig = await loadNorrixConfig(process.cwd());
939
+ if (workspaceCtx.type === 'nx' && verbose) {
940
+ logWorkspaceContext(workspaceCtx, verbose);
941
+ }
719
942
  // 1. Get project info
720
943
  const projectName = await getProjectName();
721
- // 2. Determine platform (CLI arg preferred, otherwise prompt)
722
- let platform = (cliPlatformArg || '').toLowerCase();
944
+ // 2. Determine platform (CLI arg preferred, then config, otherwise prompt)
945
+ let platform = (cliPlatformArg || norrixConfig.defaultPlatform || '').toLowerCase();
723
946
  const validPlatforms = ['android', 'ios', 'visionos'];
724
947
  spinner.stop();
725
948
  if (!validPlatforms.includes(platform)) {
@@ -772,14 +995,20 @@ export async function build(cliPlatformArg, cliConfigurationArg, cliDistribution
772
995
  }
773
996
  return undefined;
774
997
  };
775
- // 2.2 iOS distribution type (release only)
998
+ // 2.2 iOS distribution type (release only): CLI arg > config file > prompt
776
999
  let distributionType;
777
1000
  if (platform === 'ios' && configuration === 'release') {
1001
+ // Try CLI arg first
778
1002
  distributionType = normalizeIosDistribution(cliDistributionArg);
779
1003
  if (!distributionType && cliDistributionArg) {
780
1004
  throw new Error(`Invalid iOS distribution type '${cliDistributionArg}'. Use 'appstore', 'adhoc', or 'enterprise'.`);
781
1005
  }
782
- if (!distributionType) {
1006
+ // Fall back to config file
1007
+ if (!distributionType && norrixConfig.ios?.distributionType) {
1008
+ distributionType = norrixConfig.ios.distributionType;
1009
+ }
1010
+ // Prompt if still not set (unless non-interactive mode)
1011
+ if (!distributionType && !opts.nonInteractive) {
783
1012
  const { distribution } = await inquirer.prompt([
784
1013
  {
785
1014
  type: 'list',
@@ -795,6 +1024,10 @@ export async function build(cliPlatformArg, cliConfigurationArg, cliDistribution
795
1024
  ]);
796
1025
  distributionType = distribution;
797
1026
  }
1027
+ // Default to appstore in non-interactive mode if nothing else provided
1028
+ if (!distributionType) {
1029
+ distributionType = 'appstore';
1030
+ }
798
1031
  }
799
1032
  const appleVersionInfo = platform === 'ios' || platform === 'visionos'
800
1033
  ? getAppleVersionFromInfoPlist(platform)
@@ -865,7 +1098,31 @@ export async function build(cliPlatformArg, cliConfigurationArg, cliDistribution
865
1098
  if (configuration === 'release') {
866
1099
  spinner.stop();
867
1100
  if (platform === 'ios') {
1101
+ // Determine Team ID from: CLI flag > config file > prompt
1102
+ const configTeamId = norrixConfig.ios?.teamId;
1103
+ const resolvedTeamId = opts.teamId || configTeamId;
1104
+ // If we have teamId from CLI or config, and non-interactive mode, skip prompts
1105
+ const shouldPromptForTeamId = !resolvedTeamId && !opts.nonInteractive;
868
1106
  const iosAnswers = await inquirer.prompt([
1107
+ {
1108
+ type: 'input',
1109
+ name: 'teamId',
1110
+ message: 'Apple Developer Team ID (required for code signing, e.g. "ABC123XYZ"):',
1111
+ default: resolvedTeamId || '',
1112
+ when: () => shouldPromptForTeamId || !resolvedTeamId,
1113
+ validate: (input) => {
1114
+ // Team ID is optional if user provides their own p12/profile
1115
+ // but we'll strongly recommend it
1116
+ if (!input.trim()) {
1117
+ return true; // Allow empty, workflow will try to proceed without it
1118
+ }
1119
+ // Basic validation: Apple Team IDs are typically 10 alphanumeric chars
1120
+ if (/^[A-Z0-9]{10}$/.test(input.trim())) {
1121
+ return true;
1122
+ }
1123
+ return 'Team ID should be 10 alphanumeric characters (e.g. "ABC123XYZ"). Leave empty to skip.';
1124
+ },
1125
+ },
869
1126
  {
870
1127
  type: 'input',
871
1128
  name: 'p12Path',
@@ -888,7 +1145,7 @@ export async function build(cliPlatformArg, cliConfigurationArg, cliDistribution
888
1145
  {
889
1146
  type: 'confirm',
890
1147
  name: 'hasAscKey',
891
- message: 'Provide App Store Connect API Key? (optional, for App Store operations)',
1148
+ message: 'Provide App Store Connect API Key? (optional, for auto-provisioning)',
892
1149
  default: false,
893
1150
  },
894
1151
  {
@@ -912,23 +1169,24 @@ export async function build(cliPlatformArg, cliConfigurationArg, cliDistribution
912
1169
  default: '',
913
1170
  when: (a) => a.hasAscKey,
914
1171
  },
915
- {
916
- type: 'input',
917
- name: 'ascTeamId',
918
- message: 'Apple Developer Team ID (optional, required for API-key provisioning):',
919
- default: '',
920
- when: (a) => a.hasAscKey,
921
- },
922
1172
  ]);
1173
+ // Use resolved teamId from CLI/config, or from prompt
1174
+ const finalTeamId = resolvedTeamId || iosAnswers.teamId?.trim();
923
1175
  iosCredentials = {
1176
+ teamId: finalTeamId || undefined,
924
1177
  p12Base64: readOptionalFileAsBase64(iosAnswers.p12Path),
925
1178
  p12Password: iosAnswers.p12Password || undefined,
926
1179
  mobileprovisionBase64: readOptionalFileAsBase64(iosAnswers.mobileprovisionPath),
927
1180
  ascApiKeyId: iosAnswers.ascApiKeyId || undefined,
928
1181
  ascIssuerId: iosAnswers.ascIssuerId || undefined,
929
1182
  ascPrivateKey: readOptionalFileAsBase64(iosAnswers.ascPrivateKeyPath),
930
- ascTeamId: iosAnswers.ascTeamId || undefined,
1183
+ // Track paths for config saving (not sent to API)
1184
+ _p12Path: iosAnswers.p12Path || undefined,
1185
+ _mobileprovisionPath: iosAnswers.mobileprovisionPath || undefined,
931
1186
  };
1187
+ if (finalTeamId) {
1188
+ console.log(`Using Apple Team ID: ${finalTeamId}`);
1189
+ }
932
1190
  }
933
1191
  else if (platform === 'android') {
934
1192
  const androidAnswers = await inquirer.prompt([
@@ -979,6 +1237,66 @@ export async function build(cliPlatformArg, cliConfigurationArg, cliDistribution
979
1237
  keyPassword: androidAnswers.keyPassword || undefined,
980
1238
  playServiceAccountJson: readOptionalFileAsBase64(androidAnswers.playJsonPath),
981
1239
  };
1240
+ // Track Android paths for config saving
1241
+ androidCredentials._keystorePath = androidAnswers.keystorePath || undefined;
1242
+ androidCredentials._keyAlias = androidAnswers.keyAlias || undefined;
1243
+ }
1244
+ // Offer to save config if no norrix.config.ts exists and we collected useful values
1245
+ const appRoot = process.cwd();
1246
+ if (!hasNorrixConfig(appRoot) && !opts.nonInteractive) {
1247
+ // Collect saveable values
1248
+ const saveableOptions = {
1249
+ platform: platform,
1250
+ };
1251
+ if (platform === 'ios' && iosCredentials) {
1252
+ if (iosCredentials.teamId) {
1253
+ saveableOptions.teamId = iosCredentials.teamId;
1254
+ }
1255
+ if (distributionType) {
1256
+ saveableOptions.distributionType = distributionType;
1257
+ }
1258
+ // Don't save actual credential file paths since they may contain secrets
1259
+ // But save paths that users can re-use
1260
+ if (iosCredentials._p12Path) {
1261
+ saveableOptions.p12Path = iosCredentials._p12Path;
1262
+ }
1263
+ if (iosCredentials._mobileprovisionPath) {
1264
+ saveableOptions.provisioningProfilePath = iosCredentials._mobileprovisionPath;
1265
+ }
1266
+ }
1267
+ if (platform === 'android' && androidCredentials) {
1268
+ if (androidCredentials._keystorePath) {
1269
+ saveableOptions.keystorePath = androidCredentials._keystorePath;
1270
+ }
1271
+ if (androidCredentials._keyAlias) {
1272
+ saveableOptions.keyAlias = androidCredentials._keyAlias;
1273
+ }
1274
+ }
1275
+ // Only offer to save if we have something useful
1276
+ const hasSaveableValues = saveableOptions.teamId ||
1277
+ saveableOptions.distributionType ||
1278
+ saveableOptions.p12Path ||
1279
+ saveableOptions.provisioningProfilePath ||
1280
+ saveableOptions.keystorePath;
1281
+ if (hasSaveableValues) {
1282
+ const { shouldSave } = await inquirer.prompt([
1283
+ {
1284
+ type: 'confirm',
1285
+ name: 'shouldSave',
1286
+ message: 'Save these settings to norrix.config.ts for future builds?',
1287
+ default: true,
1288
+ },
1289
+ ]);
1290
+ if (shouldSave) {
1291
+ try {
1292
+ const savedPath = saveNorrixConfig(appRoot, saveableOptions);
1293
+ console.log(`✓ Configuration saved to ${path.basename(savedPath)}`);
1294
+ }
1295
+ catch (saveError) {
1296
+ console.warn(`Warning: Could not save config file: ${saveError.message}`);
1297
+ }
1298
+ }
1299
+ }
982
1300
  }
983
1301
  spinner.start('Creating project archive...');
984
1302
  }
@@ -990,8 +1308,8 @@ export async function build(cliPlatformArg, cliConfigurationArg, cliDistribution
990
1308
  });
991
1309
  writeRuntimeFingerprintFile(projectRoot, fingerprint, platform);
992
1310
  spinner.start('Creating project archive...');
993
- // 3. Zip the project
994
- const zipPath = await zipProject(projectName, false);
1311
+ // 3. Zip the project (workspace-aware)
1312
+ const { zipPath, workspaceContext } = await zipProject(projectName, false, verbose);
995
1313
  spinner.text = 'Project archive created';
996
1314
  // 4. Upload the project zip to S3
997
1315
  spinner.text = 'Working...';
@@ -1018,6 +1336,12 @@ export async function build(cliPlatformArg, cliConfigurationArg, cliDistribution
1018
1336
  catch {
1019
1337
  inferredAppId = undefined;
1020
1338
  }
1339
+ // Include workspace info for CI to properly navigate the project structure
1340
+ const workspaceInfo = workspaceContext.type === 'nx' ? {
1341
+ workspaceType: workspaceContext.type,
1342
+ appPath: workspaceContext.relativeAppPath,
1343
+ projectName: workspaceContext.projectName,
1344
+ } : undefined;
1021
1345
  const response = await axios.post(`${API_URL}/build`, {
1022
1346
  projectName,
1023
1347
  appId: inferredAppId,
@@ -1031,6 +1355,8 @@ export async function build(cliPlatformArg, cliConfigurationArg, cliDistribution
1031
1355
  fingerprint,
1032
1356
  // Provide the relative key (without public/) – the workflow prepends public/
1033
1357
  s3Key: s3KeyRel,
1358
+ // Workspace context for Nx monorepos
1359
+ ...(workspaceInfo ? { workspace: workspaceInfo } : {}),
1034
1360
  // Only include raw credentials if not encrypted
1035
1361
  ...(encryptedSecrets ? { encryptedSecrets } : {}),
1036
1362
  ...(!encryptedSecrets && iosCredentials ? { iosCredentials } : {}),
@@ -1091,8 +1417,21 @@ export async function build(cliPlatformArg, cliConfigurationArg, cliDistribution
1091
1417
  : 'App Store'}`);
1092
1418
  }
1093
1419
  console.log(` You can check the status with: norrix build-status ${buildId}`);
1420
+ // Restore original cwd if we changed it
1421
+ if (originalCwd && process.cwd() !== originalCwd) {
1422
+ process.chdir(originalCwd);
1423
+ }
1094
1424
  }
1095
1425
  catch (error) {
1426
+ // Restore original cwd if we changed it
1427
+ if (originalCwd && process.cwd() !== originalCwd) {
1428
+ try {
1429
+ process.chdir(originalCwd);
1430
+ }
1431
+ catch {
1432
+ // Ignore chdir errors during error handling
1433
+ }
1434
+ }
1096
1435
  const apiMessage = (error?.response?.data &&
1097
1436
  (error.response.data.error || error.response.data.message)) ||
1098
1437
  undefined;
@@ -1383,12 +1722,27 @@ export async function submit(cliPlatformArg, cliTrackArg, verbose = false) {
1383
1722
  * Update command implementation
1384
1723
  * Publishes over-the-air updates to deployed apps via the Next.js API gateway
1385
1724
  */
1386
- export async function update(cliPlatformArg, cliVersionArg, verbose = false) {
1725
+ export async function update(cliPlatformArg, cliVersionArg, verbose = false, options // string for backwards compatibility with old projectArg
1726
+ ) {
1727
+ // Normalize options - support both new object and legacy string projectArg
1728
+ const opts = typeof options === 'string' ? { project: options } : (options || {});
1387
1729
  ensureInitialized();
1388
1730
  let spinner;
1731
+ let originalCwd;
1389
1732
  try {
1390
1733
  spinner = ora('Preparing over-the-air update...');
1391
1734
  spinner.start();
1735
+ // 0. Resolve workspace context (handles --project and prompting)
1736
+ const resolved = await resolveWorkspaceContext(opts.project, spinner);
1737
+ originalCwd = resolved.originalCwd;
1738
+ const workspaceCtx = resolved.workspaceContext;
1739
+ // Restart spinner after potential prompts
1740
+ if (!spinner.isSpinning) {
1741
+ spinner.start('Preparing over-the-air update...');
1742
+ }
1743
+ if (workspaceCtx.type === 'nx' && verbose) {
1744
+ logWorkspaceContext(workspaceCtx, verbose);
1745
+ }
1392
1746
  // Normalize and/or ask for platform first (CLI arg takes precedence if valid)
1393
1747
  let platform = (cliPlatformArg || '').toLowerCase();
1394
1748
  const validPlatforms = ['android', 'ios', 'visionos'];
@@ -1424,27 +1778,45 @@ export async function update(cliPlatformArg, cliVersionArg, verbose = false) {
1424
1778
  // Ask for app ID and version first
1425
1779
  spinner.stop();
1426
1780
  const cliVersion = (cliVersionArg || '').trim();
1427
- const baseAnswers = await inquirer.prompt([
1428
- {
1429
- type: 'input',
1430
- name: 'appId',
1431
- message: 'Enter the App ID to update:',
1432
- default: inferredAppId || '',
1433
- validate: (input) => input.length > 0 || 'App ID is required',
1434
- },
1435
- {
1436
- type: 'input',
1437
- name: 'version',
1438
- message: inferredVersion
1439
- ? `Update version (${inferredVersion}, enter to accept):`
1440
- : 'Update version:',
1441
- default: inferredVersion,
1442
- when: () => !cliVersion,
1443
- validate: (input) => input.length > 0 || 'Version is required',
1444
- },
1445
- ]);
1446
- const appId = baseAnswers.appId;
1447
- const version = (cliVersion || baseAnswers.version);
1781
+ // Resolve appId: CLI flag → inferred → prompt
1782
+ let appId;
1783
+ if (opts.appId) {
1784
+ appId = opts.appId;
1785
+ }
1786
+ else {
1787
+ const { appId: promptedAppId } = await inquirer.prompt([
1788
+ {
1789
+ type: 'input',
1790
+ name: 'appId',
1791
+ message: 'Enter the App ID to update:',
1792
+ default: inferredAppId || '',
1793
+ validate: (input) => input.length > 0 || 'App ID is required',
1794
+ },
1795
+ ]);
1796
+ appId = promptedAppId;
1797
+ }
1798
+ // Resolve version: CLI arg → inferred → prompt
1799
+ let version;
1800
+ if (cliVersion) {
1801
+ version = cliVersion;
1802
+ }
1803
+ else if (inferredVersion && opts.nonInteractive) {
1804
+ version = inferredVersion;
1805
+ }
1806
+ else {
1807
+ const { version: promptedVersion } = await inquirer.prompt([
1808
+ {
1809
+ type: 'input',
1810
+ name: 'version',
1811
+ message: inferredVersion
1812
+ ? `Update version (${inferredVersion}, enter to accept):`
1813
+ : 'Update version:',
1814
+ default: inferredVersion,
1815
+ validate: (input) => input.length > 0 || 'Version is required',
1816
+ },
1817
+ ]);
1818
+ version = promptedVersion;
1819
+ }
1448
1820
  // Ask the server what the next buildNumber would be for this app so we
1449
1821
  // can present a sensible default in the prompt, matching what the API
1450
1822
  // will auto-increment to if left blank.
@@ -1468,38 +1840,51 @@ export async function update(cliPlatformArg, cliVersionArg, verbose = false) {
1468
1840
  : platform === 'android'
1469
1841
  ? androidVersionInfo.buildNumber
1470
1842
  : undefined;
1471
- const { buildNumber: promptedBuildNumber, notes } = await inquirer.prompt([
1472
- {
1473
- type: 'input',
1474
- name: 'buildNumber',
1475
- message: (() => {
1476
- if (serverSuggestedBuildNumber) {
1477
- return `Update build number (${serverSuggestedBuildNumber}, enter to accept or override; blank to auto increment from server):`;
1478
- }
1479
- if (localInferredBuildNumber) {
1480
- return `Update build number (${localInferredBuildNumber}, enter to auto increment from server):`;
1481
- }
1482
- return 'Update build number (leave blank to auto increment from server):';
1483
- })(),
1484
- default: serverSuggestedBuildNumber || localInferredBuildNumber || '',
1485
- validate: (input) => {
1486
- const val = String(input).trim();
1487
- if (!val)
1488
- return true; // allow blank -> server will auto-increment
1489
- if (!/^\d+$/.test(val)) {
1490
- return 'Build number must be a positive integer or blank to auto-increment';
1491
- }
1492
- return true;
1843
+ // Resolve buildNumber: CLI flag server suggestion → local inferred → prompt
1844
+ let buildNumber;
1845
+ let notes = '';
1846
+ if (opts.buildNumber) {
1847
+ buildNumber = opts.buildNumber;
1848
+ }
1849
+ else if (opts.nonInteractive) {
1850
+ // In non-interactive mode, use server suggestion or leave undefined for auto-increment
1851
+ buildNumber = serverSuggestedBuildNumber || localInferredBuildNumber || undefined;
1852
+ }
1853
+ else {
1854
+ const buildPromptAnswers = await inquirer.prompt([
1855
+ {
1856
+ type: 'input',
1857
+ name: 'buildNumber',
1858
+ message: (() => {
1859
+ if (serverSuggestedBuildNumber) {
1860
+ return `Update build number (${serverSuggestedBuildNumber}, enter to accept or override; blank to auto increment from server):`;
1861
+ }
1862
+ if (localInferredBuildNumber) {
1863
+ return `Update build number (${localInferredBuildNumber}, enter to auto increment from server):`;
1864
+ }
1865
+ return 'Update build number (leave blank to auto increment from server):';
1866
+ })(),
1867
+ default: serverSuggestedBuildNumber || localInferredBuildNumber || '',
1868
+ validate: (input) => {
1869
+ const val = String(input).trim();
1870
+ if (!val)
1871
+ return true; // allow blank -> server will auto-increment
1872
+ if (!/^\d+$/.test(val)) {
1873
+ return 'Build number must be a positive integer or blank to auto-increment';
1874
+ }
1875
+ return true;
1876
+ },
1493
1877
  },
1494
- },
1495
- {
1496
- type: 'input',
1497
- name: 'notes',
1498
- message: 'Release notes (optional):',
1499
- default: '',
1500
- },
1501
- ]);
1502
- const buildNumber = String(promptedBuildNumber || '').trim() || undefined;
1878
+ {
1879
+ type: 'input',
1880
+ name: 'notes',
1881
+ message: 'Release notes (optional):',
1882
+ default: '',
1883
+ },
1884
+ ]);
1885
+ buildNumber = String(buildPromptAnswers.buildNumber || '').trim() || undefined;
1886
+ notes = buildPromptAnswers.notes || '';
1887
+ }
1503
1888
  // Check the app directory structure before packaging
1504
1889
  const srcAppDir = path.join(process.cwd(), 'src', 'app');
1505
1890
  const appDir = path.join(process.cwd(), 'app');
@@ -1576,9 +1961,9 @@ export async function update(cliPlatformArg, cliVersionArg, verbose = false) {
1576
1961
  // fingerprint JSON under the app source tree is the
1577
1962
  // single source of truth for OTA compatibility.
1578
1963
  spinner.start('Packaging for over-the-air update...');
1579
- // Create the update bundle - pass true to include node_modules for updates
1964
+ // Create the update bundle (workspace-aware) - pass true to include node_modules for updates
1580
1965
  const projectName = await getProjectName();
1581
- const zipPath = await zipProject(projectName, true);
1966
+ const { zipPath, workspaceContext } = await zipProject(projectName, true, verbose);
1582
1967
  spinner.text = 'Uploading update to Norrix cloud storage...';
1583
1968
  const fileBuffer = fs.readFileSync(zipPath);
1584
1969
  const updateFolder = `update-${Date.now()}`;
@@ -1586,6 +1971,12 @@ export async function update(cliPlatformArg, cliVersionArg, verbose = false) {
1586
1971
  const s3KeyRel = `updates/${updateFolder}/${appId}-${safeVersion}.zip`;
1587
1972
  await putObjectToStorage(`public/${s3KeyRel}`, fileBuffer);
1588
1973
  spinner.text = 'Upload complete. Starting update...';
1974
+ // Include workspace info for CI to properly navigate the project structure
1975
+ const workspaceInfo = workspaceContext.type === 'nx' ? {
1976
+ workspaceType: workspaceContext.type,
1977
+ appPath: workspaceContext.relativeAppPath,
1978
+ projectName: workspaceContext.projectName,
1979
+ } : undefined;
1589
1980
  const response = await axios.post(`${API_URL}/update`, {
1590
1981
  appId,
1591
1982
  platform,
@@ -1595,6 +1986,8 @@ export async function update(cliPlatformArg, cliVersionArg, verbose = false) {
1595
1986
  fingerprint,
1596
1987
  // Provide the relative key (without public/). Consumers will prepend public/
1597
1988
  s3Key: s3KeyRel,
1989
+ // Workspace context for Nx monorepos
1990
+ ...(workspaceInfo ? { workspace: workspaceInfo } : {}),
1598
1991
  }, {
1599
1992
  headers: {
1600
1993
  'Content-Type': 'application/json',
@@ -1644,8 +2037,21 @@ export async function update(cliPlatformArg, cliVersionArg, verbose = false) {
1644
2037
  }
1645
2038
  console.log(formatVersionBuildLine(recordedUpdateVersion, recordedUpdateBuildNumber));
1646
2039
  console.log(` You can check the status with: norrix update-status ${updateId}`);
2040
+ // Restore original cwd if we changed it
2041
+ if (originalCwd && process.cwd() !== originalCwd) {
2042
+ process.chdir(originalCwd);
2043
+ }
1647
2044
  }
1648
2045
  catch (error) {
2046
+ // Restore original cwd if we changed it
2047
+ if (originalCwd && process.cwd() !== originalCwd) {
2048
+ try {
2049
+ process.chdir(originalCwd);
2050
+ }
2051
+ catch {
2052
+ // Ignore chdir errors during error handling
2053
+ }
2054
+ }
1649
2055
  const apiMessage = (error?.response?.data &&
1650
2056
  (error.response.data.error || error.response.data.message)) ||
1651
2057
  undefined;