hotcodepush 0.0.1 → 0.1.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 (161) hide show
  1. package/README.md +3 -3
  2. package/dist/commands/app/create.js +49 -0
  3. package/dist/commands/app/delete.js +34 -0
  4. package/dist/commands/app/get.js +33 -0
  5. package/dist/commands/app/list.js +38 -0
  6. package/dist/commands/app/transfer.js +58 -0
  7. package/dist/commands/app/update.js +34 -0
  8. package/dist/commands/audience/get.js +69 -0
  9. package/dist/commands/audit-log/list.js +82 -0
  10. package/dist/commands/binary/create.js +189 -0
  11. package/dist/commands/binary/get.js +56 -0
  12. package/dist/commands/binary/list.js +58 -0
  13. package/dist/commands/bundle/delete.js +35 -0
  14. package/dist/commands/bundle/get.js +63 -0
  15. package/dist/commands/bundle/list.js +66 -0
  16. package/dist/commands/bundle/upload.js +176 -0
  17. package/dist/commands/channel/create.js +59 -0
  18. package/dist/commands/channel/delete.js +68 -0
  19. package/dist/commands/channel/get.js +45 -0
  20. package/dist/commands/channel/list.js +39 -0
  21. package/dist/commands/channel/pause.js +32 -0
  22. package/dist/commands/channel/resume.js +32 -0
  23. package/dist/commands/channel/update.js +40 -0
  24. package/dist/commands/checkout/create.js +25 -0
  25. package/dist/commands/completion.js +36 -0
  26. package/dist/commands/customer-portal-session/create.js +25 -0
  27. package/dist/commands/device/delete.js +29 -0
  28. package/dist/commands/device/get.js +60 -0
  29. package/dist/commands/device/list.js +97 -0
  30. package/dist/commands/device/probe.js +253 -0
  31. package/dist/commands/doctor.js +363 -0
  32. package/dist/commands/fingerprint/diff.js +133 -0
  33. package/dist/commands/fingerprint.js +41 -0
  34. package/dist/commands/init.js +492 -0
  35. package/dist/commands/invitation/accept.js +53 -0
  36. package/dist/commands/invitation/create.js +52 -0
  37. package/dist/commands/invitation/delete.js +60 -0
  38. package/dist/commands/invitation/list.js +40 -0
  39. package/dist/commands/limit/list.js +34 -0
  40. package/dist/commands/login.js +185 -0
  41. package/dist/commands/logout.js +79 -0
  42. package/dist/commands/member/delete.js +35 -0
  43. package/dist/commands/member/get.js +37 -0
  44. package/dist/commands/member/list.js +41 -0
  45. package/dist/commands/member/update.js +53 -0
  46. package/dist/commands/open.js +37 -0
  47. package/dist/commands/organization/create.js +30 -0
  48. package/dist/commands/organization/delete.js +39 -0
  49. package/dist/commands/organization/get.js +38 -0
  50. package/dist/commands/organization/list.js +41 -0
  51. package/dist/commands/organization/update.js +64 -0
  52. package/dist/commands/release/create.js +237 -0
  53. package/dist/commands/release/get.js +81 -0
  54. package/dist/commands/release/list.js +69 -0
  55. package/dist/commands/release/pause.js +36 -0
  56. package/dist/commands/release/resume.js +36 -0
  57. package/dist/commands/release/revoke.js +253 -0
  58. package/dist/commands/release/rollback.js +73 -0
  59. package/dist/commands/release/rollout.js +63 -0
  60. package/dist/commands/release/update.js +82 -0
  61. package/dist/commands/resource-file/write.js +76 -0
  62. package/dist/commands/signing-key/add.js +45 -0
  63. package/dist/commands/signing-key/create.js +84 -0
  64. package/dist/commands/signing-key/delete.js +67 -0
  65. package/dist/commands/signing-key/list.js +38 -0
  66. package/dist/commands/sso-provider/delete.js +32 -0
  67. package/dist/commands/sso-provider/get.js +63 -0
  68. package/dist/commands/sso-provider/set.js +161 -0
  69. package/dist/commands/sso-provider/verify.js +31 -0
  70. package/dist/commands/statistics/get.js +189 -0
  71. package/dist/commands/usage/get.js +31 -0
  72. package/dist/commands/usage/list.js +41 -0
  73. package/dist/commands/webhook/create.js +72 -0
  74. package/dist/commands/webhook/delete.js +32 -0
  75. package/dist/commands/webhook/get.js +34 -0
  76. package/dist/commands/webhook/list.js +40 -0
  77. package/dist/commands/webhook/test.js +32 -0
  78. package/dist/commands/webhook/update.js +56 -0
  79. package/dist/commands/webhook-delivery/list.js +63 -0
  80. package/dist/commands/webhook-delivery/redeliver.js +48 -0
  81. package/dist/commands/whoami.js +24 -0
  82. package/dist/completion/hotcodepush.bash +536 -0
  83. package/dist/completion/hotcodepush.fish +979 -0
  84. package/dist/completion/hotcodepush.zsh +1372 -0
  85. package/dist/config/consts.js +32 -0
  86. package/dist/index.js +5 -0
  87. package/dist/registry.js +83 -0
  88. package/dist/utils/api-client.js +17 -0
  89. package/dist/utils/audience.js +9 -0
  90. package/dist/utils/auth-client.js +35 -0
  91. package/dist/utils/boolean-flag.js +3 -0
  92. package/dist/utils/browser.js +6 -0
  93. package/dist/utils/build-step.js +104 -0
  94. package/dist/utils/bundle-files.js +80 -0
  95. package/dist/utils/bundle-resolution.js +46 -0
  96. package/dist/utils/channel-fields.js +130 -0
  97. package/dist/utils/cli.js +75 -0
  98. package/dist/utils/command-registry.js +79 -0
  99. package/dist/utils/completion/bash.js +48 -0
  100. package/dist/utils/completion/command-tree.js +72 -0
  101. package/dist/utils/completion/fish.js +44 -0
  102. package/dist/utils/completion/posix-case-arms.js +9 -0
  103. package/dist/utils/completion/posix-quote.js +4 -0
  104. package/dist/utils/completion/script-files.js +26 -0
  105. package/dist/utils/completion/zsh.js +72 -0
  106. package/dist/utils/compressed-files.js +31 -0
  107. package/dist/utils/credential.js +15 -0
  108. package/dist/utils/device-resolution.js +17 -0
  109. package/dist/utils/duration.js +16 -0
  110. package/dist/utils/environment.js +22 -0
  111. package/dist/utils/error-mapping.js +79 -0
  112. package/dist/utils/errors.js +266 -0
  113. package/dist/utils/fingerprint.js +76 -0
  114. package/dist/utils/flag-name.js +4 -0
  115. package/dist/utils/fleet.js +9 -0
  116. package/dist/utils/framework.js +74 -0
  117. package/dist/utils/frameworks/binary-create-step.js +70 -0
  118. package/dist/utils/frameworks/capacitor.js +150 -0
  119. package/dist/utils/frameworks/cordova.js +110 -0
  120. package/dist/utils/frameworks/expo.js +152 -0
  121. package/dist/utils/frameworks/index.js +13 -0
  122. package/dist/utils/frameworks/native-glue.js +10 -0
  123. package/dist/utils/frameworks/native-projects.js +27 -0
  124. package/dist/utils/frameworks/react-native-build.js +182 -0
  125. package/dist/utils/frameworks/react-native.js +127 -0
  126. package/dist/utils/frameworks/sdk-package.js +63 -0
  127. package/dist/utils/git-provenance.js +71 -0
  128. package/dist/utils/global-options.js +34 -0
  129. package/dist/utils/hosts.js +53 -0
  130. package/dist/utils/init-steps.js +51 -0
  131. package/dist/utils/json-file.js +28 -0
  132. package/dist/utils/member-resolution.js +49 -0
  133. package/dist/utils/native-project-edit.js +41 -0
  134. package/dist/utils/nullable-field.js +10 -0
  135. package/dist/utils/outcome.js +24 -0
  136. package/dist/utils/output.js +37 -0
  137. package/dist/utils/pack.js +20 -0
  138. package/dist/utils/package-json.js +5 -0
  139. package/dist/utils/package-manager.js +50 -0
  140. package/dist/utils/pagination.js +29 -0
  141. package/dist/utils/progress.js +23 -0
  142. package/dist/utils/progression.js +119 -0
  143. package/dist/utils/project-config.js +61 -0
  144. package/dist/utils/prompts.js +53 -0
  145. package/dist/utils/react-native-project.js +119 -0
  146. package/dist/utils/release-conditions.js +80 -0
  147. package/dist/utils/release-output.js +13 -0
  148. package/dist/utils/release-resolution.js +83 -0
  149. package/dist/utils/resource-file.js +81 -0
  150. package/dist/utils/resource-resolution.js +107 -0
  151. package/dist/utils/signing-private-key.js +105 -0
  152. package/dist/utils/spending-cap.js +16 -0
  153. package/dist/utils/telemetry.js +171 -0
  154. package/dist/utils/token-store.js +63 -0
  155. package/dist/utils/update-notice.js +67 -0
  156. package/dist/utils/upload.js +148 -0
  157. package/dist/utils/usage-month.js +14 -0
  158. package/dist/utils/user-config.js +46 -0
  159. package/dist/utils/webhook-resolution.js +47 -0
  160. package/dist/utils/xcode-project.js +181 -0
  161. package/package.json +11 -9
@@ -0,0 +1,107 @@
1
+ import { z } from 'zod';
2
+ import { PROJECT_CONFIG_FILE_NAME } from '../config/consts.js';
3
+ import { isInteractive } from './environment.js';
4
+ import { AmbiguousNameError, MissingParameterError, UnknownNameError, } from './errors.js';
5
+ import { fetchAllPages } from './pagination.js';
6
+ import { assertProjectConfigAppId, ID_SCHEMA, readProjectConfig, resolveProjectChannel, } from './project-config.js';
7
+ import { promptSelect } from './prompts.js';
8
+ export const channelOptionShape = {
9
+ channel: z
10
+ .string()
11
+ .optional()
12
+ .describe("The channel, by id or name; hotcodepush.json's channel by default."),
13
+ };
14
+ // A name is looked up in `--organization` when given, otherwise in every organization of the user.
15
+ export async function fetchAppId(hotCodePush, options, projectConfig) {
16
+ if (options.app !== undefined) {
17
+ return fetchResourceId('app', options.app, () => fetchAppsInScope(hotCodePush, options.organization));
18
+ }
19
+ if (projectConfig?.appId !== undefined) {
20
+ assertProjectConfigAppId(projectConfig.appId);
21
+ return projectConfig.appId;
22
+ }
23
+ if (!isInteractive(options)) {
24
+ throw new MissingParameterError('--app');
25
+ }
26
+ const organizationId = await fetchOrganizationId(hotCodePush, options);
27
+ return promptResourceId('app', await fetchApps(hotCodePush, organizationId), options);
28
+ }
29
+ export function fetchApps(hotCodePush, organizationId) {
30
+ return fetchAllPages(page => hotCodePush.organizations.apps.list({ organizationId, ...page }));
31
+ }
32
+ export async function fetchChannel(hotCodePush, options) {
33
+ const projectConfig = readProjectConfig(options.config);
34
+ const appId = await fetchAppId(hotCodePush, options, projectConfig);
35
+ const channelId = await fetchChannelId(hotCodePush, appId, options, projectConfig);
36
+ return hotCodePush.apps.channels.get({ appId, channelId });
37
+ }
38
+ // The channel of hotcodepush.json counts only when the app is the one of the file too.
39
+ export async function fetchChannelId(hotCodePush, appId, options, projectConfig) {
40
+ if (options.channel !== undefined) {
41
+ return fetchResourceId('channel', options.channel, () => fetchChannels(hotCodePush, appId));
42
+ }
43
+ if (projectConfig !== undefined && projectConfig.appId === appId) {
44
+ return fetchResourceId('channel', resolveProjectChannel(projectConfig), () => fetchChannels(hotCodePush, appId), PROJECT_CONFIG_FILE_NAME);
45
+ }
46
+ if (!isInteractive(options)) {
47
+ throw new MissingParameterError('--channel');
48
+ }
49
+ return promptResourceId('channel', await fetchChannels(hotCodePush, appId), options);
50
+ }
51
+ export function fetchChannels(hotCodePush, appId) {
52
+ return fetchAllPages(page => hotCodePush.apps.channels.list({ appId, ...page }));
53
+ }
54
+ export async function fetchOrganizationId(hotCodePush, options) {
55
+ if (options.organization !== undefined) {
56
+ return fetchResourceId('organization', options.organization, () => fetchOrganizations(hotCodePush));
57
+ }
58
+ const fetchedOrganizations = await fetchOrganizations(hotCodePush);
59
+ const [onlyOrganization] = fetchedOrganizations;
60
+ if (onlyOrganization !== undefined && fetchedOrganizations.length === 1) {
61
+ return onlyOrganization.id;
62
+ }
63
+ return promptResourceId('organization', fetchedOrganizations, options);
64
+ }
65
+ export function fetchOrganizations(hotCodePush) {
66
+ return fetchAllPages(page => hotCodePush.organizations.list(page));
67
+ }
68
+ // A UUID is an id, and anything else is a name looked up in the list.
69
+ export async function fetchResourceId(noun, reference, fetchResources, source = `--${noun}`) {
70
+ if (ID_SCHEMA.safeParse(reference).success) {
71
+ return reference;
72
+ }
73
+ return resolveNamedResource(noun, reference, await fetchResources(), source)
74
+ .id;
75
+ }
76
+ // An app with a signing key releases only signed bundles.
77
+ export async function fetchHasSigningKey(hotCodePush, appId) {
78
+ const { count } = await hotCodePush.apps.signingKeys.count({ appId });
79
+ return count > 0;
80
+ }
81
+ export function fetchSigningKeys(hotCodePush, appId) {
82
+ return fetchAllPages(page => hotCodePush.apps.signingKeys.list({ appId, ...page }));
83
+ }
84
+ export function promptResourceId(noun, resources, options) {
85
+ return promptSelect(`--${noun}`, `Which ${noun}?`, resources.map(({ id, name }) => ({ label: name, value: id })), options);
86
+ }
87
+ // Case-insensitive, as the API keeps app and channel names unique that way.
88
+ export function resolveNamedResource(noun, name, resources, source = `--${noun}`) {
89
+ const namedResources = resources.filter(resource => resource.name.toLowerCase() === name.toLowerCase());
90
+ const [namedResource] = namedResources;
91
+ if (namedResource === undefined) {
92
+ throw new UnknownNameError(noun, name, source);
93
+ }
94
+ if (namedResources.length > 1) {
95
+ throw new AmbiguousNameError(noun, name, source);
96
+ }
97
+ return namedResource;
98
+ }
99
+ async function fetchAppsInScope(hotCodePush, organization) {
100
+ const organizationIds = organization === undefined
101
+ ? (await fetchOrganizations(hotCodePush)).map(({ id }) => id)
102
+ : [
103
+ await fetchResourceId('organization', organization, () => fetchOrganizations(hotCodePush)),
104
+ ];
105
+ const fetchedApps = await Promise.all(organizationIds.map(organizationId => fetchApps(hotCodePush, organizationId)));
106
+ return fetchedApps.flat();
107
+ }
@@ -0,0 +1,105 @@
1
+ import { createPrivateKey } from 'node:crypto';
2
+ import { readFileSync, writeFileSync } from 'node:fs';
3
+ import { resolvePublicKeyOfPrivateKey, SIGNING_KEY_BITS_MINIMUM, } from '@hotcodepush/protocol';
4
+ import { PROJECT_CONFIG_FILE_NAME } from '../config/consts.js';
5
+ import { InvalidParameterError, SigningKeyUnavailableError } from './errors.js';
6
+ // Matches an encrypted PKCS #8 PEM, or a PKCS #1 PEM whose header names its cipher.
7
+ const ENCRYPTED_KEY_PATTERN = /ENCRYPTED PRIVATE KEY|Proc-Type:/;
8
+ // Matches the first and last PEM line of PKCS #8, or of PKCS #1 as the Expo tool writes it.
9
+ // They are dropped with all whitespace, so a key pasted on one line reads like the file.
10
+ const PEM_BOUNDARY_PATTERN = /-----(BEGIN|END) (RSA )?PRIVATE KEY-----/g;
11
+ const WHITESPACE_PATTERN = /\s/g;
12
+ // A private key without a listed public key stops the upload, rather than being ignored.
13
+ export async function readSigningKeyPair(publicKeys, privateKeyPath) {
14
+ const givenPrivateKey = readGivenPrivateKey(privateKeyPath);
15
+ if (givenPrivateKey === undefined) {
16
+ if (publicKeys.length > 0) {
17
+ throw new SigningKeyUnavailableError();
18
+ }
19
+ return null;
20
+ }
21
+ if (publicKeys.length === 0) {
22
+ throw new InvalidParameterError(`A private key is given from ${givenPrivateKey.source}, and ${PROJECT_CONFIG_FILE_NAME} lists no public key of the app.`, `Add its public key to publicKeys in ${PROJECT_CONFIG_FILE_NAME}, as "hotcodepush signing-key list --json" prints it, or give no private key.`);
23
+ }
24
+ const signingKeyPair = await resolveSigningKeyPair(givenPrivateKey);
25
+ if (!publicKeys.includes(signingKeyPair.publicKey)) {
26
+ throw new InvalidParameterError(`The private key from ${givenPrivateKey.source} belongs to no public key ${PROJECT_CONFIG_FILE_NAME} lists.`, `Give the private key of a public key ${PROJECT_CONFIG_FILE_NAME} lists, or make a new pair with "hotcodepush signing-key create".`);
27
+ }
28
+ return signingKeyPair;
29
+ }
30
+ // The public half is derived from the private key, so the two cannot mismatch.
31
+ export function readSigningKeyPairFile(privateKeyPath) {
32
+ return resolveSigningKeyPair({
33
+ source: '--private-key-path',
34
+ text: readPrivateKeyFile(privateKeyPath),
35
+ });
36
+ }
37
+ export function writeSigningPrivateKeyFile(filePath, privateKey) {
38
+ const pem = createPrivateKey({
39
+ format: 'der',
40
+ key: Buffer.from(privateKey, 'base64'),
41
+ type: 'pkcs8',
42
+ }).export({ format: 'pem', type: 'pkcs8' });
43
+ writeFileSync(filePath, pem, { flag: 'wx', mode: 0o600 });
44
+ }
45
+ function importPrivateKey(text) {
46
+ const key = Buffer.from(text.replace(PEM_BOUNDARY_PATTERN, '').replace(WHITESPACE_PATTERN, ''), 'base64');
47
+ let importError;
48
+ for (const type of resolvePrivateKeyEncodings(text)) {
49
+ try {
50
+ return createPrivateKey({ format: 'der', key, type });
51
+ }
52
+ catch (error) {
53
+ importError = error;
54
+ }
55
+ }
56
+ throw importError;
57
+ }
58
+ function readGivenPrivateKey(privateKeyPath) {
59
+ if (privateKeyPath !== undefined) {
60
+ return {
61
+ source: '--private-key-path',
62
+ text: readPrivateKeyFile(privateKeyPath),
63
+ };
64
+ }
65
+ // An empty variable counts as unset, since a CI renders a secret it does not hold as empty.
66
+ const variableText = process.env.HOTCODEPUSH_SIGNING_KEY;
67
+ return variableText
68
+ ? { source: 'HOTCODEPUSH_SIGNING_KEY', text: variableText }
69
+ : undefined;
70
+ }
71
+ function readPrivateKeyFile(filePath) {
72
+ try {
73
+ return readFileSync(filePath, 'utf8');
74
+ }
75
+ catch (error) {
76
+ throw new InvalidParameterError(`The file ${filePath} from --private-key-path cannot be read.`, 'Pass the path of the private key file, a PEM of PKCS #8 or PKCS #1.', error);
77
+ }
78
+ }
79
+ function resolvePrivateKeyEncodings(text) {
80
+ if (text.includes('BEGIN RSA PRIVATE KEY')) {
81
+ return ['pkcs1'];
82
+ }
83
+ if (text.includes('BEGIN PRIVATE KEY')) {
84
+ return ['pkcs8'];
85
+ }
86
+ return ['pkcs8', 'pkcs1'];
87
+ }
88
+ // The protocol signs with the base64 of the PKCS #8 DER.
89
+ async function resolveSigningKeyPair({ source, text, }) {
90
+ if (ENCRYPTED_KEY_PATTERN.test(text)) {
91
+ throw new InvalidParameterError(`The private key from ${source} is encrypted, which is not supported.`, 'Remove its passphrase first, as "openssl pkey -in <file> -out <new file>" does.');
92
+ }
93
+ try {
94
+ const privateKey = importPrivateKey(text)
95
+ .export({ format: 'der', type: 'pkcs8' })
96
+ .toString('base64');
97
+ return {
98
+ privateKey,
99
+ publicKey: await resolvePublicKeyOfPrivateKey(privateKey),
100
+ };
101
+ }
102
+ catch (error) {
103
+ throw new InvalidParameterError(`The key from ${source} is not an RSA private key of ${SIGNING_KEY_BITS_MINIMUM} bits or more.`, `Give an RSA private key of ${SIGNING_KEY_BITS_MINIMUM} bits or more, a PEM of PKCS #8 or PKCS #1.`, error);
104
+ }
105
+ }
@@ -0,0 +1,16 @@
1
+ import { z } from 'zod';
2
+ const CENTS_PER_DOLLAR = 100;
3
+ // The flag takes whole dollars, while the API counts cents and checks the range.
4
+ export const spendingCapShape = {
5
+ spendingCap: z.coerce
6
+ .number()
7
+ .int()
8
+ .optional()
9
+ .describe('The most the organization pays a month, in whole dollars from $5 to $10,000.'),
10
+ };
11
+ export function resolveCents(dollars) {
12
+ return dollars * CENTS_PER_DOLLAR;
13
+ }
14
+ export function resolveDollarText(cents) {
15
+ return `$${(cents / CENTS_PER_DOLLAR).toLocaleString('en-US')}`;
16
+ }
@@ -0,0 +1,171 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { hostname } from 'node:os';
3
+ import { basename } from 'node:path/posix';
4
+ import { setTimeout } from 'node:timers/promises';
5
+ import { PACKAGE_JSON, POSTHOG_HOST, POSTHOG_PROJECT_KEY, SENTRY_DSN, } from '../config/consts.js';
6
+ import { isCi } from './environment.js';
7
+ import { ReportedFailureError, UnexpectedError, UnexpectedResponseError, } from './errors.js';
8
+ import { ignoreUserConfigErrors, readUserConfig, writeUserConfig, } from './user-config.js';
9
+ const COMMAND_FINISHED_EVENT = 'cli.command_finished';
10
+ // Matches `at <function> (<file>:<line>:<column>)`, or the same without the function and parentheses.
11
+ const STACK_FRAME_PATTERN = /^\s*at (?:(.+?) \()?(.+?):(\d+):(\d+)\)?$/;
12
+ const TELEMETRY_NOTICE = 'HotCodePush collects anonymous usage and error data to improve the CLI; set HOTCODEPUSH_TELEMETRY_DISABLED=1 to opt out.';
13
+ // The longest telemetry may delay the exit, requests included.
14
+ const TELEMETRY_TIMEOUT_MS = 1500;
15
+ // Only the release build has keys, so every other build reports nothing.
16
+ export function isTelemetryEnabled() {
17
+ return ((POSTHOG_PROJECT_KEY !== '' || SENTRY_DSN !== '') &&
18
+ process.env.HOTCODEPUSH_TELEMETRY_DISABLED !== '1' &&
19
+ process.env.DO_NOT_TRACK !== '1');
20
+ }
21
+ export function printTelemetryNotice() {
22
+ if (!isTelemetryEnabled()) {
23
+ return;
24
+ }
25
+ ignoreUserConfigErrors(() => {
26
+ const userConfig = readUserConfig();
27
+ if (userConfig.telemetryNoticeShownAt !== undefined) {
28
+ return;
29
+ }
30
+ process.stderr.write(`${TELEMETRY_NOTICE}\n`);
31
+ writeUserConfig({
32
+ ...userConfig,
33
+ telemetryNoticeShownAt: new Date().toISOString(),
34
+ });
35
+ });
36
+ }
37
+ // Telemetry must never fail a command or hold up its exit for long.
38
+ export async function reportFinishedCommand(finishedCommand) {
39
+ if (!isTelemetryEnabled()) {
40
+ return;
41
+ }
42
+ await waitAtMost(Promise.allSettled([
43
+ sendCommandFinishedEvent(finishedCommand),
44
+ sendUnexpectedErrorEvent(finishedCommand),
45
+ ]), TELEMETRY_TIMEOUT_MS);
46
+ }
47
+ // Drops the message above the frames, since it can hold user data.
48
+ export function resolveScrubbedStack(stack) {
49
+ return stack
50
+ .split('\n')
51
+ .filter(line => /^\s*at /.test(line))
52
+ .map(line => {
53
+ const [, functionName, filePath, lineNumber, columnNumber] = STACK_FRAME_PATTERN.exec(line) ?? [];
54
+ if (filePath === undefined) {
55
+ return line.trim();
56
+ }
57
+ const location = `${resolveFramePath(filePath)}:${lineNumber}:${columnNumber}`;
58
+ return functionName === undefined
59
+ ? `at ${location}`
60
+ : `at ${functionName} (${location})`;
61
+ })
62
+ .join('\n');
63
+ }
64
+ // A local path never leaves the machine: a package file keeps the part from `node_modules` on, any other only its name.
65
+ function resolveFramePath(filePath) {
66
+ const normalizedPath = filePath
67
+ .replace(/^file:\/\//, '')
68
+ .replaceAll('\\', '/');
69
+ if (normalizedPath.startsWith('node:')) {
70
+ return normalizedPath;
71
+ }
72
+ const nodeModulesIndex = normalizedPath.lastIndexOf('node_modules/');
73
+ return nodeModulesIndex === -1
74
+ ? basename(normalizedPath)
75
+ : normalizedPath.slice(nodeModulesIndex);
76
+ }
77
+ // The host name is hashed, so it is never sent as it is.
78
+ function resolveMachineId() {
79
+ return createHash('sha256').update(hostname()).digest('hex');
80
+ }
81
+ // Imported lazily, since the framework modules load native-project parsers that a build without telemetry never needs.
82
+ async function resolveProjectFramework() {
83
+ try {
84
+ const [{ detectFramework }, { locateProjectConfigFile }] = await Promise.all([
85
+ import('./framework.js'),
86
+ import('./project-config.js'),
87
+ ]);
88
+ return detectFramework(locateProjectConfigFile(undefined).directoryPath);
89
+ }
90
+ catch {
91
+ return null;
92
+ }
93
+ }
94
+ // The event carries only these properties: no argument, no path and no project name.
95
+ async function sendCommandFinishedEvent({ cliError, commandName, durationMs, }) {
96
+ if (POSTHOG_PROJECT_KEY === '') {
97
+ return;
98
+ }
99
+ await fetch(`${POSTHOG_HOST}/capture/`, {
100
+ body: JSON.stringify({
101
+ api_key: POSTHOG_PROJECT_KEY,
102
+ distinct_id: resolveMachineId(),
103
+ event: COMMAND_FINISHED_EVENT,
104
+ properties: {
105
+ // Keeps the event anonymous: no location from the IP address and no person profile.
106
+ $geoip_disable: true,
107
+ $process_person_profile: false,
108
+ cli_version: PACKAGE_JSON.version,
109
+ command: commandName,
110
+ duration_ms: Math.round(durationMs),
111
+ framework: await resolveProjectFramework(),
112
+ is_ci: isCi(),
113
+ outcome: cliError === undefined ? 'succeeded' : 'failed',
114
+ source: 'cli',
115
+ ...(commandName === 'init' && {
116
+ stopped_step: cliError instanceof ReportedFailureError
117
+ ? (cliError.stoppedStep ?? null)
118
+ : null,
119
+ }),
120
+ },
121
+ timestamp: new Date().toISOString(),
122
+ }),
123
+ headers: { 'Content-Type': 'application/json' },
124
+ method: 'POST',
125
+ signal: AbortSignal.timeout(TELEMETRY_TIMEOUT_MS),
126
+ });
127
+ }
128
+ // Only our own bugs reach Sentry. Every other error comes from user input or an API answer.
129
+ async function sendUnexpectedErrorEvent({ cliError, commandName, }) {
130
+ if (SENTRY_DSN === '' ||
131
+ !(cliError instanceof UnexpectedError ||
132
+ cliError instanceof UnexpectedResponseError)) {
133
+ return;
134
+ }
135
+ const reportedError = cliError.cause instanceof Error ? cliError.cause : cliError;
136
+ const { host, pathname, username: publicKey } = new URL(SENTRY_DSN);
137
+ const eventId = randomUUID().replaceAll('-', '');
138
+ const sentAt = new Date().toISOString();
139
+ const event = {
140
+ event_id: eventId,
141
+ exception: {
142
+ values: [
143
+ {
144
+ type: reportedError.name,
145
+ value: resolveScrubbedStack(reportedError.stack ?? ''),
146
+ },
147
+ ],
148
+ },
149
+ platform: 'node',
150
+ release: `${PACKAGE_JSON.name}@${PACKAGE_JSON.version}`,
151
+ tags: { code: cliError.code, command: commandName },
152
+ timestamp: sentAt,
153
+ };
154
+ await fetch(`https://${host}/api${pathname}/envelope/`, {
155
+ body: [{ event_id: eventId, sent_at: sentAt }, { type: 'event' }, event]
156
+ .map(line => JSON.stringify(line))
157
+ .join('\n'),
158
+ headers: {
159
+ 'Content-Type': 'application/x-sentry-envelope',
160
+ 'X-Sentry-Auth': `Sentry sentry_version=7, sentry_key=${publicKey}, sentry_client=${PACKAGE_JSON.name}/${PACKAGE_JSON.version}`,
161
+ },
162
+ method: 'POST',
163
+ signal: AbortSignal.timeout(TELEMETRY_TIMEOUT_MS),
164
+ });
165
+ }
166
+ async function waitAtMost(promise, timeoutMs) {
167
+ await Promise.race([
168
+ promise.catch(() => undefined),
169
+ setTimeout(timeoutMs, undefined, { ref: false }),
170
+ ]);
171
+ }
@@ -0,0 +1,63 @@
1
+ import { Entry } from '@napi-rs/keyring';
2
+ import { KEYRING_ACCOUNT_NAME, KEYRING_SERVICE_NAME, } from '../config/consts.js';
3
+ import { readUserConfig, writeUserConfig } from './user-config.js';
4
+ // Set by the first keyring failure, such as a headless CI without a usable Secret Service.
5
+ // From then on only `config.json` is used, so a read never returns a stale keyring token after a failed write.
6
+ let hasKeyringFailed = false;
7
+ export function deleteToken() {
8
+ if (!hasKeyringFailed) {
9
+ try {
10
+ createKeyringEntry().deletePassword();
11
+ }
12
+ catch {
13
+ hasKeyringFailed = true;
14
+ }
15
+ }
16
+ deleteUserConfigToken();
17
+ }
18
+ export function isEnvironmentToken() {
19
+ return Boolean(process.env.HOTCODEPUSH_TOKEN);
20
+ }
21
+ export function readToken() {
22
+ return process.env.HOTCODEPUSH_TOKEN || readStoredToken();
23
+ }
24
+ // Ignores `HOTCODEPUSH_TOKEN`.
25
+ export function readStoredToken() {
26
+ return readKeyringToken() || readUserConfig().token;
27
+ }
28
+ export function writeToken(token) {
29
+ if (!hasKeyringFailed) {
30
+ try {
31
+ createKeyringEntry().setPassword(token);
32
+ deleteUserConfigToken();
33
+ return;
34
+ }
35
+ catch {
36
+ hasKeyringFailed = true;
37
+ }
38
+ }
39
+ writeUserConfig({ ...readUserConfig(), token });
40
+ }
41
+ function createKeyringEntry() {
42
+ return new Entry(KEYRING_SERVICE_NAME, KEYRING_ACCOUNT_NAME);
43
+ }
44
+ function deleteUserConfigToken() {
45
+ const userConfig = readUserConfig();
46
+ if (userConfig.token === undefined) {
47
+ return;
48
+ }
49
+ delete userConfig.token;
50
+ writeUserConfig(userConfig);
51
+ }
52
+ function readKeyringToken() {
53
+ if (hasKeyringFailed) {
54
+ return undefined;
55
+ }
56
+ try {
57
+ return createKeyringEntry().getPassword() ?? undefined;
58
+ }
59
+ catch {
60
+ hasKeyringFailed = true;
61
+ return undefined;
62
+ }
63
+ }
@@ -0,0 +1,67 @@
1
+ import { PACKAGE_JSON } from '../config/consts.js';
2
+ import { isCi } from './environment.js';
3
+ import { ignoreUserConfigErrors, readUserConfig, writeUserConfig, } from './user-config.js';
4
+ const LATEST_VERSION_URL = `https://registry.npmjs.org/${PACKAGE_JSON.name}/latest`;
5
+ const UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
6
+ const UPDATE_CHECK_TIMEOUT_MS = 1000;
7
+ // Matches major, minor and patch. A prerelease suffix is not compared.
8
+ const VERSION_CORE_PATTERN = /^\d+\.\d+\.\d+/;
9
+ export function isNewerVersion(candidateVersion, currentVersion) {
10
+ const candidateCore = VERSION_CORE_PATTERN.exec(candidateVersion)?.[0];
11
+ const currentCore = VERSION_CORE_PATTERN.exec(currentVersion)?.[0];
12
+ if (candidateCore === undefined || currentCore === undefined) {
13
+ return false;
14
+ }
15
+ return candidateCore.localeCompare(currentCore, 'en', { numeric: true }) > 0;
16
+ }
17
+ export function printUpdateNotice() {
18
+ if (!isUpdateNoticeEnabled()) {
19
+ return;
20
+ }
21
+ ignoreUserConfigErrors(() => {
22
+ const { latestKnownVersion } = readUserConfig();
23
+ if (latestKnownVersion !== undefined &&
24
+ isNewerVersion(latestKnownVersion, PACKAGE_JSON.version)) {
25
+ process.stderr.write(`HotCodePush CLI ${latestKnownVersion} is available; you run ${PACKAGE_JSON.version}.\n`);
26
+ }
27
+ });
28
+ }
29
+ // A failed check also waits a day, so an offline machine is not asked on every command.
30
+ export async function refreshLatestKnownVersion() {
31
+ if (!isUpdateNoticeEnabled()) {
32
+ return;
33
+ }
34
+ const isCheckedRecently = ignoreUserConfigErrors(() => {
35
+ const { lastUpdateCheckAt } = readUserConfig();
36
+ return (lastUpdateCheckAt !== undefined &&
37
+ Date.now() - Date.parse(lastUpdateCheckAt) < UPDATE_CHECK_INTERVAL_MS);
38
+ });
39
+ if (isCheckedRecently !== false) {
40
+ return;
41
+ }
42
+ const latestVersion = await fetchLatestVersion();
43
+ ignoreUserConfigErrors(() => {
44
+ // Read again, since the command may have written config.json during the request.
45
+ writeUserConfig({
46
+ ...readUserConfig(),
47
+ lastUpdateCheckAt: new Date().toISOString(),
48
+ ...(latestVersion !== undefined && { latestKnownVersion: latestVersion }),
49
+ });
50
+ });
51
+ }
52
+ async function fetchLatestVersion() {
53
+ try {
54
+ const response = await fetch(LATEST_VERSION_URL, {
55
+ signal: AbortSignal.timeout(UPDATE_CHECK_TIMEOUT_MS),
56
+ });
57
+ const { version } = (await response.json());
58
+ return typeof version === 'string' ? version : undefined;
59
+ }
60
+ catch {
61
+ return undefined;
62
+ }
63
+ }
64
+ // Nobody updates from a CI log line.
65
+ function isUpdateNoticeEnabled() {
66
+ return !isCi() && process.env.HOTCODEPUSH_UPDATE_NOTICE_DISABLED !== '1';
67
+ }
@@ -0,0 +1,148 @@
1
+ import { openAsBlob } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { HotCodePushError } from '@hotcodepush/node';
4
+ import { BUNDLE_BYTES_LIMIT, signManifest } from '@hotcodepush/protocol';
5
+ import { collectBundleFiles } from './bundle-files.js';
6
+ import { compressFiles, withTemporaryDirectory } from './compressed-files.js';
7
+ import { BundleTooLargeError } from './errors.js';
8
+ import { omitNativeGlue } from './frameworks/native-glue.js';
9
+ import { resolveQuantityText } from './output.js';
10
+ import { writePack } from './pack.js';
11
+ import { fetchAllPages } from './pagination.js';
12
+ import { resolveByteText } from './progress.js';
13
+ // The most the bundle files list takes, more than any other list.
14
+ const BUNDLE_FILE_PAGE_LIMIT = 1000;
15
+ export function assertWithinBundleBytesLimit(files) {
16
+ const largeFile = files.find(file => file.sizeBytes > BUNDLE_BYTES_LIMIT);
17
+ if (largeFile !== undefined) {
18
+ throw new BundleTooLargeError(`The file ${largeFile.path}`, BUNDLE_BYTES_LIMIT);
19
+ }
20
+ const totalBytes = files.reduce((sum, file) => sum + file.sizeBytes, 0);
21
+ if (totalBytes > BUNDLE_BYTES_LIMIT) {
22
+ throw new BundleTooLargeError('The bundle', BUNDLE_BYTES_LIMIT);
23
+ }
24
+ }
25
+ export async function collectUploadFiles({ directoryPath, nativeGluePaths = [], reporter, }) {
26
+ const collectedFiles = await collectBundleFiles(directoryPath);
27
+ const files = omitNativeGlue(collectedFiles, nativeGluePaths);
28
+ const omittedFileCount = collectedFiles.length - files.length;
29
+ if (omittedFileCount > 0) {
30
+ reporter.report(`Left out ${resolveQuantityText(omittedFileCount, 'file')} of the native glue the binary carries: ${nativeGluePaths.join(', ')}.`);
31
+ }
32
+ return files;
33
+ }
34
+ export async function uploadBundle(hotCodePush, { appId, bundleVersion, directoryPath, fingerprint, gitProvenance, nativeGluePaths, platforms, reporter, signingPrivateKey, }) {
35
+ reporter.report(`Hashing the files under ${directoryPath}…`);
36
+ const files = await collectUploadFiles({
37
+ directoryPath,
38
+ nativeGluePaths,
39
+ reporter,
40
+ });
41
+ assertWithinBundleBytesLimit(files);
42
+ return withTemporaryDirectory(async (temporaryDirectoryPath) => {
43
+ const compressedFiles = await compressFiles(files, temporaryDirectoryPath);
44
+ const manifest = buildManifestToSign({
45
+ appId,
46
+ bundleVersion,
47
+ files,
48
+ fingerprint,
49
+ platforms,
50
+ });
51
+ const signature = signingPrivateKey === null
52
+ ? null
53
+ : (await signManifest(manifest, signingPrivateKey)).signature;
54
+ if (signature !== null) {
55
+ reporter.report(`Signed the manifest with key ${signature.keyId}.`);
56
+ }
57
+ const createdBundle = await hotCodePush.apps.bundles.create({
58
+ appId,
59
+ files: manifest.files,
60
+ fingerprint,
61
+ platforms: manifest.platforms,
62
+ signature,
63
+ version: bundleVersion,
64
+ ...gitProvenance,
65
+ });
66
+ const missingSha256s = await fetchMissingSha256s(hotCodePush, {
67
+ appId,
68
+ bundleId: createdBundle.id,
69
+ });
70
+ reporter.report(`Uploading ${missingSha256s.length} of ${files.length} files the app lacks…`);
71
+ const uploadedFiles = await uploadMissingFiles(hotCodePush, appId, missingSha256s, compressedFiles, reporter);
72
+ const packFilePath = join(temporaryDirectoryPath, 'pack');
73
+ await writePack([...compressedFiles.values()], packFilePath);
74
+ reporter.report('Uploading the full pack…');
75
+ await uploadPack(hotCodePush, { appId, bundleId: createdBundle.id }, packFilePath);
76
+ const completedBundle = await hotCodePush.apps.bundles.complete({
77
+ appId,
78
+ bundleId: createdBundle.id,
79
+ });
80
+ return { bundle: completedBundle, upload: uploadedFiles };
81
+ });
82
+ }
83
+ // The API rebuilds this manifest before it checks the signature, so the order must match.
84
+ // Canonical JSON leaves array order to the writer: files sort by path and platforms by UTF-16 code units.
85
+ export function buildManifestToSign({ appId, bundleVersion, files, fingerprint, platforms, }) {
86
+ return {
87
+ appId,
88
+ bundleVersion,
89
+ files: files
90
+ .map(({ path, sha256, sizeBytes }) => ({ path, sha256, sizeBytes }))
91
+ .sort((left, right) => compareCodeUnits(left.path, right.path)),
92
+ fingerprint,
93
+ platforms: [...platforms].sort(compareCodeUnits),
94
+ };
95
+ }
96
+ export async function uploadMissingFiles(hotCodePush, appId, missingSha256s, compressedFiles, reporter) {
97
+ let sizeBytes = 0;
98
+ for (const [index, sha256] of missingSha256s.entries()) {
99
+ const compressedFile = compressedFiles.get(sha256);
100
+ if (compressedFile === undefined) {
101
+ throw new Error(`The app lacks ${sha256}, which is not in the build.`);
102
+ }
103
+ reporter.report(` ${index + 1}/${missingSha256s.length} ${sha256.slice(0, 12)} (${resolveByteText(compressedFile.sizeBytes)})`);
104
+ await uploadFile(hotCodePush, appId, compressedFile);
105
+ sizeBytes += compressedFile.sizeBytes;
106
+ }
107
+ return { fileCount: missingSha256s.length, sizeBytes };
108
+ }
109
+ export async function uploadPack(hotCodePush, pathParameters, packFilePath) {
110
+ await hotCodePush.apps.bundles.pack.upload({
111
+ ...pathParameters,
112
+ body: await openAsBlob(packFilePath),
113
+ });
114
+ }
115
+ // `upload_incomplete` names the missing hashes in its details.
116
+ export function resolveMissingSha256s(error) {
117
+ if (!(error instanceof HotCodePushError) ||
118
+ error.code !== 'upload_incomplete') {
119
+ return undefined;
120
+ }
121
+ const details = error.details;
122
+ const missingSha256s = details?.missingSha256s;
123
+ return Array.isArray(missingSha256s)
124
+ ? missingSha256s.filter((sha256) => typeof sha256 === 'string')
125
+ : [];
126
+ }
127
+ function compareCodeUnits(left, right) {
128
+ if (left === right) {
129
+ return 0;
130
+ }
131
+ return left < right ? -1 : 1;
132
+ }
133
+ // Several paths can share a hash, so each is listed once.
134
+ async function fetchMissingSha256s(hotCodePush, pathParameters) {
135
+ const missingFiles = await fetchAllPages(page => hotCodePush.apps.bundles.files.list({
136
+ ...pathParameters,
137
+ isStored: false,
138
+ ...page,
139
+ }), BUNDLE_FILE_PAGE_LIMIT);
140
+ return [...new Set(missingFiles.map(({ sha256 }) => sha256))];
141
+ }
142
+ async function uploadFile(hotCodePush, appId, { compressedFilePath, sha256 }) {
143
+ await hotCodePush.apps.files.upload({
144
+ appId,
145
+ body: await openAsBlob(compressedFilePath),
146
+ sha256,
147
+ });
148
+ }