ssh-release 1.2.0 → 1.4.0

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/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.4.0 - 2026-06-27
4
+
5
+ ### Added
6
+
7
+ - 增加真实服务器 dogfood 脚本和文档,固化一次性 `/tmp/ssh-release-dogfood-*` 发布、回滚、校验和清理流程。
8
+
9
+ ## 1.3.0 - 2026-06-26
10
+
11
+ ### Added
12
+
13
+ - 增加 `ssh-release rollback --json --progress`,回滚时按 NDJSON 输出 `lock`、`switch`、`cleanup`、`verify` 阶段状态。
14
+ - 增强回滚后远端校验,成功结果会输出 `verified` 与 `verification`,确认目标版本目录、`current` 指向和远端锁清理状态。
15
+
3
16
  ## 1.2.0 - 2026-06-26
4
17
 
5
18
  ### Added
package/README.md CHANGED
@@ -18,11 +18,13 @@
18
18
  - `ssh-release unlock`:查看远端锁,并在显式确认锁路径后删除锁。
19
19
  - `--json`:输出单行 JSON,便于 CI/CD 解析。
20
20
  - `deploy --json --progress`:发布时输出 NDJSON 阶段进度,便于 CI/CD 展示实时状态。
21
+ - `rollback --json --progress`:回滚时输出 NDJSON 阶段进度,便于 CI/CD 展示实时状态。
21
22
  - `deploy --plan`:不连接远端、不修改服务器,预览上传、切换、清理和校验计划。
22
23
  - `rollback --plan`:连接远端读取版本状态,但不修改服务器,预览回滚切换计划。
23
24
  - 失败下一步提示:常见锁、回滚目标、发布校验和 SSH 错误会提示下一步操作。
24
25
  - 发布 manifest:每次发布生成 `manifest.json`,记录版本、发布时间、本地来源、文件清单、文件大小和 SHA-256。
25
26
  - 发布后远端校验:确认版本目录或目标目录存在、`current` 已指向新版本、`manifest.json` hash 匹配、远端锁已清理。
27
+ - 回滚后远端校验:确认目标版本目录存在、`current` 已指向目标版本、远端锁已清理。
26
28
  - `release` 模式:上传压缩包、远端解压、切换 `current`、清理旧版本。
27
29
  - `overwrite` 模式:直接覆盖发布到目标目录。
28
30
  - 远端 `tar` 解压失败时回退逐文件上传。
@@ -285,6 +287,7 @@ ssh-release deploy --json --progress
285
287
  ssh-release doctor --json
286
288
  ssh-release list --json
287
289
  ssh-release rollback --json
290
+ ssh-release rollback --json --progress
288
291
  ssh-release unlock --json
289
292
  ```
290
293
 
@@ -300,10 +303,11 @@ ssh-release unlock --json
300
303
  {"ok":false,"command":"deploy","error":"错误信息"}
301
304
  ```
302
305
 
303
- 发布时需要持续读取阶段状态,可以使用:
306
+ 发布或回滚时需要持续读取阶段状态,可以使用:
304
307
 
305
308
  ```bash
306
309
  ssh-release deploy --json --progress
310
+ ssh-release rollback --json --progress
307
311
  ```
308
312
 
309
313
  该模式会按行输出 NDJSON。进度事件先输出,最终结果最后输出:
@@ -314,9 +318,11 @@ ssh-release deploy --json --progress
314
318
  {"ok":true,"command":"deploy","result":{"mode":"release","version":"20260625-153000","verified":true}}
315
319
  ```
316
320
 
317
- `stage` 可能是 `source`、`lock`、`package`、`publish`、`cleanup`,`status` 可能是 `start`、`success`、`fail`。失败事件会包含 `error` 字段。
321
+ `deploy` 的 `stage` 可能是 `source`、`lock`、`package`、`publish`、`cleanup`。
322
+ `rollback` 的 `stage` 可能是 `lock`、`switch`、`cleanup`、`verify`。
323
+ `status` 可能是 `start`、`success`、`fail`。失败事件会包含 `error` 字段。
318
324
 
319
- 发布结果中的 `verification` 会列出已通过的远端校验项:
325
+ 发布和回滚结果中的 `verification` 会列出已通过的远端校验项:
320
326
 
321
327
  ```json
322
328
  {"verified":true,"verification":[{"name":"发布清单","status":"pass","message":"manifest.json 已上传并校验,文件数 12"}]}
@@ -416,6 +422,8 @@ npm install -g "$PACK_DIR"/ssh-release-"$VERSION".tgz --prefix "$PACK_DIR/prefix
416
422
 
417
423
  完整发布步骤见 [docs/release-checklist.md](https://github.com/JackEngineer/ssh-release/blob/main/docs/release-checklist.md)。
418
424
 
425
+ 真实服务器 dogfood 见 [docs/dogfood.md](https://github.com/JackEngineer/ssh-release/blob/main/docs/dogfood.md)。
426
+
419
427
  GitHub Actions 发布模板见 [docs/github-actions.md](https://github.com/JackEngineer/ssh-release/blob/main/docs/github-actions.md)。
420
428
 
421
429
  失败恢复指南见 [docs/recovery.md](https://github.com/JackEngineer/ssh-release/blob/main/docs/recovery.md)。
package/dist/cli.js CHANGED
@@ -6,7 +6,7 @@ import { CONFIG_FILE_NAME, loadConfigFile, writeConfigTemplate } from './config.
6
6
  import { runDoctorFromFile } from './doctor.js';
7
7
  import { listReleases } from './list.js';
8
8
  import { createDeployPlan, deploy, } from './release.js';
9
- import { createRollbackPlan, rollback } from './rollback.js';
9
+ import { createRollbackPlan, rollback, } from './rollback.js';
10
10
  import { createRemoteClient } from './ssh.js';
11
11
  import { unlock } from './unlock.js';
12
12
  export function isCliEntrypoint(moduleUrl, argvEntry) {
@@ -73,7 +73,13 @@ export async function runCli(argv = process.argv.slice(2), options = {}) {
73
73
  return 0;
74
74
  }
75
75
  if (command === 'rollback') {
76
- const result = await handlers.rollback(args[0], { dryRun: parsed.dryRun });
76
+ const rollbackOptions = {
77
+ dryRun: parsed.dryRun,
78
+ };
79
+ if (parsed.json && parsed.progress) {
80
+ rollbackOptions.onProgress = (event) => printJsonProgress('rollback', event, io);
81
+ }
82
+ const result = await handlers.rollback(args[0], rollbackOptions);
77
83
  if (parsed.json) {
78
84
  printJsonResult('rollback', result, 0, io);
79
85
  return 0;
@@ -192,8 +198,8 @@ function parseCliArgs(argv) {
192
198
  if (progress && !json) {
193
199
  return createParsedError('--progress 需要配合 --json 使用', configPath, json);
194
200
  }
195
- if (progress && args[0] && args[0] !== 'deploy') {
196
- return createParsedError('--progress 仅支持 deploy 命令', configPath, json);
201
+ if (progress && args[0] && args[0] !== 'deploy' && args[0] !== 'rollback') {
202
+ return createParsedError('--progress 仅支持 deploy 或 rollback 命令', configPath, json);
197
203
  }
198
204
  return {
199
205
  args: args.slice(1),
@@ -236,7 +242,9 @@ function createDefaultHandlers(configPath) {
236
242
  if (options?.dryRun) {
237
243
  return createRollbackPlan(config, createRemoteClient(config), version);
238
244
  }
239
- return rollback(config, createRemoteClient(config), version);
245
+ return rollback(config, createRemoteClient(config), version, {
246
+ onProgress: options?.onProgress,
247
+ });
240
248
  },
241
249
  list: async () => {
242
250
  const config = await loadConfigFile(configPath);
@@ -317,6 +325,12 @@ function printRollbackResult(result, io) {
317
325
  }
318
326
  io.log(`已回滚到版本: ${result.version}`);
319
327
  io.log(`当前指向: ${result.currentSymlink}`);
328
+ if (result.verified) {
329
+ io.log('回滚校验通过');
330
+ for (const check of result.verification ?? []) {
331
+ io.log(`校验: ${check.name} - ${check.message}`);
332
+ }
333
+ }
320
334
  for (const warning of result.warnings) {
321
335
  io.log(`警告: ${warning}`);
322
336
  }
@@ -410,6 +424,7 @@ function createUsageText() {
410
424
  ssh-release rollback [version] [--config <path>]
411
425
  ssh-release rollback [version] --dry-run [--config <path>]
412
426
  ssh-release rollback [version] --plan [--config <path>]
427
+ ssh-release rollback [version] --json --progress [--config <path>]
413
428
  ssh-release unlock [--confirm <lock-path>] [--config <path>]
414
429
  ssh-release <command> --json
415
430
  ssh-release --help
@@ -0,0 +1,22 @@
1
+ import path from 'node:path';
2
+ export function toPosixPath(value) {
3
+ return value.split(path.sep).join(path.posix.sep);
4
+ }
5
+ export function isExcludedPath(relativePath, exclude) {
6
+ const normalizedPath = toPosixPath(relativePath);
7
+ const pathSegments = normalizedPath.split('/');
8
+ return exclude.some((pattern) => {
9
+ const normalizedPattern = toPosixPath(pattern);
10
+ if (!normalizedPattern.includes('/')) {
11
+ return pathSegments.includes(normalizedPattern);
12
+ }
13
+ return wildcardMatch(normalizedPath, normalizedPattern)
14
+ || wildcardMatch(`./${normalizedPath}`, normalizedPattern);
15
+ });
16
+ }
17
+ function wildcardMatch(value, pattern) {
18
+ const escapedPattern = pattern
19
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&')
20
+ .replaceAll('*', '[^/]*');
21
+ return new RegExp(`^${escapedPattern}$`).test(value);
22
+ }
package/dist/lock.js CHANGED
@@ -41,9 +41,30 @@ export async function acquireRemoteLock(config, client, options = {}) {
41
41
  export async function removeRemoteLock(config, client) {
42
42
  await client.exec(`rm -rf ${shellQuote(getRemoteLockPath(config))}`);
43
43
  }
44
+ export async function waitForRemoteLockReleased(config, client, options = {}) {
45
+ const label = options.label ?? '远端锁';
46
+ const attempts = Math.max(1, options.attempts ?? 4);
47
+ const delayMs = Math.max(0, options.delayMs ?? 50);
48
+ let lockStatus = await readRemoteLockStatus(config, client);
49
+ for (let attempt = 1; attempt < attempts && lockStatus.locked; attempt += 1) {
50
+ await delay(delayMs);
51
+ lockStatus = await readRemoteLockStatus(config, client);
52
+ }
53
+ if (lockStatus.locked) {
54
+ throw new Error(`${label}未清理: ${lockStatus.lockPath}`);
55
+ }
56
+ }
44
57
  function parseRemoteLockValue(lines, fieldName) {
45
58
  const prefix = `${fieldName}=`;
46
59
  const line = lines.find((entry) => entry.startsWith(prefix));
47
60
  const value = line?.slice(prefix.length).trim();
48
61
  return value || undefined;
49
62
  }
63
+ async function delay(ms) {
64
+ if (ms === 0) {
65
+ return;
66
+ }
67
+ await new Promise((resolve) => {
68
+ setTimeout(resolve, ms);
69
+ });
70
+ }
package/dist/manifest.js CHANGED
@@ -2,6 +2,7 @@ import { createHash } from 'node:crypto';
2
2
  import { mkdtemp, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
5
+ import { isExcludedPath, toPosixPath } from './exclude.js';
5
6
  export async function createReleaseManifest(options) {
6
7
  const sourceStat = await stat(options.sourcePath);
7
8
  const files = sourceStat.isDirectory()
@@ -41,7 +42,7 @@ async function collectDirectoryFiles(sourcePath, exclude, relativeDirectory = ''
41
42
  const files = [];
42
43
  for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
43
44
  const relativePath = toPosixPath(path.join(relativeDirectory, entry.name));
44
- if (isExcluded(relativePath, exclude)) {
45
+ if (isExcludedPath(relativePath, exclude)) {
45
46
  continue;
46
47
  }
47
48
  const localPath = path.join(sourcePath, relativePath);
@@ -63,24 +64,3 @@ async function createManifestFileEntry(localPath, relativePath) {
63
64
  sha256: createHash('sha256').update(content).digest('hex'),
64
65
  };
65
66
  }
66
- function isExcluded(relativePath, exclude) {
67
- const normalizedPath = toPosixPath(relativePath);
68
- const pathSegments = normalizedPath.split('/');
69
- return exclude.some((pattern) => {
70
- const normalizedPattern = toPosixPath(pattern);
71
- if (!normalizedPattern.includes('/')) {
72
- return pathSegments.includes(normalizedPattern);
73
- }
74
- return wildcardMatch(normalizedPath, normalizedPattern)
75
- || wildcardMatch(`./${normalizedPath}`, normalizedPattern);
76
- });
77
- }
78
- function wildcardMatch(value, pattern) {
79
- const escapedPattern = pattern
80
- .replace(/[.+^${}()|[\]\\]/g, '\\$&')
81
- .replaceAll('*', '[^/]*');
82
- return new RegExp(`^${escapedPattern}$`).test(value);
83
- }
84
- function toPosixPath(value) {
85
- return value.split(path.sep).join(path.posix.sep);
86
- }
package/dist/release.js CHANGED
@@ -28,12 +28,28 @@ export function selectReleasesToDelete(releases, currentVersion, keepReleases) {
28
28
  function pad(value) {
29
29
  return value.toString().padStart(2, '0');
30
30
  }
31
+ const maxVersionSuffixAttempts = 1000;
32
+ export async function resolveUniqueReleaseVersion(config, client, baseVersionName) {
33
+ const releasesPath = remoteJoin(config.target.path, config.target.releasesDir);
34
+ for (let attempt = 1; attempt <= maxVersionSuffixAttempts; attempt += 1) {
35
+ const candidate = attempt === 1 ? baseVersionName : `${baseVersionName}-${attempt}`;
36
+ const candidatePath = remoteJoin(releasesPath, candidate);
37
+ const result = await client.exec(`test -e ${shellQuote(candidatePath)} && echo exists || true`);
38
+ if (result.stdout.trim() !== 'exists') {
39
+ return candidate;
40
+ }
41
+ }
42
+ throw new Error(`无法为版本 ${baseVersionName} 生成唯一目录名,已尝试 ${maxVersionSuffixAttempts} 次`);
43
+ }
31
44
  export async function deploy(config, client, options = {}) {
32
45
  await withDeployProgress(options, 'source', () => ensureSourceExists(config.source.path));
33
46
  const releaseDate = options.now ?? new Date();
34
- const versionName = createVersionName(releaseDate);
47
+ let versionName = createVersionName(releaseDate);
35
48
  const packageFactory = options.createPackage ?? createReleasePackage;
36
49
  const releaseLock = await withDeployProgress(options, 'lock', () => acquireRemoteLock(config, client));
50
+ if (config.deploy.mode === 'release') {
51
+ versionName = await resolveUniqueReleaseVersion(config, client, versionName);
52
+ }
37
53
  let releasePackage;
38
54
  let manifest;
39
55
  let manifestFile;
package/dist/rollback.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { readCurrentVersion, readRemoteReleaseNames, remoteJoin, shellQuote, } from './remote.js';
2
- import { acquireRemoteLock, readRemoteLockStatus } from './lock.js';
2
+ import { acquireRemoteLock, readRemoteLockStatus, waitForRemoteLockReleased } from './lock.js';
3
3
  export function selectRollbackTarget(selection) {
4
4
  const releases = [...new Set(selection.releases)].sort();
5
5
  if (selection.requestedVersion) {
@@ -61,32 +61,35 @@ export async function createRollbackPlan(config, client, requestedVersion) {
61
61
  ],
62
62
  };
63
63
  }
64
- export async function rollback(config, client, requestedVersion) {
64
+ export async function rollback(config, client, requestedVersion, options = {}) {
65
65
  if (config.deploy.mode === 'overwrite') {
66
66
  throw new Error('overwrite 模式不支持回滚');
67
67
  }
68
- const releaseLock = await acquireRemoteLock(config, client, { createTargetPath: false });
68
+ const releaseLock = await withRollbackProgress(options, 'lock', () => acquireRemoteLock(config, client, { createTargetPath: false }));
69
69
  let result;
70
70
  let rollbackError;
71
+ let cleanupError;
71
72
  try {
72
- const releasesPath = remoteJoin(config.target.path, config.target.releasesDir);
73
- const currentSymlinkPath = remoteJoin(config.target.path, config.target.currentSymlink);
74
- const releases = await readRemoteReleaseNames(client, releasesPath);
75
- const currentVersion = await readCurrentVersion(client, currentSymlinkPath);
76
- if (!currentVersion) {
77
- throw new Error('当前版本不存在');
78
- }
79
- const targetVersion = selectRollbackTarget({
80
- releases,
81
- currentVersion,
82
- requestedVersion,
73
+ result = await withRollbackProgress(options, 'switch', async () => {
74
+ const releasesPath = remoteJoin(config.target.path, config.target.releasesDir);
75
+ const currentSymlinkPath = remoteJoin(config.target.path, config.target.currentSymlink);
76
+ const releases = await readRemoteReleaseNames(client, releasesPath);
77
+ const currentVersion = await readCurrentVersion(client, currentSymlinkPath);
78
+ if (!currentVersion) {
79
+ throw new Error('当前版本不存在');
80
+ }
81
+ const targetVersion = selectRollbackTarget({
82
+ releases,
83
+ currentVersion,
84
+ requestedVersion,
85
+ });
86
+ await client.exec(`ln -sfn ${shellQuote(remoteJoin(config.target.releasesDir, targetVersion))} ${shellQuote(currentSymlinkPath)}`);
87
+ return {
88
+ version: targetVersion,
89
+ currentSymlink: currentSymlinkPath,
90
+ warnings: [],
91
+ };
83
92
  });
84
- await client.exec(`ln -sfn ${shellQuote(remoteJoin(config.target.releasesDir, targetVersion))} ${shellQuote(currentSymlinkPath)}`);
85
- result = {
86
- version: targetVersion,
87
- currentSymlink: currentSymlinkPath,
88
- warnings: [],
89
- };
90
93
  return result;
91
94
  }
92
95
  catch (error) {
@@ -94,20 +97,101 @@ export async function rollback(config, client, requestedVersion) {
94
97
  throw error;
95
98
  }
96
99
  finally {
97
- try {
98
- await releaseLock();
99
- }
100
- catch (error) {
101
- const message = `远程发布锁清理失败: ${formatError(error)}`;
102
- if (result) {
103
- result.warnings.push(message);
100
+ await withRollbackProgress(options, 'cleanup', async () => {
101
+ try {
102
+ await releaseLock();
103
+ await waitForRemoteLockReleased(config, client, { label: '回滚锁' });
104
+ }
105
+ catch (error) {
106
+ const message = `远程回滚锁清理失败: ${formatError(error)}`;
107
+ if (result) {
108
+ result.warnings.push(message);
109
+ }
110
+ if (!rollbackError) {
111
+ cleanupError = error;
112
+ }
104
113
  }
105
- else if (!rollbackError) {
106
- throw error;
114
+ if (cleanupError && !rollbackError) {
115
+ throw cleanupError;
107
116
  }
117
+ });
118
+ const rollbackResult = result;
119
+ if (rollbackResult && !rollbackError && !cleanupError) {
120
+ await withRollbackProgress(options, 'verify', async () => {
121
+ rollbackResult.verification = await verifyRollbackResult(config, client, rollbackResult);
122
+ rollbackResult.verified = true;
123
+ });
108
124
  }
109
125
  }
110
126
  }
127
+ async function verifyRollbackResult(config, client, result) {
128
+ return [
129
+ await verifyRemoteDirectory(client, remoteJoin(config.target.path, config.target.releasesDir, result.version), '目标版本', '目标版本目录存在'),
130
+ await verifyCurrentSymlink(client, result.currentSymlink, remoteJoin(config.target.releasesDir, result.version)),
131
+ await verifyRemoteLockReleased(config, client),
132
+ ];
133
+ }
134
+ async function verifyRemoteDirectory(client, remotePath, name, successMessage) {
135
+ try {
136
+ await client.exec(`test -d ${shellQuote(remotePath)}`);
137
+ }
138
+ catch (error) {
139
+ throw new Error(`${name}校验失败: ${formatError(error)}`);
140
+ }
141
+ return {
142
+ name,
143
+ status: 'pass',
144
+ message: successMessage,
145
+ };
146
+ }
147
+ async function verifyCurrentSymlink(client, currentSymlinkPath, expectedTarget) {
148
+ let actualTarget;
149
+ try {
150
+ const result = await client.exec(`readlink ${shellQuote(currentSymlinkPath)}`);
151
+ actualTarget = result.stdout.trim();
152
+ }
153
+ catch (error) {
154
+ throw new Error(`current 校验失败: ${formatError(error)}`);
155
+ }
156
+ if (actualTarget !== expectedTarget) {
157
+ throw new Error(`current 未指向目标版本: 期望 ${expectedTarget},实际 ${actualTarget || '空'}`);
158
+ }
159
+ return {
160
+ name: '当前版本',
161
+ status: 'pass',
162
+ message: 'current 已指向目标版本',
163
+ };
164
+ }
165
+ async function verifyRemoteLockReleased(config, client) {
166
+ const lockStatus = await readRemoteLockStatus(config, client);
167
+ if (lockStatus.locked) {
168
+ throw new Error(`回滚锁未清理: ${lockStatus.lockPath}`);
169
+ }
170
+ return {
171
+ name: '远端锁',
172
+ status: 'pass',
173
+ message: '回滚锁已清理',
174
+ };
175
+ }
176
+ async function withRollbackProgress(options, stage, run) {
177
+ await emitRollbackProgress(options, { stage, status: 'start' });
178
+ try {
179
+ const result = await run();
180
+ await emitRollbackProgress(options, { stage, status: 'success' });
181
+ return result;
182
+ }
183
+ catch (error) {
184
+ await emitRollbackProgress(options, {
185
+ stage,
186
+ status: 'fail',
187
+ error: formatError(error),
188
+ });
189
+ throw error;
190
+ }
191
+ }
192
+ async function emitRollbackProgress(options, event) {
193
+ await options.onProgress?.(event);
194
+ }
111
195
  function formatError(error) {
112
196
  return error instanceof Error ? error.message : String(error);
113
197
  }
package/dist/ssh.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { readdir, stat } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
+ import { isExcludedPath, toPosixPath } from './exclude.js';
3
4
  import { runProcess } from './process.js';
4
5
  import { remoteJoin, shellQuote } from './remote.js';
5
6
  export class ShellRemoteClient {
@@ -119,17 +120,18 @@ async function uploadDirectoryContents(client, localPath, remotePath, exclude) {
119
120
  }
120
121
  await walkDirectory(client, localPath, remotePath, exclude);
121
122
  }
122
- async function walkDirectory(client, localDirectory, remoteDirectory, exclude) {
123
+ export async function walkDirectory(client, localDirectory, remoteDirectory, exclude, relativeDirectory = '') {
123
124
  await client.exec(`mkdir -p ${shellQuote(remoteDirectory)}`);
124
125
  const entries = await readdir(localDirectory, { withFileTypes: true });
125
126
  for (const entry of entries) {
126
- if (exclude.includes(entry.name)) {
127
+ const relativeEntryPath = toPosixPath(path.join(relativeDirectory, entry.name));
128
+ if (isExcludedPath(relativeEntryPath, exclude)) {
127
129
  continue;
128
130
  }
129
131
  const localEntryPath = path.join(localDirectory, entry.name);
130
132
  const remoteEntryPath = remoteJoin(remoteDirectory, entry.name);
131
133
  if (entry.isDirectory()) {
132
- await walkDirectory(client, localEntryPath, remoteEntryPath, exclude);
134
+ await walkDirectory(client, localEntryPath, remoteEntryPath, exclude, relativeEntryPath);
133
135
  continue;
134
136
  }
135
137
  if (entry.isFile()) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ssh-release",
3
- "version": "1.2.0",
3
+ "version": "1.4.0",
4
4
  "description": "A general-purpose SSH file release CLI.",
5
5
  "type": "module",
6
6
  "bin": {