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.
- package/README.md +3 -3
- package/dist/commands/app/create.js +49 -0
- package/dist/commands/app/delete.js +34 -0
- package/dist/commands/app/get.js +33 -0
- package/dist/commands/app/list.js +38 -0
- package/dist/commands/app/transfer.js +58 -0
- package/dist/commands/app/update.js +34 -0
- package/dist/commands/audience/get.js +69 -0
- package/dist/commands/audit-log/list.js +82 -0
- package/dist/commands/binary/create.js +191 -0
- package/dist/commands/binary/get.js +56 -0
- package/dist/commands/binary/list.js +58 -0
- package/dist/commands/bundle/delete.js +35 -0
- package/dist/commands/bundle/get.js +63 -0
- package/dist/commands/bundle/list.js +66 -0
- package/dist/commands/bundle/upload.js +177 -0
- package/dist/commands/channel/create.js +59 -0
- package/dist/commands/channel/delete.js +68 -0
- package/dist/commands/channel/get.js +45 -0
- package/dist/commands/channel/list.js +39 -0
- package/dist/commands/channel/pause.js +32 -0
- package/dist/commands/channel/resume.js +32 -0
- package/dist/commands/channel/update.js +40 -0
- package/dist/commands/checkout/create.js +25 -0
- package/dist/commands/completion.js +37 -0
- package/dist/commands/customer-portal-session/create.js +25 -0
- package/dist/commands/device/delete.js +29 -0
- package/dist/commands/device/get.js +60 -0
- package/dist/commands/device/list.js +97 -0
- package/dist/commands/device/probe.js +253 -0
- package/dist/commands/doctor.js +364 -0
- package/dist/commands/fingerprint/diff.js +134 -0
- package/dist/commands/fingerprint.js +42 -0
- package/dist/commands/init.js +493 -0
- package/dist/commands/invitation/accept.js +53 -0
- package/dist/commands/invitation/create.js +52 -0
- package/dist/commands/invitation/delete.js +60 -0
- package/dist/commands/invitation/list.js +40 -0
- package/dist/commands/limit/list.js +34 -0
- package/dist/commands/login.js +36 -0
- package/dist/commands/logout.js +81 -0
- package/dist/commands/member/delete.js +35 -0
- package/dist/commands/member/get.js +37 -0
- package/dist/commands/member/list.js +41 -0
- package/dist/commands/member/update.js +53 -0
- package/dist/commands/open.js +37 -0
- package/dist/commands/organization/create.js +30 -0
- package/dist/commands/organization/delete.js +39 -0
- package/dist/commands/organization/get.js +38 -0
- package/dist/commands/organization/list.js +41 -0
- package/dist/commands/organization/update.js +64 -0
- package/dist/commands/release/create.js +237 -0
- package/dist/commands/release/get.js +81 -0
- package/dist/commands/release/list.js +69 -0
- package/dist/commands/release/pause.js +36 -0
- package/dist/commands/release/resume.js +36 -0
- package/dist/commands/release/revoke.js +254 -0
- package/dist/commands/release/rollback.js +73 -0
- package/dist/commands/release/rollout.js +63 -0
- package/dist/commands/release/update.js +82 -0
- package/dist/commands/resource-file/write.js +78 -0
- package/dist/commands/signing-key/add.js +45 -0
- package/dist/commands/signing-key/create.js +84 -0
- package/dist/commands/signing-key/delete.js +67 -0
- package/dist/commands/signing-key/list.js +38 -0
- package/dist/commands/sso-provider/delete.js +32 -0
- package/dist/commands/sso-provider/get.js +63 -0
- package/dist/commands/sso-provider/set.js +161 -0
- package/dist/commands/sso-provider/verify.js +31 -0
- package/dist/commands/statistics/get.js +189 -0
- package/dist/commands/usage/get.js +31 -0
- package/dist/commands/usage/list.js +41 -0
- package/dist/commands/webhook/create.js +72 -0
- package/dist/commands/webhook/delete.js +32 -0
- package/dist/commands/webhook/get.js +34 -0
- package/dist/commands/webhook/list.js +40 -0
- package/dist/commands/webhook/test.js +32 -0
- package/dist/commands/webhook/update.js +56 -0
- package/dist/commands/webhook-delivery/list.js +63 -0
- package/dist/commands/webhook-delivery/redeliver.js +48 -0
- package/dist/commands/whoami.js +24 -0
- package/dist/completion/hotcodepush.bash +536 -0
- package/dist/completion/hotcodepush.fish +979 -0
- package/dist/completion/hotcodepush.zsh +1372 -0
- package/dist/config/consts.js +32 -0
- package/dist/index.js +5 -0
- package/dist/registry.js +83 -0
- package/dist/utils/api-client.js +17 -0
- package/dist/utils/audience.js +9 -0
- package/dist/utils/auth-client.js +35 -0
- package/dist/utils/authentication.js +60 -0
- package/dist/utils/boolean-flag.js +3 -0
- package/dist/utils/browser.js +6 -0
- package/dist/utils/build-step.js +104 -0
- package/dist/utils/bundle-files.js +80 -0
- package/dist/utils/bundle-resolution.js +46 -0
- package/dist/utils/channel-fields.js +130 -0
- package/dist/utils/cli.js +80 -0
- package/dist/utils/command-registry.js +79 -0
- package/dist/utils/command.js +4 -0
- package/dist/utils/completion/bash.js +48 -0
- package/dist/utils/completion/command-tree.js +72 -0
- package/dist/utils/completion/fish.js +44 -0
- package/dist/utils/completion/posix-case-arms.js +9 -0
- package/dist/utils/completion/posix-quote.js +4 -0
- package/dist/utils/completion/script-files.js +26 -0
- package/dist/utils/completion/zsh.js +72 -0
- package/dist/utils/compressed-files.js +31 -0
- package/dist/utils/credential.js +15 -0
- package/dist/utils/device-resolution.js +17 -0
- package/dist/utils/diagnostics.js +20 -0
- package/dist/utils/duration.js +16 -0
- package/dist/utils/environment.js +22 -0
- package/dist/utils/error-mapping.js +75 -0
- package/dist/utils/errors.js +266 -0
- package/dist/utils/fingerprint.js +76 -0
- package/dist/utils/flag-name.js +4 -0
- package/dist/utils/fleet.js +9 -0
- package/dist/utils/framework.js +74 -0
- package/dist/utils/frameworks/binary-create-step.js +70 -0
- package/dist/utils/frameworks/capacitor.js +150 -0
- package/dist/utils/frameworks/cordova.js +110 -0
- package/dist/utils/frameworks/expo.js +152 -0
- package/dist/utils/frameworks/index.js +13 -0
- package/dist/utils/frameworks/native-glue.js +10 -0
- package/dist/utils/frameworks/native-projects.js +27 -0
- package/dist/utils/frameworks/react-native-build.js +182 -0
- package/dist/utils/frameworks/react-native.js +127 -0
- package/dist/utils/frameworks/sdk-package.js +63 -0
- package/dist/utils/git-provenance.js +71 -0
- package/dist/utils/global-options.js +34 -0
- package/dist/utils/hosts.js +53 -0
- package/dist/utils/init-steps.js +51 -0
- package/dist/utils/json-file.js +28 -0
- package/dist/utils/login.js +153 -0
- package/dist/utils/member-resolution.js +49 -0
- package/dist/utils/native-project-edit.js +41 -0
- package/dist/utils/nullable-field.js +10 -0
- package/dist/utils/outcome.js +24 -0
- package/dist/utils/output.js +38 -0
- package/dist/utils/pack.js +20 -0
- package/dist/utils/package-json.js +5 -0
- package/dist/utils/package-manager.js +50 -0
- package/dist/utils/pagination.js +29 -0
- package/dist/utils/progress.js +23 -0
- package/dist/utils/progression.js +119 -0
- package/dist/utils/project-config.js +61 -0
- package/dist/utils/prompts.js +53 -0
- package/dist/utils/react-native-project.js +119 -0
- package/dist/utils/release-conditions.js +79 -0
- package/dist/utils/release-output.js +13 -0
- package/dist/utils/release-resolution.js +83 -0
- package/dist/utils/resource-file.js +81 -0
- package/dist/utils/resource-resolution.js +107 -0
- package/dist/utils/signing-private-key.js +105 -0
- package/dist/utils/spending-cap.js +16 -0
- package/dist/utils/telemetry.js +172 -0
- package/dist/utils/token-store.js +63 -0
- package/dist/utils/update-notice.js +68 -0
- package/dist/utils/upload.js +148 -0
- package/dist/utils/usage-month.js +14 -0
- package/dist/utils/user-config.js +54 -0
- package/dist/utils/webhook-resolution.js +47 -0
- package/dist/utils/xcode-project.js +181 -0
- package/package.json +11 -9
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import { ChannelIndexSchema, evaluateChannelIndex, } from '@hotcodepush/protocol';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { createApiClient } from '../../utils/api-client.js';
|
|
4
|
+
import { defineCommand } from '../../utils/command.js';
|
|
5
|
+
import { deviceOptionShape } from '../../utils/device-resolution.js';
|
|
6
|
+
import { defineCommandOptions } from '../../utils/global-options.js';
|
|
7
|
+
import { resolveFilesBaseUrl } from '../../utils/hosts.js';
|
|
8
|
+
import { printDetails, printJson, printTable } from '../../utils/output.js';
|
|
9
|
+
import { fetchAllPages } from '../../utils/pagination.js';
|
|
10
|
+
import { readProjectConfig } from '../../utils/project-config.js';
|
|
11
|
+
import { promptSelect, promptText } from '../../utils/prompts.js';
|
|
12
|
+
import { resolveAttributePairs } from '../../utils/release-conditions.js';
|
|
13
|
+
import { channelOptionShape, fetchAppId, fetchChannelId, } from '../../utils/resource-resolution.js';
|
|
14
|
+
import { readApiUrl } from '../../utils/user-config.js';
|
|
15
|
+
const INDEX_FETCH_TIMEOUT_MS = 30_000;
|
|
16
|
+
// No release is older than the binary, so a device without a created binary has no floor.
|
|
17
|
+
const NO_FLOOR = new Date(0).toISOString();
|
|
18
|
+
export default defineCommand({
|
|
19
|
+
description: 'Answer why a device did not update: fetch the live index as a device would, evaluate it and mark every condition of every release.',
|
|
20
|
+
examples: [
|
|
21
|
+
'hotcodepush device probe --platform ios --binary-version 2.4.1 --binary-build 57',
|
|
22
|
+
'hotcodepush device probe --device 6b1e9d37-2f5c-4a80-9c46-d8e3a1f7b259 --json',
|
|
23
|
+
],
|
|
24
|
+
options: defineCommandOptions({
|
|
25
|
+
...channelOptionShape,
|
|
26
|
+
...deviceOptionShape,
|
|
27
|
+
attribute: z
|
|
28
|
+
.array(z.string())
|
|
29
|
+
.optional()
|
|
30
|
+
.describe('An attribute the app sets, key=value, repeatable.'),
|
|
31
|
+
binaryBuild: z
|
|
32
|
+
.string()
|
|
33
|
+
.optional()
|
|
34
|
+
.describe("The store build's build number, CFBundleVersion or versionCode."),
|
|
35
|
+
binaryVersion: z
|
|
36
|
+
.string()
|
|
37
|
+
.optional()
|
|
38
|
+
.describe("The store build's version, CFBundleShortVersionString or versionName."),
|
|
39
|
+
platform: z
|
|
40
|
+
.enum(['android', 'ios'])
|
|
41
|
+
.optional()
|
|
42
|
+
.describe('The platform, ios or android.'),
|
|
43
|
+
}),
|
|
44
|
+
action: async (options) => {
|
|
45
|
+
const hotCodePush = createApiClient();
|
|
46
|
+
const projectConfig = readProjectConfig(options.config);
|
|
47
|
+
const appId = await fetchAppId(hotCodePush, options, projectConfig);
|
|
48
|
+
const fetchedDevice = options.device === undefined
|
|
49
|
+
? undefined
|
|
50
|
+
: await hotCodePush.apps.devices.get({
|
|
51
|
+
appId,
|
|
52
|
+
deviceId: options.device,
|
|
53
|
+
});
|
|
54
|
+
const channel = await hotCodePush.apps.channels.get({
|
|
55
|
+
appId,
|
|
56
|
+
channelId: await fetchChannelId(hotCodePush, appId, {
|
|
57
|
+
...options,
|
|
58
|
+
channel: options.channel ?? fetchedDevice?.channelId ?? undefined,
|
|
59
|
+
}, projectConfig),
|
|
60
|
+
});
|
|
61
|
+
const platform = options.platform ??
|
|
62
|
+
fetchedDevice?.platform ??
|
|
63
|
+
(await promptSelect('--platform', 'Which platform?', [
|
|
64
|
+
{ label: 'iOS', value: 'ios' },
|
|
65
|
+
{ label: 'Android', value: 'android' },
|
|
66
|
+
], options));
|
|
67
|
+
const binaryVersion = options.binaryVersion ??
|
|
68
|
+
fetchedDevice?.binaryVersion ??
|
|
69
|
+
(await promptText('--binary-version', 'Which app version?', options));
|
|
70
|
+
const binaryBuild = options.binaryBuild ??
|
|
71
|
+
fetchedDevice?.binaryBuild ??
|
|
72
|
+
(await promptText('--binary-build', 'Which build number?', options));
|
|
73
|
+
const binary = (await fetchAllPages(page => hotCodePush.apps.binaries.list({ appId, ...page }))).find(candidate => candidate.platform === platform &&
|
|
74
|
+
candidate.version === binaryVersion &&
|
|
75
|
+
candidate.build === binaryBuild);
|
|
76
|
+
const servedIndex = await fetchServedIndex(appId, channel.id, platform);
|
|
77
|
+
const unknownFacts = resolveUnknownFacts(fetchedDevice, binary);
|
|
78
|
+
const builtAt = binary?.createdAt ?? null;
|
|
79
|
+
const device = {
|
|
80
|
+
attributes: {
|
|
81
|
+
...fetchedDevice?.attributes,
|
|
82
|
+
...Object.fromEntries(resolveAttributePairs(options.attribute).map(({ key, value }) => [
|
|
83
|
+
key,
|
|
84
|
+
value,
|
|
85
|
+
])),
|
|
86
|
+
},
|
|
87
|
+
binaryBuild,
|
|
88
|
+
binaryVersion,
|
|
89
|
+
builtAt: builtAt ?? NO_FLOOR,
|
|
90
|
+
currentRelease: fetchedDevice === undefined
|
|
91
|
+
? null
|
|
92
|
+
: await fetchCurrentRelease(hotCodePush, fetchedDevice, servedIndex),
|
|
93
|
+
deviceId: fetchedDevice?.deviceId ?? '',
|
|
94
|
+
failedBundleIds: fetchedDevice === undefined
|
|
95
|
+
? []
|
|
96
|
+
: resolveFailedBundleIds(await fetchAllPages(page => hotCodePush.apps.devices.events.list({
|
|
97
|
+
appId,
|
|
98
|
+
deviceId: fetchedDevice.deviceId,
|
|
99
|
+
...page,
|
|
100
|
+
})), servedIndex.index),
|
|
101
|
+
fingerprint: fetchedDevice?.fingerprint ?? binary?.fingerprint ?? null,
|
|
102
|
+
osVersion: fetchedDevice?.osVersion ?? '',
|
|
103
|
+
reportedAt: fetchedDevice?.lastSeenAt ?? null,
|
|
104
|
+
};
|
|
105
|
+
const evaluation = servedIndex.index === null
|
|
106
|
+
? null
|
|
107
|
+
: evaluateKnownFacts(servedIndex.index, device, unknownFacts);
|
|
108
|
+
if (options.json) {
|
|
109
|
+
printJson({
|
|
110
|
+
channelId: channel.id,
|
|
111
|
+
device: { ...device, builtAt },
|
|
112
|
+
outcome: evaluation?.outcome ?? null,
|
|
113
|
+
platform,
|
|
114
|
+
sequence: servedIndex.index?.sequence ?? null,
|
|
115
|
+
status: servedIndex.status,
|
|
116
|
+
unknownFacts,
|
|
117
|
+
url: servedIndex.url,
|
|
118
|
+
verdicts: evaluation?.verdicts ?? [],
|
|
119
|
+
});
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
printDetails([
|
|
123
|
+
['Channel', `${channel.name}, ${platform}`],
|
|
124
|
+
['Index', resolveServedIndexText(servedIndex)],
|
|
125
|
+
[
|
|
126
|
+
'Outcome',
|
|
127
|
+
evaluation === null ? 'none' : resolveOutcomeText(evaluation),
|
|
128
|
+
],
|
|
129
|
+
[
|
|
130
|
+
'Unknown',
|
|
131
|
+
unknownFacts.length === 0
|
|
132
|
+
? 'nothing'
|
|
133
|
+
: `${unknownFacts.join(', ')}, treated as passing. Pass --device for the facts of a real device.`,
|
|
134
|
+
],
|
|
135
|
+
]);
|
|
136
|
+
if (evaluation !== null && servedIndex.index !== null) {
|
|
137
|
+
printTable({
|
|
138
|
+
emptyText: 'The index offers no release.',
|
|
139
|
+
headers: ['RELEASE', 'ROLLOUT', 'CONDITIONS', 'VERDICT'],
|
|
140
|
+
nextOffset: null,
|
|
141
|
+
rows: evaluation.verdicts.map(verdict => [
|
|
142
|
+
`#${verdict.release.number}`,
|
|
143
|
+
`${verdict.release.rolloutPercent}%`,
|
|
144
|
+
resolveConditionsText(verdict, unknownFacts),
|
|
145
|
+
verdict.isEligible
|
|
146
|
+
? 'eligible'
|
|
147
|
+
: `${verdict.reason ?? 'skipped'}${verdict.conditionType === undefined ? '' : ` (${verdict.conditionType})`}`,
|
|
148
|
+
]),
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
},
|
|
152
|
+
});
|
|
153
|
+
// The index may no longer carry the release, so its number then comes from the API.
|
|
154
|
+
async function fetchCurrentRelease(hotCodePush, fetchedDevice, servedIndex) {
|
|
155
|
+
const releaseId = fetchedDevice.currentReleaseId;
|
|
156
|
+
if (releaseId === null) {
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
const indexRelease = servedIndex.index?.releases.find(({ id }) => id === releaseId);
|
|
160
|
+
const number = indexRelease?.number ??
|
|
161
|
+
(await hotCodePush.apps.releases.get({
|
|
162
|
+
appId: fetchedDevice.appId,
|
|
163
|
+
releaseId,
|
|
164
|
+
})).number;
|
|
165
|
+
return { id: releaseId, number };
|
|
166
|
+
}
|
|
167
|
+
// The same request a device makes.
|
|
168
|
+
async function fetchServedIndex(appId, channelId, platform) {
|
|
169
|
+
const url = `${resolveFilesBaseUrl(readApiUrl())}/apps/${appId}/channels/${channelId}/${platform}/v1/index.json`;
|
|
170
|
+
const response = await fetch(url, {
|
|
171
|
+
signal: AbortSignal.timeout(INDEX_FETCH_TIMEOUT_MS),
|
|
172
|
+
});
|
|
173
|
+
if (!response.ok) {
|
|
174
|
+
await response.body?.cancel();
|
|
175
|
+
return { index: null, status: response.status, url };
|
|
176
|
+
}
|
|
177
|
+
return {
|
|
178
|
+
index: ChannelIndexSchema.parse(await response.json()),
|
|
179
|
+
status: response.status,
|
|
180
|
+
url,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
// Rollouts count as reached and conditions on unknown facts as passing, so only known facts decide.
|
|
184
|
+
function evaluateKnownFacts(index, device, unknownFacts) {
|
|
185
|
+
const releasesById = new Map(index.releases.map(release => [release.id, release]));
|
|
186
|
+
const knownIndex = {
|
|
187
|
+
...index,
|
|
188
|
+
releases: index.releases.map(release => ({
|
|
189
|
+
...release,
|
|
190
|
+
conditions: release.conditions.filter(({ type }) => !unknownFacts.includes(type)),
|
|
191
|
+
rolloutPercent: unknownFacts.includes('rollout')
|
|
192
|
+
? 100
|
|
193
|
+
: release.rolloutPercent,
|
|
194
|
+
})),
|
|
195
|
+
};
|
|
196
|
+
const { outcome, verdicts } = evaluateChannelIndex(knownIndex, device);
|
|
197
|
+
const resolveServedRelease = (release) => releasesById.get(release.id) ?? release;
|
|
198
|
+
return {
|
|
199
|
+
outcome: outcome.release === null
|
|
200
|
+
? outcome
|
|
201
|
+
: { ...outcome, release: resolveServedRelease(outcome.release) },
|
|
202
|
+
verdicts: verdicts.map(verdict => ({
|
|
203
|
+
...verdict,
|
|
204
|
+
release: resolveServedRelease(verdict.release),
|
|
205
|
+
})),
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
function resolveConditionsText(verdict, unknownFacts) {
|
|
209
|
+
const knownVerdicts = [...verdict.conditions];
|
|
210
|
+
const conditionTexts = verdict.release.conditions.map(({ type }) => {
|
|
211
|
+
if (unknownFacts.includes(type)) {
|
|
212
|
+
return `${type} unknown`;
|
|
213
|
+
}
|
|
214
|
+
const conditionVerdict = knownVerdicts.shift();
|
|
215
|
+
return `${type} ${conditionVerdict?.isSatisfied ? 'pass' : 'fail'}`;
|
|
216
|
+
});
|
|
217
|
+
return conditionTexts.join(', ') || 'none';
|
|
218
|
+
}
|
|
219
|
+
function resolveFailedBundleIds(events, index) {
|
|
220
|
+
const failedReleaseIds = new Set(events
|
|
221
|
+
.filter(({ type }) => type === 'failed')
|
|
222
|
+
.map(({ releaseId }) => releaseId));
|
|
223
|
+
return (index?.releases ?? [])
|
|
224
|
+
.filter(({ id }) => failedReleaseIds.has(id))
|
|
225
|
+
.map(({ bundleId }) => bundleId);
|
|
226
|
+
}
|
|
227
|
+
function resolveOutcomeText({ outcome }) {
|
|
228
|
+
const releaseText = outcome.release === null
|
|
229
|
+
? 'the embedded bundle'
|
|
230
|
+
: `release #${outcome.release.number}`;
|
|
231
|
+
switch (outcome.status) {
|
|
232
|
+
case 'available':
|
|
233
|
+
return `available, ${releaseText}${outcome.isMandatory ? ', mandatory' : ''}`;
|
|
234
|
+
case 'skipped':
|
|
235
|
+
return `skipped, ${outcome.reason}${outcome.conditionType === undefined ? '' : ` (${outcome.conditionType})`}, ${releaseText}`;
|
|
236
|
+
case 'up_to_date':
|
|
237
|
+
return `up_to_date, ${releaseText}`;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
// The index sequence is the publish time in milliseconds.
|
|
241
|
+
function resolveServedIndexText({ index, status, url }) {
|
|
242
|
+
return index === null
|
|
243
|
+
? `none: HTTP ${status} at ${url}, so a device gets no release`
|
|
244
|
+
: `published ${new Date(index.sequence).toISOString()}`;
|
|
245
|
+
}
|
|
246
|
+
function resolveUnknownFacts(fetchedDevice, binary) {
|
|
247
|
+
if (fetchedDevice !== undefined) {
|
|
248
|
+
return [];
|
|
249
|
+
}
|
|
250
|
+
return binary?.fingerprint === undefined || binary.fingerprint === null
|
|
251
|
+
? ['fingerprint', 'os', 'rollout']
|
|
252
|
+
: ['os', 'rollout'];
|
|
253
|
+
}
|
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
import { ProjectConfigurationSchema, resolveSigningKeyId, } from '@hotcodepush/protocol';
|
|
2
|
+
import { INIT_MANUAL_STEP, PACKAGE_JSON, PROJECT_CONFIG_FILE_NAME, } from '../config/consts.js';
|
|
3
|
+
import { createApiClient } from '../utils/api-client.js';
|
|
4
|
+
import { defineCommand } from '../utils/command.js';
|
|
5
|
+
import { fetchCurrentUser, isUnauthenticatedError, resolveCredentialText, } from '../utils/credential.js';
|
|
6
|
+
import { resolveCliError, resolveSentence } from '../utils/error-mapping.js';
|
|
7
|
+
import { CliError, InvalidJsonError, NotLoggedInError, ReportedFailureError, SigningKeyUnavailableError, UnknownFrameworkError, } from '../utils/errors.js';
|
|
8
|
+
import { detectFramework } from '../utils/framework.js';
|
|
9
|
+
import { resolveFrameworkModule } from '../utils/frameworks/index.js';
|
|
10
|
+
import { readInstalledPackageVersion, readSdkDependencyVersion, } from '../utils/frameworks/sdk-package.js';
|
|
11
|
+
import { defineCommandOptions } from '../utils/global-options.js';
|
|
12
|
+
import { resolveFilesBaseUrl, resolveUpdatesBaseUrl } from '../utils/hosts.js';
|
|
13
|
+
import { printOutcomeRows } from '../utils/outcome.js';
|
|
14
|
+
import { printJson, resolveQuantityText } from '../utils/output.js';
|
|
15
|
+
import { readPackageJson } from '../utils/package-json.js';
|
|
16
|
+
import { ID_SCHEMA, locateProjectConfigFile, readProjectConfigFile, resolveProjectChannel, } from '../utils/project-config.js';
|
|
17
|
+
import { fetchChannels, fetchHasSigningKey, fetchResourceId, fetchSigningKeys, } from '../utils/resource-resolution.js';
|
|
18
|
+
import { readSigningKeyPair } from '../utils/signing-private-key.js';
|
|
19
|
+
import { isEnvironmentToken, readToken } from '../utils/token-store.js';
|
|
20
|
+
import { readApiUrl } from '../utils/user-config.js';
|
|
21
|
+
const CHANNEL_NAME_SCHEMA = ProjectConfigurationSchema.shape.channel;
|
|
22
|
+
const PROBE_TIMEOUT_MS = 5000;
|
|
23
|
+
export default defineCommand({
|
|
24
|
+
description: 'Check the project: its configuration, the hook wiring, the signing key, the hosts and the versions a bug report needs.',
|
|
25
|
+
authentication: 'optional',
|
|
26
|
+
examples: ['hotcodepush doctor', 'hotcodepush doctor --json'],
|
|
27
|
+
options: defineCommandOptions({}),
|
|
28
|
+
action: async (options) => {
|
|
29
|
+
const project = readProject(options.config);
|
|
30
|
+
const { app, checks: sessionAndAppChecks } = await checkSessionAndApp(project);
|
|
31
|
+
const checks = [
|
|
32
|
+
checkConfiguration(project),
|
|
33
|
+
...sessionAndAppChecks,
|
|
34
|
+
...checkFramework(project),
|
|
35
|
+
await checkHosts(),
|
|
36
|
+
await checkSigningKey(project, app),
|
|
37
|
+
checkVersions(project),
|
|
38
|
+
];
|
|
39
|
+
const status = checks.some(({ status }) => status === 'failed')
|
|
40
|
+
? 'failed'
|
|
41
|
+
: 'clean';
|
|
42
|
+
if (options.json) {
|
|
43
|
+
printJson({ checks, status });
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
printOutcomeRows(checks.map(({ check, manualStep, message, status }) => ({
|
|
47
|
+
manualStep,
|
|
48
|
+
message,
|
|
49
|
+
name: check,
|
|
50
|
+
status,
|
|
51
|
+
})));
|
|
52
|
+
}
|
|
53
|
+
if (status === 'failed') {
|
|
54
|
+
throw new ReportedFailureError('doctor_failed');
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
function readProject(configPath) {
|
|
59
|
+
const { directoryPath, filePath } = locateProjectConfigFile(configPath);
|
|
60
|
+
const framework = resolveProjectFramework(directoryPath);
|
|
61
|
+
const project = {
|
|
62
|
+
directoryPath,
|
|
63
|
+
framework,
|
|
64
|
+
packageJson: framework instanceof CliError
|
|
65
|
+
? undefined
|
|
66
|
+
: readPackageJson(directoryPath),
|
|
67
|
+
projectConfig: undefined,
|
|
68
|
+
};
|
|
69
|
+
if (filePath === undefined) {
|
|
70
|
+
return project;
|
|
71
|
+
}
|
|
72
|
+
try {
|
|
73
|
+
return { ...project, projectConfig: readProjectConfigFile(filePath) };
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
if (!(error instanceof InvalidJsonError)) {
|
|
77
|
+
throw error;
|
|
78
|
+
}
|
|
79
|
+
return { ...project, projectConfigError: error };
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
function checkConfiguration({ directoryPath, projectConfig, projectConfigError, }) {
|
|
83
|
+
if (projectConfigError !== undefined) {
|
|
84
|
+
return resolveFailedCheck('configuration', projectConfigError);
|
|
85
|
+
}
|
|
86
|
+
if (projectConfig === undefined) {
|
|
87
|
+
return {
|
|
88
|
+
check: 'configuration',
|
|
89
|
+
code: 'project_config_missing',
|
|
90
|
+
manualStep: INIT_MANUAL_STEP,
|
|
91
|
+
message: `There is no ${PROJECT_CONFIG_FILE_NAME} in ${directoryPath} or a directory above it.`,
|
|
92
|
+
status: 'failed',
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
const problems = resolveConfigurationProblems(projectConfig);
|
|
96
|
+
if (problems.length > 0) {
|
|
97
|
+
return {
|
|
98
|
+
check: 'configuration',
|
|
99
|
+
code: 'project_config_invalid',
|
|
100
|
+
manualStep: INIT_MANUAL_STEP,
|
|
101
|
+
message: `The file ${PROJECT_CONFIG_FILE_NAME} ${problems.join(' and ')}.`,
|
|
102
|
+
status: 'failed',
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
return {
|
|
106
|
+
check: 'configuration',
|
|
107
|
+
message: `The file ${PROJECT_CONFIG_FILE_NAME} names app ${projectConfig.appId} and channel ${resolveProjectChannel(projectConfig)}.`,
|
|
108
|
+
status: 'ok',
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
// A file without `channel` follows the schema default.
|
|
112
|
+
function resolveConfigurationProblems(projectConfig) {
|
|
113
|
+
const problems = [];
|
|
114
|
+
if (projectConfig.appId === undefined) {
|
|
115
|
+
problems.push('lacks appId');
|
|
116
|
+
}
|
|
117
|
+
else if (!ID_SCHEMA.safeParse(projectConfig.appId).success) {
|
|
118
|
+
problems.push('has an appId that is not an id');
|
|
119
|
+
}
|
|
120
|
+
if (projectConfig.channel !== undefined &&
|
|
121
|
+
!CHANNEL_NAME_SCHEMA.safeParse(projectConfig.channel).success) {
|
|
122
|
+
problems.push('has a channel that is not a channel name');
|
|
123
|
+
}
|
|
124
|
+
return problems;
|
|
125
|
+
}
|
|
126
|
+
// A refused credential, or an API that cannot be asked, fails the session check and leaves the app out.
|
|
127
|
+
async function checkSessionAndApp({ projectConfig, }) {
|
|
128
|
+
let user;
|
|
129
|
+
try {
|
|
130
|
+
if (readToken() === undefined) {
|
|
131
|
+
return {
|
|
132
|
+
app: undefined,
|
|
133
|
+
checks: [
|
|
134
|
+
{
|
|
135
|
+
check: 'session',
|
|
136
|
+
message: 'You are not logged in, so the app is not checked against the API.',
|
|
137
|
+
status: 'skipped',
|
|
138
|
+
},
|
|
139
|
+
],
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
user = await fetchCurrentUser();
|
|
143
|
+
}
|
|
144
|
+
catch (error) {
|
|
145
|
+
return { app: undefined, checks: [resolveFailedSessionCheck(error)] };
|
|
146
|
+
}
|
|
147
|
+
const sessionCheck = {
|
|
148
|
+
check: 'session',
|
|
149
|
+
message: resolveCredentialText(user, isEnvironmentToken()),
|
|
150
|
+
status: 'ok',
|
|
151
|
+
};
|
|
152
|
+
const { app, check } = await checkApp(projectConfig);
|
|
153
|
+
return { app, checks: [sessionCheck, check] };
|
|
154
|
+
}
|
|
155
|
+
function resolveFailedSessionCheck(error) {
|
|
156
|
+
const { code, fix, message } = resolveCliError(error);
|
|
157
|
+
if (isUnauthenticatedError(error) || error instanceof NotLoggedInError) {
|
|
158
|
+
return {
|
|
159
|
+
check: 'session',
|
|
160
|
+
code,
|
|
161
|
+
manualStep: 'Run "hotcodepush login", or set a valid HOTCODEPUSH_TOKEN.',
|
|
162
|
+
message: 'The API does not accept the credential.',
|
|
163
|
+
status: 'failed',
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
return {
|
|
167
|
+
check: 'session',
|
|
168
|
+
code,
|
|
169
|
+
manualStep: fix ?? undefined,
|
|
170
|
+
message: `The credential cannot be checked. ${resolveSentence(message)}`,
|
|
171
|
+
status: 'failed',
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
async function checkApp(projectConfig) {
|
|
175
|
+
if (projectConfig?.appId === undefined ||
|
|
176
|
+
!ID_SCHEMA.safeParse(projectConfig.appId).success) {
|
|
177
|
+
return {
|
|
178
|
+
app: undefined,
|
|
179
|
+
check: {
|
|
180
|
+
check: 'app',
|
|
181
|
+
message: 'The configuration names no app and channel to check.',
|
|
182
|
+
status: 'skipped',
|
|
183
|
+
},
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
const { appId } = projectConfig;
|
|
187
|
+
const hotCodePush = createApiClient();
|
|
188
|
+
try {
|
|
189
|
+
const [app, channel] = await Promise.all([
|
|
190
|
+
hotCodePush.apps.get({ appId }),
|
|
191
|
+
fetchResourceId('channel', resolveProjectChannel(projectConfig), () => fetchChannels(hotCodePush, appId), PROJECT_CONFIG_FILE_NAME).then(channelId => hotCodePush.apps.channels.get({ appId, channelId })),
|
|
192
|
+
]);
|
|
193
|
+
return {
|
|
194
|
+
app,
|
|
195
|
+
check: {
|
|
196
|
+
check: 'app',
|
|
197
|
+
message: `The app ${app.name} and the channel ${channel.name} exist.`,
|
|
198
|
+
status: 'ok',
|
|
199
|
+
},
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
catch (error) {
|
|
203
|
+
return {
|
|
204
|
+
app: undefined,
|
|
205
|
+
check: {
|
|
206
|
+
check: 'app',
|
|
207
|
+
code: resolveCliError(error).code,
|
|
208
|
+
manualStep: INIT_MANUAL_STEP,
|
|
209
|
+
message: `The API does not know the app or the channel. ${resolveSentence(error instanceof Error ? error.message : String(error))}`,
|
|
210
|
+
status: 'failed',
|
|
211
|
+
},
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
// No key file is looked for, since an upload is told where its key is.
|
|
216
|
+
// A key the app has but the file does not list fails, since an upload from that project would go unsigned.
|
|
217
|
+
async function checkSigningKey({ projectConfig }, app) {
|
|
218
|
+
const publicKeys = projectConfig?.appId === undefined ? [] : (projectConfig.publicKeys ?? []);
|
|
219
|
+
try {
|
|
220
|
+
if (publicKeys.length === 0 &&
|
|
221
|
+
app !== undefined &&
|
|
222
|
+
(await fetchHasSigningKey(createApiClient(), app.id))) {
|
|
223
|
+
return {
|
|
224
|
+
check: 'signing_key',
|
|
225
|
+
code: 'public_key_unlisted',
|
|
226
|
+
manualStep: `Run "hotcodepush signing-key list --json" and add each publicKey to publicKeys in ${PROJECT_CONFIG_FILE_NAME}.`,
|
|
227
|
+
message: `The app has a signing key and ${PROJECT_CONFIG_FILE_NAME} lists none.`,
|
|
228
|
+
status: 'failed',
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
const unregisteredKeyCount = app === undefined || publicKeys.length === 0
|
|
232
|
+
? 0
|
|
233
|
+
: await fetchUnregisteredPublicKeyCount(app.id, publicKeys);
|
|
234
|
+
if (unregisteredKeyCount > 0) {
|
|
235
|
+
return {
|
|
236
|
+
check: 'signing_key',
|
|
237
|
+
code: 'public_key_unregistered',
|
|
238
|
+
manualStep: 'Run "hotcodepush signing-key list --json" and keep in publicKeys only the keys it prints.',
|
|
239
|
+
message: `The file ${PROJECT_CONFIG_FILE_NAME} lists ${resolveQuantityText(unregisteredKeyCount, 'public key')} the app has not registered.`,
|
|
240
|
+
status: 'failed',
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
return await checkSigningPrivateKey(publicKeys);
|
|
244
|
+
}
|
|
245
|
+
catch (error) {
|
|
246
|
+
return resolveFailedCheck('signing_key', error);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
// No variable while keys are listed is fine, since an upload is told where its key is.
|
|
250
|
+
async function checkSigningPrivateKey(publicKeys) {
|
|
251
|
+
try {
|
|
252
|
+
const signingKeyPair = await readSigningKeyPair(publicKeys, undefined);
|
|
253
|
+
return signingKeyPair === null
|
|
254
|
+
? {
|
|
255
|
+
check: 'signing_key',
|
|
256
|
+
message: 'Code signing is off. Run "hotcodepush signing-key create" to turn it on.',
|
|
257
|
+
status: 'skipped',
|
|
258
|
+
}
|
|
259
|
+
: {
|
|
260
|
+
check: 'signing_key',
|
|
261
|
+
message: `HOTCODEPUSH_SIGNING_KEY signs with key ${resolveSigningKeyId(signingKeyPair.publicKey)}.`,
|
|
262
|
+
status: 'ok',
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
catch (error) {
|
|
266
|
+
if (error instanceof SigningKeyUnavailableError) {
|
|
267
|
+
return {
|
|
268
|
+
check: 'signing_key',
|
|
269
|
+
message: 'Code signing is on. An upload signs with --private-key-path or HOTCODEPUSH_SIGNING_KEY.',
|
|
270
|
+
status: 'ok',
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
throw error;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
// An upload signed with an unregistered key is refused.
|
|
277
|
+
async function fetchUnregisteredPublicKeyCount(appId, publicKeys) {
|
|
278
|
+
const registeredPublicKeys = new Set((await fetchSigningKeys(createApiClient(), appId)).map(({ publicKey }) => publicKey));
|
|
279
|
+
return publicKeys.filter(publicKey => !registeredPublicKeys.has(publicKey))
|
|
280
|
+
.length;
|
|
281
|
+
}
|
|
282
|
+
// The resource file is not checked, since every build writes it into the app, never into the project.
|
|
283
|
+
function checkFramework(project) {
|
|
284
|
+
const { framework } = project;
|
|
285
|
+
if (framework instanceof CliError) {
|
|
286
|
+
return [resolveFailedCheck('framework', framework)];
|
|
287
|
+
}
|
|
288
|
+
return framework.checkWiring(project);
|
|
289
|
+
}
|
|
290
|
+
// Any HTTP answer counts and a network error does not.
|
|
291
|
+
async function checkHosts() {
|
|
292
|
+
let apiUrl;
|
|
293
|
+
try {
|
|
294
|
+
apiUrl = readApiUrl();
|
|
295
|
+
}
|
|
296
|
+
catch (error) {
|
|
297
|
+
return resolveFailedCheck('hosts', error);
|
|
298
|
+
}
|
|
299
|
+
const hosts = [
|
|
300
|
+
['api', `${apiUrl.replace(/\/+$/, '')}/health`],
|
|
301
|
+
['files', resolveFilesBaseUrl(apiUrl)],
|
|
302
|
+
['updates', resolveUpdatesBaseUrl(apiUrl)],
|
|
303
|
+
];
|
|
304
|
+
const unreachable = (await Promise.all(hosts.map(async ([name, url]) => (await isReachable(url)) ? undefined : `${name} (${url})`))).filter(host => host !== undefined);
|
|
305
|
+
if (unreachable.length > 0) {
|
|
306
|
+
return {
|
|
307
|
+
check: 'hosts',
|
|
308
|
+
code: 'host_unreachable',
|
|
309
|
+
manualStep: 'Check the network, the API URL in config.json and the HOTCODEPUSH_*_BASE_URL variables.',
|
|
310
|
+
message: `The CLI cannot reach ${unreachable.join(', ')}.`,
|
|
311
|
+
status: 'failed',
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
return {
|
|
315
|
+
check: 'hosts',
|
|
316
|
+
message: `The hosts ${hosts.map(([name]) => name).join(', ')} are reachable.`,
|
|
317
|
+
status: 'ok',
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
function checkVersions({ directoryPath, framework, packageJson, }) {
|
|
321
|
+
const versions = [
|
|
322
|
+
`${PACKAGE_JSON.name} ${PACKAGE_JSON.version}`,
|
|
323
|
+
`node ${process.version}`,
|
|
324
|
+
];
|
|
325
|
+
if (!(framework instanceof CliError)) {
|
|
326
|
+
versions.push(...framework.versionedPackageNames.map(packageName => `${packageName} ${readInstalledPackageVersion(directoryPath, packageName) ?? packageJson?.dependencies?.[packageName] ?? 'missing'}`), ...(framework.versionedSdkDependencyNames ?? []).map(packageName => `${packageName} ${readSdkDependencyVersion(directoryPath, framework.packageName, packageName) ?? 'missing'}`));
|
|
327
|
+
}
|
|
328
|
+
return {
|
|
329
|
+
check: 'versions',
|
|
330
|
+
message: `The versions are ${versions.join(', ')}.`,
|
|
331
|
+
status: 'ok',
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
function resolveFailedCheck(check, error) {
|
|
335
|
+
const { code, fix, message } = resolveCliError(error);
|
|
336
|
+
return {
|
|
337
|
+
check,
|
|
338
|
+
code,
|
|
339
|
+
manualStep: fix ?? undefined,
|
|
340
|
+
message: resolveSentence(message),
|
|
341
|
+
status: 'failed',
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
async function isReachable(url) {
|
|
345
|
+
try {
|
|
346
|
+
await fetch(url, { signal: AbortSignal.timeout(PROBE_TIMEOUT_MS) });
|
|
347
|
+
return true;
|
|
348
|
+
}
|
|
349
|
+
catch {
|
|
350
|
+
return false;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
function resolveProjectFramework(directoryPath) {
|
|
354
|
+
try {
|
|
355
|
+
return resolveFrameworkModule(detectFramework(directoryPath));
|
|
356
|
+
}
|
|
357
|
+
catch (error) {
|
|
358
|
+
if (error instanceof UnknownFrameworkError ||
|
|
359
|
+
error instanceof InvalidJsonError) {
|
|
360
|
+
return error;
|
|
361
|
+
}
|
|
362
|
+
throw error;
|
|
363
|
+
}
|
|
364
|
+
}
|