@solana-mobile/dapp-store-cli 0.15.0 → 0.16.1

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 (105) 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 -293
  82. package/lib/upload/TurboStorageDriver.js +0 -718
  83. package/lib/upload/index.js +0 -2
  84. package/src/commands/ValidateCommand.ts +0 -82
  85. package/src/commands/create/CreateCliApp.ts +0 -93
  86. package/src/commands/create/CreateCliRelease.ts +0 -149
  87. package/src/commands/create/index.ts +0 -47
  88. package/src/commands/index.ts +0 -3
  89. package/src/commands/publish/PublishCliRemove.ts +0 -66
  90. package/src/commands/publish/PublishCliSubmit.ts +0 -93
  91. package/src/commands/publish/PublishCliSupport.ts +0 -66
  92. package/src/commands/publish/PublishCliUpdate.ts +0 -101
  93. package/src/commands/publish/index.ts +0 -29
  94. package/src/commands/scaffolding/ScaffoldInit.ts +0 -20
  95. package/src/commands/scaffolding/index.ts +0 -1
  96. package/src/commands/utils.ts +0 -33
  97. package/src/config/EnvVariables.ts +0 -39
  98. package/src/config/PublishDetails.ts +0 -456
  99. package/src/config/S3StorageManager.ts +0 -47
  100. package/src/config/index.ts +0 -2
  101. package/src/prebuild_schema/publishing_source.yaml +0 -64
  102. package/src/prebuild_schema/schemagen.js +0 -31
  103. package/src/upload/CachedStorageDriver.ts +0 -99
  104. package/src/upload/TurboStorageDriver.ts +0 -277
  105. package/src/upload/index.ts +0 -2
package/src/CliSetup.ts CHANGED
@@ -1,544 +1,409 @@
1
- import { Command } from "commander";
2
- import { validateCommand } from "./commands/index.js";
3
- import { createAppCommand, createReleaseCommand } from "./commands/create/index.js";
4
- import {
5
- publishRemoveCommand,
6
- publishSubmitCommand,
7
- publishSupportCommand,
8
- publishUpdateCommand
9
- } from "./commands/publish/index.js";
1
+ import { randomUUID } from 'node:crypto';
2
+ import path from 'node:path';
3
+
4
+ import * as dotenv from 'dotenv';
5
+ import { Command, Option } from 'commander';
6
+
10
7
  import {
11
8
  checkForSelfUpdate,
12
- checkSubmissionNetwork,
13
9
  Constants,
14
- alphaAppSubmissionMessage,
15
- dryRunSuccessMessage,
16
- generateNetworkSuffix,
10
+ createPortalAttestationClient,
11
+ createPortalWorkflowClient,
12
+ createPublicationSignerFromKeypair,
17
13
  parseKeypair,
18
14
  showMessage,
19
- showNetworkWarningIfApplicable
20
- } from "./CliUtils.js";
21
- import * as dotenv from "dotenv";
22
- import { initScaffold } from "./commands/scaffolding/index.js";
23
- import { loadPublishDetails, loadPublishDetailsWithChecks } from "./config/PublishDetails.js";
15
+ } from './CliUtils.js';
16
+ import {
17
+ DEFAULT_API_KEY_ENV,
18
+ DEFAULT_PRODUCTION_PORTAL_URL,
19
+ resolveApiKey,
20
+ resolvePortalTargets,
21
+ validateNewVersionArgs,
22
+ validateResumeArgs,
23
+ type NewVersionCliOptions,
24
+ type ResumeCliOptions,
25
+ type ResolvedPortalTargets,
26
+ } from './publication/cliValidation.js';
27
+ import { createPublicationProgressReporter } from './publication/PublicationProgressReporter.js';
28
+ import { extractPublicationSummaryLines } from './publication/publicationSummary.js';
29
+ import { runPublicationWorkflow } from './publication/runPublicationWorkflow.js';
30
+ import { ensurePublicationSignerBalance } from './publication/fundingPreflight.js';
31
+ import type {
32
+ PublicationResumeInput,
33
+ PublicationWorkflowInput,
34
+ } from './publication/runPublicationWorkflow.js';
35
+ import type { Keypair } from '@solana/web3.js';
24
36
 
25
37
  dotenv.config();
26
38
 
27
- const hasAddressInConfig = ({ address }: { address: string }) => {
28
- return !!address;
29
- };
30
-
31
39
  export const mainCli = new Command();
32
40
 
33
- function resolveBuildToolsPath(buildToolsPath: string | undefined) {
34
- // If a path was specified on the command line, use that
35
- if (buildToolsPath !== undefined) {
36
- return buildToolsPath;
37
- }
38
-
39
- // If a path is specified in a .env file, use that
40
- if (process.env.ANDROID_TOOLS_DIR !== undefined) {
41
- return process.env.ANDROID_TOOLS_DIR;
42
- }
43
-
44
- // No path was specified
45
- return;
46
- }
47
-
48
- /**
49
- * This method should be updated with each new release of the CLI, and just do nothing when there isn't anything to report
50
- */
51
- function latestReleaseMessage() {
52
- const messages = [
53
- `- Banner Graphic image of size 1200x600px is now manadatory for publishing updates.`,
54
- `- Feature Graphic image of size 1200x1200px is required to be featured in Editor's choice carousel. (optional)`,
55
- `- Release metadata now publishes publisher.support_email when provided; otherwise we reuse publisher.email for end-user support.`,
56
- ].join('\n\n')
57
- showMessage(
58
- `Publishing Tools Version ${ Constants.CLI_VERSION }`,
59
- messages,
60
- "warning"
61
- );
62
- }
63
-
64
- async function tryWithErrorMessage(block: () => Promise<any>) {
65
- try {
66
- await block()
67
- } catch (e) {
68
- const errorMsg = (e as Error | null)?.message ?? "";
69
-
70
- showMessage("Error", errorMsg, "error");
71
- process.exit(-1)
72
- }
73
- }
74
-
75
41
  mainCli
76
- .name("dapp-store")
42
+ .name('dapp-store')
77
43
  .version(Constants.CLI_VERSION)
78
- .description("CLI to assist with publishing to the Saga Dapp Store")
44
+ .description('Portal-backed CLI for Solana Mobile dApp version publishing')
45
+ .showHelpAfterError();
79
46
 
80
- export const initCliCmd = mainCli
81
- .command("init")
82
- .description("First-time initialization of tooling configuration")
83
- .action(async () => {
84
- await tryWithErrorMessage(async () => {
85
- const msg = initScaffold();
86
-
87
- showMessage("Initialized", msg);
88
- })
89
- });
90
-
91
- export const createCliCmd = mainCli
92
- .command("create")
93
- .description("Create a `app`, or `release`")
94
-
95
- createCliCmd.addHelpText(
96
- "after",
97
- [
98
- "",
99
- "Release metadata notes:",
100
- " We include publisher.support_email when provided; if omitted we fall back to publisher.email.",
101
- ].join("\n")
102
- );
103
-
104
- export const createAppCliCmd = createCliCmd
105
- .command("app")
106
- .description("Create a app")
107
- .requiredOption(
108
- "-k, --keypair <path-to-keypair-file>",
109
- "Path to keypair file"
110
- )
111
- .option("-u, --url <url>", "RPC URL", Constants.DEFAULT_RPC_DEVNET)
112
- .option("-d, --dry-run", "Flag for dry run. Doesn't mint an NFT")
113
- .option("-s, --storage-config <storage-config>", "Provide alternative storage configuration details")
114
- .option("-p, --priority-fee-lamports <priority-fee-lamports>", "Priority Fee lamports")
115
- .action(async ({keypair, url, dryRun, storageConfig, priorityFeeLamports }) => {
116
- await tryWithErrorMessage(async () => {
117
- showNetworkWarningIfApplicable(url)
118
- latestReleaseMessage();
119
- await checkForSelfUpdate();
120
-
121
- const signer = parseKeypair(keypair);
122
- if (signer) {
123
- const result = await createAppCommand({
124
- signer,
125
- url,
126
- dryRun,
127
- storageParams: storageConfig,
128
- priorityFeeLamports: priorityFeeLamports,
129
- });
130
-
131
- if (dryRun) {
132
- dryRunSuccessMessage()
133
- } else {
134
- const displayUrl = `https://explorer.solana.com/address/${result.appAddress}${generateNetworkSuffix(url)}`;
135
- const transactionUrl = `https://explorer.solana.com/tx/${result.transactionSignature}${generateNetworkSuffix(url)}`;
136
- const resultText = `App NFT successfully minted:\n${displayUrl}\n${transactionUrl}`;
137
- showMessage("Success", resultText);
138
- }
139
- }
140
- });
141
- });
142
-
143
- export const createReleaseCliCmd = createCliCmd
144
- .command("release")
145
- .description("Create a release")
146
- .requiredOption(
147
- "-k, --keypair <path-to-keypair-file>",
148
- "Path to keypair file"
47
+ mainCli
48
+ .option('--apk-file <path>', 'Path to the APK file to publish')
49
+ .option('--apk-url <url>', 'HTTPS URL for an externally hosted APK')
50
+ .option('--whats-new <text>', 'What changed in this version')
51
+ .option('--portal-url <url>', 'Publishing portal base URL')
52
+ .option(
53
+ '--api-key-env <name>',
54
+ 'Environment variable that contains the portal API key',
55
+ DEFAULT_API_KEY_ENV,
149
56
  )
150
57
  .option(
151
- "-a, --app-mint-address <app-mint-address>",
152
- "The mint address of the app NFT"
58
+ '--api-key-stdin',
59
+ 'Read the portal API key from stdin instead of an env var',
153
60
  )
154
- .option("-u, --url <url>", "RPC URL", Constants.DEFAULT_RPC_DEVNET)
155
- .option("-d, --dry-run", "Flag for dry run. Doesn't mint an NFT")
61
+ .option('--keypair <path>', 'Path to the Solana signer keypair')
62
+ .addOption(new Option('--rpc-url <url>', 'Solana RPC URL').hideHelp())
63
+ .option('--local-dev', 'Allow localhost portal endpoints and skip gating')
156
64
  .option(
157
- "-b, --build-tools-path <build-tools-path>",
158
- "Path to Android build tools which contains AAPT2"
65
+ '--skip-self-update',
66
+ 'Bypass the self-update check when working against a local portal',
159
67
  )
160
- .option("-s, --storage-config <storage-config>", "Provide alternative storage configuration details")
161
- .option("-p, --priority-fee-lamports <priority-fee-lamports>", "Priority Fee lamports")
162
- .action(async ({ appMintAddress, keypair, url, dryRun, buildToolsPath, storageConfig, priorityFeeLamports }) => {
163
- await tryWithErrorMessage(async () => {
164
- showNetworkWarningIfApplicable(url)
165
- latestReleaseMessage();
166
- await checkForSelfUpdate();
167
-
168
- const resolvedBuildToolsPath = resolveBuildToolsPath(buildToolsPath);
169
- if (resolvedBuildToolsPath === undefined) {
170
- throw new Error("Please specify an Android build tools directory in the .env file or via the command line argument.")
171
- }
172
-
173
- const config = await loadPublishDetailsWithChecks();
174
- if (!hasAddressInConfig(config.app) && !appMintAddress) {
175
- throw new Error("Either specify an app mint address in the config file or specify as a CLI argument to this command")
176
- }
177
-
178
- const signer = parseKeypair(keypair);
179
- if (signer) {
180
- const result = await createReleaseCommand({
181
- appMintAddress: appMintAddress,
182
- buildToolsPath: resolvedBuildToolsPath,
183
- signer,
184
- url,
185
- dryRun,
186
- storageParams: storageConfig,
187
- priorityFeeLamports: priorityFeeLamports
188
- });
189
-
190
- if (dryRun) {
191
- dryRunSuccessMessage()
192
- } else {
193
- const displayUrl = `https://explorer.solana.com/address/${result?.releaseAddress}${generateNetworkSuffix(url)}`;
194
- const transactionUrl = `https://explorer.solana.com/tx/${result.transactionSignature}${generateNetworkSuffix(url)}`;
195
- const resultText = `Release NFT successfully minted:\n${displayUrl}\n${transactionUrl}`;
196
-
197
- showMessage("Success", resultText);
198
- }
199
- }
200
- });
201
- }
202
- );
203
-
204
- mainCli
205
- .command("validate")
206
- .description("Validates details prior to publishing")
207
- .requiredOption(
208
- "-k, --keypair <path-to-keypair-file>",
209
- "Path to keypair file"
68
+ .option(
69
+ '--idempotency-key <key>',
70
+ 'Optional idempotency key for safe retries',
210
71
  )
211
72
  .option(
212
- "-b, --build-tools-path <build-tools-path>",
213
- "Path to Android build tools which contains AAPT2"
73
+ '--verbose',
74
+ 'Print detailed publication identifiers as they are emitted',
214
75
  )
215
- .action(async ({ keypair, buildToolsPath }) => {
216
- await tryWithErrorMessage(async () => {
217
- latestReleaseMessage();
218
- await checkForSelfUpdate();
219
-
220
- const resolvedBuildToolsPath = resolveBuildToolsPath(buildToolsPath);
221
- if (resolvedBuildToolsPath === undefined) {
222
- throw new Error("Please specify an Android build tools directory in the .env file or via the command line argument.")
223
- }
224
-
225
- const signer = parseKeypair(keypair);
226
- if (signer) {
227
- await validateCommand({
228
- signer,
229
- buildToolsPath: resolvedBuildToolsPath,
230
- });
231
- }
232
- });
76
+ .action(async () => {
77
+ await runRootAction();
233
78
  });
234
79
 
235
- const publishCommand = mainCli
236
- .command("publish")
237
- .description(
238
- "Submit a publishing request (`submit`, `update`, `remove`, or `support`) to the Solana Mobile dApp publisher portal"
239
- );
80
+ const resumeCommand = mainCli.command('resume');
240
81
 
241
- publishCommand
242
- .command("submit")
243
- .description("Submit a new app to the Solana Mobile dApp publisher portal")
244
- .requiredOption(
245
- "-k, --keypair <path-to-keypair-file>",
246
- "Path to keypair file"
247
- )
248
- .requiredOption(
249
- "--complies-with-solana-dapp-store-policies",
250
- "An attestation that the app complies with the Solana dApp Store policies"
251
- )
252
- .requiredOption(
253
- "--requestor-is-authorized",
254
- "An attestation that the party making this Solana dApp publisher portal request is authorized to do so"
82
+ resumeCommand
83
+ .description('Resume a partially completed publication session')
84
+ .option('--release-id <id>', 'Publication release identifier')
85
+ .option('--resume-release <id>', 'Alias for --release-id')
86
+ .option('--session-id <id>', 'Publication session identifier')
87
+ .option('--resume-session <id>', 'Alias for --session-id')
88
+ .option('--portal-url <url>', 'Publishing portal base URL')
89
+ .option(
90
+ '--api-key-env <name>',
91
+ 'Environment variable that contains the portal API key',
92
+ DEFAULT_API_KEY_ENV,
255
93
  )
256
94
  .option(
257
- "-a, --app-mint-address <app-mint-address>",
258
- "The mint address of the app NFT. If not specified, the value from your config file will be used."
95
+ '--api-key-stdin',
96
+ 'Read the portal API key from stdin instead of an env var',
259
97
  )
98
+ .option('--keypair <path>', 'Path to the Solana signer keypair')
99
+ .addOption(new Option('--rpc-url <url>', 'Solana RPC URL').hideHelp())
100
+ .option('--local-dev', 'Allow localhost portal endpoints and skip gating')
260
101
  .option(
261
- "-r, --release-mint-address <release-mint-address>",
262
- "The mint address of the release NFT. If not specified, the value from your config file will be used."
102
+ '--skip-self-update',
103
+ 'Bypass the self-update check when working against a local portal',
263
104
  )
264
- .option("-u, --url <url>", "RPC URL", Constants.DEFAULT_RPC_DEVNET)
265
105
  .option(
266
- "-d, --dry-run",
267
- "Flag for dry run. Doesn't submit the request to the publisher portal."
106
+ '--verbose',
107
+ 'Print detailed publication identifiers as they are emitted',
268
108
  )
269
- .option("-l, --alpha", "Flag to mark the submission as alpha test.")
270
- .action(
271
- async ({
272
- appMintAddress,
273
- releaseMintAddress,
274
- keypair,
275
- url,
276
- compliesWithSolanaDappStorePolicies,
277
- requestorIsAuthorized,
278
- dryRun,
279
- alpha,
280
- }) => {
281
- await tryWithErrorMessage(async () => {
282
- await checkForSelfUpdate();
283
- checkSubmissionNetwork(url);
284
-
285
- const config = await loadPublishDetails(Constants.getConfigFilePath());
286
-
287
- if (!hasAddressInConfig(config.release) && !releaseMintAddress) {
288
- throw new Error("Either specify a release mint address in the config file or specify as a CLI argument to this command.")
289
- }
290
-
291
- if (alpha) {
292
- alphaAppSubmissionMessage()
293
- }
294
-
295
- const signer = parseKeypair(keypair);
296
- if (signer) {
297
- if (config.lastUpdatedVersionOnStore != null && config.lastSubmittedVersionOnChain.address != null) {
298
- await publishUpdateCommand({
299
- appMintAddress: appMintAddress,
300
- releaseMintAddress: releaseMintAddress,
301
- signer: signer,
302
- url: url,
303
- dryRun: dryRun,
304
- compliesWithSolanaDappStorePolicies: compliesWithSolanaDappStorePolicies,
305
- requestorIsAuthorized: requestorIsAuthorized,
306
- critical: false,
307
- alphaTest: alpha,
308
- });
309
- } else {
310
- await publishSubmitCommand({
311
- appMintAddress: appMintAddress,
312
- releaseMintAddress: releaseMintAddress,
313
- signer: signer,
314
- url: url,
315
- dryRun: dryRun,
316
- compliesWithSolanaDappStorePolicies: compliesWithSolanaDappStorePolicies,
317
- requestorIsAuthorized: requestorIsAuthorized,
318
- alphaTest: alpha,
319
- });
320
- }
321
-
322
- if (dryRun) {
323
- dryRunSuccessMessage()
324
- } else {
325
- showMessage("Success", "Successfully submitted to the Solana Mobile dApp publisher portal");
326
- }
327
- }
328
- });
109
+ .action(async (options: ResumeCliOptions) => {
110
+ await runResumeAction(options);
111
+ });
112
+
113
+ mainCli.addHelpText(
114
+ 'after',
115
+ [
116
+ '',
117
+ 'Usage:',
118
+ ' dapp-store --apk-file ./app.apk --whats-new "Bug fixes"',
119
+ ' dapp-store --apk-url https://example.com/app.apk --whats-new "Bug fixes"',
120
+ ' dapp-store resume --release-id <release-id> [--session-id <session-id>]',
121
+ '',
122
+ 'Portal:',
123
+ ' Set DAPP_STORE_PORTAL_URL to the portal origin (for example https://staging.publish.solanamobile.com).',
124
+ ' The CLI derives the /api endpoint from that URL for the active publication workflow.',
125
+ ` If unset, it defaults to ${DEFAULT_PRODUCTION_PORTAL_URL}.`,
126
+ ' The target app must already exist in the portal and already have its App NFT.',
127
+ ' The portal decides whether the submission is the first release for an existing app or a later update.',
128
+ '',
129
+ 'Secrets:',
130
+ ` Portal API key defaults to ${DEFAULT_API_KEY_ENV} or the name passed via --api-key-env.`,
131
+ ' Use --api-key-stdin to read the portal API key from stdin.',
132
+ '',
133
+ 'Local development:',
134
+ ' Pass --local-dev to allow localhost portal endpoints and to skip self-update gating.',
135
+ ' Local-dev mode rejects non-local portal URLs.',
136
+ ].join('\n'),
137
+ );
138
+
139
+ async function runRootAction() {
140
+ await runWithUserFacingErrors(async () => {
141
+ const options = mainCli.opts() as NewVersionCliOptions;
142
+
143
+ if (!hasPublicationInputs(options)) {
144
+ mainCli.outputHelp();
145
+ return;
329
146
  }
330
- );
331
147
 
332
- publishCommand
333
- .command("update")
334
- .description(
335
- "Update an existing app on the Solana Mobile dApp publisher portal"
336
- )
337
- .requiredOption(
338
- "-k, --keypair <path-to-keypair-file>",
339
- "Path to keypair file"
340
- )
341
- .requiredOption(
342
- "--complies-with-solana-dapp-store-policies",
343
- "An attestation that the app complies with the Solana dApp Store policies"
344
- )
345
- .requiredOption(
346
- "--requestor-is-authorized",
347
- "An attestation that the party making this Solana dApp publisher portal request is authorized to do so"
348
- )
349
- .option(
350
- "-a, --app-mint-address <app-mint-address>",
351
- "The mint address of the app NFT. If not specified, the value from your config file will be used."
352
- )
353
- .option(
354
- "-r, --release-mint-address <release-mint-address>",
355
- "The mint address of the release NFT. If not specified, the value from your config file will be used."
356
- )
357
- .option("-c, --critical", "Flag for a critical app update request")
358
- .option("-u, --url <url>", "RPC URL", Constants.DEFAULT_RPC_DEVNET)
359
- .option(
360
- "-d, --dry-run",
361
- "Flag for dry run. Doesn't submit the request to the publisher portal."
362
- )
363
- .option("-l, --alpha", "Flag to mark the submission as alpha test.")
364
- .action(
365
- async ({
366
- appMintAddress,
367
- releaseMintAddress,
368
- keypair,
369
- url,
370
- compliesWithSolanaDappStorePolicies,
371
- requestorIsAuthorized,
372
- critical,
373
- dryRun,
374
- alpha,
375
- }) => {
376
- await tryWithErrorMessage(async () => {
377
- await checkForSelfUpdate();
378
- checkSubmissionNetwork(url);
379
-
380
- const config = await loadPublishDetails(Constants.getConfigFilePath())
381
-
382
- if (!hasAddressInConfig(config.release) && !releaseMintAddress) {
383
- throw new Error("Either specify a release mint address in the config file or specify as a CLI argument to this command.")
384
- }
385
-
386
- if (alpha) {
387
- alphaAppSubmissionMessage()
388
- }
389
-
390
- const signer = parseKeypair(keypair);
391
- if (signer) {
392
- await publishUpdateCommand({
393
- appMintAddress,
394
- releaseMintAddress,
395
- signer,
396
- url,
397
- dryRun,
398
- compliesWithSolanaDappStorePolicies,
399
- requestorIsAuthorized,
400
- critical,
401
- alphaTest: alpha,
402
- });
403
-
404
- if (dryRun) {
405
- dryRunSuccessMessage()
406
- } else {
407
- showMessage("Success", "dApp successfully updated on the publisher portal");
408
- }
409
- }
410
- });
148
+ validateNewVersionArgs(options);
149
+ enforceSelfUpdatePolicy(options);
150
+
151
+ const targets = resolvePortalTargets(options);
152
+ const apiKey = await resolveApiKey(options);
153
+ const signerKeypair = loadSignerKeypair(options.keypair);
154
+ const balanceWarning = await ensurePublicationSignerBalance({
155
+ publicKey: signerKeypair.publicKey.toBase58(),
156
+ rpcUrl: options.rpcUrl,
157
+ localDev: options.localDev,
158
+ });
159
+ if (balanceWarning) {
160
+ showMessage('Warning', balanceWarning, 'warning');
411
161
  }
412
- );
162
+ const signer = createPublicationSignerFromKeypair(signerKeypair);
163
+ const clients = createPortalClients(targets, apiKey);
164
+ const progress = createPublicationProgressReporter({
165
+ title: 'Publishing version',
166
+ mode: 'new-version',
167
+ verbose: options.verbose,
168
+ });
413
169
 
414
- publishCommand
415
- .command("remove")
416
- .description(
417
- "Remove an existing app from the Solana Mobile dApp publisher portal"
418
- )
419
- .requiredOption(
420
- "-k, --keypair <path-to-keypair-file>",
421
- "Path to keypair file"
422
- )
423
- .requiredOption(
424
- "--requestor-is-authorized",
425
- "An attestation that the party making this Solana dApp publisher portal request is authorized to do so"
426
- )
427
- .option(
428
- "-a, --app-mint-address <app-mint-address>",
429
- "The mint address of the app NFT. If not specified, the value from your config file will be used."
430
- )
431
- .option(
432
- "-r, --release-mint-address <release-mint-address>",
433
- "The mint address of the release NFT. If not specified, the value from your config file will be used."
434
- )
435
- .option("-c, --critical", "Flag for a critical app removal request")
436
- .option("-u, --url <url>", "RPC URL", Constants.DEFAULT_RPC_DEVNET)
437
- .option(
438
- "-d, --dry-run",
439
- "Flag for dry run. Doesn't submit the request to the publisher portal."
440
- )
441
- .action(
442
- async ({
443
- appMintAddress,
444
- releaseMintAddress,
445
- keypair,
446
- url,
447
- requestorIsAuthorized,
448
- critical,
449
- dryRun,
450
- }) => {
451
- await tryWithErrorMessage(async () => {
452
- await checkForSelfUpdate();
453
- checkSubmissionNetwork(url);
454
-
455
- const config = await loadPublishDetails(Constants.getConfigFilePath())
456
-
457
- if (!hasAddressInConfig(config.release) && !releaseMintAddress) {
458
- throw new Error("Either specify a release mint address in the config file or specify as a CLI argument to this command.")
459
- }
460
-
461
- const signer = parseKeypair(keypair);
462
- if (signer) {
463
- await publishRemoveCommand({
464
- appMintAddress,
465
- releaseMintAddress,
466
- signer,
467
- url,
468
- dryRun,
469
- requestorIsAuthorized,
470
- critical,
471
- });
472
-
473
- if (dryRun) {
474
- dryRunSuccessMessage()
475
- } else {
476
- showMessage("Success", "dApp successfully removed from the publisher portal");
477
- }
478
- }
479
- })
170
+ progress.start({
171
+ message: 'Connecting to publishing portal',
172
+ metadata: buildNewVersionProgressMetadata(options),
173
+ });
174
+
175
+ try {
176
+ const result = await runPublicationWorkflow({
177
+ mode: 'new-version',
178
+ client: clients.workflowClient,
179
+ input: buildNewVersionWorkflowInput(
180
+ options,
181
+ signer,
182
+ clients.attestationClient,
183
+ ),
184
+ options: {
185
+ logger: progress.logger,
186
+ },
187
+ });
188
+
189
+ progress.complete(result);
190
+ showPublicationSummary('Version publication completed', result);
191
+ } catch (error) {
192
+ progress.fail(error);
193
+ throw error;
480
194
  }
481
- );
195
+ });
196
+ }
482
197
 
483
- publishCommand
484
- .command("support <request_details>")
485
- .description(
486
- "Submit a support request for an existing app on the Solana Mobile dApp publisher portal"
487
- )
488
- .requiredOption(
489
- "-k, --keypair <path-to-keypair-file>",
490
- "Path to keypair file"
491
- )
492
- .requiredOption(
493
- "--requestor-is-authorized",
494
- "An attestation that the party making this Solana dApp publisher portal request is authorized to do so"
495
- )
496
- .option(
497
- "-a, --app-mint-address <app-mint-address>",
498
- "The mint address of the app NFT. If not specified, the value from your config file will be used."
499
- )
500
- .option(
501
- "-r, --release-mint-address <release-mint-address>",
502
- "The mint address of the release NFT. If not specified, the value from your config file will be used."
503
- )
504
- .option("-u, --url <url>", "RPC URL", Constants.DEFAULT_RPC_DEVNET)
505
- .option(
506
- "-d, --dry-run",
507
- "Flag for dry run. Doesn't submit the request to the publisher portal."
508
- )
509
- .action(
510
- async (
511
- requestDetails,
512
- { appMintAddress, releaseMintAddress, keypair, url, requestorIsAuthorized, dryRun }
513
- ) => {
514
- await tryWithErrorMessage(async () => {
515
- await checkForSelfUpdate();
516
- checkSubmissionNetwork(url);
517
-
518
- const config = await loadPublishDetails(Constants.getConfigFilePath())
519
-
520
- if (!hasAddressInConfig(config.release) && !releaseMintAddress) {
521
- throw new Error("Either specify a release mint address in the config file or specify as a CLI argument to this command.")
522
- }
523
-
524
- const signer = parseKeypair(keypair);
525
- if (signer) {
526
- await publishSupportCommand({
527
- appMintAddress,
528
- releaseMintAddress,
529
- signer,
530
- url,
531
- dryRun,
532
- requestorIsAuthorized,
533
- requestDetails,
534
- });
535
-
536
- if (dryRun) {
537
- dryRunSuccessMessage()
538
- } else {
539
- showMessage("Success", "Support request sent successfully");
540
- }
541
- }
198
+ async function runResumeAction(options: ResumeCliOptions) {
199
+ await runWithUserFacingErrors(async () => {
200
+ validateResumeArgs(options);
201
+ enforceSelfUpdatePolicy(options);
202
+
203
+ const targets = resolvePortalTargets(options);
204
+ const apiKey = await resolveApiKey(options);
205
+ const signer = createPublicationSignerFromKeypair(
206
+ loadSignerKeypair(options.keypair)
207
+ );
208
+ const clients = createPortalClients(targets, apiKey);
209
+ const progress = createPublicationProgressReporter({
210
+ title: 'Resuming publication',
211
+ mode: 'resume',
212
+ verbose: options.verbose,
213
+ });
214
+
215
+ progress.start({
216
+ message: 'Connecting to publishing portal',
217
+ metadata: buildResumeProgressMetadata(options),
218
+ });
219
+
220
+ try {
221
+ const result = await runPublicationWorkflow({
222
+ mode: 'resume',
223
+ client: clients.workflowClient,
224
+ input: buildResumeWorkflowInput(
225
+ options,
226
+ signer,
227
+ clients.attestationClient,
228
+ ),
229
+ options: {
230
+ logger: progress.logger,
231
+ },
542
232
  });
233
+
234
+ progress.complete(result);
235
+ showPublicationSummary('Publication resume completed', result);
236
+ } catch (error) {
237
+ progress.fail(error);
238
+ throw error;
543
239
  }
544
- );
240
+ });
241
+ }
242
+
243
+ function createPortalClients(
244
+ targets: ResolvedPortalTargets,
245
+ apiKey: string,
246
+ ) {
247
+ return {
248
+ workflowClient: createPortalWorkflowClient({
249
+ apiBaseUrl: targets.apiBaseUrl,
250
+ apiKey,
251
+ }),
252
+ attestationClient: createPortalAttestationClient({
253
+ apiBaseUrl: targets.apiBaseUrl,
254
+ apiKey,
255
+ }),
256
+ };
257
+ }
258
+
259
+ function buildNewVersionWorkflowInput(
260
+ options: NewVersionCliOptions,
261
+ signer: ReturnType<typeof createPublicationSignerFromKeypair>,
262
+ attestationClient: PublicationWorkflowInput['attestationClient'],
263
+ ): PublicationWorkflowInput {
264
+ return {
265
+ source: buildPublicationSource(options),
266
+ whatsNew: options.whatsNew ?? '',
267
+ idempotencyKey: options.idempotencyKey ?? randomUUID(),
268
+ signer,
269
+ attestationClient,
270
+ };
271
+ }
272
+
273
+ function buildResumeWorkflowInput(
274
+ options: ResumeCliOptions,
275
+ signer: ReturnType<typeof createPublicationSignerFromKeypair>,
276
+ attestationClient: PublicationResumeInput['attestationClient'],
277
+ ): PublicationResumeInput {
278
+ return {
279
+ publicationSessionId: resolveResumeSessionId(options),
280
+ releaseId: resolveResumeReleaseId(options),
281
+ signer,
282
+ attestationClient,
283
+ };
284
+ }
285
+
286
+ function buildPublicationSource(options: NewVersionCliOptions) {
287
+ if (options.apkFile) {
288
+ return {
289
+ kind: 'apk-file' as const,
290
+ filePath: options.apkFile,
291
+ fileName: path.basename(options.apkFile),
292
+ };
293
+ }
294
+
295
+ if (!options.apkUrl) {
296
+ throw new Error('`--apk-file` or `--apk-url` is required.');
297
+ }
298
+
299
+ return {
300
+ kind: 'apk-url' as const,
301
+ url: options.apkUrl,
302
+ fileName: inferFileNameFromUrl(options.apkUrl),
303
+ };
304
+ }
305
+
306
+ function buildNewVersionProgressMetadata(
307
+ options: NewVersionCliOptions,
308
+ ): Record<string, string> {
309
+ if (options.apkFile) {
310
+ return {
311
+ sourceKind: 'apk-file',
312
+ fileName: path.basename(options.apkFile),
313
+ };
314
+ }
315
+
316
+ if (options.apkUrl) {
317
+ const fileName = inferFileNameFromUrl(options.apkUrl);
318
+ return {
319
+ sourceKind: 'apk-url',
320
+ apkUrl: options.apkUrl,
321
+ ...(fileName ? { fileName } : {}),
322
+ };
323
+ }
324
+
325
+ return {};
326
+ }
327
+
328
+ function buildResumeProgressMetadata(
329
+ options: ResumeCliOptions,
330
+ ): Record<string, string> {
331
+ const publicationSessionId = resolveResumeSessionId(options);
332
+ const releaseId = resolveResumeReleaseId(options);
333
+
334
+ return {
335
+ ...(publicationSessionId ? { publicationSessionId } : {}),
336
+ ...(releaseId ? { releaseId } : {}),
337
+ };
338
+ }
339
+
340
+ function inferFileNameFromUrl(url: string): string | undefined {
341
+ try {
342
+ const pathname = new URL(url).pathname;
343
+ const fileName = pathname.split('/').filter(Boolean).pop();
344
+ return fileName || undefined;
345
+ } catch {
346
+ return undefined;
347
+ }
348
+ }
349
+
350
+ function enforceSelfUpdatePolicy(
351
+ options: NewVersionCliOptions | ResumeCliOptions,
352
+ ) {
353
+ if (options.skipSelfUpdate && !options.localDev) {
354
+ throw new Error(
355
+ '`--skip-self-update` is only allowed together with `--local-dev`.',
356
+ );
357
+ }
358
+ }
359
+
360
+ function resolveResumeReleaseId(options: ResumeCliOptions): string | undefined {
361
+ return options.releaseId ?? options.resumeRelease;
362
+ }
363
+
364
+ function resolveResumeSessionId(options: ResumeCliOptions): string | undefined {
365
+ return options.sessionId ?? options.resumeSession;
366
+ }
367
+
368
+ function loadSignerKeypair(keypairPath?: string): Keypair {
369
+ if (!keypairPath) {
370
+ throw new Error('`--keypair` is required.');
371
+ }
372
+
373
+ const keypair = parseKeypair(keypairPath);
374
+ if (!keypair) {
375
+ throw new Error('Failed to load the signer keypair.');
376
+ }
377
+
378
+ return keypair;
379
+ }
380
+
381
+ function hasPublicationInputs(options: NewVersionCliOptions): boolean {
382
+ return Boolean(
383
+ options.apkFile ||
384
+ options.apkUrl ||
385
+ options.whatsNew ||
386
+ options.portalUrl ||
387
+ options.keypair ||
388
+ options.rpcUrl ||
389
+ options.idempotencyKey ||
390
+ options.dappId ||
391
+ options.verbose,
392
+ );
393
+ }
394
+
395
+ function showPublicationSummary(title: string, result: unknown) {
396
+ const summaryLines = extractPublicationSummaryLines(result);
397
+ showMessage(title, summaryLines.join('\n'), 'standard');
398
+ }
399
+
400
+ async function runWithUserFacingErrors(block: () => Promise<void>) {
401
+ try {
402
+ await block();
403
+ } catch (error) {
404
+ const message =
405
+ error instanceof Error ? error.message : 'An unexpected error occurred';
406
+ showMessage('Error', message, 'error');
407
+ process.exitCode = 1;
408
+ }
409
+ }