@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/src/index.ts CHANGED
@@ -3,13 +3,35 @@ import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
 
5
5
  type CheckStatus = 'pass' | 'warn' | 'fail';
6
+ type CheckCategory = 'project' | 'workflows' | 'tooling' | 'plugins' | 'release';
7
+ type WorkflowId = 'init' | 'build' | 'release' | 'deploy' | 'desktop';
8
+ type FixStatus = 'applied' | 'skipped' | 'failed';
6
9
 
7
10
  interface CheckResult {
8
11
  id: string;
12
+ category: CheckCategory;
9
13
  title: string;
10
14
  status: CheckStatus;
11
15
  message: string;
12
16
  details?: string;
17
+ workflows: WorkflowId[];
18
+ fixable?: boolean;
19
+ }
20
+
21
+ interface WorkflowReadiness {
22
+ id: WorkflowId;
23
+ title: string;
24
+ status: CheckStatus;
25
+ score: number;
26
+ totals: Record<CheckStatus, number>;
27
+ nextAction?: string;
28
+ }
29
+
30
+ interface FixResult {
31
+ id: string;
32
+ title: string;
33
+ status: FixStatus;
34
+ message: string;
13
35
  }
14
36
 
15
37
  interface DoctorSummary {
@@ -17,11 +39,41 @@ interface DoctorSummary {
17
39
  cwd: string;
18
40
  checks: CheckResult[];
19
41
  totals: Record<CheckStatus, number>;
42
+ workflows: WorkflowReadiness[];
43
+ fixes: FixResult[];
20
44
  }
21
45
 
22
46
  interface DoctorOptions {
23
47
  json?: boolean;
48
+ markdown?: boolean;
49
+ ci?: boolean;
50
+ summary?: boolean;
51
+ verbose?: boolean;
24
52
  projectOnly?: boolean;
53
+ fix?: boolean;
54
+ }
55
+
56
+ interface DeploidConfigShape {
57
+ appName?: string;
58
+ appId?: string;
59
+ web?: { framework?: string; buildCommand?: string; webDir?: string };
60
+ android?: {
61
+ packaging?: string;
62
+ signing?: {
63
+ keystorePath?: string;
64
+ alias?: string;
65
+ storePasswordEnv?: string;
66
+ keyPasswordEnv?: string;
67
+ };
68
+ version?: { code?: number; name?: string };
69
+ build?: { buildType?: 'apk' | 'aab' | 'both' };
70
+ };
71
+ assets?: { source?: string; output?: string };
72
+ publish?: {
73
+ play?: { track?: string; serviceAccountJson?: string };
74
+ github?: { repo?: string; draft?: boolean };
75
+ };
76
+ plugins?: string[];
25
77
  }
26
78
 
27
79
  interface PipelineContext {
@@ -30,48 +82,62 @@ interface PipelineContext {
30
82
  warn: (message: string) => void;
31
83
  error: (message: string) => void;
32
84
  };
85
+ debug?: boolean;
33
86
  config: {
34
87
  appName: string;
35
88
  appId: string;
36
89
  web: { framework: string; buildCommand: string; webDir: string };
37
- android: {
38
- packaging: string;
39
- signing?: {
40
- keystorePath?: string;
41
- storePasswordEnv?: string;
42
- keyPasswordEnv?: string;
43
- };
44
- };
45
- assets?: { source: string };
90
+ android: { packaging: string };
46
91
  };
47
92
  cwd: string;
48
93
  doctorOptions?: DoctorOptions;
49
94
  }
50
95
 
51
- const CONFIG_CANDIDATES = [
52
- 'deploid.config.ts',
53
- 'deploid.config.js',
54
- 'deploid.config.mjs',
55
- 'deploid.config.cjs'
56
- ];
96
+ interface ProjectState {
97
+ cwd: string;
98
+ packageJsonPath: string;
99
+ packageJson: Record<string, unknown> | null;
100
+ configPath: string | null;
101
+ config: DeploidConfigShape | null;
102
+ capacitorConfigPath: string;
103
+ capacitorConfig: Record<string, unknown> | null;
104
+ androidDir: string;
105
+ androidBuildGradlePath: string;
106
+ packageDeps: Record<string, unknown>;
107
+ packageScripts: Record<string, unknown>;
108
+ }
109
+
110
+ const CONFIG_CANDIDATES = ['deploid.config.ts', 'deploid.config.js', 'deploid.config.mjs', 'deploid.config.cjs'];
111
+ const WORKFLOW_TITLES: Record<WorkflowId, string> = {
112
+ init: 'Project setup',
113
+ build: 'Android build',
114
+ release: 'Release readiness',
115
+ deploy: 'Device deploy',
116
+ desktop: 'Desktop packaging'
117
+ };
57
118
 
58
119
  const plugin = {
59
120
  name: 'doctor',
60
121
  plan: () => [
61
- 'Inspect project files and Deploid config',
62
- 'Check required command availability',
63
- 'Report Android setup and release readiness gaps'
122
+ 'Inspect project files and config consistency',
123
+ 'Assess workflow readiness for setup, build, release, deploy, and desktop packaging',
124
+ 'Offer machine-readable output and safe auto-fixes'
64
125
  ],
65
126
  run: runDoctor
66
127
  };
67
128
 
68
129
  async function runDoctor(ctx: PipelineContext): Promise<void> {
69
- const summary = await inspectProject(ctx.cwd, ctx.doctorOptions);
130
+ const options = ctx.doctorOptions ?? {};
131
+ const summary = await inspectProject(ctx.cwd, options);
70
132
 
71
- if (ctx.doctorOptions?.json) {
133
+ if (options.json) {
72
134
  console.log(JSON.stringify(summary, null, 2));
135
+ } else if (options.markdown) {
136
+ console.log(renderMarkdown(summary, options));
137
+ } else if (options.ci) {
138
+ console.log(renderCi(summary));
73
139
  } else {
74
- printSummary(ctx, summary);
140
+ printSummary(summary, options);
75
141
  }
76
142
 
77
143
  if (!summary.ok) {
@@ -79,183 +145,512 @@ async function runDoctor(ctx: PipelineContext): Promise<void> {
79
145
  }
80
146
  }
81
147
 
82
- async function inspectProject(cwd: string, options?: DoctorOptions): Promise<DoctorSummary> {
148
+ async function inspectProject(cwd: string, options: DoctorOptions = {}): Promise<DoctorSummary> {
149
+ const state = await loadProjectState(cwd);
83
150
  const checks: CheckResult[] = [];
84
- const packageJsonPath = path.join(cwd, 'package.json');
85
- const configPath = findExistingPath(cwd, CONFIG_CANDIDATES);
86
- const packageJson = readJson<Record<string, unknown>>(packageJsonPath);
87
- const config = configPath ? await loadProjectConfig(configPath) : null;
151
+ const fixes: FixResult[] = [];
88
152
 
89
- checks.push(
90
- fs.existsSync(packageJsonPath)
91
- ? pass('package-json', 'package.json', 'Found package.json in project root.')
92
- : fail('package-json', 'package.json', 'package.json is missing from the project root.')
93
- );
153
+ checks.push(...collectProjectChecks(state));
94
154
 
95
- checks.push(
96
- configPath
97
- ? pass('deploid-config', 'Deploid config', `Found ${path.basename(configPath)}.`)
98
- : fail('deploid-config', 'Deploid config', 'No Deploid config file was found.')
99
- );
100
-
101
- if (config) {
102
- checks.push(checkWebDir(cwd, config.web?.webDir));
103
- checks.push(checkAssetsSource(cwd, config.assets?.source));
104
- checks.push(checkSigning(cwd, config.android?.signing));
105
- checks.push(checkCapacitorConfig(cwd, config.android?.packaging));
106
- checks.push(checkAndroidProject(cwd));
155
+ if (state.config) {
156
+ checks.push(...collectConfigChecks(state));
157
+ checks.push(...collectConsistencyChecks(state));
158
+ checks.push(...collectReleaseChecks(state));
159
+ checks.push(...collectPluginChecks(state));
107
160
  } else {
108
- checks.push(warn('web-output', 'Web output directory', 'Skipped because no Deploid config was loaded.'));
109
- checks.push(warn('assets-source', 'Asset source', 'Skipped because no Deploid config was loaded.'));
110
- checks.push(warn('android-signing', 'Android signing', 'Skipped because no Deploid config was loaded.'));
111
- checks.push(warn('capacitor-config', 'Capacitor config', 'Skipped because no Deploid config was loaded.'));
112
- checks.push(warn('android-project', 'Android project', 'Skipped because no Deploid config was loaded.'));
161
+ checks.push(
162
+ warn('web-output', 'Web output directory', 'Skipped because no Deploid config was loaded.', ['init', 'build']),
163
+ warn('assets-source', 'Asset source', 'Skipped because no Deploid config was loaded.', ['init'], undefined, true),
164
+ warn('android-signing', 'Android signing', 'Skipped because no Deploid config was loaded.', ['release']),
165
+ warn('capacitor-config', 'Capacitor config', 'Skipped because no Deploid config was loaded.', ['build'], undefined, true),
166
+ warn('android-project', 'Android project', 'Skipped because no Deploid config was loaded.', ['build', 'deploy']),
167
+ warn('versioning', 'Version metadata', 'Skipped because no Deploid config was loaded.', ['release']),
168
+ warn('publish-config', 'Publish config', 'Skipped because no Deploid config was loaded.', ['release']),
169
+ warn('plugin-state', 'Plugin surface', 'Skipped because no Deploid config was loaded.', ['init', 'desktop'])
170
+ );
113
171
  }
114
172
 
115
- if (!options?.projectOnly) {
116
- checks.push(await checkCommand('node', ['--version'], 'Node.js', 'Required to run Deploid.'));
117
- checks.push(await checkCommand('npm', ['--version'], 'npm', 'Used by init, plugin setup, and Capacitor workflows.'));
118
- checks.push(await checkCommand('npx', ['--version'], 'npx', 'Used to invoke Capacitor CLI commands.'));
119
- checks.push(await checkCommand('java', ['-version'], 'Java', 'Required for Android builds.'));
120
- checks.push(await checkCommand('adb', ['version'], 'ADB', 'Required for device listing, deploy, and logs.'));
121
- checks.push(await checkAndroidSdk());
122
- checks.push(checkCapacitorDependency(packageJson));
173
+ if (!options.projectOnly) {
174
+ checks.push(...collectToolingChecks(state));
123
175
  }
124
176
 
125
- const totals = {
126
- pass: checks.filter((check) => check.status === 'pass').length,
127
- warn: checks.filter((check) => check.status === 'warn').length,
128
- fail: checks.filter((check) => check.status === 'fail').length
129
- };
177
+ if (options.fix) {
178
+ fixes.push(...applyFixes(state, checks));
179
+ if (fixes.some((fix) => fix.status === 'applied')) {
180
+ const refreshed = await inspectProject(cwd, { ...options, fix: false });
181
+ return { ...refreshed, fixes };
182
+ }
183
+ }
184
+
185
+ const totals = countStatuses(checks);
186
+ const workflows = buildWorkflowReadiness(checks);
130
187
 
131
188
  return {
132
189
  ok: totals.fail === 0,
133
190
  cwd,
134
191
  checks,
135
- totals
192
+ totals,
193
+ workflows,
194
+ fixes
136
195
  };
137
196
  }
138
197
 
139
- function printSummary(ctx: PipelineContext, summary: DoctorSummary): void {
140
- ctx.logger.info(`doctor: inspecting ${summary.cwd}`);
198
+ async function loadProjectState(cwd: string): Promise<ProjectState> {
199
+ const packageJsonPath = path.join(cwd, 'package.json');
200
+ const packageJson = readJson<Record<string, unknown>>(packageJsonPath);
201
+ const configPath = findExistingPath(cwd, CONFIG_CANDIDATES);
202
+ const config = configPath ? await loadProjectConfig(configPath) : null;
203
+ const capacitorConfigPath = path.join(cwd, 'capacitor.config.json');
204
+ const capacitorConfig = readJson<Record<string, unknown>>(capacitorConfigPath);
141
205
 
142
- for (const check of summary.checks) {
143
- const prefix = check.status === 'pass' ? 'PASS' : check.status === 'warn' ? 'WARN' : 'FAIL';
144
- const line = `${prefix} ${check.title}: ${check.message}`;
145
- if (check.status === 'pass') ctx.logger.info(line);
146
- if (check.status === 'warn') ctx.logger.warn(line);
147
- if (check.status === 'fail') ctx.logger.error(line);
148
- if (check.details) ctx.logger.info(` ${check.details}`);
149
- }
206
+ return {
207
+ cwd,
208
+ packageJsonPath,
209
+ packageJson,
210
+ configPath,
211
+ config,
212
+ capacitorConfigPath,
213
+ capacitorConfig,
214
+ androidDir: path.join(cwd, 'android'),
215
+ androidBuildGradlePath: path.join(cwd, 'android', 'app', 'build.gradle'),
216
+ packageDeps: {
217
+ ...(asRecord(packageJson?.dependencies)),
218
+ ...(asRecord(packageJson?.devDependencies))
219
+ },
220
+ packageScripts: asRecord(packageJson?.scripts)
221
+ };
222
+ }
150
223
 
151
- ctx.logger.info(
152
- `doctor summary: ${summary.totals.pass} passed, ${summary.totals.warn} warnings, ${summary.totals.fail} failures`
153
- );
224
+ function collectProjectChecks(state: ProjectState): CheckResult[] {
225
+ return [
226
+ fs.existsSync(state.packageJsonPath)
227
+ ? pass('package-json', 'package.json', 'Found package.json in project root.', ['init'])
228
+ : fail('package-json', 'package.json', 'package.json is missing from the project root.', ['init']),
229
+ state.configPath
230
+ ? pass('deploid-config', 'Deploid config', `Found ${path.basename(state.configPath)}.`, ['init', 'build', 'release'])
231
+ : fail('deploid-config', 'Deploid config', 'No Deploid config file was found.', ['init', 'build', 'release'])
232
+ ];
154
233
  }
155
234
 
156
- async function checkCommand(command: string, args: string[], title: string, details: string): Promise<CheckResult> {
157
- const result = spawnSync(command, args, { encoding: 'utf8' });
158
- if (result.status === 0) {
159
- const output = `${result.stdout || ''} ${result.stderr || ''}`.trim().split('\n')[0]?.trim();
160
- return pass(command, title, `${command} is available.`, output || details);
235
+ function collectConfigChecks(state: ProjectState): CheckResult[] {
236
+ const config = state.config;
237
+ const checks: CheckResult[] = [];
238
+ checks.push(checkBuildCommand(state));
239
+ checks.push(checkWebDir(state));
240
+ checks.push(checkAssetsSource(state));
241
+ checks.push(checkCapacitorConfig(state));
242
+ checks.push(checkAndroidProject(state));
243
+ checks.push(checkSigning(state));
244
+ checks.push(checkVersioning(state));
245
+ return checks;
246
+ }
247
+
248
+ function collectConsistencyChecks(state: ProjectState): CheckResult[] {
249
+ const checks: CheckResult[] = [];
250
+ const config = state.config;
251
+ const capacitorConfig = state.capacitorConfig;
252
+
253
+ if (config?.android?.packaging === 'capacitor' && capacitorConfig) {
254
+ const mismatches: string[] = [];
255
+ if (capacitorConfig.appId && capacitorConfig.appId !== config.appId) mismatches.push(`appId=${String(capacitorConfig.appId)}`);
256
+ if (capacitorConfig.appName && capacitorConfig.appName !== config.appName) mismatches.push(`appName=${String(capacitorConfig.appName)}`);
257
+ if (capacitorConfig.webDir && capacitorConfig.webDir !== config.web?.webDir) mismatches.push(`webDir=${String(capacitorConfig.webDir)}`);
258
+
259
+ checks.push(
260
+ mismatches.length === 0
261
+ ? pass('capacitor-sync', 'Capacitor sync', 'Capacitor metadata matches Deploid config.', ['build', 'release'])
262
+ : warn(
263
+ 'capacitor-sync',
264
+ 'Capacitor sync',
265
+ `Capacitor metadata differs from Deploid config (${mismatches.join(', ')}).`,
266
+ ['build', 'release'],
267
+ 'Run `deploid package` to resync generated native metadata.'
268
+ )
269
+ );
161
270
  }
162
271
 
163
- return fail(command, title, `${command} is not available.`, result.error?.message || result.stderr?.trim() || details);
272
+ const packageBuild = asRecord(state.packageJson?.build);
273
+ if (Object.keys(packageBuild).length > 0 && config) {
274
+ const mismatches: string[] = [];
275
+ if (packageBuild.appId && packageBuild.appId !== config.appId) mismatches.push('build.appId');
276
+ if (packageBuild.productName && packageBuild.productName !== config.appName) mismatches.push('build.productName');
277
+ checks.push(
278
+ mismatches.length === 0
279
+ ? pass('package-build-meta', 'Package metadata', 'package.json build metadata matches config.', ['desktop', 'release'])
280
+ : warn(
281
+ 'package-build-meta',
282
+ 'Package metadata',
283
+ `package.json metadata differs from config (${mismatches.join(', ')}).`,
284
+ ['desktop', 'release'],
285
+ 'Align package.json and deploid.config.ts to avoid release drift.'
286
+ )
287
+ );
288
+ }
289
+
290
+ if (fs.existsSync(state.androidBuildGradlePath) && config?.appId) {
291
+ const buildGradle = safeRead(state.androidBuildGradlePath);
292
+ const appIdMatch = buildGradle.match(/applicationId\s+"([^"]+)"/);
293
+ if (appIdMatch?.[1] === config.appId) {
294
+ checks.push(pass('android-app-id', 'Android appId', 'Gradle applicationId matches config.', ['build', 'release']));
295
+ } else if (appIdMatch?.[1]) {
296
+ checks.push(
297
+ warn(
298
+ 'android-app-id',
299
+ 'Android appId',
300
+ `Gradle applicationId is ${appIdMatch[1]} but config uses ${config.appId}.`,
301
+ ['build', 'release'],
302
+ 'Run `deploid package` before your next build.'
303
+ )
304
+ );
305
+ }
306
+ }
307
+
308
+ return checks;
164
309
  }
165
310
 
166
- async function checkAndroidSdk(): Promise<CheckResult> {
167
- const envHome = process.env.ANDROID_HOME || process.env.ANDROID_SDK_ROOT;
168
- const sdkPath = envHome || path.join(process.env.HOME || '', 'Android', 'Sdk');
311
+ function collectReleaseChecks(state: ProjectState): CheckResult[] {
312
+ const config = state.config;
313
+ if (!config) return [];
169
314
 
170
- if (!sdkPath || !fs.existsSync(sdkPath)) {
171
- return fail(
172
- 'android-sdk',
173
- 'Android SDK',
174
- 'Android SDK directory was not found.',
175
- 'Set ANDROID_HOME or ANDROID_SDK_ROOT, or install the SDK in ~/Android/Sdk.'
315
+ const checks: CheckResult[] = [];
316
+ const playConfig = config.publish?.play;
317
+ const githubConfig = config.publish?.github;
318
+
319
+ if (playConfig?.serviceAccountJson) {
320
+ const fullPath = path.join(state.cwd, playConfig.serviceAccountJson);
321
+ checks.push(
322
+ fs.existsSync(fullPath)
323
+ ? pass('play-service-account', 'Play credentials', `Found ${playConfig.serviceAccountJson}.`, ['release'])
324
+ : fail(
325
+ 'play-service-account',
326
+ 'Play credentials',
327
+ `${playConfig.serviceAccountJson} does not exist.`,
328
+ ['release'],
329
+ 'Add the Play service account JSON before automating Play uploads.'
330
+ )
176
331
  );
332
+ } else {
333
+ checks.push(warn('play-service-account', 'Play credentials', 'No Play service account configured.', ['release']));
177
334
  }
178
335
 
179
- const platformToolsPath = path.join(sdkPath, 'platform-tools');
180
- if (!fs.existsSync(platformToolsPath)) {
181
- return warn(
182
- 'android-sdk',
183
- 'Android SDK',
184
- `SDK found at ${sdkPath}, but platform-tools is missing.`,
185
- 'Install Android SDK Platform Tools to enable adb-based workflows.'
336
+ checks.push(
337
+ githubConfig?.repo
338
+ ? pass('github-release', 'GitHub release target', `Configured for ${githubConfig.repo}.`, ['release'])
339
+ : warn('github-release', 'GitHub release target', 'No GitHub release repo configured.', ['release'])
340
+ );
341
+
342
+ return checks;
343
+ }
344
+
345
+ function collectPluginChecks(state: ProjectState): CheckResult[] {
346
+ const checks: CheckResult[] = [];
347
+ const config = state.config;
348
+ const deps = state.packageDeps;
349
+ const hasElectronFiles = fs.existsSync(path.join(state.cwd, 'electron'));
350
+ const hasDesktopScripts = ['electron:build', 'electron:build:win', 'electron:build:mac'].some((key) => typeof state.packageScripts[key] === 'string');
351
+ const usesCapacitor = config?.android?.packaging === 'capacitor';
352
+
353
+ if (usesCapacitor) {
354
+ checks.push(
355
+ typeof deps['@capacitor/core'] === 'string' && typeof deps['@capacitor/cli'] === 'string'
356
+ ? pass('capacitor-dependency', 'Capacitor packages', 'Capacitor dependencies are present.', ['build', 'deploy'])
357
+ : warn(
358
+ 'capacitor-dependency',
359
+ 'Capacitor packages',
360
+ 'Capacitor dependencies are incomplete in package.json.',
361
+ ['build', 'deploy'],
362
+ 'Install @capacitor/core and @capacitor/cli in the app project.'
363
+ )
186
364
  );
187
365
  }
188
366
 
189
- return pass('android-sdk', 'Android SDK', `SDK found at ${sdkPath}.`);
367
+ if (hasElectronFiles || hasDesktopScripts) {
368
+ checks.push(
369
+ typeof deps.electron === 'string' && typeof deps['electron-builder'] === 'string'
370
+ ? pass('electron-dependency', 'Electron packages', 'Electron dependencies are present.', ['desktop'])
371
+ : warn(
372
+ 'electron-dependency',
373
+ 'Electron packages',
374
+ 'Desktop packaging files exist but Electron dependencies are incomplete.',
375
+ ['desktop'],
376
+ 'Run `deploid electron` or install electron and electron-builder.'
377
+ )
378
+ );
379
+ } else {
380
+ checks.push(warn('electron-dependency', 'Electron packages', 'Desktop packaging is not configured.', ['desktop']));
381
+ }
382
+
383
+ return checks;
384
+ }
385
+
386
+ function collectToolingChecks(state: ProjectState): CheckResult[] {
387
+ return [
388
+ checkCommand('node', ['--version'], 'Node.js', 'Required to run Deploid.', ['init', 'build', 'release', 'deploy', 'desktop']),
389
+ checkNpm(),
390
+ checkCommand('npx', ['--version'], 'npx', 'Used to invoke Capacitor CLI commands.', ['build', 'release']),
391
+ checkJava(),
392
+ checkAdb(),
393
+ checkAndroidSdk(),
394
+ checkGradleWrapper(state)
395
+ ];
190
396
  }
191
397
 
192
- function checkCapacitorDependency(packageJson: Record<string, unknown> | null): CheckResult {
193
- if (!packageJson) {
194
- return warn('capacitor-dependency', 'Capacitor dependency', 'Skipped because package.json could not be read.');
398
+ function buildWorkflowReadiness(checks: CheckResult[]): WorkflowReadiness[] {
399
+ return (Object.keys(WORKFLOW_TITLES) as WorkflowId[]).map((workflow) => {
400
+ const relevant = checks.filter((check) => check.workflows.includes(workflow));
401
+ const totals = countStatuses(relevant);
402
+ const total = relevant.length || 1;
403
+ const score = Math.max(0, Math.round(((totals.pass + totals.warn * 0.5) / total) * 100));
404
+ const status: CheckStatus =
405
+ totals.fail > 0 ? 'fail' : totals.warn > 0 ? 'warn' : 'pass';
406
+ const nextAction = relevant.find((check) => check.status !== 'pass')?.details || relevant.find((check) => check.status !== 'pass')?.message;
407
+
408
+ return {
409
+ id: workflow,
410
+ title: WORKFLOW_TITLES[workflow],
411
+ status,
412
+ score,
413
+ totals,
414
+ nextAction
415
+ };
416
+ });
417
+ }
418
+
419
+ function applyFixes(state: ProjectState, checks: CheckResult[]): FixResult[] {
420
+ const fixes: FixResult[] = [];
421
+ const missingAssetsSource = checks.find((check) => check.id === 'assets-source' && check.status === 'fail');
422
+ if (missingAssetsSource) {
423
+ const source = state.config?.assets?.source;
424
+ if (source) {
425
+ const dir = path.join(state.cwd, path.dirname(source));
426
+ if (!fs.existsSync(dir)) {
427
+ fs.mkdirSync(dir, { recursive: true });
428
+ fixes.push({ id: 'assets-dir', title: 'Asset directory', status: 'applied', message: `Created ${path.relative(state.cwd, dir)}.` });
429
+ } else {
430
+ fixes.push({ id: 'assets-dir', title: 'Asset directory', status: 'skipped', message: 'Asset directory already exists.' });
431
+ }
432
+ }
195
433
  }
196
434
 
197
- const deps = {
198
- ...(asRecord(packageJson.dependencies)),
199
- ...(asRecord(packageJson.devDependencies))
200
- };
435
+ const capacitorMissing = checks.find((check) => check.id === 'capacitor-config' && check.fixable && state.config?.android?.packaging === 'capacitor');
436
+ if (capacitorMissing && state.config) {
437
+ const webDir = state.config.web?.webDir || 'dist';
438
+ const nextConfig = {
439
+ appId: state.config.appId || 'com.example.myapp',
440
+ appName: state.config.appName || 'MyApp',
441
+ webDir,
442
+ bundledWebRuntime: false
443
+ };
444
+ if (!fs.existsSync(state.capacitorConfigPath)) {
445
+ fs.writeFileSync(state.capacitorConfigPath, `${JSON.stringify(nextConfig, null, 2)}\n`);
446
+ fixes.push({ id: 'capacitor-config', title: 'Capacitor config', status: 'applied', message: 'Created capacitor.config.json.' });
447
+ }
448
+ }
201
449
 
202
- if (typeof deps['@capacitor/core'] === 'string' || typeof deps['@capacitor/cli'] === 'string') {
203
- return pass('capacitor-dependency', 'Capacitor dependency', 'Capacitor dependencies are present.');
450
+ const signingWarn = checks.find((check) => check.id === 'android-signing' && check.status === 'warn' && state.config?.android?.signing);
451
+ if (signingWarn && state.config?.android?.signing) {
452
+ const envExamplePath = path.join(state.cwd, '.env.deploid.example');
453
+ const lines = [
454
+ '# Deploid signing placeholders',
455
+ state.config.android.signing.storePasswordEnv ? `${state.config.android.signing.storePasswordEnv}=replace-me` : null,
456
+ state.config.android.signing.keyPasswordEnv ? `${state.config.android.signing.keyPasswordEnv}=replace-me` : null
457
+ ].filter((value): value is string => Boolean(value));
458
+ if (lines.length > 1 && !fs.existsSync(envExamplePath)) {
459
+ fs.writeFileSync(envExamplePath, `${lines.join('\n')}\n`);
460
+ fixes.push({ id: 'signing-env-example', title: 'Signing env template', status: 'applied', message: 'Created .env.deploid.example.' });
461
+ }
204
462
  }
205
463
 
206
- return warn(
207
- 'capacitor-dependency',
208
- 'Capacitor dependency',
209
- 'No Capacitor dependency found in package.json.',
210
- 'Run `deploid init` or install @capacitor/core and @capacitor/cli if this project targets Android.'
464
+ if (fixes.length === 0) {
465
+ fixes.push({ id: 'noop', title: 'Auto-fix', status: 'skipped', message: 'No safe automatic fixes were available.' });
466
+ }
467
+
468
+ return fixes;
469
+ }
470
+
471
+ function printSummary(summary: DoctorSummary, options: DoctorOptions): void {
472
+ const showPasses = options.verbose && !options.summary;
473
+ const showDetails = !options.summary;
474
+
475
+ console.log('Deploid Doctor');
476
+ console.log(`Project: ${summary.cwd}`);
477
+ console.log(
478
+ `Status: ${summary.ok ? 'OK' : 'ACTION NEEDED'} (${summary.totals.pass} passed, ${summary.totals.warn} warnings, ${summary.totals.fail} failures)`
211
479
  );
480
+
481
+ console.log('');
482
+ console.log('Workflow readiness:');
483
+ for (const workflow of summary.workflows) {
484
+ console.log(` ${workflow.status.toUpperCase().padEnd(4, ' ')} ${workflow.title.padEnd(20, ' ')} ${String(workflow.score).padStart(3, ' ')}%`);
485
+ if (workflow.nextAction && showDetails) {
486
+ console.log(` ${workflow.nextAction}`);
487
+ }
488
+ }
489
+
490
+ const categories: Array<{ key: CheckCategory; title: string }> = [
491
+ { key: 'project', title: 'Project' },
492
+ { key: 'release', title: 'Release' },
493
+ { key: 'plugins', title: 'Plugins' },
494
+ { key: 'tooling', title: 'Tooling' }
495
+ ];
496
+
497
+ for (const category of categories) {
498
+ const rows = summary.checks.filter((check) => check.category === category.key && (showPasses || check.status !== 'pass'));
499
+ if (rows.length === 0) continue;
500
+ console.log('');
501
+ console.log(`${category.title}:`);
502
+ for (const check of rows) {
503
+ console.log(` ${check.status.toUpperCase().padEnd(4, ' ')} ${check.title.padEnd(22, ' ')} ${check.message}`);
504
+ if (check.details && showDetails) {
505
+ console.log(` ${check.details}`);
506
+ }
507
+ }
508
+ }
509
+
510
+ if (summary.fixes.length > 0) {
511
+ console.log('');
512
+ console.log('Fixes:');
513
+ for (const fix of summary.fixes) {
514
+ console.log(` ${fix.status.toUpperCase().padEnd(7, ' ')} ${fix.title}: ${fix.message}`);
515
+ }
516
+ }
517
+
518
+ if (!summary.ok) {
519
+ console.log('');
520
+ console.log('Next actions:');
521
+ for (const check of summary.checks.filter((item) => item.status !== 'pass').slice(0, 6)) {
522
+ console.log(` - ${check.title}: ${check.details || check.message}`);
523
+ }
524
+ }
525
+ }
526
+
527
+ function renderMarkdown(summary: DoctorSummary, options: DoctorOptions): string {
528
+ const lines: string[] = [];
529
+ lines.push('# Deploid Doctor');
530
+ lines.push('');
531
+ lines.push(`- Project: \`${summary.cwd}\``);
532
+ lines.push(`- Status: **${summary.ok ? 'OK' : 'ACTION NEEDED'}**`);
533
+ lines.push(`- Totals: ${summary.totals.pass} passed, ${summary.totals.warn} warnings, ${summary.totals.fail} failures`);
534
+ lines.push('');
535
+ lines.push('## Workflow Readiness');
536
+ for (const workflow of summary.workflows) {
537
+ lines.push(`- ${workflow.title}: ${workflow.status.toUpperCase()} (${workflow.score}%)`);
538
+ if (workflow.nextAction && !options.summary) lines.push(` ${workflow.nextAction}`);
539
+ }
540
+
541
+ const sections: CheckCategory[] = ['project', 'release', 'plugins', 'tooling'];
542
+ for (const section of sections) {
543
+ const rows = summary.checks.filter((check) => check.category === section && (!options.summary || check.status !== 'pass'));
544
+ if (rows.length === 0) continue;
545
+ lines.push('');
546
+ lines.push(`## ${capitalize(section)}`);
547
+ for (const row of rows) {
548
+ lines.push(`- ${row.status.toUpperCase()} ${row.title}: ${row.message}`);
549
+ if (row.details && !options.summary) lines.push(` ${row.details}`);
550
+ }
551
+ }
552
+
553
+ if (summary.fixes.length > 0) {
554
+ lines.push('');
555
+ lines.push('## Fixes');
556
+ for (const fix of summary.fixes) lines.push(`- ${fix.status.toUpperCase()} ${fix.title}: ${fix.message}`);
557
+ }
558
+
559
+ return lines.join('\n');
212
560
  }
213
561
 
214
- function checkWebDir(cwd: string, webDir: string | undefined): CheckResult {
562
+ function renderCi(summary: DoctorSummary): string {
563
+ const lines = [
564
+ `DOCTOR_STATUS=${summary.ok ? 'ok' : 'action-needed'}`,
565
+ `DOCTOR_PASSED=${summary.totals.pass}`,
566
+ `DOCTOR_WARNINGS=${summary.totals.warn}`,
567
+ `DOCTOR_FAILURES=${summary.totals.fail}`
568
+ ];
569
+ for (const workflow of summary.workflows) {
570
+ lines.push(`WORKFLOW_${workflow.id.toUpperCase()}=${workflow.status}:${workflow.score}`);
571
+ }
572
+ return lines.join('\n');
573
+ }
574
+
575
+ function checkBuildCommand(state: ProjectState): CheckResult {
576
+ const buildCommand = state.config?.web?.buildCommand;
577
+ if (!buildCommand) {
578
+ return fail('build-command', 'Build command', 'No `web.buildCommand` configured.', ['init', 'build']);
579
+ }
580
+
581
+ const scriptName = inferScriptName(buildCommand);
582
+ if (scriptName && typeof state.packageScripts[scriptName] !== 'string') {
583
+ return warn(
584
+ 'build-command',
585
+ 'Build command',
586
+ `Configured build command references missing script "${scriptName}".`,
587
+ ['init', 'build'],
588
+ 'Add the script to package.json or update `web.buildCommand`.'
589
+ );
590
+ }
591
+
592
+ return pass('build-command', 'Build command', `Configured build command: ${buildCommand}.`, ['init', 'build']);
593
+ }
594
+
595
+ function checkWebDir(state: ProjectState): CheckResult {
596
+ const webDir = state.config?.web?.webDir;
215
597
  if (!webDir) {
216
- return warn('web-output', 'Web output directory', 'No `web.webDir` configured.');
598
+ return fail('web-output', 'Web output directory', 'No `web.webDir` configured.', ['init', 'build']);
217
599
  }
218
600
 
219
- const fullPath = path.join(cwd, webDir);
220
- if (fs.existsSync(fullPath)) {
221
- return pass('web-output', 'Web output directory', `Found ${webDir}.`);
601
+ const fullPath = path.join(state.cwd, webDir);
602
+ if (!fs.existsSync(fullPath)) {
603
+ return warn(
604
+ 'web-output',
605
+ 'Web output directory',
606
+ `${webDir} does not exist yet.`,
607
+ ['build'],
608
+ 'Run your web build before packaging if you expect ready-to-sync assets.'
609
+ );
222
610
  }
223
611
 
224
- return warn(
225
- 'web-output',
226
- 'Web output directory',
227
- `${webDir} does not exist yet.`,
228
- 'Run your web build before packaging if you expect a ready-to-sync output directory.'
229
- );
612
+ const indexPath = path.join(fullPath, 'index.html');
613
+ if (!fs.existsSync(indexPath)) {
614
+ return warn(
615
+ 'web-output',
616
+ 'Web output directory',
617
+ `${webDir} exists but index.html is missing.`,
618
+ ['build'],
619
+ 'Check `web.webDir` or your framework build output.'
620
+ );
621
+ }
622
+
623
+ return pass('web-output', 'Web output directory', `Found ${webDir}.`, ['build']);
230
624
  }
231
625
 
232
- function checkAssetsSource(cwd: string, source: string | undefined): CheckResult {
626
+ function checkAssetsSource(state: ProjectState): CheckResult {
627
+ const source = state.config?.assets?.source;
233
628
  if (!source) {
234
- return warn('assets-source', 'Asset source', 'No `assets.source` configured.');
629
+ return warn('assets-source', 'Asset source', 'No `assets.source` configured.', ['init'], undefined, true);
235
630
  }
236
631
 
237
- const sourcePath = path.join(cwd, source);
632
+ const sourcePath = path.join(state.cwd, source);
238
633
  if (fs.existsSync(sourcePath)) {
239
- return pass('assets-source', 'Asset source', `Found ${source}.`);
634
+ return pass('assets-source', 'Asset source', `Found ${source}.`, ['init']);
240
635
  }
241
636
 
242
637
  return fail(
243
638
  'assets-source',
244
639
  'Asset source',
245
640
  `${source} does not exist.`,
246
- 'Add the source asset or update `assets.source` before running `deploid assets`.'
641
+ ['init'],
642
+ 'Add the source asset or update `assets.source` before running `deploid assets`.',
643
+ true
247
644
  );
248
645
  }
249
646
 
250
- function checkSigning(
251
- cwd: string,
252
- signing: { keystorePath?: string; storePasswordEnv?: string; keyPasswordEnv?: string } | undefined
253
- ): CheckResult {
647
+ function checkSigning(state: ProjectState): CheckResult {
648
+ const signing = state.config?.android?.signing;
254
649
  if (!signing?.keystorePath) {
255
- return warn('android-signing', 'Android signing', 'No Android signing config found.');
650
+ return warn('android-signing', 'Android signing', 'No Android signing config found.', ['release']);
256
651
  }
257
652
 
258
- const keystorePath = path.join(cwd, signing.keystorePath);
653
+ const keystorePath = path.join(state.cwd, signing.keystorePath);
259
654
  const missingEnvVars = [signing.storePasswordEnv, signing.keyPasswordEnv]
260
655
  .filter((name): name is string => Boolean(name))
261
656
  .filter((name) => !process.env[name]);
@@ -265,6 +660,7 @@ function checkSigning(
265
660
  'android-signing',
266
661
  'Android signing',
267
662
  `Keystore file is missing: ${signing.keystorePath}.`,
663
+ ['release'],
268
664
  'Create the keystore or fix `android.signing.keystorePath`.'
269
665
  );
270
666
  }
@@ -274,55 +670,213 @@ function checkSigning(
274
670
  'android-signing',
275
671
  'Android signing',
276
672
  `Keystore found, but env vars are missing: ${missingEnvVars.join(', ')}.`,
673
+ ['release'],
277
674
  'Release builds will fail until those password env vars are exported.'
278
675
  );
279
676
  }
280
677
 
281
- return pass('android-signing', 'Android signing', 'Signing keystore and env vars look ready.');
678
+ return pass('android-signing', 'Android signing', 'Signing keystore and env vars look ready.', ['release']);
282
679
  }
283
680
 
284
- function checkCapacitorConfig(cwd: string, packaging: string | undefined): CheckResult {
285
- if (packaging !== 'capacitor') {
286
- return warn('capacitor-config', 'Capacitor config', `Packaging engine is ${packaging || 'unknown'}.`);
681
+ function checkCapacitorConfig(state: ProjectState): CheckResult {
682
+ if (state.config?.android?.packaging !== 'capacitor') {
683
+ return warn('capacitor-config', 'Capacitor config', `Packaging engine is ${state.config?.android?.packaging || 'unknown'}.`, ['build']);
287
684
  }
288
685
 
289
- const capacitorConfigPath = path.join(cwd, 'capacitor.config.json');
290
- if (fs.existsSync(capacitorConfigPath)) {
291
- return pass('capacitor-config', 'Capacitor config', 'Found capacitor.config.json.');
686
+ if (fs.existsSync(state.capacitorConfigPath)) {
687
+ return pass('capacitor-config', 'Capacitor config', 'Found capacitor.config.json.', ['build']);
292
688
  }
293
689
 
294
690
  return warn(
295
691
  'capacitor-config',
296
692
  'Capacitor config',
297
693
  'capacitor.config.json is missing.',
298
- 'Run `deploid init` or `deploid package` to scaffold Capacitor configuration.'
694
+ ['build'],
695
+ 'Run `deploid init`, `deploid package`, or `deploid doctor --fix` to scaffold Capacitor configuration.',
696
+ true
299
697
  );
300
698
  }
301
699
 
302
- function checkAndroidProject(cwd: string): CheckResult {
303
- const androidPath = path.join(cwd, 'android');
304
- if (fs.existsSync(androidPath)) {
305
- return pass('android-project', 'Android project', 'Found android/ project.');
700
+ function checkAndroidProject(state: ProjectState): CheckResult {
701
+ if (fs.existsSync(state.androidDir)) {
702
+ return pass('android-project', 'Android project', 'Found android/ project.', ['build', 'deploy']);
306
703
  }
307
704
 
308
705
  return warn(
309
706
  'android-project',
310
707
  'Android project',
311
708
  'android/ project has not been generated yet.',
709
+ ['build', 'deploy'],
312
710
  'Run `deploid package` before building or deploying Android artifacts.'
313
711
  );
314
712
  }
315
713
 
316
- function pass(id: string, title: string, message: string, details?: string): CheckResult {
317
- return { id, title, status: 'pass', message, details };
714
+ function checkVersioning(state: ProjectState): CheckResult {
715
+ const version = state.config?.android?.version;
716
+ if (!version?.code || !version?.name) {
717
+ return warn('versioning', 'Version metadata', 'Android version code/name are incomplete.', ['release']);
718
+ }
719
+
720
+ if (version.code < 1) {
721
+ return fail('versioning', 'Version metadata', 'Android version code must be >= 1.', ['release']);
722
+ }
723
+
724
+ return pass('versioning', 'Version metadata', `Configured version ${version.name} (${version.code}).`, ['release']);
725
+ }
726
+
727
+ function checkCommand(command: string, args: string[], title: string, details: string, workflows: WorkflowId[]): CheckResult {
728
+ const result = spawnSync(command, args, { encoding: 'utf8' });
729
+ if (result.status === 0) {
730
+ const output = `${result.stdout || ''} ${result.stderr || ''}`.trim().split('\n')[0]?.trim();
731
+ return pass(command, title, `${command} is available.`, workflows, output || details);
732
+ }
733
+
734
+ return fail(command, title, `${command} is not available.`, workflows, result.error?.message || result.stderr?.trim() || details);
735
+ }
736
+
737
+ function checkNpm(): CheckResult {
738
+ const check = checkCommand('npm', ['--version'], 'npm', 'Used by init, plugin setup, and Capacitor workflows.', ['init', 'build', 'release', 'desktop']);
739
+ if (check.status === 'pass') {
740
+ const major = Number.parseInt((check.details || '').split('.')[0] || '0', 10);
741
+ if (major > 0 && major < 9) {
742
+ return warn('npm', 'npm', `npm ${check.details} is available but older than recommended.`, ['init', 'build', 'release', 'desktop']);
743
+ }
744
+ }
745
+ return check;
746
+ }
747
+
748
+ function checkJava(): CheckResult {
749
+ const result = spawnSync('java', ['-version'], { encoding: 'utf8' });
750
+ if (result.status !== 0) {
751
+ return fail('java', 'Java', 'java is not available.', ['build', 'release'], result.error?.message || 'Install Java 17+ for Android builds.');
752
+ }
753
+ const firstLine = `${result.stdout || ''} ${result.stderr || ''}`.trim().split('\n')[0]?.trim();
754
+ const match = firstLine.match(/version "(\d+)/);
755
+ const major = Number(match?.[1] || '0');
756
+ if (major > 0 && major < 17) {
757
+ return warn('java', 'Java', `Java ${major} is installed but Java 17+ is recommended.`, ['build', 'release'], firstLine);
758
+ }
759
+ return pass('java', 'Java', 'java is available.', ['build', 'release'], firstLine);
760
+ }
761
+
762
+ function checkAdb(): CheckResult {
763
+ const version = checkCommand('adb', ['version'], 'ADB', 'Required for device listing, deploy, and logs.', ['deploy']);
764
+ if (version.status !== 'pass') return version;
765
+
766
+ const devicesResult = spawnSync('adb', ['devices'], { encoding: 'utf8' });
767
+ const lines = `${devicesResult.stdout || ''}`.split('\n').filter((line) => /\t/.test(line));
768
+ const unauthorized = lines.filter((line) => line.includes('unauthorized') || line.includes('offline'));
769
+ if (unauthorized.length > 0) {
770
+ return warn('adb', 'ADB', `ADB is available but ${unauthorized.length} device(s) need attention.`, ['deploy'], unauthorized.join(', '));
771
+ }
772
+ if (lines.length === 0) {
773
+ return warn('adb', 'ADB', 'ADB is available but no devices are connected.', ['deploy']);
774
+ }
775
+ return pass('adb', 'ADB', `ADB is available with ${lines.length} connected device(s).`, ['deploy'], version.details);
776
+ }
777
+
778
+ function checkAndroidSdk(): CheckResult {
779
+ const envHome = process.env.ANDROID_HOME || process.env.ANDROID_SDK_ROOT;
780
+ const sdkPath = envHome || path.join(process.env.HOME || '', 'Android', 'Sdk');
781
+
782
+ if (!sdkPath || !fs.existsSync(sdkPath)) {
783
+ return fail(
784
+ 'android-sdk',
785
+ 'Android SDK',
786
+ 'Android SDK directory was not found.',
787
+ ['build', 'release', 'deploy'],
788
+ 'Set ANDROID_HOME or ANDROID_SDK_ROOT, or install the SDK in ~/Android/Sdk.'
789
+ );
790
+ }
791
+
792
+ const platformToolsPath = path.join(sdkPath, 'platform-tools');
793
+ if (!fs.existsSync(platformToolsPath)) {
794
+ return warn(
795
+ 'android-sdk',
796
+ 'Android SDK',
797
+ `SDK found at ${sdkPath}, but platform-tools is missing.`,
798
+ ['build', 'release', 'deploy'],
799
+ 'Install Android SDK Platform Tools to enable adb-based workflows.'
800
+ );
801
+ }
802
+
803
+ const hasBuildTools = fs.existsSync(path.join(sdkPath, 'build-tools'));
804
+ if (!hasBuildTools) {
805
+ return warn('android-sdk', 'Android SDK', `SDK found at ${sdkPath}, but build-tools is missing.`, ['build', 'release']);
806
+ }
807
+
808
+ return pass('android-sdk', 'Android SDK', `SDK found at ${sdkPath}.`, ['build', 'release', 'deploy']);
809
+ }
810
+
811
+ function checkGradleWrapper(state: ProjectState): CheckResult {
812
+ if (!fs.existsSync(state.androidDir)) {
813
+ return warn('gradle-wrapper', 'Gradle wrapper', 'Skipped because android/ has not been generated yet.', ['build', 'release']);
814
+ }
815
+ const wrapper = path.join(state.androidDir, 'gradlew');
816
+ if (!fs.existsSync(wrapper)) {
817
+ return fail('gradle-wrapper', 'Gradle wrapper', 'android/ exists but gradlew is missing.', ['build', 'release']);
818
+ }
819
+
820
+ const result = spawnSync(wrapper, ['-v'], { cwd: state.androidDir, encoding: 'utf8' });
821
+ if (result.status !== 0) {
822
+ return warn('gradle-wrapper', 'Gradle wrapper', 'Gradle wrapper exists but did not respond cleanly.', ['build', 'release']);
823
+ }
824
+ const firstLine = `${result.stdout || ''}${result.stderr || ''}`.split('\n').find((line) => line.trim().length > 0)?.trim();
825
+ return pass('gradle-wrapper', 'Gradle wrapper', 'Gradle wrapper is present.', ['build', 'release'], firstLine);
826
+ }
827
+
828
+ function countStatuses(checks: CheckResult[]): Record<CheckStatus, number> {
829
+ return {
830
+ pass: checks.filter((check) => check.status === 'pass').length,
831
+ warn: checks.filter((check) => check.status === 'warn').length,
832
+ fail: checks.filter((check) => check.status === 'fail').length
833
+ };
318
834
  }
319
835
 
320
- function warn(id: string, title: string, message: string, details?: string): CheckResult {
321
- return { id, title, status: 'warn', message, details };
836
+ function pass(
837
+ id: string,
838
+ title: string,
839
+ message: string,
840
+ workflows: WorkflowId[],
841
+ details?: string,
842
+ fixable = false
843
+ ): CheckResult {
844
+ return { id, category: categoryFor(id), title, status: 'pass', message, details, workflows, fixable };
322
845
  }
323
846
 
324
- function fail(id: string, title: string, message: string, details?: string): CheckResult {
325
- return { id, title, status: 'fail', message, details };
847
+ function warn(
848
+ id: string,
849
+ title: string,
850
+ message: string,
851
+ workflows: WorkflowId[],
852
+ details?: string,
853
+ fixable = false
854
+ ): CheckResult {
855
+ return { id, category: categoryFor(id), title, status: 'warn', message, details, workflows, fixable };
856
+ }
857
+
858
+ function fail(
859
+ id: string,
860
+ title: string,
861
+ message: string,
862
+ workflows: WorkflowId[],
863
+ details?: string,
864
+ fixable = false
865
+ ): CheckResult {
866
+ return { id, category: categoryFor(id), title, status: 'fail', message, details, workflows, fixable };
867
+ }
868
+
869
+ function categoryFor(id: string): CheckCategory {
870
+ if (['node', 'npm', 'npx', 'java', 'adb', 'android-sdk', 'gradle-wrapper'].includes(id)) return 'tooling';
871
+ if (['capacitor-dependency', 'electron-dependency', 'plugin-state'].includes(id)) return 'plugins';
872
+ if (['android-signing', 'versioning', 'play-service-account', 'github-release', 'package-build-meta'].includes(id)) return 'release';
873
+ if (['build-command', 'capacitor-sync'].includes(id)) return 'workflows';
874
+ return 'project';
875
+ }
876
+
877
+ function inferScriptName(command: string): string | null {
878
+ const match = command.match(/(?:npm|pnpm|bun)\s+run\s+([a-zA-Z0-9:_-]+)/) || command.match(/yarn\s+([a-zA-Z0-9:_-]+)/);
879
+ return match?.[1] || null;
326
880
  }
327
881
 
328
882
  function findExistingPath(cwd: string, candidates: string[]): string | null {
@@ -333,10 +887,10 @@ function findExistingPath(cwd: string, candidates: string[]): string | null {
333
887
  return null;
334
888
  }
335
889
 
336
- async function loadProjectConfig(configPath: string): Promise<Record<string, any> | null> {
890
+ async function loadProjectConfig(configPath: string): Promise<DeploidConfigShape | null> {
337
891
  try {
338
892
  const mod = await import(pathToFileUrl(configPath).href);
339
- return (mod.default || mod) as Record<string, any>;
893
+ return (mod.default || mod) as DeploidConfigShape;
340
894
  } catch {
341
895
  return null;
342
896
  }
@@ -357,9 +911,21 @@ function readJson<T>(filePath: string): T | null {
357
911
  }
358
912
  }
359
913
 
914
+ function safeRead(filePath: string): string {
915
+ try {
916
+ return fs.readFileSync(filePath, 'utf8');
917
+ } catch {
918
+ return '';
919
+ }
920
+ }
921
+
360
922
  function asRecord(value: unknown): Record<string, unknown> {
361
923
  return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : {};
362
924
  }
363
925
 
926
+ function capitalize(value: string): string {
927
+ return value.charAt(0).toUpperCase() + value.slice(1);
928
+ }
929
+
364
930
  export default plugin;
365
931
  export { inspectProject, plugin };