Skip to content

Commit 7555b39

Browse files
authored
Merge pull request #16 from ernilambar/update-error-codes
Update error codes
2 parents 5853b6d + 7b50b21 commit 7555b39

File tree

16 files changed

+887
-88
lines changed

16 files changed

+887
-88
lines changed

index.js

Lines changed: 55 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
* Deploys a WordPress plugin or theme to the WordPress.org SVN repo.
77
* Config is read from package.json under the "wpDeployer" key.
88
* Run from the project root (where package.json and wpDeployer config live).
9+
*
10+
* Exit codes: 0 success, 1 config/validation/preflight, 2 deploy failure, 130 SIGINT.
911
*/
1012

1113
import fs from 'fs-extra'
@@ -14,6 +16,9 @@ import { exec as execCb } from 'child_process'
1416
import { promisify } from 'util'
1517
import { resolveSettings } from './lib/config.js'
1618
import { createPluginSteps, createThemeSteps } from './lib/steps.js'
19+
import { runPreflightSync } from './lib/preflight.js'
20+
import { DeployError } from './lib/deploy-error.js'
21+
import { EXIT_SUCCESS, EXIT_CONFIG, EXIT_RUNTIME, EXIT_SIGINT } from './lib/exit-codes.js'
1722

1823
const exec = promisify(execCb)
1924

@@ -22,20 +27,51 @@ const pkg = fs.readJsonSync('./package.json')
2227
const awk = process.platform === 'win32' ? 'gawk' : 'awk'
2328
const noRunIfEmpty = process.platform !== 'darwin' ? '--no-run-if-empty ' : ''
2429

30+
process.on('SIGINT', () => {
31+
console.error(chalk.yellow('Interrupted.'))
32+
process.exit(EXIT_SIGINT)
33+
})
34+
2535
const wpDeployer = async () => {
2636
console.log(chalk.cyan('Processing...'))
2737

28-
const { settings, error } = resolveSettings(pkg)
38+
const { settings, error, errorMessage } = resolveSettings(pkg)
39+
if (error === 'invalid_slug') {
40+
console.error(chalk.red(`Invalid slug: ${errorMessage}`))
41+
return EXIT_CONFIG
42+
}
2943
if (error === 'username_required') {
3044
console.error(chalk.red('Username is required.'))
31-
process.exit()
45+
return EXIT_CONFIG
3246
}
3347
if (error === 'theme_earlier_version_required') {
3448
console.error(chalk.red('For repoType theme, earlierVersion is required.'))
35-
process.exit()
49+
return EXIT_CONFIG
50+
}
51+
if (error === 'invalid_new_version') {
52+
console.error(chalk.red(`Invalid package version for SVN (newVersion): ${errorMessage}`))
53+
return EXIT_CONFIG
54+
}
55+
if (error === 'invalid_earlier_version') {
56+
console.error(chalk.red(`Invalid earlierVersion for SVN: ${errorMessage}`))
57+
return EXIT_CONFIG
58+
}
59+
if (error === 'invalid_svn_url') {
60+
console.error(chalk.red(`Invalid SVN URL: ${errorMessage}`))
61+
return EXIT_CONFIG
3662
}
3763

38-
const helpers = { exec, chalk, fs, awk, noRunIfEmpty }
64+
try {
65+
runPreflightSync(settings, { fs, awk })
66+
} catch (e) {
67+
if (e instanceof DeployError) {
68+
console.error(chalk.red(e.message))
69+
return e.exitCode
70+
}
71+
throw e
72+
}
73+
74+
const helpers = { exec, fs, awk, noRunIfEmpty }
3975
const steps = settings.repoType === 'plugin'
4076
? createPluginSteps(settings, helpers)
4177
: createThemeSteps(settings, helpers)
@@ -45,9 +81,23 @@ const wpDeployer = async () => {
4581
for (const step of steps) {
4682
s = await step(s)
4783
}
84+
console.log(chalk.green('Finished successfully.'))
85+
return EXIT_SUCCESS
4886
} catch (err) {
49-
console.error(chalk.red(err))
87+
console.error(chalk.red(err?.message || err))
88+
return EXIT_RUNTIME
5089
}
5190
}
5291

5392
wpDeployer()
93+
.then((code) => {
94+
process.exit(code ?? EXIT_SUCCESS)
95+
})
96+
.catch((err) => {
97+
if (err instanceof DeployError) {
98+
console.error(chalk.red(err.message))
99+
process.exit(err.exitCode)
100+
}
101+
console.error(chalk.red(err?.message || err))
102+
process.exit(EXIT_RUNTIME)
103+
})

lib/config.js

Lines changed: 63 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,14 @@
44
*/
55

66
import merge from 'just-merge'
7+
import { validateSvnSafeVersion, validateSvnUrl } from './validators/index.js'
8+
import { hasPathUnsafeChars } from './validators/shared.js'
79

810
export function getDefaults (pkg) {
911
return {
1012
url: '',
1113
slug: `${pkg.name}`,
14+
// Seeded from pkg.name; resolveSettings aligns with slug when wpDeployer omits mainFile
1215
mainFile: `${pkg.name}.php`,
1316
username: '',
1417
repoType: 'plugin',
@@ -27,26 +30,60 @@ export function getDefaults (pkg) {
2730
/**
2831
* Resolve settings from package.json. Does not exit; returns an error key if validation fails.
2932
* @param {object} pkg - Parsed package.json (must have name, version)
30-
* @returns {{ settings: object, error: null | 'username_required' | 'theme_earlier_version_required' }}
33+
* @returns {{ settings: object, error: null | 'invalid_slug' | 'username_required' | 'theme_earlier_version_required' | 'invalid_svn_url' | 'invalid_new_version' | 'invalid_earlier_version', errorMessage?: string }}
3134
*/
3235
export function resolveSettings (pkg) {
3336
const defaults = getDefaults(pkg)
34-
let settings = merge(defaults, Object.prototype.hasOwnProperty.call(pkg, 'wpDeployer') ? pkg.wpDeployer : {})
37+
const wpDeployer = Object.prototype.hasOwnProperty.call(pkg, 'wpDeployer') ? pkg.wpDeployer : {}
38+
let settings = merge(defaults, wpDeployer)
39+
40+
const slug = settings.slug
41+
if (typeof slug !== 'string' || slug.length === 0) {
42+
return { settings, error: 'invalid_slug', errorMessage: 'slug must be a non-empty string.' }
43+
}
44+
if (slug !== slug.trim()) {
45+
return { settings, error: 'invalid_slug', errorMessage: 'slug must not have leading or trailing whitespace.' }
46+
}
47+
if (slug === '.' || slug === '..' || slug.includes('..')) {
48+
return { settings, error: 'invalid_slug', errorMessage: 'slug must not be "." or ".." or contain "..".' }
49+
}
50+
if (hasPathUnsafeChars(slug)) {
51+
return {
52+
settings,
53+
error: 'invalid_slug',
54+
errorMessage: 'slug must be a single path segment (no / or \\).'
55+
}
56+
}
57+
58+
if (!Object.prototype.hasOwnProperty.call(wpDeployer, 'mainFile')) {
59+
settings.mainFile = `${settings.slug}.php`
60+
}
3561

3662
settings = merge(settings, {
3763
svnPath: settings.tmpDir.replace(/\/$|$/, '/') + settings.slug
3864
})
3965

4066
settings.buildDir = settings.buildDir.replace(/\/$|$/, '/')
4167

68+
// Default WordPress.org SVN URLs follow `slug` (merged default: pkg.name), not pkg.name directly.
4269
if (!settings.url) {
4370
if (settings.repoType === 'plugin') {
44-
settings.url = `https://plugins.svn.wordpress.org/${pkg.name}/`
71+
settings.url = `https://plugins.svn.wordpress.org/${settings.slug}/`
4572
} else if (settings.repoType === 'theme') {
46-
settings.url = `https://themes.svn.wordpress.org/${pkg.name}/`
73+
settings.url = `https://themes.svn.wordpress.org/${settings.slug}/`
4774
}
4875
}
4976

77+
const urlResult = validateSvnUrl(settings.url)
78+
if (!urlResult.ok) {
79+
return {
80+
settings,
81+
error: 'invalid_svn_url',
82+
errorMessage: urlResult.message
83+
}
84+
}
85+
settings.url = urlResult.value
86+
5087
if (!settings.username) {
5188
return { settings, error: 'username_required' }
5289
}
@@ -55,5 +92,27 @@ export function resolveSettings (pkg) {
5592
return { settings, error: 'theme_earlier_version_required' }
5693
}
5794

95+
const newVersionResult = validateSvnSafeVersion(settings.newVersion)
96+
if (!newVersionResult.ok) {
97+
return {
98+
settings,
99+
error: 'invalid_new_version',
100+
errorMessage: newVersionResult.message
101+
}
102+
}
103+
settings.newVersion = newVersionResult.value
104+
105+
if (settings.repoType === 'theme') {
106+
const earlierResult = validateSvnSafeVersion(settings.earlierVersion)
107+
if (!earlierResult.ok) {
108+
return {
109+
settings,
110+
error: 'invalid_earlier_version',
111+
errorMessage: earlierResult.message
112+
}
113+
}
114+
settings.earlierVersion = earlierResult.value
115+
}
116+
58117
return { settings, error: null }
59118
}

lib/deploy-error.js

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { EXIT_RUNTIME } from './exit-codes.js'
2+
3+
/**
4+
* Error with explicit CLI exit code (preflight / deploy).
5+
*/
6+
export class DeployError extends Error {
7+
/**
8+
* @param {string} message
9+
* @param {number} [exitCode=EXIT_RUNTIME]
10+
*/
11+
constructor (message, exitCode = EXIT_RUNTIME) {
12+
super(message)
13+
this.name = 'DeployError'
14+
this.exitCode = exitCode
15+
}
16+
}

lib/exit-codes.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
/** Process exit codes for the wp-deployer CLI */
2+
export const EXIT_SUCCESS = 0
3+
/** Invalid config, validation, or preflight (environment / paths) */
4+
export const EXIT_CONFIG = 1
5+
/** Deploy pipeline / SVN / filesystem failure */
6+
export const EXIT_RUNTIME = 2
7+
/** User interrupt */
8+
export const EXIT_SIGINT = 130

lib/preflight.js

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
/**
2+
* Pre-deploy checks: required tools on PATH, build/assets directories.
3+
*/
4+
5+
import path from 'path'
6+
import { execSync } from 'child_process'
7+
import { DeployError } from './deploy-error.js'
8+
import { EXIT_CONFIG } from './exit-codes.js'
9+
10+
/**
11+
* @param {string} command - Executable name (no spaces)
12+
*/
13+
function assertOnPath (command) {
14+
try {
15+
if (process.platform === 'win32') {
16+
execSync(`where ${command}`, { stdio: 'ignore' })
17+
} else {
18+
execSync(`command -v ${command}`, { stdio: 'ignore', shell: true })
19+
}
20+
} catch {
21+
throw new DeployError(
22+
`${command} not found on PATH. Install it or fix your environment.`,
23+
EXIT_CONFIG
24+
)
25+
}
26+
}
27+
28+
function stripTrailingSlash (dir) {
29+
return dir.replace(/\/+$/, '') || dir
30+
}
31+
32+
function needsBuildCopy (settings) {
33+
if (settings.repoType === 'theme') return true
34+
return settings.deployTrunk === true
35+
}
36+
37+
function needsAssetsDir (settings) {
38+
return settings.repoType === 'plugin' && settings.deployAssets === true
39+
}
40+
41+
function needsAwk (settings) {
42+
if (settings.repoType === 'theme') return true
43+
return settings.deployTrunk === true || settings.deployAssets === true
44+
}
45+
46+
/**
47+
* @param {object} settings - Resolved wpDeployer settings
48+
* @param {{ fs: import('fs-extra').default, awk: string }} io
49+
* @param {{ checkCommands?: boolean }} [options] - Set checkCommands false in tests only
50+
*/
51+
export function runPreflightSync (settings, { fs, awk }, options = {}) {
52+
const { checkCommands = true } = options
53+
54+
if (checkCommands) {
55+
assertOnPath('svn')
56+
if (needsAwk(settings)) {
57+
assertOnPath(awk)
58+
}
59+
}
60+
61+
const cwd = process.cwd()
62+
63+
if (needsBuildCopy(settings)) {
64+
const rel = stripTrailingSlash(settings.buildDir)
65+
const abs = path.resolve(cwd, rel)
66+
if (!fs.existsSync(abs)) {
67+
throw new DeployError(
68+
`buildDir does not exist: ${settings.buildDir} (resolved to ${abs})`,
69+
EXIT_CONFIG
70+
)
71+
}
72+
if (!fs.statSync(abs).isDirectory()) {
73+
throw new DeployError(
74+
`buildDir is not a directory: ${settings.buildDir}`,
75+
EXIT_CONFIG
76+
)
77+
}
78+
const entries = fs.readdirSync(abs)
79+
if (entries.length === 0) {
80+
throw new DeployError(
81+
`buildDir is empty: ${settings.buildDir}. Build your project before deploying.`,
82+
EXIT_CONFIG
83+
)
84+
}
85+
}
86+
87+
if (needsAssetsDir(settings)) {
88+
const rel = stripTrailingSlash(settings.assetsDir)
89+
const abs = path.resolve(cwd, rel)
90+
if (!fs.existsSync(abs)) {
91+
throw new DeployError(
92+
`assetsDir does not exist: ${settings.assetsDir} (resolved to ${abs})`,
93+
EXIT_CONFIG
94+
)
95+
}
96+
if (!fs.statSync(abs).isDirectory()) {
97+
throw new DeployError(
98+
`assetsDir is not a directory: ${settings.assetsDir}`,
99+
EXIT_CONFIG
100+
)
101+
}
102+
}
103+
}

0 commit comments

Comments
 (0)