@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,210 @@
1
+ import { expect, jest, test } from '@jest/globals';
2
+ import { Writable } from 'node:stream';
3
+
4
+ import { createPublicationProgressReporter } from '../PublicationProgressReporter';
5
+
6
+ function createTestStream() {
7
+ const output: string[] = [];
8
+ const stream = new Writable({
9
+ write(chunk, _encoding, callback) {
10
+ output.push(String(chunk));
11
+ callback();
12
+ },
13
+ }) as Writable & NodeJS.WriteStream & { output: string[] };
14
+
15
+ stream.isTTY = false;
16
+ stream.columns = 120;
17
+ stream.output = output;
18
+
19
+ return stream;
20
+ }
21
+
22
+ test('local file upload progress materially advances the bar', () => {
23
+ const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
24
+ const stream = createTestStream();
25
+ try {
26
+ const reporter = createPublicationProgressReporter({
27
+ title: 'Publishing version',
28
+ mode: 'new-version',
29
+ stream,
30
+ }) as any;
31
+
32
+ reporter.start({
33
+ metadata: {
34
+ sourceKind: 'apk-file',
35
+ fileName: 'app-release.apk',
36
+ },
37
+ });
38
+
39
+ reporter.logger.debug('Uploading APK to portal storage', {
40
+ step: 'source.upload',
41
+ status: 'running',
42
+ fileName: 'app-release.apk',
43
+ fileSize: 100,
44
+ bytesUploaded: 50,
45
+ bytesTotal: 100,
46
+ stepProgress: 0.5,
47
+ });
48
+
49
+ const lines = reporter.buildLines();
50
+
51
+ expect(reporter.getProgressPercent()).toBeGreaterThan(0.2);
52
+ expect(lines[1]).toContain('23%');
53
+ expect(lines).toContain('Upload: 50 B / 100 B (50%)');
54
+ } finally {
55
+ logSpy.mockRestore();
56
+ }
57
+ });
58
+
59
+ test('ingestion status updates continue advancing overall progress', () => {
60
+ const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
61
+ const stream = createTestStream();
62
+ try {
63
+ const reporter = createPublicationProgressReporter({
64
+ title: 'Publishing version',
65
+ mode: 'new-version',
66
+ stream,
67
+ }) as any;
68
+
69
+ reporter.start({
70
+ metadata: {
71
+ sourceKind: 'apk-file',
72
+ fileName: 'app-release.apk',
73
+ },
74
+ });
75
+
76
+ reporter.logger.info('APK uploaded to portal storage', {
77
+ step: 'source.upload',
78
+ status: 'complete',
79
+ fileName: 'app-release.apk',
80
+ });
81
+
82
+ reporter.logger.info('Extracting APK metadata', {
83
+ step: 'ingestion.wait',
84
+ status: 'running',
85
+ ingestionStatus: 'processing',
86
+ ingestionStage: 'ExtractingApk',
87
+ ingestionDetail: 'Extracting APK metadata',
88
+ ingestionProgress: 45,
89
+ stepProgress: 0.7,
90
+ });
91
+
92
+ const lines = reporter.buildLines();
93
+
94
+ expect(reporter.getProgressPercent()).toBeGreaterThan(0.55);
95
+ expect(lines[1]).toContain('57%');
96
+ expect(lines).toContain('Ingestion: Extracting APK metadata (45%)');
97
+ } finally {
98
+ logSpy.mockRestore();
99
+ }
100
+ });
101
+
102
+ test('ingestion detail and percentage are rendered while polling', () => {
103
+ const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
104
+ const stream = createTestStream();
105
+ try {
106
+ const reporter = createPublicationProgressReporter({
107
+ title: 'Publishing version',
108
+ mode: 'new-version',
109
+ stream,
110
+ }) as any;
111
+
112
+ reporter.start({
113
+ metadata: {
114
+ sourceKind: 'apk-file',
115
+ fileName: 'app-release.apk',
116
+ },
117
+ });
118
+
119
+ reporter.logger.info('Extracting APK metadata', {
120
+ step: 'ingestion.wait',
121
+ status: 'running',
122
+ ingestionStatus: 'processing',
123
+ ingestionProgress: 45,
124
+ ingestionStage: 'ExtractingApk',
125
+ ingestionDetail: 'Extracting APK metadata',
126
+ stepProgress: 0.45,
127
+ });
128
+
129
+ const lines = reporter.buildLines();
130
+
131
+ expect(lines).toContain('Ingestion: Extracting APK metadata (45%)');
132
+ } finally {
133
+ logSpy.mockRestore();
134
+ }
135
+ });
136
+
137
+ test('verbose mode prints detailed identifiers as they arrive', () => {
138
+ const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
139
+ const stream = createTestStream();
140
+ try {
141
+ const reporter = createPublicationProgressReporter({
142
+ title: 'Publishing version',
143
+ mode: 'new-version',
144
+ verbose: true,
145
+ stream,
146
+ });
147
+
148
+ reporter.start({
149
+ metadata: {
150
+ sourceKind: 'apk-file',
151
+ fileName: 'app-release.apk',
152
+ },
153
+ });
154
+
155
+ reporter.logger.info('Ingestion session created', {
156
+ step: 'ingestion.create',
157
+ status: 'complete',
158
+ releaseId: 'release-123',
159
+ ingestionSessionId: 'ingestion-456',
160
+ });
161
+
162
+ reporter.logger.info('Publication session loaded', {
163
+ step: 'session.load',
164
+ status: 'complete',
165
+ publicationSessionId: 'session-789',
166
+ });
167
+
168
+ reporter.logger.info('Release NFT transaction submitted', {
169
+ step: 'mint.submit',
170
+ status: 'complete',
171
+ transactionSignature: 'release-tx-sig',
172
+ });
173
+
174
+ reporter.logger.info('Release collection verified', {
175
+ step: 'verify.submit',
176
+ status: 'complete',
177
+ transactionSignature: 'collection-tx-sig',
178
+ });
179
+
180
+ reporter.logger.info('Attestation payload created', {
181
+ step: 'attestation.create',
182
+ status: 'complete',
183
+ requestUniqueId: 'attest-111',
184
+ });
185
+
186
+ reporter.logger.info('Release submitted to store', {
187
+ step: 'submit.store',
188
+ status: 'complete',
189
+ hubspotTicketId: 'ticket-222',
190
+ });
191
+
192
+ const output = stream.output.join('');
193
+
194
+ expect(output).toContain('Verbose: Release ID: release-123');
195
+ expect(output).toContain('Verbose: Ingestion session ID: ingestion-456');
196
+ expect(output).toContain('Verbose: Publication session ID: session-789');
197
+ expect(output).toContain(
198
+ 'Verbose: Release transaction signature: release-tx-sig',
199
+ );
200
+ expect(output).toContain(
201
+ 'Verbose: Collection transaction signature: collection-tx-sig',
202
+ );
203
+ expect(output).toContain(
204
+ 'Verbose: Attestation request ID: attest-111',
205
+ );
206
+ expect(output).toContain('Verbose: Ticket ID: ticket-222');
207
+ } finally {
208
+ logSpy.mockRestore();
209
+ }
210
+ });
@@ -0,0 +1,78 @@
1
+ import { describe, expect, it, jest } from "@jest/globals";
2
+ import { PublicKey } from "@solana/web3.js";
3
+
4
+ import {
5
+ ensurePublicationSignerBalance,
6
+ MIN_PUBLICATION_SIGNER_BALANCE_LAMPORTS,
7
+ resolveFundingPreflightRpcUrl,
8
+ } from "../fundingPreflight.js";
9
+
10
+ describe("resolveFundingPreflightRpcUrl", () => {
11
+ it("defaults to mainnet when local-dev is not enabled", () => {
12
+ expect(resolveFundingPreflightRpcUrl({})).toBe(
13
+ "https://api.mainnet-beta.solana.com"
14
+ );
15
+ });
16
+
17
+ it("skips the default RPC in local-dev mode", () => {
18
+ expect(resolveFundingPreflightRpcUrl({ localDev: true })).toBeUndefined();
19
+ });
20
+
21
+ it("honors an explicit RPC URL", () => {
22
+ expect(
23
+ resolveFundingPreflightRpcUrl({
24
+ localDev: true,
25
+ rpcUrl: "https://rpc.example.com",
26
+ })
27
+ ).toBe("https://rpc.example.com");
28
+ });
29
+ });
30
+
31
+ describe("ensurePublicationSignerBalance", () => {
32
+ const publicKey = new PublicKey(new Uint8Array(32).fill(7)).toBase58();
33
+
34
+ it("throws before publication when the signer balance is too low", async () => {
35
+ await expect(
36
+ ensurePublicationSignerBalance(
37
+ {
38
+ publicKey,
39
+ },
40
+ () => ({
41
+ getBalance: jest
42
+ .fn()
43
+ .mockResolvedValue(MIN_PUBLICATION_SIGNER_BALANCE_LAMPORTS - 1),
44
+ })
45
+ )
46
+ ).rejects.toThrow("publishing needs at least");
47
+ });
48
+
49
+ it("returns without warning when the signer balance is sufficient", async () => {
50
+ const result = await ensurePublicationSignerBalance(
51
+ {
52
+ publicKey,
53
+ },
54
+ () => ({
55
+ getBalance: jest
56
+ .fn()
57
+ .mockResolvedValue(MIN_PUBLICATION_SIGNER_BALANCE_LAMPORTS + 1),
58
+ })
59
+ );
60
+
61
+ expect(result).toBeUndefined();
62
+ });
63
+
64
+ it("returns a warning when the RPC balance check fails", async () => {
65
+ const result = await ensurePublicationSignerBalance(
66
+ {
67
+ publicKey,
68
+ },
69
+ () => ({
70
+ getBalance: jest.fn().mockRejectedValue(new Error("RPC timeout")),
71
+ })
72
+ );
73
+
74
+ expect(result).toContain("Continuing without a SOL preflight check");
75
+ expect(result).toContain("RPC timeout");
76
+ });
77
+ });
78
+
@@ -0,0 +1,30 @@
1
+ import { expect, test } from '@jest/globals';
2
+
3
+ import { extractPublicationSummaryLines } from '../publicationSummary';
4
+
5
+ test('summary keeps the completion message and only the compact fields', () => {
6
+ const lines = extractPublicationSummaryLines({
7
+ releaseId: 'release-123',
8
+ publicationSessionId: 'session-123',
9
+ ingestionSessionId: 'ingestion-123',
10
+ releaseMintAddress: 'release-mint-abc',
11
+ collectionMintAddress: 'collection-mint-def',
12
+ releaseTransactionSignature: 'release-tx',
13
+ collectionTransactionSignature: 'collection-tx',
14
+ attestationRequestUniqueId: 'attest-123',
15
+ hubspotTicketId: 'ticket-123',
16
+ });
17
+
18
+ expect(lines).toEqual([
19
+ 'This app is now in review.',
20
+ 'Release mint address: release-mint-abc',
21
+ 'Collection mint address: collection-mint-def',
22
+ 'Ticket ID: ticket-123',
23
+ ]);
24
+ });
25
+
26
+ test('summary still shows the in-review message when no fields are available', () => {
27
+ expect(extractPublicationSummaryLines(undefined)).toEqual([
28
+ 'This app is now in review.',
29
+ ]);
30
+ });
@@ -0,0 +1,264 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ export const DEFAULT_LOCAL_PORTAL_URL = "http://localhost:3333";
5
+ export const DEFAULT_PRODUCTION_PORTAL_URL = "https://publish.solanamobile.com";
6
+ export const DEFAULT_API_KEY_ENV = "DAPP_STORE_API_KEY";
7
+ export const UPDATED_PUBLISHING_CLI_DOCS_URL =
8
+ "https://docs.solanamobile.com/dapp-store/publishing-cli";
9
+
10
+ export type NewVersionCliOptions = {
11
+ apkFile?: string;
12
+ apkUrl?: string;
13
+ whatsNew?: string;
14
+ portalUrl?: string;
15
+ apiKeyEnv?: string;
16
+ apiKeyStdin?: boolean;
17
+ keypair?: string;
18
+ rpcUrl?: string;
19
+ localDev?: boolean;
20
+ skipSelfUpdate?: boolean;
21
+ idempotencyKey?: string;
22
+ dappId?: string;
23
+ verbose?: boolean;
24
+ };
25
+
26
+ export type ResumeCliOptions = {
27
+ releaseId?: string;
28
+ sessionId?: string;
29
+ resumeRelease?: string;
30
+ resumeSession?: string;
31
+ portalUrl?: string;
32
+ apiKeyEnv?: string;
33
+ apiKeyStdin?: boolean;
34
+ keypair?: string;
35
+ rpcUrl?: string;
36
+ localDev?: boolean;
37
+ skipSelfUpdate?: boolean;
38
+ verbose?: boolean;
39
+ };
40
+
41
+ export type ResolvedPortalTargets = {
42
+ apiBaseUrl: string;
43
+ };
44
+
45
+ function normalizeUrl(value: string, label: string): string {
46
+ try {
47
+ return new URL(value).toString().replace(/\/$/, "");
48
+ } catch {
49
+ throw new Error(`Invalid ${label}: ${value}`);
50
+ }
51
+ }
52
+
53
+ function deriveApiBaseUrl(portalUrl: string): string {
54
+ const normalized = new URL(portalUrl);
55
+ const basePath = normalized.pathname.replace(/\/$/, "");
56
+ normalized.pathname = basePath.length === 0 ? "/api" : `${basePath}/api`;
57
+ return normalized.toString().replace(/\/$/, "");
58
+ }
59
+
60
+ function isLocalhostUrl(url: string): boolean {
61
+ try {
62
+ const parsed = new URL(url);
63
+ return (
64
+ parsed.hostname === "localhost" ||
65
+ parsed.hostname === "127.0.0.1" ||
66
+ parsed.hostname === "::1" ||
67
+ parsed.hostname === "[::1]"
68
+ );
69
+ } catch {
70
+ return false;
71
+ }
72
+ }
73
+
74
+ export function resolvePortalTargets(input: {
75
+ portalUrl?: string;
76
+ localDev?: boolean;
77
+ }): ResolvedPortalTargets {
78
+ const portalUrl =
79
+ input.portalUrl ??
80
+ process.env.DAPP_STORE_PORTAL_URL ??
81
+ (input.localDev ? DEFAULT_LOCAL_PORTAL_URL : undefined) ??
82
+ DEFAULT_PRODUCTION_PORTAL_URL;
83
+
84
+ const normalizedPortalUrl = normalizeUrl(portalUrl, "portal URL");
85
+ const normalizedApiBaseUrl = normalizeUrl(
86
+ deriveApiBaseUrl(normalizedPortalUrl),
87
+ "portal API base URL"
88
+ );
89
+
90
+ if (input.localDev) {
91
+ const localTargets = [
92
+ ["portal URL", normalizedPortalUrl],
93
+ ["portal API base URL", normalizedApiBaseUrl],
94
+ ] as const;
95
+
96
+ const nonLocalTarget = localTargets.find(
97
+ ([, value]) => !isLocalhostUrl(value)
98
+ );
99
+ if (nonLocalTarget) {
100
+ throw new Error(
101
+ `--local-dev only allows localhost portal endpoints. Received ${nonLocalTarget[0]}: ${nonLocalTarget[1]}`
102
+ );
103
+ }
104
+ }
105
+
106
+ if (!input.localDev) {
107
+ const portalTargets = [
108
+ ["portal URL", normalizedPortalUrl],
109
+ ["portal API base URL", normalizedApiBaseUrl],
110
+ ] as const;
111
+ const insecureTarget = portalTargets.find(([, value]) => {
112
+ return new URL(value).protocol !== "https:";
113
+ });
114
+
115
+ if (insecureTarget) {
116
+ throw new Error(
117
+ `Portal endpoints must use HTTPS unless --local-dev is set. Received ${insecureTarget[0]}: ${insecureTarget[1]}`
118
+ );
119
+ }
120
+ }
121
+
122
+ return {
123
+ apiBaseUrl: normalizedApiBaseUrl,
124
+ };
125
+ }
126
+
127
+ export function validateNewVersionArgs(input: NewVersionCliOptions): void {
128
+ const apkSourceCount = (input.apkFile ? 1 : 0) + (input.apkUrl ? 1 : 0);
129
+ if (apkSourceCount !== 1) {
130
+ throw new Error("Specify exactly one of `--apk-file` or `--apk-url`.");
131
+ }
132
+
133
+ if (!input.whatsNew || input.whatsNew.trim().length === 0) {
134
+ throw new Error("`--whats-new` is required.");
135
+ }
136
+
137
+ if (!input.keypair || input.keypair.trim().length === 0) {
138
+ throw new Error("`--keypair` is required.");
139
+ }
140
+
141
+ if (input.apkFile) {
142
+ const apkPath = path.resolve(input.apkFile);
143
+ if (!fs.existsSync(apkPath)) {
144
+ throw new Error(`APK file not found: ${apkPath}`);
145
+ }
146
+ }
147
+
148
+ if (input.apkUrl) {
149
+ let parsed: URL;
150
+ try {
151
+ parsed = new URL(input.apkUrl);
152
+ } catch {
153
+ throw new Error("`--apk-url` must be a valid HTTPS URL.");
154
+ }
155
+
156
+ if (parsed.protocol !== "https:") {
157
+ throw new Error("`--apk-url` must use HTTPS.");
158
+ }
159
+ }
160
+ }
161
+
162
+ export function validateResumeArgs(input: ResumeCliOptions): void {
163
+ const releaseId = resolveResumeTarget(
164
+ input.releaseId,
165
+ input.resumeRelease,
166
+ "--release-id",
167
+ "--resume-release"
168
+ );
169
+ const sessionId = resolveResumeTarget(
170
+ input.sessionId,
171
+ input.resumeSession,
172
+ "--session-id",
173
+ "--resume-session"
174
+ );
175
+ const resumeTargetCount = (releaseId ? 1 : 0) + (sessionId ? 1 : 0);
176
+ if (resumeTargetCount !== 1) {
177
+ throw new Error("Specify exactly one of `--release-id` or `--session-id`.");
178
+ }
179
+
180
+ if (!input.keypair || input.keypair.trim().length === 0) {
181
+ throw new Error("`--keypair` is required.");
182
+ }
183
+ }
184
+
185
+ function resolveResumeTarget(
186
+ primary?: string,
187
+ alias?: string,
188
+ primaryLabel?: string,
189
+ aliasLabel?: string
190
+ ): string | undefined {
191
+ const trimmedPrimary = primary?.trim();
192
+ const trimmedAlias = alias?.trim();
193
+
194
+ if (trimmedPrimary && trimmedAlias && trimmedPrimary !== trimmedAlias) {
195
+ throw new Error(
196
+ `Conflicting values were provided for ${primaryLabel} and ${aliasLabel}.`
197
+ );
198
+ }
199
+
200
+ return trimmedPrimary ?? trimmedAlias;
201
+ }
202
+
203
+ export async function resolveApiKey(input: {
204
+ apiKeyEnv?: string;
205
+ apiKeyStdin?: boolean;
206
+ }): Promise<string> {
207
+ const envVarName = input.apiKeyEnv ?? DEFAULT_API_KEY_ENV;
208
+
209
+ if (input.apiKeyStdin) {
210
+ return await readSecretFromStdin();
211
+ }
212
+
213
+ const envValue = process.env[envVarName]?.trim();
214
+ if (envValue) {
215
+ return envValue;
216
+ }
217
+
218
+ throw new Error(
219
+ withUpdatedCliDocs(
220
+ `Portal API key is required. Set ${envVarName} or pass --api-key-stdin.`
221
+ )
222
+ );
223
+ }
224
+
225
+ export function formatUpdatedCliUsageError(message: string): string {
226
+ return withUpdatedCliDocs(normalizeCliErrorMessage(message));
227
+ }
228
+
229
+ async function readSecretFromStdin(): Promise<string> {
230
+ if (process.stdin.isTTY) {
231
+ throw new Error(withUpdatedCliDocs("No API key was piped into stdin."));
232
+ }
233
+
234
+ const chunks: Buffer[] = [];
235
+ for await (const chunk of process.stdin) {
236
+ chunks.push(Buffer.from(chunk));
237
+ }
238
+
239
+ const value = Buffer.concat(chunks).toString("utf8").trim();
240
+ if (!value) {
241
+ throw new Error(withUpdatedCliDocs("No API key was provided on stdin."));
242
+ }
243
+
244
+ return value;
245
+ }
246
+
247
+ function withUpdatedCliDocs(message: string): string {
248
+ return [
249
+ message,
250
+ "",
251
+ "The publishing CLI has changed. See the updated usage guide:",
252
+ UPDATED_PUBLISHING_CLI_DOCS_URL,
253
+ ].join("\n");
254
+ }
255
+
256
+ function normalizeCliErrorMessage(message: string): string {
257
+ const normalized = message.replace(/^error:\s*/i, "").trim();
258
+ if (normalized.length === 0) {
259
+ return "Invalid CLI arguments.";
260
+ }
261
+
262
+ const sentence = normalized.endsWith(".") ? normalized : `${normalized}.`;
263
+ return sentence.charAt(0).toUpperCase() + sentence.slice(1);
264
+ }
@@ -0,0 +1,123 @@
1
+ import {
2
+ Connection,
3
+ LAMPORTS_PER_SOL,
4
+ PublicKey,
5
+ type Commitment,
6
+ } from "@solana/web3.js";
7
+
8
+ const BALANCE_CHECK_COMMITMENT: Commitment = "confirmed";
9
+ const DEFAULT_MAINNET_RPC_URL = "https://api.mainnet-beta.solana.com";
10
+
11
+ // Release NFT minting currently needs roughly ~0.015 SOL in rent plus fees.
12
+ // Keep a small buffer so the CLI fails before uploading the APK.
13
+ export const MIN_PUBLICATION_SIGNER_BALANCE_LAMPORTS = 16_000_000;
14
+
15
+ type FundingPreflightInput = {
16
+ localDev?: boolean;
17
+ publicKey: string;
18
+ rpcUrl?: string;
19
+ };
20
+
21
+ type BalanceClient = {
22
+ getBalance(publicKey: PublicKey): Promise<number>;
23
+ };
24
+
25
+ type BalanceClientFactory = (rpcUrl: string) => BalanceClient;
26
+
27
+ function createBalanceClient(rpcUrl: string): BalanceClient {
28
+ const connection = new Connection(rpcUrl, BALANCE_CHECK_COMMITMENT);
29
+ return {
30
+ async getBalance(publicKey: PublicKey) {
31
+ return await connection.getBalance(publicKey, BALANCE_CHECK_COMMITMENT);
32
+ },
33
+ };
34
+ }
35
+
36
+ function trimOptional(value?: string): string | undefined {
37
+ const trimmed = value?.trim();
38
+ return trimmed ? trimmed : undefined;
39
+ }
40
+
41
+ export function resolveFundingPreflightRpcUrl(input: {
42
+ localDev?: boolean;
43
+ rpcUrl?: string;
44
+ }): string | undefined {
45
+ const explicitRpcUrl = trimOptional(input.rpcUrl);
46
+ if (explicitRpcUrl) {
47
+ return explicitRpcUrl;
48
+ }
49
+
50
+ if (input.localDev) {
51
+ return undefined;
52
+ }
53
+
54
+ return DEFAULT_MAINNET_RPC_URL;
55
+ }
56
+
57
+ function formatSolAmount(lamports: number): string {
58
+ return (lamports / LAMPORTS_PER_SOL).toFixed(6);
59
+ }
60
+
61
+ function buildInsufficientBalanceMessage(input: {
62
+ balanceLamports: number;
63
+ publicKey: string;
64
+ requiredLamports: number;
65
+ }): string {
66
+ return `Signer ${input.publicKey} has ${formatSolAmount(
67
+ input.balanceLamports
68
+ )} SOL, but publishing needs at least ${formatSolAmount(
69
+ input.requiredLamports
70
+ )} SOL available before it starts.`;
71
+ }
72
+
73
+ function buildRpcWarningMessage(rpcUrl: string, error: unknown): string {
74
+ const detail = error instanceof Error ? error.message : String(error);
75
+ return [
76
+ `Unable to confirm the signer balance via ${rpcUrl}.`,
77
+ `Continuing without a SOL preflight check: ${detail}.`,
78
+ ].join(" ");
79
+ }
80
+
81
+ export async function ensurePublicationSignerBalance(
82
+ input: FundingPreflightInput,
83
+ clientFactory: BalanceClientFactory = createBalanceClient
84
+ ): Promise<string | undefined> {
85
+ const rpcUrl = resolveFundingPreflightRpcUrl(input);
86
+ if (!rpcUrl) {
87
+ return undefined;
88
+ }
89
+
90
+ let publicKey: PublicKey;
91
+ try {
92
+ publicKey = new PublicKey(input.publicKey);
93
+ } catch (error) {
94
+ const detail = error instanceof Error ? error.message : String(error);
95
+ throw new Error(
96
+ `Invalid signer public key for balance preflight: ${input.publicKey}. ${detail}`
97
+ );
98
+ }
99
+
100
+ try {
101
+ const balanceLamports = await clientFactory(rpcUrl).getBalance(publicKey);
102
+ if (balanceLamports < MIN_PUBLICATION_SIGNER_BALANCE_LAMPORTS) {
103
+ throw new Error(
104
+ buildInsufficientBalanceMessage({
105
+ balanceLamports,
106
+ publicKey: input.publicKey,
107
+ requiredLamports: MIN_PUBLICATION_SIGNER_BALANCE_LAMPORTS,
108
+ })
109
+ );
110
+ }
111
+
112
+ return undefined;
113
+ } catch (error) {
114
+ if (
115
+ error instanceof Error &&
116
+ error.message.includes("publishing needs at least")
117
+ ) {
118
+ throw error;
119
+ }
120
+
121
+ return buildRpcWarningMessage(rpcUrl, error);
122
+ }
123
+ }