@eliware/vyops 1.0.5 → 1.0.6

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/README.md CHANGED
@@ -55,8 +55,13 @@ Console switches:
55
55
  ./vyops.mjs --version
56
56
  ./vyops.mjs --dry-run vyos@core1 /path/to/config.boot
57
57
  ./vyops.mjs --force --debug vyos@core1 /path/to/config.boot
58
+ ./vyops.mjs --backup vyos@core1 /path/to/backup
58
59
  ```
59
60
 
61
+ `--backup` downloads the active `/config/config.boot` and the complete
62
+ `/config/scripts` tree into the destination directory. It does not enter
63
+ configuration mode or modify the router.
64
+
60
65
  `--dry-run` validates the config and skips SSH, commit, save, download, and Git pushback.
61
66
 
62
67
  The deployment workflow:
@@ -105,7 +110,7 @@ Deployments require existing SSH keys and a reachable VyOS target. The workflow
105
110
 
106
111
  ## Support
107
112
 
108
- Repository: `git@github.com:eliware/vyops-js.git`
113
+ Repository: `git@github.com:eliware/vyops.git`
109
114
 
110
115
  Open an issue or provide a focused patch with tests and validation results.
111
116
 
package/RELEASE_NOTES.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # Release Notes
2
2
 
3
+ ## 1.0.6
4
+
5
+ - Add `--backup` to download the active `config.boot` and complete
6
+ `/config/scripts` tree into a local backup directory.
7
+ - Fail promptly on VyOS load and commit errors instead of waiting indefinitely
8
+ for confirmation prompts.
9
+ - Preserve synchronized scripts when post-commit local config download fails.
10
+ - Include staged configuration changes in Git pushback detection.
11
+ - Expand CLI, deployment, backup, Git, and SSH regression coverage.
12
+
13
+ Verification:
14
+
15
+ - `npm test` (100% coverage across statements, branches, functions, and lines)
16
+ - `npm run lint`
17
+ - `npm run audit`
18
+ - `npm run validate:package`
19
+
3
20
  ## 1.0.5
4
21
 
5
22
  - Create nested temporary directories before uploading synchronized scripts.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eliware/vyops",
3
- "version": "1.0.5",
3
+ "version": "1.0.6",
4
4
  "description": "Deploy native VyOS configuration files over SSH.",
5
5
  "main": "vyops.mjs",
6
6
  "scripts": {
@@ -21,12 +21,12 @@
21
21
  ],
22
22
  "type": "module",
23
23
  "dependencies": {
24
- "@eliware/common": "^1.1.7",
24
+ "@eliware/common": "^1.1.8",
25
25
  "ssh2": "^1.17.0"
26
26
  },
27
27
  "devDependencies": {
28
28
  "jest": "^30.4.2",
29
- "oxlint": "^1.77.0"
29
+ "oxlint": "^1.79.0"
30
30
  },
31
31
  "bin": {
32
32
  "vyops": "./bin/vyops"
package/src/args.mjs CHANGED
@@ -1,5 +1,6 @@
1
1
  export const usage = `Usage:
2
2
  vyops [--dry-run] [--force] [--debug] <user@hostname-or-ip> <config.boot>
3
+ vyops --backup [--debug] <user@hostname-or-ip> <destination-directory>
3
4
  vyops --help
4
5
  vyops --version`;
5
6
 
@@ -8,8 +9,8 @@ export function parseArgs(argv) {
8
9
  if (argv.length === 1 && argv[0] === '--version') return { version: true };
9
10
 
10
11
  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
+ if ([...options].some(option => !['--dry-run', '--force', '--debug', '--backup'].includes(option))) throw new Error(usage);
12
13
  const values = argv.filter(value => !value.startsWith('--'));
13
14
  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
+ return { target: values[0], config: values[1], ...(options.has('--backup') ? { backup: true } : {}), ...(options.has('--dry-run') ? { dryRun: true } : {}), ...(options.has('--force') ? { force: true } : {}), ...(options.has('--debug') ? { debug: true } : {}) };
15
16
  }
package/src/backup.mjs ADDED
@@ -0,0 +1,29 @@
1
+ import { fs, path, log } from '@eliware/common';
2
+ import { close, connect, download, exec } from './ssh.mjs';
3
+
4
+ function remoteScriptPath(value) {
5
+ const name = value.replace(/^\/config\/scripts\/?/, '');
6
+ if (!name || name.startsWith('/') || name.split('/').includes('..')) throw new Error(`unsafe remote script path: ${value}`);
7
+ return name;
8
+ }
9
+
10
+ export async function backup({ target, config }) {
11
+ const client = await connect(target);
12
+ try {
13
+ await fs.promises.mkdir(config, { recursive: true });
14
+ await fs.promises.mkdir(path(config, 'scripts'), { recursive: true });
15
+ await download(client, '/config/config.boot', path(config, 'config.boot'));
16
+ const result = await exec(client, "find /config/scripts -type f -print 2>/dev/null");
17
+ if (result.code !== 0) throw new Error(`could not list remote scripts: ${result.stderr || result.stdout}`.trim());
18
+ const files = result.stdout.split(/\r?\n/).map(value => value.trim()).filter(Boolean).map(remoteScriptPath);
19
+ for (const name of files) {
20
+ const local = path(config, 'scripts', name);
21
+ await fs.promises.mkdir(path(local, '..'), { recursive: true });
22
+ await download(client, `/config/scripts/${name}`, local);
23
+ log.debug(`[vyops] backed up script: ${name}`);
24
+ }
25
+ return 0;
26
+ } finally {
27
+ await close(client);
28
+ }
29
+ }
package/src/deploy.mjs CHANGED
@@ -82,6 +82,8 @@ export async function deploy({ target, config }) {
82
82
  const client = await connect(target);
83
83
  debugLog('SSH connected');
84
84
  let finalizeHooks;
85
+ let hooksFinalized = false;
86
+ let deploymentCommitted = false;
85
87
  const runId = randomUUID();
86
88
  const remote = `/home/vyos/.config.deploy.${runId}`;
87
89
  try {
@@ -92,25 +94,29 @@ export async function deploy({ target, config }) {
92
94
  debugLog('starting interactive deployment sequence');
93
95
  const output = await interactive(client, [
94
96
  'configure',
95
- { command: `load ${remote}`, reject: /(?:load failed|error|invalid)/i },
97
+ { command: `load ${remote}`, reject: /(?:load failed|commit failed|commit aborted|cannot commit|configuration (?:commit )?failed|invalid configuration|error|invalid)/i },
96
98
  "printf '%s\\n' '--- compare ---'",
97
99
  'compare',
98
100
  "printf '%s\\n' '--- end compare ---'",
99
- { command: 'commit-confirm 5', reject: /(?:commit failed|error|invalid)/i },
101
+ { command: 'commit-confirm 5', reject: /(?:commit failed|commit aborted|cannot commit|configuration (?:commit )?failed|invalid configuration|error|invalid)/i },
100
102
  { command: 'confirm', reject: /(?:confirm failed|error|invalid)/i },
101
103
  { command: 'save', reject: /(?:save failed|error|invalid)/i },
102
104
  'exit',
103
105
  'exit',
104
106
  ], debugLog);
105
107
  debugLog(`interactive sequence returned (${output.length} bytes)`);
108
+ deploymentCommitted = true;
106
109
  const compare = extractCompare(output);
107
110
  if (compare) log.info(compare);
108
- if (finalizeHooks) await finalizeHooks(true);
109
111
  debugLog(`syncing live config: /config/config.boot -> ${config}`);
110
112
  await download(client, '/config/config.boot', config);
113
+ if (finalizeHooks) {
114
+ hooksFinalized = true;
115
+ await finalizeHooks(true);
116
+ }
111
117
  return 0;
112
118
  } catch (error) {
113
- if (finalizeHooks) await finalizeHooks(false);
119
+ if (finalizeHooks && !hooksFinalized && !deploymentCommitted) await finalizeHooks(false);
114
120
  throw error;
115
121
  } finally {
116
122
  debugLog('cleaning up remote file');
package/src/git.mjs CHANGED
@@ -75,7 +75,7 @@ export async function pushBack(config, { force = false } = {}) {
75
75
  const repo = root.trim();
76
76
  return withRepositoryLock(repo, async () => {
77
77
  const relative = config.startsWith(`${repo}/`) ? config.slice(repo.length + 1) : config;
78
- const { stdout: diff } = await git(['diff', '--', relative], repo);
78
+ const { stdout: diff } = await git(['diff', 'HEAD', '--', relative], repo);
79
79
  if (!diff) return false;
80
80
  await git(['add', '--', relative], repo);
81
81
  const timestamp = new Date().toISOString().replace('T', ' ').slice(0, 19);
package/src/main.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  import { parseArgs, usage } from './args.mjs';
2
2
  import packageJson from '../package.json' with { type: 'json' };
3
3
  import { deploy } from './deploy.mjs';
4
+ import { backup } from './backup.mjs';
4
5
  import { readAndValidateConfig } from './validate.mjs';
5
6
  import { pushBack, shouldSkip } from './git.mjs';
6
7
  import { closeAll } from './ssh.mjs';
@@ -20,6 +21,11 @@ try {
20
21
  log.info(packageJson.version);
21
22
  process.exit(0);
22
23
  }
24
+ if (args.backup) {
25
+ await backup(args);
26
+ log.info(`Backup successful: ${args.config}`);
27
+ process.exit(0);
28
+ }
23
29
  log.debug(`[vyops] validating config: ${args.config}`);
24
30
  await readAndValidateConfig(args.config);
25
31
  log.debug('[vyops] config validation complete');
package/src/ssh.mjs CHANGED
@@ -183,6 +183,13 @@ export function interactive(client, commands, log = () => {}) {
183
183
  response += text;
184
184
  log(`recv (${text.length} bytes): ${JSON.stringify(text)}`);
185
185
  const cleaned = clean(response);
186
+ if (waiting && typeof currentItem !== 'string' && currentItem.reject?.test(cleaned)) {
187
+ settled = true;
188
+ clearTimeout(timer);
189
+ stream.close();
190
+ reject(new Error(`interactive command failed: ${currentItem.command}`));
191
+ return;
192
+ }
186
193
  const pager = /(?:^|\n):\s*$/.test(cleaned) || /--More--\s*$/i.test(cleaned);
187
194
  log(`state after data: index=${index}, waiting=${waiting}, answering=${answering}, prompt=${prompt(cleaned)}, pager=${pager}, bytes=${text.length}`);
188
195
  if (pager) {
@@ -207,13 +214,6 @@ export function interactive(client, commands, log = () => {}) {
207
214
  answering = false;
208
215
  waiting = false;
209
216
  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
217
  if (index >= commands.length) {
218
218
  settled = true;
219
219
  clearTimeout(timer);