@norrix/cli 0.0.25 → 0.0.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,15 +10,17 @@ import archiver from 'archiver';
10
10
  import { configureAmplify, loadCliEnvFiles } from './amplify-config.js';
11
11
  import { computeFingerprint, writeRuntimeFingerprintFile } from './fingerprinting.js';
12
12
  import { loadNorrixConfig, hasNorrixConfig, saveNorrixConfig } from './config.js';
13
- import { detectWorkspaceContext, getNxProjectDependencies, getWorkspaceDependenciesFallback, createWorkspaceManifest, logWorkspaceContext, isAtWorkspaceRoot, discoverNativeScriptApps, getWorkspaceContextForApp, } from './workspace.js';
13
+ import { detectWorkspaceContext, getNxProjectDependencies, getWorkspaceDependenciesFallback, createWorkspaceManifest, logWorkspaceContext, isAtWorkspaceRoot, discoverNativeScriptApps, getWorkspaceContextForApp, detectNxBuildConfigurations, } from './workspace.js';
14
14
  import { signIn as amplifySignIn, signOut as amplifySignOut, getCurrentUser, fetchAuthSession, } from 'aws-amplify/auth';
15
15
  import crypto from 'crypto';
16
16
  import { Amplify } from 'aws-amplify';
17
17
  import { PROD_DEFAULTS } from './prod-defaults.js';
18
18
  import { DEV_DEFAULTS } from './dev-defaults.js';
19
+ import { clearSelectedOrgId, getSelectedOrgId, setSelectedOrgId } from './cli-settings.js';
19
20
  let CURRENT_ENV = 'prod';
20
21
  let CURRENT_DEFAULTS = PROD_DEFAULTS;
21
22
  let API_URL = PROD_DEFAULTS.apiUrl;
23
+ let CURRENT_ORG_ID;
22
24
  let IS_INITIALIZED = false;
23
25
  function defaultsForEnv(env) {
24
26
  return env === 'dev' ? DEV_DEFAULTS : PROD_DEFAULTS;
@@ -40,6 +42,10 @@ export function initNorrixCli(env = 'prod') {
40
42
  CURRENT_DEFAULTS = defaultsForEnv(env);
41
43
  configureAmplify(env);
42
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);
43
49
  IS_INITIALIZED = true;
44
50
  }
45
51
  function ensureInitialized() {
@@ -48,21 +54,196 @@ function ensureInitialized() {
48
54
  }
49
55
  }
50
56
  /**
51
- * Return Authorization header containing the current Cognito ID token (if signed in).
57
+ * Check if an API key is configured for authentication.
58
+ * API key takes precedence over Cognito session for CI/enterprise workflows.
52
59
  */
53
- async function getAuthHeaders() {
60
+ function getApiKey() {
61
+ const key = process.env.NORRIX_API_KEY?.trim();
62
+ if (key && key.startsWith('nrx_')) {
63
+ return key;
64
+ }
65
+ return undefined;
66
+ }
67
+ /**
68
+ * Return Authorization headers for API requests.
69
+ *
70
+ * Authentication precedence:
71
+ * 1. NORRIX_API_KEY environment variable (for CI/enterprise workflows)
72
+ * 2. Cognito session (interactive login via `norrix login`)
73
+ *
74
+ * When using an API key, the organization is determined by the key itself,
75
+ * so X-Norrix-Org-Id header is not needed.
76
+ */
77
+ async function getAuthHeaders(options) {
54
78
  ensureInitialized();
79
+ const headers = {};
80
+ // Check for API key first (takes precedence for CI workflows)
81
+ const apiKey = getApiKey();
82
+ if (apiKey) {
83
+ headers['X-Api-Key'] = apiKey;
84
+ // API keys are org-scoped, so we don't need to send org header
85
+ return headers;
86
+ }
87
+ // Fall back to Cognito session
55
88
  try {
56
89
  const session = await fetchAuthSession();
57
90
  const idToken = session.tokens?.idToken?.toString();
58
91
  if (idToken) {
59
- return { Authorization: `Bearer ${idToken}` };
92
+ headers.Authorization = `Bearer ${idToken}`;
60
93
  }
61
94
  }
62
95
  catch (_) {
63
96
  /* not signed in */
64
97
  }
65
- return {};
98
+ if (options?.includeOrg !== false && CURRENT_ORG_ID) {
99
+ headers['X-Norrix-Org-Id'] = CURRENT_ORG_ID;
100
+ }
101
+ return headers;
102
+ }
103
+ function setCurrentOrgId(orgId) {
104
+ const v = (orgId ?? '').toString().trim();
105
+ CURRENT_ORG_ID = v ? v : undefined;
106
+ }
107
+ async function fetchOrganizations(verbose = false) {
108
+ ensureInitialized();
109
+ try {
110
+ const res = await axios.get(`${API_URL}/orgs`, {
111
+ headers: await getAuthHeaders({ includeOrg: false }),
112
+ });
113
+ const organizations = Array.isArray(res.data?.organizations)
114
+ ? res.data.organizations
115
+ : [];
116
+ const selectedOrganizationId = res.data?.selectedOrganizationId
117
+ ? String(res.data.selectedOrganizationId)
118
+ : undefined;
119
+ return { organizations, selectedOrganizationId };
120
+ }
121
+ catch (err) {
122
+ if (verbose) {
123
+ console.error('--- Verbose error details (orgs fetch) ---');
124
+ console.error(err);
125
+ if (err?.response) {
126
+ console.error('Axios response status:', err.response.status);
127
+ console.error('Axios response data:', err.response.data);
128
+ }
129
+ }
130
+ throw err;
131
+ }
132
+ }
133
+ async function ensureOrgSelected(params) {
134
+ ensureInitialized();
135
+ const explicit = (params.orgIdArg ?? process.env.NORRIX_ORG_ID ?? '')
136
+ .toString()
137
+ .trim();
138
+ if (explicit) {
139
+ setCurrentOrgId(explicit);
140
+ return explicit;
141
+ }
142
+ const stored = getSelectedOrgId(CURRENT_ENV, API_URL);
143
+ if (!params.requireSelection) {
144
+ if (stored)
145
+ setCurrentOrgId(stored);
146
+ return stored;
147
+ }
148
+ // Validate stored selection (and discover orgs for prompting).
149
+ const { organizations } = await fetchOrganizations(Boolean(params.verbose));
150
+ const normalizedOrgs = organizations.filter((o) => o && o.id);
151
+ if (stored && normalizedOrgs.some((o) => o.id === stored)) {
152
+ setCurrentOrgId(stored);
153
+ return stored;
154
+ }
155
+ if (stored) {
156
+ clearSelectedOrgId(CURRENT_ENV, API_URL);
157
+ }
158
+ if (normalizedOrgs.length === 1) {
159
+ const only = normalizedOrgs[0];
160
+ setSelectedOrgId(CURRENT_ENV, API_URL, only.id);
161
+ setCurrentOrgId(only.id);
162
+ return only.id;
163
+ }
164
+ if (params.nonInteractive) {
165
+ throw new Error('No organization selected. Use --org <orgId> or run `norrix orgs select`.');
166
+ }
167
+ if (!normalizedOrgs.length) {
168
+ throw new Error('No organizations found for this user.');
169
+ }
170
+ const choices = normalizedOrgs.map((o) => {
171
+ const suffix = o.id.length > 8 ? o.id.slice(-8) : o.id;
172
+ return {
173
+ name: `${o.name} (${o.role}) • …${suffix}`,
174
+ value: o.id,
175
+ };
176
+ });
177
+ const { orgId } = await inquirer.prompt([
178
+ {
179
+ type: 'list',
180
+ name: 'orgId',
181
+ message: params.promptMessage || 'Select organization:',
182
+ choices,
183
+ },
184
+ ]);
185
+ const selected = String(orgId || '').trim();
186
+ if (!selected) {
187
+ throw new Error('Organization selection cancelled.');
188
+ }
189
+ setSelectedOrgId(CURRENT_ENV, API_URL, selected);
190
+ setCurrentOrgId(selected);
191
+ return selected;
192
+ }
193
+ export async function orgsList(verbose = false) {
194
+ ensureInitialized();
195
+ try {
196
+ const { organizations, selectedOrganizationId } = await fetchOrganizations(verbose);
197
+ if (!organizations.length) {
198
+ console.log('No organizations found.');
199
+ return;
200
+ }
201
+ console.log('Organizations:');
202
+ for (const o of organizations) {
203
+ const selectedMark = selectedOrganizationId && o.id === selectedOrganizationId ? ' (selected)' : '';
204
+ console.log(`- ${o.name} [${o.role}] ${o.id}${selectedMark}`);
205
+ }
206
+ }
207
+ catch (err) {
208
+ ora().fail(`Failed to list organizations: ${err?.message || err}`);
209
+ if (verbose && err?.response) {
210
+ console.error('Axios response status:', err.response.status);
211
+ console.error('Axios response data:', err.response.data);
212
+ }
213
+ }
214
+ }
215
+ export async function orgsSelect(verbose = false) {
216
+ ensureInitialized();
217
+ try {
218
+ await ensureOrgSelected({
219
+ requireSelection: true,
220
+ nonInteractive: false,
221
+ verbose,
222
+ promptMessage: 'Select default organization for this environment:',
223
+ });
224
+ if (CURRENT_ORG_ID) {
225
+ console.log(`✅ Selected organization: ${CURRENT_ORG_ID}`);
226
+ }
227
+ }
228
+ catch (err) {
229
+ ora().fail(`Failed to select organization: ${err?.message || err}`);
230
+ if (verbose && err?.response) {
231
+ console.error('Axios response status:', err.response.status);
232
+ console.error('Axios response data:', err.response.data);
233
+ }
234
+ }
235
+ }
236
+ export async function orgsCurrent() {
237
+ ensureInitialized();
238
+ const envOrg = (process.env.NORRIX_ORG_ID ?? '').toString().trim();
239
+ const stored = getSelectedOrgId(CURRENT_ENV, API_URL);
240
+ const current = envOrg || stored;
241
+ if (!current) {
242
+ console.log('No default organization selected for this environment.');
243
+ console.log('Run `norrix orgs select` or pass `--org <orgId>`.');
244
+ return;
245
+ }
246
+ console.log(`Current organization for ${CURRENT_ENV} (${API_URL}): ${current}`);
66
247
  }
67
248
  /**
68
249
  * Norrix CLI Command Implementations
@@ -80,6 +261,16 @@ async function getAuthHeaders() {
80
261
  // Get dirname equivalent in ESM
81
262
  const __filename = fileURLToPath(import.meta.url);
82
263
  const __dirname = path.dirname(__filename);
264
+ /**
265
+ * Safely trim a string value, returning undefined for empty/null values.
266
+ * Handles both CLI args and config file values to ensure no trailing/leading whitespace.
267
+ */
268
+ function trimString(input) {
269
+ if (input == null)
270
+ return undefined;
271
+ const trimmed = String(input).trim();
272
+ return trimmed || undefined;
273
+ }
83
274
  function normalizePath(input) {
84
275
  if (!input)
85
276
  return undefined;
@@ -715,8 +906,7 @@ async function resolveWorkspaceContext(projectArg, spinner) {
715
906
  * For Nx workspaces, this includes the app, dependent libs, and workspace config files.
716
907
  * For standalone projects, this zips the current directory.
717
908
  */
718
- async function zipProject(projectName, isUpdate = false, verbose = false) {
719
- const workspaceCtx = detectWorkspaceContext();
909
+ async function zipProject(projectName, workspaceCtx, isUpdate = false, verbose = false) {
720
910
  if (workspaceCtx.type === 'nx') {
721
911
  return zipWorkspaceProject(projectName, workspaceCtx, isUpdate, verbose);
722
912
  }
@@ -813,16 +1003,39 @@ async function zipWorkspaceProject(projectName, workspaceCtx, isUpdate = false,
813
1003
  });
814
1004
  archive.pipe(output);
815
1005
  logWorkspaceContext(workspaceCtx, verbose);
816
- // Get workspace dependencies using Nx CLI (preferred) or fallback
1006
+ // Get workspace dependencies using Nx CLI (preferred) with fallback supplementation
817
1007
  let deps;
818
1008
  if (workspaceCtx.projectName) {
819
- deps = getNxProjectDependencies(workspaceCtx.projectName, workspaceCtx.workspaceRoot, verbose);
1009
+ deps = getNxProjectDependencies(workspaceCtx.projectName, workspaceCtx.workspaceRoot, verbose, workspaceCtx.appRoot // Pass appRoot for webpack alias detection
1010
+ );
1011
+ }
1012
+ // Always supplement with fallback detection to catch anything Nx might miss
1013
+ // (e.g., dynamic imports, SCSS dependencies, transitive deps from source scanning)
1014
+ const fallbackDeps = getWorkspaceDependenciesFallback(workspaceCtx, verbose);
1015
+ if (deps) {
1016
+ // Merge fallback libs into Nx-detected libs
1017
+ const mergedLibPaths = new Set(deps.libPaths);
1018
+ for (const libPath of fallbackDeps.libPaths) {
1019
+ if (!mergedLibPaths.has(libPath)) {
1020
+ mergedLibPaths.add(libPath);
1021
+ if (verbose) {
1022
+ console.log(`[workspace] Fallback added additional lib: ${libPath}`);
1023
+ }
1024
+ }
1025
+ }
1026
+ deps.libPaths = Array.from(mergedLibPaths);
1027
+ // Also merge local file deps
1028
+ const mergedLocalFileDeps = new Set(deps.localFileDeps);
1029
+ for (const dep of fallbackDeps.localFileDeps) {
1030
+ mergedLocalFileDeps.add(dep);
1031
+ }
1032
+ deps.localFileDeps = Array.from(mergedLocalFileDeps);
820
1033
  }
821
- if (!deps) {
1034
+ else {
822
1035
  if (verbose) {
823
- console.log('[workspace] Using fallback dependency detection');
1036
+ console.log('[workspace] Using fallback dependency detection (Nx CLI not available)');
824
1037
  }
825
- deps = getWorkspaceDependenciesFallback(workspaceCtx, verbose);
1038
+ deps = fallbackDeps;
826
1039
  }
827
1040
  // Create manifest for CI
828
1041
  const manifest = createWorkspaceManifest(workspaceCtx, deps);
@@ -909,6 +1122,68 @@ async function zipWorkspaceProject(projectName, workspaceCtx, isUpdate = false,
909
1122
  archive.directory(absoluteAssetPath, assetPath);
910
1123
  }
911
1124
  }
1125
+ // 6. Add local file dependencies (file: protocol paths from package.json)
1126
+ // Skip any that are already covered by libPaths to avoid duplicate entries
1127
+ if (deps.localFileDeps && deps.localFileDeps.length > 0) {
1128
+ const addedDirs = new Set();
1129
+ // Filter out local deps that are subdirectories of already-added libs
1130
+ const filteredLocalDeps = deps.localFileDeps.filter(localDep => {
1131
+ // Check if this local dep is inside any of the lib paths
1132
+ for (const libPath of deps.libPaths) {
1133
+ if (localDep.startsWith(libPath + '/') || localDep === libPath) {
1134
+ if (verbose) {
1135
+ console.log(` - ${localDep} (skipped, covered by ${libPath})`);
1136
+ }
1137
+ return false;
1138
+ }
1139
+ }
1140
+ return true;
1141
+ });
1142
+ if (filteredLocalDeps.length > 0) {
1143
+ console.log(`Adding ${filteredLocalDeps.length} local file dependencies`);
1144
+ }
1145
+ for (const localDep of filteredLocalDeps) {
1146
+ const absoluteLocalPath = path.join(workspaceCtx.workspaceRoot, localDep);
1147
+ if (fs.existsSync(absoluteLocalPath)) {
1148
+ const stat = fs.statSync(absoluteLocalPath);
1149
+ if (stat.isFile()) {
1150
+ // For files, ensure the parent directory structure is maintained
1151
+ if (verbose) {
1152
+ console.log(` - ${localDep} (file)`);
1153
+ }
1154
+ archive.file(absoluteLocalPath, { name: localDep });
1155
+ // Also add the directory if it hasn't been added yet (for other potential files)
1156
+ const parentDir = path.dirname(localDep);
1157
+ if (parentDir && parentDir !== '.' && !addedDirs.has(parentDir)) {
1158
+ // We just add the file, not the whole directory
1159
+ }
1160
+ }
1161
+ else if (stat.isDirectory()) {
1162
+ // Skip if this directory or a parent is already in libPaths
1163
+ const alreadyCovered = deps.libPaths.some(libPath => localDep.startsWith(libPath + '/') || libPath.startsWith(localDep + '/'));
1164
+ if (alreadyCovered) {
1165
+ if (verbose) {
1166
+ console.log(` - ${localDep} (skipped, overlaps with libPaths)`);
1167
+ }
1168
+ continue;
1169
+ }
1170
+ if (verbose) {
1171
+ console.log(` - ${localDep} (directory)`);
1172
+ }
1173
+ archive.directory(absoluteLocalPath, localDep, (entry) => {
1174
+ if (entry.name.includes('node_modules')) {
1175
+ return false;
1176
+ }
1177
+ return entry;
1178
+ });
1179
+ addedDirs.add(localDep);
1180
+ }
1181
+ }
1182
+ else if (verbose) {
1183
+ console.log(` - ${localDep} (not found, skipping)`);
1184
+ }
1185
+ }
1186
+ }
912
1187
  archive.finalize();
913
1188
  });
914
1189
  }
@@ -921,6 +1196,23 @@ export async function build(cliPlatformArg, cliConfigurationArg, cliDistribution
921
1196
  // Normalize options - support both new object and legacy string projectArg
922
1197
  const opts = typeof options === 'string' ? { project: options } : (options || {});
923
1198
  ensureInitialized();
1199
+ try {
1200
+ await ensureOrgSelected({
1201
+ orgIdArg: opts.org,
1202
+ nonInteractive: opts.nonInteractive,
1203
+ requireSelection: true,
1204
+ verbose,
1205
+ promptMessage: 'Select organization for this build:',
1206
+ });
1207
+ }
1208
+ catch (error) {
1209
+ ora().fail(`Organization selection failed: ${error?.message || error}`);
1210
+ if (verbose && error?.response) {
1211
+ console.error('Axios response status:', error.response.status);
1212
+ console.error('Axios response data:', error.response.data);
1213
+ }
1214
+ return;
1215
+ }
924
1216
  let spinner;
925
1217
  let originalCwd;
926
1218
  try {
@@ -971,6 +1263,31 @@ export async function build(cliPlatformArg, cliConfigurationArg, cliDistribution
971
1263
  ]);
972
1264
  configuration = answer.configuration;
973
1265
  }
1266
+ // 2.2 Determine Nx configuration for workspace builds (CLI arg preferred, then prompt if available)
1267
+ let nxConfiguration = opts.nxConfiguration;
1268
+ if (!nxConfiguration && workspaceCtx.type === 'nx' && !opts.nonInteractive) {
1269
+ const nxConfigs = detectNxBuildConfigurations(workspaceCtx.appRoot);
1270
+ if (nxConfigs && nxConfigs.configurations.length > 0) {
1271
+ const choices = nxConfigs.configurations.map((c) => ({
1272
+ name: c === nxConfigs.defaultConfiguration ? `${c} (default)` : c,
1273
+ value: c,
1274
+ }));
1275
+ // Add option to skip/use default
1276
+ choices.unshift({ name: '(none - use defaults)', value: '' });
1277
+ const { chosenNxConfig } = await inquirer.prompt([
1278
+ {
1279
+ type: 'list',
1280
+ name: 'chosenNxConfig',
1281
+ message: 'Nx build configuration (environment):',
1282
+ choices,
1283
+ default: nxConfigs.defaultConfiguration || '',
1284
+ },
1285
+ ]);
1286
+ nxConfiguration = chosenNxConfig || undefined;
1287
+ }
1288
+ }
1289
+ // Store resolved nxConfiguration back to opts for later use
1290
+ opts.nxConfiguration = nxConfiguration;
974
1291
  const normalizeIosDistribution = (input) => {
975
1292
  const v = String(input ?? '')
976
1293
  .trim()
@@ -1098,94 +1415,147 @@ export async function build(cliPlatformArg, cliConfigurationArg, cliDistribution
1098
1415
  if (configuration === 'release') {
1099
1416
  spinner.stop();
1100
1417
  if (platform === 'ios') {
1101
- // Determine Team ID from: CLI flag > config file > prompt
1418
+ // Resolve values from: CLI flags > config file (trimmed to handle whitespace)
1102
1419
  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;
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())) {
1420
+ const configP12Path = norrixConfig.ios?.p12Path;
1421
+ const configProfilePath = norrixConfig.ios?.provisioningProfilePath;
1422
+ const configAscKeyId = norrixConfig.ios?.ascApiKeyId;
1423
+ const configAscIssuerId = norrixConfig.ios?.ascIssuerId;
1424
+ const configAscKeyPath = norrixConfig.ios?.ascPrivateKeyPath;
1425
+ const resolvedTeamId = trimString(opts.teamId) || configTeamId;
1426
+ const resolvedP12Path = trimString(opts.p12Path) || configP12Path;
1427
+ const resolvedP12Password = trimString(opts.p12Password);
1428
+ const resolvedProfilePath = trimString(opts.profilePath) || configProfilePath;
1429
+ const resolvedAscKeyId = trimString(opts.ascKeyId) || configAscKeyId;
1430
+ const resolvedAscIssuerId = trimString(opts.ascIssuerId) || configAscIssuerId;
1431
+ const resolvedAscKeyPath = trimString(opts.ascKeyPath) || configAscKeyPath;
1432
+ if (opts.nonInteractive) {
1433
+ // Non-interactive mode: use CLI flags and config values only
1434
+ iosCredentials = {
1435
+ teamId: resolvedTeamId || undefined,
1436
+ p12Base64: readOptionalFileAsBase64(resolvedP12Path),
1437
+ p12Password: resolvedP12Password || undefined,
1438
+ mobileprovisionBase64: readOptionalFileAsBase64(resolvedProfilePath),
1439
+ ascApiKeyId: resolvedAscKeyId || undefined,
1440
+ ascIssuerId: resolvedAscIssuerId || undefined,
1441
+ ascPrivateKey: readOptionalFileAsBase64(resolvedAscKeyPath),
1442
+ };
1443
+ if (resolvedTeamId) {
1444
+ console.log(`Using Apple Team ID: ${resolvedTeamId}`);
1445
+ }
1446
+ if (resolvedAscKeyId) {
1447
+ console.log(`Using ASC API Key: ${resolvedAscKeyId}`);
1448
+ }
1449
+ }
1450
+ else {
1451
+ // Interactive mode: prompt for values with CLI/config as defaults
1452
+ // Flow: Team ID -> ASC Key (recommended) -> If no ASC, then .p12/.mobileprovision
1453
+ const iosAnswers = await inquirer.prompt([
1454
+ {
1455
+ type: 'input',
1456
+ name: 'teamId',
1457
+ message: 'Apple Developer Team ID (required for code signing, e.g. "ABC123XYZ"):',
1458
+ default: resolvedTeamId || '',
1459
+ validate: (input) => {
1460
+ if (!input.trim()) {
1461
+ return true; // Allow empty, workflow will try to proceed without it
1462
+ }
1463
+ if (/^[A-Z0-9]{10}$/.test(input.trim())) {
1464
+ return true;
1465
+ }
1466
+ return 'Team ID should be 10 alphanumeric characters (e.g. "ABC123XYZ"). Leave empty to skip.';
1467
+ },
1468
+ },
1469
+ // ASC API Key is the recommended approach - ask first
1470
+ {
1471
+ type: 'confirm',
1472
+ name: 'useAscKey',
1473
+ message: 'Use App Store Connect API Key for auto-provisioning? (recommended)',
1474
+ default: Boolean(resolvedAscKeyId) || true,
1475
+ },
1476
+ {
1477
+ type: 'input',
1478
+ name: 'ascApiKeyId',
1479
+ message: 'ASC API Key ID:',
1480
+ default: resolvedAscKeyId || '',
1481
+ when: (a) => a.useAscKey,
1482
+ validate: (input) => {
1483
+ if (!input.trim()) {
1484
+ return 'API Key ID is required when using ASC Key';
1485
+ }
1121
1486
  return true;
1122
- }
1123
- return 'Team ID should be 10 alphanumeric characters (e.g. "ABC123XYZ"). Leave empty to skip.';
1487
+ },
1124
1488
  },
1125
- },
1126
- {
1127
- type: 'input',
1128
- name: 'p12Path',
1129
- message: 'Path to iOS .p12 certificate (optional):',
1130
- default: '',
1131
- },
1132
- {
1133
- type: 'password',
1134
- name: 'p12Password',
1135
- message: 'Password for .p12 (if any):',
1136
- mask: '*',
1137
- default: '',
1138
- },
1139
- {
1140
- type: 'input',
1141
- name: 'mobileprovisionPath',
1142
- message: 'Path to provisioning profile .mobileprovision (optional):',
1143
- default: '',
1144
- },
1145
- {
1146
- type: 'confirm',
1147
- name: 'hasAscKey',
1148
- message: 'Provide App Store Connect API Key? (optional, for auto-provisioning)',
1149
- default: false,
1150
- },
1151
- {
1152
- type: 'input',
1153
- name: 'ascApiKeyId',
1154
- message: 'ASC API Key ID (optional):',
1155
- default: '',
1156
- when: (a) => a.hasAscKey,
1157
- },
1158
- {
1159
- type: 'input',
1160
- name: 'ascIssuerId',
1161
- message: 'ASC Issuer ID (optional):',
1162
- default: '',
1163
- when: (a) => a.hasAscKey,
1164
- },
1165
- {
1166
- type: 'input',
1167
- name: 'ascPrivateKeyPath',
1168
- message: 'Path to ASC private key .p8 (optional):',
1169
- default: '',
1170
- when: (a) => a.hasAscKey,
1171
- },
1172
- ]);
1173
- // Use resolved teamId from CLI/config, or from prompt
1174
- const finalTeamId = resolvedTeamId || iosAnswers.teamId?.trim();
1175
- iosCredentials = {
1176
- teamId: finalTeamId || undefined,
1177
- p12Base64: readOptionalFileAsBase64(iosAnswers.p12Path),
1178
- p12Password: iosAnswers.p12Password || undefined,
1179
- mobileprovisionBase64: readOptionalFileAsBase64(iosAnswers.mobileprovisionPath),
1180
- ascApiKeyId: iosAnswers.ascApiKeyId || undefined,
1181
- ascIssuerId: iosAnswers.ascIssuerId || undefined,
1182
- ascPrivateKey: readOptionalFileAsBase64(iosAnswers.ascPrivateKeyPath),
1183
- // Track paths for config saving (not sent to API)
1184
- _p12Path: iosAnswers.p12Path || undefined,
1185
- _mobileprovisionPath: iosAnswers.mobileprovisionPath || undefined,
1186
- };
1187
- if (finalTeamId) {
1188
- console.log(`Using Apple Team ID: ${finalTeamId}`);
1489
+ {
1490
+ type: 'input',
1491
+ name: 'ascIssuerId',
1492
+ message: 'ASC Issuer ID:',
1493
+ default: resolvedAscIssuerId || '',
1494
+ when: (a) => a.useAscKey,
1495
+ validate: (input) => {
1496
+ if (!input.trim()) {
1497
+ return 'Issuer ID is required when using ASC Key';
1498
+ }
1499
+ return true;
1500
+ },
1501
+ },
1502
+ {
1503
+ type: 'input',
1504
+ name: 'ascPrivateKeyPath',
1505
+ message: 'Path to ASC private key .p8:',
1506
+ default: resolvedAscKeyPath || '',
1507
+ when: (a) => a.useAscKey,
1508
+ validate: (input) => {
1509
+ if (!input.trim()) {
1510
+ return 'Path to .p8 key file is required when using ASC Key';
1511
+ }
1512
+ return true;
1513
+ },
1514
+ },
1515
+ // Only ask for .p12/.mobileprovision if NOT using ASC Key
1516
+ {
1517
+ type: 'input',
1518
+ name: 'p12Path',
1519
+ message: 'Path to iOS .p12 certificate (optional):',
1520
+ default: resolvedP12Path || '',
1521
+ when: (a) => !a.useAscKey,
1522
+ },
1523
+ {
1524
+ type: 'password',
1525
+ name: 'p12Password',
1526
+ message: 'Password for .p12 (if any):',
1527
+ mask: '*',
1528
+ default: '',
1529
+ when: (a) => !a.useAscKey && a.p12Path,
1530
+ },
1531
+ {
1532
+ type: 'input',
1533
+ name: 'mobileprovisionPath',
1534
+ message: 'Path to provisioning profile .mobileprovision (optional):',
1535
+ default: resolvedProfilePath || '',
1536
+ when: (a) => !a.useAscKey,
1537
+ },
1538
+ ]);
1539
+ // Use resolved teamId from CLI/config, or from prompt
1540
+ const finalTeamId = trimString(iosAnswers.teamId) || resolvedTeamId;
1541
+ iosCredentials = {
1542
+ teamId: finalTeamId || undefined,
1543
+ p12Base64: readOptionalFileAsBase64(iosAnswers.p12Path),
1544
+ p12Password: trimString(iosAnswers.p12Password),
1545
+ mobileprovisionBase64: readOptionalFileAsBase64(iosAnswers.mobileprovisionPath),
1546
+ ascApiKeyId: trimString(iosAnswers.ascApiKeyId),
1547
+ ascIssuerId: trimString(iosAnswers.ascIssuerId),
1548
+ ascPrivateKey: readOptionalFileAsBase64(iosAnswers.ascPrivateKeyPath),
1549
+ // Track paths for config saving (not sent to API)
1550
+ _p12Path: trimString(iosAnswers.p12Path),
1551
+ _mobileprovisionPath: trimString(iosAnswers.mobileprovisionPath),
1552
+ _ascApiKeyId: trimString(iosAnswers.ascApiKeyId),
1553
+ _ascIssuerId: trimString(iosAnswers.ascIssuerId),
1554
+ _ascPrivateKeyPath: trimString(iosAnswers.ascPrivateKeyPath),
1555
+ };
1556
+ if (finalTeamId) {
1557
+ console.log(`Using Apple Team ID: ${finalTeamId}`);
1558
+ }
1189
1559
  }
1190
1560
  }
1191
1561
  else if (platform === 'android') {
@@ -1232,14 +1602,14 @@ export async function build(cliPlatformArg, cliConfigurationArg, cliDistribution
1232
1602
  ]);
1233
1603
  androidCredentials = {
1234
1604
  keystoreBase64: readOptionalFileAsBase64(androidAnswers.keystorePath),
1235
- keystorePassword: androidAnswers.keystorePassword || undefined,
1236
- keyAlias: androidAnswers.keyAlias || undefined,
1237
- keyPassword: androidAnswers.keyPassword || undefined,
1605
+ keystorePassword: trimString(androidAnswers.keystorePassword),
1606
+ keyAlias: trimString(androidAnswers.keyAlias),
1607
+ keyPassword: trimString(androidAnswers.keyPassword),
1238
1608
  playServiceAccountJson: readOptionalFileAsBase64(androidAnswers.playJsonPath),
1239
1609
  };
1240
1610
  // Track Android paths for config saving
1241
- androidCredentials._keystorePath = androidAnswers.keystorePath || undefined;
1242
- androidCredentials._keyAlias = androidAnswers.keyAlias || undefined;
1611
+ androidCredentials._keystorePath = trimString(androidAnswers.keystorePath);
1612
+ androidCredentials._keyAlias = trimString(androidAnswers.keyAlias);
1243
1613
  }
1244
1614
  // Offer to save config if no norrix.config.ts exists and we collected useful values
1245
1615
  const appRoot = process.cwd();
@@ -1263,6 +1633,16 @@ export async function build(cliPlatformArg, cliConfigurationArg, cliDistribution
1263
1633
  if (iosCredentials._mobileprovisionPath) {
1264
1634
  saveableOptions.provisioningProfilePath = iosCredentials._mobileprovisionPath;
1265
1635
  }
1636
+ // Save ASC API Key details for future builds
1637
+ if (iosCredentials._ascApiKeyId) {
1638
+ saveableOptions.ascApiKeyId = iosCredentials._ascApiKeyId;
1639
+ }
1640
+ if (iosCredentials._ascIssuerId) {
1641
+ saveableOptions.ascIssuerId = iosCredentials._ascIssuerId;
1642
+ }
1643
+ if (iosCredentials._ascPrivateKeyPath) {
1644
+ saveableOptions.ascPrivateKeyPath = iosCredentials._ascPrivateKeyPath;
1645
+ }
1266
1646
  }
1267
1647
  if (platform === 'android' && androidCredentials) {
1268
1648
  if (androidCredentials._keystorePath) {
@@ -1277,7 +1657,8 @@ export async function build(cliPlatformArg, cliConfigurationArg, cliDistribution
1277
1657
  saveableOptions.distributionType ||
1278
1658
  saveableOptions.p12Path ||
1279
1659
  saveableOptions.provisioningProfilePath ||
1280
- saveableOptions.keystorePath;
1660
+ saveableOptions.keystorePath ||
1661
+ saveableOptions.ascApiKeyId;
1281
1662
  if (hasSaveableValues) {
1282
1663
  const { shouldSave } = await inquirer.prompt([
1283
1664
  {
@@ -1309,7 +1690,7 @@ export async function build(cliPlatformArg, cliConfigurationArg, cliDistribution
1309
1690
  writeRuntimeFingerprintFile(projectRoot, fingerprint, platform);
1310
1691
  spinner.start('Creating project archive...');
1311
1692
  // 3. Zip the project (workspace-aware)
1312
- const { zipPath, workspaceContext } = await zipProject(projectName, false, verbose);
1693
+ const { zipPath, workspaceContext } = await zipProject(projectName, workspaceCtx, false, verbose);
1313
1694
  spinner.text = 'Project archive created';
1314
1695
  // 4. Upload the project zip to S3
1315
1696
  spinner.text = 'Working...';
@@ -1342,6 +1723,11 @@ export async function build(cliPlatformArg, cliConfigurationArg, cliDistribution
1342
1723
  appPath: workspaceContext.relativeAppPath,
1343
1724
  projectName: workspaceContext.projectName,
1344
1725
  } : undefined;
1726
+ // For standalone projects, use the project option or infer from app ID/package name
1727
+ // This allows env vars to be scoped to specific standalone projects within an org
1728
+ const standaloneProjectName = workspaceContext.type === 'standalone'
1729
+ ? (opts.project || inferredAppId || projectName)
1730
+ : undefined;
1345
1731
  const response = await axios.post(`${API_URL}/build`, {
1346
1732
  projectName,
1347
1733
  appId: inferredAppId,
@@ -1351,12 +1737,18 @@ export async function build(cliPlatformArg, cliConfigurationArg, cliDistribution
1351
1737
  version: version || '',
1352
1738
  buildNumber: buildNumber || '',
1353
1739
  configuration,
1740
+ // Nx configuration (e.g., 'prod', 'stg', 'dev') for monorepo builds
1741
+ ...(opts.nxConfiguration ? { nxConfiguration: opts.nxConfiguration } : {}),
1354
1742
  ...(distributionType ? { distributionType } : {}),
1743
+ // Android package type override (apk or aab) - takes precedence over distributionType
1744
+ ...(opts.androidPackageType ? { androidPackageType: opts.androidPackageType } : {}),
1355
1745
  fingerprint,
1356
1746
  // Provide the relative key (without public/) – the workflow prepends public/
1357
1747
  s3Key: s3KeyRel,
1358
1748
  // Workspace context for Nx monorepos
1359
1749
  ...(workspaceInfo ? { workspace: workspaceInfo } : {}),
1750
+ // For standalone projects, include project name for env var scoping
1751
+ ...(standaloneProjectName ? { projectName: standaloneProjectName } : {}),
1360
1752
  // Only include raw credentials if not encrypted
1361
1753
  ...(encryptedSecrets ? { encryptedSecrets } : {}),
1362
1754
  ...(!encryptedSecrets && iosCredentials ? { iosCredentials } : {}),
@@ -1462,8 +1854,25 @@ export async function build(cliPlatformArg, cliConfigurationArg, cliDistribution
1462
1854
  * Submit command implementation
1463
1855
  * Submits the built app to app stores via the Next.js API gateway
1464
1856
  */
1465
- export async function submit(cliPlatformArg, cliTrackArg, verbose = false) {
1857
+ export async function submit(cliPlatformArg, cliTrackArg, verbose = false, options) {
1466
1858
  ensureInitialized();
1859
+ try {
1860
+ await ensureOrgSelected({
1861
+ orgIdArg: options?.org,
1862
+ nonInteractive: Boolean(options?.nonInteractive),
1863
+ requireSelection: true,
1864
+ verbose,
1865
+ promptMessage: 'Select organization for this submission:',
1866
+ });
1867
+ }
1868
+ catch (error) {
1869
+ ora().fail(`Organization selection failed: ${error?.message || error}`);
1870
+ if (verbose && error?.response) {
1871
+ console.error('Axios response status:', error.response.status);
1872
+ console.error('Axios response data:', error.response.data);
1873
+ }
1874
+ return;
1875
+ }
1467
1876
  const spinner = ora('Preparing app for submission...');
1468
1877
  try {
1469
1878
  spinner.start();
@@ -1727,6 +2136,23 @@ export async function update(cliPlatformArg, cliVersionArg, verbose = false, opt
1727
2136
  // Normalize options - support both new object and legacy string projectArg
1728
2137
  const opts = typeof options === 'string' ? { project: options } : (options || {});
1729
2138
  ensureInitialized();
2139
+ try {
2140
+ await ensureOrgSelected({
2141
+ orgIdArg: opts.org,
2142
+ nonInteractive: opts.nonInteractive,
2143
+ requireSelection: true,
2144
+ verbose,
2145
+ promptMessage: 'Select organization for this update:',
2146
+ });
2147
+ }
2148
+ catch (error) {
2149
+ ora().fail(`Organization selection failed: ${error?.message || error}`);
2150
+ if (verbose && error?.response) {
2151
+ console.error('Axios response status:', error.response.status);
2152
+ console.error('Axios response data:', error.response.data);
2153
+ }
2154
+ return;
2155
+ }
1730
2156
  let spinner;
1731
2157
  let originalCwd;
1732
2158
  try {
@@ -1759,6 +2185,33 @@ export async function update(cliPlatformArg, cliVersionArg, verbose = false, opt
1759
2185
  platform = chosenPlatform;
1760
2186
  spinner.start('Preparing over-the-air update...');
1761
2187
  }
2188
+ // Determine Nx configuration for workspace builds (CLI arg preferred, then prompt if available)
2189
+ let nxConfiguration = opts.nxConfiguration;
2190
+ if (!nxConfiguration && workspaceCtx.type === 'nx' && !opts.nonInteractive) {
2191
+ spinner.stop();
2192
+ const nxConfigs = detectNxBuildConfigurations(workspaceCtx.appRoot);
2193
+ if (nxConfigs && nxConfigs.configurations.length > 0) {
2194
+ const choices = nxConfigs.configurations.map((c) => ({
2195
+ name: c === nxConfigs.defaultConfiguration ? `${c} (default)` : c,
2196
+ value: c,
2197
+ }));
2198
+ // Add option to skip/use default
2199
+ choices.unshift({ name: '(none - use defaults)', value: '' });
2200
+ const { chosenNxConfig } = await inquirer.prompt([
2201
+ {
2202
+ type: 'list',
2203
+ name: 'chosenNxConfig',
2204
+ message: 'Nx build configuration (environment):',
2205
+ choices,
2206
+ default: nxConfigs.defaultConfiguration || '',
2207
+ },
2208
+ ]);
2209
+ nxConfiguration = chosenNxConfig || undefined;
2210
+ }
2211
+ spinner.start('Preparing over-the-air update...');
2212
+ }
2213
+ // Store resolved nxConfiguration back to opts for later use
2214
+ opts.nxConfiguration = nxConfiguration;
1762
2215
  // Infer version from native project files (same as build)
1763
2216
  const appleVersionInfo = platform === 'ios' || platform === 'visionos'
1764
2217
  ? getAppleVersionFromInfoPlist(platform)
@@ -1963,7 +2416,7 @@ export async function update(cliPlatformArg, cliVersionArg, verbose = false, opt
1963
2416
  spinner.start('Packaging for over-the-air update...');
1964
2417
  // Create the update bundle (workspace-aware) - pass true to include node_modules for updates
1965
2418
  const projectName = await getProjectName();
1966
- const { zipPath, workspaceContext } = await zipProject(projectName, true, verbose);
2419
+ const { zipPath, workspaceContext } = await zipProject(projectName, workspaceCtx, true, verbose);
1967
2420
  spinner.text = 'Uploading update to Norrix cloud storage...';
1968
2421
  const fileBuffer = fs.readFileSync(zipPath);
1969
2422
  const updateFolder = `update-${Date.now()}`;
@@ -1977,6 +2430,11 @@ export async function update(cliPlatformArg, cliVersionArg, verbose = false, opt
1977
2430
  appPath: workspaceContext.relativeAppPath,
1978
2431
  projectName: workspaceContext.projectName,
1979
2432
  } : undefined;
2433
+ // For standalone projects, use the project option or infer from app ID
2434
+ // This allows env vars to be scoped to specific standalone projects within an org
2435
+ const standaloneProjectName = workspaceContext.type === 'standalone'
2436
+ ? (opts.project || appId)
2437
+ : undefined;
1980
2438
  const response = await axios.post(`${API_URL}/update`, {
1981
2439
  appId,
1982
2440
  platform,
@@ -1984,10 +2442,14 @@ export async function update(cliPlatformArg, cliVersionArg, verbose = false, opt
1984
2442
  buildNumber: buildNumber || '',
1985
2443
  releaseNotes: notes,
1986
2444
  fingerprint,
2445
+ // Nx configuration (e.g., 'prod', 'stg', 'dev') for monorepo builds
2446
+ ...(opts.nxConfiguration ? { nxConfiguration: opts.nxConfiguration } : {}),
1987
2447
  // Provide the relative key (without public/). Consumers will prepend public/
1988
2448
  s3Key: s3KeyRel,
1989
2449
  // Workspace context for Nx monorepos
1990
2450
  ...(workspaceInfo ? { workspace: workspaceInfo } : {}),
2451
+ // For standalone projects, include project name for env var scoping
2452
+ ...(standaloneProjectName ? { projectName: standaloneProjectName } : {}),
1991
2453
  }, {
1992
2454
  headers: {
1993
2455
  'Content-Type': 'application/json',
@@ -2079,8 +2541,20 @@ export async function update(cliPlatformArg, cliVersionArg, verbose = false, opt
2079
2541
  * Build Status command implementation
2080
2542
  * Checks the status of a build via the Next.js API gateway
2081
2543
  */
2082
- export async function buildStatus(buildId, verbose = false) {
2544
+ export async function buildStatus(buildId, verbose = false, options) {
2083
2545
  ensureInitialized();
2546
+ try {
2547
+ await ensureOrgSelected({
2548
+ orgIdArg: options?.org,
2549
+ nonInteractive: true,
2550
+ requireSelection: false,
2551
+ verbose,
2552
+ });
2553
+ }
2554
+ catch (error) {
2555
+ ora().fail(`Organization selection failed: ${error?.message || error}`);
2556
+ return;
2557
+ }
2084
2558
  try {
2085
2559
  const spinner = ora(`Checking status of build ${buildId}...`).start();
2086
2560
  const response = await axios.get(`${API_URL}/build/${buildId}`, {
@@ -2117,8 +2591,20 @@ export async function buildStatus(buildId, verbose = false) {
2117
2591
  * Submit Status command implementation
2118
2592
  * Checks the status of a submission via the Next.js API gateway
2119
2593
  */
2120
- export async function submitStatus(submitId, verbose = false) {
2594
+ export async function submitStatus(submitId, verbose = false, options) {
2121
2595
  ensureInitialized();
2596
+ try {
2597
+ await ensureOrgSelected({
2598
+ orgIdArg: options?.org,
2599
+ nonInteractive: true,
2600
+ requireSelection: false,
2601
+ verbose,
2602
+ });
2603
+ }
2604
+ catch (error) {
2605
+ ora().fail(`Organization selection failed: ${error?.message || error}`);
2606
+ return;
2607
+ }
2122
2608
  try {
2123
2609
  const spinner = ora(`Checking status of submission ${submitId}...`).start();
2124
2610
  const response = await axios.get(`${API_URL}/submit/${submitId}`, {
@@ -2151,8 +2637,20 @@ export async function submitStatus(submitId, verbose = false) {
2151
2637
  * Update Status command implementation
2152
2638
  * Checks the status of an update via the Next.js API gateway
2153
2639
  */
2154
- export async function updateStatus(updateId, verbose = false) {
2640
+ export async function updateStatus(updateId, verbose = false, options) {
2155
2641
  ensureInitialized();
2642
+ try {
2643
+ await ensureOrgSelected({
2644
+ orgIdArg: options?.org,
2645
+ nonInteractive: true,
2646
+ requireSelection: false,
2647
+ verbose,
2648
+ });
2649
+ }
2650
+ catch (error) {
2651
+ ora().fail(`Organization selection failed: ${error?.message || error}`);
2652
+ return;
2653
+ }
2156
2654
  try {
2157
2655
  const spinner = ora(`Checking status of update ${updateId}...`).start();
2158
2656
  const response = await axios.get(`${API_URL}/update/${updateId}`, {
@@ -2290,6 +2788,41 @@ export async function uploadFile(filePath, options, verbose = false) {
2290
2788
  */
2291
2789
  export async function currentUser(verbose = false) {
2292
2790
  ensureInitialized();
2791
+ // Check for API key authentication first
2792
+ const apiKey = getApiKey();
2793
+ if (apiKey) {
2794
+ const spinner = ora('Verifying API key...').start();
2795
+ try {
2796
+ // Make a request to verify the API key and get user info
2797
+ const response = await axios.get(`${API_URL}/user`, {
2798
+ headers: await getAuthHeaders(),
2799
+ });
2800
+ spinner.stop();
2801
+ const user = response.data?.user;
2802
+ if (user) {
2803
+ console.log('🔑 Authenticated via API key');
2804
+ console.log(`Email: ${user.email}`);
2805
+ console.log(`Username: ${user.username ?? 'N/A'}`);
2806
+ console.log(`Organization: ${response.data?.organization?.name ?? 'N/A'}`);
2807
+ console.log(`API Key: ${apiKey.substring(0, 12)}...`);
2808
+ }
2809
+ else {
2810
+ console.log('🔑 API key configured but unable to fetch user details.');
2811
+ }
2812
+ return;
2813
+ }
2814
+ catch (error) {
2815
+ spinner.stop();
2816
+ if (error?.response?.status === 401) {
2817
+ console.log('⚠️ API key is invalid or expired.');
2818
+ }
2819
+ else {
2820
+ console.log('⚠️ Failed to verify API key:', error?.message || error);
2821
+ }
2822
+ return;
2823
+ }
2824
+ }
2825
+ // Fall back to Cognito session
2293
2826
  try {
2294
2827
  const spinner = ora('Fetching current user...').start();
2295
2828
  let user;
@@ -2309,6 +2842,10 @@ export async function currentUser(verbose = false) {
2309
2842
  spinner.stop();
2310
2843
  if (!user) {
2311
2844
  console.log('⚠️ No user is currently signed in.');
2845
+ console.log('');
2846
+ console.log('To authenticate, either:');
2847
+ console.log(' • Run `norrix sign-in` for interactive login');
2848
+ console.log(' • Set NORRIX_API_KEY environment variable for CI/automation');
2312
2849
  return;
2313
2850
  }
2314
2851
  console.log('Signed-in user info:');
@@ -2397,4 +2934,310 @@ export async function billingPortal(verbose = false) {
2397
2934
  }
2398
2935
  }
2399
2936
  }
2937
+ function normalizeVisibilityType(input) {
2938
+ if (!input)
2939
+ return 'secret';
2940
+ if (input === 'plaintext' || input === 'secret')
2941
+ return input;
2942
+ console.error('Error: --visibility must be either "plaintext" or "secret"');
2943
+ process.exit(1);
2944
+ }
2945
+ /**
2946
+ * Set an environment variable for build-time injection
2947
+ */
2948
+ export async function envSet(name, value, verbose = false, options = {}) {
2949
+ ensureInitialized();
2950
+ let spinner;
2951
+ try {
2952
+ const visibilityType = normalizeVisibilityType(options.visibilityType);
2953
+ // Validate secret name (env var format)
2954
+ if (!/^[A-Z_][A-Z0-9_]*$/i.test(name)) {
2955
+ console.error('Error: Name must be a valid environment variable name (alphanumeric and underscores, cannot start with a number)');
2956
+ process.exit(1);
2957
+ }
2958
+ spinner = ora(`Setting env var "${name}"...`).start();
2959
+ const payload = {
2960
+ type: 'variable',
2961
+ name,
2962
+ visibilityType,
2963
+ project: options.project,
2964
+ };
2965
+ if (visibilityType === 'plaintext') {
2966
+ payload.value = value;
2967
+ }
2968
+ else {
2969
+ Object.assign(payload, encryptSecretValue(value));
2970
+ }
2971
+ const res = await axios.post(`${API_URL}/env`, payload, {
2972
+ headers: await getAuthHeaders(),
2973
+ });
2974
+ spinner.succeed(`Env var "${name}" set successfully`);
2975
+ if (res.data?.message) {
2976
+ console.log(res.data.message);
2977
+ }
2978
+ }
2979
+ catch (error) {
2980
+ const apiMessage = error?.response?.data?.error ||
2981
+ error?.response?.data?.message ||
2982
+ error?.message ||
2983
+ String(error);
2984
+ spinner?.fail(`Failed to set env var: ${apiMessage}`);
2985
+ if (verbose) {
2986
+ console.error('--- Verbose error details (env-set) ---');
2987
+ console.error(error);
2988
+ if (error?.response) {
2989
+ console.error('Axios response status:', error.response.status);
2990
+ console.error('Axios response data:', error.response.data);
2991
+ }
2992
+ if (error?.stack) {
2993
+ console.error(error.stack);
2994
+ }
2995
+ }
2996
+ process.exit(1);
2997
+ }
2998
+ }
2999
+ /**
3000
+ * Upload an environment file for build-time injection
3001
+ */
3002
+ export async function envSetFile(name, filePath, verbose = false, options = {}) {
3003
+ ensureInitialized();
3004
+ let spinner;
3005
+ try {
3006
+ const visibilityType = normalizeVisibilityType(options.visibilityType);
3007
+ const resolvedPath = normalizePath(filePath);
3008
+ if (!resolvedPath || !fs.existsSync(resolvedPath)) {
3009
+ console.error(`Error: File not found: ${filePath}`);
3010
+ process.exit(1);
3011
+ }
3012
+ spinner = ora(`Uploading env file "${name}"...`).start();
3013
+ // Read and encode file content
3014
+ const fileContent = fs.readFileSync(resolvedPath);
3015
+ const base64Content = fileContent.toString('base64');
3016
+ const payload = {
3017
+ type: 'file',
3018
+ name,
3019
+ visibilityType,
3020
+ destPath: options.destPath || name,
3021
+ project: options.project,
3022
+ };
3023
+ if (visibilityType === 'plaintext') {
3024
+ payload.value = base64Content;
3025
+ }
3026
+ else {
3027
+ Object.assign(payload, encryptSecretValue(base64Content));
3028
+ }
3029
+ const res = await axios.post(`${API_URL}/env`, payload, {
3030
+ headers: await getAuthHeaders(),
3031
+ });
3032
+ spinner.succeed(`Env file "${name}" uploaded successfully`);
3033
+ if (res.data?.message) {
3034
+ console.log(res.data.message);
3035
+ }
3036
+ }
3037
+ catch (error) {
3038
+ const apiMessage = error?.response?.data?.error ||
3039
+ error?.response?.data?.message ||
3040
+ error?.message ||
3041
+ String(error);
3042
+ spinner?.fail(`Failed to upload env file: ${apiMessage}`);
3043
+ if (verbose) {
3044
+ console.error('--- Verbose error details (env-set-file) ---');
3045
+ console.error(error);
3046
+ if (error?.response) {
3047
+ console.error('Axios response status:', error.response.status);
3048
+ console.error('Axios response data:', error.response.data);
3049
+ }
3050
+ if (error?.stack) {
3051
+ console.error(error.stack);
3052
+ }
3053
+ }
3054
+ process.exit(1);
3055
+ }
3056
+ }
3057
+ /**
3058
+ * List all environment variables/files for a project
3059
+ */
3060
+ export async function envList(verbose = false, options = {}) {
3061
+ ensureInitialized();
3062
+ let spinner;
3063
+ try {
3064
+ spinner = ora('Fetching env vars...').start();
3065
+ const params = new URLSearchParams();
3066
+ if (options.project) {
3067
+ params.set('project', options.project);
3068
+ }
3069
+ const res = await axios.get(`${API_URL}/env?${params.toString()}`, {
3070
+ headers: await getAuthHeaders(),
3071
+ });
3072
+ spinner.stop();
3073
+ const envs = res.data?.envs || [];
3074
+ const projectName = options.project || res.data?.project || 'default';
3075
+ if (envs.length === 0) {
3076
+ console.log(`\nNo environment variables found for project: ${projectName}`);
3077
+ console.log('\nTo add env vars/files, use:');
3078
+ console.log(' norrix env set <name> <value>');
3079
+ console.log(' norrix env set-file <name> <path>');
3080
+ return;
3081
+ }
3082
+ console.log(`\nEnvironment variables for project: ${projectName}\n`);
3083
+ // Separate variables and files
3084
+ const variables = envs.filter((e) => e.type === 'variable');
3085
+ const files = envs.filter((e) => e.type === 'file');
3086
+ if (variables.length > 0) {
3087
+ console.log('Variables:');
3088
+ for (const variable of variables) {
3089
+ const dateStr = variable.updatedAt
3090
+ ? `set ${new Date(variable.updatedAt).toLocaleDateString()}`
3091
+ : '';
3092
+ const visibility = variable.visibilityType || variable.visibility || 'secret';
3093
+ console.log(` • ${variable.name} (${visibility}${dateStr ? `, ${dateStr}` : ''})`);
3094
+ }
3095
+ }
3096
+ if (files.length > 0) {
3097
+ if (variables.length > 0)
3098
+ console.log('');
3099
+ console.log('Files:');
3100
+ for (const file of files) {
3101
+ const dateStr = file.updatedAt
3102
+ ? `set ${new Date(file.updatedAt).toLocaleDateString()}`
3103
+ : '';
3104
+ const visibility = file.visibilityType || file.visibility || 'secret';
3105
+ const destPath = file.destPath || file.name;
3106
+ console.log(` • ${file.name} → ${destPath} (${visibility}${dateStr ? `, ${dateStr}` : ''})`);
3107
+ }
3108
+ }
3109
+ console.log('');
3110
+ }
3111
+ catch (error) {
3112
+ const apiMessage = error?.response?.data?.error ||
3113
+ error?.response?.data?.message ||
3114
+ error?.message ||
3115
+ String(error);
3116
+ spinner?.fail(`Failed to list env vars: ${apiMessage}`);
3117
+ if (verbose) {
3118
+ console.error('--- Verbose error details (env-list) ---');
3119
+ console.error(error);
3120
+ if (error?.response) {
3121
+ console.error('Axios response status:', error.response.status);
3122
+ console.error('Axios response data:', error.response.data);
3123
+ }
3124
+ if (error?.stack) {
3125
+ console.error(error.stack);
3126
+ }
3127
+ }
3128
+ process.exit(1);
3129
+ }
3130
+ }
3131
+ /**
3132
+ * Delete an environment variable
3133
+ */
3134
+ export async function envDelete(name, verbose = false, options = {}) {
3135
+ ensureInitialized();
3136
+ let spinner;
3137
+ try {
3138
+ spinner = ora(`Deleting env var "${name}"...`).start();
3139
+ const res = await axios.delete(`${API_URL}/env`, {
3140
+ data: {
3141
+ type: 'variable',
3142
+ name,
3143
+ project: options.project,
3144
+ },
3145
+ headers: await getAuthHeaders(),
3146
+ });
3147
+ spinner.succeed(`Env var "${name}" deleted successfully`);
3148
+ if (res.data?.message) {
3149
+ console.log(res.data.message);
3150
+ }
3151
+ }
3152
+ catch (error) {
3153
+ const apiMessage = error?.response?.data?.error ||
3154
+ error?.response?.data?.message ||
3155
+ error?.message ||
3156
+ String(error);
3157
+ spinner?.fail(`Failed to delete env var: ${apiMessage}`);
3158
+ if (verbose) {
3159
+ console.error('--- Verbose error details (env-delete) ---');
3160
+ console.error(error);
3161
+ if (error?.response) {
3162
+ console.error('Axios response status:', error.response.status);
3163
+ console.error('Axios response data:', error.response.data);
3164
+ }
3165
+ if (error?.stack) {
3166
+ console.error(error.stack);
3167
+ }
3168
+ }
3169
+ process.exit(1);
3170
+ }
3171
+ }
3172
+ /**
3173
+ * Delete an environment file
3174
+ */
3175
+ export async function envDeleteFile(name, verbose = false, options = {}) {
3176
+ ensureInitialized();
3177
+ let spinner;
3178
+ try {
3179
+ spinner = ora(`Deleting env file "${name}"...`).start();
3180
+ const res = await axios.delete(`${API_URL}/env`, {
3181
+ data: {
3182
+ type: 'file',
3183
+ name,
3184
+ project: options.project,
3185
+ },
3186
+ headers: await getAuthHeaders(),
3187
+ });
3188
+ spinner.succeed(`Env file "${name}" deleted successfully`);
3189
+ if (res.data?.message) {
3190
+ console.log(res.data.message);
3191
+ }
3192
+ }
3193
+ catch (error) {
3194
+ const apiMessage = error?.response?.data?.error ||
3195
+ error?.response?.data?.message ||
3196
+ error?.message ||
3197
+ String(error);
3198
+ spinner?.fail(`Failed to delete env file: ${apiMessage}`);
3199
+ if (verbose) {
3200
+ console.error('--- Verbose error details (env-delete-file) ---');
3201
+ console.error(error);
3202
+ if (error?.response) {
3203
+ console.error('Axios response status:', error.response.status);
3204
+ console.error('Axios response data:', error.response.data);
3205
+ }
3206
+ if (error?.stack) {
3207
+ console.error(error.stack);
3208
+ }
3209
+ }
3210
+ process.exit(1);
3211
+ }
3212
+ }
3213
+ /**
3214
+ * Encrypt a secret value using the Norrix public key (envelope encryption)
3215
+ */
3216
+ function encryptSecretValue(value) {
3217
+ const publicKeyPem = process.env.NORRIX_BUILD_PUBLIC_KEY;
3218
+ if (!publicKeyPem) {
3219
+ // If no public key is configured, send value in a format the server can handle
3220
+ // The server will encrypt at rest using its own key
3221
+ return {
3222
+ encryptedValue: Buffer.from(value, 'utf8').toString('base64'),
3223
+ encryptedKey: '',
3224
+ iv: '',
3225
+ tag: '',
3226
+ };
3227
+ }
3228
+ const nodeCrypto = crypto;
3229
+ const aesKey = nodeCrypto.randomBytes(32); // AES-256
3230
+ const iv = nodeCrypto.randomBytes(12); // GCM nonce
3231
+ const cipher = nodeCrypto.createCipheriv('aes-256-gcm', aesKey, iv);
3232
+ const plaintext = Buffer.from(value, 'utf8');
3233
+ const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
3234
+ const tag = cipher.getAuthTag();
3235
+ const encryptedKey = nodeCrypto.publicEncrypt({ key: publicKeyPem, oaepHash: 'sha256' }, aesKey);
3236
+ return {
3237
+ encryptedValue: ciphertext.toString('base64'),
3238
+ encryptedKey: encryptedKey.toString('base64'),
3239
+ iv: iv.toString('base64'),
3240
+ tag: tag.toString('base64'),
3241
+ };
3242
+ }
2400
3243
  //# sourceMappingURL=commands.js.map