@norrix/cli 0.0.6 → 0.0.8

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.
@@ -8,6 +8,7 @@ import { fileURLToPath } from 'url';
8
8
  import archiver from 'archiver';
9
9
  // import FormData from 'form-data';
10
10
  import { configureAmplify } from './amplify-config.js';
11
+ import { computeFingerprint, writeFingerprintFile } from './fingerprinting.js';
11
12
  import { signIn as amplifySignIn, signOut as amplifySignOut, getCurrentUser, fetchAuthSession, } from 'aws-amplify/auth';
12
13
  import crypto from 'crypto';
13
14
  import { Amplify } from 'aws-amplify';
@@ -112,6 +113,11 @@ function getS3BucketRegionFromAmplify() {
112
113
  }
113
114
  return { bucket, region };
114
115
  }
116
+ function logVerbose(message, verbose) {
117
+ if (verbose) {
118
+ console.log(message);
119
+ }
120
+ }
115
121
  async function uploadToS3Sdk(key, data) {
116
122
  const { bucket, region } = getS3BucketRegionFromAmplify();
117
123
  let credentials = undefined;
@@ -133,6 +139,198 @@ async function uploadToS3Sdk(key, data) {
133
139
  const client = new S3Client({ region, credentials });
134
140
  await client.send(new PutObjectCommand({ Bucket: bucket, Key: key, Body: data }));
135
141
  }
142
+ export async function printFingerprint(cliPlatformArg, appId, verbose = false) {
143
+ try {
144
+ const projectRoot = process.cwd();
145
+ const platform = (cliPlatformArg || '').toLowerCase() || undefined;
146
+ const snapshot = computeFingerprint({
147
+ projectRoot,
148
+ platform,
149
+ appId,
150
+ });
151
+ writeFingerprintFile(projectRoot, snapshot);
152
+ console.log('Norrix fingerprint snapshot:');
153
+ console.log(JSON.stringify(snapshot, null, 2));
154
+ }
155
+ catch (error) {
156
+ ora().fail(`Failed to compute fingerprint: ${error?.message || error}`);
157
+ if (verbose) {
158
+ console.error('--- Verbose error details (fingerprint) ---');
159
+ console.error(error);
160
+ if (error?.stack) {
161
+ console.error(error.stack);
162
+ }
163
+ }
164
+ }
165
+ }
166
+ async function fetchBuildFingerprint(buildId, verbose = false) {
167
+ try {
168
+ const spinner = ora(`Fetching fingerprint for build ${buildId}...`).start();
169
+ const response = await axios.get(`${API_URL}/build/${buildId}`, {
170
+ headers: await getAuthHeaders(),
171
+ });
172
+ spinner.stop();
173
+ const build = response.data;
174
+ if (!build?.fingerprintHash || !build?.fingerprintNativeJson) {
175
+ console.log(`Build ${buildId} does not have fingerprint data recorded (fingerprintHash/fingerprintNativeJson missing).`);
176
+ return undefined;
177
+ }
178
+ let nativeJson = {};
179
+ try {
180
+ nativeJson = JSON.parse(build.fingerprintNativeJson || '{}');
181
+ }
182
+ catch {
183
+ nativeJson = {};
184
+ }
185
+ return {
186
+ hash: build.fingerprintHash,
187
+ nativePlugins: nativeJson.nativePlugins || {},
188
+ appResourcesHash: nativeJson.appResourcesHash ?? null,
189
+ nativeSourcesHash: nativeJson.nativeSourcesHash ?? null,
190
+ platform: build.platform ?? null,
191
+ };
192
+ }
193
+ catch (error) {
194
+ ora().fail(`Failed to fetch fingerprint for build ${buildId}: ${error?.message || error}`);
195
+ if (verbose) {
196
+ console.error('--- Verbose error details (fingerprint-compare/fetch) ---');
197
+ console.error(error);
198
+ if (error?.response) {
199
+ console.error('Axios response status:', error.response.status);
200
+ console.error('Axios response data:', error.response.data);
201
+ }
202
+ if (error?.stack) {
203
+ console.error(error.stack);
204
+ }
205
+ }
206
+ return undefined;
207
+ }
208
+ }
209
+ function diffPluginMaps(fromPlugins, toPlugins) {
210
+ const added = [];
211
+ const removed = [];
212
+ const changed = [];
213
+ const allNames = new Set([
214
+ ...Object.keys(fromPlugins),
215
+ ...Object.keys(toPlugins),
216
+ ]);
217
+ for (const name of allNames) {
218
+ const fromV = fromPlugins[name];
219
+ const toV = toPlugins[name];
220
+ if (fromV && !toV) {
221
+ removed.push({ name, version: fromV });
222
+ }
223
+ else if (!fromV && toV) {
224
+ added.push({ name, version: toV });
225
+ }
226
+ else if (fromV && toV && fromV !== toV) {
227
+ changed.push({ name, from: fromV, to: toV });
228
+ }
229
+ }
230
+ return { added, removed, changed };
231
+ }
232
+ export async function compareFingerprint(fromBuildId, toArg, verbose = false) {
233
+ try {
234
+ if (!fromBuildId) {
235
+ throw new Error('Missing required --from <build-id> argument');
236
+ }
237
+ const fromFp = await fetchBuildFingerprint(fromBuildId, verbose);
238
+ if (!fromFp) {
239
+ ora().fail(`Build ${fromBuildId} does not have fingerprint data; cannot compare.`);
240
+ return;
241
+ }
242
+ const toIsLocal = !toArg || toArg === 'local';
243
+ let toLabel;
244
+ let toFp;
245
+ if (toIsLocal) {
246
+ const projectRoot = process.cwd();
247
+ const platform = fromFp.platform || undefined;
248
+ const localSnapshot = computeFingerprint({
249
+ projectRoot,
250
+ platform: platform || undefined,
251
+ });
252
+ toFp = {
253
+ hash: localSnapshot.hash,
254
+ nativePlugins: localSnapshot.nativePlugins,
255
+ appResourcesHash: localSnapshot.appResourcesHash ?? null,
256
+ nativeSourcesHash: localSnapshot.nativeSourcesHash ?? null,
257
+ platform: platform || undefined,
258
+ };
259
+ toLabel = 'local project';
260
+ }
261
+ else {
262
+ toFp = await fetchBuildFingerprint(toArg, verbose);
263
+ if (!toFp) {
264
+ ora().fail(`Build ${toArg} does not have fingerprint data; cannot compare.`);
265
+ return;
266
+ }
267
+ toLabel = `build ${toArg}`;
268
+ }
269
+ console.log('\nFingerprint comparison');
270
+ console.log('----------------------');
271
+ console.log(`From: build ${fromBuildId}`);
272
+ console.log(` Platform: ${fromFp.platform || 'unknown'}`);
273
+ console.log(` Hash: ${fromFp.hash}`);
274
+ console.log(`To: ${toLabel}`);
275
+ console.log(` Platform: ${toFp.platform || fromFp.platform || 'unknown'}`);
276
+ console.log(` Hash: ${toFp.hash}`);
277
+ const hashesMatch = fromFp.hash === toFp.hash;
278
+ console.log('\nSummary');
279
+ if (hashesMatch) {
280
+ console.log('- Native fingerprints are identical (hash values match). OTA compatibility is expected.');
281
+ }
282
+ else {
283
+ console.log('- Native fingerprints differ (hash values do not match). OTA between these may require a new store binary.');
284
+ }
285
+ const pluginDiff = diffPluginMaps(fromFp.nativePlugins || {}, toFp.nativePlugins || {});
286
+ console.log('\nNativeScript plugin differences');
287
+ if (pluginDiff.added.length === 0 &&
288
+ pluginDiff.removed.length === 0 &&
289
+ pluginDiff.changed.length === 0) {
290
+ console.log('- No plugin differences detected');
291
+ }
292
+ else {
293
+ if (pluginDiff.added.length) {
294
+ console.log('- Added plugins:');
295
+ for (const p of pluginDiff.added) {
296
+ console.log(` + ${p.name}@${p.version}`);
297
+ }
298
+ }
299
+ if (pluginDiff.removed.length) {
300
+ console.log('- Removed plugins:');
301
+ for (const p of pluginDiff.removed) {
302
+ console.log(` - ${p.name}@${p.version}`);
303
+ }
304
+ }
305
+ if (pluginDiff.changed.length) {
306
+ console.log('- Changed plugins:');
307
+ for (const p of pluginDiff.changed) {
308
+ console.log(` ~ ${p.name}: ${p.from} -> ${p.to}`);
309
+ }
310
+ }
311
+ }
312
+ console.log('\nApp_Resources hash');
313
+ console.log(`- From: ${fromFp.appResourcesHash || 'none'}`);
314
+ console.log(`- To: ${toFp.appResourcesHash || 'none'}`);
315
+ console.log('\niOS NativeSource hash');
316
+ console.log(`- From: ${fromFp.nativeSourcesHash || 'none'}`);
317
+ console.log(`- To: ${toFp.nativeSourcesHash || 'none'}`);
318
+ }
319
+ catch (error) {
320
+ ora().fail(`Failed to compare fingerprints: ${error?.message || error}`);
321
+ if (verbose) {
322
+ console.error('--- Verbose error details (fingerprint-compare) ---');
323
+ console.error(error);
324
+ if (error?.response) {
325
+ console.error('Axios response status:', error.response.status);
326
+ console.error('Axios response data:', error.response.data);
327
+ }
328
+ if (error?.stack) {
329
+ console.error(error.stack);
330
+ }
331
+ }
332
+ }
333
+ }
136
334
  /**
137
335
  * Get the current NativeScript project's name from package.json
138
336
  */
@@ -174,6 +372,99 @@ function getNativeScriptAppPath() {
174
372
  }
175
373
  return undefined;
176
374
  }
375
+ /**
376
+ * Parse NativeScript app id from nativescript.config.(ts|js).
377
+ * Looks for top-level `id`, then platform-specific `ios.id` / `android.id`.
378
+ */
379
+ function getNativeScriptAppId(platform) {
380
+ const normalizedPlatform = (platform || '').toLowerCase();
381
+ const candidates = ['nativescript.config.ts', 'nativescript.config.js'];
382
+ try {
383
+ for (const file of candidates) {
384
+ const full = path.join(process.cwd(), file);
385
+ if (!fs.existsSync(full))
386
+ continue;
387
+ const content = fs.readFileSync(full, 'utf8');
388
+ // Prefer platform-specific id if platform is known
389
+ if (normalizedPlatform === 'ios' || normalizedPlatform === 'visionos') {
390
+ const m = content.match(/ios\s*:\s*{[^}]*id\s*:\s*['"`]([^'"`]+)['"`]/s);
391
+ if (m && m[1])
392
+ return m[1].trim();
393
+ }
394
+ else if (normalizedPlatform === 'android') {
395
+ const m = content.match(/android\s*:\s*{[^}]*id\s*:\s*['"`]([^'"`]+)['"`]/s);
396
+ if (m && m[1])
397
+ return m[1].trim();
398
+ }
399
+ // Fallback to top-level id
400
+ const topLevel = content.match(/id\s*:\s*['"`]([^'"`]+)['"`]/);
401
+ if (topLevel && topLevel[1])
402
+ return topLevel[1].trim();
403
+ }
404
+ }
405
+ catch (_) {
406
+ // ignore parse errors and fall back to manual input
407
+ }
408
+ return undefined;
409
+ }
410
+ function getNativeScriptAppResourcesPath() {
411
+ try {
412
+ const candidates = ['nativescript.config.ts', 'nativescript.config.js'];
413
+ for (const file of candidates) {
414
+ const full = path.join(process.cwd(), file);
415
+ if (!fs.existsSync(full))
416
+ continue;
417
+ const content = fs.readFileSync(full, 'utf8');
418
+ const match = content.match(/appResourcesPath\s*:\s*['"`]([^'"`]+)['"`]/);
419
+ if (match && match[1]) {
420
+ return match[1].trim();
421
+ }
422
+ }
423
+ }
424
+ catch (_) {
425
+ // ignore parse errors and fall back
426
+ }
427
+ return undefined;
428
+ }
429
+ function getAppleVersionFromInfoPlist(platform) {
430
+ const appResourcesPath = getNativeScriptAppResourcesPath();
431
+ if (!appResourcesPath)
432
+ return {};
433
+ const platformDir = platform.toLowerCase() === 'visionos' ? 'visionOS' : 'iOS';
434
+ const plistPath = path.join(process.cwd(), appResourcesPath, platformDir, 'Info.plist');
435
+ if (!fs.existsSync(plistPath))
436
+ return {};
437
+ try {
438
+ const xml = fs.readFileSync(plistPath, 'utf8');
439
+ const shortVersionMatch = xml.match(/<key>CFBundleShortVersionString<\/key>\s*<string>([^<]+)<\/string>/);
440
+ const bundleVersionMatch = xml.match(/<key>CFBundleVersion<\/key>\s*<string>([^<]+)<\/string>/);
441
+ const version = shortVersionMatch?.[1]?.trim();
442
+ const buildNumber = bundleVersionMatch?.[1]?.trim();
443
+ return { version, buildNumber };
444
+ }
445
+ catch (_) {
446
+ return {};
447
+ }
448
+ }
449
+ function getAndroidVersionFromAppGradle() {
450
+ const appResourcesPath = getNativeScriptAppResourcesPath();
451
+ if (!appResourcesPath)
452
+ return {};
453
+ const gradlePath = path.join(process.cwd(), appResourcesPath, 'Android', 'app.gradle');
454
+ if (!fs.existsSync(gradlePath))
455
+ return {};
456
+ try {
457
+ const content = fs.readFileSync(gradlePath, 'utf8');
458
+ const versionNameMatch = content.match(/versionName\s+"([^"]+)"/);
459
+ const versionCodeMatch = content.match(/versionCode\s+(\d+)/);
460
+ const version = versionNameMatch?.[1]?.trim();
461
+ const buildNumber = versionCodeMatch?.[1]?.trim();
462
+ return { version, buildNumber };
463
+ }
464
+ catch (_) {
465
+ return {};
466
+ }
467
+ }
177
468
  /**
178
469
  * Creates a zip file of the current directory (NativeScript project)
179
470
  */
@@ -222,9 +513,7 @@ async function zipProject(projectName, isUpdate = false) {
222
513
  }
223
514
  else {
224
515
  console.warn('Warning: app directory not found in the project root');
225
- const checked = [nsAppDir, srcDir, appDir]
226
- .filter(Boolean)
227
- .join(', ');
516
+ const checked = [nsAppDir, srcDir, appDir].filter(Boolean).join(', ');
228
517
  console.warn(`Checked locations: ${checked}`);
229
518
  console.log('Creating an empty app directory in the zip');
230
519
  }
@@ -246,20 +535,27 @@ async function zipProject(projectName, isUpdate = false) {
246
535
  * Build command implementation
247
536
  * Uploads project to S3 and triggers build via the Next.js API gateway -> WarpBuild
248
537
  */
249
- export async function build() {
538
+ export async function build(cliPlatformArg, verbose = false) {
250
539
  try {
251
540
  const spinner = ora('Preparing app for building...').start();
252
541
  // 1. Get project info
253
542
  const projectName = await getProjectName();
254
- // 2. Ask for platform
543
+ // 2. Determine platform (CLI arg preferred, otherwise prompt)
544
+ let platform = (cliPlatformArg || '').toLowerCase();
545
+ const validPlatforms = ['android', 'ios', 'visionos'];
255
546
  spinner.stop();
256
- const { platform, configuration } = await inquirer.prompt([
257
- {
258
- type: 'list',
259
- name: 'platform',
260
- message: 'Select target platform:',
261
- choices: ['android', 'ios'],
262
- },
547
+ if (!validPlatforms.includes(platform)) {
548
+ const { chosenPlatform } = await inquirer.prompt([
549
+ {
550
+ type: 'list',
551
+ name: 'chosenPlatform',
552
+ message: 'Select target platform:',
553
+ choices: validPlatforms,
554
+ },
555
+ ]);
556
+ platform = chosenPlatform;
557
+ }
558
+ const { configuration } = await inquirer.prompt([
263
559
  {
264
560
  type: 'list',
265
561
  name: 'configuration',
@@ -268,32 +564,52 @@ export async function build() {
268
564
  default: 'debug',
269
565
  },
270
566
  ]);
567
+ const appleVersionInfo = platform === 'ios' || platform === 'visionos'
568
+ ? getAppleVersionFromInfoPlist(platform)
569
+ : {};
570
+ const inferredIosOrVisionVersion = appleVersionInfo.version;
571
+ const androidVersionInfo = platform === 'android' ? getAndroidVersionFromAppGradle() : {};
572
+ const inferredAndroidVersion = androidVersionInfo.version;
573
+ const inferredVersion = inferredIosOrVisionVersion || inferredAndroidVersion || '';
574
+ if (inferredVersion) {
575
+ logVerbose(`Auto-detected app version from project files: ${inferredVersion}`, verbose);
576
+ }
271
577
  const { version } = await inquirer.prompt([
272
578
  {
273
579
  type: 'input',
274
580
  name: 'version',
275
- message: 'App version (optional):',
276
- default: '',
581
+ message: inferredVersion
582
+ ? `App version (${inferredVersion}, enter to accept):`
583
+ : 'App version:',
584
+ default: inferredVersion,
277
585
  validate: (input) => {
278
586
  const val = String(input).trim();
279
- if (val === '')
280
- return true; // optional
587
+ if (val === '') {
588
+ return 'Version is required';
589
+ }
281
590
  // Strict SemVer: MAJOR.MINOR.PATCH with optional -prerelease and +build metadata
282
591
  const semverRe = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/;
283
592
  return semverRe.test(val)
284
593
  ? true
285
- : 'Enter a valid semver (e.g., 2.0.0 or 2.0.0-beta.1) or leave blank';
594
+ : 'Enter a valid semver (e.g., 2.0.0 or 2.0.0-beta.1)';
286
595
  },
287
596
  },
288
597
  ]);
289
598
  let buildNumber;
290
599
  if (platform === 'android') {
600
+ const inferredAndroid = androidVersionInfo;
601
+ const inferredVersionCode = inferredAndroid.buildNumber;
602
+ if (inferredVersionCode) {
603
+ logVerbose(`Auto-detected Android versionCode from app.gradle: ${inferredVersionCode}`, verbose);
604
+ }
291
605
  const { versionCode } = await inquirer.prompt([
292
606
  {
293
607
  type: 'input',
294
608
  name: 'versionCode',
295
- message: 'App build number, aka versionCode (optional - will auto increment when blank):',
296
- default: '',
609
+ message: inferredVersionCode
610
+ ? `App build number, aka versionCode (${inferredVersionCode} - enter to auto increment):`
611
+ : 'App build number, aka versionCode (enter to auto increment):',
612
+ default: inferredVersionCode || '',
297
613
  validate: (input) => {
298
614
  const val = String(input).trim();
299
615
  if (val === '')
@@ -434,6 +750,13 @@ export async function build() {
434
750
  }
435
751
  spinner.start('Creating project archive...');
436
752
  }
753
+ // Compute a native-layer fingerprint before archiving
754
+ const projectRoot = process.cwd();
755
+ const fingerprint = computeFingerprint({
756
+ projectRoot,
757
+ platform,
758
+ });
759
+ writeFingerprintFile(projectRoot, fingerprint);
437
760
  spinner.start('Creating project archive...');
438
761
  // 3. Zip the project
439
762
  const zipPath = await zipProject(projectName, false);
@@ -460,6 +783,7 @@ export async function build() {
460
783
  version: version || '',
461
784
  buildNumber: buildNumber || '',
462
785
  configuration,
786
+ fingerprint,
463
787
  // Provide the relative key (without public/) – the workflow prepends public/
464
788
  s3Key: s3KeyRel,
465
789
  // Only include raw credentials if not encrypted
@@ -483,14 +807,25 @@ export async function build() {
483
807
  console.log(` You can check the status with: norrix build-status ${buildId}`);
484
808
  }
485
809
  catch (error) {
486
- ora().fail(`Build failed: ${error.message}`);
810
+ ora().fail(`Build failed: ${error?.message || error}`);
811
+ if (verbose) {
812
+ console.error('--- Verbose error details (build) ---');
813
+ console.error(error);
814
+ if (error?.response) {
815
+ console.error('Axios response status:', error.response.status);
816
+ console.error('Axios response data:', error.response.data);
817
+ }
818
+ if (error?.stack) {
819
+ console.error(error.stack);
820
+ }
821
+ }
487
822
  }
488
823
  }
489
824
  /**
490
825
  * Submit command implementation
491
826
  * Submits the built app to app stores via the Next.js API gateway
492
827
  */
493
- export async function submit() {
828
+ export async function submit(cliPlatformArg, verbose = false) {
494
829
  try {
495
830
  const spinner = ora('Preparing app for submission...').start();
496
831
  // 1. Get available builds to show in prompt
@@ -505,11 +840,13 @@ export async function submit() {
505
840
  // Filter for successful builds
506
841
  availableBuilds = buildsResponse.data.builds
507
842
  .filter((build) => build.status === 'success')
508
- .map((build) => ({
509
- name: `${build.projectName} (${build.platform} - ${build.id})`,
510
- value: build.id,
511
- platform: build.platform,
512
- }));
843
+ .map((build) => {
844
+ return {
845
+ name: `${build.projectName} (${build.platform} - ${build.version} (${build.buildNumber}) - ${build.id})`,
846
+ value: build.id,
847
+ platform: build.platform,
848
+ };
849
+ });
513
850
  }
514
851
  }
515
852
  catch (error) {
@@ -541,20 +878,24 @@ export async function submit() {
541
878
  ]);
542
879
  buildId = buildIdAnswer.buildId;
543
880
  }
544
- // Get the platform for the selected build
545
- const selectedBuild = availableBuilds.find((build) => build.value === buildId);
546
- const defaultPlatform = selectedBuild ? selectedBuild.platform : null;
547
- let platform;
548
- if (defaultPlatform) {
549
- platform = defaultPlatform;
881
+ // Determine platform, preferring CLI arg, then build metadata, then prompt
882
+ let platform = (cliPlatformArg || '').toLowerCase();
883
+ const validPlatforms = ['android', 'ios', 'visionos'];
884
+ // Fallback to platform from selected build if CLI arg missing/invalid
885
+ if (!validPlatforms.includes(platform)) {
886
+ const selectedBuild = availableBuilds.find((build) => build.value === buildId);
887
+ if (selectedBuild && validPlatforms.includes(selectedBuild.platform)) {
888
+ platform = selectedBuild.platform;
889
+ }
550
890
  }
551
- else {
891
+ // If still unknown, prompt the user
892
+ if (!validPlatforms.includes(platform)) {
552
893
  const platformAnswer = await inquirer.prompt([
553
894
  {
554
895
  type: 'list',
555
896
  name: 'platform',
556
897
  message: 'Select target platform:',
557
- choices: ['android', 'ios'],
898
+ choices: validPlatforms,
558
899
  },
559
900
  ]);
560
901
  platform = platformAnswer.platform;
@@ -656,16 +997,51 @@ export async function submit() {
656
997
  console.log(` You can check the status with: norrix submit-status ${submitId}`);
657
998
  }
658
999
  catch (error) {
659
- ora().fail(`Submission failed: ${error.message}`);
1000
+ ora().fail(`Submission failed: ${error?.message || error}`);
1001
+ if (verbose) {
1002
+ console.error('--- Verbose error details (submit) ---');
1003
+ console.error(error);
1004
+ if (error?.response) {
1005
+ console.error('Axios response status:', error.response.status);
1006
+ console.error('Axios response data:', error.response.data);
1007
+ }
1008
+ if (error?.stack) {
1009
+ console.error(error.stack);
1010
+ }
1011
+ }
660
1012
  }
661
1013
  }
662
1014
  /**
663
1015
  * Update command implementation
664
1016
  * Publishes over-the-air updates to deployed apps via the Next.js API gateway
665
1017
  */
666
- export async function update() {
1018
+ export async function update(cliPlatformArg, verbose = false) {
667
1019
  try {
668
1020
  const spinner = ora('Preparing over-the-air update...').start();
1021
+ // Normalize and/or ask for platform first (CLI arg takes precedence if valid)
1022
+ let platform = (cliPlatformArg || '').toLowerCase();
1023
+ const validPlatforms = ['android', 'ios', 'visionos'];
1024
+ if (!validPlatforms.includes(platform)) {
1025
+ spinner.stop();
1026
+ const { chosenPlatform } = await inquirer.prompt([
1027
+ {
1028
+ type: 'list',
1029
+ name: 'chosenPlatform',
1030
+ message: 'Which platform?',
1031
+ choices: validPlatforms,
1032
+ },
1033
+ ]);
1034
+ platform = chosenPlatform;
1035
+ spinner.start('Preparing over-the-air update...');
1036
+ }
1037
+ // Attempt to infer app ID from NativeScript config based on platform
1038
+ let inferredAppId;
1039
+ try {
1040
+ inferredAppId = getNativeScriptAppId(platform);
1041
+ }
1042
+ catch (_) {
1043
+ // ignore, will fall back to prompt without default
1044
+ }
669
1045
  // Ask for app ID and other details
670
1046
  spinner.stop();
671
1047
  const { appId, version, notes } = await inquirer.prompt([
@@ -673,6 +1049,7 @@ export async function update() {
673
1049
  type: 'input',
674
1050
  name: 'appId',
675
1051
  message: 'Enter the App ID to update:',
1052
+ default: inferredAppId || '',
676
1053
  validate: (input) => input.length > 0 || 'App ID is required',
677
1054
  },
678
1055
  {
@@ -701,6 +1078,15 @@ export async function update() {
701
1078
  spinner.warn('Warning: app directory not found in the project. The update may be incomplete.');
702
1079
  spinner.warn('Expected app at either:\n- ' + appDir + '\n- ' + srcAppDir);
703
1080
  }
1081
+ // Compute fingerprint for the current app state so the backend can
1082
+ // compare it with the binary fingerprint and decide if OTA is allowed.
1083
+ const projectRoot = process.cwd();
1084
+ const fingerprint = computeFingerprint({
1085
+ projectRoot,
1086
+ platform,
1087
+ appId,
1088
+ });
1089
+ writeFingerprintFile(projectRoot, fingerprint);
704
1090
  spinner.start('Packaging JavaScript bundle for over-the-air update...');
705
1091
  // Create the update bundle - pass true to include node_modules for updates
706
1092
  const projectName = await getProjectName();
@@ -714,8 +1100,10 @@ export async function update() {
714
1100
  spinner.text = 'Upload complete. Starting update...';
715
1101
  const response = await axios.post(`${API_URL}/update`, {
716
1102
  appId,
1103
+ platform,
717
1104
  version,
718
1105
  releaseNotes: notes,
1106
+ fingerprint,
719
1107
  // Provide the relative key (without public/). Consumers will prepend public/
720
1108
  s3Key: s3KeyRel,
721
1109
  }, {
@@ -733,14 +1121,25 @@ export async function update() {
733
1121
  console.log(` You can check the status with: norrix update-status ${updateId}`);
734
1122
  }
735
1123
  catch (error) {
736
- ora().fail(`Update failed: ${error.message}`);
1124
+ ora().fail(`Update failed: ${error?.message || error}`);
1125
+ if (verbose) {
1126
+ console.error('--- Verbose error details (update) ---');
1127
+ console.error(error);
1128
+ if (error?.response) {
1129
+ console.error('Axios response status:', error.response.status);
1130
+ console.error('Axios response data:', error.response.data);
1131
+ }
1132
+ if (error?.stack) {
1133
+ console.error(error.stack);
1134
+ }
1135
+ }
737
1136
  }
738
1137
  }
739
1138
  /**
740
1139
  * Build Status command implementation
741
1140
  * Checks the status of a build via the Next.js API gateway
742
1141
  */
743
- export async function buildStatus(buildId) {
1142
+ export async function buildStatus(buildId, verbose = false) {
744
1143
  try {
745
1144
  const spinner = ora(`Checking status of build ${buildId}...`).start();
746
1145
  const response = await axios.get(`${API_URL}/build/${buildId}`, {
@@ -759,14 +1158,25 @@ export async function buildStatus(buildId) {
759
1158
  }
760
1159
  }
761
1160
  catch (error) {
762
- ora().fail(`Failed to check build status: ${error.message}`);
1161
+ ora().fail(`Failed to check build status: ${error?.message || error}`);
1162
+ if (verbose) {
1163
+ console.error('--- Verbose error details (build-status) ---');
1164
+ console.error(error);
1165
+ if (error?.response) {
1166
+ console.error('Axios response status:', error.response.status);
1167
+ console.error('Axios response data:', error.response.data);
1168
+ }
1169
+ if (error?.stack) {
1170
+ console.error(error.stack);
1171
+ }
1172
+ }
763
1173
  }
764
1174
  }
765
1175
  /**
766
1176
  * Submit Status command implementation
767
1177
  * Checks the status of a submission via the Next.js API gateway
768
1178
  */
769
- export async function submitStatus(submitId) {
1179
+ export async function submitStatus(submitId, verbose = false) {
770
1180
  try {
771
1181
  const spinner = ora(`Checking status of submission ${submitId}...`).start();
772
1182
  const response = await axios.get(`${API_URL}/submit/${submitId}`, {
@@ -781,14 +1191,25 @@ export async function submitStatus(submitId) {
781
1191
  console.log(`Message: ${response.data.message}`);
782
1192
  }
783
1193
  catch (error) {
784
- ora().fail(`Failed to check submission status: ${error.message}`);
1194
+ ora().fail(`Failed to check submission status: ${error?.message || error}`);
1195
+ if (verbose) {
1196
+ console.error('--- Verbose error details (submit-status) ---');
1197
+ console.error(error);
1198
+ if (error?.response) {
1199
+ console.error('Axios response status:', error.response.status);
1200
+ console.error('Axios response data:', error.response.data);
1201
+ }
1202
+ if (error?.stack) {
1203
+ console.error(error.stack);
1204
+ }
1205
+ }
785
1206
  }
786
1207
  }
787
1208
  /**
788
1209
  * Update Status command implementation
789
1210
  * Checks the status of an update via the Next.js API gateway
790
1211
  */
791
- export async function updateStatus(updateId) {
1212
+ export async function updateStatus(updateId, verbose = false) {
792
1213
  try {
793
1214
  const spinner = ora(`Checking status of update ${updateId}...`).start();
794
1215
  const response = await axios.get(`${API_URL}/update/${updateId}`, {
@@ -803,13 +1224,24 @@ export async function updateStatus(updateId) {
803
1224
  console.log(`Message: ${response.data.message}`);
804
1225
  }
805
1226
  catch (error) {
806
- ora().fail(`Failed to check update status: ${error.message}`);
1227
+ ora().fail(`Failed to check update status: ${error?.message || error}`);
1228
+ if (verbose) {
1229
+ console.error('--- Verbose error details (update-status) ---');
1230
+ console.error(error);
1231
+ if (error?.response) {
1232
+ console.error('Axios response status:', error.response.status);
1233
+ console.error('Axios response data:', error.response.data);
1234
+ }
1235
+ if (error?.stack) {
1236
+ console.error(error.stack);
1237
+ }
1238
+ }
807
1239
  }
808
1240
  }
809
1241
  /**
810
1242
  * Sign-In command implementation (email / password via Cognito)
811
1243
  */
812
- export async function signIn() {
1244
+ export async function signIn(verbose = false) {
813
1245
  try {
814
1246
  const answers = await inquirer.prompt([
815
1247
  {
@@ -839,26 +1271,48 @@ export async function signIn() {
839
1271
  spinner.succeed(`Signed in as ${answers.email}`);
840
1272
  }
841
1273
  catch (error) {
842
- ora().fail(`Sign-in failed: ${error.message}`);
1274
+ ora().fail(`Sign-in failed: ${error?.message || error}`);
1275
+ if (verbose) {
1276
+ console.error('--- Verbose error details (sign-in) ---');
1277
+ console.error(error);
1278
+ if (error?.response) {
1279
+ console.error('Axios response status:', error.response.status);
1280
+ console.error('Axios response data:', error.response.data);
1281
+ }
1282
+ if (error?.stack) {
1283
+ console.error(error.stack);
1284
+ }
1285
+ }
843
1286
  }
844
1287
  }
845
1288
  /**
846
1289
  * Sign-Out command implementation
847
1290
  */
848
- export async function signOut() {
1291
+ export async function signOut(verbose = false) {
849
1292
  try {
850
1293
  const spinner = ora('Signing out...').start();
851
1294
  await amplifySignOut();
852
1295
  spinner.succeed('Signed out successfully');
853
1296
  }
854
1297
  catch (error) {
855
- ora().fail(`Sign-out failed: ${error.message}`);
1298
+ ora().fail(`Sign-out failed: ${error?.message || error}`);
1299
+ if (verbose) {
1300
+ console.error('--- Verbose error details (sign-out) ---');
1301
+ console.error(error);
1302
+ if (error?.response) {
1303
+ console.error('Axios response status:', error.response.status);
1304
+ console.error('Axios response data:', error.response.data);
1305
+ }
1306
+ if (error?.stack) {
1307
+ console.error(error.stack);
1308
+ }
1309
+ }
856
1310
  }
857
1311
  }
858
1312
  /**
859
1313
  * Upload a file to S3 via Amplify Storage
860
1314
  */
861
- export async function uploadFile(filePath, options) {
1315
+ export async function uploadFile(filePath, options, verbose = false) {
862
1316
  try {
863
1317
  const resolvedPath = path.resolve(process.cwd(), filePath);
864
1318
  if (!fs.existsSync(resolvedPath)) {
@@ -871,13 +1325,24 @@ export async function uploadFile(filePath, options) {
871
1325
  spinner.succeed(`Uploaded ${key} successfully`);
872
1326
  }
873
1327
  catch (error) {
874
- ora().fail(`Upload failed: ${error.message}`);
1328
+ ora().fail(`Upload failed: ${error?.message || error}`);
1329
+ if (verbose) {
1330
+ console.error('--- Verbose error details (upload) ---');
1331
+ console.error(error);
1332
+ if (error?.response) {
1333
+ console.error('Axios response status:', error.response.status);
1334
+ console.error('Axios response data:', error.response.data);
1335
+ }
1336
+ if (error?.stack) {
1337
+ console.error(error.stack);
1338
+ }
1339
+ }
875
1340
  }
876
1341
  }
877
1342
  /**
878
1343
  * Current User command implementation
879
1344
  */
880
- export async function currentUser() {
1345
+ export async function currentUser(verbose = false) {
881
1346
  try {
882
1347
  const spinner = ora('Fetching current user...').start();
883
1348
  let user;
@@ -911,7 +1376,76 @@ export async function currentUser() {
911
1376
  console.log('⚠️ No user is currently signed in.');
912
1377
  return;
913
1378
  }
914
- ora().fail(`Failed to fetch current user: ${error.message}`);
1379
+ ora().fail(`Failed to fetch current user: ${error?.message || error}`);
1380
+ if (verbose) {
1381
+ console.error('--- Verbose error details (current-user) ---');
1382
+ console.error(error);
1383
+ if (error?.response) {
1384
+ console.error('Axios response status:', error.response.status);
1385
+ console.error('Axios response data:', error.response.data);
1386
+ }
1387
+ if (error?.stack) {
1388
+ console.error(error.stack);
1389
+ }
1390
+ }
1391
+ }
1392
+ }
1393
+ /**
1394
+ * Open billing checkout for the current organization
1395
+ */
1396
+ export async function billingCheckout(priceId, verbose = false) {
1397
+ try {
1398
+ if (!priceId) {
1399
+ const a = await inquirer.prompt([
1400
+ { type: 'input', name: 'priceId', message: 'Stripe Price ID:' },
1401
+ ]);
1402
+ priceId = a.priceId;
1403
+ }
1404
+ const spinner = ora('Creating checkout session...').start();
1405
+ const res = await axios.post(`${API_URL}/billing/checkout`, { priceId }, { headers: await getAuthHeaders() });
1406
+ spinner.succeed('Checkout session created');
1407
+ console.log('Open this URL in your browser to complete purchase:');
1408
+ console.log(res.data.url);
1409
+ }
1410
+ catch (error) {
1411
+ ora().fail(`Checkout failed: ${error?.message || error}`);
1412
+ if (verbose) {
1413
+ console.error('--- Verbose error details (billing-checkout) ---');
1414
+ console.error(error);
1415
+ if (error?.response) {
1416
+ console.error('Axios response status:', error.response.status);
1417
+ console.error('Axios response data:', error.response.data);
1418
+ }
1419
+ if (error?.stack) {
1420
+ console.error(error.stack);
1421
+ }
1422
+ }
1423
+ }
1424
+ }
1425
+ /**
1426
+ * Open Stripe billing portal for the current organization
1427
+ */
1428
+ export async function billingPortal(verbose = false) {
1429
+ try {
1430
+ const spinner = ora('Creating billing portal session...').start();
1431
+ const res = await axios.post(`${API_URL}/billing/portal`, {}, { headers: await getAuthHeaders() });
1432
+ spinner.succeed('Portal session created');
1433
+ console.log('Open this URL in your browser to manage billing:');
1434
+ console.log(res.data.url);
1435
+ }
1436
+ catch (error) {
1437
+ ora().fail(`Portal failed: ${error?.message || error}`);
1438
+ if (verbose) {
1439
+ console.error('--- Verbose error details (billing-portal) ---');
1440
+ console.error(error);
1441
+ if (error?.response) {
1442
+ console.error('Axios response status:', error.response.status);
1443
+ console.error('Axios response data:', error.response.data);
1444
+ }
1445
+ if (error?.stack) {
1446
+ console.error(error.stack);
1447
+ }
1448
+ }
915
1449
  }
916
1450
  }
917
1451
  //# sourceMappingURL=commands.js.map