@deploid/plugin-doctor 2.0.1 → 2.0.3

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.js CHANGED
@@ -1,215 +1,800 @@
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
- 'deploid.config.ts',
6
- 'deploid.config.js',
7
- 'deploid.config.mjs',
8
- 'deploid.config.cjs'
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 Deploid config',
14
- 'Check required command availability',
15
- 'Report Android setup and release readiness gaps'
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 summary = await inspectProject(ctx.cwd, ctx.doctorOptions);
21
- if (ctx.doctorOptions?.json) {
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(ctx, summary);
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 packageJsonPath = path.join(cwd, 'package.json');
34
- const configPath = findExistingPath(cwd, CONFIG_CANDIDATES);
35
- const packageJson = readJson(packageJsonPath);
36
- const config = configPath ? await loadProjectConfig(configPath) : null;
37
- checks.push(fs.existsSync(packageJsonPath)
38
- ? pass('package-json', 'package.json', 'Found package.json in project root.')
39
- : fail('package-json', 'package.json', 'package.json is missing from the project root.'));
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
- checks.push(warn('assets-source', 'Asset source', 'Skipped because no Deploid config was loaded.'));
53
- checks.push(warn('android-signing', 'Android signing', 'Skipped because no Deploid config was loaded.'));
54
- checks.push(warn('capacitor-config', 'Capacitor config', 'Skipped because no Deploid config was loaded.'));
55
- checks.push(warn('android-project', 'Android project', 'Skipped because no Deploid config was loaded.'));
56
- }
57
- if (!options?.projectOnly) {
58
- checks.push(await checkCommand('node', ['--version'], 'Node.js', 'Required to run Deploid.'));
59
- checks.push(await checkCommand('npm', ['--version'], 'npm', 'Used by init, plugin setup, and Capacitor workflows.'));
60
- checks.push(await checkCommand('npx', ['--version'], 'npx', 'Used to invoke Capacitor CLI commands.'));
61
- checks.push(await checkCommand('java', ['-version'], 'Java', 'Required for Android builds.'));
62
- checks.push(await checkCommand('adb', ['version'], 'ADB', 'Required for device listing, deploy, and logs.'));
63
- checks.push(await checkAndroidSdk());
64
- checks.push(checkCapacitorDependency(packageJson));
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 printSummary(ctx, summary) {
79
- const statusLabel = summary.ok ? 'OK' : 'ACTION NEEDED';
80
- const groups = [
81
- { key: 'project', title: 'Project' },
82
- { key: 'tooling', title: 'Tooling' }
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.', true));
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
+ }
157
+ }
158
+ return checks;
159
+ }
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.'));
172
+ }
173
+ else {
174
+ checks.push(warn('play-service-account', 'Play credentials', 'No Play service account configured.', ['release'], undefined, true));
175
+ }
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'], undefined, true));
179
+ return checks;
180
+ }
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.'));
192
+ }
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)
83
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 capacitorNeedsSync = checks.find((check) => ['capacitor-config', 'capacitor-sync'].includes(check.id) && check.fixable && state.config?.android?.packaging === 'capacitor');
249
+ if (capacitorNeedsSync && 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
+ const hadConfig = 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: `${hadConfig ? 'Synced' : 'Created'} capacitor.config.json.` });
260
+ }
261
+ if (state.configPath && state.config) {
262
+ const originalConfig = safeRead(state.configPath);
263
+ let updatedConfig = originalConfig;
264
+ if (checks.find((check) => check.id === 'android-signing' && check.status !== 'pass')) {
265
+ updatedConfig = ensureProperty(updatedConfig, ['android'], 'signing', {
266
+ keystorePath: state.config.android?.signing?.keystorePath || 'secrets/android-upload-keystore.jks',
267
+ alias: state.config.android?.signing?.alias || slugify(state.config.appName || 'upload'),
268
+ storePasswordEnv: state.config.android?.signing?.storePasswordEnv || 'DEPLOID_ANDROID_STORE_PASSWORD',
269
+ keyPasswordEnv: state.config.android?.signing?.keyPasswordEnv || 'DEPLOID_ANDROID_KEY_PASSWORD'
270
+ });
271
+ }
272
+ if (checks.find((check) => check.id === 'versioning' && check.status !== 'pass')) {
273
+ updatedConfig = ensureProperty(updatedConfig, ['android'], 'version', {
274
+ code: state.config.android?.version?.code && state.config.android.version.code >= 1 ? state.config.android.version.code : 1,
275
+ name: state.config.android?.version?.name || '1.0.0'
276
+ });
277
+ }
278
+ if (checks.find((check) => check.id === 'github-release' && check.status !== 'pass')) {
279
+ updatedConfig = ensureProperty(updatedConfig, ['publish'], 'github', {
280
+ repo: inferGithubRepo(state.cwd) || 'owner/repo',
281
+ draft: true
282
+ });
283
+ }
284
+ if (checks.find((check) => check.id === 'play-service-account' && check.status !== 'pass')) {
285
+ updatedConfig = ensureProperty(updatedConfig, ['publish'], 'play', {
286
+ track: state.config.publish?.play?.track || 'internal',
287
+ serviceAccountJson: state.config.publish?.play?.serviceAccountJson || 'secrets/play-service-account.json'
288
+ });
289
+ }
290
+ if (updatedConfig !== originalConfig) {
291
+ fs.writeFileSync(state.configPath, updatedConfig);
292
+ fixes.push({ id: 'release-config', title: 'Release config', status: 'applied', message: `Updated ${path.basename(state.configPath)} with release placeholders.` });
293
+ }
294
+ }
295
+ const envExamplePath = path.join(state.cwd, '.env.deploid.example');
296
+ const envLines = [
297
+ '# Deploid signing placeholders',
298
+ state.config?.android?.signing?.storePasswordEnv ? `${state.config.android.signing.storePasswordEnv}=replace-me` : 'DEPLOID_ANDROID_STORE_PASSWORD=replace-me',
299
+ state.config?.android?.signing?.keyPasswordEnv ? `${state.config.android.signing.keyPasswordEnv}=replace-me` : 'DEPLOID_ANDROID_KEY_PASSWORD=replace-me',
300
+ state.config?.publish?.play?.serviceAccountJson ? `GOOGLE_PLAY_SERVICE_ACCOUNT_JSON=${state.config.publish.play.serviceAccountJson}` : null
301
+ ].filter((value) => Boolean(value));
302
+ if (envLines.length > 1) {
303
+ const existing = fs.existsSync(envExamplePath) ? safeRead(envExamplePath) : '';
304
+ const missingLines = envLines.filter((line) => !existing.includes(line));
305
+ if (!fs.existsSync(envExamplePath)) {
306
+ fs.writeFileSync(envExamplePath, `${envLines.join('\n')}\n`);
307
+ fixes.push({ id: 'signing-env-example', title: 'Signing env template', status: 'applied', message: 'Created .env.deploid.example.' });
308
+ }
309
+ else if (missingLines.length > 0) {
310
+ const needsBreak = existing.length > 0 && !existing.endsWith('\n');
311
+ fs.appendFileSync(envExamplePath, `${needsBreak ? '\n' : ''}${missingLines.join('\n')}\n`);
312
+ fixes.push({ id: 'signing-env-example', title: 'Signing env template', status: 'applied', message: 'Updated .env.deploid.example.' });
313
+ }
314
+ }
315
+ const sensitivePaths = [
316
+ state.config?.android?.signing?.keystorePath || 'secrets/android-upload-keystore.jks',
317
+ state.config?.publish?.play?.serviceAccountJson || 'secrets/play-service-account.json',
318
+ '.env.deploid',
319
+ '.env.deploid.local'
320
+ ].filter((value) => Boolean(value));
321
+ if (sensitivePaths.length > 0) {
322
+ const gitignorePath = path.join(state.cwd, '.gitignore');
323
+ const existing = fs.existsSync(gitignorePath) ? safeRead(gitignorePath) : '';
324
+ const missingEntries = sensitivePaths.filter((entry) => !existing.includes(entry));
325
+ if (missingEntries.length > 0) {
326
+ const needsBreak = existing.length > 0 && !existing.endsWith('\n');
327
+ fs.appendFileSync(gitignorePath, `${needsBreak ? '\n' : ''}${missingEntries.join('\n')}\n`);
328
+ fixes.push({ id: 'gitignore-release', title: 'Release gitignore', status: 'applied', message: 'Updated .gitignore with release-sensitive paths.' });
329
+ }
330
+ }
331
+ const playServiceAccount = state.config?.publish?.play?.serviceAccountJson || 'secrets/play-service-account.json';
332
+ if (playServiceAccount) {
333
+ const secretsDir = path.join(state.cwd, path.dirname(playServiceAccount));
334
+ if (!fs.existsSync(secretsDir)) {
335
+ fs.mkdirSync(secretsDir, { recursive: true });
336
+ fixes.push({ id: 'secrets-dir', title: 'Secrets directory', status: 'applied', message: `Created ${path.relative(state.cwd, secretsDir)}.` });
337
+ }
338
+ }
339
+ if (fixes.length === 0) {
340
+ fixes.push({ id: 'noop', title: 'Auto-fix', status: 'skipped', message: 'No safe automatic fixes were available.' });
341
+ }
342
+ return fixes;
343
+ }
344
+ function printSummary(summary, options) {
345
+ const showPasses = options.verbose && !options.summary;
346
+ const showDetails = !options.summary;
84
347
  console.log('Deploid Doctor');
85
348
  console.log(`Project: ${summary.cwd}`);
86
- console.log(`Status: ${statusLabel} (${summary.totals.pass} passed, ${summary.totals.warn} warnings, ${summary.totals.fail} failures)`);
87
- for (const group of groups) {
88
- const checks = summary.checks.filter((check) => check.category === group.key);
89
- if (checks.length === 0)
349
+ console.log(`Status: ${summary.ok ? 'OK' : 'ACTION NEEDED'} (${summary.totals.pass} passed, ${summary.totals.warn} warnings, ${summary.totals.fail} failures)`);
350
+ console.log('');
351
+ console.log('Workflow readiness:');
352
+ for (const workflow of summary.workflows) {
353
+ console.log(` ${workflow.status.toUpperCase().padEnd(4, ' ')} ${workflow.title.padEnd(20, ' ')} ${String(workflow.score).padStart(3, ' ')}%`);
354
+ if (workflow.nextAction && showDetails) {
355
+ console.log(` ${workflow.nextAction}`);
356
+ }
357
+ }
358
+ const categories = [
359
+ { key: 'project', title: 'Project' },
360
+ { key: 'release', title: 'Release' },
361
+ { key: 'plugins', title: 'Plugins' },
362
+ { key: 'tooling', title: 'Tooling' }
363
+ ];
364
+ for (const category of categories) {
365
+ const rows = summary.checks.filter((check) => check.category === category.key && (showPasses || check.status !== 'pass'));
366
+ if (rows.length === 0)
90
367
  continue;
91
368
  console.log('');
92
- console.log(`${group.title}:`);
93
- for (const check of checks) {
94
- const prefix = check.status.toUpperCase().padEnd(4, ' ');
95
- const title = check.title.padEnd(22, ' ');
96
- console.log(` ${prefix} ${title} ${check.message}`);
97
- if (check.details && check.status !== 'pass') {
98
- console.log(` ${check.details}`);
99
- }
100
- else if (check.details && ctx.debug) {
369
+ console.log(`${category.title}:`);
370
+ for (const check of rows) {
371
+ console.log(` ${check.status.toUpperCase().padEnd(4, ' ')} ${check.title.padEnd(22, ' ')} ${check.message}`);
372
+ if (check.details && showDetails) {
101
373
  console.log(` ${check.details}`);
102
374
  }
103
375
  }
104
376
  }
377
+ if (summary.fixes.length > 0) {
378
+ console.log('');
379
+ console.log('Fixes:');
380
+ for (const fix of summary.fixes) {
381
+ console.log(` ${fix.status.toUpperCase().padEnd(7, ' ')} ${fix.title}: ${fix.message}`);
382
+ }
383
+ }
105
384
  if (!summary.ok) {
106
- const actions = summary.checks.filter((check) => check.status !== 'pass');
107
385
  console.log('');
108
386
  console.log('Next actions:');
109
- for (const check of actions) {
387
+ for (const check of summary.checks.filter((item) => item.status !== 'pass').slice(0, 6)) {
110
388
  console.log(` - ${check.title}: ${check.details || check.message}`);
111
389
  }
112
390
  }
113
391
  }
114
- async function checkCommand(command, args, title, details) {
115
- const result = spawnSync(command, args, { encoding: 'utf8' });
116
- if (result.status === 0) {
117
- const output = `${result.stdout || ''} ${result.stderr || ''}`.trim().split('\n')[0]?.trim();
118
- return pass(command, title, `${command} is available.`, output || details);
392
+ function renderMarkdown(summary, options) {
393
+ const lines = [];
394
+ lines.push('# Deploid Doctor');
395
+ lines.push('');
396
+ lines.push(`- Project: \`${summary.cwd}\``);
397
+ lines.push(`- Status: **${summary.ok ? 'OK' : 'ACTION NEEDED'}**`);
398
+ lines.push(`- Totals: ${summary.totals.pass} passed, ${summary.totals.warn} warnings, ${summary.totals.fail} failures`);
399
+ lines.push('');
400
+ lines.push('## Workflow Readiness');
401
+ for (const workflow of summary.workflows) {
402
+ lines.push(`- ${workflow.title}: ${workflow.status.toUpperCase()} (${workflow.score}%)`);
403
+ if (workflow.nextAction && !options.summary)
404
+ lines.push(` ${workflow.nextAction}`);
119
405
  }
120
- return fail(command, title, `${command} is not available.`, result.error?.message || result.stderr?.trim() || details);
121
- }
122
- async function checkAndroidSdk() {
123
- const envHome = process.env.ANDROID_HOME || process.env.ANDROID_SDK_ROOT;
124
- const sdkPath = envHome || path.join(process.env.HOME || '', 'Android', 'Sdk');
125
- if (!sdkPath || !fs.existsSync(sdkPath)) {
126
- return fail('android-sdk', 'Android SDK', 'Android SDK directory was not found.', 'Set ANDROID_HOME or ANDROID_SDK_ROOT, or install the SDK in ~/Android/Sdk.');
406
+ const sections = ['project', 'release', 'plugins', 'tooling'];
407
+ for (const section of sections) {
408
+ const rows = summary.checks.filter((check) => check.category === section && (!options.summary || check.status !== 'pass'));
409
+ if (rows.length === 0)
410
+ continue;
411
+ lines.push('');
412
+ lines.push(`## ${capitalize(section)}`);
413
+ for (const row of rows) {
414
+ lines.push(`- ${row.status.toUpperCase()} ${row.title}: ${row.message}`);
415
+ if (row.details && !options.summary)
416
+ lines.push(` ${row.details}`);
417
+ }
127
418
  }
128
- const platformToolsPath = path.join(sdkPath, 'platform-tools');
129
- if (!fs.existsSync(platformToolsPath)) {
130
- 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.');
419
+ if (summary.fixes.length > 0) {
420
+ lines.push('');
421
+ lines.push('## Fixes');
422
+ for (const fix of summary.fixes)
423
+ lines.push(`- ${fix.status.toUpperCase()} ${fix.title}: ${fix.message}`);
131
424
  }
132
- return pass('android-sdk', 'Android SDK', `SDK found at ${sdkPath}.`);
425
+ return lines.join('\n');
133
426
  }
134
- function checkCapacitorDependency(packageJson) {
135
- if (!packageJson) {
136
- return warn('capacitor-dependency', 'Capacitor dependency', 'Skipped because package.json could not be read.');
427
+ function renderCi(summary) {
428
+ const lines = [
429
+ `DOCTOR_STATUS=${summary.ok ? 'ok' : 'action-needed'}`,
430
+ `DOCTOR_PASSED=${summary.totals.pass}`,
431
+ `DOCTOR_WARNINGS=${summary.totals.warn}`,
432
+ `DOCTOR_FAILURES=${summary.totals.fail}`
433
+ ];
434
+ for (const workflow of summary.workflows) {
435
+ lines.push(`WORKFLOW_${workflow.id.toUpperCase()}=${workflow.status}:${workflow.score}`);
137
436
  }
138
- const deps = {
139
- ...(asRecord(packageJson.dependencies)),
140
- ...(asRecord(packageJson.devDependencies))
141
- };
142
- if (typeof deps['@capacitor/core'] === 'string' || typeof deps['@capacitor/cli'] === 'string') {
143
- return pass('capacitor-dependency', 'Capacitor dependency', 'Capacitor dependencies are present.');
437
+ return lines.join('\n');
438
+ }
439
+ function checkBuildCommand(state) {
440
+ const buildCommand = state.config?.web?.buildCommand;
441
+ if (!buildCommand) {
442
+ return fail('build-command', 'Build command', 'No `web.buildCommand` configured.', ['init', 'build']);
443
+ }
444
+ const scriptName = inferScriptName(buildCommand);
445
+ if (scriptName && typeof state.packageScripts[scriptName] !== 'string') {
446
+ 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`.');
144
447
  }
145
- return warn('capacitor-dependency', 'Capacitor dependency', 'No Capacitor dependency found in package.json.', 'Run `deploid init` or install @capacitor/core and @capacitor/cli if this project targets Android.');
448
+ return pass('build-command', 'Build command', `Configured build command: ${buildCommand}.`, ['init', 'build']);
146
449
  }
147
- function checkWebDir(cwd, webDir) {
450
+ function checkWebDir(state) {
451
+ const webDir = state.config?.web?.webDir;
148
452
  if (!webDir) {
149
- return warn('web-output', 'Web output directory', 'No `web.webDir` configured.');
453
+ return fail('web-output', 'Web output directory', 'No `web.webDir` configured.', ['init', 'build']);
150
454
  }
151
- const fullPath = path.join(cwd, webDir);
152
- if (fs.existsSync(fullPath)) {
153
- return pass('web-output', 'Web output directory', `Found ${webDir}.`);
455
+ const fullPath = path.join(state.cwd, webDir);
456
+ if (!fs.existsSync(fullPath)) {
457
+ 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.');
154
458
  }
155
- return warn('web-output', 'Web output directory', `${webDir} does not exist yet.`, 'Run your web build before packaging if you expect a ready-to-sync output directory.');
459
+ const indexPath = path.join(fullPath, 'index.html');
460
+ if (!fs.existsSync(indexPath)) {
461
+ return warn('web-output', 'Web output directory', `${webDir} exists but index.html is missing.`, ['build'], 'Check `web.webDir` or your framework build output.');
462
+ }
463
+ return pass('web-output', 'Web output directory', `Found ${webDir}.`, ['build']);
156
464
  }
157
- function checkAssetsSource(cwd, source) {
465
+ function checkAssetsSource(state) {
466
+ const source = state.config?.assets?.source;
158
467
  if (!source) {
159
- return warn('assets-source', 'Asset source', 'No `assets.source` configured.');
468
+ return warn('assets-source', 'Asset source', 'No `assets.source` configured.', ['init'], undefined, true);
160
469
  }
161
- const sourcePath = path.join(cwd, source);
470
+ const sourcePath = path.join(state.cwd, source);
162
471
  if (fs.existsSync(sourcePath)) {
163
- return pass('assets-source', 'Asset source', `Found ${source}.`);
472
+ return pass('assets-source', 'Asset source', `Found ${source}.`, ['init']);
164
473
  }
165
- return fail('assets-source', 'Asset source', `${source} does not exist.`, 'Add the source asset or update `assets.source` before running `deploid assets`.');
474
+ return fail('assets-source', 'Asset source', `${source} does not exist.`, ['init'], 'Add the source asset or update `assets.source` before running `deploid assets`.', true);
166
475
  }
167
- function checkSigning(cwd, signing) {
476
+ function checkSigning(state) {
477
+ const signing = state.config?.android?.signing;
168
478
  if (!signing?.keystorePath) {
169
- return warn('android-signing', 'Android signing', 'No Android signing config found.');
479
+ return warn('android-signing', 'Android signing', 'No Android signing config found.', ['release'], undefined, true);
170
480
  }
171
- const keystorePath = path.join(cwd, signing.keystorePath);
481
+ const keystorePath = path.join(state.cwd, signing.keystorePath);
172
482
  const missingEnvVars = [signing.storePasswordEnv, signing.keyPasswordEnv]
173
483
  .filter((name) => Boolean(name))
174
484
  .filter((name) => !process.env[name]);
175
485
  if (!fs.existsSync(keystorePath)) {
176
- return fail('android-signing', 'Android signing', `Keystore file is missing: ${signing.keystorePath}.`, 'Create the keystore or fix `android.signing.keystorePath`.');
486
+ return fail('android-signing', 'Android signing', `Keystore file is missing: ${signing.keystorePath}.`, ['release'], 'Create the keystore or fix `android.signing.keystorePath`.', true);
177
487
  }
178
488
  if (missingEnvVars.length > 0) {
179
- 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.');
489
+ 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.', true);
180
490
  }
181
- return pass('android-signing', 'Android signing', 'Signing keystore and env vars look ready.');
491
+ return pass('android-signing', 'Android signing', 'Signing keystore and env vars look ready.', ['release']);
182
492
  }
183
- function checkCapacitorConfig(cwd, packaging) {
184
- if (packaging !== 'capacitor') {
185
- return warn('capacitor-config', 'Capacitor config', `Packaging engine is ${packaging || 'unknown'}.`);
493
+ function checkCapacitorConfig(state) {
494
+ if (state.config?.android?.packaging !== 'capacitor') {
495
+ return warn('capacitor-config', 'Capacitor config', `Packaging engine is ${state.config?.android?.packaging || 'unknown'}.`, ['build']);
186
496
  }
187
- const capacitorConfigPath = path.join(cwd, 'capacitor.config.json');
188
- if (fs.existsSync(capacitorConfigPath)) {
189
- return pass('capacitor-config', 'Capacitor config', 'Found capacitor.config.json.');
497
+ if (fs.existsSync(state.capacitorConfigPath)) {
498
+ return pass('capacitor-config', 'Capacitor config', 'Found capacitor.config.json.', ['build']);
499
+ }
500
+ 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);
501
+ }
502
+ function checkAndroidProject(state) {
503
+ if (fs.existsSync(state.androidDir)) {
504
+ return pass('android-project', 'Android project', 'Found android/ project.', ['build', 'deploy']);
505
+ }
506
+ return warn('android-project', 'Android project', 'android/ project has not been generated yet.', ['build', 'deploy'], 'Run `deploid package` before building or deploying Android artifacts.');
507
+ }
508
+ function checkVersioning(state) {
509
+ const version = state.config?.android?.version;
510
+ if (!version?.code || !version?.name) {
511
+ return warn('versioning', 'Version metadata', 'Android version code/name are incomplete.', ['release'], undefined, true);
512
+ }
513
+ if (version.code < 1) {
514
+ return fail('versioning', 'Version metadata', 'Android version code must be >= 1.', ['release'], undefined, true);
515
+ }
516
+ return pass('versioning', 'Version metadata', `Configured version ${version.name} (${version.code}).`, ['release']);
517
+ }
518
+ function checkCommand(command, args, title, details, workflows) {
519
+ const result = spawnSync(command, args, { encoding: 'utf8' });
520
+ if (result.status === 0) {
521
+ const output = `${result.stdout || ''} ${result.stderr || ''}`.trim().split('\n')[0]?.trim();
522
+ return pass(command, title, `${command} is available.`, workflows, output || details);
190
523
  }
191
- return warn('capacitor-config', 'Capacitor config', 'capacitor.config.json is missing.', 'Run `deploid init` or `deploid package` to scaffold Capacitor configuration.');
524
+ return fail(command, title, `${command} is not available.`, workflows, result.error?.message || result.stderr?.trim() || details);
192
525
  }
193
- function checkAndroidProject(cwd) {
194
- const androidPath = path.join(cwd, 'android');
195
- if (fs.existsSync(androidPath)) {
196
- return pass('android-project', 'Android project', 'Found android/ project.');
526
+ function checkNpm() {
527
+ const check = checkCommand('npm', ['--version'], 'npm', 'Used by init, plugin setup, and Capacitor workflows.', ['init', 'build', 'release', 'desktop']);
528
+ if (check.status === 'pass') {
529
+ const major = Number.parseInt((check.details || '').split('.')[0] || '0', 10);
530
+ if (major > 0 && major < 9) {
531
+ return warn('npm', 'npm', `npm ${check.details} is available but older than recommended.`, ['init', 'build', 'release', 'desktop']);
532
+ }
533
+ }
534
+ return check;
535
+ }
536
+ function checkJava() {
537
+ const result = spawnSync('java', ['-version'], { encoding: 'utf8' });
538
+ if (result.status !== 0) {
539
+ return fail('java', 'Java', 'java is not available.', ['build', 'release'], result.error?.message || 'Install Java 17+ for Android builds.');
540
+ }
541
+ const firstLine = `${result.stdout || ''} ${result.stderr || ''}`.trim().split('\n')[0]?.trim();
542
+ const match = firstLine.match(/version "(\d+)/);
543
+ const major = Number(match?.[1] || '0');
544
+ if (major > 0 && major < 17) {
545
+ return warn('java', 'Java', `Java ${major} is installed but Java 17+ is recommended.`, ['build', 'release'], firstLine);
546
+ }
547
+ return pass('java', 'Java', 'java is available.', ['build', 'release'], firstLine);
548
+ }
549
+ function checkAdb() {
550
+ const version = checkCommand('adb', ['version'], 'ADB', 'Required for device listing, deploy, and logs.', ['deploy']);
551
+ if (version.status !== 'pass')
552
+ return version;
553
+ const devicesResult = spawnSync('adb', ['devices'], { encoding: 'utf8' });
554
+ const lines = `${devicesResult.stdout || ''}`.split('\n').filter((line) => /\t/.test(line));
555
+ const unauthorized = lines.filter((line) => line.includes('unauthorized') || line.includes('offline'));
556
+ if (unauthorized.length > 0) {
557
+ return warn('adb', 'ADB', `ADB is available but ${unauthorized.length} device(s) need attention.`, ['deploy'], unauthorized.join(', '));
197
558
  }
198
- return warn('android-project', 'Android project', 'android/ project has not been generated yet.', 'Run `deploid package` before building or deploying Android artifacts.');
559
+ if (lines.length === 0) {
560
+ return warn('adb', 'ADB', 'ADB is available but no devices are connected.', ['deploy']);
561
+ }
562
+ return pass('adb', 'ADB', `ADB is available with ${lines.length} connected device(s).`, ['deploy'], version.details);
563
+ }
564
+ function checkAndroidSdk() {
565
+ const envHome = process.env.ANDROID_HOME || process.env.ANDROID_SDK_ROOT;
566
+ const sdkPath = envHome || path.join(process.env.HOME || '', 'Android', 'Sdk');
567
+ if (!sdkPath || !fs.existsSync(sdkPath)) {
568
+ 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.');
569
+ }
570
+ const platformToolsPath = path.join(sdkPath, 'platform-tools');
571
+ if (!fs.existsSync(platformToolsPath)) {
572
+ 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.');
573
+ }
574
+ const hasBuildTools = fs.existsSync(path.join(sdkPath, 'build-tools'));
575
+ if (!hasBuildTools) {
576
+ return warn('android-sdk', 'Android SDK', `SDK found at ${sdkPath}, but build-tools is missing.`, ['build', 'release']);
577
+ }
578
+ return pass('android-sdk', 'Android SDK', `SDK found at ${sdkPath}.`, ['build', 'release', 'deploy']);
579
+ }
580
+ function checkGradleWrapper(state) {
581
+ if (!fs.existsSync(state.androidDir)) {
582
+ return warn('gradle-wrapper', 'Gradle wrapper', 'Skipped because android/ has not been generated yet.', ['build', 'release']);
583
+ }
584
+ const wrapper = path.join(state.androidDir, 'gradlew');
585
+ if (!fs.existsSync(wrapper)) {
586
+ return fail('gradle-wrapper', 'Gradle wrapper', 'android/ exists but gradlew is missing.', ['build', 'release']);
587
+ }
588
+ const result = spawnSync(wrapper, ['-v'], { cwd: state.androidDir, encoding: 'utf8' });
589
+ if (result.status !== 0) {
590
+ return warn('gradle-wrapper', 'Gradle wrapper', 'Gradle wrapper exists but did not respond cleanly.', ['build', 'release']);
591
+ }
592
+ const firstLine = `${result.stdout || ''}${result.stderr || ''}`.split('\n').find((line) => line.trim().length > 0)?.trim();
593
+ return pass('gradle-wrapper', 'Gradle wrapper', 'Gradle wrapper is present.', ['build', 'release'], firstLine);
594
+ }
595
+ function countStatuses(checks) {
596
+ return {
597
+ pass: checks.filter((check) => check.status === 'pass').length,
598
+ warn: checks.filter((check) => check.status === 'warn').length,
599
+ fail: checks.filter((check) => check.status === 'fail').length
600
+ };
601
+ }
602
+ function pass(id, title, message, workflows, details, fixable = false) {
603
+ return { id, category: categoryFor(id), title, status: 'pass', message, details, workflows, fixable };
604
+ }
605
+ function warn(id, title, message, workflows, details, fixable = false) {
606
+ return { id, category: categoryFor(id), title, status: 'warn', message, details, workflows, fixable };
607
+ }
608
+ function fail(id, title, message, workflows, details, fixable = false) {
609
+ return { id, category: categoryFor(id), title, status: 'fail', message, details, workflows, fixable };
199
610
  }
200
611
  function categoryFor(id) {
201
- return ['node', 'npm', 'npx', 'java', 'adb', 'android-sdk', 'capacitor-dependency'].includes(id)
202
- ? 'tooling'
203
- : 'project';
612
+ if (['node', 'npm', 'npx', 'java', 'adb', 'android-sdk', 'gradle-wrapper'].includes(id))
613
+ return 'tooling';
614
+ if (['capacitor-dependency', 'electron-dependency', 'plugin-state'].includes(id))
615
+ return 'plugins';
616
+ if (['android-signing', 'versioning', 'play-service-account', 'github-release', 'package-build-meta'].includes(id))
617
+ return 'release';
618
+ if (['build-command', 'capacitor-sync'].includes(id))
619
+ return 'workflows';
620
+ return 'project';
621
+ }
622
+ function ensureProperty(source, pathSegments, propertyName, value) {
623
+ let current = source;
624
+ for (let index = 0; index < pathSegments.length; index += 1) {
625
+ const parentPath = pathSegments.slice(0, index);
626
+ current = ensureObjectProperty(current, parentPath, pathSegments[index]);
627
+ }
628
+ return ensureValueProperty(current, pathSegments, propertyName, value);
629
+ }
630
+ function ensureObjectProperty(source, parentPath, propertyName) {
631
+ const parentRange = findObjectRangeByPath(source, parentPath);
632
+ if (!parentRange) {
633
+ throw new Error(`Unable to find config object path: ${parentPath.join('.') || 'root'}`);
634
+ }
635
+ const body = source.slice(parentRange.openBraceIndex + 1, parentRange.closeBraceIndex);
636
+ if (hasProperty(body, propertyName)) {
637
+ return source;
638
+ }
639
+ const parentIndent = lineIndentAt(source, parentRange.openBraceIndex);
640
+ const childIndent = `${parentIndent} `;
641
+ const insertion = `\n${childIndent}${propertyName}: {},`;
642
+ return `${source.slice(0, parentRange.closeBraceIndex)}${insertion}\n${parentIndent}${source.slice(parentRange.closeBraceIndex)}`;
643
+ }
644
+ function ensureValueProperty(source, parentPath, propertyName, value) {
645
+ const parentRange = findObjectRangeByPath(source, parentPath);
646
+ if (!parentRange) {
647
+ throw new Error(`Unable to find config object path: ${parentPath.join('.') || 'root'}`);
648
+ }
649
+ const body = source.slice(parentRange.openBraceIndex + 1, parentRange.closeBraceIndex);
650
+ if (hasProperty(body, propertyName)) {
651
+ return source;
652
+ }
653
+ const parentIndent = lineIndentAt(source, parentRange.openBraceIndex);
654
+ const childIndent = `${parentIndent} `;
655
+ const renderedValue = renderValue(value, childIndent);
656
+ const insertion = `\n${childIndent}${propertyName}: ${renderedValue},`;
657
+ return `${source.slice(0, parentRange.closeBraceIndex)}${insertion}\n${parentIndent}${source.slice(parentRange.closeBraceIndex)}`;
658
+ }
659
+ function hasProperty(objectBody, propertyName) {
660
+ return new RegExp(`(^|\\n)\\s*${escapeRegExp(propertyName)}\\s*:`, 'm').test(objectBody);
204
661
  }
205
- function pass(id, title, message, details) {
206
- return { id, category: categoryFor(id), title, status: 'pass', message, details };
662
+ function findObjectRangeByPath(source, pathSegments) {
663
+ let currentRange = findRootObjectRange(source);
664
+ if (!currentRange)
665
+ return null;
666
+ for (const segment of pathSegments) {
667
+ const body = source.slice(currentRange.openBraceIndex + 1, currentRange.closeBraceIndex);
668
+ const propertyPattern = new RegExp(`(^|\\n)(\\s*)${escapeRegExp(segment)}\\s*:\\s*\\{`, 'm');
669
+ const match = propertyPattern.exec(body);
670
+ if (!match || typeof match.index !== 'number')
671
+ return null;
672
+ const braceOffset = body.indexOf('{', match.index);
673
+ if (braceOffset === -1)
674
+ return null;
675
+ const openBraceIndex = currentRange.openBraceIndex + 1 + braceOffset;
676
+ const closeBraceIndex = findMatchingBrace(source, openBraceIndex);
677
+ if (closeBraceIndex === -1)
678
+ return null;
679
+ currentRange = { openBraceIndex, closeBraceIndex };
680
+ }
681
+ return currentRange;
207
682
  }
208
- function warn(id, title, message, details) {
209
- return { id, category: categoryFor(id), title, status: 'warn', message, details };
683
+ function findRootObjectRange(source) {
684
+ const exportMatch = /(export\s+default|module\.exports\s*=)\s*\{/.exec(source);
685
+ if (!exportMatch || typeof exportMatch.index !== 'number')
686
+ return null;
687
+ const openBraceIndex = source.indexOf('{', exportMatch.index);
688
+ if (openBraceIndex === -1)
689
+ return null;
690
+ const closeBraceIndex = findMatchingBrace(source, openBraceIndex);
691
+ if (closeBraceIndex === -1)
692
+ return null;
693
+ return { openBraceIndex, closeBraceIndex };
210
694
  }
211
- function fail(id, title, message, details) {
212
- return { id, category: categoryFor(id), title, status: 'fail', message, details };
695
+ function findMatchingBrace(source, openBraceIndex) {
696
+ let depth = 0;
697
+ let inSingle = false;
698
+ let inDouble = false;
699
+ let inTemplate = false;
700
+ let inLineComment = false;
701
+ let inBlockComment = false;
702
+ for (let index = openBraceIndex; index < source.length; index += 1) {
703
+ const char = source[index];
704
+ const next = source[index + 1];
705
+ const prev = source[index - 1];
706
+ if (inLineComment) {
707
+ if (char === '\n')
708
+ inLineComment = false;
709
+ continue;
710
+ }
711
+ if (inBlockComment) {
712
+ if (prev === '*' && char === '/')
713
+ inBlockComment = false;
714
+ continue;
715
+ }
716
+ if (!inSingle && !inDouble && !inTemplate) {
717
+ if (char === '/' && next === '/') {
718
+ inLineComment = true;
719
+ continue;
720
+ }
721
+ if (char === '/' && next === '*') {
722
+ inBlockComment = true;
723
+ continue;
724
+ }
725
+ }
726
+ if (!inDouble && !inTemplate && char === '\'' && prev !== '\\') {
727
+ inSingle = !inSingle;
728
+ continue;
729
+ }
730
+ if (!inSingle && !inTemplate && char === '"' && prev !== '\\') {
731
+ inDouble = !inDouble;
732
+ continue;
733
+ }
734
+ if (!inSingle && !inDouble && char === '`' && prev !== '\\') {
735
+ inTemplate = !inTemplate;
736
+ continue;
737
+ }
738
+ if (inSingle || inDouble || inTemplate)
739
+ continue;
740
+ if (char === '{')
741
+ depth += 1;
742
+ if (char === '}') {
743
+ depth -= 1;
744
+ if (depth === 0)
745
+ return index;
746
+ }
747
+ }
748
+ return -1;
749
+ }
750
+ function lineIndentAt(source, index) {
751
+ const lineStart = source.lastIndexOf('\n', index) + 1;
752
+ const line = source.slice(lineStart, index);
753
+ return line.match(/^\s*/)?.[0] || '';
754
+ }
755
+ function renderValue(value, indent) {
756
+ if (typeof value === 'string') {
757
+ return `'${value.replace(/\\/g, '\\\\').replace(/'/g, '\\\'')}'`;
758
+ }
759
+ if (typeof value === 'number' || typeof value === 'boolean') {
760
+ return String(value);
761
+ }
762
+ if (Array.isArray(value)) {
763
+ return `[${value.map((entry) => renderValue(entry, indent)).join(', ')}]`;
764
+ }
765
+ if (value && typeof value === 'object') {
766
+ const entries = Object.entries(value);
767
+ if (entries.length === 0)
768
+ return '{}';
769
+ return `{\n${entries.map(([key, entryValue]) => `${indent} ${key}: ${renderValue(entryValue, `${indent} `)}`).join(',\n')}\n${indent}}`;
770
+ }
771
+ return 'undefined';
772
+ }
773
+ function escapeRegExp(value) {
774
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
775
+ }
776
+ function inferGithubRepo(cwd) {
777
+ const packageJsonPath = path.join(cwd, 'package.json');
778
+ if (!fs.existsSync(packageJsonPath))
779
+ return null;
780
+ try {
781
+ const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
782
+ const repositoryUrl = typeof packageJson.repository === 'string' ? packageJson.repository : packageJson.repository?.url;
783
+ if (!repositoryUrl)
784
+ return null;
785
+ const match = repositoryUrl.match(/github\.com[:/](.+?)(?:\.git)?$/);
786
+ return match?.[1] || null;
787
+ }
788
+ catch {
789
+ return null;
790
+ }
791
+ }
792
+ function slugify(value) {
793
+ return value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
794
+ }
795
+ function inferScriptName(command) {
796
+ const match = command.match(/(?:npm|pnpm|bun)\s+run\s+([a-zA-Z0-9:_-]+)/) || command.match(/yarn\s+([a-zA-Z0-9:_-]+)/);
797
+ return match?.[1] || null;
213
798
  }
214
799
  function findExistingPath(cwd, candidates) {
215
800
  for (const candidate of candidates) {
@@ -242,9 +827,20 @@ function readJson(filePath) {
242
827
  return null;
243
828
  }
244
829
  }
830
+ function safeRead(filePath) {
831
+ try {
832
+ return fs.readFileSync(filePath, 'utf8');
833
+ }
834
+ catch {
835
+ return '';
836
+ }
837
+ }
245
838
  function asRecord(value) {
246
839
  return typeof value === 'object' && value !== null ? value : {};
247
840
  }
841
+ function capitalize(value) {
842
+ return value.charAt(0).toUpperCase() + value.slice(1);
843
+ }
248
844
  export default plugin;
249
845
  export { inspectProject, plugin };
250
846
  //# sourceMappingURL=index.js.map