@deploid/plugin-doctor 2.0.0 → 2.0.2
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/dist/index.d.ts +28 -8
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +510 -126
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/index.ts +720 -154
package/dist/index.js
CHANGED
|
@@ -1,190 +1,563 @@
|
|
|
1
1
|
import { spawnSync } from 'node:child_process';
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
-
const CONFIG_CANDIDATES = [
|
|
5
|
-
|
|
6
|
-
'
|
|
7
|
-
'
|
|
8
|
-
'
|
|
9
|
-
|
|
4
|
+
const CONFIG_CANDIDATES = ['deploid.config.ts', 'deploid.config.js', 'deploid.config.mjs', 'deploid.config.cjs'];
|
|
5
|
+
const WORKFLOW_TITLES = {
|
|
6
|
+
init: 'Project setup',
|
|
7
|
+
build: 'Android build',
|
|
8
|
+
release: 'Release readiness',
|
|
9
|
+
deploy: 'Device deploy',
|
|
10
|
+
desktop: 'Desktop packaging'
|
|
11
|
+
};
|
|
10
12
|
const plugin = {
|
|
11
13
|
name: 'doctor',
|
|
12
14
|
plan: () => [
|
|
13
|
-
'Inspect project files and
|
|
14
|
-
'
|
|
15
|
-
'
|
|
15
|
+
'Inspect project files and config consistency',
|
|
16
|
+
'Assess workflow readiness for setup, build, release, deploy, and desktop packaging',
|
|
17
|
+
'Offer machine-readable output and safe auto-fixes'
|
|
16
18
|
],
|
|
17
19
|
run: runDoctor
|
|
18
20
|
};
|
|
19
21
|
async function runDoctor(ctx) {
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
+
const options = ctx.doctorOptions ?? {};
|
|
23
|
+
const summary = await inspectProject(ctx.cwd, options);
|
|
24
|
+
if (options.json) {
|
|
22
25
|
console.log(JSON.stringify(summary, null, 2));
|
|
23
26
|
}
|
|
27
|
+
else if (options.markdown) {
|
|
28
|
+
console.log(renderMarkdown(summary, options));
|
|
29
|
+
}
|
|
30
|
+
else if (options.ci) {
|
|
31
|
+
console.log(renderCi(summary));
|
|
32
|
+
}
|
|
24
33
|
else {
|
|
25
|
-
printSummary(
|
|
34
|
+
printSummary(summary, options);
|
|
26
35
|
}
|
|
27
36
|
if (!summary.ok) {
|
|
28
37
|
process.exitCode = 1;
|
|
29
38
|
}
|
|
30
39
|
}
|
|
31
|
-
async function inspectProject(cwd, options) {
|
|
40
|
+
async function inspectProject(cwd, options = {}) {
|
|
41
|
+
const state = await loadProjectState(cwd);
|
|
32
42
|
const checks = [];
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
checks.push(configPath
|
|
41
|
-
? pass('deploid-config', 'Deploid config', `Found ${path.basename(configPath)}.`)
|
|
42
|
-
: fail('deploid-config', 'Deploid config', 'No Deploid config file was found.'));
|
|
43
|
-
if (config) {
|
|
44
|
-
checks.push(checkWebDir(cwd, config.web?.webDir));
|
|
45
|
-
checks.push(checkAssetsSource(cwd, config.assets?.source));
|
|
46
|
-
checks.push(checkSigning(cwd, config.android?.signing));
|
|
47
|
-
checks.push(checkCapacitorConfig(cwd, config.android?.packaging));
|
|
48
|
-
checks.push(checkAndroidProject(cwd));
|
|
43
|
+
const fixes = [];
|
|
44
|
+
checks.push(...collectProjectChecks(state));
|
|
45
|
+
if (state.config) {
|
|
46
|
+
checks.push(...collectConfigChecks(state));
|
|
47
|
+
checks.push(...collectConsistencyChecks(state));
|
|
48
|
+
checks.push(...collectReleaseChecks(state));
|
|
49
|
+
checks.push(...collectPluginChecks(state));
|
|
49
50
|
}
|
|
50
51
|
else {
|
|
51
|
-
checks.push(warn('web-output', 'Web output directory', 'Skipped because no Deploid config was loaded.'));
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
checks.push(
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
}
|
|
66
|
-
const totals = {
|
|
67
|
-
pass: checks.filter((check) => check.status === 'pass').length,
|
|
68
|
-
warn: checks.filter((check) => check.status === 'warn').length,
|
|
69
|
-
fail: checks.filter((check) => check.status === 'fail').length
|
|
70
|
-
};
|
|
52
|
+
checks.push(warn('web-output', 'Web output directory', 'Skipped because no Deploid config was loaded.', ['init', 'build']), warn('assets-source', 'Asset source', 'Skipped because no Deploid config was loaded.', ['init'], undefined, true), warn('android-signing', 'Android signing', 'Skipped because no Deploid config was loaded.', ['release']), warn('capacitor-config', 'Capacitor config', 'Skipped because no Deploid config was loaded.', ['build'], undefined, true), warn('android-project', 'Android project', 'Skipped because no Deploid config was loaded.', ['build', 'deploy']), warn('versioning', 'Version metadata', 'Skipped because no Deploid config was loaded.', ['release']), warn('publish-config', 'Publish config', 'Skipped because no Deploid config was loaded.', ['release']), warn('plugin-state', 'Plugin surface', 'Skipped because no Deploid config was loaded.', ['init', 'desktop']));
|
|
53
|
+
}
|
|
54
|
+
if (!options.projectOnly) {
|
|
55
|
+
checks.push(...collectToolingChecks(state));
|
|
56
|
+
}
|
|
57
|
+
if (options.fix) {
|
|
58
|
+
fixes.push(...applyFixes(state, checks));
|
|
59
|
+
if (fixes.some((fix) => fix.status === 'applied')) {
|
|
60
|
+
const refreshed = await inspectProject(cwd, { ...options, fix: false });
|
|
61
|
+
return { ...refreshed, fixes };
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
const totals = countStatuses(checks);
|
|
65
|
+
const workflows = buildWorkflowReadiness(checks);
|
|
71
66
|
return {
|
|
72
67
|
ok: totals.fail === 0,
|
|
73
68
|
cwd,
|
|
74
69
|
checks,
|
|
75
|
-
totals
|
|
70
|
+
totals,
|
|
71
|
+
workflows,
|
|
72
|
+
fixes
|
|
76
73
|
};
|
|
77
74
|
}
|
|
78
|
-
function
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
75
|
+
async function loadProjectState(cwd) {
|
|
76
|
+
const packageJsonPath = path.join(cwd, 'package.json');
|
|
77
|
+
const packageJson = readJson(packageJsonPath);
|
|
78
|
+
const configPath = findExistingPath(cwd, CONFIG_CANDIDATES);
|
|
79
|
+
const config = configPath ? await loadProjectConfig(configPath) : null;
|
|
80
|
+
const capacitorConfigPath = path.join(cwd, 'capacitor.config.json');
|
|
81
|
+
const capacitorConfig = readJson(capacitorConfigPath);
|
|
82
|
+
return {
|
|
83
|
+
cwd,
|
|
84
|
+
packageJsonPath,
|
|
85
|
+
packageJson,
|
|
86
|
+
configPath,
|
|
87
|
+
config,
|
|
88
|
+
capacitorConfigPath,
|
|
89
|
+
capacitorConfig,
|
|
90
|
+
androidDir: path.join(cwd, 'android'),
|
|
91
|
+
androidBuildGradlePath: path.join(cwd, 'android', 'app', 'build.gradle'),
|
|
92
|
+
packageDeps: {
|
|
93
|
+
...(asRecord(packageJson?.dependencies)),
|
|
94
|
+
...(asRecord(packageJson?.devDependencies))
|
|
95
|
+
},
|
|
96
|
+
packageScripts: asRecord(packageJson?.scripts)
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
function collectProjectChecks(state) {
|
|
100
|
+
return [
|
|
101
|
+
fs.existsSync(state.packageJsonPath)
|
|
102
|
+
? pass('package-json', 'package.json', 'Found package.json in project root.', ['init'])
|
|
103
|
+
: fail('package-json', 'package.json', 'package.json is missing from the project root.', ['init']),
|
|
104
|
+
state.configPath
|
|
105
|
+
? pass('deploid-config', 'Deploid config', `Found ${path.basename(state.configPath)}.`, ['init', 'build', 'release'])
|
|
106
|
+
: fail('deploid-config', 'Deploid config', 'No Deploid config file was found.', ['init', 'build', 'release'])
|
|
107
|
+
];
|
|
108
|
+
}
|
|
109
|
+
function collectConfigChecks(state) {
|
|
110
|
+
const config = state.config;
|
|
111
|
+
const checks = [];
|
|
112
|
+
checks.push(checkBuildCommand(state));
|
|
113
|
+
checks.push(checkWebDir(state));
|
|
114
|
+
checks.push(checkAssetsSource(state));
|
|
115
|
+
checks.push(checkCapacitorConfig(state));
|
|
116
|
+
checks.push(checkAndroidProject(state));
|
|
117
|
+
checks.push(checkSigning(state));
|
|
118
|
+
checks.push(checkVersioning(state));
|
|
119
|
+
return checks;
|
|
120
|
+
}
|
|
121
|
+
function collectConsistencyChecks(state) {
|
|
122
|
+
const checks = [];
|
|
123
|
+
const config = state.config;
|
|
124
|
+
const capacitorConfig = state.capacitorConfig;
|
|
125
|
+
if (config?.android?.packaging === 'capacitor' && capacitorConfig) {
|
|
126
|
+
const mismatches = [];
|
|
127
|
+
if (capacitorConfig.appId && capacitorConfig.appId !== config.appId)
|
|
128
|
+
mismatches.push(`appId=${String(capacitorConfig.appId)}`);
|
|
129
|
+
if (capacitorConfig.appName && capacitorConfig.appName !== config.appName)
|
|
130
|
+
mismatches.push(`appName=${String(capacitorConfig.appName)}`);
|
|
131
|
+
if (capacitorConfig.webDir && capacitorConfig.webDir !== config.web?.webDir)
|
|
132
|
+
mismatches.push(`webDir=${String(capacitorConfig.webDir)}`);
|
|
133
|
+
checks.push(mismatches.length === 0
|
|
134
|
+
? pass('capacitor-sync', 'Capacitor sync', 'Capacitor metadata matches Deploid config.', ['build', 'release'])
|
|
135
|
+
: warn('capacitor-sync', 'Capacitor sync', `Capacitor metadata differs from Deploid config (${mismatches.join(', ')}).`, ['build', 'release'], 'Run `deploid package` to resync generated native metadata.'));
|
|
136
|
+
}
|
|
137
|
+
const packageBuild = asRecord(state.packageJson?.build);
|
|
138
|
+
if (Object.keys(packageBuild).length > 0 && config) {
|
|
139
|
+
const mismatches = [];
|
|
140
|
+
if (packageBuild.appId && packageBuild.appId !== config.appId)
|
|
141
|
+
mismatches.push('build.appId');
|
|
142
|
+
if (packageBuild.productName && packageBuild.productName !== config.appName)
|
|
143
|
+
mismatches.push('build.productName');
|
|
144
|
+
checks.push(mismatches.length === 0
|
|
145
|
+
? pass('package-build-meta', 'Package metadata', 'package.json build metadata matches config.', ['desktop', 'release'])
|
|
146
|
+
: warn('package-build-meta', 'Package metadata', `package.json metadata differs from config (${mismatches.join(', ')}).`, ['desktop', 'release'], 'Align package.json and deploid.config.ts to avoid release drift.'));
|
|
147
|
+
}
|
|
148
|
+
if (fs.existsSync(state.androidBuildGradlePath) && config?.appId) {
|
|
149
|
+
const buildGradle = safeRead(state.androidBuildGradlePath);
|
|
150
|
+
const appIdMatch = buildGradle.match(/applicationId\s+"([^"]+)"/);
|
|
151
|
+
if (appIdMatch?.[1] === config.appId) {
|
|
152
|
+
checks.push(pass('android-app-id', 'Android appId', 'Gradle applicationId matches config.', ['build', 'release']));
|
|
153
|
+
}
|
|
154
|
+
else if (appIdMatch?.[1]) {
|
|
155
|
+
checks.push(warn('android-app-id', 'Android appId', `Gradle applicationId is ${appIdMatch[1]} but config uses ${config.appId}.`, ['build', 'release'], 'Run `deploid package` before your next build.'));
|
|
156
|
+
}
|
|
99
157
|
}
|
|
100
|
-
return
|
|
158
|
+
return checks;
|
|
101
159
|
}
|
|
102
|
-
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
160
|
+
function collectReleaseChecks(state) {
|
|
161
|
+
const config = state.config;
|
|
162
|
+
if (!config)
|
|
163
|
+
return [];
|
|
164
|
+
const checks = [];
|
|
165
|
+
const playConfig = config.publish?.play;
|
|
166
|
+
const githubConfig = config.publish?.github;
|
|
167
|
+
if (playConfig?.serviceAccountJson) {
|
|
168
|
+
const fullPath = path.join(state.cwd, playConfig.serviceAccountJson);
|
|
169
|
+
checks.push(fs.existsSync(fullPath)
|
|
170
|
+
? pass('play-service-account', 'Play credentials', `Found ${playConfig.serviceAccountJson}.`, ['release'])
|
|
171
|
+
: fail('play-service-account', 'Play credentials', `${playConfig.serviceAccountJson} does not exist.`, ['release'], 'Add the Play service account JSON before automating Play uploads.'));
|
|
107
172
|
}
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
return warn('android-sdk', 'Android SDK', `SDK found at ${sdkPath}, but platform-tools is missing.`, 'Install Android SDK Platform Tools to enable adb-based workflows.');
|
|
173
|
+
else {
|
|
174
|
+
checks.push(warn('play-service-account', 'Play credentials', 'No Play service account configured.', ['release']));
|
|
111
175
|
}
|
|
112
|
-
|
|
176
|
+
checks.push(githubConfig?.repo
|
|
177
|
+
? pass('github-release', 'GitHub release target', `Configured for ${githubConfig.repo}.`, ['release'])
|
|
178
|
+
: warn('github-release', 'GitHub release target', 'No GitHub release repo configured.', ['release']));
|
|
179
|
+
return checks;
|
|
113
180
|
}
|
|
114
|
-
function
|
|
115
|
-
|
|
116
|
-
|
|
181
|
+
function collectPluginChecks(state) {
|
|
182
|
+
const checks = [];
|
|
183
|
+
const config = state.config;
|
|
184
|
+
const deps = state.packageDeps;
|
|
185
|
+
const hasElectronFiles = fs.existsSync(path.join(state.cwd, 'electron'));
|
|
186
|
+
const hasDesktopScripts = ['electron:build', 'electron:build:win', 'electron:build:mac'].some((key) => typeof state.packageScripts[key] === 'string');
|
|
187
|
+
const usesCapacitor = config?.android?.packaging === 'capacitor';
|
|
188
|
+
if (usesCapacitor) {
|
|
189
|
+
checks.push(typeof deps['@capacitor/core'] === 'string' && typeof deps['@capacitor/cli'] === 'string'
|
|
190
|
+
? pass('capacitor-dependency', 'Capacitor packages', 'Capacitor dependencies are present.', ['build', 'deploy'])
|
|
191
|
+
: warn('capacitor-dependency', 'Capacitor packages', 'Capacitor dependencies are incomplete in package.json.', ['build', 'deploy'], 'Install @capacitor/core and @capacitor/cli in the app project.'));
|
|
117
192
|
}
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
193
|
+
if (hasElectronFiles || hasDesktopScripts) {
|
|
194
|
+
checks.push(typeof deps.electron === 'string' && typeof deps['electron-builder'] === 'string'
|
|
195
|
+
? pass('electron-dependency', 'Electron packages', 'Electron dependencies are present.', ['desktop'])
|
|
196
|
+
: warn('electron-dependency', 'Electron packages', 'Desktop packaging files exist but Electron dependencies are incomplete.', ['desktop'], 'Run `deploid electron` or install electron and electron-builder.'));
|
|
197
|
+
}
|
|
198
|
+
else {
|
|
199
|
+
checks.push(warn('electron-dependency', 'Electron packages', 'Desktop packaging is not configured.', ['desktop']));
|
|
200
|
+
}
|
|
201
|
+
return checks;
|
|
202
|
+
}
|
|
203
|
+
function collectToolingChecks(state) {
|
|
204
|
+
return [
|
|
205
|
+
checkCommand('node', ['--version'], 'Node.js', 'Required to run Deploid.', ['init', 'build', 'release', 'deploy', 'desktop']),
|
|
206
|
+
checkNpm(),
|
|
207
|
+
checkCommand('npx', ['--version'], 'npx', 'Used to invoke Capacitor CLI commands.', ['build', 'release']),
|
|
208
|
+
checkJava(),
|
|
209
|
+
checkAdb(),
|
|
210
|
+
checkAndroidSdk(),
|
|
211
|
+
checkGradleWrapper(state)
|
|
212
|
+
];
|
|
213
|
+
}
|
|
214
|
+
function buildWorkflowReadiness(checks) {
|
|
215
|
+
return Object.keys(WORKFLOW_TITLES).map((workflow) => {
|
|
216
|
+
const relevant = checks.filter((check) => check.workflows.includes(workflow));
|
|
217
|
+
const totals = countStatuses(relevant);
|
|
218
|
+
const total = relevant.length || 1;
|
|
219
|
+
const score = Math.max(0, Math.round(((totals.pass + totals.warn * 0.5) / total) * 100));
|
|
220
|
+
const status = totals.fail > 0 ? 'fail' : totals.warn > 0 ? 'warn' : 'pass';
|
|
221
|
+
const nextAction = relevant.find((check) => check.status !== 'pass')?.details || relevant.find((check) => check.status !== 'pass')?.message;
|
|
222
|
+
return {
|
|
223
|
+
id: workflow,
|
|
224
|
+
title: WORKFLOW_TITLES[workflow],
|
|
225
|
+
status,
|
|
226
|
+
score,
|
|
227
|
+
totals,
|
|
228
|
+
nextAction
|
|
229
|
+
};
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
function applyFixes(state, checks) {
|
|
233
|
+
const fixes = [];
|
|
234
|
+
const missingAssetsSource = checks.find((check) => check.id === 'assets-source' && check.status === 'fail');
|
|
235
|
+
if (missingAssetsSource) {
|
|
236
|
+
const source = state.config?.assets?.source;
|
|
237
|
+
if (source) {
|
|
238
|
+
const dir = path.join(state.cwd, path.dirname(source));
|
|
239
|
+
if (!fs.existsSync(dir)) {
|
|
240
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
241
|
+
fixes.push({ id: 'assets-dir', title: 'Asset directory', status: 'applied', message: `Created ${path.relative(state.cwd, dir)}.` });
|
|
242
|
+
}
|
|
243
|
+
else {
|
|
244
|
+
fixes.push({ id: 'assets-dir', title: 'Asset directory', status: 'skipped', message: 'Asset directory already exists.' });
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
const capacitorMissing = checks.find((check) => check.id === 'capacitor-config' && check.fixable && state.config?.android?.packaging === 'capacitor');
|
|
249
|
+
if (capacitorMissing && state.config) {
|
|
250
|
+
const webDir = state.config.web?.webDir || 'dist';
|
|
251
|
+
const nextConfig = {
|
|
252
|
+
appId: state.config.appId || 'com.example.myapp',
|
|
253
|
+
appName: state.config.appName || 'MyApp',
|
|
254
|
+
webDir,
|
|
255
|
+
bundledWebRuntime: false
|
|
256
|
+
};
|
|
257
|
+
if (!fs.existsSync(state.capacitorConfigPath)) {
|
|
258
|
+
fs.writeFileSync(state.capacitorConfigPath, `${JSON.stringify(nextConfig, null, 2)}\n`);
|
|
259
|
+
fixes.push({ id: 'capacitor-config', title: 'Capacitor config', status: 'applied', message: 'Created capacitor.config.json.' });
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
const signingWarn = checks.find((check) => check.id === 'android-signing' && check.status === 'warn' && state.config?.android?.signing);
|
|
263
|
+
if (signingWarn && state.config?.android?.signing) {
|
|
264
|
+
const envExamplePath = path.join(state.cwd, '.env.deploid.example');
|
|
265
|
+
const lines = [
|
|
266
|
+
'# Deploid signing placeholders',
|
|
267
|
+
state.config.android.signing.storePasswordEnv ? `${state.config.android.signing.storePasswordEnv}=replace-me` : null,
|
|
268
|
+
state.config.android.signing.keyPasswordEnv ? `${state.config.android.signing.keyPasswordEnv}=replace-me` : null
|
|
269
|
+
].filter((value) => Boolean(value));
|
|
270
|
+
if (lines.length > 1 && !fs.existsSync(envExamplePath)) {
|
|
271
|
+
fs.writeFileSync(envExamplePath, `${lines.join('\n')}\n`);
|
|
272
|
+
fixes.push({ id: 'signing-env-example', title: 'Signing env template', status: 'applied', message: 'Created .env.deploid.example.' });
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
if (fixes.length === 0) {
|
|
276
|
+
fixes.push({ id: 'noop', title: 'Auto-fix', status: 'skipped', message: 'No safe automatic fixes were available.' });
|
|
277
|
+
}
|
|
278
|
+
return fixes;
|
|
279
|
+
}
|
|
280
|
+
function printSummary(summary, options) {
|
|
281
|
+
const showPasses = options.verbose && !options.summary;
|
|
282
|
+
const showDetails = !options.summary;
|
|
283
|
+
console.log('Deploid Doctor');
|
|
284
|
+
console.log(`Project: ${summary.cwd}`);
|
|
285
|
+
console.log(`Status: ${summary.ok ? 'OK' : 'ACTION NEEDED'} (${summary.totals.pass} passed, ${summary.totals.warn} warnings, ${summary.totals.fail} failures)`);
|
|
286
|
+
console.log('');
|
|
287
|
+
console.log('Workflow readiness:');
|
|
288
|
+
for (const workflow of summary.workflows) {
|
|
289
|
+
console.log(` ${workflow.status.toUpperCase().padEnd(4, ' ')} ${workflow.title.padEnd(20, ' ')} ${String(workflow.score).padStart(3, ' ')}%`);
|
|
290
|
+
if (workflow.nextAction && showDetails) {
|
|
291
|
+
console.log(` ${workflow.nextAction}`);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
const categories = [
|
|
295
|
+
{ key: 'project', title: 'Project' },
|
|
296
|
+
{ key: 'release', title: 'Release' },
|
|
297
|
+
{ key: 'plugins', title: 'Plugins' },
|
|
298
|
+
{ key: 'tooling', title: 'Tooling' }
|
|
299
|
+
];
|
|
300
|
+
for (const category of categories) {
|
|
301
|
+
const rows = summary.checks.filter((check) => check.category === category.key && (showPasses || check.status !== 'pass'));
|
|
302
|
+
if (rows.length === 0)
|
|
303
|
+
continue;
|
|
304
|
+
console.log('');
|
|
305
|
+
console.log(`${category.title}:`);
|
|
306
|
+
for (const check of rows) {
|
|
307
|
+
console.log(` ${check.status.toUpperCase().padEnd(4, ' ')} ${check.title.padEnd(22, ' ')} ${check.message}`);
|
|
308
|
+
if (check.details && showDetails) {
|
|
309
|
+
console.log(` ${check.details}`);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
if (summary.fixes.length > 0) {
|
|
314
|
+
console.log('');
|
|
315
|
+
console.log('Fixes:');
|
|
316
|
+
for (const fix of summary.fixes) {
|
|
317
|
+
console.log(` ${fix.status.toUpperCase().padEnd(7, ' ')} ${fix.title}: ${fix.message}`);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
if (!summary.ok) {
|
|
321
|
+
console.log('');
|
|
322
|
+
console.log('Next actions:');
|
|
323
|
+
for (const check of summary.checks.filter((item) => item.status !== 'pass').slice(0, 6)) {
|
|
324
|
+
console.log(` - ${check.title}: ${check.details || check.message}`);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
function renderMarkdown(summary, options) {
|
|
329
|
+
const lines = [];
|
|
330
|
+
lines.push('# Deploid Doctor');
|
|
331
|
+
lines.push('');
|
|
332
|
+
lines.push(`- Project: \`${summary.cwd}\``);
|
|
333
|
+
lines.push(`- Status: **${summary.ok ? 'OK' : 'ACTION NEEDED'}**`);
|
|
334
|
+
lines.push(`- Totals: ${summary.totals.pass} passed, ${summary.totals.warn} warnings, ${summary.totals.fail} failures`);
|
|
335
|
+
lines.push('');
|
|
336
|
+
lines.push('## Workflow Readiness');
|
|
337
|
+
for (const workflow of summary.workflows) {
|
|
338
|
+
lines.push(`- ${workflow.title}: ${workflow.status.toUpperCase()} (${workflow.score}%)`);
|
|
339
|
+
if (workflow.nextAction && !options.summary)
|
|
340
|
+
lines.push(` ${workflow.nextAction}`);
|
|
341
|
+
}
|
|
342
|
+
const sections = ['project', 'release', 'plugins', 'tooling'];
|
|
343
|
+
for (const section of sections) {
|
|
344
|
+
const rows = summary.checks.filter((check) => check.category === section && (!options.summary || check.status !== 'pass'));
|
|
345
|
+
if (rows.length === 0)
|
|
346
|
+
continue;
|
|
347
|
+
lines.push('');
|
|
348
|
+
lines.push(`## ${capitalize(section)}`);
|
|
349
|
+
for (const row of rows) {
|
|
350
|
+
lines.push(`- ${row.status.toUpperCase()} ${row.title}: ${row.message}`);
|
|
351
|
+
if (row.details && !options.summary)
|
|
352
|
+
lines.push(` ${row.details}`);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
if (summary.fixes.length > 0) {
|
|
356
|
+
lines.push('');
|
|
357
|
+
lines.push('## Fixes');
|
|
358
|
+
for (const fix of summary.fixes)
|
|
359
|
+
lines.push(`- ${fix.status.toUpperCase()} ${fix.title}: ${fix.message}`);
|
|
360
|
+
}
|
|
361
|
+
return lines.join('\n');
|
|
362
|
+
}
|
|
363
|
+
function renderCi(summary) {
|
|
364
|
+
const lines = [
|
|
365
|
+
`DOCTOR_STATUS=${summary.ok ? 'ok' : 'action-needed'}`,
|
|
366
|
+
`DOCTOR_PASSED=${summary.totals.pass}`,
|
|
367
|
+
`DOCTOR_WARNINGS=${summary.totals.warn}`,
|
|
368
|
+
`DOCTOR_FAILURES=${summary.totals.fail}`
|
|
369
|
+
];
|
|
370
|
+
for (const workflow of summary.workflows) {
|
|
371
|
+
lines.push(`WORKFLOW_${workflow.id.toUpperCase()}=${workflow.status}:${workflow.score}`);
|
|
124
372
|
}
|
|
125
|
-
return
|
|
373
|
+
return lines.join('\n');
|
|
126
374
|
}
|
|
127
|
-
function
|
|
375
|
+
function checkBuildCommand(state) {
|
|
376
|
+
const buildCommand = state.config?.web?.buildCommand;
|
|
377
|
+
if (!buildCommand) {
|
|
378
|
+
return fail('build-command', 'Build command', 'No `web.buildCommand` configured.', ['init', 'build']);
|
|
379
|
+
}
|
|
380
|
+
const scriptName = inferScriptName(buildCommand);
|
|
381
|
+
if (scriptName && typeof state.packageScripts[scriptName] !== 'string') {
|
|
382
|
+
return warn('build-command', 'Build command', `Configured build command references missing script "${scriptName}".`, ['init', 'build'], 'Add the script to package.json or update `web.buildCommand`.');
|
|
383
|
+
}
|
|
384
|
+
return pass('build-command', 'Build command', `Configured build command: ${buildCommand}.`, ['init', 'build']);
|
|
385
|
+
}
|
|
386
|
+
function checkWebDir(state) {
|
|
387
|
+
const webDir = state.config?.web?.webDir;
|
|
128
388
|
if (!webDir) {
|
|
129
|
-
return
|
|
389
|
+
return fail('web-output', 'Web output directory', 'No `web.webDir` configured.', ['init', 'build']);
|
|
390
|
+
}
|
|
391
|
+
const fullPath = path.join(state.cwd, webDir);
|
|
392
|
+
if (!fs.existsSync(fullPath)) {
|
|
393
|
+
return warn('web-output', 'Web output directory', `${webDir} does not exist yet.`, ['build'], 'Run your web build before packaging if you expect ready-to-sync assets.');
|
|
130
394
|
}
|
|
131
|
-
const
|
|
132
|
-
if (fs.existsSync(
|
|
133
|
-
return
|
|
395
|
+
const indexPath = path.join(fullPath, 'index.html');
|
|
396
|
+
if (!fs.existsSync(indexPath)) {
|
|
397
|
+
return warn('web-output', 'Web output directory', `${webDir} exists but index.html is missing.`, ['build'], 'Check `web.webDir` or your framework build output.');
|
|
134
398
|
}
|
|
135
|
-
return
|
|
399
|
+
return pass('web-output', 'Web output directory', `Found ${webDir}.`, ['build']);
|
|
136
400
|
}
|
|
137
|
-
function checkAssetsSource(
|
|
401
|
+
function checkAssetsSource(state) {
|
|
402
|
+
const source = state.config?.assets?.source;
|
|
138
403
|
if (!source) {
|
|
139
|
-
return warn('assets-source', 'Asset source', 'No `assets.source` configured.');
|
|
404
|
+
return warn('assets-source', 'Asset source', 'No `assets.source` configured.', ['init'], undefined, true);
|
|
140
405
|
}
|
|
141
|
-
const sourcePath = path.join(cwd, source);
|
|
406
|
+
const sourcePath = path.join(state.cwd, source);
|
|
142
407
|
if (fs.existsSync(sourcePath)) {
|
|
143
|
-
return pass('assets-source', 'Asset source', `Found ${source}
|
|
408
|
+
return pass('assets-source', 'Asset source', `Found ${source}.`, ['init']);
|
|
144
409
|
}
|
|
145
|
-
return fail('assets-source', 'Asset source', `${source} does not exist.`, 'Add the source asset or update `assets.source` before running `deploid assets`.');
|
|
410
|
+
return fail('assets-source', 'Asset source', `${source} does not exist.`, ['init'], 'Add the source asset or update `assets.source` before running `deploid assets`.', true);
|
|
146
411
|
}
|
|
147
|
-
function checkSigning(
|
|
412
|
+
function checkSigning(state) {
|
|
413
|
+
const signing = state.config?.android?.signing;
|
|
148
414
|
if (!signing?.keystorePath) {
|
|
149
|
-
return warn('android-signing', 'Android signing', 'No Android signing config found.');
|
|
415
|
+
return warn('android-signing', 'Android signing', 'No Android signing config found.', ['release']);
|
|
150
416
|
}
|
|
151
|
-
const keystorePath = path.join(cwd, signing.keystorePath);
|
|
417
|
+
const keystorePath = path.join(state.cwd, signing.keystorePath);
|
|
152
418
|
const missingEnvVars = [signing.storePasswordEnv, signing.keyPasswordEnv]
|
|
153
419
|
.filter((name) => Boolean(name))
|
|
154
420
|
.filter((name) => !process.env[name]);
|
|
155
421
|
if (!fs.existsSync(keystorePath)) {
|
|
156
|
-
return fail('android-signing', 'Android signing', `Keystore file is missing: ${signing.keystorePath}.`, 'Create the keystore or fix `android.signing.keystorePath`.');
|
|
422
|
+
return fail('android-signing', 'Android signing', `Keystore file is missing: ${signing.keystorePath}.`, ['release'], 'Create the keystore or fix `android.signing.keystorePath`.');
|
|
157
423
|
}
|
|
158
424
|
if (missingEnvVars.length > 0) {
|
|
159
|
-
return warn('android-signing', 'Android signing', `Keystore found, but env vars are missing: ${missingEnvVars.join(', ')}.`, 'Release builds will fail until those password env vars are exported.');
|
|
425
|
+
return warn('android-signing', 'Android signing', `Keystore found, but env vars are missing: ${missingEnvVars.join(', ')}.`, ['release'], 'Release builds will fail until those password env vars are exported.');
|
|
160
426
|
}
|
|
161
|
-
return pass('android-signing', 'Android signing', 'Signing keystore and env vars look ready.');
|
|
427
|
+
return pass('android-signing', 'Android signing', 'Signing keystore and env vars look ready.', ['release']);
|
|
162
428
|
}
|
|
163
|
-
function checkCapacitorConfig(
|
|
164
|
-
if (packaging !== 'capacitor') {
|
|
165
|
-
return warn('capacitor-config', 'Capacitor config', `Packaging engine is ${packaging || 'unknown'}
|
|
429
|
+
function checkCapacitorConfig(state) {
|
|
430
|
+
if (state.config?.android?.packaging !== 'capacitor') {
|
|
431
|
+
return warn('capacitor-config', 'Capacitor config', `Packaging engine is ${state.config?.android?.packaging || 'unknown'}.`, ['build']);
|
|
166
432
|
}
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
433
|
+
if (fs.existsSync(state.capacitorConfigPath)) {
|
|
434
|
+
return pass('capacitor-config', 'Capacitor config', 'Found capacitor.config.json.', ['build']);
|
|
435
|
+
}
|
|
436
|
+
return warn('capacitor-config', 'Capacitor config', 'capacitor.config.json is missing.', ['build'], 'Run `deploid init`, `deploid package`, or `deploid doctor --fix` to scaffold Capacitor configuration.', true);
|
|
437
|
+
}
|
|
438
|
+
function checkAndroidProject(state) {
|
|
439
|
+
if (fs.existsSync(state.androidDir)) {
|
|
440
|
+
return pass('android-project', 'Android project', 'Found android/ project.', ['build', 'deploy']);
|
|
170
441
|
}
|
|
171
|
-
return warn('
|
|
442
|
+
return warn('android-project', 'Android project', 'android/ project has not been generated yet.', ['build', 'deploy'], 'Run `deploid package` before building or deploying Android artifacts.');
|
|
172
443
|
}
|
|
173
|
-
function
|
|
174
|
-
const
|
|
175
|
-
if (
|
|
176
|
-
return
|
|
444
|
+
function checkVersioning(state) {
|
|
445
|
+
const version = state.config?.android?.version;
|
|
446
|
+
if (!version?.code || !version?.name) {
|
|
447
|
+
return warn('versioning', 'Version metadata', 'Android version code/name are incomplete.', ['release']);
|
|
177
448
|
}
|
|
178
|
-
|
|
449
|
+
if (version.code < 1) {
|
|
450
|
+
return fail('versioning', 'Version metadata', 'Android version code must be >= 1.', ['release']);
|
|
451
|
+
}
|
|
452
|
+
return pass('versioning', 'Version metadata', `Configured version ${version.name} (${version.code}).`, ['release']);
|
|
179
453
|
}
|
|
180
|
-
function
|
|
181
|
-
|
|
454
|
+
function checkCommand(command, args, title, details, workflows) {
|
|
455
|
+
const result = spawnSync(command, args, { encoding: 'utf8' });
|
|
456
|
+
if (result.status === 0) {
|
|
457
|
+
const output = `${result.stdout || ''} ${result.stderr || ''}`.trim().split('\n')[0]?.trim();
|
|
458
|
+
return pass(command, title, `${command} is available.`, workflows, output || details);
|
|
459
|
+
}
|
|
460
|
+
return fail(command, title, `${command} is not available.`, workflows, result.error?.message || result.stderr?.trim() || details);
|
|
461
|
+
}
|
|
462
|
+
function checkNpm() {
|
|
463
|
+
const check = checkCommand('npm', ['--version'], 'npm', 'Used by init, plugin setup, and Capacitor workflows.', ['init', 'build', 'release', 'desktop']);
|
|
464
|
+
if (check.status === 'pass') {
|
|
465
|
+
const major = Number.parseInt((check.details || '').split('.')[0] || '0', 10);
|
|
466
|
+
if (major > 0 && major < 9) {
|
|
467
|
+
return warn('npm', 'npm', `npm ${check.details} is available but older than recommended.`, ['init', 'build', 'release', 'desktop']);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
return check;
|
|
182
471
|
}
|
|
183
|
-
function
|
|
184
|
-
|
|
472
|
+
function checkJava() {
|
|
473
|
+
const result = spawnSync('java', ['-version'], { encoding: 'utf8' });
|
|
474
|
+
if (result.status !== 0) {
|
|
475
|
+
return fail('java', 'Java', 'java is not available.', ['build', 'release'], result.error?.message || 'Install Java 17+ for Android builds.');
|
|
476
|
+
}
|
|
477
|
+
const firstLine = `${result.stdout || ''} ${result.stderr || ''}`.trim().split('\n')[0]?.trim();
|
|
478
|
+
const match = firstLine.match(/version "(\d+)/);
|
|
479
|
+
const major = Number(match?.[1] || '0');
|
|
480
|
+
if (major > 0 && major < 17) {
|
|
481
|
+
return warn('java', 'Java', `Java ${major} is installed but Java 17+ is recommended.`, ['build', 'release'], firstLine);
|
|
482
|
+
}
|
|
483
|
+
return pass('java', 'Java', 'java is available.', ['build', 'release'], firstLine);
|
|
185
484
|
}
|
|
186
|
-
function
|
|
187
|
-
|
|
485
|
+
function checkAdb() {
|
|
486
|
+
const version = checkCommand('adb', ['version'], 'ADB', 'Required for device listing, deploy, and logs.', ['deploy']);
|
|
487
|
+
if (version.status !== 'pass')
|
|
488
|
+
return version;
|
|
489
|
+
const devicesResult = spawnSync('adb', ['devices'], { encoding: 'utf8' });
|
|
490
|
+
const lines = `${devicesResult.stdout || ''}`.split('\n').filter((line) => /\t/.test(line));
|
|
491
|
+
const unauthorized = lines.filter((line) => line.includes('unauthorized') || line.includes('offline'));
|
|
492
|
+
if (unauthorized.length > 0) {
|
|
493
|
+
return warn('adb', 'ADB', `ADB is available but ${unauthorized.length} device(s) need attention.`, ['deploy'], unauthorized.join(', '));
|
|
494
|
+
}
|
|
495
|
+
if (lines.length === 0) {
|
|
496
|
+
return warn('adb', 'ADB', 'ADB is available but no devices are connected.', ['deploy']);
|
|
497
|
+
}
|
|
498
|
+
return pass('adb', 'ADB', `ADB is available with ${lines.length} connected device(s).`, ['deploy'], version.details);
|
|
499
|
+
}
|
|
500
|
+
function checkAndroidSdk() {
|
|
501
|
+
const envHome = process.env.ANDROID_HOME || process.env.ANDROID_SDK_ROOT;
|
|
502
|
+
const sdkPath = envHome || path.join(process.env.HOME || '', 'Android', 'Sdk');
|
|
503
|
+
if (!sdkPath || !fs.existsSync(sdkPath)) {
|
|
504
|
+
return fail('android-sdk', 'Android SDK', 'Android SDK directory was not found.', ['build', 'release', 'deploy'], 'Set ANDROID_HOME or ANDROID_SDK_ROOT, or install the SDK in ~/Android/Sdk.');
|
|
505
|
+
}
|
|
506
|
+
const platformToolsPath = path.join(sdkPath, 'platform-tools');
|
|
507
|
+
if (!fs.existsSync(platformToolsPath)) {
|
|
508
|
+
return warn('android-sdk', 'Android SDK', `SDK found at ${sdkPath}, but platform-tools is missing.`, ['build', 'release', 'deploy'], 'Install Android SDK Platform Tools to enable adb-based workflows.');
|
|
509
|
+
}
|
|
510
|
+
const hasBuildTools = fs.existsSync(path.join(sdkPath, 'build-tools'));
|
|
511
|
+
if (!hasBuildTools) {
|
|
512
|
+
return warn('android-sdk', 'Android SDK', `SDK found at ${sdkPath}, but build-tools is missing.`, ['build', 'release']);
|
|
513
|
+
}
|
|
514
|
+
return pass('android-sdk', 'Android SDK', `SDK found at ${sdkPath}.`, ['build', 'release', 'deploy']);
|
|
515
|
+
}
|
|
516
|
+
function checkGradleWrapper(state) {
|
|
517
|
+
if (!fs.existsSync(state.androidDir)) {
|
|
518
|
+
return warn('gradle-wrapper', 'Gradle wrapper', 'Skipped because android/ has not been generated yet.', ['build', 'release']);
|
|
519
|
+
}
|
|
520
|
+
const wrapper = path.join(state.androidDir, 'gradlew');
|
|
521
|
+
if (!fs.existsSync(wrapper)) {
|
|
522
|
+
return fail('gradle-wrapper', 'Gradle wrapper', 'android/ exists but gradlew is missing.', ['build', 'release']);
|
|
523
|
+
}
|
|
524
|
+
const result = spawnSync(wrapper, ['-v'], { cwd: state.androidDir, encoding: 'utf8' });
|
|
525
|
+
if (result.status !== 0) {
|
|
526
|
+
return warn('gradle-wrapper', 'Gradle wrapper', 'Gradle wrapper exists but did not respond cleanly.', ['build', 'release']);
|
|
527
|
+
}
|
|
528
|
+
const firstLine = `${result.stdout || ''}${result.stderr || ''}`.split('\n').find((line) => line.trim().length > 0)?.trim();
|
|
529
|
+
return pass('gradle-wrapper', 'Gradle wrapper', 'Gradle wrapper is present.', ['build', 'release'], firstLine);
|
|
530
|
+
}
|
|
531
|
+
function countStatuses(checks) {
|
|
532
|
+
return {
|
|
533
|
+
pass: checks.filter((check) => check.status === 'pass').length,
|
|
534
|
+
warn: checks.filter((check) => check.status === 'warn').length,
|
|
535
|
+
fail: checks.filter((check) => check.status === 'fail').length
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
function pass(id, title, message, workflows, details, fixable = false) {
|
|
539
|
+
return { id, category: categoryFor(id), title, status: 'pass', message, details, workflows, fixable };
|
|
540
|
+
}
|
|
541
|
+
function warn(id, title, message, workflows, details, fixable = false) {
|
|
542
|
+
return { id, category: categoryFor(id), title, status: 'warn', message, details, workflows, fixable };
|
|
543
|
+
}
|
|
544
|
+
function fail(id, title, message, workflows, details, fixable = false) {
|
|
545
|
+
return { id, category: categoryFor(id), title, status: 'fail', message, details, workflows, fixable };
|
|
546
|
+
}
|
|
547
|
+
function categoryFor(id) {
|
|
548
|
+
if (['node', 'npm', 'npx', 'java', 'adb', 'android-sdk', 'gradle-wrapper'].includes(id))
|
|
549
|
+
return 'tooling';
|
|
550
|
+
if (['capacitor-dependency', 'electron-dependency', 'plugin-state'].includes(id))
|
|
551
|
+
return 'plugins';
|
|
552
|
+
if (['android-signing', 'versioning', 'play-service-account', 'github-release', 'package-build-meta'].includes(id))
|
|
553
|
+
return 'release';
|
|
554
|
+
if (['build-command', 'capacitor-sync'].includes(id))
|
|
555
|
+
return 'workflows';
|
|
556
|
+
return 'project';
|
|
557
|
+
}
|
|
558
|
+
function inferScriptName(command) {
|
|
559
|
+
const match = command.match(/(?:npm|pnpm|bun)\s+run\s+([a-zA-Z0-9:_-]+)/) || command.match(/yarn\s+([a-zA-Z0-9:_-]+)/);
|
|
560
|
+
return match?.[1] || null;
|
|
188
561
|
}
|
|
189
562
|
function findExistingPath(cwd, candidates) {
|
|
190
563
|
for (const candidate of candidates) {
|
|
@@ -217,9 +590,20 @@ function readJson(filePath) {
|
|
|
217
590
|
return null;
|
|
218
591
|
}
|
|
219
592
|
}
|
|
593
|
+
function safeRead(filePath) {
|
|
594
|
+
try {
|
|
595
|
+
return fs.readFileSync(filePath, 'utf8');
|
|
596
|
+
}
|
|
597
|
+
catch {
|
|
598
|
+
return '';
|
|
599
|
+
}
|
|
600
|
+
}
|
|
220
601
|
function asRecord(value) {
|
|
221
602
|
return typeof value === 'object' && value !== null ? value : {};
|
|
222
603
|
}
|
|
604
|
+
function capitalize(value) {
|
|
605
|
+
return value.charAt(0).toUpperCase() + value.slice(1);
|
|
606
|
+
}
|
|
223
607
|
export default plugin;
|
|
224
608
|
export { inspectProject, plugin };
|
|
225
609
|
//# sourceMappingURL=index.js.map
|