@eliware/vyops 1.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/LICENSE +21 -0
- package/README.md +113 -0
- package/RELEASE_NOTES.md +29 -0
- package/bin/vyops +3 -0
- package/package.json +51 -0
- package/src/args.mjs +15 -0
- package/src/deploy.mjs +102 -0
- package/src/git.mjs +86 -0
- package/src/main.mjs +45 -0
- package/src/ssh.mjs +273 -0
- package/src/validate.mjs +137 -0
- package/vyops.mjs +4 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Eli Sterling
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# VyOps
|
|
2
|
+
|
|
3
|
+
> Deploy native VyOS configuration files over SSH and synchronize confirmed router state back to Git.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- SSH-key authentication via `ssh2`.
|
|
8
|
+
- Native VyOS curly-brace configuration validation.
|
|
9
|
+
- Candidate load, `compare`, `commit-confirm`, confirmation, and save.
|
|
10
|
+
- Post-commit hook installation from `scripts/commit/post-hooks.d/`.
|
|
11
|
+
- Pushback of confirmed `/config/config.boot` changes to the current Git repository.
|
|
12
|
+
|
|
13
|
+
## Requirements
|
|
14
|
+
|
|
15
|
+
- Node.js 26 or newer.
|
|
16
|
+
- npm.
|
|
17
|
+
- SSH access to the target VyOS router using an existing key.
|
|
18
|
+
- A Git working tree when pushback behavior is enabled by the deployment workflow.
|
|
19
|
+
|
|
20
|
+
## Installation
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
npm install
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
The package provides:
|
|
27
|
+
|
|
28
|
+
```text
|
|
29
|
+
vyops Deploy or dry-run a configuration.
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Configuration
|
|
33
|
+
|
|
34
|
+
| Variable | Required | Default | Purpose |
|
|
35
|
+
|---|---|---|---|
|
|
36
|
+
| `VYOPS_SSH_KEY` | No | `$HOME/.ssh/id_rsa` | Private SSH key path. |
|
|
37
|
+
| `SSH_AUTH_SOCK` | No | unset | SSH agent socket passed to `ssh2`. |
|
|
38
|
+
| `LOG_LEVEL` | No | `info` | Winston log level (`error`, `warn`, `info`, `http`, `verbose`, `debug`, or `silly`). |
|
|
39
|
+
|
|
40
|
+
The target must be supplied as `user@host`. The config must use native VyOS curly-brace syntax.
|
|
41
|
+
|
|
42
|
+
## Usage
|
|
43
|
+
|
|
44
|
+
Deploy:
|
|
45
|
+
|
|
46
|
+
```sh
|
|
47
|
+
./vyops.mjs vyos@core1 /path/to/config.boot
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Console switches:
|
|
51
|
+
|
|
52
|
+
```sh
|
|
53
|
+
./vyops.mjs --help
|
|
54
|
+
./vyops.mjs --version
|
|
55
|
+
./vyops.mjs --dry-run vyos@core1 /path/to/config.boot
|
|
56
|
+
./vyops.mjs --force --debug vyos@core1 /path/to/config.boot
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
`--dry-run` validates the config and skips SSH, commit, save, download, and Git pushback.
|
|
60
|
+
|
|
61
|
+
The deployment workflow:
|
|
62
|
+
|
|
63
|
+
1. Connects using SSH keys.
|
|
64
|
+
2. Uploads the config to `/home/vyos`.
|
|
65
|
+
3. Loads it into the candidate configuration.
|
|
66
|
+
4. Prints `compare` output.
|
|
67
|
+
5. Runs `commit-confirm`.
|
|
68
|
+
6. Confirms and saves only after confirmation.
|
|
69
|
+
7. Installs local post-commit hooks, when present.
|
|
70
|
+
8. Downloads `/config/config.boot` back to the supplied config path.
|
|
71
|
+
9. Removes temporary remote files and closes SSH sessions.
|
|
72
|
+
|
|
73
|
+
Exit code `0` means success. Non-zero means validation or deployment failure.
|
|
74
|
+
|
|
75
|
+
## Git pushback behavior
|
|
76
|
+
|
|
77
|
+
After a successful deployment, VyOps commits and pushes changes to the current Git repository with a `Pushback <timestamp>` commit. A later run skips deployment when the config is unchanged and the latest commit is a pushback commit.
|
|
78
|
+
|
|
79
|
+
Review repository status and remotes before deployment. Do not run deployments concurrently against `core1` and `core2`.
|
|
80
|
+
|
|
81
|
+
## Security
|
|
82
|
+
|
|
83
|
+
- Config files may contain secrets; do not print, publish, or commit deployment logs containing config contents.
|
|
84
|
+
- Use least-privilege SSH accounts and keys dedicated to the target routers.
|
|
85
|
+
- Keep private keys outside the repository and restrict their filesystem permissions.
|
|
86
|
+
- `compare` output is logged during deployment; review logs and diffs through the normal change-control process.
|
|
87
|
+
- Use `LOG_LEVEL=debug` only when troubleshooting; debug output includes connection, path, and command progress.
|
|
88
|
+
|
|
89
|
+
## Development and validation
|
|
90
|
+
|
|
91
|
+
```sh
|
|
92
|
+
npm install
|
|
93
|
+
npm test
|
|
94
|
+
npm run lint
|
|
95
|
+
npm run audit
|
|
96
|
+
npm run validate:package
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Coverage is generated by the test command. Focused tests should be added with behavior changes. Never deploy to a router during tests unless explicitly requested.
|
|
100
|
+
|
|
101
|
+
## Operational notes
|
|
102
|
+
|
|
103
|
+
Deployments require existing SSH keys and a reachable VyOS target. The workflow uses confirmed commits and saves only after confirmation. Temporary remote files are cleaned up in the deployment cleanup path. SIGINT, SIGTERM, and SIGHUP trigger SSH cleanup through the shared signal handlers.
|
|
104
|
+
|
|
105
|
+
## Support
|
|
106
|
+
|
|
107
|
+
Repository: `git@github.com:eliware/vyops-js.git`
|
|
108
|
+
|
|
109
|
+
Open an issue or provide a focused patch with tests and validation results.
|
|
110
|
+
|
|
111
|
+
## License
|
|
112
|
+
|
|
113
|
+
MIT License; see `LICENSE`.
|
package/RELEASE_NOTES.md
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Release Notes
|
|
2
|
+
|
|
3
|
+
## 1.0.2
|
|
4
|
+
|
|
5
|
+
- Hardened SSH host verification with the user's `known_hosts` file.
|
|
6
|
+
- Added validated targets, SSH/SFTP timeouts, randomized remote temporary paths, and awaited cleanup.
|
|
7
|
+
- Added transactional post-commit hook backup and rollback.
|
|
8
|
+
- Added Git pushback isolation, locking, stale-lock recovery, and `--force`.
|
|
9
|
+
- Added `--debug` logging and improved interactive deployment validation.
|
|
10
|
+
- Added npm packaging whitelist and package validation.
|
|
11
|
+
- Added CI timeout, dependency audit, and package validation.
|
|
12
|
+
|
|
13
|
+
Verification:
|
|
14
|
+
|
|
15
|
+
- `npm test` (100% coverage)
|
|
16
|
+
- `npm run lint`
|
|
17
|
+
- `npm run audit`
|
|
18
|
+
- `npm run validate:package`
|
|
19
|
+
|
|
20
|
+
## 1.0.3 - 2026-08-10
|
|
21
|
+
|
|
22
|
+
- Expanded README documentation for installation, configuration, usage, security, operations, development, and support.
|
|
23
|
+
- Documented Git pushback behavior and dry-run validation.
|
|
24
|
+
- Added `@eliware/common` logging, filesystem, path, error-handler, and signal-handler integration.
|
|
25
|
+
|
|
26
|
+
Verification:
|
|
27
|
+
|
|
28
|
+
- `npm test`
|
|
29
|
+
- `npm run lint`
|
package/bin/vyops
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@eliware/vyops",
|
|
3
|
+
"version": "1.0.3",
|
|
4
|
+
"description": "Deploy native VyOS configuration files over SSH.",
|
|
5
|
+
"main": "vyops.mjs",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"lint": "npx oxlint .",
|
|
8
|
+
"test": "npx --node-options=\"--experimental-vm-modules --no-warnings\" jest --detectOpenHandles --silent --coverage",
|
|
9
|
+
"deploy": "./vyops.mjs",
|
|
10
|
+
"test:gaps": "npm test 2>&1 | grep '|' | grep -vF '| 100 | 100 | 100 | 100 |'",
|
|
11
|
+
"pack": "npm pack --dry-run",
|
|
12
|
+
"validate:package": "npm pack --dry-run --json | node scripts/validate-package.mjs",
|
|
13
|
+
"audit": "npm audit --omit=dev --audit-level=moderate"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"vyos",
|
|
17
|
+
"networking",
|
|
18
|
+
"gitops",
|
|
19
|
+
"ssh",
|
|
20
|
+
"cli"
|
|
21
|
+
],
|
|
22
|
+
"type": "module",
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"@eliware/common": "^1.1.7",
|
|
25
|
+
"ssh2": "^1.17.0"
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"jest": "^30.4.2",
|
|
29
|
+
"oxlint": "^1.77.0"
|
|
30
|
+
},
|
|
31
|
+
"bin": {
|
|
32
|
+
"vyops": "./bin/vyops"
|
|
33
|
+
},
|
|
34
|
+
"license": "MIT",
|
|
35
|
+
"repository": {
|
|
36
|
+
"type": "git",
|
|
37
|
+
"url": "git+https://github.com/eliware/vyops.git"
|
|
38
|
+
},
|
|
39
|
+
"homepage": "https://github.com/eliware/vyops#readme",
|
|
40
|
+
"bugs": {
|
|
41
|
+
"url": "https://github.com/eliware/vyops/issues"
|
|
42
|
+
},
|
|
43
|
+
"files": [
|
|
44
|
+
"vyops.mjs",
|
|
45
|
+
"bin/",
|
|
46
|
+
"src/",
|
|
47
|
+
"README.md",
|
|
48
|
+
"LICENSE",
|
|
49
|
+
"RELEASE_NOTES.md"
|
|
50
|
+
]
|
|
51
|
+
}
|
package/src/args.mjs
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export const usage = `Usage:
|
|
2
|
+
vyops [--dry-run] [--force] [--debug] <user@hostname-or-ip> <config.boot>
|
|
3
|
+
vyops --help
|
|
4
|
+
vyops --version`;
|
|
5
|
+
|
|
6
|
+
export function parseArgs(argv) {
|
|
7
|
+
if (argv.length === 1 && argv[0] === '--help') return { help: true };
|
|
8
|
+
if (argv.length === 1 && argv[0] === '--version') return { version: true };
|
|
9
|
+
|
|
10
|
+
const options = new Set(argv.filter(value => value.startsWith('--')));
|
|
11
|
+
if ([...options].some(option => !['--dry-run', '--force', '--debug'].includes(option))) throw new Error(usage);
|
|
12
|
+
const values = argv.filter(value => !value.startsWith('--'));
|
|
13
|
+
if (values.length !== 2) throw new Error(usage);
|
|
14
|
+
return { target: values[0], config: values[1], ...(options.has('--dry-run') ? { dryRun: true } : {}), ...(options.has('--force') ? { force: true } : {}), ...(options.has('--debug') ? { debug: true } : {}) };
|
|
15
|
+
}
|
package/src/deploy.mjs
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { fs, path as eliwarePath, log } from '@eliware/common';
|
|
3
|
+
import { close, connect, download, exec, interactive, upload } from './ssh.mjs';
|
|
4
|
+
|
|
5
|
+
export function extractCompare(output) {
|
|
6
|
+
const ansi = new RegExp(`${String.fromCharCode(27)}\\[[0-9;?]*[ -/]*[@-~]`, 'g');
|
|
7
|
+
const title = new RegExp(`${String.fromCharCode(27)}\\][^${String.fromCharCode(7)}]*(?:${String.fromCharCode(7)}|${String.fromCharCode(27)}\\\\)`, 'g');
|
|
8
|
+
const clean = output.replace(ansi, '').replace(title, '').replace(new RegExp(`${String.fromCharCode(27)}[=><]`, 'g'), '').replace(/\r/g, '');
|
|
9
|
+
const match = clean.match(/# compare\n([\s\S]*?)(?=\n[^\n]*# printf)/)
|
|
10
|
+
?? clean.match(/(No changes between working and active configurations\.)[\s\S]*?(?=\n[^\n]*# printf)/);
|
|
11
|
+
return match?.[1].replace(/^\[edit\]\s*$/gm, '').trim() ?? '';
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async function installPostCommitHooks(client, config, log, runId) {
|
|
15
|
+
const hooksDir = eliwarePath(config, '..', 'scripts', 'commit', 'post-hooks.d');
|
|
16
|
+
let names;
|
|
17
|
+
try {
|
|
18
|
+
names = (await fs.promises.readdir(hooksDir, { withFileTypes: true }))
|
|
19
|
+
.filter(entry => entry.isFile())
|
|
20
|
+
.map(entry => entry.name)
|
|
21
|
+
.sort();
|
|
22
|
+
} catch (error) {
|
|
23
|
+
if (error.code === 'ENOENT') return null;
|
|
24
|
+
throw error;
|
|
25
|
+
}
|
|
26
|
+
if (!names.length) return null;
|
|
27
|
+
if (names.some(name => !name || name === '.' || name === '..' || /[\\/]/.test(name))) {
|
|
28
|
+
throw new Error('post-commit hook name is invalid');
|
|
29
|
+
}
|
|
30
|
+
const remoteDir = `/home/vyos/.post-hooks.${runId}`;
|
|
31
|
+
const backupDir = `/home/vyos/.post-hooks-backup.${runId}`;
|
|
32
|
+
const installDir = '/config/scripts/commit/post-hooks.d';
|
|
33
|
+
const made = await exec(client, `mkdir -p ${JSON.stringify(remoteDir)} && sudo mkdir -p ${JSON.stringify(installDir)} ${JSON.stringify(backupDir)}`);
|
|
34
|
+
if (made.code !== 0) throw new Error(`post-commit hook directory setup failed: ${made.stderr || made.stdout}`.trim());
|
|
35
|
+
try {
|
|
36
|
+
for (const name of names) {
|
|
37
|
+
const local = eliwarePath(hooksDir, name);
|
|
38
|
+
const remote = `${remoteDir}/${name}`;
|
|
39
|
+
const installed = `${installDir}/${name}`;
|
|
40
|
+
const backup = `${backupDir}/${name}`;
|
|
41
|
+
const backedUp = await exec(client, `if sudo test -e ${JSON.stringify(installed)}; then sudo cp -p -- ${JSON.stringify(installed)} ${JSON.stringify(backup)}; fi`);
|
|
42
|
+
if (backedUp.code !== 0) throw new Error(`post-commit hook backup failed (${name}): ${backedUp.stderr || backedUp.stdout}`.trim());
|
|
43
|
+
await upload(client, local, remote);
|
|
44
|
+
const installedResult = await exec(client, `sudo install -m 755 ${JSON.stringify(remote)} ${JSON.stringify(installed)}`);
|
|
45
|
+
if (installedResult.code !== 0) throw new Error(`post-commit hook install failed (${name}): ${installedResult.stderr || installedResult.stdout}`.trim());
|
|
46
|
+
log(`installed post-commit hook: ${name}`);
|
|
47
|
+
}
|
|
48
|
+
} catch (error) {
|
|
49
|
+
await exec(client, `sudo rm -f -- ${names.map(name => JSON.stringify(`${installDir}/${name}`)).join(' ')}; for f in ${JSON.stringify(backupDir)}/*; do [ -e "$f" ] && sudo mv -- "$f" ${JSON.stringify(installDir)}/; done; rm -rf -- ${JSON.stringify(remoteDir)} ${JSON.stringify(backupDir)}`).catch(cleanupError => log(`hook rollback failed: ${cleanupError.message}`));
|
|
50
|
+
throw error;
|
|
51
|
+
}
|
|
52
|
+
return async committed => {
|
|
53
|
+
if (committed) {
|
|
54
|
+
await exec(client, `sudo rm -rf -- ${JSON.stringify(backupDir)}; rm -rf -- ${JSON.stringify(remoteDir)}`).catch(error => log(`hook cleanup failed: ${error.message}`));
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
await exec(client, `sudo rm -f -- ${names.map(name => JSON.stringify(`${installDir}/${name}`)).join(' ')}; for f in ${JSON.stringify(backupDir)}/*; do [ -e "$f" ] && sudo mv -- "$f" ${JSON.stringify(installDir)}/; done; rm -rf -- ${JSON.stringify(remoteDir)} ${JSON.stringify(backupDir)}`).catch(error => log(`hook rollback failed: ${error.message}`));
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function deploy({ target, config }) {
|
|
62
|
+
const debugLog = message => log.debug(`[vyops] ${message}`);
|
|
63
|
+
debugLog(`connecting: ${target}`);
|
|
64
|
+
const client = await connect(target);
|
|
65
|
+
debugLog('SSH connected');
|
|
66
|
+
let finalizeHooks;
|
|
67
|
+
const runId = randomUUID();
|
|
68
|
+
const remote = `/home/vyos/.config.deploy.${runId}`;
|
|
69
|
+
try {
|
|
70
|
+
debugLog(`uploading config: ${config}`);
|
|
71
|
+
await upload(client, config, remote);
|
|
72
|
+
debugLog(`upload complete: ${remote}`);
|
|
73
|
+
finalizeHooks = await installPostCommitHooks(client, config, debugLog, runId);
|
|
74
|
+
debugLog('starting interactive deployment sequence');
|
|
75
|
+
const output = await interactive(client, [
|
|
76
|
+
'configure',
|
|
77
|
+
{ command: `load ${remote}`, reject: /(?:load failed|error|invalid)/i },
|
|
78
|
+
"printf '%s\\n' '--- compare ---'",
|
|
79
|
+
'compare',
|
|
80
|
+
"printf '%s\\n' '--- end compare ---'",
|
|
81
|
+
{ command: 'commit-confirm 5', reject: /(?:commit failed|error|invalid)/i },
|
|
82
|
+
{ command: 'confirm', reject: /(?:confirm failed|error|invalid)/i },
|
|
83
|
+
{ command: 'save', reject: /(?:save failed|error|invalid)/i },
|
|
84
|
+
'exit',
|
|
85
|
+
'exit',
|
|
86
|
+
], debugLog);
|
|
87
|
+
debugLog(`interactive sequence returned (${output.length} bytes)`);
|
|
88
|
+
const compare = extractCompare(output);
|
|
89
|
+
if (compare) log.info(compare);
|
|
90
|
+
if (finalizeHooks) await finalizeHooks(true);
|
|
91
|
+
debugLog(`syncing live config: /config/config.boot -> ${config}`);
|
|
92
|
+
await download(client, '/config/config.boot', config);
|
|
93
|
+
return 0;
|
|
94
|
+
} catch (error) {
|
|
95
|
+
if (finalizeHooks) await finalizeHooks(false);
|
|
96
|
+
throw error;
|
|
97
|
+
} finally {
|
|
98
|
+
debugLog('cleaning up remote file');
|
|
99
|
+
await exec(client, `rm -f -- ${JSON.stringify(remote)}`).catch(error => debugLog(`cleanup failed: ${error.message}`));
|
|
100
|
+
await close(client);
|
|
101
|
+
}
|
|
102
|
+
}
|
package/src/git.mjs
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { promises as fs } from 'node:fs';
|
|
3
|
+
import { promisify } from 'node:util';
|
|
4
|
+
import { path } from '@eliware/common';
|
|
5
|
+
|
|
6
|
+
const run = promisify(execFile);
|
|
7
|
+
const LOCK_MAX_AGE = 60 * 60 * 1000;
|
|
8
|
+
|
|
9
|
+
async function git(args, cwd) {
|
|
10
|
+
return run('git', args, { cwd, encoding: 'utf8' });
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async function staleLock(lock) {
|
|
14
|
+
let owner;
|
|
15
|
+
let stats;
|
|
16
|
+
try {
|
|
17
|
+
[owner, stats] = await Promise.all([
|
|
18
|
+
fs.readFile(path(lock, 'owner'), 'utf8'),
|
|
19
|
+
fs.stat(lock),
|
|
20
|
+
]);
|
|
21
|
+
} catch {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
const pid = Number.parseInt(owner.trim(), 10);
|
|
25
|
+
if (Number.isInteger(pid) && pid > 0) {
|
|
26
|
+
try {
|
|
27
|
+
process.kill(pid, 0);
|
|
28
|
+
return false;
|
|
29
|
+
} catch (error) {
|
|
30
|
+
if (error.code === 'EPERM') return false;
|
|
31
|
+
if (error.code === 'ESRCH') return true;
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return Date.now() - stats.mtimeMs > LOCK_MAX_AGE;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function withRepositoryLock(repo, action, force) {
|
|
39
|
+
const lock = path(repo, '.git', 'vyops-pushback.lock');
|
|
40
|
+
try {
|
|
41
|
+
await fs.mkdir(lock);
|
|
42
|
+
} catch (error) {
|
|
43
|
+
if (error.code !== 'EEXIST' || (!force && !(await staleLock(lock)))) {
|
|
44
|
+
if (error.code === 'EEXIST') throw new Error('another pushback is already running');
|
|
45
|
+
throw error;
|
|
46
|
+
}
|
|
47
|
+
await fs.rm(lock, { recursive: true, force: true });
|
|
48
|
+
await fs.mkdir(lock);
|
|
49
|
+
}
|
|
50
|
+
try {
|
|
51
|
+
await fs.writeFile(path(lock, 'owner'), `${process.pid}\n`, 'utf8');
|
|
52
|
+
return await action();
|
|
53
|
+
} finally {
|
|
54
|
+
await fs.rm(lock, { recursive: true, force: true });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function configDirectory(config) {
|
|
59
|
+
return path(config, '..');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function shouldSkip(config) {
|
|
63
|
+
const cwd = configDirectory(config);
|
|
64
|
+
const { stdout: root } = await git(['rev-parse', '--show-toplevel'], cwd);
|
|
65
|
+
const repo = root.trim();
|
|
66
|
+
const relative = config.startsWith(`${repo}/`) ? config.slice(repo.length + 1) : config;
|
|
67
|
+
const { stdout: status } = await git(['status', '--porcelain', '--', relative], repo);
|
|
68
|
+
if (status.trim()) return false;
|
|
69
|
+
const { stdout: subject } = await git(['log', '-1', '--format=%s'], repo);
|
|
70
|
+
return subject.trim().startsWith('Pushback ');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function pushBack(config, { force = false } = {}) {
|
|
74
|
+
const { stdout: root } = await git(['rev-parse', '--show-toplevel'], configDirectory(config));
|
|
75
|
+
const repo = root.trim();
|
|
76
|
+
return withRepositoryLock(repo, async () => {
|
|
77
|
+
const relative = config.startsWith(`${repo}/`) ? config.slice(repo.length + 1) : config;
|
|
78
|
+
const { stdout: diff } = await git(['diff', '--', relative], repo);
|
|
79
|
+
if (!diff) return false;
|
|
80
|
+
await git(['add', '--', relative], repo);
|
|
81
|
+
const timestamp = new Date().toISOString().replace('T', ' ').slice(0, 19);
|
|
82
|
+
await git(['commit', '--only', '-m', `Pushback ${timestamp}`, '--', relative], repo);
|
|
83
|
+
await git(['push'], repo);
|
|
84
|
+
return true;
|
|
85
|
+
}, force);
|
|
86
|
+
}
|
package/src/main.mjs
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { parseArgs, usage } from './args.mjs';
|
|
2
|
+
import packageJson from '../package.json' with { type: 'json' };
|
|
3
|
+
import { deploy } from './deploy.mjs';
|
|
4
|
+
import { readAndValidateConfig } from './validate.mjs';
|
|
5
|
+
import { pushBack, shouldSkip } from './git.mjs';
|
|
6
|
+
import { closeAll } from './ssh.mjs';
|
|
7
|
+
import { log, registerHandlers, registerSignals } from '@eliware/common';
|
|
8
|
+
|
|
9
|
+
const errors = registerHandlers({ log });
|
|
10
|
+
const signals = registerSignals({ log, shutdownHook: async () => closeAll() });
|
|
11
|
+
|
|
12
|
+
try {
|
|
13
|
+
const args = parseArgs(process.argv.slice(2));
|
|
14
|
+
log.debug(`[vyops] arguments parsed: target=${args.target || '-'} config=${args.config || '-'}`);
|
|
15
|
+
if (args.help) {
|
|
16
|
+
log.info(usage);
|
|
17
|
+
process.exit(0);
|
|
18
|
+
}
|
|
19
|
+
if (args.version) {
|
|
20
|
+
log.info(packageJson.version);
|
|
21
|
+
process.exit(0);
|
|
22
|
+
}
|
|
23
|
+
log.debug(`[vyops] validating config: ${args.config}`);
|
|
24
|
+
await readAndValidateConfig(args.config);
|
|
25
|
+
log.debug('[vyops] config validation complete');
|
|
26
|
+
if (args.dryRun) {
|
|
27
|
+
log.info(`Configuration valid; dry run for ${args.target}`);
|
|
28
|
+
process.exit(0);
|
|
29
|
+
}
|
|
30
|
+
if (!args.force && await shouldSkip(args.config)) {
|
|
31
|
+
log.info('Latest commit is Pushback and config is unchanged; skipping deployment');
|
|
32
|
+
process.exit(0);
|
|
33
|
+
}
|
|
34
|
+
log.debug('[vyops] deployment starting');
|
|
35
|
+
await deploy(args);
|
|
36
|
+
log.debug('[vyops] deployment complete; starting Git pushback');
|
|
37
|
+
if (await pushBack(args.config, { force: args.force })) log.info('Pushback committed and pushed');
|
|
38
|
+
log.info('Deployment successful');
|
|
39
|
+
} catch (error) {
|
|
40
|
+
log.error(error.message);
|
|
41
|
+
process.exitCode = 1;
|
|
42
|
+
} finally {
|
|
43
|
+
signals.removeHandlers();
|
|
44
|
+
errors.removeHandlers();
|
|
45
|
+
}
|
package/src/ssh.mjs
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import { createHmac } from 'node:crypto';
|
|
2
|
+
import { fs, log } from '@eliware/common';
|
|
3
|
+
import ssh2 from 'ssh2';
|
|
4
|
+
|
|
5
|
+
const { Client, utils } = ssh2;
|
|
6
|
+
|
|
7
|
+
const CONNECT_TIMEOUT = 30000;
|
|
8
|
+
const OPERATION_TIMEOUT = 60000;
|
|
9
|
+
|
|
10
|
+
export function parseTarget(target) {
|
|
11
|
+
if (typeof target !== 'string' || !target || /\s/.test(target)) {
|
|
12
|
+
throw new Error('invalid target; expected user@host');
|
|
13
|
+
}
|
|
14
|
+
const at = target.indexOf('@');
|
|
15
|
+
if (at <= 0 || at !== target.lastIndexOf('@') || at === target.length - 1) {
|
|
16
|
+
throw new Error('invalid target; expected user@host');
|
|
17
|
+
}
|
|
18
|
+
const username = target.slice(0, at);
|
|
19
|
+
const host = target.slice(at + 1);
|
|
20
|
+
if (!/^[A-Za-z0-9._-]+$/.test(username) || !/^(?:\[[0-9A-Fa-f]*:[0-9A-Fa-f:]+\]|(?!\[)[A-Za-z0-9._:-]+)$/.test(host)) {
|
|
21
|
+
throw new Error('invalid target; expected user@host');
|
|
22
|
+
}
|
|
23
|
+
return { username, host };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function wildcardMatch(pattern, value) {
|
|
27
|
+
const expression = `^${pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.')}$`;
|
|
28
|
+
return new RegExp(expression).test(value);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function hostMatches(pattern, hosts) {
|
|
32
|
+
if (pattern.startsWith('|1|')) {
|
|
33
|
+
const [, , salt, digest] = pattern.split('|');
|
|
34
|
+
if (!salt || !digest) return false;
|
|
35
|
+
return hosts.some(host => createHmac('sha1', Buffer.from(salt, 'base64')).update(host).digest('base64') === digest);
|
|
36
|
+
}
|
|
37
|
+
return hosts.some(host => wildcardMatch(pattern, host));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function keyMatches(expected, actual) {
|
|
41
|
+
try {
|
|
42
|
+
const parsed = utils.parseKey(`${expected.type} ${expected.data}`);
|
|
43
|
+
return parsed.getPublicSSH().equals(actual);
|
|
44
|
+
} catch {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function verifyKnownHost(host, key, knownHosts) {
|
|
50
|
+
const hosts = [host, `[${host}]:22`];
|
|
51
|
+
let matched = false;
|
|
52
|
+
for (const line of knownHosts.split(/\r?\n/)) {
|
|
53
|
+
const trimmed = line.trim();
|
|
54
|
+
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
55
|
+
const fields = trimmed.split(/\s+/);
|
|
56
|
+
if (fields[0].startsWith('@')) {
|
|
57
|
+
if (fields[0] === '@revoked' && fields[1]?.split(',').some(pattern => hostMatches(pattern, hosts))) return false;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (fields.length < 3) continue;
|
|
61
|
+
const [hostList, type, data] = fields;
|
|
62
|
+
const patterns = hostList.split(',');
|
|
63
|
+
if (patterns.some(pattern => pattern.startsWith('!') && hostMatches(pattern.slice(1), hosts))) return false;
|
|
64
|
+
if (patterns.some(pattern => !pattern.startsWith('!') && hostMatches(pattern, hosts))
|
|
65
|
+
&& keyMatches({ type, data }, key)) matched = true;
|
|
66
|
+
}
|
|
67
|
+
return matched;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function connect(target) {
|
|
71
|
+
const { username, host } = parseTarget(target);
|
|
72
|
+
log.debug(`[vyops] SSH connecting: ${username}@${host}`);
|
|
73
|
+
const privateKey = await fs.promises.readFile(process.env.VYOPS_SSH_KEY || `${process.env.HOME}/.ssh/id_rsa`);
|
|
74
|
+
const knownHostsPath = `${process.env.HOME}/.ssh/known_hosts`;
|
|
75
|
+
const knownHosts = await fs.promises.readFile(knownHostsPath, 'utf8');
|
|
76
|
+
return new Promise((resolve, reject) => {
|
|
77
|
+
const client = new Client();
|
|
78
|
+
client.once('ready', () => { log.debug(`[vyops] SSH connected: ${username}@${host}`); resolve(client); });
|
|
79
|
+
client.once('error', reject);
|
|
80
|
+
client.once('close', () => activeClients.delete(client));
|
|
81
|
+
activeClients.add(client);
|
|
82
|
+
client.connect({
|
|
83
|
+
host,
|
|
84
|
+
username,
|
|
85
|
+
agent: process.env.SSH_AUTH_SOCK,
|
|
86
|
+
privateKey,
|
|
87
|
+
hostVerifier: (key, callback) => callback(verifyKnownHost(host, key, knownHosts)),
|
|
88
|
+
readyTimeout: CONNECT_TIMEOUT,
|
|
89
|
+
authTimeout: CONNECT_TIMEOUT,
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function exec(client, command) {
|
|
95
|
+
log.debug(`[vyops] SSH exec: ${command}`);
|
|
96
|
+
return new Promise((resolve, reject) => {
|
|
97
|
+
const timer = setTimeout(() => reject(new Error(`SSH command timed out: ${command}`)), OPERATION_TIMEOUT);
|
|
98
|
+
client.exec(command, (error, stream) => {
|
|
99
|
+
if (error) { clearTimeout(timer); return reject(error); }
|
|
100
|
+
let stdout = '', stderr = '';
|
|
101
|
+
const finish = (callback, value) => {
|
|
102
|
+
clearTimeout(timer);
|
|
103
|
+
callback(value);
|
|
104
|
+
};
|
|
105
|
+
stream.on('data', data => { stdout += data; });
|
|
106
|
+
stream.stderr.on('data', data => { stderr += data; });
|
|
107
|
+
stream.on('error', error2 => finish(reject, error2));
|
|
108
|
+
stream.on('close', code => finish(resolve, { code: code ?? 1, stdout, stderr }));
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export async function upload(client, local, remote, mode = 0o600) {
|
|
114
|
+
log.debug(`[vyops] SFTP upload: ${local} -> ${remote}`);
|
|
115
|
+
const data = await fs.promises.readFile(local);
|
|
116
|
+
return new Promise((resolve, reject) => {
|
|
117
|
+
const timer = setTimeout(() => reject(new Error(`SFTP upload timed out: ${remote}`)), OPERATION_TIMEOUT);
|
|
118
|
+
client.sftp((error, sftp) => {
|
|
119
|
+
if (error) { clearTimeout(timer); return reject(error); }
|
|
120
|
+
sftp.writeFile(remote, data, { mode }, error2 => {
|
|
121
|
+
clearTimeout(timer);
|
|
122
|
+
return error2 ? reject(error2) : resolve();
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function download(client, remote, local) {
|
|
129
|
+
log.debug(`[vyops] SFTP download: ${remote} -> ${local}`);
|
|
130
|
+
return new Promise((resolve, reject) => {
|
|
131
|
+
const timer = setTimeout(() => reject(new Error(`SFTP download timed out: ${remote}`)), OPERATION_TIMEOUT);
|
|
132
|
+
client.sftp((error, sftp) => {
|
|
133
|
+
if (error) { clearTimeout(timer); return reject(error); }
|
|
134
|
+
sftp.fastGet(remote, local, error2 => {
|
|
135
|
+
clearTimeout(timer);
|
|
136
|
+
return error2 ? reject(error2) : resolve();
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function interactive(client, commands, log = () => {}) {
|
|
143
|
+
return new Promise((resolve, reject) => {
|
|
144
|
+
client.shell({ term: 'xterm', cols: 160, rows: 48 }, (error, stream) => {
|
|
145
|
+
if (error) return reject(error);
|
|
146
|
+
log('VyOS interactive shell opened');
|
|
147
|
+
let output = '';
|
|
148
|
+
let response = '';
|
|
149
|
+
let index = 0;
|
|
150
|
+
let waiting = false;
|
|
151
|
+
let answering = false;
|
|
152
|
+
let settled = false;
|
|
153
|
+
let timedOut = false;
|
|
154
|
+
let currentItem;
|
|
155
|
+
log(`interactive sequence start (${commands.length} commands)`);
|
|
156
|
+
const timer = setTimeout(() => {
|
|
157
|
+
log(`timeout; next command: ${index + 1}/${commands.length}; waiting=${waiting}; answering=${answering}`);
|
|
158
|
+
log(`partial response:\n${response}`);
|
|
159
|
+
timedOut = true;
|
|
160
|
+
settled = true;
|
|
161
|
+
stream.close();
|
|
162
|
+
reject(new Error('interactive SSH timeout'));
|
|
163
|
+
}, 60000);
|
|
164
|
+
const ansi = new RegExp(`${String.fromCharCode(27)}\\[[0-9;?]*[ -/]*[@-~]`, 'g');
|
|
165
|
+
const title = new RegExp(`${String.fromCharCode(27)}\\][^${String.fromCharCode(7)}]*(?:${String.fromCharCode(7)}|${String.fromCharCode(27)}\\\\)`, 'g');
|
|
166
|
+
const clean = value => value.replace(ansi, '').replace(title, '').replace(/\r/g, '');
|
|
167
|
+
const prompt = value => /(?:^|\n)[A-Za-z0-9._-]+@[A-Za-z0-9._:-]+(?::~\$|#)\s*$/.test(value);
|
|
168
|
+
const sendNext = () => {
|
|
169
|
+
if (waiting || index >= commands.length) return;
|
|
170
|
+
currentItem = commands[index++];
|
|
171
|
+
const item = currentItem;
|
|
172
|
+
const command = typeof item === 'string' ? item : item.command;
|
|
173
|
+
response = '';
|
|
174
|
+
waiting = true;
|
|
175
|
+
log(`send [${index}/${commands.length}]: ${command}`);
|
|
176
|
+
log(`state before send: index=${index}, waiting=${waiting}, answering=${answering}`);
|
|
177
|
+
stream.write(`${command}\n`);
|
|
178
|
+
log(`write complete [${index}/${commands.length}]`);
|
|
179
|
+
};
|
|
180
|
+
stream.on('data', data => {
|
|
181
|
+
const text = data.toString();
|
|
182
|
+
output += text;
|
|
183
|
+
response += text;
|
|
184
|
+
log(`recv (${text.length} bytes): ${JSON.stringify(text)}`);
|
|
185
|
+
const cleaned = clean(response);
|
|
186
|
+
const pager = /(?:^|\n):\s*$/.test(cleaned) || /--More--\s*$/i.test(cleaned);
|
|
187
|
+
log(`state after data: index=${index}, waiting=${waiting}, answering=${answering}, prompt=${prompt(cleaned)}, pager=${pager}, bytes=${text.length}`);
|
|
188
|
+
if (pager) {
|
|
189
|
+
log('pager prompt detected; sending space');
|
|
190
|
+
stream.write(' ');
|
|
191
|
+
log('write complete: space');
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
if (/Proceed\s*\?\s*\[Y\/n\]/i.test(cleaned) && !answering) {
|
|
195
|
+
answering = true;
|
|
196
|
+
log('commit-confirm prompt detected; sending: y');
|
|
197
|
+
response = '';
|
|
198
|
+
stream.write('y\n');
|
|
199
|
+
log('write complete: y');
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
const commandComplete = waiting
|
|
203
|
+
&& prompt(cleaned)
|
|
204
|
+
&& !/Proceed\s*\?\s*\[Y\/n\]/i.test(cleaned);
|
|
205
|
+
log(`command completion check: complete=${commandComplete}`);
|
|
206
|
+
if (commandComplete) {
|
|
207
|
+
answering = false;
|
|
208
|
+
waiting = false;
|
|
209
|
+
log(`response [${index}]:\n${cleaned}`);
|
|
210
|
+
if (typeof currentItem !== 'string' && currentItem.reject?.test(cleaned)) {
|
|
211
|
+
settled = true;
|
|
212
|
+
clearTimeout(timer);
|
|
213
|
+
stream.close();
|
|
214
|
+
reject(new Error(`interactive command failed: ${currentItem.command}`));
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
if (index >= commands.length) {
|
|
218
|
+
settled = true;
|
|
219
|
+
clearTimeout(timer);
|
|
220
|
+
stream.end();
|
|
221
|
+
resolve(output);
|
|
222
|
+
} else sendNext();
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
stream.on('error', error => {
|
|
226
|
+
log(`interactive stream error: ${error.message}`);
|
|
227
|
+
clearTimeout(timer);
|
|
228
|
+
reject(error);
|
|
229
|
+
});
|
|
230
|
+
stream.stderr.on('data', data => {
|
|
231
|
+
const text = data.toString();
|
|
232
|
+
output += text;
|
|
233
|
+
response += text;
|
|
234
|
+
log(`recv stderr (${text.length} bytes): ${JSON.stringify(text)}`);
|
|
235
|
+
});
|
|
236
|
+
stream.on('close', () => {
|
|
237
|
+
log(`VyOS interactive shell closed; settled=${settled}; index=${index}/${commands.length}`);
|
|
238
|
+
clearTimeout(timer);
|
|
239
|
+
if (!settled && !timedOut) {
|
|
240
|
+
const finalCommand = commands.at(-1);
|
|
241
|
+
const expectedClose = typeof finalCommand === 'string'
|
|
242
|
+
? finalCommand === 'exit'
|
|
243
|
+
: finalCommand?.command === 'exit';
|
|
244
|
+
if (commands.length === 0 || (index >= commands.length && expectedClose)) resolve(output);
|
|
245
|
+
else reject(new Error('interactive SSH closed before command sequence completed'));
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
sendNext();
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const activeClients = new Set();
|
|
254
|
+
|
|
255
|
+
export function close(client) {
|
|
256
|
+
if (!client) return Promise.resolve();
|
|
257
|
+
return new Promise(resolve => {
|
|
258
|
+
let settled = false;
|
|
259
|
+
const done = () => {
|
|
260
|
+
if (settled) return;
|
|
261
|
+
settled = true;
|
|
262
|
+
activeClients.delete(client);
|
|
263
|
+
resolve();
|
|
264
|
+
};
|
|
265
|
+
client.once('close', done);
|
|
266
|
+
client.once('error', done);
|
|
267
|
+
client.end();
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export async function closeAll() {
|
|
272
|
+
await Promise.all([...activeClients].map(client => close(client)));
|
|
273
|
+
}
|
package/src/validate.mjs
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { fs } from '@eliware/common';
|
|
2
|
+
|
|
3
|
+
function naturalCompare(a, b) {
|
|
4
|
+
if (a.startsWith(b) && a !== b) return 1;
|
|
5
|
+
if (b.startsWith(a) && a !== b) return -1;
|
|
6
|
+
const aa = a.split(/(\d+)/), bb = b.split(/(\d+)/);
|
|
7
|
+
for (let i = 0; i < Math.min(aa.length, bb.length); i += 1) {
|
|
8
|
+
const an = /^\d+$/.test(aa[i]), bn = /^\d+$/.test(bb[i]);
|
|
9
|
+
if (an && bn && Number(aa[i]) !== Number(bb[i])) return Number(aa[i]) - Number(bb[i]);
|
|
10
|
+
if (aa[i] !== bb[i]) return aa[i] < bb[i] ? -1 : 1;
|
|
11
|
+
}
|
|
12
|
+
return aa.length - bb.length || 0;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function fail(line, message) {
|
|
16
|
+
throw new Error(`config validation failed at line ${line}: ${message}`);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function validateConfig(text) {
|
|
20
|
+
if (!text.trim()) throw new Error('config validation failed: file is empty');
|
|
21
|
+
|
|
22
|
+
let depth = 0;
|
|
23
|
+
let hasStatement = false;
|
|
24
|
+
let line = 1;
|
|
25
|
+
let lineText = '';
|
|
26
|
+
|
|
27
|
+
const finishLine = () => {
|
|
28
|
+
let code = '';
|
|
29
|
+
let q = null;
|
|
30
|
+
let e = false;
|
|
31
|
+
for (const char of lineText) {
|
|
32
|
+
if (e) { code += char; e = false; continue; }
|
|
33
|
+
if (q && char === '\\') { code += char; e = true; continue; }
|
|
34
|
+
if (char === '"' || char === "'") {
|
|
35
|
+
q = q === char ? null : q || char;
|
|
36
|
+
code += char;
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
if (!q && char === '#') break;
|
|
40
|
+
code += char;
|
|
41
|
+
}
|
|
42
|
+
if (q) fail(line, 'unterminated quote');
|
|
43
|
+
|
|
44
|
+
const trimmed = code.trim();
|
|
45
|
+
if (!trimmed) return;
|
|
46
|
+
hasStatement = true;
|
|
47
|
+
|
|
48
|
+
const expectedDepth = trimmed === '}' ? depth - 1 : depth;
|
|
49
|
+
if (expectedDepth < 0) fail(line, 'unexpected closing brace');
|
|
50
|
+
const expectedIndent = ' '.repeat(expectedDepth);
|
|
51
|
+
if (!lineText.startsWith(expectedIndent) || lineText.slice(expectedIndent.length).startsWith(' ')) {
|
|
52
|
+
fail(line, `incorrect indentation; expected ${expectedDepth * 4} spaces`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
let open = -1;
|
|
56
|
+
let close = -1;
|
|
57
|
+
q = null; e = false;
|
|
58
|
+
for (let i = 0; i < code.length; i += 1) {
|
|
59
|
+
const char = code[i];
|
|
60
|
+
if (e) { e = false; continue; }
|
|
61
|
+
if (q && char === '\\') { e = true; continue; }
|
|
62
|
+
if (char === '"' || char === "'") { q = q === char ? null : q || char; continue; }
|
|
63
|
+
if (q) continue;
|
|
64
|
+
if (char === '{') { if (open >= 0) fail(line, 'multiple opening braces on one line'); open = i; }
|
|
65
|
+
if (char === '}') { if (close >= 0) fail(line, 'multiple closing braces on one line'); close = i; }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (open >= 0 && close >= 0) fail(line, 'opening and closing brace must be on separate lines');
|
|
69
|
+
if (open >= 0) {
|
|
70
|
+
if (!code.slice(0, open).trim() || code.slice(open + 1).trim()) fail(line, 'opening brace must end the line');
|
|
71
|
+
depth += 1;
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (close >= 0) {
|
|
75
|
+
if (code.slice(0, close).trim() || code.slice(close + 1).trim()) fail(line, 'closing brace must be alone');
|
|
76
|
+
depth -= 1;
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
for (const char of text) {
|
|
81
|
+
if (char === '\n') { finishLine(); lineText = ''; line += 1; }
|
|
82
|
+
else lineText += char;
|
|
83
|
+
}
|
|
84
|
+
finishLine();
|
|
85
|
+
if (depth !== 0) fail(line, 'unbalanced braces');
|
|
86
|
+
if (!hasStatement) throw new Error('config validation failed: no configuration statements');
|
|
87
|
+
return text;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function readAndValidateConfig(path) {
|
|
91
|
+
const text = await fs.promises.readFile(path, 'utf8');
|
|
92
|
+
return validateConfig(text);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
export function fixConfig(text) {
|
|
97
|
+
const lines = text.replace(/\r\n?/g, '\n').split('\n').map(line => line.trim());
|
|
98
|
+
let index = 0;
|
|
99
|
+
|
|
100
|
+
const parseLevel = depth => {
|
|
101
|
+
const units = [];
|
|
102
|
+
while (index < lines.length) {
|
|
103
|
+
const trimmed = lines[index++];
|
|
104
|
+
if (!trimmed) { units.push({ key: '', lines: [''] }); continue; }
|
|
105
|
+
if (trimmed.startsWith('#')) units.push({ key: '', lines: [`${' '.repeat(depth)}${trimmed}`] });
|
|
106
|
+
else if (trimmed === '}') {
|
|
107
|
+
if (depth === 0) throw new Error(`config validation failed: unexpected closing brace at line ${index}`);
|
|
108
|
+
return { units, closed: true };
|
|
109
|
+
} else if (trimmed.endsWith('{')) {
|
|
110
|
+
const header = trimmed;
|
|
111
|
+
const children = parseLevel(depth + 1);
|
|
112
|
+
if (!children.closed) throw new Error(`config validation failed: unbalanced braces at line ${index}`);
|
|
113
|
+
units.push({ key: header, lines: [`${' '.repeat(depth)}${header}`, ...children.units.flatMap(unit => unit.lines), `${' '.repeat(depth)}}`] });
|
|
114
|
+
} else units.push({ key: trimmed, lines: [`${' '.repeat(depth)}${trimmed}`] });
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
for (let i = 0; i < units.length;) {
|
|
118
|
+
if (!units[i].key.endsWith('{')) { i += 1; continue; }
|
|
119
|
+
let end = i;
|
|
120
|
+
while (end < units.length && units[end].key.endsWith('{')) end += 1;
|
|
121
|
+
units.splice(i, end - i, ...units.slice(i, end).sort((a, b) => naturalCompare(
|
|
122
|
+
a.key.replace(/\s*\{$/, ''), b.key.replace(/\s*\{$/, ''),
|
|
123
|
+
)));
|
|
124
|
+
i = end;
|
|
125
|
+
}
|
|
126
|
+
return { units, closed: false };
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const parsed = parseLevel(0);
|
|
130
|
+
let output = parsed.units.flatMap(unit => unit.lines);
|
|
131
|
+
const header = [];
|
|
132
|
+
while (output[0]?.startsWith('//')) header.push(output.shift());
|
|
133
|
+
while (output[0] === '') output.shift();
|
|
134
|
+
while (output.at(-1) === '') output.pop();
|
|
135
|
+
if (header.length) output.push('', '', ...header);
|
|
136
|
+
return `${output.join('\n')}\n`;
|
|
137
|
+
}
|
package/vyops.mjs
ADDED