@solana-mobile/dapp-store-cli 0.16.0 → 1.0.0

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.
Files changed (113) hide show
  1. package/bin/dapp-store.js +3 -1
  2. package/lib/CliSetup.js +304 -505
  3. package/lib/CliUtils.js +6 -376
  4. package/lib/__tests__/CliSetupTest.js +484 -74
  5. package/lib/cli/__tests__/parseErrors.test.js +25 -0
  6. package/lib/cli/__tests__/signer.test.js +436 -0
  7. package/lib/cli/constants.js +23 -0
  8. package/lib/cli/messages.js +21 -0
  9. package/lib/cli/parseErrors.js +41 -0
  10. package/lib/{commands/publish/PublishCliSupport.js → cli/selfUpdate.js} +72 -38
  11. package/lib/{commands/publish/PublishCliRemove.js → cli/signer.js} +35 -56
  12. package/lib/index.js +96 -5
  13. package/lib/package.json +5 -24
  14. package/lib/portal/__tests__/releaseMetadata.test.js +647 -0
  15. package/lib/portal/__tests__/translators.test.js +76 -0
  16. package/lib/portal/__tests__/workflowClient.test.js +457 -0
  17. package/lib/portal/attestationClient.js +143 -0
  18. package/lib/portal/files.js +64 -0
  19. package/lib/portal/http.js +364 -0
  20. package/lib/portal/records.js +64 -0
  21. package/lib/portal/releaseMetadata.js +748 -0
  22. package/lib/portal/translators.js +460 -0
  23. package/lib/portal/types.js +1 -0
  24. package/lib/portal/workflowClient.js +704 -0
  25. package/lib/publication/PublicationProgressReporter.js +1051 -0
  26. package/lib/publication/__tests__/PublicationProgressReporter.test.js +174 -0
  27. package/lib/{commands/ValidateCommand.js → publication/__tests__/fundingPreflight.test.js} +90 -66
  28. package/lib/publication/__tests__/publicationSummary.test.js +26 -0
  29. package/lib/publication/cliValidation.js +482 -0
  30. package/lib/publication/fundingPreflight.js +246 -0
  31. package/lib/publication/publicationSummary.js +99 -0
  32. package/lib/{commands/utils.js → publication/runPublicationWorkflow.js} +16 -46
  33. package/package.json +5 -24
  34. package/src/CliSetup.ts +370 -505
  35. package/src/CliUtils.ts +9 -233
  36. package/src/__tests__/CliSetupTest.ts +272 -120
  37. package/src/cli/__tests__/parseErrors.test.ts +34 -0
  38. package/src/cli/__tests__/signer.test.ts +359 -0
  39. package/src/cli/constants.ts +3 -0
  40. package/src/cli/messages.ts +27 -0
  41. package/src/cli/parseErrors.ts +62 -0
  42. package/src/cli/selfUpdate.ts +59 -0
  43. package/src/cli/signer.ts +38 -0
  44. package/src/index.ts +31 -4
  45. package/src/portal/__tests__/releaseMetadata.test.ts +508 -0
  46. package/src/portal/__tests__/translators.test.ts +82 -0
  47. package/src/portal/__tests__/workflowClient.test.ts +278 -0
  48. package/src/portal/attestationClient.ts +19 -0
  49. package/src/portal/files.ts +73 -0
  50. package/src/portal/http.ts +170 -0
  51. package/src/portal/records.ts +38 -0
  52. package/src/portal/releaseMetadata.ts +489 -0
  53. package/src/portal/translators.ts +750 -0
  54. package/src/portal/types.ts +27 -0
  55. package/src/portal/workflowClient.ts +575 -0
  56. package/src/publication/PublicationProgressReporter.ts +1026 -0
  57. package/src/publication/__tests__/PublicationProgressReporter.test.ts +210 -0
  58. package/src/publication/__tests__/fundingPreflight.test.ts +78 -0
  59. package/src/publication/__tests__/publicationSummary.test.ts +30 -0
  60. package/src/publication/cliValidation.ts +264 -0
  61. package/src/publication/fundingPreflight.ts +123 -0
  62. package/src/publication/publicationSummary.ts +26 -0
  63. package/src/publication/runPublicationWorkflow.ts +46 -0
  64. package/lib/commands/create/CreateCliApp.js +0 -223
  65. package/lib/commands/create/CreateCliRelease.js +0 -290
  66. package/lib/commands/create/index.js +0 -40
  67. package/lib/commands/index.js +0 -3
  68. package/lib/commands/publish/PublishCliSubmit.js +0 -208
  69. package/lib/commands/publish/PublishCliUpdate.js +0 -211
  70. package/lib/commands/publish/index.js +0 -22
  71. package/lib/commands/scaffolding/ScaffoldInit.js +0 -15
  72. package/lib/commands/scaffolding/index.js +0 -1
  73. package/lib/config/EnvVariables.js +0 -59
  74. package/lib/config/PublishDetails.js +0 -915
  75. package/lib/config/S3StorageManager.js +0 -93
  76. package/lib/config/index.js +0 -2
  77. package/lib/generated/config_obj.json +0 -1
  78. package/lib/generated/config_schema.json +0 -1
  79. package/lib/prebuild_schema/publishing_source.yaml +0 -64
  80. package/lib/prebuild_schema/schemagen.js +0 -25
  81. package/lib/upload/CachedStorageDriver.js +0 -458
  82. package/lib/upload/TurboStorageDriver.js +0 -718
  83. package/lib/upload/__tests__/CachedStorageDriver.test.js +0 -437
  84. package/lib/upload/__tests__/TurboStorageDriver.test.js +0 -17
  85. package/lib/upload/__tests__/contentGateway.test.js +0 -17
  86. package/lib/upload/contentGateway.js +0 -23
  87. package/lib/upload/index.js +0 -2
  88. package/src/commands/ValidateCommand.ts +0 -82
  89. package/src/commands/create/CreateCliApp.ts +0 -93
  90. package/src/commands/create/CreateCliRelease.ts +0 -149
  91. package/src/commands/create/index.ts +0 -47
  92. package/src/commands/index.ts +0 -3
  93. package/src/commands/publish/PublishCliRemove.ts +0 -66
  94. package/src/commands/publish/PublishCliSubmit.ts +0 -93
  95. package/src/commands/publish/PublishCliSupport.ts +0 -66
  96. package/src/commands/publish/PublishCliUpdate.ts +0 -101
  97. package/src/commands/publish/index.ts +0 -29
  98. package/src/commands/scaffolding/ScaffoldInit.ts +0 -20
  99. package/src/commands/scaffolding/index.ts +0 -1
  100. package/src/commands/utils.ts +0 -33
  101. package/src/config/EnvVariables.ts +0 -39
  102. package/src/config/PublishDetails.ts +0 -456
  103. package/src/config/S3StorageManager.ts +0 -47
  104. package/src/config/index.ts +0 -2
  105. package/src/prebuild_schema/publishing_source.yaml +0 -64
  106. package/src/prebuild_schema/schemagen.js +0 -31
  107. package/src/upload/CachedStorageDriver.ts +0 -179
  108. package/src/upload/TurboStorageDriver.ts +0 -283
  109. package/src/upload/__tests__/CachedStorageDriver.test.ts +0 -246
  110. package/src/upload/__tests__/TurboStorageDriver.test.ts +0 -15
  111. package/src/upload/__tests__/contentGateway.test.ts +0 -31
  112. package/src/upload/contentGateway.ts +0 -37
  113. package/src/upload/index.ts +0 -2
@@ -0,0 +1,1026 @@
1
+ import * as readline from 'node:readline';
2
+
3
+ import type {
4
+ PublicationWorkflowLogger,
5
+ PublicationWorkflowResult,
6
+ } from './runPublicationWorkflow.js';
7
+
8
+ type PublicationProgressMode = 'new-version' | 'resume';
9
+
10
+ type ProgressPhaseKey =
11
+ | 'source'
12
+ | 'ingestion'
13
+ | 'context'
14
+ | 'mint'
15
+ | 'verify'
16
+ | 'attest'
17
+ | 'submit';
18
+
19
+ type ProgressPhaseState = 'pending' | 'active' | 'complete';
20
+
21
+ type ProgressMetadata = Record<string, unknown>;
22
+
23
+ type ProgressSourceKind = 'apk-file' | 'apk-url' | 'resume';
24
+
25
+ type ProgressContext = Partial<{
26
+ sourceKind: ProgressSourceKind;
27
+ fileName: string;
28
+ apkUrl: string;
29
+ sourceReleaseId: string;
30
+ androidPackage: string;
31
+ versionName: string;
32
+ releaseId: string;
33
+ publicationSessionId: string;
34
+ ingestionSessionId: string;
35
+ mintAddress: string;
36
+ transactionSignature: string;
37
+ hubspotTicketId: string;
38
+ requestUniqueId: string;
39
+ fileSize: number;
40
+ bytesUploaded: number;
41
+ bytesTotal: number;
42
+ ingestionStatus: string;
43
+ ingestionProgress: number;
44
+ ingestionStage: string;
45
+ ingestionDetail: string;
46
+ activeStep: string;
47
+ }>;
48
+
49
+ const SPINNER_FRAMES = ['|', '/', '-', '\\'] as const;
50
+
51
+ const PHASES: Array<{ key: ProgressPhaseKey; label: string }> = [
52
+ { key: 'source', label: 'Prepare source' },
53
+ { key: 'ingestion', label: 'Process APK' },
54
+ { key: 'context', label: 'Load release context' },
55
+ { key: 'mint', label: 'Mint release NFT' },
56
+ { key: 'verify', label: 'Verify collection' },
57
+ { key: 'attest', label: 'Create attestation' },
58
+ { key: 'submit', label: 'Submit to review' },
59
+ ];
60
+
61
+ const PHASE_WEIGHT_PROFILES: Record<
62
+ ProgressSourceKind,
63
+ Record<ProgressPhaseKey, number>
64
+ > = {
65
+ 'apk-file': {
66
+ source: 0.42,
67
+ ingestion: 0.2,
68
+ context: 0.08,
69
+ mint: 0.12,
70
+ verify: 0.08,
71
+ attest: 0.04,
72
+ submit: 0.06,
73
+ },
74
+ 'apk-url': {
75
+ source: 0.05,
76
+ ingestion: 0.43,
77
+ context: 0.12,
78
+ mint: 0.16,
79
+ verify: 0.1,
80
+ attest: 0.05,
81
+ submit: 0.09,
82
+ },
83
+ resume: {
84
+ source: 0.42,
85
+ ingestion: 0.2,
86
+ context: 0.08,
87
+ mint: 0.12,
88
+ verify: 0.08,
89
+ attest: 0.04,
90
+ submit: 0.06,
91
+ },
92
+ };
93
+
94
+ const STEP_PROGRESS_RANGES: Record<
95
+ string,
96
+ { start: number; end: number }
97
+ > = {
98
+ 'source.prepare': { start: 0.02, end: 0.1 },
99
+ 'source.upload': { start: 0.1, end: 1 },
100
+ 'source.ready': { start: 0.2, end: 1 },
101
+ 'ingestion.create': { start: 0.05, end: 0.2 },
102
+ 'ingestion.wait': { start: 0.2, end: 1 },
103
+ 'bundle.load': { start: 0.1, end: 0.55 },
104
+ 'session.load': { start: 0.55, end: 1 },
105
+ 'mint.prepare': { start: 0.05, end: 0.3 },
106
+ 'mint.submit': { start: 0.3, end: 0.65 },
107
+ 'mint.save': { start: 0.65, end: 1 },
108
+ 'verify.prepare': { start: 0.05, end: 0.35 },
109
+ 'verify.submit': { start: 0.35, end: 1 },
110
+ 'attestation.create': { start: 0.05, end: 1 },
111
+ 'submit.store': { start: 0.05, end: 1 },
112
+ };
113
+
114
+ const DEFAULT_RUNNING_STEP_PROGRESS = 0.2;
115
+
116
+ const STEP_TO_PHASE: Record<string, ProgressPhaseKey> = {
117
+ 'source.prepare': 'source',
118
+ 'source.upload': 'source',
119
+ 'source.ready': 'source',
120
+ 'ingestion.create': 'ingestion',
121
+ 'ingestion.wait': 'ingestion',
122
+ 'bundle.load': 'context',
123
+ 'session.load': 'context',
124
+ 'mint.prepare': 'mint',
125
+ 'mint.submit': 'mint',
126
+ 'mint.save': 'mint',
127
+ 'verify.prepare': 'verify',
128
+ 'verify.submit': 'verify',
129
+ 'attestation.create': 'attest',
130
+ 'submit.store': 'submit',
131
+ };
132
+
133
+ const PHASE_FINAL_STEPS: Record<ProgressPhaseKey, string[]> = {
134
+ source: ['source.ready', 'source.upload'],
135
+ ingestion: ['ingestion.wait'],
136
+ context: ['session.load'],
137
+ mint: ['mint.save'],
138
+ verify: ['verify.submit'],
139
+ attest: ['attestation.create'],
140
+ submit: ['submit.store'],
141
+ };
142
+
143
+ const STAGE_COMPLETED_PHASES: Record<string, ProgressPhaseKey[]> = {
144
+ PreparedForMint: ['source', 'ingestion', 'context'],
145
+ MintSubmitted: ['source', 'ingestion', 'context'],
146
+ MintSaved: ['source', 'ingestion', 'context', 'mint'],
147
+ VerificationSubmitted: ['source', 'ingestion', 'context', 'mint'],
148
+ Verified: ['source', 'ingestion', 'context', 'mint', 'verify'],
149
+ Attested: ['source', 'ingestion', 'context', 'mint', 'verify', 'attest'],
150
+ Submitted: [
151
+ 'source',
152
+ 'ingestion',
153
+ 'context',
154
+ 'mint',
155
+ 'verify',
156
+ 'attest',
157
+ 'submit',
158
+ ],
159
+ };
160
+
161
+ const MAX_RECENT_EVENTS = 2;
162
+ const VERBOSE_PREFIX = 'Verbose';
163
+
164
+ export function createPublicationProgressReporter(input: {
165
+ title: string;
166
+ mode: PublicationProgressMode;
167
+ verbose?: boolean;
168
+ stream?: NodeJS.WriteStream;
169
+ }) {
170
+ return new PublicationProgressReporter(input);
171
+ }
172
+
173
+ class PublicationProgressReporter {
174
+ readonly logger: PublicationWorkflowLogger;
175
+
176
+ private readonly title: string;
177
+ private readonly stream: NodeJS.WriteStream;
178
+ private readonly interactive: boolean;
179
+ private readonly verbose: boolean;
180
+ private readonly phaseStates: Record<ProgressPhaseKey, ProgressPhaseState>;
181
+ private readonly phaseProgress: Record<ProgressPhaseKey, number>;
182
+ private readonly verboseValues = new Map<string, string>();
183
+
184
+ private currentMessage: string;
185
+ private recentEvents: string[] = [];
186
+ private context: ProgressContext = {};
187
+ private spinnerIndex = 0;
188
+ private renderedLineCount = 0;
189
+ private intervalId?: ReturnType<typeof setInterval>;
190
+ private finalState?: 'complete' | 'failed';
191
+
192
+ constructor(input: {
193
+ title: string;
194
+ mode: PublicationProgressMode;
195
+ verbose?: boolean;
196
+ stream?: NodeJS.WriteStream;
197
+ }) {
198
+ this.title = input.title;
199
+ this.stream = input.stream ?? process.stdout;
200
+ this.interactive =
201
+ Boolean(this.stream.isTTY) && process.env.TERM !== 'dumb';
202
+ this.verbose = Boolean(input.verbose);
203
+ this.phaseStates = Object.fromEntries(
204
+ PHASES.map(({ key }) => [key, 'pending']),
205
+ ) as Record<ProgressPhaseKey, ProgressPhaseState>;
206
+ this.phaseProgress = Object.fromEntries(
207
+ PHASES.map(({ key }) => [key, 0]),
208
+ ) as Record<ProgressPhaseKey, number>;
209
+ this.currentMessage =
210
+ input.mode === 'resume'
211
+ ? 'Loading existing publication state'
212
+ : 'Preparing publication workflow';
213
+
214
+ if (input.mode === 'resume') {
215
+ this.context.sourceKind = 'resume';
216
+ }
217
+
218
+ if (input.mode === 'resume') {
219
+ this.phaseStates.source = 'complete';
220
+ this.phaseStates.ingestion = 'complete';
221
+ this.phaseProgress.source = 1;
222
+ this.phaseProgress.ingestion = 1;
223
+ }
224
+
225
+ this.logger = {
226
+ debug: (message, metadata) => {
227
+ this.handleEvent('debug', message, metadata);
228
+ },
229
+ info: (message, metadata) => {
230
+ this.handleEvent('info', message, metadata);
231
+ },
232
+ warn: (message, metadata) => {
233
+ this.handleEvent('warn', message, metadata);
234
+ },
235
+ };
236
+ }
237
+
238
+ start(input?: { message?: string; metadata?: ProgressMetadata }) {
239
+ if (input?.message) {
240
+ this.currentMessage = input.message;
241
+ }
242
+
243
+ if (input?.metadata) {
244
+ this.updateContext(input.metadata);
245
+ }
246
+
247
+ this.ensureActivePhase();
248
+
249
+ if (this.interactive) {
250
+ this.stream.write('\x1B[?25l');
251
+ this.render();
252
+ this.intervalId = setInterval(() => {
253
+ if (this.finalState) {
254
+ return;
255
+ }
256
+
257
+ this.spinnerIndex = (this.spinnerIndex + 1) % SPINNER_FRAMES.length;
258
+ this.render();
259
+ }, 120);
260
+ this.intervalId.unref?.();
261
+ return;
262
+ }
263
+
264
+ console.log(`${this.title}: ${this.currentMessage}`);
265
+ }
266
+
267
+ complete(result: PublicationWorkflowResult) {
268
+ this.finalState = 'complete';
269
+ this.currentMessage = 'Publication workflow completed';
270
+ this.updateContext({
271
+ androidPackage: result.publicationBundle.release.androidPackage,
272
+ versionName: result.publicationBundle.release.versionName,
273
+ releaseId: result.releaseId,
274
+ publicationSessionId: result.publicationSessionId,
275
+ ingestionSessionId: result.ingestionSessionId,
276
+ mintAddress: result.releaseMintAddress,
277
+ transactionSignature:
278
+ result.collectionTransactionSignature ??
279
+ result.releaseTransactionSignature,
280
+ hubspotTicketId: result.hubspotTicketId,
281
+ requestUniqueId: result.attestationRequestUniqueId,
282
+ });
283
+
284
+ for (const { key } of PHASES) {
285
+ this.phaseStates[key] = 'complete';
286
+ this.phaseProgress[key] = 1;
287
+ }
288
+
289
+ this.pushRecentEvent('Publication workflow completed');
290
+ this.stopAndRenderFinalState();
291
+ }
292
+
293
+ fail(error: unknown) {
294
+ this.finalState = 'failed';
295
+ if (error instanceof Error && error.message.trim().length > 0) {
296
+ this.pushRecentEvent(`Failed: ${error.message.trim()}`);
297
+ }
298
+ this.stopAndRenderFinalState();
299
+ }
300
+
301
+ private handleEvent(
302
+ level: 'debug' | 'info' | 'warn',
303
+ message: string,
304
+ metadata?: ProgressMetadata,
305
+ ) {
306
+ this.currentMessage = message.trim().length > 0 ? message : this.currentMessage;
307
+
308
+ if (metadata) {
309
+ this.updateContext(metadata);
310
+ this.updatePhaseState(metadata);
311
+ this.applyStageHint(metadata);
312
+ this.emitVerboseMetadata(metadata);
313
+ }
314
+
315
+ if (level === 'warn') {
316
+ this.pushRecentEvent(`Warning: ${message}`);
317
+ } else if (
318
+ level === 'info' &&
319
+ this.readString(metadata, 'status') === 'complete'
320
+ ) {
321
+ this.pushRecentEvent(message);
322
+ }
323
+
324
+ if (this.interactive) {
325
+ this.render();
326
+ return;
327
+ }
328
+
329
+ if (level === 'debug') {
330
+ return;
331
+ }
332
+
333
+ console.log(this.buildLogLine(level, message, metadata));
334
+ }
335
+
336
+ private updatePhaseState(metadata: ProgressMetadata) {
337
+ const step = this.readString(metadata, 'step');
338
+ if (!step) {
339
+ return;
340
+ }
341
+
342
+ const phaseKey = STEP_TO_PHASE[step];
343
+ if (!phaseKey) {
344
+ return;
345
+ }
346
+
347
+ const phaseIndex = this.getPhaseIndex(phaseKey);
348
+ for (let index = 0; index < phaseIndex; index += 1) {
349
+ this.phaseStates[PHASES[index].key] = 'complete';
350
+ this.phaseProgress[PHASES[index].key] = 1;
351
+ }
352
+
353
+ if (this.phaseStates[phaseKey] !== 'complete') {
354
+ this.phaseStates[phaseKey] = 'active';
355
+ }
356
+
357
+ const status = this.readString(metadata, 'status');
358
+ const resolvedProgress = this.resolvePhaseProgress(step, metadata, status);
359
+ if (resolvedProgress !== undefined) {
360
+ this.phaseProgress[phaseKey] = Math.max(
361
+ this.phaseProgress[phaseKey],
362
+ resolvedProgress,
363
+ );
364
+ }
365
+
366
+ if (
367
+ status === 'complete' &&
368
+ PHASE_FINAL_STEPS[phaseKey].includes(step)
369
+ ) {
370
+ this.phaseStates[phaseKey] = 'complete';
371
+ this.phaseProgress[phaseKey] = 1;
372
+ this.ensureActivePhase();
373
+ }
374
+ }
375
+
376
+ private resolvePhaseProgress(
377
+ step: string,
378
+ metadata: ProgressMetadata,
379
+ status: string | undefined,
380
+ ): number | undefined {
381
+ const range = STEP_PROGRESS_RANGES[step];
382
+ const explicitProgress =
383
+ this.readProgress(metadata, 'stepProgress') ??
384
+ this.readByteProgress(metadata);
385
+
386
+ if (status === 'complete') {
387
+ return range?.end ?? 1;
388
+ }
389
+
390
+ if (!range) {
391
+ return explicitProgress;
392
+ }
393
+
394
+ if (explicitProgress !== undefined) {
395
+ return this.interpolateProgress(range, explicitProgress);
396
+ }
397
+
398
+ if (status === 'running') {
399
+ return this.interpolateProgress(range, DEFAULT_RUNNING_STEP_PROGRESS);
400
+ }
401
+
402
+ return undefined;
403
+ }
404
+
405
+ private interpolateProgress(
406
+ range: { start: number; end: number },
407
+ progress: number,
408
+ ): number {
409
+ const clampedProgress = Math.max(0, Math.min(1, progress));
410
+ return range.start + (range.end - range.start) * clampedProgress;
411
+ }
412
+
413
+ private applyStageHint(metadata: ProgressMetadata) {
414
+ const stage = this.readString(metadata, 'stage');
415
+ if (!stage) {
416
+ return;
417
+ }
418
+
419
+ const completedPhases = STAGE_COMPLETED_PHASES[stage];
420
+ if (!completedPhases) {
421
+ return;
422
+ }
423
+
424
+ for (const phaseKey of completedPhases) {
425
+ this.phaseStates[phaseKey] = 'complete';
426
+ this.phaseProgress[phaseKey] = 1;
427
+ }
428
+
429
+ this.ensureActivePhase();
430
+ }
431
+
432
+ private updateContext(metadata: ProgressMetadata) {
433
+ const sourceKind = this.readString(metadata, 'sourceKind');
434
+ if (
435
+ sourceKind === 'apk-file' ||
436
+ sourceKind === 'apk-url' ||
437
+ sourceKind === 'resume'
438
+ ) {
439
+ this.context.sourceKind = sourceKind;
440
+ }
441
+
442
+ const fileName = this.readString(metadata, 'fileName');
443
+ if (fileName) {
444
+ this.context.fileName = fileName;
445
+ }
446
+
447
+ const apkUrl = this.readString(metadata, 'apkUrl');
448
+ if (apkUrl) {
449
+ this.context.apkUrl = apkUrl;
450
+ }
451
+
452
+ const sourceReleaseId = this.readString(metadata, 'sourceReleaseId');
453
+ if (sourceReleaseId) {
454
+ this.context.sourceReleaseId = sourceReleaseId;
455
+ }
456
+
457
+ const androidPackage = this.readString(metadata, 'androidPackage');
458
+ if (androidPackage) {
459
+ this.context.androidPackage = androidPackage;
460
+ }
461
+
462
+ const versionName = this.readString(metadata, 'versionName');
463
+ if (versionName) {
464
+ this.context.versionName = versionName;
465
+ }
466
+
467
+ const releaseId = this.readString(metadata, 'releaseId');
468
+ if (releaseId) {
469
+ this.context.releaseId = releaseId;
470
+ }
471
+
472
+ const publicationSessionId =
473
+ this.readString(metadata, 'publicationSessionId') ??
474
+ this.readString(metadata, 'sessionId');
475
+ if (publicationSessionId) {
476
+ this.context.publicationSessionId = publicationSessionId;
477
+ }
478
+
479
+ const ingestionSessionId = this.readString(metadata, 'ingestionSessionId');
480
+ if (ingestionSessionId) {
481
+ this.context.ingestionSessionId = ingestionSessionId;
482
+ }
483
+
484
+ const mintAddress = this.readString(metadata, 'mintAddress');
485
+ if (mintAddress) {
486
+ this.context.mintAddress = mintAddress;
487
+ }
488
+
489
+ const transactionSignature = this.readString(
490
+ metadata,
491
+ 'transactionSignature',
492
+ );
493
+ if (transactionSignature) {
494
+ this.context.transactionSignature = transactionSignature;
495
+ }
496
+
497
+ const hubspotTicketId = this.readString(metadata, 'hubspotTicketId');
498
+ if (hubspotTicketId) {
499
+ this.context.hubspotTicketId = hubspotTicketId;
500
+ }
501
+
502
+ const requestUniqueId = this.readString(metadata, 'requestUniqueId');
503
+ if (requestUniqueId) {
504
+ this.context.requestUniqueId = requestUniqueId;
505
+ }
506
+
507
+ const fileSize = this.readNumber(metadata, 'fileSize');
508
+ if (fileSize !== undefined) {
509
+ this.context.fileSize = fileSize;
510
+ }
511
+
512
+ const bytesUploaded = this.readNumber(metadata, 'bytesUploaded');
513
+ if (bytesUploaded !== undefined) {
514
+ this.context.bytesUploaded = bytesUploaded;
515
+ }
516
+
517
+ const bytesTotal =
518
+ this.readNumber(metadata, 'bytesTotal') ??
519
+ this.readNumber(metadata, 'fileSize');
520
+ if (bytesTotal !== undefined) {
521
+ this.context.bytesTotal = bytesTotal;
522
+ }
523
+
524
+ const ingestionStatus = this.readString(metadata, 'ingestionStatus');
525
+ if (ingestionStatus) {
526
+ this.context.ingestionStatus = ingestionStatus;
527
+ }
528
+
529
+ const ingestionProgress = this.readNumber(metadata, 'ingestionProgress');
530
+ if (ingestionProgress !== undefined) {
531
+ this.context.ingestionProgress = Math.max(
532
+ 0,
533
+ Math.min(100, ingestionProgress),
534
+ );
535
+ }
536
+
537
+ const ingestionStage = this.readString(metadata, 'ingestionStage');
538
+ if (ingestionStage) {
539
+ this.context.ingestionStage = ingestionStage;
540
+ }
541
+
542
+ const ingestionDetail = this.readString(metadata, 'ingestionDetail');
543
+ if (ingestionDetail) {
544
+ this.context.ingestionDetail = ingestionDetail;
545
+ }
546
+
547
+ const activeStep = this.readString(metadata, 'step');
548
+ if (activeStep) {
549
+ this.context.activeStep = activeStep;
550
+ }
551
+ }
552
+
553
+ private stopAndRenderFinalState() {
554
+ if (this.intervalId) {
555
+ clearInterval(this.intervalId);
556
+ this.intervalId = undefined;
557
+ }
558
+
559
+ if (this.interactive) {
560
+ this.render();
561
+ this.stream.write('\n');
562
+ this.stream.write('\x1B[?25h');
563
+ return;
564
+ }
565
+
566
+ const statusLabel =
567
+ this.finalState === 'complete' ? 'completed' : 'failed';
568
+ console.log(`${this.title}: ${statusLabel}`);
569
+ }
570
+
571
+ private ensureActivePhase() {
572
+ if (this.finalState) {
573
+ return;
574
+ }
575
+
576
+ const activePhase = PHASES.find(
577
+ ({ key }) => this.phaseStates[key] === 'active',
578
+ );
579
+ if (activePhase) {
580
+ return;
581
+ }
582
+
583
+ const nextPendingPhase = PHASES.find(
584
+ ({ key }) => this.phaseStates[key] === 'pending',
585
+ );
586
+
587
+ if (nextPendingPhase) {
588
+ this.phaseStates[nextPendingPhase.key] = 'active';
589
+ }
590
+ }
591
+
592
+ private render() {
593
+ const lines = this.buildLines();
594
+
595
+ if (this.renderedLineCount > 0) {
596
+ readline.moveCursor(this.stream, 0, -(this.renderedLineCount - 1));
597
+ readline.cursorTo(this.stream, 0);
598
+ readline.clearScreenDown(this.stream);
599
+ }
600
+
601
+ this.stream.write(lines.join('\n'));
602
+ this.renderedLineCount = lines.length;
603
+ }
604
+
605
+ private buildLines(): string[] {
606
+ const completedCount = PHASES.filter(
607
+ ({ key }) => this.phaseStates[key] === 'complete',
608
+ ).length;
609
+ const activePhase = this.getActivePhaseKey();
610
+ const phaseLabel =
611
+ activePhase && this.finalState !== 'complete'
612
+ ? PHASES[this.getPhaseIndex(activePhase)].label
613
+ : 'Complete';
614
+ const percent = this.getProgressPercent();
615
+ const bar = this.buildProgressBar(percent);
616
+ const statusToken = this.getStatusToken();
617
+ const lines = [
618
+ this.fitToWidth(
619
+ `${this.title} [${statusToken}] ${completedCount}/${PHASES.length} complete | ${phaseLabel}`,
620
+ ),
621
+ this.fitToWidth(`${bar} ${Math.round(percent * 100)}%`),
622
+ this.fitToWidth(`Working: ${this.currentMessage}`),
623
+ ...this.buildDetailLines(),
624
+ ];
625
+
626
+ return lines;
627
+ }
628
+
629
+ private buildDetailLines(): string[] {
630
+ const targetTokens = [
631
+ this.context.androidPackage
632
+ ? `app ${this.truncateMiddle(this.context.androidPackage, 42)}`
633
+ : null,
634
+ this.context.versionName
635
+ ? `version ${this.truncateMiddle(this.context.versionName, 24)}`
636
+ : null,
637
+ this.context.fileName
638
+ ? `file ${this.truncateMiddle(this.context.fileName, 26)}`
639
+ : null,
640
+ this.context.apkUrl
641
+ ? `url ${this.truncateMiddle(this.compactUrl(this.context.apkUrl), 32)}`
642
+ : null,
643
+ this.context.sourceReleaseId
644
+ ? `source ${this.compactIdentifier(this.context.sourceReleaseId)}`
645
+ : null,
646
+ ].filter((token): token is string => token !== null);
647
+
648
+ const lines: string[] = [];
649
+ if (targetTokens.length > 0) {
650
+ lines.push(this.fitToWidth(`Target: ${targetTokens.join(' | ')}`));
651
+ }
652
+ const uploadLine = this.buildUploadLine();
653
+ if (uploadLine) {
654
+ lines.push(this.fitToWidth(uploadLine));
655
+ }
656
+ const ingestionLine = this.buildIngestionLine();
657
+ if (ingestionLine) {
658
+ lines.push(this.fitToWidth(ingestionLine));
659
+ }
660
+ if (this.recentEvents.length > 0) {
661
+ lines.push(
662
+ this.fitToWidth(
663
+ `Recent: ${this.recentEvents[this.recentEvents.length - 1]}`,
664
+ ),
665
+ );
666
+ }
667
+
668
+ return lines;
669
+ }
670
+
671
+ private buildProgressBar(percent: number): string {
672
+ const columns = this.stream.columns ?? 100;
673
+ const barWidth = Math.min(32, Math.max(10, columns - 44));
674
+ const filled = Math.max(0, Math.min(barWidth, Math.round(percent * barWidth)));
675
+ const empty = Math.max(0, barWidth - filled);
676
+ return `[${'#'.repeat(filled)}${'-'.repeat(empty)}]`;
677
+ }
678
+
679
+ private buildUploadLine(): string | null {
680
+ if (
681
+ this.context.bytesTotal === undefined ||
682
+ this.context.bytesUploaded === undefined
683
+ ) {
684
+ return null;
685
+ }
686
+
687
+ const activeStep = this.context.activeStep;
688
+ if (activeStep !== 'source.upload' && this.finalState !== 'complete') {
689
+ return null;
690
+ }
691
+
692
+ const ratio =
693
+ this.context.bytesTotal > 0
694
+ ? this.context.bytesUploaded / this.context.bytesTotal
695
+ : 1;
696
+
697
+ return `Upload: ${this.formatBytes(this.context.bytesUploaded)} / ${this.formatBytes(this.context.bytesTotal)} (${Math.round(
698
+ Math.max(0, Math.min(1, ratio)) * 100,
699
+ )}%)`;
700
+ }
701
+
702
+ private buildIngestionLine(): string | null {
703
+ if (
704
+ !this.context.ingestionStatus &&
705
+ !this.context.ingestionStage &&
706
+ this.context.ingestionProgress === undefined
707
+ ) {
708
+ return null;
709
+ }
710
+
711
+ const activePhase = this.getActivePhaseKey();
712
+ if (activePhase !== 'ingestion' && this.finalState !== 'complete') {
713
+ return null;
714
+ }
715
+
716
+ const detail =
717
+ this.context.ingestionDetail ??
718
+ this.context.ingestionStage ??
719
+ this.context.ingestionStatus;
720
+
721
+ const progress =
722
+ this.context.ingestionProgress === undefined
723
+ ? null
724
+ : `${Math.round(this.context.ingestionProgress)}%`;
725
+
726
+ if (detail && progress) {
727
+ return `Ingestion: ${detail} (${progress})`;
728
+ }
729
+
730
+ if (detail) {
731
+ return `Ingestion: ${detail}`;
732
+ }
733
+
734
+ if (progress) {
735
+ return `Ingestion: ${progress}`;
736
+ }
737
+
738
+ return null;
739
+ }
740
+
741
+ private getProgressPercent(): number {
742
+ if (this.finalState === 'complete') {
743
+ return 1;
744
+ }
745
+
746
+ const weights = this.getPhaseWeights();
747
+ const progress = PHASES.reduce((total, { key }) => {
748
+ const phaseProgress = Math.max(0, Math.min(1, this.phaseProgress[key]));
749
+ return total + phaseProgress * weights[key];
750
+ }, 0);
751
+
752
+ return Math.max(0, Math.min(0.99, progress));
753
+ }
754
+
755
+ private getStatusToken(): string {
756
+ if (this.finalState === 'complete') {
757
+ return 'done';
758
+ }
759
+
760
+ if (this.finalState === 'failed') {
761
+ return 'fail';
762
+ }
763
+
764
+ return SPINNER_FRAMES[this.spinnerIndex];
765
+ }
766
+
767
+ private getActivePhaseKey(): ProgressPhaseKey | undefined {
768
+ const activePhase = PHASES.find(
769
+ ({ key }) => this.phaseStates[key] === 'active',
770
+ );
771
+ if (activePhase) {
772
+ return activePhase.key;
773
+ }
774
+
775
+ const pendingPhase = PHASES.find(
776
+ ({ key }) => this.phaseStates[key] === 'pending',
777
+ );
778
+ return pendingPhase?.key;
779
+ }
780
+
781
+ private getPhaseIndex(phaseKey: ProgressPhaseKey): number {
782
+ return PHASES.findIndex(({ key }) => key === phaseKey);
783
+ }
784
+
785
+ private emitVerboseMetadata(metadata: ProgressMetadata) {
786
+ if (!this.verbose) {
787
+ return;
788
+ }
789
+
790
+ const lines = this.buildVerboseMetadataLines(metadata);
791
+ if (lines.length === 0) {
792
+ return;
793
+ }
794
+
795
+ if (this.interactive && this.renderedLineCount > 0) {
796
+ readline.moveCursor(this.stream, 0, -(this.renderedLineCount - 1));
797
+ readline.cursorTo(this.stream, 0);
798
+ readline.clearScreenDown(this.stream);
799
+ this.renderedLineCount = 0;
800
+ }
801
+
802
+ for (const line of lines) {
803
+ this.stream.write(`${line}\n`);
804
+ }
805
+ }
806
+
807
+ private buildVerboseMetadataLines(metadata: ProgressMetadata): string[] {
808
+ const lines: string[] = [];
809
+ const step = this.readString(metadata, 'step');
810
+
811
+ this.appendVerboseField(
812
+ lines,
813
+ 'releaseId',
814
+ 'Release ID',
815
+ this.readString(metadata, 'releaseId'),
816
+ );
817
+ this.appendVerboseField(
818
+ lines,
819
+ 'publicationSessionId',
820
+ 'Publication session ID',
821
+ this.readString(metadata, 'publicationSessionId') ??
822
+ this.readString(metadata, 'sessionId'),
823
+ );
824
+ this.appendVerboseField(
825
+ lines,
826
+ 'ingestionSessionId',
827
+ 'Ingestion session ID',
828
+ this.readString(metadata, 'ingestionSessionId'),
829
+ );
830
+
831
+ const transactionSignature = this.readString(metadata, 'transactionSignature');
832
+ if (transactionSignature) {
833
+ const transactionKey =
834
+ step === 'verify.submit'
835
+ ? 'collectionTransactionSignature'
836
+ : 'releaseTransactionSignature';
837
+ const transactionLabel =
838
+ step === 'verify.submit'
839
+ ? 'Collection transaction signature'
840
+ : 'Release transaction signature';
841
+
842
+ this.appendVerboseField(
843
+ lines,
844
+ transactionKey,
845
+ transactionLabel,
846
+ transactionSignature,
847
+ );
848
+ }
849
+
850
+ this.appendVerboseField(
851
+ lines,
852
+ 'attestationRequestUniqueId',
853
+ 'Attestation request ID',
854
+ this.readString(metadata, 'requestUniqueId'),
855
+ );
856
+ this.appendVerboseField(
857
+ lines,
858
+ 'ticketId',
859
+ 'Ticket ID',
860
+ this.readString(metadata, 'hubspotTicketId'),
861
+ );
862
+
863
+ return lines;
864
+ }
865
+
866
+ private appendVerboseField(
867
+ lines: string[],
868
+ key: string,
869
+ label: string,
870
+ value: string | undefined,
871
+ ) {
872
+ if (!value) {
873
+ return;
874
+ }
875
+
876
+ const previousValue = this.verboseValues.get(key);
877
+ if (previousValue === value) {
878
+ return;
879
+ }
880
+
881
+ this.verboseValues.set(key, value);
882
+ lines.push(`${VERBOSE_PREFIX}: ${label}: ${value}`);
883
+ }
884
+
885
+ private pushRecentEvent(message: string) {
886
+ this.recentEvents.push(this.truncateMiddle(message.trim(), 96));
887
+ if (this.recentEvents.length > MAX_RECENT_EVENTS) {
888
+ this.recentEvents = this.recentEvents.slice(-MAX_RECENT_EVENTS);
889
+ }
890
+ }
891
+
892
+ private buildLogLine(
893
+ level: 'info' | 'warn',
894
+ message: string,
895
+ metadata?: ProgressMetadata,
896
+ ): string {
897
+ const step = this.readString(metadata, 'step');
898
+ const phaseKey = step ? STEP_TO_PHASE[step] : this.getActivePhaseKey();
899
+ const phaseIndex = phaseKey ? this.getPhaseIndex(phaseKey) + 1 : 0;
900
+ const phaseLabel = phaseKey
901
+ ? PHASES[this.getPhaseIndex(phaseKey)].label
902
+ : 'Publication workflow';
903
+ const prefix =
904
+ level === 'warn'
905
+ ? 'warning'
906
+ : phaseIndex > 0
907
+ ? `${phaseIndex}/${PHASES.length}`
908
+ : 'info';
909
+
910
+ return `${prefix} ${phaseLabel}: ${message}`;
911
+ }
912
+
913
+ private compactIdentifier(value: string): string {
914
+ return this.truncateMiddle(value, 18);
915
+ }
916
+
917
+ private getPhaseWeights(): Record<ProgressPhaseKey, number> {
918
+ const profile =
919
+ this.context.sourceKind ??
920
+ (this.context.apkUrl ? 'apk-url' : 'apk-file');
921
+ return PHASE_WEIGHT_PROFILES[profile];
922
+ }
923
+
924
+ private compactUrl(value: string): string {
925
+ try {
926
+ const url = new URL(value);
927
+ const path = url.pathname === '/' ? '' : url.pathname;
928
+ return `${url.host}${path}`;
929
+ } catch {
930
+ return value;
931
+ }
932
+ }
933
+
934
+ private fitToWidth(value: string): string {
935
+ const width = this.stream.columns ?? 100;
936
+ if (width <= 3) {
937
+ return value.slice(0, Math.max(0, width));
938
+ }
939
+ return value.length <= width ? value : `${value.slice(0, width - 3)}...`;
940
+ }
941
+
942
+ private truncateMiddle(value: string, maxLength: number): string {
943
+ if (value.length <= maxLength) {
944
+ return value;
945
+ }
946
+
947
+ if (maxLength <= 3) {
948
+ return value.slice(0, maxLength);
949
+ }
950
+
951
+ const startLength = Math.ceil((maxLength - 3) / 2);
952
+ const endLength = Math.floor((maxLength - 3) / 2);
953
+ return `${value.slice(0, startLength)}...${value.slice(-endLength)}`;
954
+ }
955
+
956
+ private readString(
957
+ metadata: ProgressMetadata | undefined,
958
+ key: string,
959
+ ): string | undefined {
960
+ const value = metadata?.[key];
961
+ return typeof value === 'string' && value.trim().length > 0
962
+ ? value.trim()
963
+ : undefined;
964
+ }
965
+
966
+ private readNumber(
967
+ metadata: ProgressMetadata | undefined,
968
+ key: string,
969
+ ): number | undefined {
970
+ const value = metadata?.[key];
971
+ return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
972
+ }
973
+
974
+ private readProgress(
975
+ metadata: ProgressMetadata | undefined,
976
+ key: string,
977
+ ): number | undefined {
978
+ const value = this.readNumber(metadata, key);
979
+ if (value === undefined) {
980
+ return undefined;
981
+ }
982
+
983
+ if (value > 1) {
984
+ return Math.max(0, Math.min(1, value / 100));
985
+ }
986
+
987
+ return Math.max(0, Math.min(1, value));
988
+ }
989
+
990
+ private readByteProgress(
991
+ metadata: ProgressMetadata | undefined,
992
+ ): number | undefined {
993
+ const bytesUploaded = this.readNumber(metadata, 'bytesUploaded');
994
+ const bytesTotal =
995
+ this.readNumber(metadata, 'bytesTotal') ??
996
+ this.readNumber(metadata, 'fileSize');
997
+
998
+ if (
999
+ bytesUploaded === undefined ||
1000
+ bytesTotal === undefined ||
1001
+ bytesTotal <= 0
1002
+ ) {
1003
+ return undefined;
1004
+ }
1005
+
1006
+ return Math.max(0, Math.min(1, bytesUploaded / bytesTotal));
1007
+ }
1008
+
1009
+ private formatBytes(value: number): string {
1010
+ if (value < 1024) {
1011
+ return `${Math.round(value)} B`;
1012
+ }
1013
+
1014
+ const units = ['KB', 'MB', 'GB', 'TB'];
1015
+ let size = value;
1016
+ let unitIndex = -1;
1017
+
1018
+ do {
1019
+ size /= 1024;
1020
+ unitIndex += 1;
1021
+ } while (size >= 1024 && unitIndex < units.length - 1);
1022
+
1023
+ const digits = size >= 100 ? 0 : size >= 10 ? 1 : 2;
1024
+ return `${size.toFixed(digits)} ${units[unitIndex]}`;
1025
+ }
1026
+ }