hotcodepush 0.0.1 → 0.2.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 (165) 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 +191 -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 +177 -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 +37 -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 +364 -0
  32. package/dist/commands/fingerprint/diff.js +134 -0
  33. package/dist/commands/fingerprint.js +42 -0
  34. package/dist/commands/init.js +493 -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 +36 -0
  41. package/dist/commands/logout.js +81 -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 +254 -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 +78 -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/authentication.js +60 -0
  92. package/dist/utils/boolean-flag.js +3 -0
  93. package/dist/utils/browser.js +6 -0
  94. package/dist/utils/build-step.js +104 -0
  95. package/dist/utils/bundle-files.js +80 -0
  96. package/dist/utils/bundle-resolution.js +46 -0
  97. package/dist/utils/channel-fields.js +130 -0
  98. package/dist/utils/cli.js +80 -0
  99. package/dist/utils/command-registry.js +79 -0
  100. package/dist/utils/command.js +4 -0
  101. package/dist/utils/completion/bash.js +48 -0
  102. package/dist/utils/completion/command-tree.js +72 -0
  103. package/dist/utils/completion/fish.js +44 -0
  104. package/dist/utils/completion/posix-case-arms.js +9 -0
  105. package/dist/utils/completion/posix-quote.js +4 -0
  106. package/dist/utils/completion/script-files.js +26 -0
  107. package/dist/utils/completion/zsh.js +72 -0
  108. package/dist/utils/compressed-files.js +31 -0
  109. package/dist/utils/credential.js +15 -0
  110. package/dist/utils/device-resolution.js +17 -0
  111. package/dist/utils/diagnostics.js +20 -0
  112. package/dist/utils/duration.js +16 -0
  113. package/dist/utils/environment.js +22 -0
  114. package/dist/utils/error-mapping.js +75 -0
  115. package/dist/utils/errors.js +266 -0
  116. package/dist/utils/fingerprint.js +76 -0
  117. package/dist/utils/flag-name.js +4 -0
  118. package/dist/utils/fleet.js +9 -0
  119. package/dist/utils/framework.js +74 -0
  120. package/dist/utils/frameworks/binary-create-step.js +70 -0
  121. package/dist/utils/frameworks/capacitor.js +150 -0
  122. package/dist/utils/frameworks/cordova.js +110 -0
  123. package/dist/utils/frameworks/expo.js +152 -0
  124. package/dist/utils/frameworks/index.js +13 -0
  125. package/dist/utils/frameworks/native-glue.js +10 -0
  126. package/dist/utils/frameworks/native-projects.js +27 -0
  127. package/dist/utils/frameworks/react-native-build.js +182 -0
  128. package/dist/utils/frameworks/react-native.js +127 -0
  129. package/dist/utils/frameworks/sdk-package.js +63 -0
  130. package/dist/utils/git-provenance.js +71 -0
  131. package/dist/utils/global-options.js +34 -0
  132. package/dist/utils/hosts.js +53 -0
  133. package/dist/utils/init-steps.js +51 -0
  134. package/dist/utils/json-file.js +28 -0
  135. package/dist/utils/login.js +153 -0
  136. package/dist/utils/member-resolution.js +49 -0
  137. package/dist/utils/native-project-edit.js +41 -0
  138. package/dist/utils/nullable-field.js +10 -0
  139. package/dist/utils/outcome.js +24 -0
  140. package/dist/utils/output.js +38 -0
  141. package/dist/utils/pack.js +20 -0
  142. package/dist/utils/package-json.js +5 -0
  143. package/dist/utils/package-manager.js +50 -0
  144. package/dist/utils/pagination.js +29 -0
  145. package/dist/utils/progress.js +23 -0
  146. package/dist/utils/progression.js +119 -0
  147. package/dist/utils/project-config.js +61 -0
  148. package/dist/utils/prompts.js +53 -0
  149. package/dist/utils/react-native-project.js +119 -0
  150. package/dist/utils/release-conditions.js +79 -0
  151. package/dist/utils/release-output.js +13 -0
  152. package/dist/utils/release-resolution.js +83 -0
  153. package/dist/utils/resource-file.js +81 -0
  154. package/dist/utils/resource-resolution.js +107 -0
  155. package/dist/utils/signing-private-key.js +105 -0
  156. package/dist/utils/spending-cap.js +16 -0
  157. package/dist/utils/telemetry.js +172 -0
  158. package/dist/utils/token-store.js +63 -0
  159. package/dist/utils/update-notice.js +68 -0
  160. package/dist/utils/upload.js +148 -0
  161. package/dist/utils/usage-month.js +14 -0
  162. package/dist/utils/user-config.js +54 -0
  163. package/dist/utils/webhook-resolution.js +47 -0
  164. package/dist/utils/xcode-project.js +181 -0
  165. package/package.json +11 -9
@@ -0,0 +1,81 @@
1
+ import { createPublicKey } from 'node:crypto';
2
+ import { mkdirSync, writeFileSync } from 'node:fs';
3
+ import { dirname } from 'node:path';
4
+ import { ConfigurationSchema, resolveSigningKeyId, SIGNING_KEY_BITS_MINIMUM, SigningPublicKeySchema, } from '@hotcodepush/protocol';
5
+ import { PROJECT_CONFIG_FILE_NAME } from '../config/consts.js';
6
+ import { InvalidParameterError } from './errors.js';
7
+ // The manifest is the unsigned bundle manifest without patches, whether or not a binary was created.
8
+ // The public keys take the form that the platform API imports.
9
+ export function buildResourceFile({ builtAt, channelId, embeddedBundle, fingerprint, hosts, platform, projectConfig, }) {
10
+ return ConfigurationSchema.parse({
11
+ ...omitProjectOnlyKeys(projectConfig),
12
+ builtAt,
13
+ channelId,
14
+ embeddedBundleId: embeddedBundle?.id ?? null,
15
+ embeddedBundleManifest: embeddedBundle === null
16
+ ? null
17
+ : {
18
+ appId: projectConfig.appId,
19
+ bundleVersion: embeddedBundle.bundleVersion,
20
+ files: embeddedBundle.files.map(({ path, sha256, sizeBytes }) => ({
21
+ path,
22
+ sha256,
23
+ sizeBytes,
24
+ })),
25
+ fingerprint,
26
+ keyId: null,
27
+ platforms: [platform],
28
+ },
29
+ fingerprint,
30
+ publicKeys: (projectConfig.publicKeys ?? []).map(publicKey => resolveDevicePublicKey(publicKey, platform)),
31
+ ...(hosts.filesBaseUrl === undefined
32
+ ? {}
33
+ : { filesBaseUrl: hosts.filesBaseUrl }),
34
+ ...(hosts.updatesBaseUrl === undefined
35
+ ? {}
36
+ : { updatesBaseUrl: hosts.updatesBaseUrl }),
37
+ });
38
+ }
39
+ export function writeResourceFile(filePath, configuration) {
40
+ mkdirSync(dirname(filePath), { recursive: true });
41
+ writeFileSync(filePath, `${JSON.stringify(configuration, null, 2)}\n`);
42
+ }
43
+ // The device imports the DER with its platform API and handles no ASN.1 itself.
44
+ // iOS `SecKeyCreateWithData` takes PKCS #1 and Android `X509EncodedKeySpec` takes SPKI.
45
+ function resolveDevicePublicKey(publicKey, platform) {
46
+ if (!SigningPublicKeySchema.safeParse(publicKey).success) {
47
+ throw buildUnusablePublicKeyError(publicKey);
48
+ }
49
+ let keyObject;
50
+ try {
51
+ keyObject = createPublicKey({
52
+ format: 'der',
53
+ key: Buffer.from(publicKey.slice(publicKey.indexOf(':') + 1), 'base64'),
54
+ type: 'spki',
55
+ });
56
+ }
57
+ catch (error) {
58
+ throw buildUnusablePublicKeyError(publicKey, error);
59
+ }
60
+ if (keyObject.asymmetricKeyType !== 'rsa' ||
61
+ (keyObject.asymmetricKeyDetails?.modulusLength ?? 0) <
62
+ SIGNING_KEY_BITS_MINIMUM) {
63
+ throw buildUnusablePublicKeyError(publicKey);
64
+ }
65
+ return {
66
+ der: keyObject
67
+ .export({ format: 'der', type: platform === 'ios' ? 'pkcs1' : 'spki' })
68
+ .toString('base64'),
69
+ keyId: resolveSigningKeyId(publicKey),
70
+ };
71
+ }
72
+ function buildUnusablePublicKeyError(publicKey, cause) {
73
+ return new InvalidParameterError(`The publicKeys in ${PROJECT_CONFIG_FILE_NAME} list ${publicKey.slice(0, 32)}…, which is not an rsa-v1_5-sha256 key of ${SIGNING_KEY_BITS_MINIMUM} bits or more.`, 'Replace it with the public key "hotcodepush signing-key create" writes.', cause);
74
+ }
75
+ // The resolved id replaces the channel, and only the CLI reads the fingerprint paths.
76
+ function omitProjectOnlyKeys(projectConfig) {
77
+ const configuration = { ...projectConfig };
78
+ delete configuration.channel;
79
+ delete configuration.extraFingerprintPaths;
80
+ return configuration;
81
+ }
@@ -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. Defaults to hotcodepush.json's channel."),
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,172 @@
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 { printNotice } from './diagnostics.js';
7
+ import { isCi } from './environment.js';
8
+ import { ReportedFailureError, UnexpectedError, UnexpectedResponseError, } from './errors.js';
9
+ import { ignoreUserConfigErrors, readUserConfig, writeUserConfig, } from './user-config.js';
10
+ const COMMAND_FINISHED_EVENT = 'cli.command_finished';
11
+ // Matches `at <function> (<file>:<line>:<column>)`, or the same without the function and parentheses.
12
+ const STACK_FRAME_PATTERN = /^\s*at (?:(.+?) \()?(.+?):(\d+):(\d+)\)?$/;
13
+ const TELEMETRY_NOTICE = 'HotCodePush collects anonymous usage and error data to improve the CLI. Set HOTCODEPUSH_TELEMETRY_DISABLED=1 to opt out.';
14
+ // The longest telemetry may delay the exit, requests included.
15
+ const TELEMETRY_TIMEOUT_MS = 1500;
16
+ // Only the release build has keys, so every other build reports nothing.
17
+ export function isTelemetryEnabled() {
18
+ return ((POSTHOG_PROJECT_KEY !== '' || SENTRY_DSN !== '') &&
19
+ process.env.HOTCODEPUSH_TELEMETRY_DISABLED !== '1' &&
20
+ process.env.DO_NOT_TRACK !== '1');
21
+ }
22
+ export function printTelemetryNotice() {
23
+ if (!isTelemetryEnabled()) {
24
+ return;
25
+ }
26
+ ignoreUserConfigErrors(() => {
27
+ const userConfig = readUserConfig();
28
+ if (userConfig.telemetryNoticeShownAt !== undefined) {
29
+ return;
30
+ }
31
+ printNotice(TELEMETRY_NOTICE);
32
+ writeUserConfig({
33
+ ...userConfig,
34
+ telemetryNoticeShownAt: new Date().toISOString(),
35
+ });
36
+ });
37
+ }
38
+ // Telemetry must never fail a command or hold up its exit for long.
39
+ export async function reportFinishedCommand(finishedCommand) {
40
+ if (!isTelemetryEnabled()) {
41
+ return;
42
+ }
43
+ await waitAtMost(Promise.allSettled([
44
+ sendCommandFinishedEvent(finishedCommand),
45
+ sendUnexpectedErrorEvent(finishedCommand),
46
+ ]), TELEMETRY_TIMEOUT_MS);
47
+ }
48
+ // Drops the message above the frames, since it can hold user data.
49
+ export function resolveScrubbedStack(stack) {
50
+ return stack
51
+ .split('\n')
52
+ .filter(line => /^\s*at /.test(line))
53
+ .map(line => {
54
+ const [, functionName, filePath, lineNumber, columnNumber] = STACK_FRAME_PATTERN.exec(line) ?? [];
55
+ if (filePath === undefined) {
56
+ return line.trim();
57
+ }
58
+ const location = `${resolveFramePath(filePath)}:${lineNumber}:${columnNumber}`;
59
+ return functionName === undefined
60
+ ? `at ${location}`
61
+ : `at ${functionName} (${location})`;
62
+ })
63
+ .join('\n');
64
+ }
65
+ // A local path never leaves the machine: a package file keeps the part from `node_modules` on, any other only its name.
66
+ function resolveFramePath(filePath) {
67
+ const normalizedPath = filePath
68
+ .replace(/^file:\/\//, '')
69
+ .replaceAll('\\', '/');
70
+ if (normalizedPath.startsWith('node:')) {
71
+ return normalizedPath;
72
+ }
73
+ const nodeModulesIndex = normalizedPath.lastIndexOf('node_modules/');
74
+ return nodeModulesIndex === -1
75
+ ? basename(normalizedPath)
76
+ : normalizedPath.slice(nodeModulesIndex);
77
+ }
78
+ // The host name is hashed, so it is never sent as it is.
79
+ function resolveMachineId() {
80
+ return createHash('sha256').update(hostname()).digest('hex');
81
+ }
82
+ // Imported lazily, since the framework modules load native-project parsers that a build without telemetry never needs.
83
+ async function resolveProjectFramework() {
84
+ try {
85
+ const [{ detectFramework }, { locateProjectConfigFile }] = await Promise.all([
86
+ import('./framework.js'),
87
+ import('./project-config.js'),
88
+ ]);
89
+ return detectFramework(locateProjectConfigFile(undefined).directoryPath);
90
+ }
91
+ catch {
92
+ return null;
93
+ }
94
+ }
95
+ // The event carries only these properties: no argument, no path and no project name.
96
+ async function sendCommandFinishedEvent({ cliError, commandName, durationMs, }) {
97
+ if (POSTHOG_PROJECT_KEY === '') {
98
+ return;
99
+ }
100
+ await fetch(`${POSTHOG_HOST}/capture/`, {
101
+ body: JSON.stringify({
102
+ api_key: POSTHOG_PROJECT_KEY,
103
+ distinct_id: resolveMachineId(),
104
+ event: COMMAND_FINISHED_EVENT,
105
+ properties: {
106
+ // Keeps the event anonymous: no location from the IP address and no person profile.
107
+ $geoip_disable: true,
108
+ $process_person_profile: false,
109
+ cli_version: PACKAGE_JSON.version,
110
+ command: commandName,
111
+ duration_ms: Math.round(durationMs),
112
+ framework: await resolveProjectFramework(),
113
+ is_ci: isCi(),
114
+ outcome: cliError === undefined ? 'succeeded' : 'failed',
115
+ source: 'cli',
116
+ ...(commandName === 'init' && {
117
+ stopped_step: cliError instanceof ReportedFailureError
118
+ ? (cliError.stoppedStep ?? null)
119
+ : null,
120
+ }),
121
+ },
122
+ timestamp: new Date().toISOString(),
123
+ }),
124
+ headers: { 'Content-Type': 'application/json' },
125
+ method: 'POST',
126
+ signal: AbortSignal.timeout(TELEMETRY_TIMEOUT_MS),
127
+ });
128
+ }
129
+ // Only our own bugs reach Sentry. Every other error comes from user input or an API answer.
130
+ async function sendUnexpectedErrorEvent({ cliError, commandName, }) {
131
+ if (SENTRY_DSN === '' ||
132
+ !(cliError instanceof UnexpectedError ||
133
+ cliError instanceof UnexpectedResponseError)) {
134
+ return;
135
+ }
136
+ const reportedError = cliError.cause instanceof Error ? cliError.cause : cliError;
137
+ const { host, pathname, username: publicKey } = new URL(SENTRY_DSN);
138
+ const eventId = randomUUID().replaceAll('-', '');
139
+ const sentAt = new Date().toISOString();
140
+ const event = {
141
+ event_id: eventId,
142
+ exception: {
143
+ values: [
144
+ {
145
+ type: reportedError.name,
146
+ value: resolveScrubbedStack(reportedError.stack ?? ''),
147
+ },
148
+ ],
149
+ },
150
+ platform: 'node',
151
+ release: `${PACKAGE_JSON.name}@${PACKAGE_JSON.version}`,
152
+ tags: { code: cliError.code, command: commandName },
153
+ timestamp: sentAt,
154
+ };
155
+ await fetch(`https://${host}/api${pathname}/envelope/`, {
156
+ body: [{ event_id: eventId, sent_at: sentAt }, { type: 'event' }, event]
157
+ .map(line => JSON.stringify(line))
158
+ .join('\n'),
159
+ headers: {
160
+ 'Content-Type': 'application/x-sentry-envelope',
161
+ 'X-Sentry-Auth': `Sentry sentry_version=7, sentry_key=${publicKey}, sentry_client=${PACKAGE_JSON.name}/${PACKAGE_JSON.version}`,
162
+ },
163
+ method: 'POST',
164
+ signal: AbortSignal.timeout(TELEMETRY_TIMEOUT_MS),
165
+ });
166
+ }
167
+ async function waitAtMost(promise, timeoutMs) {
168
+ await Promise.race([
169
+ promise.catch(() => undefined),
170
+ setTimeout(timeoutMs, undefined, { ref: false }),
171
+ ]);
172
+ }
@@ -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,68 @@
1
+ import { PACKAGE_JSON } from '../config/consts.js';
2
+ import { printNotice } from './diagnostics.js';
3
+ import { isCi } from './environment.js';
4
+ import { ignoreUserConfigErrors, readUserConfig, writeUserConfig, } from './user-config.js';
5
+ const LATEST_VERSION_URL = `https://registry.npmjs.org/${PACKAGE_JSON.name}/latest`;
6
+ const UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
7
+ const UPDATE_CHECK_TIMEOUT_MS = 1000;
8
+ // Matches major, minor and patch. A prerelease suffix is not compared.
9
+ const VERSION_CORE_PATTERN = /^\d+\.\d+\.\d+/;
10
+ export function isNewerVersion(candidateVersion, currentVersion) {
11
+ const candidateCore = VERSION_CORE_PATTERN.exec(candidateVersion)?.[0];
12
+ const currentCore = VERSION_CORE_PATTERN.exec(currentVersion)?.[0];
13
+ if (candidateCore === undefined || currentCore === undefined) {
14
+ return false;
15
+ }
16
+ return candidateCore.localeCompare(currentCore, 'en', { numeric: true }) > 0;
17
+ }
18
+ export function printUpdateNotice() {
19
+ if (!isUpdateNoticeEnabled()) {
20
+ return;
21
+ }
22
+ ignoreUserConfigErrors(() => {
23
+ const { latestKnownVersion } = readUserConfig();
24
+ if (latestKnownVersion !== undefined &&
25
+ isNewerVersion(latestKnownVersion, PACKAGE_JSON.version)) {
26
+ printNotice(`HotCodePush CLI ${latestKnownVersion} is available. You run ${PACKAGE_JSON.version}.`);
27
+ }
28
+ });
29
+ }
30
+ // A failed check also waits a day, so an offline machine is not asked on every command.
31
+ export async function refreshLatestKnownVersion() {
32
+ if (!isUpdateNoticeEnabled()) {
33
+ return;
34
+ }
35
+ const isCheckedRecently = ignoreUserConfigErrors(() => {
36
+ const { lastUpdateCheckAt } = readUserConfig();
37
+ return (lastUpdateCheckAt !== undefined &&
38
+ Date.now() - Date.parse(lastUpdateCheckAt) < UPDATE_CHECK_INTERVAL_MS);
39
+ });
40
+ if (isCheckedRecently !== false) {
41
+ return;
42
+ }
43
+ const latestVersion = await fetchLatestVersion();
44
+ ignoreUserConfigErrors(() => {
45
+ // Read again, since the command may have written config.json during the request.
46
+ writeUserConfig({
47
+ ...readUserConfig(),
48
+ lastUpdateCheckAt: new Date().toISOString(),
49
+ ...(latestVersion !== undefined && { latestKnownVersion: latestVersion }),
50
+ });
51
+ });
52
+ }
53
+ async function fetchLatestVersion() {
54
+ try {
55
+ const response = await fetch(LATEST_VERSION_URL, {
56
+ signal: AbortSignal.timeout(UPDATE_CHECK_TIMEOUT_MS),
57
+ });
58
+ const { version } = (await response.json());
59
+ return typeof version === 'string' ? version : undefined;
60
+ }
61
+ catch {
62
+ return undefined;
63
+ }
64
+ }
65
+ // Nobody updates from a CI log line.
66
+ function isUpdateNoticeEnabled() {
67
+ return !isCi() && process.env.HOTCODEPUSH_UPDATE_NOTICE_DISABLED !== '1';
68
+ }