@norrix/cli 0.0.24 → 0.0.26

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,14 +9,18 @@ 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, detectNxBuildConfigurations, } 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';
15
17
  import { PROD_DEFAULTS } from './prod-defaults.js';
16
18
  import { DEV_DEFAULTS } from './dev-defaults.js';
19
+ import { clearSelectedOrgId, getSelectedOrgId, setSelectedOrgId } from './cli-settings.js';
17
20
  let CURRENT_ENV = 'prod';
18
21
  let CURRENT_DEFAULTS = PROD_DEFAULTS;
19
22
  let API_URL = PROD_DEFAULTS.apiUrl;
23
+ let CURRENT_ORG_ID;
20
24
  let IS_INITIALIZED = false;
21
25
  function defaultsForEnv(env) {
22
26
  return env === 'dev' ? DEV_DEFAULTS : PROD_DEFAULTS;
@@ -38,6 +42,10 @@ export function initNorrixCli(env = 'prod') {
38
42
  CURRENT_DEFAULTS = defaultsForEnv(env);
39
43
  configureAmplify(env);
40
44
  API_URL = process.env.NORRIX_API_URL || CURRENT_DEFAULTS.apiUrl;
45
+ // Load persisted org selection for this env + API URL profile (if any).
46
+ // Allow per-invocation override via NORRIX_ORG_ID env var.
47
+ const envOrg = (process.env.NORRIX_ORG_ID ?? '').toString().trim();
48
+ CURRENT_ORG_ID = envOrg || getSelectedOrgId(env, API_URL);
41
49
  IS_INITIALIZED = true;
42
50
  }
43
51
  function ensureInitialized() {
@@ -48,19 +56,168 @@ function ensureInitialized() {
48
56
  /**
49
57
  * Return Authorization header containing the current Cognito ID token (if signed in).
50
58
  */
51
- async function getAuthHeaders() {
59
+ async function getAuthHeaders(options) {
52
60
  ensureInitialized();
61
+ const headers = {};
53
62
  try {
54
63
  const session = await fetchAuthSession();
55
64
  const idToken = session.tokens?.idToken?.toString();
56
65
  if (idToken) {
57
- return { Authorization: `Bearer ${idToken}` };
66
+ headers.Authorization = `Bearer ${idToken}`;
58
67
  }
59
68
  }
60
69
  catch (_) {
61
70
  /* not signed in */
62
71
  }
63
- return {};
72
+ if (options?.includeOrg !== false && CURRENT_ORG_ID) {
73
+ headers['X-Norrix-Org-Id'] = CURRENT_ORG_ID;
74
+ }
75
+ return headers;
76
+ }
77
+ function setCurrentOrgId(orgId) {
78
+ const v = (orgId ?? '').toString().trim();
79
+ CURRENT_ORG_ID = v ? v : undefined;
80
+ }
81
+ async function fetchOrganizations(verbose = false) {
82
+ ensureInitialized();
83
+ try {
84
+ const res = await axios.get(`${API_URL}/orgs`, {
85
+ headers: await getAuthHeaders({ includeOrg: false }),
86
+ });
87
+ const organizations = Array.isArray(res.data?.organizations)
88
+ ? res.data.organizations
89
+ : [];
90
+ const selectedOrganizationId = res.data?.selectedOrganizationId
91
+ ? String(res.data.selectedOrganizationId)
92
+ : undefined;
93
+ return { organizations, selectedOrganizationId };
94
+ }
95
+ catch (err) {
96
+ if (verbose) {
97
+ console.error('--- Verbose error details (orgs fetch) ---');
98
+ console.error(err);
99
+ if (err?.response) {
100
+ console.error('Axios response status:', err.response.status);
101
+ console.error('Axios response data:', err.response.data);
102
+ }
103
+ }
104
+ throw err;
105
+ }
106
+ }
107
+ async function ensureOrgSelected(params) {
108
+ ensureInitialized();
109
+ const explicit = (params.orgIdArg ?? process.env.NORRIX_ORG_ID ?? '')
110
+ .toString()
111
+ .trim();
112
+ if (explicit) {
113
+ setCurrentOrgId(explicit);
114
+ return explicit;
115
+ }
116
+ const stored = getSelectedOrgId(CURRENT_ENV, API_URL);
117
+ if (!params.requireSelection) {
118
+ if (stored)
119
+ setCurrentOrgId(stored);
120
+ return stored;
121
+ }
122
+ // Validate stored selection (and discover orgs for prompting).
123
+ const { organizations } = await fetchOrganizations(Boolean(params.verbose));
124
+ const normalizedOrgs = organizations.filter((o) => o && o.id);
125
+ if (stored && normalizedOrgs.some((o) => o.id === stored)) {
126
+ setCurrentOrgId(stored);
127
+ return stored;
128
+ }
129
+ if (stored) {
130
+ clearSelectedOrgId(CURRENT_ENV, API_URL);
131
+ }
132
+ if (normalizedOrgs.length === 1) {
133
+ const only = normalizedOrgs[0];
134
+ setSelectedOrgId(CURRENT_ENV, API_URL, only.id);
135
+ setCurrentOrgId(only.id);
136
+ return only.id;
137
+ }
138
+ if (params.nonInteractive) {
139
+ throw new Error('No organization selected. Use --org <orgId> or run `norrix orgs select`.');
140
+ }
141
+ if (!normalizedOrgs.length) {
142
+ throw new Error('No organizations found for this user.');
143
+ }
144
+ const choices = normalizedOrgs.map((o) => {
145
+ const suffix = o.id.length > 8 ? o.id.slice(-8) : o.id;
146
+ return {
147
+ name: `${o.name} (${o.role}) • …${suffix}`,
148
+ value: o.id,
149
+ };
150
+ });
151
+ const { orgId } = await inquirer.prompt([
152
+ {
153
+ type: 'list',
154
+ name: 'orgId',
155
+ message: params.promptMessage || 'Select organization:',
156
+ choices,
157
+ },
158
+ ]);
159
+ const selected = String(orgId || '').trim();
160
+ if (!selected) {
161
+ throw new Error('Organization selection cancelled.');
162
+ }
163
+ setSelectedOrgId(CURRENT_ENV, API_URL, selected);
164
+ setCurrentOrgId(selected);
165
+ return selected;
166
+ }
167
+ export async function orgsList(verbose = false) {
168
+ ensureInitialized();
169
+ try {
170
+ const { organizations, selectedOrganizationId } = await fetchOrganizations(verbose);
171
+ if (!organizations.length) {
172
+ console.log('No organizations found.');
173
+ return;
174
+ }
175
+ console.log('Organizations:');
176
+ for (const o of organizations) {
177
+ const selectedMark = selectedOrganizationId && o.id === selectedOrganizationId ? ' (selected)' : '';
178
+ console.log(`- ${o.name} [${o.role}] ${o.id}${selectedMark}`);
179
+ }
180
+ }
181
+ catch (err) {
182
+ ora().fail(`Failed to list organizations: ${err?.message || err}`);
183
+ if (verbose && err?.response) {
184
+ console.error('Axios response status:', err.response.status);
185
+ console.error('Axios response data:', err.response.data);
186
+ }
187
+ }
188
+ }
189
+ export async function orgsSelect(verbose = false) {
190
+ ensureInitialized();
191
+ try {
192
+ await ensureOrgSelected({
193
+ requireSelection: true,
194
+ nonInteractive: false,
195
+ verbose,
196
+ promptMessage: 'Select default organization for this environment:',
197
+ });
198
+ if (CURRENT_ORG_ID) {
199
+ console.log(`✅ Selected organization: ${CURRENT_ORG_ID}`);
200
+ }
201
+ }
202
+ catch (err) {
203
+ ora().fail(`Failed to select organization: ${err?.message || err}`);
204
+ if (verbose && err?.response) {
205
+ console.error('Axios response status:', err.response.status);
206
+ console.error('Axios response data:', err.response.data);
207
+ }
208
+ }
209
+ }
210
+ export async function orgsCurrent() {
211
+ ensureInitialized();
212
+ const envOrg = (process.env.NORRIX_ORG_ID ?? '').toString().trim();
213
+ const stored = getSelectedOrgId(CURRENT_ENV, API_URL);
214
+ const current = envOrg || stored;
215
+ if (!current) {
216
+ console.log('No default organization selected for this environment.');
217
+ console.log('Run `norrix orgs select` or pass `--org <orgId>`.');
218
+ return;
219
+ }
220
+ console.log(`Current organization for ${CURRENT_ENV} (${API_URL}): ${current}`);
64
221
  }
65
222
  /**
66
223
  * Norrix CLI Command Implementations
@@ -78,6 +235,16 @@ async function getAuthHeaders() {
78
235
  // Get dirname equivalent in ESM
79
236
  const __filename = fileURLToPath(import.meta.url);
80
237
  const __dirname = path.dirname(__filename);
238
+ /**
239
+ * Safely trim a string value, returning undefined for empty/null values.
240
+ * Handles both CLI args and config file values to ensure no trailing/leading whitespace.
241
+ */
242
+ function trimString(input) {
243
+ if (input == null)
244
+ return undefined;
245
+ const trimmed = String(input).trim();
246
+ return trimmed || undefined;
247
+ }
81
248
  function normalizePath(input) {
82
249
  if (!input)
83
250
  return undefined;
@@ -635,20 +802,105 @@ function getAndroidVersionFromAppGradle() {
635
802
  }
636
803
  }
637
804
  /**
638
- * Creates a zip file of the current directory (NativeScript project)
805
+ * Resolve workspace context for a command.
806
+ *
807
+ * When running from workspace root:
808
+ * - If --project is provided, use that project
809
+ * - Otherwise, discover NativeScript apps and prompt for selection
810
+ *
811
+ * When running from within an app directory:
812
+ * - Use the current directory's context
813
+ *
814
+ * @param projectArg - Optional project name from --project flag
815
+ * @param spinner - Optional spinner to stop before prompting
639
816
  */
640
- async function zipProject(projectName, isUpdate = false) {
817
+ async function resolveWorkspaceContext(projectArg, spinner) {
818
+ const originalCwd = process.cwd();
819
+ // If we're at workspace root (has nx.json but no nativescript.config)
820
+ if (isAtWorkspaceRoot()) {
821
+ // If --project was provided, use it
822
+ if (projectArg) {
823
+ const ctx = getWorkspaceContextForApp(projectArg);
824
+ if (!ctx) {
825
+ const apps = discoverNativeScriptApps();
826
+ const appNames = apps.map((a) => a.name).join(', ');
827
+ throw new Error(`Project '${projectArg}' not found in workspace. Available NativeScript apps: ${appNames || 'none'}`);
828
+ }
829
+ // Change to the app directory for subsequent operations
830
+ process.chdir(ctx.appRoot);
831
+ return { workspaceContext: ctx, originalCwd };
832
+ }
833
+ // Discover NativeScript apps and prompt for selection
834
+ const apps = discoverNativeScriptApps();
835
+ if (apps.length === 0) {
836
+ throw new Error('No NativeScript apps found in this workspace. ' +
837
+ 'Run this command from within a NativeScript app directory, or ensure your apps have nativescript.config.ts files.');
838
+ }
839
+ if (apps.length === 1) {
840
+ // Only one app, use it automatically
841
+ if (spinner)
842
+ spinner.stop();
843
+ console.log(`Found NativeScript app: ${apps[0].name} (${apps[0].path})`);
844
+ const ctx = getWorkspaceContextForApp(apps[0].name);
845
+ if (ctx) {
846
+ process.chdir(ctx.appRoot);
847
+ return { workspaceContext: ctx, originalCwd };
848
+ }
849
+ }
850
+ // Multiple apps - stop spinner before prompting
851
+ if (spinner)
852
+ spinner.stop();
853
+ // Multiple apps - prompt for selection
854
+ const { selectedApp } = await inquirer.prompt([
855
+ {
856
+ type: 'list',
857
+ name: 'selectedApp',
858
+ message: 'Select a NativeScript app:',
859
+ choices: apps.map((app) => ({
860
+ name: `${app.name} (${app.path})`,
861
+ value: app.name,
862
+ })),
863
+ pageSize: 15, // Show more items to avoid scroll issues
864
+ loop: false, // Don't loop back to start when at end
865
+ },
866
+ ]);
867
+ const ctx = getWorkspaceContextForApp(selectedApp);
868
+ if (!ctx) {
869
+ throw new Error(`Failed to get context for selected app: ${selectedApp}`);
870
+ }
871
+ process.chdir(ctx.appRoot);
872
+ return { workspaceContext: ctx, originalCwd };
873
+ }
874
+ // Not at workspace root - use current directory
875
+ const ctx = detectWorkspaceContext();
876
+ return { workspaceContext: ctx, originalCwd };
877
+ }
878
+ /**
879
+ * Creates a zip file of the current directory (NativeScript project).
880
+ * For Nx workspaces, this includes the app, dependent libs, and workspace config files.
881
+ * For standalone projects, this zips the current directory.
882
+ */
883
+ async function zipProject(projectName, workspaceCtx, isUpdate = false, verbose = false) {
884
+ if (workspaceCtx.type === 'nx') {
885
+ return zipWorkspaceProject(projectName, workspaceCtx, isUpdate, verbose);
886
+ }
887
+ else {
888
+ return zipStandaloneProject(projectName, workspaceCtx, isUpdate);
889
+ }
890
+ }
891
+ /**
892
+ * Zip a standalone NativeScript project (original behavior)
893
+ */
894
+ async function zipStandaloneProject(projectName, workspaceCtx, isUpdate = false) {
641
895
  return new Promise((resolve, reject) => {
642
896
  const outputPath = path.join(process.cwd(), `${projectName}.zip`);
643
897
  const output = fs.createWriteStream(outputPath);
644
898
  const archive = archiver('zip', {
645
- zlib: { level: 9 }, // Compression level
899
+ zlib: { level: 9 },
646
900
  });
647
- // Listen for all archive data to be written
648
901
  output.on('close', () => {
649
- resolve(outputPath);
902
+ resolve({ zipPath: outputPath, workspaceContext: workspaceCtx });
650
903
  });
651
- // Listen for warnings and errors
652
904
  archive.on('warning', (err) => {
653
905
  if (err.code === 'ENOENT') {
654
906
  console.warn('Archive warning:', err);
@@ -660,9 +912,8 @@ async function zipProject(projectName, isUpdate = false) {
660
912
  archive.on('error', (err) => {
661
913
  reject(err);
662
914
  });
663
- // Pipe archive data to the file
664
915
  archive.pipe(output);
665
- // Determine the primary app directory (prefer appPath from NativeScript config)
916
+ // Determine the primary app directory
666
917
  const nsAppPath = getNativeScriptAppPath();
667
918
  const nsAppDir = nsAppPath
668
919
  ? path.join(process.cwd(), nsAppPath)
@@ -682,18 +933,12 @@ async function zipProject(projectName, isUpdate = false) {
682
933
  }
683
934
  else {
684
935
  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
936
  }
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
937
  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
938
+ 'node_modules/**',
939
+ '*.zip',
940
+ 'platforms/**',
941
+ 'hooks/**',
697
942
  ];
698
943
  if (isUpdate) {
699
944
  ignorePatterns.push('**/assets/norrix.fingerprint.json');
@@ -702,7 +947,217 @@ async function zipProject(projectName, isUpdate = false) {
702
947
  cwd: process.cwd(),
703
948
  ignore: ignorePatterns,
704
949
  });
705
- // Finalize the archive
950
+ archive.finalize();
951
+ });
952
+ }
953
+ /**
954
+ * Zip an Nx workspace project including the app and its dependencies
955
+ */
956
+ async function zipWorkspaceProject(projectName, workspaceCtx, isUpdate = false, verbose = false) {
957
+ return new Promise((resolve, reject) => {
958
+ const outputPath = path.join(workspaceCtx.appRoot, `${projectName}.zip`);
959
+ const output = fs.createWriteStream(outputPath);
960
+ const archive = archiver('zip', {
961
+ zlib: { level: 9 },
962
+ });
963
+ output.on('close', () => {
964
+ resolve({ zipPath: outputPath, workspaceContext: workspaceCtx });
965
+ });
966
+ archive.on('warning', (err) => {
967
+ if (err.code === 'ENOENT') {
968
+ if (verbose)
969
+ console.warn('Archive warning:', err);
970
+ }
971
+ else {
972
+ reject(err);
973
+ }
974
+ });
975
+ archive.on('error', (err) => {
976
+ reject(err);
977
+ });
978
+ archive.pipe(output);
979
+ logWorkspaceContext(workspaceCtx, verbose);
980
+ // Get workspace dependencies using Nx CLI (preferred) with fallback supplementation
981
+ let deps;
982
+ if (workspaceCtx.projectName) {
983
+ deps = getNxProjectDependencies(workspaceCtx.projectName, workspaceCtx.workspaceRoot, verbose, workspaceCtx.appRoot // Pass appRoot for webpack alias detection
984
+ );
985
+ }
986
+ // Always supplement with fallback detection to catch anything Nx might miss
987
+ // (e.g., dynamic imports, SCSS dependencies, transitive deps from source scanning)
988
+ const fallbackDeps = getWorkspaceDependenciesFallback(workspaceCtx, verbose);
989
+ if (deps) {
990
+ // Merge fallback libs into Nx-detected libs
991
+ const mergedLibPaths = new Set(deps.libPaths);
992
+ for (const libPath of fallbackDeps.libPaths) {
993
+ if (!mergedLibPaths.has(libPath)) {
994
+ mergedLibPaths.add(libPath);
995
+ if (verbose) {
996
+ console.log(`[workspace] Fallback added additional lib: ${libPath}`);
997
+ }
998
+ }
999
+ }
1000
+ deps.libPaths = Array.from(mergedLibPaths);
1001
+ // Also merge local file deps
1002
+ const mergedLocalFileDeps = new Set(deps.localFileDeps);
1003
+ for (const dep of fallbackDeps.localFileDeps) {
1004
+ mergedLocalFileDeps.add(dep);
1005
+ }
1006
+ deps.localFileDeps = Array.from(mergedLocalFileDeps);
1007
+ }
1008
+ else {
1009
+ if (verbose) {
1010
+ console.log('[workspace] Using fallback dependency detection (Nx CLI not available)');
1011
+ }
1012
+ deps = fallbackDeps;
1013
+ }
1014
+ // Create manifest for CI
1015
+ const manifest = createWorkspaceManifest(workspaceCtx, deps);
1016
+ archive.append(JSON.stringify(manifest, null, 2), {
1017
+ name: '.norrix/manifest.json',
1018
+ });
1019
+ // Base ignore patterns for the entire workspace
1020
+ const ignorePatterns = [
1021
+ '**/node_modules/**',
1022
+ '**/*.zip',
1023
+ '**/platforms/**',
1024
+ '**/dist/**',
1025
+ '**/.git/**',
1026
+ '**/hooks/**',
1027
+ // Exclude other apps (not the current one)
1028
+ 'apps/**',
1029
+ ];
1030
+ if (isUpdate) {
1031
+ ignorePatterns.push('**/assets/norrix.fingerprint.json');
1032
+ }
1033
+ // 1. Add the app itself at its relative workspace path
1034
+ console.log(`Adding app: ${workspaceCtx.relativeAppPath}`);
1035
+ archive.directory(workspaceCtx.appRoot, workspaceCtx.relativeAppPath, (entry) => {
1036
+ // Filter out node_modules, platforms, etc.
1037
+ if (entry.name.includes('node_modules') ||
1038
+ entry.name.includes('platforms') ||
1039
+ entry.name.endsWith('.zip')) {
1040
+ return false;
1041
+ }
1042
+ if (isUpdate && entry.name.includes('norrix.fingerprint.json')) {
1043
+ return false;
1044
+ }
1045
+ return entry;
1046
+ });
1047
+ // 2. Add dependent libs
1048
+ if (deps.libPaths.length > 0) {
1049
+ console.log(`Adding ${deps.libPaths.length} library dependencies`);
1050
+ for (const libPath of deps.libPaths) {
1051
+ const absoluteLibPath = path.join(workspaceCtx.workspaceRoot, libPath);
1052
+ if (fs.existsSync(absoluteLibPath)) {
1053
+ if (verbose) {
1054
+ console.log(` - ${libPath}`);
1055
+ }
1056
+ archive.directory(absoluteLibPath, libPath, (entry) => {
1057
+ if (entry.name.includes('node_modules')) {
1058
+ return false;
1059
+ }
1060
+ return entry;
1061
+ });
1062
+ }
1063
+ }
1064
+ }
1065
+ // 3. Add root config files
1066
+ console.log('Adding workspace root configuration files');
1067
+ for (const configFile of deps.rootConfigs) {
1068
+ const configPath = path.join(workspaceCtx.workspaceRoot, configFile);
1069
+ if (fs.existsSync(configPath)) {
1070
+ if (verbose) {
1071
+ console.log(` - ${configFile}`);
1072
+ }
1073
+ archive.file(configPath, { name: configFile });
1074
+ }
1075
+ }
1076
+ // 4. Add tools directory if it exists and is referenced
1077
+ for (const toolPath of deps.toolPaths) {
1078
+ const absoluteToolPath = path.join(workspaceCtx.workspaceRoot, toolPath);
1079
+ if (fs.existsSync(absoluteToolPath) && fs.statSync(absoluteToolPath).isDirectory()) {
1080
+ console.log(`Adding tools: ${toolPath}`);
1081
+ archive.directory(absoluteToolPath, toolPath, (entry) => {
1082
+ if (entry.name.includes('node_modules')) {
1083
+ return false;
1084
+ }
1085
+ return entry;
1086
+ });
1087
+ }
1088
+ }
1089
+ // 5. Add asset paths if they exist
1090
+ for (const assetPath of deps.assetPaths) {
1091
+ const absoluteAssetPath = path.join(workspaceCtx.workspaceRoot, assetPath);
1092
+ if (fs.existsSync(absoluteAssetPath) && fs.statSync(absoluteAssetPath).isDirectory()) {
1093
+ if (verbose) {
1094
+ console.log(`Adding assets: ${assetPath}`);
1095
+ }
1096
+ archive.directory(absoluteAssetPath, assetPath);
1097
+ }
1098
+ }
1099
+ // 6. Add local file dependencies (file: protocol paths from package.json)
1100
+ // Skip any that are already covered by libPaths to avoid duplicate entries
1101
+ if (deps.localFileDeps && deps.localFileDeps.length > 0) {
1102
+ const addedDirs = new Set();
1103
+ // Filter out local deps that are subdirectories of already-added libs
1104
+ const filteredLocalDeps = deps.localFileDeps.filter(localDep => {
1105
+ // Check if this local dep is inside any of the lib paths
1106
+ for (const libPath of deps.libPaths) {
1107
+ if (localDep.startsWith(libPath + '/') || localDep === libPath) {
1108
+ if (verbose) {
1109
+ console.log(` - ${localDep} (skipped, covered by ${libPath})`);
1110
+ }
1111
+ return false;
1112
+ }
1113
+ }
1114
+ return true;
1115
+ });
1116
+ if (filteredLocalDeps.length > 0) {
1117
+ console.log(`Adding ${filteredLocalDeps.length} local file dependencies`);
1118
+ }
1119
+ for (const localDep of filteredLocalDeps) {
1120
+ const absoluteLocalPath = path.join(workspaceCtx.workspaceRoot, localDep);
1121
+ if (fs.existsSync(absoluteLocalPath)) {
1122
+ const stat = fs.statSync(absoluteLocalPath);
1123
+ if (stat.isFile()) {
1124
+ // For files, ensure the parent directory structure is maintained
1125
+ if (verbose) {
1126
+ console.log(` - ${localDep} (file)`);
1127
+ }
1128
+ archive.file(absoluteLocalPath, { name: localDep });
1129
+ // Also add the directory if it hasn't been added yet (for other potential files)
1130
+ const parentDir = path.dirname(localDep);
1131
+ if (parentDir && parentDir !== '.' && !addedDirs.has(parentDir)) {
1132
+ // We just add the file, not the whole directory
1133
+ }
1134
+ }
1135
+ else if (stat.isDirectory()) {
1136
+ // Skip if this directory or a parent is already in libPaths
1137
+ const alreadyCovered = deps.libPaths.some(libPath => localDep.startsWith(libPath + '/') || libPath.startsWith(localDep + '/'));
1138
+ if (alreadyCovered) {
1139
+ if (verbose) {
1140
+ console.log(` - ${localDep} (skipped, overlaps with libPaths)`);
1141
+ }
1142
+ continue;
1143
+ }
1144
+ if (verbose) {
1145
+ console.log(` - ${localDep} (directory)`);
1146
+ }
1147
+ archive.directory(absoluteLocalPath, localDep, (entry) => {
1148
+ if (entry.name.includes('node_modules')) {
1149
+ return false;
1150
+ }
1151
+ return entry;
1152
+ });
1153
+ addedDirs.add(localDep);
1154
+ }
1155
+ }
1156
+ else if (verbose) {
1157
+ console.log(` - ${localDep} (not found, skipping)`);
1158
+ }
1159
+ }
1160
+ }
706
1161
  archive.finalize();
707
1162
  });
708
1163
  }
@@ -710,16 +1165,50 @@ async function zipProject(projectName, isUpdate = false) {
710
1165
  * Build command implementation
711
1166
  * Uploads project to S3 and triggers build via the Next.js API gateway -> WarpBuild
712
1167
  */
713
- export async function build(cliPlatformArg, cliConfigurationArg, cliDistributionArg, verbose = false) {
1168
+ export async function build(cliPlatformArg, cliConfigurationArg, cliDistributionArg, verbose = false, options // string for backwards compatibility with old projectArg
1169
+ ) {
1170
+ // Normalize options - support both new object and legacy string projectArg
1171
+ const opts = typeof options === 'string' ? { project: options } : (options || {});
714
1172
  ensureInitialized();
1173
+ try {
1174
+ await ensureOrgSelected({
1175
+ orgIdArg: opts.org,
1176
+ nonInteractive: opts.nonInteractive,
1177
+ requireSelection: true,
1178
+ verbose,
1179
+ promptMessage: 'Select organization for this build:',
1180
+ });
1181
+ }
1182
+ catch (error) {
1183
+ ora().fail(`Organization selection failed: ${error?.message || error}`);
1184
+ if (verbose && error?.response) {
1185
+ console.error('Axios response status:', error.response.status);
1186
+ console.error('Axios response data:', error.response.data);
1187
+ }
1188
+ return;
1189
+ }
715
1190
  let spinner;
1191
+ let originalCwd;
716
1192
  try {
717
1193
  spinner = ora('Preparing app for building...');
718
1194
  spinner.start();
1195
+ // 0. Resolve workspace context (handles --project and prompting)
1196
+ const resolved = await resolveWorkspaceContext(opts.project, spinner);
1197
+ originalCwd = resolved.originalCwd;
1198
+ const workspaceCtx = resolved.workspaceContext;
1199
+ // Restart spinner after potential prompts
1200
+ if (!spinner.isSpinning) {
1201
+ spinner.start('Preparing app for building...');
1202
+ }
1203
+ // Load Norrix config file if present
1204
+ const norrixConfig = await loadNorrixConfig(process.cwd());
1205
+ if (workspaceCtx.type === 'nx' && verbose) {
1206
+ logWorkspaceContext(workspaceCtx, verbose);
1207
+ }
719
1208
  // 1. Get project info
720
1209
  const projectName = await getProjectName();
721
- // 2. Determine platform (CLI arg preferred, otherwise prompt)
722
- let platform = (cliPlatformArg || '').toLowerCase();
1210
+ // 2. Determine platform (CLI arg preferred, then config, otherwise prompt)
1211
+ let platform = (cliPlatformArg || norrixConfig.defaultPlatform || '').toLowerCase();
723
1212
  const validPlatforms = ['android', 'ios', 'visionos'];
724
1213
  spinner.stop();
725
1214
  if (!validPlatforms.includes(platform)) {
@@ -748,6 +1237,31 @@ export async function build(cliPlatformArg, cliConfigurationArg, cliDistribution
748
1237
  ]);
749
1238
  configuration = answer.configuration;
750
1239
  }
1240
+ // 2.2 Determine Nx configuration for workspace builds (CLI arg preferred, then prompt if available)
1241
+ let nxConfiguration = opts.nxConfiguration;
1242
+ if (!nxConfiguration && workspaceCtx.type === 'nx' && !opts.nonInteractive) {
1243
+ const nxConfigs = detectNxBuildConfigurations(workspaceCtx.appRoot);
1244
+ if (nxConfigs && nxConfigs.configurations.length > 0) {
1245
+ const choices = nxConfigs.configurations.map((c) => ({
1246
+ name: c === nxConfigs.defaultConfiguration ? `${c} (default)` : c,
1247
+ value: c,
1248
+ }));
1249
+ // Add option to skip/use default
1250
+ choices.unshift({ name: '(none - use defaults)', value: '' });
1251
+ const { chosenNxConfig } = await inquirer.prompt([
1252
+ {
1253
+ type: 'list',
1254
+ name: 'chosenNxConfig',
1255
+ message: 'Nx build configuration (environment):',
1256
+ choices,
1257
+ default: nxConfigs.defaultConfiguration || '',
1258
+ },
1259
+ ]);
1260
+ nxConfiguration = chosenNxConfig || undefined;
1261
+ }
1262
+ }
1263
+ // Store resolved nxConfiguration back to opts for later use
1264
+ opts.nxConfiguration = nxConfiguration;
751
1265
  const normalizeIosDistribution = (input) => {
752
1266
  const v = String(input ?? '')
753
1267
  .trim()
@@ -772,14 +1286,20 @@ export async function build(cliPlatformArg, cliConfigurationArg, cliDistribution
772
1286
  }
773
1287
  return undefined;
774
1288
  };
775
- // 2.2 iOS distribution type (release only)
1289
+ // 2.2 iOS distribution type (release only): CLI arg > config file > prompt
776
1290
  let distributionType;
777
1291
  if (platform === 'ios' && configuration === 'release') {
1292
+ // Try CLI arg first
778
1293
  distributionType = normalizeIosDistribution(cliDistributionArg);
779
1294
  if (!distributionType && cliDistributionArg) {
780
1295
  throw new Error(`Invalid iOS distribution type '${cliDistributionArg}'. Use 'appstore', 'adhoc', or 'enterprise'.`);
781
1296
  }
782
- if (!distributionType) {
1297
+ // Fall back to config file
1298
+ if (!distributionType && norrixConfig.ios?.distributionType) {
1299
+ distributionType = norrixConfig.ios.distributionType;
1300
+ }
1301
+ // Prompt if still not set (unless non-interactive mode)
1302
+ if (!distributionType && !opts.nonInteractive) {
783
1303
  const { distribution } = await inquirer.prompt([
784
1304
  {
785
1305
  type: 'list',
@@ -795,6 +1315,10 @@ export async function build(cliPlatformArg, cliConfigurationArg, cliDistribution
795
1315
  ]);
796
1316
  distributionType = distribution;
797
1317
  }
1318
+ // Default to appstore in non-interactive mode if nothing else provided
1319
+ if (!distributionType) {
1320
+ distributionType = 'appstore';
1321
+ }
798
1322
  }
799
1323
  const appleVersionInfo = platform === 'ios' || platform === 'visionos'
800
1324
  ? getAppleVersionFromInfoPlist(platform)
@@ -865,70 +1389,148 @@ export async function build(cliPlatformArg, cliConfigurationArg, cliDistribution
865
1389
  if (configuration === 'release') {
866
1390
  spinner.stop();
867
1391
  if (platform === 'ios') {
868
- const iosAnswers = await inquirer.prompt([
869
- {
870
- type: 'input',
871
- name: 'p12Path',
872
- message: 'Path to iOS .p12 certificate (optional):',
873
- default: '',
874
- },
875
- {
876
- type: 'password',
877
- name: 'p12Password',
878
- message: 'Password for .p12 (if any):',
879
- mask: '*',
880
- default: '',
881
- },
882
- {
883
- type: 'input',
884
- name: 'mobileprovisionPath',
885
- message: 'Path to provisioning profile .mobileprovision (optional):',
886
- default: '',
887
- },
888
- {
889
- type: 'confirm',
890
- name: 'hasAscKey',
891
- message: 'Provide App Store Connect API Key? (optional, for App Store operations)',
892
- default: false,
893
- },
894
- {
895
- type: 'input',
896
- name: 'ascApiKeyId',
897
- message: 'ASC API Key ID (optional):',
898
- default: '',
899
- when: (a) => a.hasAscKey,
900
- },
901
- {
902
- type: 'input',
903
- name: 'ascIssuerId',
904
- message: 'ASC Issuer ID (optional):',
905
- default: '',
906
- when: (a) => a.hasAscKey,
907
- },
908
- {
909
- type: 'input',
910
- name: 'ascPrivateKeyPath',
911
- message: 'Path to ASC private key .p8 (optional):',
912
- default: '',
913
- when: (a) => a.hasAscKey,
914
- },
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
- ]);
923
- iosCredentials = {
924
- p12Base64: readOptionalFileAsBase64(iosAnswers.p12Path),
925
- p12Password: iosAnswers.p12Password || undefined,
926
- mobileprovisionBase64: readOptionalFileAsBase64(iosAnswers.mobileprovisionPath),
927
- ascApiKeyId: iosAnswers.ascApiKeyId || undefined,
928
- ascIssuerId: iosAnswers.ascIssuerId || undefined,
929
- ascPrivateKey: readOptionalFileAsBase64(iosAnswers.ascPrivateKeyPath),
930
- ascTeamId: iosAnswers.ascTeamId || undefined,
931
- };
1392
+ // Resolve values from: CLI flags > config file (trimmed to handle whitespace)
1393
+ const configTeamId = norrixConfig.ios?.teamId;
1394
+ const configP12Path = norrixConfig.ios?.p12Path;
1395
+ const configProfilePath = norrixConfig.ios?.provisioningProfilePath;
1396
+ const configAscKeyId = norrixConfig.ios?.ascApiKeyId;
1397
+ const configAscIssuerId = norrixConfig.ios?.ascIssuerId;
1398
+ const configAscKeyPath = norrixConfig.ios?.ascPrivateKeyPath;
1399
+ const resolvedTeamId = trimString(opts.teamId) || configTeamId;
1400
+ const resolvedP12Path = trimString(opts.p12Path) || configP12Path;
1401
+ const resolvedP12Password = trimString(opts.p12Password);
1402
+ const resolvedProfilePath = trimString(opts.profilePath) || configProfilePath;
1403
+ const resolvedAscKeyId = trimString(opts.ascKeyId) || configAscKeyId;
1404
+ const resolvedAscIssuerId = trimString(opts.ascIssuerId) || configAscIssuerId;
1405
+ const resolvedAscKeyPath = trimString(opts.ascKeyPath) || configAscKeyPath;
1406
+ if (opts.nonInteractive) {
1407
+ // Non-interactive mode: use CLI flags and config values only
1408
+ iosCredentials = {
1409
+ teamId: resolvedTeamId || undefined,
1410
+ p12Base64: readOptionalFileAsBase64(resolvedP12Path),
1411
+ p12Password: resolvedP12Password || undefined,
1412
+ mobileprovisionBase64: readOptionalFileAsBase64(resolvedProfilePath),
1413
+ ascApiKeyId: resolvedAscKeyId || undefined,
1414
+ ascIssuerId: resolvedAscIssuerId || undefined,
1415
+ ascPrivateKey: readOptionalFileAsBase64(resolvedAscKeyPath),
1416
+ };
1417
+ if (resolvedTeamId) {
1418
+ console.log(`Using Apple Team ID: ${resolvedTeamId}`);
1419
+ }
1420
+ if (resolvedAscKeyId) {
1421
+ console.log(`Using ASC API Key: ${resolvedAscKeyId}`);
1422
+ }
1423
+ }
1424
+ else {
1425
+ // Interactive mode: prompt for values with CLI/config as defaults
1426
+ // Flow: Team ID -> ASC Key (recommended) -> If no ASC, then .p12/.mobileprovision
1427
+ const iosAnswers = await inquirer.prompt([
1428
+ {
1429
+ type: 'input',
1430
+ name: 'teamId',
1431
+ message: 'Apple Developer Team ID (required for code signing, e.g. "ABC123XYZ"):',
1432
+ default: resolvedTeamId || '',
1433
+ validate: (input) => {
1434
+ if (!input.trim()) {
1435
+ return true; // Allow empty, workflow will try to proceed without it
1436
+ }
1437
+ if (/^[A-Z0-9]{10}$/.test(input.trim())) {
1438
+ return true;
1439
+ }
1440
+ return 'Team ID should be 10 alphanumeric characters (e.g. "ABC123XYZ"). Leave empty to skip.';
1441
+ },
1442
+ },
1443
+ // ASC API Key is the recommended approach - ask first
1444
+ {
1445
+ type: 'confirm',
1446
+ name: 'useAscKey',
1447
+ message: 'Use App Store Connect API Key for auto-provisioning? (recommended)',
1448
+ default: Boolean(resolvedAscKeyId) || true,
1449
+ },
1450
+ {
1451
+ type: 'input',
1452
+ name: 'ascApiKeyId',
1453
+ message: 'ASC API Key ID:',
1454
+ default: resolvedAscKeyId || '',
1455
+ when: (a) => a.useAscKey,
1456
+ validate: (input) => {
1457
+ if (!input.trim()) {
1458
+ return 'API Key ID is required when using ASC Key';
1459
+ }
1460
+ return true;
1461
+ },
1462
+ },
1463
+ {
1464
+ type: 'input',
1465
+ name: 'ascIssuerId',
1466
+ message: 'ASC Issuer ID:',
1467
+ default: resolvedAscIssuerId || '',
1468
+ when: (a) => a.useAscKey,
1469
+ validate: (input) => {
1470
+ if (!input.trim()) {
1471
+ return 'Issuer ID is required when using ASC Key';
1472
+ }
1473
+ return true;
1474
+ },
1475
+ },
1476
+ {
1477
+ type: 'input',
1478
+ name: 'ascPrivateKeyPath',
1479
+ message: 'Path to ASC private key .p8:',
1480
+ default: resolvedAscKeyPath || '',
1481
+ when: (a) => a.useAscKey,
1482
+ validate: (input) => {
1483
+ if (!input.trim()) {
1484
+ return 'Path to .p8 key file is required when using ASC Key';
1485
+ }
1486
+ return true;
1487
+ },
1488
+ },
1489
+ // Only ask for .p12/.mobileprovision if NOT using ASC Key
1490
+ {
1491
+ type: 'input',
1492
+ name: 'p12Path',
1493
+ message: 'Path to iOS .p12 certificate (optional):',
1494
+ default: resolvedP12Path || '',
1495
+ when: (a) => !a.useAscKey,
1496
+ },
1497
+ {
1498
+ type: 'password',
1499
+ name: 'p12Password',
1500
+ message: 'Password for .p12 (if any):',
1501
+ mask: '*',
1502
+ default: '',
1503
+ when: (a) => !a.useAscKey && a.p12Path,
1504
+ },
1505
+ {
1506
+ type: 'input',
1507
+ name: 'mobileprovisionPath',
1508
+ message: 'Path to provisioning profile .mobileprovision (optional):',
1509
+ default: resolvedProfilePath || '',
1510
+ when: (a) => !a.useAscKey,
1511
+ },
1512
+ ]);
1513
+ // Use resolved teamId from CLI/config, or from prompt
1514
+ const finalTeamId = trimString(iosAnswers.teamId) || resolvedTeamId;
1515
+ iosCredentials = {
1516
+ teamId: finalTeamId || undefined,
1517
+ p12Base64: readOptionalFileAsBase64(iosAnswers.p12Path),
1518
+ p12Password: trimString(iosAnswers.p12Password),
1519
+ mobileprovisionBase64: readOptionalFileAsBase64(iosAnswers.mobileprovisionPath),
1520
+ ascApiKeyId: trimString(iosAnswers.ascApiKeyId),
1521
+ ascIssuerId: trimString(iosAnswers.ascIssuerId),
1522
+ ascPrivateKey: readOptionalFileAsBase64(iosAnswers.ascPrivateKeyPath),
1523
+ // Track paths for config saving (not sent to API)
1524
+ _p12Path: trimString(iosAnswers.p12Path),
1525
+ _mobileprovisionPath: trimString(iosAnswers.mobileprovisionPath),
1526
+ _ascApiKeyId: trimString(iosAnswers.ascApiKeyId),
1527
+ _ascIssuerId: trimString(iosAnswers.ascIssuerId),
1528
+ _ascPrivateKeyPath: trimString(iosAnswers.ascPrivateKeyPath),
1529
+ };
1530
+ if (finalTeamId) {
1531
+ console.log(`Using Apple Team ID: ${finalTeamId}`);
1532
+ }
1533
+ }
932
1534
  }
933
1535
  else if (platform === 'android') {
934
1536
  const androidAnswers = await inquirer.prompt([
@@ -974,11 +1576,82 @@ export async function build(cliPlatformArg, cliConfigurationArg, cliDistribution
974
1576
  ]);
975
1577
  androidCredentials = {
976
1578
  keystoreBase64: readOptionalFileAsBase64(androidAnswers.keystorePath),
977
- keystorePassword: androidAnswers.keystorePassword || undefined,
978
- keyAlias: androidAnswers.keyAlias || undefined,
979
- keyPassword: androidAnswers.keyPassword || undefined,
1579
+ keystorePassword: trimString(androidAnswers.keystorePassword),
1580
+ keyAlias: trimString(androidAnswers.keyAlias),
1581
+ keyPassword: trimString(androidAnswers.keyPassword),
980
1582
  playServiceAccountJson: readOptionalFileAsBase64(androidAnswers.playJsonPath),
981
1583
  };
1584
+ // Track Android paths for config saving
1585
+ androidCredentials._keystorePath = trimString(androidAnswers.keystorePath);
1586
+ androidCredentials._keyAlias = trimString(androidAnswers.keyAlias);
1587
+ }
1588
+ // Offer to save config if no norrix.config.ts exists and we collected useful values
1589
+ const appRoot = process.cwd();
1590
+ if (!hasNorrixConfig(appRoot) && !opts.nonInteractive) {
1591
+ // Collect saveable values
1592
+ const saveableOptions = {
1593
+ platform: platform,
1594
+ };
1595
+ if (platform === 'ios' && iosCredentials) {
1596
+ if (iosCredentials.teamId) {
1597
+ saveableOptions.teamId = iosCredentials.teamId;
1598
+ }
1599
+ if (distributionType) {
1600
+ saveableOptions.distributionType = distributionType;
1601
+ }
1602
+ // Don't save actual credential file paths since they may contain secrets
1603
+ // But save paths that users can re-use
1604
+ if (iosCredentials._p12Path) {
1605
+ saveableOptions.p12Path = iosCredentials._p12Path;
1606
+ }
1607
+ if (iosCredentials._mobileprovisionPath) {
1608
+ saveableOptions.provisioningProfilePath = iosCredentials._mobileprovisionPath;
1609
+ }
1610
+ // Save ASC API Key details for future builds
1611
+ if (iosCredentials._ascApiKeyId) {
1612
+ saveableOptions.ascApiKeyId = iosCredentials._ascApiKeyId;
1613
+ }
1614
+ if (iosCredentials._ascIssuerId) {
1615
+ saveableOptions.ascIssuerId = iosCredentials._ascIssuerId;
1616
+ }
1617
+ if (iosCredentials._ascPrivateKeyPath) {
1618
+ saveableOptions.ascPrivateKeyPath = iosCredentials._ascPrivateKeyPath;
1619
+ }
1620
+ }
1621
+ if (platform === 'android' && androidCredentials) {
1622
+ if (androidCredentials._keystorePath) {
1623
+ saveableOptions.keystorePath = androidCredentials._keystorePath;
1624
+ }
1625
+ if (androidCredentials._keyAlias) {
1626
+ saveableOptions.keyAlias = androidCredentials._keyAlias;
1627
+ }
1628
+ }
1629
+ // Only offer to save if we have something useful
1630
+ const hasSaveableValues = saveableOptions.teamId ||
1631
+ saveableOptions.distributionType ||
1632
+ saveableOptions.p12Path ||
1633
+ saveableOptions.provisioningProfilePath ||
1634
+ saveableOptions.keystorePath ||
1635
+ saveableOptions.ascApiKeyId;
1636
+ if (hasSaveableValues) {
1637
+ const { shouldSave } = await inquirer.prompt([
1638
+ {
1639
+ type: 'confirm',
1640
+ name: 'shouldSave',
1641
+ message: 'Save these settings to norrix.config.ts for future builds?',
1642
+ default: true,
1643
+ },
1644
+ ]);
1645
+ if (shouldSave) {
1646
+ try {
1647
+ const savedPath = saveNorrixConfig(appRoot, saveableOptions);
1648
+ console.log(`✓ Configuration saved to ${path.basename(savedPath)}`);
1649
+ }
1650
+ catch (saveError) {
1651
+ console.warn(`Warning: Could not save config file: ${saveError.message}`);
1652
+ }
1653
+ }
1654
+ }
982
1655
  }
983
1656
  spinner.start('Creating project archive...');
984
1657
  }
@@ -990,8 +1663,8 @@ export async function build(cliPlatformArg, cliConfigurationArg, cliDistribution
990
1663
  });
991
1664
  writeRuntimeFingerprintFile(projectRoot, fingerprint, platform);
992
1665
  spinner.start('Creating project archive...');
993
- // 3. Zip the project
994
- const zipPath = await zipProject(projectName, false);
1666
+ // 3. Zip the project (workspace-aware)
1667
+ const { zipPath, workspaceContext } = await zipProject(projectName, workspaceCtx, false, verbose);
995
1668
  spinner.text = 'Project archive created';
996
1669
  // 4. Upload the project zip to S3
997
1670
  spinner.text = 'Working...';
@@ -1018,6 +1691,17 @@ export async function build(cliPlatformArg, cliConfigurationArg, cliDistribution
1018
1691
  catch {
1019
1692
  inferredAppId = undefined;
1020
1693
  }
1694
+ // Include workspace info for CI to properly navigate the project structure
1695
+ const workspaceInfo = workspaceContext.type === 'nx' ? {
1696
+ workspaceType: workspaceContext.type,
1697
+ appPath: workspaceContext.relativeAppPath,
1698
+ projectName: workspaceContext.projectName,
1699
+ } : undefined;
1700
+ // For standalone projects, use the project option or infer from app ID/package name
1701
+ // This allows env vars to be scoped to specific standalone projects within an org
1702
+ const standaloneProjectName = workspaceContext.type === 'standalone'
1703
+ ? (opts.project || inferredAppId || projectName)
1704
+ : undefined;
1021
1705
  const response = await axios.post(`${API_URL}/build`, {
1022
1706
  projectName,
1023
1707
  appId: inferredAppId,
@@ -1027,10 +1711,18 @@ export async function build(cliPlatformArg, cliConfigurationArg, cliDistribution
1027
1711
  version: version || '',
1028
1712
  buildNumber: buildNumber || '',
1029
1713
  configuration,
1714
+ // Nx configuration (e.g., 'prod', 'stg', 'dev') for monorepo builds
1715
+ ...(opts.nxConfiguration ? { nxConfiguration: opts.nxConfiguration } : {}),
1030
1716
  ...(distributionType ? { distributionType } : {}),
1717
+ // Android package type override (apk or aab) - takes precedence over distributionType
1718
+ ...(opts.androidPackageType ? { androidPackageType: opts.androidPackageType } : {}),
1031
1719
  fingerprint,
1032
1720
  // Provide the relative key (without public/) – the workflow prepends public/
1033
1721
  s3Key: s3KeyRel,
1722
+ // Workspace context for Nx monorepos
1723
+ ...(workspaceInfo ? { workspace: workspaceInfo } : {}),
1724
+ // For standalone projects, include project name for env var scoping
1725
+ ...(standaloneProjectName ? { projectName: standaloneProjectName } : {}),
1034
1726
  // Only include raw credentials if not encrypted
1035
1727
  ...(encryptedSecrets ? { encryptedSecrets } : {}),
1036
1728
  ...(!encryptedSecrets && iosCredentials ? { iosCredentials } : {}),
@@ -1091,8 +1783,21 @@ export async function build(cliPlatformArg, cliConfigurationArg, cliDistribution
1091
1783
  : 'App Store'}`);
1092
1784
  }
1093
1785
  console.log(` You can check the status with: norrix build-status ${buildId}`);
1786
+ // Restore original cwd if we changed it
1787
+ if (originalCwd && process.cwd() !== originalCwd) {
1788
+ process.chdir(originalCwd);
1789
+ }
1094
1790
  }
1095
1791
  catch (error) {
1792
+ // Restore original cwd if we changed it
1793
+ if (originalCwd && process.cwd() !== originalCwd) {
1794
+ try {
1795
+ process.chdir(originalCwd);
1796
+ }
1797
+ catch {
1798
+ // Ignore chdir errors during error handling
1799
+ }
1800
+ }
1096
1801
  const apiMessage = (error?.response?.data &&
1097
1802
  (error.response.data.error || error.response.data.message)) ||
1098
1803
  undefined;
@@ -1123,8 +1828,25 @@ export async function build(cliPlatformArg, cliConfigurationArg, cliDistribution
1123
1828
  * Submit command implementation
1124
1829
  * Submits the built app to app stores via the Next.js API gateway
1125
1830
  */
1126
- export async function submit(cliPlatformArg, cliTrackArg, verbose = false) {
1831
+ export async function submit(cliPlatformArg, cliTrackArg, verbose = false, options) {
1127
1832
  ensureInitialized();
1833
+ try {
1834
+ await ensureOrgSelected({
1835
+ orgIdArg: options?.org,
1836
+ nonInteractive: Boolean(options?.nonInteractive),
1837
+ requireSelection: true,
1838
+ verbose,
1839
+ promptMessage: 'Select organization for this submission:',
1840
+ });
1841
+ }
1842
+ catch (error) {
1843
+ ora().fail(`Organization selection failed: ${error?.message || error}`);
1844
+ if (verbose && error?.response) {
1845
+ console.error('Axios response status:', error.response.status);
1846
+ console.error('Axios response data:', error.response.data);
1847
+ }
1848
+ return;
1849
+ }
1128
1850
  const spinner = ora('Preparing app for submission...');
1129
1851
  try {
1130
1852
  spinner.start();
@@ -1383,12 +2105,44 @@ export async function submit(cliPlatformArg, cliTrackArg, verbose = false) {
1383
2105
  * Update command implementation
1384
2106
  * Publishes over-the-air updates to deployed apps via the Next.js API gateway
1385
2107
  */
1386
- export async function update(cliPlatformArg, cliVersionArg, verbose = false) {
2108
+ export async function update(cliPlatformArg, cliVersionArg, verbose = false, options // string for backwards compatibility with old projectArg
2109
+ ) {
2110
+ // Normalize options - support both new object and legacy string projectArg
2111
+ const opts = typeof options === 'string' ? { project: options } : (options || {});
1387
2112
  ensureInitialized();
2113
+ try {
2114
+ await ensureOrgSelected({
2115
+ orgIdArg: opts.org,
2116
+ nonInteractive: opts.nonInteractive,
2117
+ requireSelection: true,
2118
+ verbose,
2119
+ promptMessage: 'Select organization for this update:',
2120
+ });
2121
+ }
2122
+ catch (error) {
2123
+ ora().fail(`Organization selection failed: ${error?.message || error}`);
2124
+ if (verbose && error?.response) {
2125
+ console.error('Axios response status:', error.response.status);
2126
+ console.error('Axios response data:', error.response.data);
2127
+ }
2128
+ return;
2129
+ }
1388
2130
  let spinner;
2131
+ let originalCwd;
1389
2132
  try {
1390
2133
  spinner = ora('Preparing over-the-air update...');
1391
2134
  spinner.start();
2135
+ // 0. Resolve workspace context (handles --project and prompting)
2136
+ const resolved = await resolveWorkspaceContext(opts.project, spinner);
2137
+ originalCwd = resolved.originalCwd;
2138
+ const workspaceCtx = resolved.workspaceContext;
2139
+ // Restart spinner after potential prompts
2140
+ if (!spinner.isSpinning) {
2141
+ spinner.start('Preparing over-the-air update...');
2142
+ }
2143
+ if (workspaceCtx.type === 'nx' && verbose) {
2144
+ logWorkspaceContext(workspaceCtx, verbose);
2145
+ }
1392
2146
  // Normalize and/or ask for platform first (CLI arg takes precedence if valid)
1393
2147
  let platform = (cliPlatformArg || '').toLowerCase();
1394
2148
  const validPlatforms = ['android', 'ios', 'visionos'];
@@ -1405,6 +2159,33 @@ export async function update(cliPlatformArg, cliVersionArg, verbose = false) {
1405
2159
  platform = chosenPlatform;
1406
2160
  spinner.start('Preparing over-the-air update...');
1407
2161
  }
2162
+ // Determine Nx configuration for workspace builds (CLI arg preferred, then prompt if available)
2163
+ let nxConfiguration = opts.nxConfiguration;
2164
+ if (!nxConfiguration && workspaceCtx.type === 'nx' && !opts.nonInteractive) {
2165
+ spinner.stop();
2166
+ const nxConfigs = detectNxBuildConfigurations(workspaceCtx.appRoot);
2167
+ if (nxConfigs && nxConfigs.configurations.length > 0) {
2168
+ const choices = nxConfigs.configurations.map((c) => ({
2169
+ name: c === nxConfigs.defaultConfiguration ? `${c} (default)` : c,
2170
+ value: c,
2171
+ }));
2172
+ // Add option to skip/use default
2173
+ choices.unshift({ name: '(none - use defaults)', value: '' });
2174
+ const { chosenNxConfig } = await inquirer.prompt([
2175
+ {
2176
+ type: 'list',
2177
+ name: 'chosenNxConfig',
2178
+ message: 'Nx build configuration (environment):',
2179
+ choices,
2180
+ default: nxConfigs.defaultConfiguration || '',
2181
+ },
2182
+ ]);
2183
+ nxConfiguration = chosenNxConfig || undefined;
2184
+ }
2185
+ spinner.start('Preparing over-the-air update...');
2186
+ }
2187
+ // Store resolved nxConfiguration back to opts for later use
2188
+ opts.nxConfiguration = nxConfiguration;
1408
2189
  // Infer version from native project files (same as build)
1409
2190
  const appleVersionInfo = platform === 'ios' || platform === 'visionos'
1410
2191
  ? getAppleVersionFromInfoPlist(platform)
@@ -1424,27 +2205,45 @@ export async function update(cliPlatformArg, cliVersionArg, verbose = false) {
1424
2205
  // Ask for app ID and version first
1425
2206
  spinner.stop();
1426
2207
  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);
2208
+ // Resolve appId: CLI flag → inferred → prompt
2209
+ let appId;
2210
+ if (opts.appId) {
2211
+ appId = opts.appId;
2212
+ }
2213
+ else {
2214
+ const { appId: promptedAppId } = await inquirer.prompt([
2215
+ {
2216
+ type: 'input',
2217
+ name: 'appId',
2218
+ message: 'Enter the App ID to update:',
2219
+ default: inferredAppId || '',
2220
+ validate: (input) => input.length > 0 || 'App ID is required',
2221
+ },
2222
+ ]);
2223
+ appId = promptedAppId;
2224
+ }
2225
+ // Resolve version: CLI arg → inferred → prompt
2226
+ let version;
2227
+ if (cliVersion) {
2228
+ version = cliVersion;
2229
+ }
2230
+ else if (inferredVersion && opts.nonInteractive) {
2231
+ version = inferredVersion;
2232
+ }
2233
+ else {
2234
+ const { version: promptedVersion } = await inquirer.prompt([
2235
+ {
2236
+ type: 'input',
2237
+ name: 'version',
2238
+ message: inferredVersion
2239
+ ? `Update version (${inferredVersion}, enter to accept):`
2240
+ : 'Update version:',
2241
+ default: inferredVersion,
2242
+ validate: (input) => input.length > 0 || 'Version is required',
2243
+ },
2244
+ ]);
2245
+ version = promptedVersion;
2246
+ }
1448
2247
  // Ask the server what the next buildNumber would be for this app so we
1449
2248
  // can present a sensible default in the prompt, matching what the API
1450
2249
  // will auto-increment to if left blank.
@@ -1468,38 +2267,51 @@ export async function update(cliPlatformArg, cliVersionArg, verbose = false) {
1468
2267
  : platform === 'android'
1469
2268
  ? androidVersionInfo.buildNumber
1470
2269
  : 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;
2270
+ // Resolve buildNumber: CLI flag server suggestion → local inferred → prompt
2271
+ let buildNumber;
2272
+ let notes = '';
2273
+ if (opts.buildNumber) {
2274
+ buildNumber = opts.buildNumber;
2275
+ }
2276
+ else if (opts.nonInteractive) {
2277
+ // In non-interactive mode, use server suggestion or leave undefined for auto-increment
2278
+ buildNumber = serverSuggestedBuildNumber || localInferredBuildNumber || undefined;
2279
+ }
2280
+ else {
2281
+ const buildPromptAnswers = await inquirer.prompt([
2282
+ {
2283
+ type: 'input',
2284
+ name: 'buildNumber',
2285
+ message: (() => {
2286
+ if (serverSuggestedBuildNumber) {
2287
+ return `Update build number (${serverSuggestedBuildNumber}, enter to accept or override; blank to auto increment from server):`;
2288
+ }
2289
+ if (localInferredBuildNumber) {
2290
+ return `Update build number (${localInferredBuildNumber}, enter to auto increment from server):`;
2291
+ }
2292
+ return 'Update build number (leave blank to auto increment from server):';
2293
+ })(),
2294
+ default: serverSuggestedBuildNumber || localInferredBuildNumber || '',
2295
+ validate: (input) => {
2296
+ const val = String(input).trim();
2297
+ if (!val)
2298
+ return true; // allow blank -> server will auto-increment
2299
+ if (!/^\d+$/.test(val)) {
2300
+ return 'Build number must be a positive integer or blank to auto-increment';
2301
+ }
2302
+ return true;
2303
+ },
1493
2304
  },
1494
- },
1495
- {
1496
- type: 'input',
1497
- name: 'notes',
1498
- message: 'Release notes (optional):',
1499
- default: '',
1500
- },
1501
- ]);
1502
- const buildNumber = String(promptedBuildNumber || '').trim() || undefined;
2305
+ {
2306
+ type: 'input',
2307
+ name: 'notes',
2308
+ message: 'Release notes (optional):',
2309
+ default: '',
2310
+ },
2311
+ ]);
2312
+ buildNumber = String(buildPromptAnswers.buildNumber || '').trim() || undefined;
2313
+ notes = buildPromptAnswers.notes || '';
2314
+ }
1503
2315
  // Check the app directory structure before packaging
1504
2316
  const srcAppDir = path.join(process.cwd(), 'src', 'app');
1505
2317
  const appDir = path.join(process.cwd(), 'app');
@@ -1576,9 +2388,9 @@ export async function update(cliPlatformArg, cliVersionArg, verbose = false) {
1576
2388
  // fingerprint JSON under the app source tree is the
1577
2389
  // single source of truth for OTA compatibility.
1578
2390
  spinner.start('Packaging for over-the-air update...');
1579
- // Create the update bundle - pass true to include node_modules for updates
2391
+ // Create the update bundle (workspace-aware) - pass true to include node_modules for updates
1580
2392
  const projectName = await getProjectName();
1581
- const zipPath = await zipProject(projectName, true);
2393
+ const { zipPath, workspaceContext } = await zipProject(projectName, workspaceCtx, true, verbose);
1582
2394
  spinner.text = 'Uploading update to Norrix cloud storage...';
1583
2395
  const fileBuffer = fs.readFileSync(zipPath);
1584
2396
  const updateFolder = `update-${Date.now()}`;
@@ -1586,6 +2398,17 @@ export async function update(cliPlatformArg, cliVersionArg, verbose = false) {
1586
2398
  const s3KeyRel = `updates/${updateFolder}/${appId}-${safeVersion}.zip`;
1587
2399
  await putObjectToStorage(`public/${s3KeyRel}`, fileBuffer);
1588
2400
  spinner.text = 'Upload complete. Starting update...';
2401
+ // Include workspace info for CI to properly navigate the project structure
2402
+ const workspaceInfo = workspaceContext.type === 'nx' ? {
2403
+ workspaceType: workspaceContext.type,
2404
+ appPath: workspaceContext.relativeAppPath,
2405
+ projectName: workspaceContext.projectName,
2406
+ } : undefined;
2407
+ // For standalone projects, use the project option or infer from app ID
2408
+ // This allows env vars to be scoped to specific standalone projects within an org
2409
+ const standaloneProjectName = workspaceContext.type === 'standalone'
2410
+ ? (opts.project || appId)
2411
+ : undefined;
1589
2412
  const response = await axios.post(`${API_URL}/update`, {
1590
2413
  appId,
1591
2414
  platform,
@@ -1593,8 +2416,14 @@ export async function update(cliPlatformArg, cliVersionArg, verbose = false) {
1593
2416
  buildNumber: buildNumber || '',
1594
2417
  releaseNotes: notes,
1595
2418
  fingerprint,
2419
+ // Nx configuration (e.g., 'prod', 'stg', 'dev') for monorepo builds
2420
+ ...(opts.nxConfiguration ? { nxConfiguration: opts.nxConfiguration } : {}),
1596
2421
  // Provide the relative key (without public/). Consumers will prepend public/
1597
2422
  s3Key: s3KeyRel,
2423
+ // Workspace context for Nx monorepos
2424
+ ...(workspaceInfo ? { workspace: workspaceInfo } : {}),
2425
+ // For standalone projects, include project name for env var scoping
2426
+ ...(standaloneProjectName ? { projectName: standaloneProjectName } : {}),
1598
2427
  }, {
1599
2428
  headers: {
1600
2429
  'Content-Type': 'application/json',
@@ -1644,8 +2473,21 @@ export async function update(cliPlatformArg, cliVersionArg, verbose = false) {
1644
2473
  }
1645
2474
  console.log(formatVersionBuildLine(recordedUpdateVersion, recordedUpdateBuildNumber));
1646
2475
  console.log(` You can check the status with: norrix update-status ${updateId}`);
2476
+ // Restore original cwd if we changed it
2477
+ if (originalCwd && process.cwd() !== originalCwd) {
2478
+ process.chdir(originalCwd);
2479
+ }
1647
2480
  }
1648
2481
  catch (error) {
2482
+ // Restore original cwd if we changed it
2483
+ if (originalCwd && process.cwd() !== originalCwd) {
2484
+ try {
2485
+ process.chdir(originalCwd);
2486
+ }
2487
+ catch {
2488
+ // Ignore chdir errors during error handling
2489
+ }
2490
+ }
1649
2491
  const apiMessage = (error?.response?.data &&
1650
2492
  (error.response.data.error || error.response.data.message)) ||
1651
2493
  undefined;
@@ -1673,8 +2515,20 @@ export async function update(cliPlatformArg, cliVersionArg, verbose = false) {
1673
2515
  * Build Status command implementation
1674
2516
  * Checks the status of a build via the Next.js API gateway
1675
2517
  */
1676
- export async function buildStatus(buildId, verbose = false) {
2518
+ export async function buildStatus(buildId, verbose = false, options) {
1677
2519
  ensureInitialized();
2520
+ try {
2521
+ await ensureOrgSelected({
2522
+ orgIdArg: options?.org,
2523
+ nonInteractive: true,
2524
+ requireSelection: false,
2525
+ verbose,
2526
+ });
2527
+ }
2528
+ catch (error) {
2529
+ ora().fail(`Organization selection failed: ${error?.message || error}`);
2530
+ return;
2531
+ }
1678
2532
  try {
1679
2533
  const spinner = ora(`Checking status of build ${buildId}...`).start();
1680
2534
  const response = await axios.get(`${API_URL}/build/${buildId}`, {
@@ -1711,8 +2565,20 @@ export async function buildStatus(buildId, verbose = false) {
1711
2565
  * Submit Status command implementation
1712
2566
  * Checks the status of a submission via the Next.js API gateway
1713
2567
  */
1714
- export async function submitStatus(submitId, verbose = false) {
2568
+ export async function submitStatus(submitId, verbose = false, options) {
1715
2569
  ensureInitialized();
2570
+ try {
2571
+ await ensureOrgSelected({
2572
+ orgIdArg: options?.org,
2573
+ nonInteractive: true,
2574
+ requireSelection: false,
2575
+ verbose,
2576
+ });
2577
+ }
2578
+ catch (error) {
2579
+ ora().fail(`Organization selection failed: ${error?.message || error}`);
2580
+ return;
2581
+ }
1716
2582
  try {
1717
2583
  const spinner = ora(`Checking status of submission ${submitId}...`).start();
1718
2584
  const response = await axios.get(`${API_URL}/submit/${submitId}`, {
@@ -1745,8 +2611,20 @@ export async function submitStatus(submitId, verbose = false) {
1745
2611
  * Update Status command implementation
1746
2612
  * Checks the status of an update via the Next.js API gateway
1747
2613
  */
1748
- export async function updateStatus(updateId, verbose = false) {
2614
+ export async function updateStatus(updateId, verbose = false, options) {
1749
2615
  ensureInitialized();
2616
+ try {
2617
+ await ensureOrgSelected({
2618
+ orgIdArg: options?.org,
2619
+ nonInteractive: true,
2620
+ requireSelection: false,
2621
+ verbose,
2622
+ });
2623
+ }
2624
+ catch (error) {
2625
+ ora().fail(`Organization selection failed: ${error?.message || error}`);
2626
+ return;
2627
+ }
1750
2628
  try {
1751
2629
  const spinner = ora(`Checking status of update ${updateId}...`).start();
1752
2630
  const response = await axios.get(`${API_URL}/update/${updateId}`, {
@@ -1991,4 +2869,310 @@ export async function billingPortal(verbose = false) {
1991
2869
  }
1992
2870
  }
1993
2871
  }
2872
+ function normalizeVisibilityType(input) {
2873
+ if (!input)
2874
+ return 'secret';
2875
+ if (input === 'plaintext' || input === 'secret')
2876
+ return input;
2877
+ console.error('Error: --visibility must be either "plaintext" or "secret"');
2878
+ process.exit(1);
2879
+ }
2880
+ /**
2881
+ * Set an environment variable for build-time injection
2882
+ */
2883
+ export async function envSet(name, value, verbose = false, options = {}) {
2884
+ ensureInitialized();
2885
+ let spinner;
2886
+ try {
2887
+ const visibilityType = normalizeVisibilityType(options.visibilityType);
2888
+ // Validate secret name (env var format)
2889
+ if (!/^[A-Z_][A-Z0-9_]*$/i.test(name)) {
2890
+ console.error('Error: Name must be a valid environment variable name (alphanumeric and underscores, cannot start with a number)');
2891
+ process.exit(1);
2892
+ }
2893
+ spinner = ora(`Setting env var "${name}"...`).start();
2894
+ const payload = {
2895
+ type: 'variable',
2896
+ name,
2897
+ visibilityType,
2898
+ project: options.project,
2899
+ };
2900
+ if (visibilityType === 'plaintext') {
2901
+ payload.value = value;
2902
+ }
2903
+ else {
2904
+ Object.assign(payload, encryptSecretValue(value));
2905
+ }
2906
+ const res = await axios.post(`${API_URL}/env`, payload, {
2907
+ headers: await getAuthHeaders(),
2908
+ });
2909
+ spinner.succeed(`Env var "${name}" set successfully`);
2910
+ if (res.data?.message) {
2911
+ console.log(res.data.message);
2912
+ }
2913
+ }
2914
+ catch (error) {
2915
+ const apiMessage = error?.response?.data?.error ||
2916
+ error?.response?.data?.message ||
2917
+ error?.message ||
2918
+ String(error);
2919
+ spinner?.fail(`Failed to set env var: ${apiMessage}`);
2920
+ if (verbose) {
2921
+ console.error('--- Verbose error details (env-set) ---');
2922
+ console.error(error);
2923
+ if (error?.response) {
2924
+ console.error('Axios response status:', error.response.status);
2925
+ console.error('Axios response data:', error.response.data);
2926
+ }
2927
+ if (error?.stack) {
2928
+ console.error(error.stack);
2929
+ }
2930
+ }
2931
+ process.exit(1);
2932
+ }
2933
+ }
2934
+ /**
2935
+ * Upload an environment file for build-time injection
2936
+ */
2937
+ export async function envSetFile(name, filePath, verbose = false, options = {}) {
2938
+ ensureInitialized();
2939
+ let spinner;
2940
+ try {
2941
+ const visibilityType = normalizeVisibilityType(options.visibilityType);
2942
+ const resolvedPath = normalizePath(filePath);
2943
+ if (!resolvedPath || !fs.existsSync(resolvedPath)) {
2944
+ console.error(`Error: File not found: ${filePath}`);
2945
+ process.exit(1);
2946
+ }
2947
+ spinner = ora(`Uploading env file "${name}"...`).start();
2948
+ // Read and encode file content
2949
+ const fileContent = fs.readFileSync(resolvedPath);
2950
+ const base64Content = fileContent.toString('base64');
2951
+ const payload = {
2952
+ type: 'file',
2953
+ name,
2954
+ visibilityType,
2955
+ destPath: options.destPath || name,
2956
+ project: options.project,
2957
+ };
2958
+ if (visibilityType === 'plaintext') {
2959
+ payload.value = base64Content;
2960
+ }
2961
+ else {
2962
+ Object.assign(payload, encryptSecretValue(base64Content));
2963
+ }
2964
+ const res = await axios.post(`${API_URL}/env`, payload, {
2965
+ headers: await getAuthHeaders(),
2966
+ });
2967
+ spinner.succeed(`Env file "${name}" uploaded successfully`);
2968
+ if (res.data?.message) {
2969
+ console.log(res.data.message);
2970
+ }
2971
+ }
2972
+ catch (error) {
2973
+ const apiMessage = error?.response?.data?.error ||
2974
+ error?.response?.data?.message ||
2975
+ error?.message ||
2976
+ String(error);
2977
+ spinner?.fail(`Failed to upload env file: ${apiMessage}`);
2978
+ if (verbose) {
2979
+ console.error('--- Verbose error details (env-set-file) ---');
2980
+ console.error(error);
2981
+ if (error?.response) {
2982
+ console.error('Axios response status:', error.response.status);
2983
+ console.error('Axios response data:', error.response.data);
2984
+ }
2985
+ if (error?.stack) {
2986
+ console.error(error.stack);
2987
+ }
2988
+ }
2989
+ process.exit(1);
2990
+ }
2991
+ }
2992
+ /**
2993
+ * List all environment variables/files for a project
2994
+ */
2995
+ export async function envList(verbose = false, options = {}) {
2996
+ ensureInitialized();
2997
+ let spinner;
2998
+ try {
2999
+ spinner = ora('Fetching env vars...').start();
3000
+ const params = new URLSearchParams();
3001
+ if (options.project) {
3002
+ params.set('project', options.project);
3003
+ }
3004
+ const res = await axios.get(`${API_URL}/env?${params.toString()}`, {
3005
+ headers: await getAuthHeaders(),
3006
+ });
3007
+ spinner.stop();
3008
+ const envs = res.data?.envs || [];
3009
+ const projectName = options.project || res.data?.project || 'default';
3010
+ if (envs.length === 0) {
3011
+ console.log(`\nNo environment variables found for project: ${projectName}`);
3012
+ console.log('\nTo add env vars/files, use:');
3013
+ console.log(' norrix env set <name> <value>');
3014
+ console.log(' norrix env set-file <name> <path>');
3015
+ return;
3016
+ }
3017
+ console.log(`\nEnvironment variables for project: ${projectName}\n`);
3018
+ // Separate variables and files
3019
+ const variables = envs.filter((e) => e.type === 'variable');
3020
+ const files = envs.filter((e) => e.type === 'file');
3021
+ if (variables.length > 0) {
3022
+ console.log('Variables:');
3023
+ for (const variable of variables) {
3024
+ const dateStr = variable.updatedAt
3025
+ ? `set ${new Date(variable.updatedAt).toLocaleDateString()}`
3026
+ : '';
3027
+ const visibility = variable.visibilityType || variable.visibility || 'secret';
3028
+ console.log(` • ${variable.name} (${visibility}${dateStr ? `, ${dateStr}` : ''})`);
3029
+ }
3030
+ }
3031
+ if (files.length > 0) {
3032
+ if (variables.length > 0)
3033
+ console.log('');
3034
+ console.log('Files:');
3035
+ for (const file of files) {
3036
+ const dateStr = file.updatedAt
3037
+ ? `set ${new Date(file.updatedAt).toLocaleDateString()}`
3038
+ : '';
3039
+ const visibility = file.visibilityType || file.visibility || 'secret';
3040
+ const destPath = file.destPath || file.name;
3041
+ console.log(` • ${file.name} → ${destPath} (${visibility}${dateStr ? `, ${dateStr}` : ''})`);
3042
+ }
3043
+ }
3044
+ console.log('');
3045
+ }
3046
+ catch (error) {
3047
+ const apiMessage = error?.response?.data?.error ||
3048
+ error?.response?.data?.message ||
3049
+ error?.message ||
3050
+ String(error);
3051
+ spinner?.fail(`Failed to list env vars: ${apiMessage}`);
3052
+ if (verbose) {
3053
+ console.error('--- Verbose error details (env-list) ---');
3054
+ console.error(error);
3055
+ if (error?.response) {
3056
+ console.error('Axios response status:', error.response.status);
3057
+ console.error('Axios response data:', error.response.data);
3058
+ }
3059
+ if (error?.stack) {
3060
+ console.error(error.stack);
3061
+ }
3062
+ }
3063
+ process.exit(1);
3064
+ }
3065
+ }
3066
+ /**
3067
+ * Delete an environment variable
3068
+ */
3069
+ export async function envDelete(name, verbose = false, options = {}) {
3070
+ ensureInitialized();
3071
+ let spinner;
3072
+ try {
3073
+ spinner = ora(`Deleting env var "${name}"...`).start();
3074
+ const res = await axios.delete(`${API_URL}/env`, {
3075
+ data: {
3076
+ type: 'variable',
3077
+ name,
3078
+ project: options.project,
3079
+ },
3080
+ headers: await getAuthHeaders(),
3081
+ });
3082
+ spinner.succeed(`Env var "${name}" deleted successfully`);
3083
+ if (res.data?.message) {
3084
+ console.log(res.data.message);
3085
+ }
3086
+ }
3087
+ catch (error) {
3088
+ const apiMessage = error?.response?.data?.error ||
3089
+ error?.response?.data?.message ||
3090
+ error?.message ||
3091
+ String(error);
3092
+ spinner?.fail(`Failed to delete env var: ${apiMessage}`);
3093
+ if (verbose) {
3094
+ console.error('--- Verbose error details (env-delete) ---');
3095
+ console.error(error);
3096
+ if (error?.response) {
3097
+ console.error('Axios response status:', error.response.status);
3098
+ console.error('Axios response data:', error.response.data);
3099
+ }
3100
+ if (error?.stack) {
3101
+ console.error(error.stack);
3102
+ }
3103
+ }
3104
+ process.exit(1);
3105
+ }
3106
+ }
3107
+ /**
3108
+ * Delete an environment file
3109
+ */
3110
+ export async function envDeleteFile(name, verbose = false, options = {}) {
3111
+ ensureInitialized();
3112
+ let spinner;
3113
+ try {
3114
+ spinner = ora(`Deleting env file "${name}"...`).start();
3115
+ const res = await axios.delete(`${API_URL}/env`, {
3116
+ data: {
3117
+ type: 'file',
3118
+ name,
3119
+ project: options.project,
3120
+ },
3121
+ headers: await getAuthHeaders(),
3122
+ });
3123
+ spinner.succeed(`Env file "${name}" deleted successfully`);
3124
+ if (res.data?.message) {
3125
+ console.log(res.data.message);
3126
+ }
3127
+ }
3128
+ catch (error) {
3129
+ const apiMessage = error?.response?.data?.error ||
3130
+ error?.response?.data?.message ||
3131
+ error?.message ||
3132
+ String(error);
3133
+ spinner?.fail(`Failed to delete env file: ${apiMessage}`);
3134
+ if (verbose) {
3135
+ console.error('--- Verbose error details (env-delete-file) ---');
3136
+ console.error(error);
3137
+ if (error?.response) {
3138
+ console.error('Axios response status:', error.response.status);
3139
+ console.error('Axios response data:', error.response.data);
3140
+ }
3141
+ if (error?.stack) {
3142
+ console.error(error.stack);
3143
+ }
3144
+ }
3145
+ process.exit(1);
3146
+ }
3147
+ }
3148
+ /**
3149
+ * Encrypt a secret value using the Norrix public key (envelope encryption)
3150
+ */
3151
+ function encryptSecretValue(value) {
3152
+ const publicKeyPem = process.env.NORRIX_BUILD_PUBLIC_KEY;
3153
+ if (!publicKeyPem) {
3154
+ // If no public key is configured, send value in a format the server can handle
3155
+ // The server will encrypt at rest using its own key
3156
+ return {
3157
+ encryptedValue: Buffer.from(value, 'utf8').toString('base64'),
3158
+ encryptedKey: '',
3159
+ iv: '',
3160
+ tag: '',
3161
+ };
3162
+ }
3163
+ const nodeCrypto = crypto;
3164
+ const aesKey = nodeCrypto.randomBytes(32); // AES-256
3165
+ const iv = nodeCrypto.randomBytes(12); // GCM nonce
3166
+ const cipher = nodeCrypto.createCipheriv('aes-256-gcm', aesKey, iv);
3167
+ const plaintext = Buffer.from(value, 'utf8');
3168
+ const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
3169
+ const tag = cipher.getAuthTag();
3170
+ const encryptedKey = nodeCrypto.publicEncrypt({ key: publicKeyPem, oaepHash: 'sha256' }, aesKey);
3171
+ return {
3172
+ encryptedValue: ciphertext.toString('base64'),
3173
+ encryptedKey: encryptedKey.toString('base64'),
3174
+ iv: iv.toString('base64'),
3175
+ tag: tag.toString('base64'),
3176
+ };
3177
+ }
1994
3178
  //# sourceMappingURL=commands.js.map