@scalebun-release/cli 2.5.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 (210) hide show
  1. package/bin/scalebun.js +2 -0
  2. package/lib/api/client.d.ts +52 -0
  3. package/lib/api/client.d.ts.map +1 -0
  4. package/lib/api/client.js +335 -0
  5. package/lib/api/client.js.map +1 -0
  6. package/lib/commands/ci-init.d.ts +3 -0
  7. package/lib/commands/ci-init.d.ts.map +1 -0
  8. package/lib/commands/ci-init.js +348 -0
  9. package/lib/commands/ci-init.js.map +1 -0
  10. package/lib/commands/doctor.d.ts +31 -0
  11. package/lib/commands/doctor.d.ts.map +1 -0
  12. package/lib/commands/doctor.js +559 -0
  13. package/lib/commands/doctor.js.map +1 -0
  14. package/lib/commands/launch.d.ts +3 -0
  15. package/lib/commands/launch.d.ts.map +1 -0
  16. package/lib/commands/launch.js +23 -0
  17. package/lib/commands/launch.js.map +1 -0
  18. package/lib/commands/link.d.ts +16 -0
  19. package/lib/commands/link.d.ts.map +1 -0
  20. package/lib/commands/link.js +164 -0
  21. package/lib/commands/link.js.map +1 -0
  22. package/lib/commands/login.d.ts +12 -0
  23. package/lib/commands/login.d.ts.map +1 -0
  24. package/lib/commands/login.js +124 -0
  25. package/lib/commands/login.js.map +1 -0
  26. package/lib/commands/migrate-codepush.d.ts +13 -0
  27. package/lib/commands/migrate-codepush.d.ts.map +1 -0
  28. package/lib/commands/migrate-codepush.js +98 -0
  29. package/lib/commands/migrate-codepush.js.map +1 -0
  30. package/lib/commands/ota-bundle.d.ts +22 -0
  31. package/lib/commands/ota-bundle.d.ts.map +1 -0
  32. package/lib/commands/ota-bundle.js +253 -0
  33. package/lib/commands/ota-bundle.js.map +1 -0
  34. package/lib/commands/ota-channels.d.ts +3 -0
  35. package/lib/commands/ota-channels.d.ts.map +1 -0
  36. package/lib/commands/ota-channels.js +231 -0
  37. package/lib/commands/ota-channels.js.map +1 -0
  38. package/lib/commands/ota-delete-bundle.d.ts +16 -0
  39. package/lib/commands/ota-delete-bundle.d.ts.map +1 -0
  40. package/lib/commands/ota-delete-bundle.js +96 -0
  41. package/lib/commands/ota-delete-bundle.js.map +1 -0
  42. package/lib/commands/ota-inspect.d.ts +3 -0
  43. package/lib/commands/ota-inspect.d.ts.map +1 -0
  44. package/lib/commands/ota-inspect.js +258 -0
  45. package/lib/commands/ota-inspect.js.map +1 -0
  46. package/lib/commands/ota-keys.d.ts +91 -0
  47. package/lib/commands/ota-keys.d.ts.map +1 -0
  48. package/lib/commands/ota-keys.js +765 -0
  49. package/lib/commands/ota-keys.js.map +1 -0
  50. package/lib/commands/ota-promote.d.ts +3 -0
  51. package/lib/commands/ota-promote.d.ts.map +1 -0
  52. package/lib/commands/ota-promote.js +194 -0
  53. package/lib/commands/ota-promote.js.map +1 -0
  54. package/lib/commands/ota-publish.d.ts +26 -0
  55. package/lib/commands/ota-publish.d.ts.map +1 -0
  56. package/lib/commands/ota-publish.js +892 -0
  57. package/lib/commands/ota-publish.js.map +1 -0
  58. package/lib/commands/ota-rollout.d.ts +3 -0
  59. package/lib/commands/ota-rollout.d.ts.map +1 -0
  60. package/lib/commands/ota-rollout.js +265 -0
  61. package/lib/commands/ota-rollout.js.map +1 -0
  62. package/lib/commands/ota-sign.d.ts +19 -0
  63. package/lib/commands/ota-sign.d.ts.map +1 -0
  64. package/lib/commands/ota-sign.js +245 -0
  65. package/lib/commands/ota-sign.js.map +1 -0
  66. package/lib/commands/push-apns.d.ts +3 -0
  67. package/lib/commands/push-apns.d.ts.map +1 -0
  68. package/lib/commands/push-apns.js +166 -0
  69. package/lib/commands/push-apns.js.map +1 -0
  70. package/lib/commands/quickstart.d.ts +4 -0
  71. package/lib/commands/quickstart.d.ts.map +1 -0
  72. package/lib/commands/quickstart.js +672 -0
  73. package/lib/commands/quickstart.js.map +1 -0
  74. package/lib/commands/shell.d.ts +28 -0
  75. package/lib/commands/shell.d.ts.map +1 -0
  76. package/lib/commands/shell.js +512 -0
  77. package/lib/commands/shell.js.map +1 -0
  78. package/lib/commands/tls-pins.d.ts +27 -0
  79. package/lib/commands/tls-pins.d.ts.map +1 -0
  80. package/lib/commands/tls-pins.js +209 -0
  81. package/lib/commands/tls-pins.js.map +1 -0
  82. package/lib/index.d.ts +2 -0
  83. package/lib/index.d.ts.map +1 -0
  84. package/lib/index.js +233 -0
  85. package/lib/index.js.map +1 -0
  86. package/lib/ui/banner.d.ts +23 -0
  87. package/lib/ui/banner.d.ts.map +1 -0
  88. package/lib/ui/banner.js +162 -0
  89. package/lib/ui/banner.js.map +1 -0
  90. package/lib/ui/graphics.d.ts +18 -0
  91. package/lib/ui/graphics.d.ts.map +1 -0
  92. package/lib/ui/graphics.js +134 -0
  93. package/lib/ui/graphics.js.map +1 -0
  94. package/lib/ui/mark.d.ts +4 -0
  95. package/lib/ui/mark.d.ts.map +1 -0
  96. package/lib/ui/mark.js +21 -0
  97. package/lib/ui/mark.js.map +1 -0
  98. package/lib/ui/menu.d.ts +109 -0
  99. package/lib/ui/menu.d.ts.map +1 -0
  100. package/lib/ui/menu.js +314 -0
  101. package/lib/ui/menu.js.map +1 -0
  102. package/lib/ui/sixel.d.ts +6 -0
  103. package/lib/ui/sixel.d.ts.map +1 -0
  104. package/lib/ui/sixel.js +9 -0
  105. package/lib/ui/sixel.js.map +1 -0
  106. package/lib/utils/apps.d.ts +29 -0
  107. package/lib/utils/apps.d.ts.map +1 -0
  108. package/lib/utils/apps.js +19 -0
  109. package/lib/utils/apps.js.map +1 -0
  110. package/lib/utils/browser.d.ts +9 -0
  111. package/lib/utils/browser.d.ts.map +1 -0
  112. package/lib/utils/browser.js +39 -0
  113. package/lib/utils/browser.js.map +1 -0
  114. package/lib/utils/bundle.d.ts +153 -0
  115. package/lib/utils/bundle.d.ts.map +1 -0
  116. package/lib/utils/bundle.js +459 -0
  117. package/lib/utils/bundle.js.map +1 -0
  118. package/lib/utils/bundleStore.d.ts +128 -0
  119. package/lib/utils/bundleStore.d.ts.map +1 -0
  120. package/lib/utils/bundleStore.js +250 -0
  121. package/lib/utils/bundleStore.js.map +1 -0
  122. package/lib/utils/context.d.ts +88 -0
  123. package/lib/utils/context.d.ts.map +1 -0
  124. package/lib/utils/context.js +247 -0
  125. package/lib/utils/context.js.map +1 -0
  126. package/lib/utils/credentials.d.ts +59 -0
  127. package/lib/utils/credentials.d.ts.map +1 -0
  128. package/lib/utils/credentials.js +147 -0
  129. package/lib/utils/credentials.js.map +1 -0
  130. package/lib/utils/guide.d.ts +63 -0
  131. package/lib/utils/guide.d.ts.map +1 -0
  132. package/lib/utils/guide.js +191 -0
  133. package/lib/utils/guide.js.map +1 -0
  134. package/lib/utils/hash.d.ts +3 -0
  135. package/lib/utils/hash.d.ts.map +1 -0
  136. package/lib/utils/hash.js +44 -0
  137. package/lib/utils/hash.js.map +1 -0
  138. package/lib/utils/native-id.d.ts +17 -0
  139. package/lib/utils/native-id.d.ts.map +1 -0
  140. package/lib/utils/native-id.js +105 -0
  141. package/lib/utils/native-id.js.map +1 -0
  142. package/lib/utils/output.d.ts +96 -0
  143. package/lib/utils/output.d.ts.map +1 -0
  144. package/lib/utils/output.js +135 -0
  145. package/lib/utils/output.js.map +1 -0
  146. package/lib/utils/prompt.d.ts +106 -0
  147. package/lib/utils/prompt.d.ts.map +1 -0
  148. package/lib/utils/prompt.js +457 -0
  149. package/lib/utils/prompt.js.map +1 -0
  150. package/lib/utils/provenance.d.ts +30 -0
  151. package/lib/utils/provenance.d.ts.map +1 -0
  152. package/lib/utils/provenance.js +157 -0
  153. package/lib/utils/provenance.js.map +1 -0
  154. package/lib/utils/resolve.d.ts +81 -0
  155. package/lib/utils/resolve.d.ts.map +1 -0
  156. package/lib/utils/resolve.js +139 -0
  157. package/lib/utils/resolve.js.map +1 -0
  158. package/lib/utils/sdkBin.d.ts +18 -0
  159. package/lib/utils/sdkBin.d.ts.map +1 -0
  160. package/lib/utils/sdkBin.js +76 -0
  161. package/lib/utils/sdkBin.js.map +1 -0
  162. package/lib/utils/signer.d.ts +94 -0
  163. package/lib/utils/signer.d.ts.map +1 -0
  164. package/lib/utils/signer.js +230 -0
  165. package/lib/utils/signer.js.map +1 -0
  166. package/lib/utils/signing.d.ts +102 -0
  167. package/lib/utils/signing.d.ts.map +1 -0
  168. package/lib/utils/signing.js +229 -0
  169. package/lib/utils/signing.js.map +1 -0
  170. package/lib/utils/transportSecurity.d.ts +36 -0
  171. package/lib/utils/transportSecurity.d.ts.map +1 -0
  172. package/lib/utils/transportSecurity.js +74 -0
  173. package/lib/utils/transportSecurity.js.map +1 -0
  174. package/lib/utils/trustAnchor.d.ts +109 -0
  175. package/lib/utils/trustAnchor.d.ts.map +1 -0
  176. package/lib/utils/trustAnchor.js +169 -0
  177. package/lib/utils/trustAnchor.js.map +1 -0
  178. package/lib/utils/ui.d.ts +12 -0
  179. package/lib/utils/ui.d.ts.map +1 -0
  180. package/lib/utils/ui.js +60 -0
  181. package/lib/utils/ui.js.map +1 -0
  182. package/lib/utils/validate.d.ts +62 -0
  183. package/lib/utils/validate.d.ts.map +1 -0
  184. package/lib/utils/validate.js +189 -0
  185. package/lib/utils/validate.js.map +1 -0
  186. package/lib/vendor/ota-protocol/base64.d.ts +11 -0
  187. package/lib/vendor/ota-protocol/base64.d.ts.map +1 -0
  188. package/lib/vendor/ota-protocol/base64.js +39 -0
  189. package/lib/vendor/ota-protocol/base64.js.map +1 -0
  190. package/lib/vendor/ota-protocol/codec.d.ts +26 -0
  191. package/lib/vendor/ota-protocol/codec.d.ts.map +1 -0
  192. package/lib/vendor/ota-protocol/codec.js +349 -0
  193. package/lib/vendor/ota-protocol/codec.js.map +1 -0
  194. package/lib/vendor/ota-protocol/hex.d.ts +8 -0
  195. package/lib/vendor/ota-protocol/hex.d.ts.map +1 -0
  196. package/lib/vendor/ota-protocol/hex.js +31 -0
  197. package/lib/vendor/ota-protocol/hex.js.map +1 -0
  198. package/lib/vendor/ota-protocol/index.d.ts +17 -0
  199. package/lib/vendor/ota-protocol/index.d.ts.map +1 -0
  200. package/lib/vendor/ota-protocol/index.js +41 -0
  201. package/lib/vendor/ota-protocol/index.js.map +1 -0
  202. package/lib/vendor/ota-protocol/limits.d.ts +39 -0
  203. package/lib/vendor/ota-protocol/limits.d.ts.map +1 -0
  204. package/lib/vendor/ota-protocol/limits.js +42 -0
  205. package/lib/vendor/ota-protocol/limits.js.map +1 -0
  206. package/lib/vendor/ota-protocol/types.d.ts +73 -0
  207. package/lib/vendor/ota-protocol/types.d.ts.map +1 -0
  208. package/lib/vendor/ota-protocol/types.js +24 -0
  209. package/lib/vendor/ota-protocol/types.js.map +1 -0
  210. package/package.json +37 -0
@@ -0,0 +1,892 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ var __importDefault = (this && this.__importDefault) || function (mod) {
36
+ return (mod && mod.__esModule) ? mod : { "default": mod };
37
+ };
38
+ Object.defineProperty(exports, "__esModule", { value: true });
39
+ exports.parseRolloutPercent = parseRolloutPercent;
40
+ exports.registerOtaPublishCommand = registerOtaPublishCommand;
41
+ const fs = __importStar(require("fs"));
42
+ const path = __importStar(require("path"));
43
+ const crypto = __importStar(require("crypto"));
44
+ const chalk_1 = __importDefault(require("chalk"));
45
+ const credentials_1 = require("../utils/credentials");
46
+ const client_1 = require("../api/client");
47
+ const validate_1 = require("../utils/validate");
48
+ const resolve_1 = require("../utils/resolve");
49
+ const context_1 = require("../utils/context");
50
+ const output_1 = require("../utils/output");
51
+ const bundle_1 = require("../utils/bundle");
52
+ const hash_1 = require("../utils/hash");
53
+ const native_id_1 = require("../utils/native-id");
54
+ const provenance_1 = require("../utils/provenance");
55
+ const ota_keys_1 = require("./ota-keys");
56
+ const signing_1 = require("../utils/signing");
57
+ const signer_1 = require("../utils/signer");
58
+ const apps_1 = require("../utils/apps");
59
+ const prompt_1 = require("../utils/prompt");
60
+ const browser_1 = require("../utils/browser");
61
+ const login_1 = require("./login");
62
+ const DASHBOARD_OTA_URL = 'https://dash.scalebun.com/ota/publish';
63
+ /**
64
+ * `scalebun ota publish` — the flagship CLI command.
65
+ *
66
+ * Flow:
67
+ * 1. Build the RN JS bundle (npx react-native bundle, Hermes default)
68
+ * 2. Compute SHA-256 of the output
69
+ * 3. POST /saas/apps/:appId/ota/bundles/presign → presigned PUT URL
70
+ * 4. PUT the bytes directly to storage (R2/S3)
71
+ * 5. POST /saas/apps/:appId/ota/bundles/:id/finalize → version assigned
72
+ * 6. POST /saas/apps/:appId/ota/bundles/:id/promote → configure installMode & rollout
73
+ * 7. Print success
74
+ */
75
+ /**
76
+ * Parse `--rollout`, refusing anything that isn't 0-100.
77
+ *
78
+ * This was `parseInt(x, 10) || 100`, which turned BOTH `--rollout 0` and any
79
+ * typo into a 100% rollout — the opposite of what the operator asked for. A
80
+ * dark release (0%) is a documented, legitimate way to promote a bundle without
81
+ * exposing it, so silently inverting it exposes every production device to an
82
+ * untested bundle. Exits rather than guessing.
83
+ */
84
+ function parseRolloutPercent(raw) {
85
+ // Delegates so this flag, the promote flag, and the interactive prompt all
86
+ // accept the same things — including a trailing `%`, which Number() reads as
87
+ // NaN and which is the obvious thing to type for a percentage.
88
+ return (0, validate_1.checkedPercent)('--rollout', raw, 100);
89
+ }
90
+ function registerOtaPublishCommand(ota) {
91
+ ota
92
+ .command('publish')
93
+ .description('Bundle, hash, upload, and finalize an OTA update release')
94
+ // Not requiredOption: when it (and other flags) are missing in a TTY, the
95
+ // interactive wizard fills them in. Non-interactive runs still require it.
96
+ .option('--app-id <id>', 'Scalebun application ID')
97
+ .option('--channel <nameOrId>', 'OTA channel name (e.g. default, beta, production) or channel ID', 'default')
98
+ .option('--channel-id <id>', 'OTA channel ID (legacy alias)')
99
+ .option('--platform <platform>', 'Target platform: android | ios', 'android')
100
+ .option('--install-mode <mode>', 'IMMEDIATE (SDK restarts the app right after install; also marked mandatory) | ' +
101
+ 'ON_NEXT_RESTART (default: activates on next cold boot) | ' +
102
+ 'ON_NEXT_RESUME (SDK restarts when the app next returns to the foreground)', 'ON_NEXT_RESTART')
103
+ .option('--target-version <semver>', 'Target app native semver range (e.g. ">=1.0.0", "1.0.0")', '>=1.0.0')
104
+ .option('--rollout <percent>', 'Target rollout percentage (1-100)', '100')
105
+ .option('--entry-file <path>', 'JS entry file', 'index.js')
106
+ .option('--release-note <text>', 'Release note for dashboard display and device changelog')
107
+ .option('--hermes', 'Compile to Hermes bytecode (the default — flag kept for compatibility)')
108
+ .option('--hermes-disabled', 'Ship plain JS instead of Hermes bytecode (~20-30% slower cold start)')
109
+ .option('--allow-plain-js', 'Publish uncompiled JS if hermesc fails, instead of aborting')
110
+ .option('--key-dir <dir>', 'Directory holding the RSA signing key (default: ./.scalebun)')
111
+ .option('--key-id <id>', 'Signing key id (defaults to $SCALEBUN_KEY_ID or the local ./.scalebun key id)')
112
+ .option('--passphrase <pass>', 'Passphrase for an encrypted signing key (defaults to $SCALEBUN_SIGNING_KEY_PASSPHRASE)')
113
+ .option('--require-signing', 'Fail if the release cannot be signed (also via $SCALEBUN_REQUIRE_SIGNING). Prevents silently shipping an unsigned release a signature-enforcing device would reject.')
114
+ .option('--skip-bundle', 'Skip react-native bundle (provide --bundle-path instead)')
115
+ .option('--bundle-path <path>', 'Path to a pre-built bundle file')
116
+ .option('--custom-bundle-path <path>', 'Alias for --bundle-path (explicit naming)')
117
+ .option('--sourcemap', 'Generate and upload sourcemap for crash symbolication')
118
+ .option('--keep-artifacts', 'Keep build artifacts (bundle + sourcemap) after upload')
119
+ .option('--skip-promote', 'Upload and finalize only — do not make the release live')
120
+ .option('--yes', 'Skip the interactive wizard\'s final confirmation')
121
+ .action(async (opts, cmd) => {
122
+ // Interactive wizard: in a terminal with no --app-id (and no pre-built
123
+ // bundle), guide the user through the flags. The Dashboard and Cancel
124
+ // paths exit inside the wizard; the CLI path fills `opts` and returns.
125
+ if (process.stdout.isTTY &&
126
+ !opts.appId &&
127
+ !opts.skipBundle &&
128
+ !opts.bundlePath &&
129
+ !opts.customBundlePath) {
130
+ await runPublishWizard(opts, cmd);
131
+ }
132
+ // publish was the ONE command that never adopted the resolver. It
133
+ // demanded --app-id even with a scalebun.json sitting beside it, so the
134
+ // flagship command was the only one that failed on a correctly linked
135
+ // project. The wizard above still fills opts.appId when it runs; this
136
+ // covers every other path, including CI.
137
+ opts.appId = await (0, resolve_1.resolveAppId)({ appId: opts.appId, nonInteractive: opts.yes });
138
+ // commander cannot tell "--channel default" from the option's own
139
+ // default, so ask it: only a value that came from the CLI outranks the
140
+ // project's configured channel.
141
+ const explicitChannel = cmd?.getOptionValueSource?.('channel') === 'cli' ? opts.channel : undefined;
142
+ const creds = (0, credentials_1.readCredentials)();
143
+ if (!creds) {
144
+ console.error('❌ Not logged in. Run `scalebun login` first.');
145
+ process.exit(1);
146
+ }
147
+ const installMode = (0, validate_1.checkedEnum)('--install-mode', opts.installMode || 'ON_NEXT_RESTART', ['IMMEDIATE', 'ON_NEXT_RESTART', 'ON_NEXT_RESUME'], { caseInsensitive: true });
148
+ // Validate BEFORE building: a bad --rollout should cost a second, not a
149
+ // full Hermes compile.
150
+ const rolloutPercent = parseRolloutPercent(opts.rollout);
151
+ // The channel default was a hardcoded 'default' while `scalebun link`
152
+ // had already written the project's real channel to scalebun.json.
153
+ // Publishing on a linked project therefore aimed at a channel that did
154
+ // not exist — and said so only after a full Hermes build.
155
+ // explicit flag > legacy --channel-id > project config > 'default'.
156
+ const projectChannel = (0, context_1.getContext)({ appId: opts.appId }).channel;
157
+ const targetChannel = opts.channelId || explicitChannel || projectChannel || 'default';
158
+ const client = new client_1.ApiClient(creds);
159
+ // Was a bare cast, so `--platform windows` reached the bundler and
160
+ // failed later with an error about a missing entry file.
161
+ const platform = (0, validate_1.checkedEnum)('--platform', opts.platform, ['android', 'ios'], {
162
+ caseInsensitive: true,
163
+ });
164
+ // Fail closed BEFORE the expensive bundle: if this release must be signed
165
+ // (--require-signing / SCALEBUN_REQUIRE_SIGNING / a configured key id) but
166
+ // no signing key can be resolved, stop now rather than after a full Hermes
167
+ // compile. A signature-enforcing device rejects an unsigned release, so
168
+ // shipping unsigned here would be a broken deploy.
169
+ {
170
+ const early = (0, ota_keys_1.resolveSigningMaterial)({
171
+ keyDir: opts.keyDir,
172
+ keyId: opts.keyId,
173
+ passphrase: opts.passphrase,
174
+ });
175
+ if ((0, ota_keys_1.signingRequired)({ requireSigning: opts.requireSigning }, early) &&
176
+ !early.privatePem) {
177
+ (0, output_1.fail)('Signing is required for this release, but no signing key is available.', [
178
+ 'Provide the key via --key-dir, the SCALEBUN_SIGNING_PRIVATE_KEY secret,',
179
+ 'or ./.scalebun/ota-signing-key.pem.',
180
+ 'To publish unsigned (SHA-256 only), drop --require-signing / --key-id and',
181
+ 'unset SCALEBUN_REQUIRE_SIGNING / SCALEBUN_KEY_ID.',
182
+ ], output_1.ExitCode.Usage);
183
+ }
184
+ }
185
+ // Check the channel EXISTS before building anything.
186
+ //
187
+ // The presign call is what rejects an unknown channel, and it happens
188
+ // after Metro and Hermes have run — so a name that was never going to
189
+ // work cost a full 50-second compile before saying so. Every flag in
190
+ // this command already validates before the network; the channel is the
191
+ // one piece of state that needs a round trip to check, and one cheap GET
192
+ // is worth far less than the build it saves.
193
+ //
194
+ // Best-effort: a listing that fails for any other reason must not block
195
+ // a publish that would have succeeded, so this only ever fires on a
196
+ // definite "that channel is not there".
197
+ //
198
+ // The try/catch wraps ONLY the network listing. The abort below is
199
+ // deliberately OUTSIDE it: inside the interactive shell `fail()` ->
200
+ // process.exit is replaced by a throw (so one command can't kill the
201
+ // session), and a `fail()` raised inside this catch was swallowed as if
202
+ // it were a listing error — the guard printed "no bundle was compiled"
203
+ // and then built anyway. Deciding after the try keeps the abort real.
204
+ let knownChannels = null;
205
+ try {
206
+ const known = await client.get(`/saas/apps/${opts.appId}/ota/channels`);
207
+ knownChannels = Array.isArray(known) ? known : [];
208
+ }
209
+ catch {
210
+ // Listing failed for some other reason. Let the publish proceed and
211
+ // let presign be the authority, exactly as before.
212
+ knownChannels = null;
213
+ }
214
+ if (knownChannels && knownChannels.length) {
215
+ const hit = knownChannels.find((c) => c.id === targetChannel || c.name.toLowerCase() === targetChannel.toLowerCase());
216
+ if (!hit) {
217
+ (0, output_1.fail)(`No OTA channel named "${targetChannel}" on this app.`, [
218
+ `Existing: ${knownChannels.map((c) => c.name).join(', ')}`,
219
+ `Pass --channel <name>, or create it: scalebun ota channels create --name ${targetChannel}`,
220
+ 'Checked before building — no bundle was compiled.',
221
+ ], output_1.ExitCode.Usage);
222
+ }
223
+ }
224
+ // Runtime binding. `targetAppVersion` gates on a marketing string; these
225
+ // are the actual contract between the bundle and the binary that runs
226
+ // it. The backend withholds a bundle from a device whose runtime does
227
+ // not match — see runtime-compat.ts.
228
+ const rnVersion = (0, bundle_1.detectProjectRnVersion)();
229
+ // Generated here, before the build, so it can be compiled into the JS.
230
+ // The SDK reads it back at boot to prove the bundle it installed is the
231
+ // one actually executing.
232
+ const identityToken = crypto.randomBytes(16).toString('hex');
233
+ // Sourcemap outcome tracking — the publish summary must report what
234
+ // actually happened, not what was requested.
235
+ let sourcemapStored = false;
236
+ let sourcemapRegistered = false;
237
+ // Step 1: Build the bundle (or use pre-built)
238
+ let bundlePath;
239
+ let hermesBytecodeVersion = null;
240
+ let bundleCarriesMarker = false;
241
+ // The `--assets-dest` directory from buildBundle, walked into the asset
242
+ // manifest. Undefined for a pre-built bundle (no assets to enumerate).
243
+ let assetsDir;
244
+ if (opts.skipBundle && (opts.bundlePath || opts.customBundlePath)) {
245
+ bundlePath = opts.bundlePath || opts.customBundlePath;
246
+ if (!fs.existsSync(bundlePath)) {
247
+ console.error(`❌ Bundle file not found: ${bundlePath}`);
248
+ process.exit(1);
249
+ }
250
+ console.log(`📦 Using pre-built bundle: ${bundlePath}`);
251
+ // A pre-built artifact is taken as-is: injecting a marker would mean
252
+ // rewriting someone else's build output, and if it is already Hermes
253
+ // bytecode a text prefix would corrupt it outright.
254
+ hermesBytecodeVersion = (0, bundle_1.readHermesBytecodeVersion)(bundlePath);
255
+ console.log(' ℹ️ Pre-built bundle — no identity marker injected. Install verification ' +
256
+ 'will be unavailable for this release.');
257
+ }
258
+ else {
259
+ // Hermes is the DEFAULT. The docs have always said "compiled to Hermes
260
+ // bytecode by default (--hermes)" while the flag was actually opt-in —
261
+ // so every publish that didn't know the flag shipped plain JS with a
262
+ // slower cold start and no HBC version for the runtime gate to check.
263
+ // Making the code match the docs is the right direction: bytecode is
264
+ // what production wants, and `--hermes-disabled` remains the opt-out.
265
+ const useHermes = opts.hermesDisabled ? false : true;
266
+ console.log(`📦 Building ${platform} bundle${useHermes ? ' (Hermes bytecode)' : ''}…`);
267
+ const result = (0, bundle_1.buildBundle)({
268
+ platform,
269
+ entryFile: opts.entryFile,
270
+ dev: false,
271
+ hermes: useHermes,
272
+ allowPlainJs: opts.allowPlainJs ?? false,
273
+ sourcemap: opts.sourcemap,
274
+ identityToken,
275
+ });
276
+ bundlePath = result.bundlePath;
277
+ hermesBytecodeVersion = result.hermesBytecodeVersion;
278
+ bundleCarriesMarker = true;
279
+ assetsDir = result.assetsPath;
280
+ console.log(` ✅ Bundle built: ${bundlePath}`);
281
+ // ASSETS DO NOT SHIP OVER OTA.
282
+ //
283
+ // An update is one artifact: the JS/bytecode bundle. Images and fonts
284
+ // are resolved from what the INSTALLED BINARY already contains, so an
285
+ // asset added since the last store build is referenced by the new JS
286
+ // and is not on the device. The update installs cleanly, reports
287
+ // success, and renders a missing image — the worst kind of failure.
288
+ // Metro writes those files next to the bundle and the publish flow
289
+ // has always discarded them without a word. Now it says so.
290
+ if (result.assets.length) {
291
+ const shown = result.assets.slice(0, 5);
292
+ console.log(` ℹ️ This bundle references ${result.assets.length} asset(s). ` +
293
+ 'OTA ships JS only — assets resolve from the installed binary.');
294
+ console.log(` ${shown.join(', ')}${result.assets.length > shown.length ? `, +${result.assets.length - shown.length} more` : ''}`);
295
+ console.log(' Any image or font ADDED since your last store build will be ' +
296
+ 'missing on device. Changing which existing asset you use is fine; ' +
297
+ 'a new one needs a native release (fonts always do).');
298
+ }
299
+ // You asked for bytecode and didn't get it: that is a failed build,
300
+ // not a warning. This degradation has shipped silently twice now —
301
+ // first via `npx hermesc` (not a real package), then via spawnSync's
302
+ // 1MB maxBuffer killing the compiler — and a plain-JS bundle both
303
+ // starts ~20-30% slower and carries no HBC version for the runtime
304
+ // gate to check. If plain JS is what you want, say so.
305
+ if (useHermes && !result.hermesCompiled) {
306
+ console.error('❌ --hermes was requested but the bundle is NOT Hermes bytecode (see the hermesc error above).\n' +
307
+ ' To intentionally publish plain JS, use --hermes-disabled.');
308
+ process.exit(1);
309
+ }
310
+ }
311
+ console.log(` Runtime: RN ${rnVersion ?? 'unknown'}` +
312
+ (hermesBytecodeVersion !== null
313
+ ? `, Hermes HBC v${hermesBytecodeVersion}`
314
+ : ', plain JS'));
315
+ if (!rnVersion) {
316
+ console.warn(' ⚠️ React Native version could not be detected — this bundle will not be ' +
317
+ 'runtime-gated, and can reach binaries that cannot run it.');
318
+ }
319
+ // The sourcemap must be located BEFORE presign, because sourcemaps are
320
+ // uploaded through a presigned URL requested alongside the bundle's.
321
+ const sourcemapPath = opts.sourcemap && fs.existsSync(bundlePath + '.map')
322
+ ? bundlePath + '.map'
323
+ : null;
324
+ if (opts.sourcemap && !sourcemapPath) {
325
+ console.warn(' ⚠️ --sourcemap was passed but no .map file was produced — continuing without one.');
326
+ }
327
+ // Step 2: Hash the bundle
328
+ console.log('🔒 Computing SHA-256…');
329
+ const sha256 = (0, hash_1.sha256File)(bundlePath);
330
+ const sizeBytes = fs.statSync(bundlePath).size;
331
+ console.log(` Hash: ${sha256}`);
332
+ console.log(` Size: ${(sizeBytes / 1024 / 1024).toFixed(2)} MB`);
333
+ if (sizeBytes > 50000000) {
334
+ console.error(`❌ Bundle is ${(sizeBytes / 1024 / 1024).toFixed(1)} MB — exceeds 50 MB limit`);
335
+ process.exit(1);
336
+ }
337
+ // Provenance (git + CI) and the asset manifest — best-effort metadata so
338
+ // the dashboard can show Git SHA / PR / CI build and per-asset size diffs.
339
+ // Both are optional on the backend; sending them changes nothing about
340
+ // delivery, only what the Bundles page can display.
341
+ const provenance = (0, provenance_1.collectProvenance)();
342
+ const assetManifest = (0, provenance_1.collectAssetManifest)(bundlePath, sha256, sizeBytes, assetsDir);
343
+ if (provenance.gitSha) {
344
+ console.log(` Source: ${provenance.gitSha.slice(0, 7)}${provenance.gitBranch ? ` (${provenance.gitBranch})` : ''}` +
345
+ `${provenance.ciProvider ? ` · ${provenance.ciProvider}` : ''}`);
346
+ }
347
+ console.log(` Assets: ${assetManifest.length} (bundle + ${assetManifest.length - 1} files)`);
348
+ // The binary's native application id (Android applicationId / iOS bundle
349
+ // id). Signed releases are identified by it and the device verifies it
350
+ // against its own package name. Sending it lets the server adopt it as the
351
+ // app's bundleId when that field is still empty (the common case), so the
352
+ // first signed publish just works instead of failing with an empty
353
+ // applicationId.
354
+ const nativeApplicationId = (0, native_id_1.detectNativeApplicationId)(platform);
355
+ if (nativeApplicationId) {
356
+ console.log(` Native app id: ${nativeApplicationId}`);
357
+ }
358
+ // Step 3: Get presigned upload URL
359
+ console.log('🔗 Requesting presigned upload URL…');
360
+ let presigned;
361
+ try {
362
+ presigned = await client.post(`/saas/apps/${opts.appId}/ota/bundles/presign`, {
363
+ channelId: targetChannel,
364
+ platform,
365
+ nativeApplicationId,
366
+ sha256,
367
+ sizeBytes,
368
+ releaseNote: opts.releaseNote,
369
+ rnVersion: rnVersion ?? undefined,
370
+ hermes: hermesBytecodeVersion !== null,
371
+ hermesBytecodeVersion: hermesBytecodeVersion ?? undefined,
372
+ identityToken: bundleCarriesMarker ? identityToken : undefined,
373
+ // Provenance + asset manifest — optional; the backend stores them
374
+ // for the dashboard and ignores them if omitted.
375
+ ...provenance,
376
+ assets: assetManifest,
377
+ // Sourcemaps ride the presign flow: declaring them here returns a
378
+ // second presigned PUT URL. This CLI used to skip these fields and
379
+ // instead POST base64 JSON to /bundles/:id/sourcemap after promote
380
+ // — an endpoint that DOES NOT EXIST and never has. The error was
381
+ // caught and printed as a "non-fatal" warning, so every
382
+ // `--sourcemap` publish ever made silently uploaded nothing while
383
+ // the summary implied symbolication would work.
384
+ ...(sourcemapPath
385
+ ? {
386
+ sourcemapSha256: (0, hash_1.sha256File)(sourcemapPath),
387
+ sourcemapSizeBytes: fs.statSync(sourcemapPath).size,
388
+ }
389
+ : {}),
390
+ },
391
+ // Presign is safe to retry: it never got an answer if the socket
392
+ // dropped, and a stray UPLOADING row is superseded by the retry. A
393
+ // single "socket hang up" must not sink an already-built bundle.
394
+ { retry: true });
395
+ }
396
+ catch (err) {
397
+ console.error(`❌ Presign failed: ${err.message}`);
398
+ process.exit(1);
399
+ }
400
+ console.log(` Bundle ID: ${presigned.bundleId}`);
401
+ console.log(` Upload URL expires in ${presigned.expiresIn}s`);
402
+ // Step 4: Upload to R2/S3
403
+ console.log('⬆️ Uploading bundle to storage…');
404
+ const bundleData = fs.readFileSync(bundlePath);
405
+ try {
406
+ await client.putBytes(presigned.uploadUrl, bundleData, 'application/octet-stream');
407
+ }
408
+ catch (err) {
409
+ console.error(`❌ Upload failed: ${err.message}`);
410
+ process.exit(1);
411
+ }
412
+ console.log(' ✅ Upload complete');
413
+ // Step 5: Finalize — server-side SHA-256 verification + version assignment
414
+ console.log('🔍 Finalizing (server-side hash verification)…');
415
+ let finalized;
416
+ try {
417
+ // Sign under SB-OTA-RSA-SHA256-V1 with the locally-held RSA private key,
418
+ // if this project has adopted signing. The key never leaves this machine:
419
+ // we build the binary signed-release payload from the identity the server
420
+ // RESERVED at presign (releaseId/bundleId/releaseSequence/project/app) plus
421
+ // the artifact's own size + SHA-256, RSA-sign it, self-verify, and upload
422
+ // only { signedPayload, signature }. ScaleBun serves a signature it could
423
+ // not itself have produced. Unsigned publishes stay valid; the device only
424
+ // enforces once the app embeds a trusted public key.
425
+ const signing = (0, ota_keys_1.resolveSigningMaterial)({
426
+ keyDir: opts.keyDir,
427
+ keyId: opts.keyId,
428
+ passphrase: opts.passphrase,
429
+ });
430
+ let finalizeBody = undefined;
431
+ // Fail closed: a release that must be signed but has no key must not
432
+ // silently ship unsigned — a signature-enforcing device rejects it, so
433
+ // "unsigned" here is a broken deploy, not a lenient fallback.
434
+ if ((0, ota_keys_1.signingRequired)({ requireSigning: opts.requireSigning }, signing) && !signing.privatePem) {
435
+ (0, output_1.fail)('Signing is required for this release, but no signing key is available.', [
436
+ 'Provide the key via --key-dir, the SCALEBUN_SIGNING_PRIVATE_KEY secret,',
437
+ 'or ./.scalebun/ota-signing-key.pem.',
438
+ 'To publish unsigned (SHA-256 only), drop --require-signing / --key-id and',
439
+ 'unset SCALEBUN_REQUIRE_SIGNING / SCALEBUN_KEY_ID.',
440
+ ], output_1.ExitCode.Usage);
441
+ }
442
+ if (signing.privatePem) {
443
+ const keyId = signing.keyId;
444
+ if (!keyId) {
445
+ (0, output_1.fail)('A signing key is present but its key id is unknown.', [
446
+ 'Pass --key-id <id> or set SCALEBUN_KEY_ID, or regenerate with',
447
+ '`scalebun ota keys generate` which writes the key id alongside the key.',
448
+ ], output_1.ExitCode.Usage);
449
+ }
450
+ // Distinguish the two failure modes so the message points at the
451
+ // real cause instead of always blaming the backend version.
452
+ if (!presigned.applicationId) {
453
+ // The app has no bundleId, and the server could not adopt one.
454
+ (0, output_1.fail)("This app has no bundleId (native application id), so a signed release can't be identified.", nativeApplicationId
455
+ ? [
456
+ `The CLI detected '${nativeApplicationId}' and sent it, but the backend did not`,
457
+ 'adopt it — the deployed backend predates bundleId self-heal. Set the app',
458
+ "bundleId in the dashboard (app settings), then re-run publish.",
459
+ ]
460
+ : [
461
+ 'Could not read the native applicationId from this project (looked for',
462
+ 'android/app/build.gradle applicationId / the iOS bundle identifier).',
463
+ 'Set the app bundleId in the dashboard (app settings), or run publish from',
464
+ 'the app root so the id can be detected, then re-run.',
465
+ ], output_1.ExitCode.Rejected);
466
+ }
467
+ if (!presigned.projectId ||
468
+ !presigned.releaseId ||
469
+ presigned.releaseSequence === undefined ||
470
+ presigned.releaseSequence === null) {
471
+ (0, output_1.fail)('This ScaleBun backend did not return SB-OTA-RSA-SHA256-V1 release identity.', [
472
+ 'The RSA signing protocol needs the server to reserve releaseId + releaseSequence',
473
+ 'at presign. Update the ScaleBun backend, or publish without a local signing key',
474
+ 'to ship on SHA-256 integrity only.',
475
+ ], output_1.ExitCode.Rejected);
476
+ }
477
+ const payload = {
478
+ signatureAlgorithm: 'SB-OTA-RSA-SHA256-V1',
479
+ hashAlgorithm: 'SHA-256',
480
+ keyId: keyId,
481
+ projectId: presigned.projectId,
482
+ applicationId: presigned.applicationId,
483
+ releaseId: presigned.releaseId,
484
+ bundleId: presigned.bundleId,
485
+ platform,
486
+ artifactType: 'full',
487
+ targetAppVersion: opts.targetVersion || '>=1.0.0',
488
+ targetRuntimeVersion: rnVersion ?? '',
489
+ minimumSdkVersion: presigned.minimumSdkVersion ?? '',
490
+ releaseSequence: presigned.releaseSequence,
491
+ issuedAtEpochSeconds: Math.floor(Date.now() / 1000),
492
+ expiresAtEpochSeconds: 0,
493
+ artifactSize: sizeBytes,
494
+ artifactSha256: sha256,
495
+ baseBundleId: '',
496
+ baseArtifactSha256: '',
497
+ // A full download IS the final bundle, so the installed result hash
498
+ // equals the downloaded artifact hash.
499
+ resultArtifactSha256: sha256,
500
+ };
501
+ // An external signer is used only when one is configured. With
502
+ // nothing set this is the same local-PEM path as before, byte for
503
+ // byte — the point of the change is that a customer who has not
504
+ // asked for an HSM cannot tell it exists.
505
+ const externalSigner = process.env.SCALEBUN_SIGNER_COMMAND?.trim();
506
+ const displayMetadata = {
507
+ releaseId: presigned.releaseId,
508
+ releaseNote: opts.releaseNote,
509
+ };
510
+ const envelope = externalSigner
511
+ ? await (0, signing_1.buildReleaseEnvelopeWith)(payload, (0, signer_1.resolveSigner)({ privateKeyPem: signing.privatePem }), { displayMetadata })
512
+ : (0, signing_1.buildReleaseEnvelope)(payload, signing.privatePem, {
513
+ passphrase: signing.passphrase,
514
+ displayMetadata,
515
+ });
516
+ const via = externalSigner
517
+ ? ' (external signer)'
518
+ : signing.privateSource === 'env'
519
+ ? ' (key from SCALEBUN_SIGNING_PRIVATE_KEY)'
520
+ : signing.privateSource === 'flag'
521
+ ? ' (key from --key-dir)'
522
+ : '';
523
+ console.log(` 🔏 Signed with RSA key (SB-OTA-RSA-SHA256-V1)${via}`);
524
+ finalizeBody = {
525
+ protocol: envelope.protocol,
526
+ keyId: envelope.keyId,
527
+ signedPayload: envelope.signedPayload,
528
+ signature: envelope.signature,
529
+ };
530
+ }
531
+ finalized = await client.post(`/saas/apps/${opts.appId}/ota/bundles/${presigned.bundleId}/finalize`, finalizeBody);
532
+ }
533
+ catch (err) {
534
+ console.error(`❌ Finalize failed: ${err.message}`);
535
+ process.exit(1);
536
+ }
537
+ // Step 6: Promote release to ACTIVE status with installMode
538
+ if (opts.skipPromote) {
539
+ console.log('⏭️ Skipping promotion (--skip-promote) — bundle is uploaded but NOT live.');
540
+ console.log(` Promote later from the dashboard, or re-run without --skip-promote.`);
541
+ }
542
+ else {
543
+ console.log('🚀 Promoting release to active target channel…');
544
+ try {
545
+ await client.post(`/saas/apps/${opts.appId}/ota/bundles/${presigned.bundleId}/promote`, {
546
+ targetAppVersion: opts.targetVersion || '>=1.0.0',
547
+ rolloutPercent,
548
+ isMandatory: installMode === 'IMMEDIATE',
549
+ installMode,
550
+ releaseNote: opts.releaseNote,
551
+ });
552
+ }
553
+ catch (err) {
554
+ // This used to warn and exit 0 — so a CI pipeline printed
555
+ // "published successfully" while no device would ever receive the
556
+ // update (promotion is what makes a bundle live). A pipeline must
557
+ // fail when its purpose failed.
558
+ console.error(`❌ Promotion failed: ${err.message}`);
559
+ console.error(` The bundle IS uploaded (v pending, id ${presigned.bundleId}) but is NOT live.\n` +
560
+ ' Fix the error and promote from the dashboard, or publish again.\n' +
561
+ ' To upload without promoting on purpose, use --skip-promote.');
562
+ process.exit(1);
563
+ }
564
+ }
565
+ // Step 7: Upload the sourcemap through its presigned URL.
566
+ // (The old code POSTed base64 JSON to /bundles/:id/sourcemap — a route
567
+ // that has never existed — and downgraded the 404 to a "non-fatal"
568
+ // warning, so symbolication silently never worked for OTA bundles.)
569
+ if (sourcemapPath && presigned.sourcemapUploadUrl) {
570
+ console.log('🗺️ Uploading sourcemap for crash symbolication…');
571
+ try {
572
+ await client.putBytes(presigned.sourcemapUploadUrl, fs.readFileSync(sourcemapPath), 'application/json');
573
+ console.log(' ✅ Sourcemap uploaded');
574
+ sourcemapStored = true;
575
+ // Cleanup happens AFTER symbolication registration below — the
576
+ // symbols upload reads this same file.
577
+ }
578
+ catch (err) {
579
+ console.warn(` ⚠️ Sourcemap upload failed: ${err.message}\n` +
580
+ ' Stack traces from this bundle will not symbolicate. Re-publish to retry.');
581
+ }
582
+ }
583
+ else if (sourcemapPath && !presigned.sourcemapUploadUrl) {
584
+ console.warn(' ⚠️ Backend did not return a sourcemap upload URL — is it up to date?');
585
+ }
586
+ // D6: register the map with SYMBOLICATION, not just storage. The
587
+ // presigned PUT above lands the map in the OTA bundle bucket —
588
+ // `OtaBundle.sourcemapKey` — which NOTHING reads: the symbolication
589
+ // worker only consumes `symbolUpload` rows, written by
590
+ // POST /apps/:appId/symbols. Without this second upload, every
591
+ // `--sourcemap` publish paid to store a map no crash could ever use.
592
+ // That endpoint authenticates with the app's SDK SECRET key (a server
593
+ // credential, deliberately not the client key baked into the binary),
594
+ // so this step is opt-in via SCALEBUN_SDK_SECRET.
595
+ if (sourcemapPath && fs.existsSync(sourcemapPath)) {
596
+ const sdkSecret = process.env.SCALEBUN_SDK_SECRET;
597
+ if (!sdkSecret) {
598
+ console.warn(' ⚠️ SCALEBUN_SDK_SECRET not set — the sourcemap was stored but NOT ' +
599
+ 'registered for crash symbolication. Set the app\'s SDK secret key ' +
600
+ 'in that env var to enable symbolicated stack traces.');
601
+ }
602
+ else {
603
+ console.log('🔣 Registering sourcemap for crash symbolication…');
604
+ try {
605
+ const boundary = `----scalebun${Date.now().toString(16)}`;
606
+ const mapBytes = fs.readFileSync(sourcemapPath);
607
+ const field = (name, value) => `--${boundary}\r\nContent-Disposition: form-data; name="${name}"\r\n\r\n${value}\r\n`;
608
+ const head = Buffer.from(field('platform', 'js') +
609
+ (rnVersion ? field('version', opts.targetVersion ?? '') : '') +
610
+ field('buildUuid', sha256) +
611
+ `--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="bundle.jsbundle.map"\r\nContent-Type: application/json\r\n\r\n`, 'utf-8');
612
+ const tail = Buffer.from(`\r\n--${boundary}--\r\n`, 'utf-8');
613
+ await client.postMultipart(`/apps/${opts.appId}/symbols`, Buffer.concat([head, mapBytes, tail]), `multipart/form-data; boundary=${boundary}`, { 'x-scalebun-secret-key': sdkSecret });
614
+ console.log(' ✅ Sourcemap registered — crashes from this bundle will symbolicate');
615
+ sourcemapRegistered = true;
616
+ }
617
+ catch (err) {
618
+ console.warn(` ⚠️ Symbolication registration failed: ${err.message}`);
619
+ }
620
+ }
621
+ if (!opts.keepArtifacts) {
622
+ try {
623
+ fs.unlinkSync(sourcemapPath);
624
+ }
625
+ catch {
626
+ /* non-fatal */
627
+ }
628
+ }
629
+ }
630
+ // Step 8: Print detailed success summary
631
+ const installModeLabel = installMode === 'IMMEDIATE'
632
+ ? '⚡ IMMEDIATE (SDK restarts the app right after install)'
633
+ : installMode === 'ON_NEXT_RESUME'
634
+ ? '🔄 ON_NEXT_RESUME (SDK restarts on next return to foreground)'
635
+ : '🌙 ON_NEXT_RESTART (activates silently on next launch)';
636
+ console.log('');
637
+ console.log(`✅ Bundle v${finalized.version} published successfully!`);
638
+ console.log(` Channel: ${finalized.channelName} (${finalized.environmentName})`);
639
+ console.log(` Platform: ${finalized.platform}`);
640
+ console.log(` Install Mode: ${installModeLabel}`);
641
+ console.log(` SHA-256: ${finalized.sha256}`);
642
+ console.log(` Size: ${(finalized.sizeBytes / 1024).toFixed(1)} KB`);
643
+ if (opts.sourcemap) {
644
+ // Report what actually happened. This line used to print success
645
+ // whenever --sourcemap was passed — even when no map was produced,
646
+ // the upload failed, or SCALEBUN_SDK_SECRET was unset so nothing was
647
+ // registered for symbolication. CI logs then certified symbolication
648
+ // that would not happen, and the first production crash arrived
649
+ // minified.
650
+ const smState = sourcemapRegistered
651
+ ? 'Uploaded and registered ✓ (stack traces will symbolicate)'
652
+ : sourcemapStored
653
+ ? 'Uploaded, NOT registered — set SCALEBUN_SDK_SECRET to symbolicate'
654
+ : 'NOT uploaded — stack traces from this bundle stay minified';
655
+ console.log(` Sourcemap: ${smState}`);
656
+ }
657
+ const rolloutNow = opts.skipPromote ? 0 : rolloutPercent;
658
+ // The --app-id on every line here was load-bearing before `link` and
659
+ // the resolver existed. It is now noise: the id resolves from
660
+ // scalebun.json, and a suggestion carrying a 25-character cuid reads as
661
+ // something you must paste rather than something you can just run.
662
+ (0, output_1.printNextSteps)([
663
+ { label: 'See live state', argv: ['ota', 'status'] },
664
+ ...(!opts.skipPromote && rolloutNow < 100
665
+ ? [{ label: 'Widen the rollout', argv: ['ota', 'rollout', '--percent', '100'] }]
666
+ : []),
667
+ ...(opts.skipPromote
668
+ ? [{ label: 'Make it live', argv: ['ota', 'promote'] }]
669
+ : []),
670
+ { label: 'Stop new deliveries', argv: ['ota', 'pause'] },
671
+ { label: 'Revert every device', argv: ['ota', 'rollback'] },
672
+ ], chalk_1.default.cyan);
673
+ if (!opts.skipPromote && rolloutNow === 100) {
674
+ console.log('');
675
+ console.log(' Tip: production releases usually start staged — `--rollout 10`, watch the');
676
+ console.log(' health page, then `ota rollout --percent 100` once it looks clean.');
677
+ }
678
+ // Clean up bundle unless --keep-artifacts or using pre-built
679
+ if (!opts.keepArtifacts && !opts.skipBundle) {
680
+ try {
681
+ fs.unlinkSync(bundlePath);
682
+ }
683
+ catch {
684
+ // non-fatal
685
+ }
686
+ }
687
+ });
688
+ }
689
+ /**
690
+ * Fill `opts` interactively. Picks the publish method first: the Dashboard and
691
+ * Cancel branches finish inside here (process.exit); the CLI branch mutates
692
+ * `opts` and returns so the normal publish flow runs.
693
+ */
694
+ async function runPublishWizard(opts, cmd) {
695
+ console.log(chalk_1.default.bold.cyan('\n⚡ ScaleBun — publish an OTA update\n'));
696
+ const method = await (0, prompt_1.select)('How do you want to publish?', [
697
+ { label: 'Publish now (CLI)', value: 'cli', hint: 'bundle, upload & promote this release' },
698
+ { label: 'Build a bundle for the Dashboard', value: 'dashboard', hint: `drag & drop at ${DASHBOARD_OTA_URL}` },
699
+ { label: 'Cancel', value: 'cancel' },
700
+ ]);
701
+ if (method === 'cancel') {
702
+ console.log('Cancelled — nothing published.');
703
+ process.exit(0);
704
+ }
705
+ if (method === 'dashboard') {
706
+ await runDashboardBundleFlow(opts);
707
+ process.exit(0);
708
+ }
709
+ // ── CLI path ────────────────────────────────────────────────────────────────
710
+ let creds = (0, credentials_1.readCredentials)();
711
+ if (!creds) {
712
+ console.log('\nYou need to sign in first.\n');
713
+ await (0, login_1.runLogin)({});
714
+ creds = (0, credentials_1.readCredentials)();
715
+ if (!creds)
716
+ process.exit(1);
717
+ }
718
+ const client = new client_1.ApiClient(creds);
719
+ opts.appId = await pickApp(client);
720
+ opts.platform = await (0, prompt_1.select)('Platform', [
721
+ { label: 'Android', value: 'android' },
722
+ { label: 'iOS', value: 'ios' },
723
+ ], { default: opts.platform === 'ios' ? 1 : 0 });
724
+ opts.channel = await pickChannel(client, opts.appId, opts.channel);
725
+ // The wizard fills opts by mutation, but the action decides whether to trust
726
+ // opts.channel by asking commander where the value came from
727
+ // (getOptionValueSource === 'cli'). A bare `ota publish` leaves that source at
728
+ // 'default', so a wizard-picked channel was silently dropped in favour of
729
+ // scalebun.json's channel. Register the pick through commander so the single
730
+ // resolution path sees it as explicit — every other wizard answer is read off
731
+ // opts directly, only the channel is gated on the source.
732
+ cmd?.setOptionValueWithSource('channel', opts.channel, 'cli');
733
+ opts.installMode = await (0, prompt_1.select)('Install mode', [
734
+ { label: 'ON_NEXT_RESTART', value: 'ON_NEXT_RESTART', hint: 'applies on the next cold launch (default)' },
735
+ { label: 'ON_NEXT_RESUME', value: 'ON_NEXT_RESUME', hint: 'applies when the app next returns to the foreground' },
736
+ { label: 'IMMEDIATE', value: 'IMMEDIATE', hint: 'reloads the app right away — for hotfixes' },
737
+ ]);
738
+ opts.rollout = String(await (0, prompt_1.number)('Rollout %', { default: 100, min: 0, max: 100 }));
739
+ opts.targetVersion = await (0, prompt_1.text)('Target native app version range', {
740
+ default: opts.targetVersion || '>=1.0.0',
741
+ });
742
+ const note = await (0, prompt_1.text)('Release note (optional)', {
743
+ default: opts.releaseNote || '',
744
+ allowEmpty: true,
745
+ });
746
+ opts.releaseNote = note || undefined;
747
+ const useHermes = await (0, prompt_1.confirm)('Compile to Hermes bytecode? (smaller, faster cold start)', {
748
+ default: true,
749
+ });
750
+ if (useHermes)
751
+ opts.hermes = true;
752
+ else
753
+ opts.hermesDisabled = true;
754
+ if (await (0, prompt_1.confirm)('Configure advanced options?', { default: false })) {
755
+ opts.entryFile = await (0, prompt_1.text)('JS entry file', { default: opts.entryFile || 'index.js' });
756
+ opts.sourcemap = await (0, prompt_1.confirm)('Generate & upload a sourcemap (crash symbolication)?', {
757
+ default: !!opts.sourcemap,
758
+ });
759
+ opts.keepArtifacts = await (0, prompt_1.confirm)('Keep build artifacts after upload?', {
760
+ default: !!opts.keepArtifacts,
761
+ });
762
+ opts.skipPromote = await (0, prompt_1.confirm)('Upload only — do NOT make it live yet (skip promote)?', {
763
+ default: !!opts.skipPromote,
764
+ });
765
+ }
766
+ printReview(opts);
767
+ if (!opts.yes) {
768
+ // No default: the wizard has just collected every answer, and Enter
769
+ // meaning "throw it away" is the same trap as the promote confirmation.
770
+ const go = await (0, prompt_1.confirm)('Publish now?');
771
+ if (!go) {
772
+ (0, prompt_1.closePrompts)();
773
+ console.log('Aborted — nothing published.');
774
+ process.exit(0);
775
+ }
776
+ }
777
+ (0, prompt_1.closePrompts)();
778
+ console.log('');
779
+ }
780
+ /** App pick-list from the account, with a manual-entry fallback. */
781
+ async function pickApp(client) {
782
+ let entries;
783
+ try {
784
+ entries = await (0, apps_1.fetchAllApps)(client);
785
+ }
786
+ catch {
787
+ entries = [];
788
+ }
789
+ if (!entries.length) {
790
+ console.log(chalk_1.default.yellow(' Could not list your apps — enter the app id manually.'));
791
+ // Was: `if (!id) process.exit(1)` — a silent exit 1 from a prompt that had
792
+ // just asked for an app id, with nothing said about why it stopped.
793
+ return (0, prompt_1.text)('App id', {
794
+ help: 'Find it in the dashboard URL, or run `scalebun apps` from another terminal.',
795
+ validate: (v) => (/\s/.test(v) ? 'An app id has no spaces.' : null),
796
+ });
797
+ }
798
+ return (0, prompt_1.select)('App', entries.map((e) => ({
799
+ label: e.app.name,
800
+ value: e.app.id,
801
+ hint: `${e.app.id} · ${e.orgName}/${e.projectName}${e.app.platform ? ` · ${e.app.platform}` : ''}`,
802
+ })));
803
+ }
804
+ /** Channel pick-list for the app (falls back to typing a name). */
805
+ async function pickChannel(client, appId, current) {
806
+ let channels = [];
807
+ try {
808
+ channels = (await client.get(`/saas/apps/${appId}/ota/channels`)) ?? [];
809
+ }
810
+ catch {
811
+ channels = [];
812
+ }
813
+ const choices = channels.map((c) => ({
814
+ label: c.name,
815
+ value: c.name,
816
+ hint: c.environment?.name ? `env: ${c.environment.name}` : undefined,
817
+ }));
818
+ if (!choices.some((c) => c.value === 'default')) {
819
+ choices.unshift({ label: 'default', value: 'default', hint: 'created automatically on first publish' });
820
+ }
821
+ const OTHER = '__other__';
822
+ choices.push({ label: 'Other (type a name)…', value: OTHER, hint: undefined });
823
+ const defaultIdx = current ? Math.max(0, choices.findIndex((c) => c.value === current)) : 0;
824
+ const picked = await (0, prompt_1.select)('Channel', choices, { default: defaultIdx });
825
+ if (picked === OTHER)
826
+ return (await (0, prompt_1.text)('Channel name', { default: 'default' })) || 'default';
827
+ return picked;
828
+ }
829
+ /** The Dashboard path: build a standalone bundle and point the user at the dashboard. */
830
+ async function runDashboardBundleFlow(opts) {
831
+ const platform = await (0, prompt_1.select)('Platform', [
832
+ { label: 'Android', value: 'android' },
833
+ { label: 'iOS', value: 'ios' },
834
+ ], { default: opts.platform === 'ios' ? 1 : 0 });
835
+ const useHermes = await (0, prompt_1.confirm)('Compile to Hermes bytecode? (smaller, faster cold start)', {
836
+ default: true,
837
+ });
838
+ const entryFile = await (0, prompt_1.text)('JS entry file', { default: opts.entryFile || 'index.js' });
839
+ const sourcemap = await (0, prompt_1.confirm)('Generate a sourcemap alongside the bundle?', { default: false });
840
+ // Release stdin BEFORE spawning `react-native bundle`. buildBundle runs Metro
841
+ // with stdio:'inherit'; on Windows an inherited child sharing a still-open
842
+ // readline on the console gets a corrupt stdin handle and is killed (exit code
843
+ // null). The CLI-publish path already closes prompts before building — do the
844
+ // same here.
845
+ (0, prompt_1.closePrompts)();
846
+ console.log(`\n📦 Building ${platform} bundle${useHermes ? ' (Hermes bytecode)' : ''}…`);
847
+ const result = (0, bundle_1.buildBundle)({
848
+ platform,
849
+ entryFile,
850
+ dev: false,
851
+ hermes: useHermes,
852
+ allowPlainJs: false,
853
+ sourcemap,
854
+ });
855
+ const outPath = path.resolve(`./index.${platform}.bundle`);
856
+ fs.copyFileSync(result.bundlePath, outPath);
857
+ const sizeMb = (fs.statSync(outPath).size / (1024 * 1024)).toFixed(2);
858
+ console.log(`\n✅ Bundle created: ${chalk_1.default.cyan(outPath)} (${sizeMb} MB)`);
859
+ console.log(chalk_1.default.dim(` (equivalent: scalebun ota bundle --platform ${platform} ${useHermes ? '--hermes' : '--hermes-disabled'}${sourcemap ? ' --sourcemap' : ''})`));
860
+ console.log(`\n👉 Drag & drop it into the Scalebun Dashboard: ${chalk_1.default.cyan(DASHBOARD_OTA_URL)}`);
861
+ const open = await (0, prompt_1.confirm)('Open the dashboard now?', { default: true });
862
+ (0, prompt_1.closePrompts)();
863
+ if (open)
864
+ (0, browser_1.openUrl)(DASHBOARD_OTA_URL);
865
+ }
866
+ /** Show the equivalent command before the irreversible publish. */
867
+ function printReview(opts) {
868
+ const parts = [
869
+ 'scalebun ota publish',
870
+ `--app-id ${opts.appId}`,
871
+ `--platform ${opts.platform}`,
872
+ `--channel ${opts.channel}`,
873
+ `--install-mode ${opts.installMode}`,
874
+ `--rollout ${opts.rollout}`,
875
+ `--target-version "${opts.targetVersion}"`,
876
+ ];
877
+ if (opts.releaseNote)
878
+ parts.push(`--release-note "${opts.releaseNote}"`);
879
+ parts.push(opts.hermesDisabled ? '--hermes-disabled' : '--hermes');
880
+ if (opts.sourcemap)
881
+ parts.push('--sourcemap');
882
+ if (opts.keepArtifacts)
883
+ parts.push('--keep-artifacts');
884
+ if (opts.skipPromote)
885
+ parts.push('--skip-promote');
886
+ if (opts.entryFile && opts.entryFile !== 'index.js')
887
+ parts.push(`--entry-file ${opts.entryFile}`);
888
+ console.log('\n' + chalk_1.default.bold('Review'));
889
+ console.log(' ' + chalk_1.default.green(parts.join(' \\\n ')));
890
+ console.log('');
891
+ }
892
+ //# sourceMappingURL=ota-publish.js.map