@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.d.ts +27 -9
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +730 -134
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/index.ts +961 -174
package/src/index.ts
CHANGED
|
@@ -3,14 +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;
|
|
9
|
-
category:
|
|
12
|
+
category: CheckCategory;
|
|
10
13
|
title: string;
|
|
11
14
|
status: CheckStatus;
|
|
12
15
|
message: string;
|
|
13
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;
|
|
14
35
|
}
|
|
15
36
|
|
|
16
37
|
interface DoctorSummary {
|
|
@@ -18,11 +39,41 @@ interface DoctorSummary {
|
|
|
18
39
|
cwd: string;
|
|
19
40
|
checks: CheckResult[];
|
|
20
41
|
totals: Record<CheckStatus, number>;
|
|
42
|
+
workflows: WorkflowReadiness[];
|
|
43
|
+
fixes: FixResult[];
|
|
21
44
|
}
|
|
22
45
|
|
|
23
46
|
interface DoctorOptions {
|
|
24
47
|
json?: boolean;
|
|
48
|
+
markdown?: boolean;
|
|
49
|
+
ci?: boolean;
|
|
50
|
+
summary?: boolean;
|
|
51
|
+
verbose?: boolean;
|
|
25
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[];
|
|
26
77
|
}
|
|
27
78
|
|
|
28
79
|
interface PipelineContext {
|
|
@@ -36,44 +87,57 @@ interface PipelineContext {
|
|
|
36
87
|
appName: string;
|
|
37
88
|
appId: string;
|
|
38
89
|
web: { framework: string; buildCommand: string; webDir: string };
|
|
39
|
-
android: {
|
|
40
|
-
packaging: string;
|
|
41
|
-
signing?: {
|
|
42
|
-
keystorePath?: string;
|
|
43
|
-
storePasswordEnv?: string;
|
|
44
|
-
keyPasswordEnv?: string;
|
|
45
|
-
};
|
|
46
|
-
};
|
|
47
|
-
assets?: { source: string };
|
|
90
|
+
android: { packaging: string };
|
|
48
91
|
};
|
|
49
92
|
cwd: string;
|
|
50
93
|
doctorOptions?: DoctorOptions;
|
|
51
94
|
}
|
|
52
95
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
+
};
|
|
59
118
|
|
|
60
119
|
const plugin = {
|
|
61
120
|
name: 'doctor',
|
|
62
121
|
plan: () => [
|
|
63
|
-
'Inspect project files and
|
|
64
|
-
'
|
|
65
|
-
'
|
|
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'
|
|
66
125
|
],
|
|
67
126
|
run: runDoctor
|
|
68
127
|
};
|
|
69
128
|
|
|
70
129
|
async function runDoctor(ctx: PipelineContext): Promise<void> {
|
|
71
|
-
const
|
|
130
|
+
const options = ctx.doctorOptions ?? {};
|
|
131
|
+
const summary = await inspectProject(ctx.cwd, options);
|
|
72
132
|
|
|
73
|
-
if (
|
|
133
|
+
if (options.json) {
|
|
74
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));
|
|
75
139
|
} else {
|
|
76
|
-
printSummary(
|
|
140
|
+
printSummary(summary, options);
|
|
77
141
|
}
|
|
78
142
|
|
|
79
143
|
if (!summary.ok) {
|
|
@@ -81,208 +145,586 @@ async function runDoctor(ctx: PipelineContext): Promise<void> {
|
|
|
81
145
|
}
|
|
82
146
|
}
|
|
83
147
|
|
|
84
|
-
async function inspectProject(cwd: string, options
|
|
148
|
+
async function inspectProject(cwd: string, options: DoctorOptions = {}): Promise<DoctorSummary> {
|
|
149
|
+
const state = await loadProjectState(cwd);
|
|
85
150
|
const checks: CheckResult[] = [];
|
|
86
|
-
const
|
|
87
|
-
const configPath = findExistingPath(cwd, CONFIG_CANDIDATES);
|
|
88
|
-
const packageJson = readJson<Record<string, unknown>>(packageJsonPath);
|
|
89
|
-
const config = configPath ? await loadProjectConfig(configPath) : null;
|
|
90
|
-
|
|
91
|
-
checks.push(
|
|
92
|
-
fs.existsSync(packageJsonPath)
|
|
93
|
-
? pass('package-json', 'package.json', 'Found package.json in project root.')
|
|
94
|
-
: fail('package-json', 'package.json', 'package.json is missing from the project root.')
|
|
95
|
-
);
|
|
151
|
+
const fixes: FixResult[] = [];
|
|
96
152
|
|
|
97
|
-
checks.push(
|
|
98
|
-
configPath
|
|
99
|
-
? pass('deploid-config', 'Deploid config', `Found ${path.basename(configPath)}.`)
|
|
100
|
-
: fail('deploid-config', 'Deploid config', 'No Deploid config file was found.')
|
|
101
|
-
);
|
|
153
|
+
checks.push(...collectProjectChecks(state));
|
|
102
154
|
|
|
103
|
-
if (config) {
|
|
104
|
-
checks.push(
|
|
105
|
-
checks.push(
|
|
106
|
-
checks.push(
|
|
107
|
-
checks.push(
|
|
108
|
-
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));
|
|
109
160
|
} else {
|
|
110
|
-
checks.push(
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
+
);
|
|
115
171
|
}
|
|
116
172
|
|
|
117
|
-
if (!options
|
|
118
|
-
checks.push(
|
|
119
|
-
checks.push(await checkCommand('npm', ['--version'], 'npm', 'Used by init, plugin setup, and Capacitor workflows.'));
|
|
120
|
-
checks.push(await checkCommand('npx', ['--version'], 'npx', 'Used to invoke Capacitor CLI commands.'));
|
|
121
|
-
checks.push(await checkCommand('java', ['-version'], 'Java', 'Required for Android builds.'));
|
|
122
|
-
checks.push(await checkCommand('adb', ['version'], 'ADB', 'Required for device listing, deploy, and logs.'));
|
|
123
|
-
checks.push(await checkAndroidSdk());
|
|
124
|
-
checks.push(checkCapacitorDependency(packageJson));
|
|
173
|
+
if (!options.projectOnly) {
|
|
174
|
+
checks.push(...collectToolingChecks(state));
|
|
125
175
|
}
|
|
126
176
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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);
|
|
132
187
|
|
|
133
188
|
return {
|
|
134
189
|
ok: totals.fail === 0,
|
|
135
190
|
cwd,
|
|
136
191
|
checks,
|
|
137
|
-
totals
|
|
192
|
+
totals,
|
|
193
|
+
workflows,
|
|
194
|
+
fixes
|
|
138
195
|
};
|
|
139
196
|
}
|
|
140
197
|
|
|
141
|
-
function
|
|
142
|
-
const
|
|
143
|
-
const
|
|
144
|
-
|
|
145
|
-
|
|
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);
|
|
205
|
+
|
|
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
|
+
}
|
|
223
|
+
|
|
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'])
|
|
146
232
|
];
|
|
233
|
+
}
|
|
234
|
+
|
|
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
|
+
true
|
|
269
|
+
)
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const packageBuild = asRecord(state.packageJson?.build);
|
|
274
|
+
if (Object.keys(packageBuild).length > 0 && config) {
|
|
275
|
+
const mismatches: string[] = [];
|
|
276
|
+
if (packageBuild.appId && packageBuild.appId !== config.appId) mismatches.push('build.appId');
|
|
277
|
+
if (packageBuild.productName && packageBuild.productName !== config.appName) mismatches.push('build.productName');
|
|
278
|
+
checks.push(
|
|
279
|
+
mismatches.length === 0
|
|
280
|
+
? pass('package-build-meta', 'Package metadata', 'package.json build metadata matches config.', ['desktop', 'release'])
|
|
281
|
+
: warn(
|
|
282
|
+
'package-build-meta',
|
|
283
|
+
'Package metadata',
|
|
284
|
+
`package.json metadata differs from config (${mismatches.join(', ')}).`,
|
|
285
|
+
['desktop', 'release'],
|
|
286
|
+
'Align package.json and deploid.config.ts to avoid release drift.'
|
|
287
|
+
)
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (fs.existsSync(state.androidBuildGradlePath) && config?.appId) {
|
|
292
|
+
const buildGradle = safeRead(state.androidBuildGradlePath);
|
|
293
|
+
const appIdMatch = buildGradle.match(/applicationId\s+"([^"]+)"/);
|
|
294
|
+
if (appIdMatch?.[1] === config.appId) {
|
|
295
|
+
checks.push(pass('android-app-id', 'Android appId', 'Gradle applicationId matches config.', ['build', 'release']));
|
|
296
|
+
} else if (appIdMatch?.[1]) {
|
|
297
|
+
checks.push(
|
|
298
|
+
warn(
|
|
299
|
+
'android-app-id',
|
|
300
|
+
'Android appId',
|
|
301
|
+
`Gradle applicationId is ${appIdMatch[1]} but config uses ${config.appId}.`,
|
|
302
|
+
['build', 'release'],
|
|
303
|
+
'Run `deploid package` before your next build.'
|
|
304
|
+
)
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
return checks;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function collectReleaseChecks(state: ProjectState): CheckResult[] {
|
|
313
|
+
const config = state.config;
|
|
314
|
+
if (!config) return [];
|
|
315
|
+
|
|
316
|
+
const checks: CheckResult[] = [];
|
|
317
|
+
const playConfig = config.publish?.play;
|
|
318
|
+
const githubConfig = config.publish?.github;
|
|
319
|
+
|
|
320
|
+
if (playConfig?.serviceAccountJson) {
|
|
321
|
+
const fullPath = path.join(state.cwd, playConfig.serviceAccountJson);
|
|
322
|
+
checks.push(
|
|
323
|
+
fs.existsSync(fullPath)
|
|
324
|
+
? pass('play-service-account', 'Play credentials', `Found ${playConfig.serviceAccountJson}.`, ['release'])
|
|
325
|
+
: fail(
|
|
326
|
+
'play-service-account',
|
|
327
|
+
'Play credentials',
|
|
328
|
+
`${playConfig.serviceAccountJson} does not exist.`,
|
|
329
|
+
['release'],
|
|
330
|
+
'Add the Play service account JSON before automating Play uploads.'
|
|
331
|
+
)
|
|
332
|
+
);
|
|
333
|
+
} else {
|
|
334
|
+
checks.push(warn('play-service-account', 'Play credentials', 'No Play service account configured.', ['release'], undefined, true));
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
checks.push(
|
|
338
|
+
githubConfig?.repo
|
|
339
|
+
? pass('github-release', 'GitHub release target', `Configured for ${githubConfig.repo}.`, ['release'])
|
|
340
|
+
: warn('github-release', 'GitHub release target', 'No GitHub release repo configured.', ['release'], undefined, true)
|
|
341
|
+
);
|
|
342
|
+
|
|
343
|
+
return checks;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function collectPluginChecks(state: ProjectState): CheckResult[] {
|
|
347
|
+
const checks: CheckResult[] = [];
|
|
348
|
+
const config = state.config;
|
|
349
|
+
const deps = state.packageDeps;
|
|
350
|
+
const hasElectronFiles = fs.existsSync(path.join(state.cwd, 'electron'));
|
|
351
|
+
const hasDesktopScripts = ['electron:build', 'electron:build:win', 'electron:build:mac'].some((key) => typeof state.packageScripts[key] === 'string');
|
|
352
|
+
const usesCapacitor = config?.android?.packaging === 'capacitor';
|
|
353
|
+
|
|
354
|
+
if (usesCapacitor) {
|
|
355
|
+
checks.push(
|
|
356
|
+
typeof deps['@capacitor/core'] === 'string' && typeof deps['@capacitor/cli'] === 'string'
|
|
357
|
+
? pass('capacitor-dependency', 'Capacitor packages', 'Capacitor dependencies are present.', ['build', 'deploy'])
|
|
358
|
+
: warn(
|
|
359
|
+
'capacitor-dependency',
|
|
360
|
+
'Capacitor packages',
|
|
361
|
+
'Capacitor dependencies are incomplete in package.json.',
|
|
362
|
+
['build', 'deploy'],
|
|
363
|
+
'Install @capacitor/core and @capacitor/cli in the app project.'
|
|
364
|
+
)
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
if (hasElectronFiles || hasDesktopScripts) {
|
|
369
|
+
checks.push(
|
|
370
|
+
typeof deps.electron === 'string' && typeof deps['electron-builder'] === 'string'
|
|
371
|
+
? pass('electron-dependency', 'Electron packages', 'Electron dependencies are present.', ['desktop'])
|
|
372
|
+
: warn(
|
|
373
|
+
'electron-dependency',
|
|
374
|
+
'Electron packages',
|
|
375
|
+
'Desktop packaging files exist but Electron dependencies are incomplete.',
|
|
376
|
+
['desktop'],
|
|
377
|
+
'Run `deploid electron` or install electron and electron-builder.'
|
|
378
|
+
)
|
|
379
|
+
);
|
|
380
|
+
} else {
|
|
381
|
+
checks.push(warn('electron-dependency', 'Electron packages', 'Desktop packaging is not configured.', ['desktop']));
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
return checks;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function collectToolingChecks(state: ProjectState): CheckResult[] {
|
|
388
|
+
return [
|
|
389
|
+
checkCommand('node', ['--version'], 'Node.js', 'Required to run Deploid.', ['init', 'build', 'release', 'deploy', 'desktop']),
|
|
390
|
+
checkNpm(),
|
|
391
|
+
checkCommand('npx', ['--version'], 'npx', 'Used to invoke Capacitor CLI commands.', ['build', 'release']),
|
|
392
|
+
checkJava(),
|
|
393
|
+
checkAdb(),
|
|
394
|
+
checkAndroidSdk(),
|
|
395
|
+
checkGradleWrapper(state)
|
|
396
|
+
];
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function buildWorkflowReadiness(checks: CheckResult[]): WorkflowReadiness[] {
|
|
400
|
+
return (Object.keys(WORKFLOW_TITLES) as WorkflowId[]).map((workflow) => {
|
|
401
|
+
const relevant = checks.filter((check) => check.workflows.includes(workflow));
|
|
402
|
+
const totals = countStatuses(relevant);
|
|
403
|
+
const total = relevant.length || 1;
|
|
404
|
+
const score = Math.max(0, Math.round(((totals.pass + totals.warn * 0.5) / total) * 100));
|
|
405
|
+
const status: CheckStatus =
|
|
406
|
+
totals.fail > 0 ? 'fail' : totals.warn > 0 ? 'warn' : 'pass';
|
|
407
|
+
const nextAction = relevant.find((check) => check.status !== 'pass')?.details || relevant.find((check) => check.status !== 'pass')?.message;
|
|
408
|
+
|
|
409
|
+
return {
|
|
410
|
+
id: workflow,
|
|
411
|
+
title: WORKFLOW_TITLES[workflow],
|
|
412
|
+
status,
|
|
413
|
+
score,
|
|
414
|
+
totals,
|
|
415
|
+
nextAction
|
|
416
|
+
};
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function applyFixes(state: ProjectState, checks: CheckResult[]): FixResult[] {
|
|
421
|
+
const fixes: FixResult[] = [];
|
|
422
|
+
const missingAssetsSource = checks.find((check) => check.id === 'assets-source' && check.status === 'fail');
|
|
423
|
+
if (missingAssetsSource) {
|
|
424
|
+
const source = state.config?.assets?.source;
|
|
425
|
+
if (source) {
|
|
426
|
+
const dir = path.join(state.cwd, path.dirname(source));
|
|
427
|
+
if (!fs.existsSync(dir)) {
|
|
428
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
429
|
+
fixes.push({ id: 'assets-dir', title: 'Asset directory', status: 'applied', message: `Created ${path.relative(state.cwd, dir)}.` });
|
|
430
|
+
} else {
|
|
431
|
+
fixes.push({ id: 'assets-dir', title: 'Asset directory', status: 'skipped', message: 'Asset directory already exists.' });
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const capacitorNeedsSync = checks.find(
|
|
437
|
+
(check) => ['capacitor-config', 'capacitor-sync'].includes(check.id) && check.fixable && state.config?.android?.packaging === 'capacitor'
|
|
438
|
+
);
|
|
439
|
+
if (capacitorNeedsSync && state.config) {
|
|
440
|
+
const webDir = state.config.web?.webDir || 'dist';
|
|
441
|
+
const nextConfig = {
|
|
442
|
+
appId: state.config.appId || 'com.example.myapp',
|
|
443
|
+
appName: state.config.appName || 'MyApp',
|
|
444
|
+
webDir,
|
|
445
|
+
bundledWebRuntime: false
|
|
446
|
+
};
|
|
447
|
+
const hadConfig = fs.existsSync(state.capacitorConfigPath);
|
|
448
|
+
fs.writeFileSync(state.capacitorConfigPath, `${JSON.stringify(nextConfig, null, 2)}\n`);
|
|
449
|
+
fixes.push({ id: 'capacitor-config', title: 'Capacitor config', status: 'applied', message: `${hadConfig ? 'Synced' : 'Created'} capacitor.config.json.` });
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
if (state.configPath && state.config) {
|
|
453
|
+
const originalConfig = safeRead(state.configPath);
|
|
454
|
+
let updatedConfig = originalConfig;
|
|
455
|
+
|
|
456
|
+
if (checks.find((check) => check.id === 'android-signing' && check.status !== 'pass')) {
|
|
457
|
+
updatedConfig = ensureProperty(updatedConfig, ['android'], 'signing', {
|
|
458
|
+
keystorePath: state.config.android?.signing?.keystorePath || 'secrets/android-upload-keystore.jks',
|
|
459
|
+
alias: state.config.android?.signing?.alias || slugify(state.config.appName || 'upload'),
|
|
460
|
+
storePasswordEnv: state.config.android?.signing?.storePasswordEnv || 'DEPLOID_ANDROID_STORE_PASSWORD',
|
|
461
|
+
keyPasswordEnv: state.config.android?.signing?.keyPasswordEnv || 'DEPLOID_ANDROID_KEY_PASSWORD'
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
if (checks.find((check) => check.id === 'versioning' && check.status !== 'pass')) {
|
|
466
|
+
updatedConfig = ensureProperty(updatedConfig, ['android'], 'version', {
|
|
467
|
+
code: state.config.android?.version?.code && state.config.android.version.code >= 1 ? state.config.android.version.code : 1,
|
|
468
|
+
name: state.config.android?.version?.name || '1.0.0'
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
if (checks.find((check) => check.id === 'github-release' && check.status !== 'pass')) {
|
|
473
|
+
updatedConfig = ensureProperty(updatedConfig, ['publish'], 'github', {
|
|
474
|
+
repo: inferGithubRepo(state.cwd) || 'owner/repo',
|
|
475
|
+
draft: true
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
if (checks.find((check) => check.id === 'play-service-account' && check.status !== 'pass')) {
|
|
480
|
+
updatedConfig = ensureProperty(updatedConfig, ['publish'], 'play', {
|
|
481
|
+
track: state.config.publish?.play?.track || 'internal',
|
|
482
|
+
serviceAccountJson: state.config.publish?.play?.serviceAccountJson || 'secrets/play-service-account.json'
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
if (updatedConfig !== originalConfig) {
|
|
487
|
+
fs.writeFileSync(state.configPath, updatedConfig);
|
|
488
|
+
fixes.push({ id: 'release-config', title: 'Release config', status: 'applied', message: `Updated ${path.basename(state.configPath)} with release placeholders.` });
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
const envExamplePath = path.join(state.cwd, '.env.deploid.example');
|
|
493
|
+
const envLines = [
|
|
494
|
+
'# Deploid signing placeholders',
|
|
495
|
+
state.config?.android?.signing?.storePasswordEnv ? `${state.config.android.signing.storePasswordEnv}=replace-me` : 'DEPLOID_ANDROID_STORE_PASSWORD=replace-me',
|
|
496
|
+
state.config?.android?.signing?.keyPasswordEnv ? `${state.config.android.signing.keyPasswordEnv}=replace-me` : 'DEPLOID_ANDROID_KEY_PASSWORD=replace-me',
|
|
497
|
+
state.config?.publish?.play?.serviceAccountJson ? `GOOGLE_PLAY_SERVICE_ACCOUNT_JSON=${state.config.publish.play.serviceAccountJson}` : null
|
|
498
|
+
].filter((value): value is string => Boolean(value));
|
|
499
|
+
if (envLines.length > 1) {
|
|
500
|
+
const existing = fs.existsSync(envExamplePath) ? safeRead(envExamplePath) : '';
|
|
501
|
+
const missingLines = envLines.filter((line) => !existing.includes(line));
|
|
502
|
+
if (!fs.existsSync(envExamplePath)) {
|
|
503
|
+
fs.writeFileSync(envExamplePath, `${envLines.join('\n')}\n`);
|
|
504
|
+
fixes.push({ id: 'signing-env-example', title: 'Signing env template', status: 'applied', message: 'Created .env.deploid.example.' });
|
|
505
|
+
} else if (missingLines.length > 0) {
|
|
506
|
+
const needsBreak = existing.length > 0 && !existing.endsWith('\n');
|
|
507
|
+
fs.appendFileSync(envExamplePath, `${needsBreak ? '\n' : ''}${missingLines.join('\n')}\n`);
|
|
508
|
+
fixes.push({ id: 'signing-env-example', title: 'Signing env template', status: 'applied', message: 'Updated .env.deploid.example.' });
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
const sensitivePaths = [
|
|
513
|
+
state.config?.android?.signing?.keystorePath || 'secrets/android-upload-keystore.jks',
|
|
514
|
+
state.config?.publish?.play?.serviceAccountJson || 'secrets/play-service-account.json',
|
|
515
|
+
'.env.deploid',
|
|
516
|
+
'.env.deploid.local'
|
|
517
|
+
].filter((value): value is string => Boolean(value));
|
|
518
|
+
if (sensitivePaths.length > 0) {
|
|
519
|
+
const gitignorePath = path.join(state.cwd, '.gitignore');
|
|
520
|
+
const existing = fs.existsSync(gitignorePath) ? safeRead(gitignorePath) : '';
|
|
521
|
+
const missingEntries = sensitivePaths.filter((entry) => !existing.includes(entry));
|
|
522
|
+
if (missingEntries.length > 0) {
|
|
523
|
+
const needsBreak = existing.length > 0 && !existing.endsWith('\n');
|
|
524
|
+
fs.appendFileSync(gitignorePath, `${needsBreak ? '\n' : ''}${missingEntries.join('\n')}\n`);
|
|
525
|
+
fixes.push({ id: 'gitignore-release', title: 'Release gitignore', status: 'applied', message: 'Updated .gitignore with release-sensitive paths.' });
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
const playServiceAccount = state.config?.publish?.play?.serviceAccountJson || 'secrets/play-service-account.json';
|
|
530
|
+
if (playServiceAccount) {
|
|
531
|
+
const secretsDir = path.join(state.cwd, path.dirname(playServiceAccount));
|
|
532
|
+
if (!fs.existsSync(secretsDir)) {
|
|
533
|
+
fs.mkdirSync(secretsDir, { recursive: true });
|
|
534
|
+
fixes.push({ id: 'secrets-dir', title: 'Secrets directory', status: 'applied', message: `Created ${path.relative(state.cwd, secretsDir)}.` });
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
if (fixes.length === 0) {
|
|
539
|
+
fixes.push({ id: 'noop', title: 'Auto-fix', status: 'skipped', message: 'No safe automatic fixes were available.' });
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
return fixes;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function printSummary(summary: DoctorSummary, options: DoctorOptions): void {
|
|
546
|
+
const showPasses = options.verbose && !options.summary;
|
|
547
|
+
const showDetails = !options.summary;
|
|
147
548
|
|
|
148
549
|
console.log('Deploid Doctor');
|
|
149
550
|
console.log(`Project: ${summary.cwd}`);
|
|
150
551
|
console.log(
|
|
151
|
-
`Status: ${
|
|
552
|
+
`Status: ${summary.ok ? 'OK' : 'ACTION NEEDED'} (${summary.totals.pass} passed, ${summary.totals.warn} warnings, ${summary.totals.fail} failures)`
|
|
152
553
|
);
|
|
153
554
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
555
|
+
console.log('');
|
|
556
|
+
console.log('Workflow readiness:');
|
|
557
|
+
for (const workflow of summary.workflows) {
|
|
558
|
+
console.log(` ${workflow.status.toUpperCase().padEnd(4, ' ')} ${workflow.title.padEnd(20, ' ')} ${String(workflow.score).padStart(3, ' ')}%`);
|
|
559
|
+
if (workflow.nextAction && showDetails) {
|
|
560
|
+
console.log(` ${workflow.nextAction}`);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
157
563
|
|
|
158
|
-
|
|
159
|
-
|
|
564
|
+
const categories: Array<{ key: CheckCategory; title: string }> = [
|
|
565
|
+
{ key: 'project', title: 'Project' },
|
|
566
|
+
{ key: 'release', title: 'Release' },
|
|
567
|
+
{ key: 'plugins', title: 'Plugins' },
|
|
568
|
+
{ key: 'tooling', title: 'Tooling' }
|
|
569
|
+
];
|
|
160
570
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
571
|
+
for (const category of categories) {
|
|
572
|
+
const rows = summary.checks.filter((check) => check.category === category.key && (showPasses || check.status !== 'pass'));
|
|
573
|
+
if (rows.length === 0) continue;
|
|
574
|
+
console.log('');
|
|
575
|
+
console.log(`${category.title}:`);
|
|
576
|
+
for (const check of rows) {
|
|
577
|
+
console.log(` ${check.status.toUpperCase().padEnd(4, ' ')} ${check.title.padEnd(22, ' ')} ${check.message}`);
|
|
578
|
+
if (check.details && showDetails) {
|
|
168
579
|
console.log(` ${check.details}`);
|
|
169
580
|
}
|
|
170
581
|
}
|
|
171
582
|
}
|
|
172
583
|
|
|
584
|
+
if (summary.fixes.length > 0) {
|
|
585
|
+
console.log('');
|
|
586
|
+
console.log('Fixes:');
|
|
587
|
+
for (const fix of summary.fixes) {
|
|
588
|
+
console.log(` ${fix.status.toUpperCase().padEnd(7, ' ')} ${fix.title}: ${fix.message}`);
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
173
592
|
if (!summary.ok) {
|
|
174
|
-
const actions = summary.checks.filter((check) => check.status !== 'pass');
|
|
175
593
|
console.log('');
|
|
176
594
|
console.log('Next actions:');
|
|
177
|
-
for (const check of
|
|
595
|
+
for (const check of summary.checks.filter((item) => item.status !== 'pass').slice(0, 6)) {
|
|
178
596
|
console.log(` - ${check.title}: ${check.details || check.message}`);
|
|
179
597
|
}
|
|
180
598
|
}
|
|
181
599
|
}
|
|
182
600
|
|
|
183
|
-
|
|
184
|
-
const
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
601
|
+
function renderMarkdown(summary: DoctorSummary, options: DoctorOptions): string {
|
|
602
|
+
const lines: string[] = [];
|
|
603
|
+
lines.push('# Deploid Doctor');
|
|
604
|
+
lines.push('');
|
|
605
|
+
lines.push(`- Project: \`${summary.cwd}\``);
|
|
606
|
+
lines.push(`- Status: **${summary.ok ? 'OK' : 'ACTION NEEDED'}**`);
|
|
607
|
+
lines.push(`- Totals: ${summary.totals.pass} passed, ${summary.totals.warn} warnings, ${summary.totals.fail} failures`);
|
|
608
|
+
lines.push('');
|
|
609
|
+
lines.push('## Workflow Readiness');
|
|
610
|
+
for (const workflow of summary.workflows) {
|
|
611
|
+
lines.push(`- ${workflow.title}: ${workflow.status.toUpperCase()} (${workflow.score}%)`);
|
|
612
|
+
if (workflow.nextAction && !options.summary) lines.push(` ${workflow.nextAction}`);
|
|
188
613
|
}
|
|
189
614
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
'Android SDK',
|
|
201
|
-
'Android SDK directory was not found.',
|
|
202
|
-
'Set ANDROID_HOME or ANDROID_SDK_ROOT, or install the SDK in ~/Android/Sdk.'
|
|
203
|
-
);
|
|
615
|
+
const sections: CheckCategory[] = ['project', 'release', 'plugins', 'tooling'];
|
|
616
|
+
for (const section of sections) {
|
|
617
|
+
const rows = summary.checks.filter((check) => check.category === section && (!options.summary || check.status !== 'pass'));
|
|
618
|
+
if (rows.length === 0) continue;
|
|
619
|
+
lines.push('');
|
|
620
|
+
lines.push(`## ${capitalize(section)}`);
|
|
621
|
+
for (const row of rows) {
|
|
622
|
+
lines.push(`- ${row.status.toUpperCase()} ${row.title}: ${row.message}`);
|
|
623
|
+
if (row.details && !options.summary) lines.push(` ${row.details}`);
|
|
624
|
+
}
|
|
204
625
|
}
|
|
205
626
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
'Android SDK',
|
|
211
|
-
`SDK found at ${sdkPath}, but platform-tools is missing.`,
|
|
212
|
-
'Install Android SDK Platform Tools to enable adb-based workflows.'
|
|
213
|
-
);
|
|
627
|
+
if (summary.fixes.length > 0) {
|
|
628
|
+
lines.push('');
|
|
629
|
+
lines.push('## Fixes');
|
|
630
|
+
for (const fix of summary.fixes) lines.push(`- ${fix.status.toUpperCase()} ${fix.title}: ${fix.message}`);
|
|
214
631
|
}
|
|
215
632
|
|
|
216
|
-
return
|
|
633
|
+
return lines.join('\n');
|
|
217
634
|
}
|
|
218
635
|
|
|
219
|
-
function
|
|
220
|
-
|
|
221
|
-
|
|
636
|
+
function renderCi(summary: DoctorSummary): string {
|
|
637
|
+
const lines = [
|
|
638
|
+
`DOCTOR_STATUS=${summary.ok ? 'ok' : 'action-needed'}`,
|
|
639
|
+
`DOCTOR_PASSED=${summary.totals.pass}`,
|
|
640
|
+
`DOCTOR_WARNINGS=${summary.totals.warn}`,
|
|
641
|
+
`DOCTOR_FAILURES=${summary.totals.fail}`
|
|
642
|
+
];
|
|
643
|
+
for (const workflow of summary.workflows) {
|
|
644
|
+
lines.push(`WORKFLOW_${workflow.id.toUpperCase()}=${workflow.status}:${workflow.score}`);
|
|
222
645
|
}
|
|
646
|
+
return lines.join('\n');
|
|
647
|
+
}
|
|
223
648
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
649
|
+
function checkBuildCommand(state: ProjectState): CheckResult {
|
|
650
|
+
const buildCommand = state.config?.web?.buildCommand;
|
|
651
|
+
if (!buildCommand) {
|
|
652
|
+
return fail('build-command', 'Build command', 'No `web.buildCommand` configured.', ['init', 'build']);
|
|
653
|
+
}
|
|
228
654
|
|
|
229
|
-
|
|
230
|
-
|
|
655
|
+
const scriptName = inferScriptName(buildCommand);
|
|
656
|
+
if (scriptName && typeof state.packageScripts[scriptName] !== 'string') {
|
|
657
|
+
return warn(
|
|
658
|
+
'build-command',
|
|
659
|
+
'Build command',
|
|
660
|
+
`Configured build command references missing script "${scriptName}".`,
|
|
661
|
+
['init', 'build'],
|
|
662
|
+
'Add the script to package.json or update `web.buildCommand`.'
|
|
663
|
+
);
|
|
231
664
|
}
|
|
232
665
|
|
|
233
|
-
return
|
|
234
|
-
'capacitor-dependency',
|
|
235
|
-
'Capacitor dependency',
|
|
236
|
-
'No Capacitor dependency found in package.json.',
|
|
237
|
-
'Run `deploid init` or install @capacitor/core and @capacitor/cli if this project targets Android.'
|
|
238
|
-
);
|
|
666
|
+
return pass('build-command', 'Build command', `Configured build command: ${buildCommand}.`, ['init', 'build']);
|
|
239
667
|
}
|
|
240
668
|
|
|
241
|
-
function checkWebDir(
|
|
669
|
+
function checkWebDir(state: ProjectState): CheckResult {
|
|
670
|
+
const webDir = state.config?.web?.webDir;
|
|
242
671
|
if (!webDir) {
|
|
243
|
-
return
|
|
672
|
+
return fail('web-output', 'Web output directory', 'No `web.webDir` configured.', ['init', 'build']);
|
|
244
673
|
}
|
|
245
674
|
|
|
246
|
-
const fullPath = path.join(cwd, webDir);
|
|
247
|
-
if (fs.existsSync(fullPath)) {
|
|
248
|
-
return
|
|
675
|
+
const fullPath = path.join(state.cwd, webDir);
|
|
676
|
+
if (!fs.existsSync(fullPath)) {
|
|
677
|
+
return warn(
|
|
678
|
+
'web-output',
|
|
679
|
+
'Web output directory',
|
|
680
|
+
`${webDir} does not exist yet.`,
|
|
681
|
+
['build'],
|
|
682
|
+
'Run your web build before packaging if you expect ready-to-sync assets.'
|
|
683
|
+
);
|
|
249
684
|
}
|
|
250
685
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
686
|
+
const indexPath = path.join(fullPath, 'index.html');
|
|
687
|
+
if (!fs.existsSync(indexPath)) {
|
|
688
|
+
return warn(
|
|
689
|
+
'web-output',
|
|
690
|
+
'Web output directory',
|
|
691
|
+
`${webDir} exists but index.html is missing.`,
|
|
692
|
+
['build'],
|
|
693
|
+
'Check `web.webDir` or your framework build output.'
|
|
694
|
+
);
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
return pass('web-output', 'Web output directory', `Found ${webDir}.`, ['build']);
|
|
257
698
|
}
|
|
258
699
|
|
|
259
|
-
function checkAssetsSource(
|
|
700
|
+
function checkAssetsSource(state: ProjectState): CheckResult {
|
|
701
|
+
const source = state.config?.assets?.source;
|
|
260
702
|
if (!source) {
|
|
261
|
-
return warn('assets-source', 'Asset source', 'No `assets.source` configured.');
|
|
703
|
+
return warn('assets-source', 'Asset source', 'No `assets.source` configured.', ['init'], undefined, true);
|
|
262
704
|
}
|
|
263
705
|
|
|
264
|
-
const sourcePath = path.join(cwd, source);
|
|
706
|
+
const sourcePath = path.join(state.cwd, source);
|
|
265
707
|
if (fs.existsSync(sourcePath)) {
|
|
266
|
-
return pass('assets-source', 'Asset source', `Found ${source}
|
|
708
|
+
return pass('assets-source', 'Asset source', `Found ${source}.`, ['init']);
|
|
267
709
|
}
|
|
268
710
|
|
|
269
711
|
return fail(
|
|
270
712
|
'assets-source',
|
|
271
713
|
'Asset source',
|
|
272
714
|
`${source} does not exist.`,
|
|
273
|
-
'
|
|
715
|
+
['init'],
|
|
716
|
+
'Add the source asset or update `assets.source` before running `deploid assets`.',
|
|
717
|
+
true
|
|
274
718
|
);
|
|
275
719
|
}
|
|
276
720
|
|
|
277
|
-
function checkSigning(
|
|
278
|
-
|
|
279
|
-
signing: { keystorePath?: string; storePasswordEnv?: string; keyPasswordEnv?: string } | undefined
|
|
280
|
-
): CheckResult {
|
|
721
|
+
function checkSigning(state: ProjectState): CheckResult {
|
|
722
|
+
const signing = state.config?.android?.signing;
|
|
281
723
|
if (!signing?.keystorePath) {
|
|
282
|
-
return warn('android-signing', 'Android signing', 'No Android signing config found.');
|
|
724
|
+
return warn('android-signing', 'Android signing', 'No Android signing config found.', ['release'], undefined, true);
|
|
283
725
|
}
|
|
284
726
|
|
|
285
|
-
const keystorePath = path.join(cwd, signing.keystorePath);
|
|
727
|
+
const keystorePath = path.join(state.cwd, signing.keystorePath);
|
|
286
728
|
const missingEnvVars = [signing.storePasswordEnv, signing.keyPasswordEnv]
|
|
287
729
|
.filter((name): name is string => Boolean(name))
|
|
288
730
|
.filter((name) => !process.env[name]);
|
|
@@ -292,7 +734,9 @@ function checkSigning(
|
|
|
292
734
|
'android-signing',
|
|
293
735
|
'Android signing',
|
|
294
736
|
`Keystore file is missing: ${signing.keystorePath}.`,
|
|
295
|
-
'
|
|
737
|
+
['release'],
|
|
738
|
+
'Create the keystore or fix `android.signing.keystorePath`.',
|
|
739
|
+
true
|
|
296
740
|
);
|
|
297
741
|
}
|
|
298
742
|
|
|
@@ -301,61 +745,392 @@ function checkSigning(
|
|
|
301
745
|
'android-signing',
|
|
302
746
|
'Android signing',
|
|
303
747
|
`Keystore found, but env vars are missing: ${missingEnvVars.join(', ')}.`,
|
|
304
|
-
'
|
|
748
|
+
['release'],
|
|
749
|
+
'Release builds will fail until those password env vars are exported.',
|
|
750
|
+
true
|
|
305
751
|
);
|
|
306
752
|
}
|
|
307
753
|
|
|
308
|
-
return pass('android-signing', 'Android signing', 'Signing keystore and env vars look ready.');
|
|
754
|
+
return pass('android-signing', 'Android signing', 'Signing keystore and env vars look ready.', ['release']);
|
|
309
755
|
}
|
|
310
756
|
|
|
311
|
-
function checkCapacitorConfig(
|
|
312
|
-
if (packaging !== 'capacitor') {
|
|
313
|
-
return warn('capacitor-config', 'Capacitor config', `Packaging engine is ${packaging || 'unknown'}
|
|
757
|
+
function checkCapacitorConfig(state: ProjectState): CheckResult {
|
|
758
|
+
if (state.config?.android?.packaging !== 'capacitor') {
|
|
759
|
+
return warn('capacitor-config', 'Capacitor config', `Packaging engine is ${state.config?.android?.packaging || 'unknown'}.`, ['build']);
|
|
314
760
|
}
|
|
315
761
|
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
return pass('capacitor-config', 'Capacitor config', 'Found capacitor.config.json.');
|
|
762
|
+
if (fs.existsSync(state.capacitorConfigPath)) {
|
|
763
|
+
return pass('capacitor-config', 'Capacitor config', 'Found capacitor.config.json.', ['build']);
|
|
319
764
|
}
|
|
320
765
|
|
|
321
766
|
return warn(
|
|
322
767
|
'capacitor-config',
|
|
323
768
|
'Capacitor config',
|
|
324
769
|
'capacitor.config.json is missing.',
|
|
325
|
-
'
|
|
770
|
+
['build'],
|
|
771
|
+
'Run `deploid init`, `deploid package`, or `deploid doctor --fix` to scaffold Capacitor configuration.',
|
|
772
|
+
true
|
|
326
773
|
);
|
|
327
774
|
}
|
|
328
775
|
|
|
329
|
-
function checkAndroidProject(
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
return pass('android-project', 'Android project', 'Found android/ project.');
|
|
776
|
+
function checkAndroidProject(state: ProjectState): CheckResult {
|
|
777
|
+
if (fs.existsSync(state.androidDir)) {
|
|
778
|
+
return pass('android-project', 'Android project', 'Found android/ project.', ['build', 'deploy']);
|
|
333
779
|
}
|
|
334
780
|
|
|
335
781
|
return warn(
|
|
336
782
|
'android-project',
|
|
337
783
|
'Android project',
|
|
338
784
|
'android/ project has not been generated yet.',
|
|
785
|
+
['build', 'deploy'],
|
|
339
786
|
'Run `deploid package` before building or deploying Android artifacts.'
|
|
340
787
|
);
|
|
341
788
|
}
|
|
342
789
|
|
|
343
|
-
function
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
790
|
+
function checkVersioning(state: ProjectState): CheckResult {
|
|
791
|
+
const version = state.config?.android?.version;
|
|
792
|
+
if (!version?.code || !version?.name) {
|
|
793
|
+
return warn('versioning', 'Version metadata', 'Android version code/name are incomplete.', ['release'], undefined, true);
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
if (version.code < 1) {
|
|
797
|
+
return fail('versioning', 'Version metadata', 'Android version code must be >= 1.', ['release'], undefined, true);
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
return pass('versioning', 'Version metadata', `Configured version ${version.name} (${version.code}).`, ['release']);
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
function checkCommand(command: string, args: string[], title: string, details: string, workflows: WorkflowId[]): CheckResult {
|
|
804
|
+
const result = spawnSync(command, args, { encoding: 'utf8' });
|
|
805
|
+
if (result.status === 0) {
|
|
806
|
+
const output = `${result.stdout || ''} ${result.stderr || ''}`.trim().split('\n')[0]?.trim();
|
|
807
|
+
return pass(command, title, `${command} is available.`, workflows, output || details);
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
return fail(command, title, `${command} is not available.`, workflows, result.error?.message || result.stderr?.trim() || details);
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
function checkNpm(): CheckResult {
|
|
814
|
+
const check = checkCommand('npm', ['--version'], 'npm', 'Used by init, plugin setup, and Capacitor workflows.', ['init', 'build', 'release', 'desktop']);
|
|
815
|
+
if (check.status === 'pass') {
|
|
816
|
+
const major = Number.parseInt((check.details || '').split('.')[0] || '0', 10);
|
|
817
|
+
if (major > 0 && major < 9) {
|
|
818
|
+
return warn('npm', 'npm', `npm ${check.details} is available but older than recommended.`, ['init', 'build', 'release', 'desktop']);
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
return check;
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
function checkJava(): CheckResult {
|
|
825
|
+
const result = spawnSync('java', ['-version'], { encoding: 'utf8' });
|
|
826
|
+
if (result.status !== 0) {
|
|
827
|
+
return fail('java', 'Java', 'java is not available.', ['build', 'release'], result.error?.message || 'Install Java 17+ for Android builds.');
|
|
828
|
+
}
|
|
829
|
+
const firstLine = `${result.stdout || ''} ${result.stderr || ''}`.trim().split('\n')[0]?.trim();
|
|
830
|
+
const match = firstLine.match(/version "(\d+)/);
|
|
831
|
+
const major = Number(match?.[1] || '0');
|
|
832
|
+
if (major > 0 && major < 17) {
|
|
833
|
+
return warn('java', 'Java', `Java ${major} is installed but Java 17+ is recommended.`, ['build', 'release'], firstLine);
|
|
834
|
+
}
|
|
835
|
+
return pass('java', 'Java', 'java is available.', ['build', 'release'], firstLine);
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
function checkAdb(): CheckResult {
|
|
839
|
+
const version = checkCommand('adb', ['version'], 'ADB', 'Required for device listing, deploy, and logs.', ['deploy']);
|
|
840
|
+
if (version.status !== 'pass') return version;
|
|
841
|
+
|
|
842
|
+
const devicesResult = spawnSync('adb', ['devices'], { encoding: 'utf8' });
|
|
843
|
+
const lines = `${devicesResult.stdout || ''}`.split('\n').filter((line) => /\t/.test(line));
|
|
844
|
+
const unauthorized = lines.filter((line) => line.includes('unauthorized') || line.includes('offline'));
|
|
845
|
+
if (unauthorized.length > 0) {
|
|
846
|
+
return warn('adb', 'ADB', `ADB is available but ${unauthorized.length} device(s) need attention.`, ['deploy'], unauthorized.join(', '));
|
|
847
|
+
}
|
|
848
|
+
if (lines.length === 0) {
|
|
849
|
+
return warn('adb', 'ADB', 'ADB is available but no devices are connected.', ['deploy']);
|
|
850
|
+
}
|
|
851
|
+
return pass('adb', 'ADB', `ADB is available with ${lines.length} connected device(s).`, ['deploy'], version.details);
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
function checkAndroidSdk(): CheckResult {
|
|
855
|
+
const envHome = process.env.ANDROID_HOME || process.env.ANDROID_SDK_ROOT;
|
|
856
|
+
const sdkPath = envHome || path.join(process.env.HOME || '', 'Android', 'Sdk');
|
|
857
|
+
|
|
858
|
+
if (!sdkPath || !fs.existsSync(sdkPath)) {
|
|
859
|
+
return fail(
|
|
860
|
+
'android-sdk',
|
|
861
|
+
'Android SDK',
|
|
862
|
+
'Android SDK directory was not found.',
|
|
863
|
+
['build', 'release', 'deploy'],
|
|
864
|
+
'Set ANDROID_HOME or ANDROID_SDK_ROOT, or install the SDK in ~/Android/Sdk.'
|
|
865
|
+
);
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
const platformToolsPath = path.join(sdkPath, 'platform-tools');
|
|
869
|
+
if (!fs.existsSync(platformToolsPath)) {
|
|
870
|
+
return warn(
|
|
871
|
+
'android-sdk',
|
|
872
|
+
'Android SDK',
|
|
873
|
+
`SDK found at ${sdkPath}, but platform-tools is missing.`,
|
|
874
|
+
['build', 'release', 'deploy'],
|
|
875
|
+
'Install Android SDK Platform Tools to enable adb-based workflows.'
|
|
876
|
+
);
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
const hasBuildTools = fs.existsSync(path.join(sdkPath, 'build-tools'));
|
|
880
|
+
if (!hasBuildTools) {
|
|
881
|
+
return warn('android-sdk', 'Android SDK', `SDK found at ${sdkPath}, but build-tools is missing.`, ['build', 'release']);
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
return pass('android-sdk', 'Android SDK', `SDK found at ${sdkPath}.`, ['build', 'release', 'deploy']);
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
function checkGradleWrapper(state: ProjectState): CheckResult {
|
|
888
|
+
if (!fs.existsSync(state.androidDir)) {
|
|
889
|
+
return warn('gradle-wrapper', 'Gradle wrapper', 'Skipped because android/ has not been generated yet.', ['build', 'release']);
|
|
890
|
+
}
|
|
891
|
+
const wrapper = path.join(state.androidDir, 'gradlew');
|
|
892
|
+
if (!fs.existsSync(wrapper)) {
|
|
893
|
+
return fail('gradle-wrapper', 'Gradle wrapper', 'android/ exists but gradlew is missing.', ['build', 'release']);
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
const result = spawnSync(wrapper, ['-v'], { cwd: state.androidDir, encoding: 'utf8' });
|
|
897
|
+
if (result.status !== 0) {
|
|
898
|
+
return warn('gradle-wrapper', 'Gradle wrapper', 'Gradle wrapper exists but did not respond cleanly.', ['build', 'release']);
|
|
899
|
+
}
|
|
900
|
+
const firstLine = `${result.stdout || ''}${result.stderr || ''}`.split('\n').find((line) => line.trim().length > 0)?.trim();
|
|
901
|
+
return pass('gradle-wrapper', 'Gradle wrapper', 'Gradle wrapper is present.', ['build', 'release'], firstLine);
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
function countStatuses(checks: CheckResult[]): Record<CheckStatus, number> {
|
|
905
|
+
return {
|
|
906
|
+
pass: checks.filter((check) => check.status === 'pass').length,
|
|
907
|
+
warn: checks.filter((check) => check.status === 'warn').length,
|
|
908
|
+
fail: checks.filter((check) => check.status === 'fail').length
|
|
909
|
+
};
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
function pass(
|
|
913
|
+
id: string,
|
|
914
|
+
title: string,
|
|
915
|
+
message: string,
|
|
916
|
+
workflows: WorkflowId[],
|
|
917
|
+
details?: string,
|
|
918
|
+
fixable = false
|
|
919
|
+
): CheckResult {
|
|
920
|
+
return { id, category: categoryFor(id), title, status: 'pass', message, details, workflows, fixable };
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
function warn(
|
|
924
|
+
id: string,
|
|
925
|
+
title: string,
|
|
926
|
+
message: string,
|
|
927
|
+
workflows: WorkflowId[],
|
|
928
|
+
details?: string,
|
|
929
|
+
fixable = false
|
|
930
|
+
): CheckResult {
|
|
931
|
+
return { id, category: categoryFor(id), title, status: 'warn', message, details, workflows, fixable };
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
function fail(
|
|
935
|
+
id: string,
|
|
936
|
+
title: string,
|
|
937
|
+
message: string,
|
|
938
|
+
workflows: WorkflowId[],
|
|
939
|
+
details?: string,
|
|
940
|
+
fixable = false
|
|
941
|
+
): CheckResult {
|
|
942
|
+
return { id, category: categoryFor(id), title, status: 'fail', message, details, workflows, fixable };
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
function categoryFor(id: string): CheckCategory {
|
|
946
|
+
if (['node', 'npm', 'npx', 'java', 'adb', 'android-sdk', 'gradle-wrapper'].includes(id)) return 'tooling';
|
|
947
|
+
if (['capacitor-dependency', 'electron-dependency', 'plugin-state'].includes(id)) return 'plugins';
|
|
948
|
+
if (['android-signing', 'versioning', 'play-service-account', 'github-release', 'package-build-meta'].includes(id)) return 'release';
|
|
949
|
+
if (['build-command', 'capacitor-sync'].includes(id)) return 'workflows';
|
|
950
|
+
return 'project';
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
function ensureProperty(source: string, pathSegments: string[], propertyName: string, value: unknown): string {
|
|
954
|
+
let current = source;
|
|
955
|
+
for (let index = 0; index < pathSegments.length; index += 1) {
|
|
956
|
+
const parentPath = pathSegments.slice(0, index);
|
|
957
|
+
current = ensureObjectProperty(current, parentPath, pathSegments[index]);
|
|
958
|
+
}
|
|
959
|
+
return ensureValueProperty(current, pathSegments, propertyName, value);
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
function ensureObjectProperty(source: string, parentPath: string[], propertyName: string): string {
|
|
963
|
+
const parentRange = findObjectRangeByPath(source, parentPath);
|
|
964
|
+
if (!parentRange) {
|
|
965
|
+
throw new Error(`Unable to find config object path: ${parentPath.join('.') || 'root'}`);
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
const body = source.slice(parentRange.openBraceIndex + 1, parentRange.closeBraceIndex);
|
|
969
|
+
if (hasProperty(body, propertyName)) {
|
|
970
|
+
return source;
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
const parentIndent = lineIndentAt(source, parentRange.openBraceIndex);
|
|
974
|
+
const childIndent = `${parentIndent} `;
|
|
975
|
+
const insertion = `\n${childIndent}${propertyName}: {},`;
|
|
976
|
+
return `${source.slice(0, parentRange.closeBraceIndex)}${insertion}\n${parentIndent}${source.slice(parentRange.closeBraceIndex)}`;
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
function ensureValueProperty(source: string, parentPath: string[], propertyName: string, value: unknown): string {
|
|
980
|
+
const parentRange = findObjectRangeByPath(source, parentPath);
|
|
981
|
+
if (!parentRange) {
|
|
982
|
+
throw new Error(`Unable to find config object path: ${parentPath.join('.') || 'root'}`);
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
const body = source.slice(parentRange.openBraceIndex + 1, parentRange.closeBraceIndex);
|
|
986
|
+
if (hasProperty(body, propertyName)) {
|
|
987
|
+
return source;
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
const parentIndent = lineIndentAt(source, parentRange.openBraceIndex);
|
|
991
|
+
const childIndent = `${parentIndent} `;
|
|
992
|
+
const renderedValue = renderValue(value, childIndent);
|
|
993
|
+
const insertion = `\n${childIndent}${propertyName}: ${renderedValue},`;
|
|
994
|
+
return `${source.slice(0, parentRange.closeBraceIndex)}${insertion}\n${parentIndent}${source.slice(parentRange.closeBraceIndex)}`;
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
function hasProperty(objectBody: string, propertyName: string): boolean {
|
|
998
|
+
return new RegExp(`(^|\\n)\\s*${escapeRegExp(propertyName)}\\s*:`, 'm').test(objectBody);
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
function findObjectRangeByPath(source: string, pathSegments: string[]): { openBraceIndex: number; closeBraceIndex: number } | null {
|
|
1002
|
+
let currentRange = findRootObjectRange(source);
|
|
1003
|
+
if (!currentRange) return null;
|
|
1004
|
+
|
|
1005
|
+
for (const segment of pathSegments) {
|
|
1006
|
+
const body = source.slice(currentRange.openBraceIndex + 1, currentRange.closeBraceIndex);
|
|
1007
|
+
const propertyPattern = new RegExp(`(^|\\n)(\\s*)${escapeRegExp(segment)}\\s*:\\s*\\{`, 'm');
|
|
1008
|
+
const match = propertyPattern.exec(body);
|
|
1009
|
+
if (!match || typeof match.index !== 'number') return null;
|
|
1010
|
+
const braceOffset = body.indexOf('{', match.index);
|
|
1011
|
+
if (braceOffset === -1) return null;
|
|
1012
|
+
const openBraceIndex: number = currentRange.openBraceIndex + 1 + braceOffset;
|
|
1013
|
+
const closeBraceIndex: number = findMatchingBrace(source, openBraceIndex);
|
|
1014
|
+
if (closeBraceIndex === -1) return null;
|
|
1015
|
+
currentRange = { openBraceIndex, closeBraceIndex };
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
return currentRange;
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
function findRootObjectRange(source: string): { openBraceIndex: number; closeBraceIndex: number } | null {
|
|
1022
|
+
const exportMatch = /(export\s+default|module\.exports\s*=)\s*\{/.exec(source);
|
|
1023
|
+
if (!exportMatch || typeof exportMatch.index !== 'number') return null;
|
|
1024
|
+
const openBraceIndex = source.indexOf('{', exportMatch.index);
|
|
1025
|
+
if (openBraceIndex === -1) return null;
|
|
1026
|
+
const closeBraceIndex = findMatchingBrace(source, openBraceIndex);
|
|
1027
|
+
if (closeBraceIndex === -1) return null;
|
|
1028
|
+
return { openBraceIndex, closeBraceIndex };
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
function findMatchingBrace(source: string, openBraceIndex: number): number {
|
|
1032
|
+
let depth = 0;
|
|
1033
|
+
let inSingle = false;
|
|
1034
|
+
let inDouble = false;
|
|
1035
|
+
let inTemplate = false;
|
|
1036
|
+
let inLineComment = false;
|
|
1037
|
+
let inBlockComment = false;
|
|
1038
|
+
|
|
1039
|
+
for (let index = openBraceIndex; index < source.length; index += 1) {
|
|
1040
|
+
const char = source[index];
|
|
1041
|
+
const next = source[index + 1];
|
|
1042
|
+
const prev = source[index - 1];
|
|
1043
|
+
|
|
1044
|
+
if (inLineComment) {
|
|
1045
|
+
if (char === '\n') inLineComment = false;
|
|
1046
|
+
continue;
|
|
1047
|
+
}
|
|
1048
|
+
if (inBlockComment) {
|
|
1049
|
+
if (prev === '*' && char === '/') inBlockComment = false;
|
|
1050
|
+
continue;
|
|
1051
|
+
}
|
|
1052
|
+
if (!inSingle && !inDouble && !inTemplate) {
|
|
1053
|
+
if (char === '/' && next === '/') {
|
|
1054
|
+
inLineComment = true;
|
|
1055
|
+
continue;
|
|
1056
|
+
}
|
|
1057
|
+
if (char === '/' && next === '*') {
|
|
1058
|
+
inBlockComment = true;
|
|
1059
|
+
continue;
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
if (!inDouble && !inTemplate && char === '\'' && prev !== '\\') {
|
|
1063
|
+
inSingle = !inSingle;
|
|
1064
|
+
continue;
|
|
1065
|
+
}
|
|
1066
|
+
if (!inSingle && !inTemplate && char === '"' && prev !== '\\') {
|
|
1067
|
+
inDouble = !inDouble;
|
|
1068
|
+
continue;
|
|
1069
|
+
}
|
|
1070
|
+
if (!inSingle && !inDouble && char === '`' && prev !== '\\') {
|
|
1071
|
+
inTemplate = !inTemplate;
|
|
1072
|
+
continue;
|
|
1073
|
+
}
|
|
1074
|
+
if (inSingle || inDouble || inTemplate) continue;
|
|
1075
|
+
if (char === '{') depth += 1;
|
|
1076
|
+
if (char === '}') {
|
|
1077
|
+
depth -= 1;
|
|
1078
|
+
if (depth === 0) return index;
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
return -1;
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
function lineIndentAt(source: string, index: number): string {
|
|
1086
|
+
const lineStart = source.lastIndexOf('\n', index) + 1;
|
|
1087
|
+
const line = source.slice(lineStart, index);
|
|
1088
|
+
return line.match(/^\s*/)?.[0] || '';
|
|
347
1089
|
}
|
|
348
1090
|
|
|
349
|
-
function
|
|
350
|
-
|
|
1091
|
+
function renderValue(value: unknown, indent: string): string {
|
|
1092
|
+
if (typeof value === 'string') {
|
|
1093
|
+
return `'${value.replace(/\\/g, '\\\\').replace(/'/g, '\\\'')}'`;
|
|
1094
|
+
}
|
|
1095
|
+
if (typeof value === 'number' || typeof value === 'boolean') {
|
|
1096
|
+
return String(value);
|
|
1097
|
+
}
|
|
1098
|
+
if (Array.isArray(value)) {
|
|
1099
|
+
return `[${value.map((entry) => renderValue(entry, indent)).join(', ')}]`;
|
|
1100
|
+
}
|
|
1101
|
+
if (value && typeof value === 'object') {
|
|
1102
|
+
const entries = Object.entries(value);
|
|
1103
|
+
if (entries.length === 0) return '{}';
|
|
1104
|
+
return `{\n${entries.map(([key, entryValue]) => `${indent} ${key}: ${renderValue(entryValue, `${indent} `)}`).join(',\n')}\n${indent}}`;
|
|
1105
|
+
}
|
|
1106
|
+
return 'undefined';
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
function escapeRegExp(value: string): string {
|
|
1110
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
function inferGithubRepo(cwd: string): string | null {
|
|
1114
|
+
const packageJsonPath = path.join(cwd, 'package.json');
|
|
1115
|
+
if (!fs.existsSync(packageJsonPath)) return null;
|
|
1116
|
+
try {
|
|
1117
|
+
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')) as { repository?: string | { url?: string } };
|
|
1118
|
+
const repositoryUrl = typeof packageJson.repository === 'string' ? packageJson.repository : packageJson.repository?.url;
|
|
1119
|
+
if (!repositoryUrl) return null;
|
|
1120
|
+
const match = repositoryUrl.match(/github\.com[:/](.+?)(?:\.git)?$/);
|
|
1121
|
+
return match?.[1] || null;
|
|
1122
|
+
} catch {
|
|
1123
|
+
return null;
|
|
1124
|
+
}
|
|
351
1125
|
}
|
|
352
1126
|
|
|
353
|
-
function
|
|
354
|
-
return
|
|
1127
|
+
function slugify(value: string): string {
|
|
1128
|
+
return value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
|
355
1129
|
}
|
|
356
1130
|
|
|
357
|
-
function
|
|
358
|
-
|
|
1131
|
+
function inferScriptName(command: string): string | null {
|
|
1132
|
+
const match = command.match(/(?:npm|pnpm|bun)\s+run\s+([a-zA-Z0-9:_-]+)/) || command.match(/yarn\s+([a-zA-Z0-9:_-]+)/);
|
|
1133
|
+
return match?.[1] || null;
|
|
359
1134
|
}
|
|
360
1135
|
|
|
361
1136
|
function findExistingPath(cwd: string, candidates: string[]): string | null {
|
|
@@ -366,10 +1141,10 @@ function findExistingPath(cwd: string, candidates: string[]): string | null {
|
|
|
366
1141
|
return null;
|
|
367
1142
|
}
|
|
368
1143
|
|
|
369
|
-
async function loadProjectConfig(configPath: string): Promise<
|
|
1144
|
+
async function loadProjectConfig(configPath: string): Promise<DeploidConfigShape | null> {
|
|
370
1145
|
try {
|
|
371
1146
|
const mod = await import(pathToFileUrl(configPath).href);
|
|
372
|
-
return (mod.default || mod) as
|
|
1147
|
+
return (mod.default || mod) as DeploidConfigShape;
|
|
373
1148
|
} catch {
|
|
374
1149
|
return null;
|
|
375
1150
|
}
|
|
@@ -390,9 +1165,21 @@ function readJson<T>(filePath: string): T | null {
|
|
|
390
1165
|
}
|
|
391
1166
|
}
|
|
392
1167
|
|
|
1168
|
+
function safeRead(filePath: string): string {
|
|
1169
|
+
try {
|
|
1170
|
+
return fs.readFileSync(filePath, 'utf8');
|
|
1171
|
+
} catch {
|
|
1172
|
+
return '';
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
|
|
393
1176
|
function asRecord(value: unknown): Record<string, unknown> {
|
|
394
1177
|
return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : {};
|
|
395
1178
|
}
|
|
396
1179
|
|
|
1180
|
+
function capitalize(value: string): string {
|
|
1181
|
+
return value.charAt(0).toUpperCase() + value.slice(1);
|
|
1182
|
+
}
|
|
1183
|
+
|
|
397
1184
|
export default plugin;
|
|
398
1185
|
export { inspectProject, plugin };
|