- 新增 apps/cli/src/cmd/rollback.ts:读 LKG → 指纹比对定位备份 → 覆盖写入口(同 deploy 姿势)→ 写后校验(失败用回滚源还原)→ 受控重启 → probeOnce 复核 build.hash == LKG(90s 墙钟) - 纯函数 planRollback/pickBackupByHash/hashMatches/backupNameRegex + 流式 hashRemoteFile(client 注入,可假测);hash_full 全量优先、 老记录回落 12 位前缀(A-P0-3/R-2 向后兼容) - --dry-run 只读零写;--backup 逃生门直指备份路径;退出码 0/1/2 分明 - HELP/README 写明能力圈边界:RPC 命令需被控端可达,「完全起不来」 走看门狗自动恢复或带外恢复;不提供自动回滚(DELIVERY-PIPELINE §6) - 19 条单测:无 LKG 零调用 / already-lkg 零写 / 写后校验失败自动还原 / 无匹配零写 / dry-run 只读 / 多候选 mtime 新→早 / Windows 路径与元字符
228 lines
9.4 KiB
TypeScript
228 lines
9.4 KiB
TypeScript
import { closeSync, existsSync, openSync, readFileSync, readSync, renameSync, statSync, writeFileSync, writeSync } from 'node:fs';
|
||
import fsSync from 'node:fs';
|
||
import net from 'node:net';
|
||
import os from 'node:os';
|
||
import path, { join } from 'node:path';
|
||
import { parseArgs } from 'node:util';
|
||
import {
|
||
NodeAgentClient,
|
||
loadMacroFile,
|
||
runMacro,
|
||
ClientError,
|
||
loadConfig,
|
||
saveConfig,
|
||
configPath,
|
||
toWsUrl,
|
||
loadKeys,
|
||
createKeys,
|
||
keysFilePath,
|
||
discoverOnce,
|
||
resolveTarget,
|
||
resolveNodeSelector,
|
||
emptyConfig,
|
||
type ClientConfig,
|
||
type ResolvedTarget,
|
||
type NodeProfile,
|
||
} from '@nodeagent/client';
|
||
import {
|
||
CapabilityNames,
|
||
matchPattern,
|
||
DEFAULT_DISCOVERY_PORT,
|
||
type CapabilityDescriptor,
|
||
type InvokeResult,
|
||
} from '@nodeagent/protocol';
|
||
import { type Options } from './types.js';
|
||
|
||
// 命令行参数解析(从 index.ts 拆出)
|
||
export function parseOptions(rest: string[]): { opts: Options; positionals: string[] } {
|
||
const { values, positionals } = parseArgs({
|
||
args: rest,
|
||
options: {
|
||
// ⚠️ 这两个是 L-01 同类问题的「漏网之鱼」,由 cli-options 守卫扫出(v2.0.3):
|
||
// `--help` 原来靠 positionals[0]===undefined 偶然命中;`--no-tls` 则绕过解析器
|
||
// 直接在 cmd/conn.ts 里扫 `process.argv`。两者都是「能跑,但没接进解析器」。
|
||
help: { type: 'boolean', default: false },
|
||
'no-tls': { type: 'boolean', default: false },
|
||
json: { type: 'boolean', default: false },
|
||
insecure: { type: 'boolean', default: false },
|
||
port: { type: 'string' },
|
||
key: { type: 'string' },
|
||
id: { type: 'string' },
|
||
limit: { type: 'string' },
|
||
args: { type: 'string' },
|
||
config: { type: 'boolean', default: false },
|
||
// v2 图形操作
|
||
out: { type: 'string' },
|
||
format: { type: 'string' },
|
||
scale: { type: 'string' },
|
||
region: { type: 'string' },
|
||
button: { type: 'string' },
|
||
duration: { type: 'string' },
|
||
interval: { type: 'string' },
|
||
tail: { type: 'boolean' },
|
||
compare: { type: 'boolean' },
|
||
'anchor-path': { type: 'string' },
|
||
note: { type: 'string' },
|
||
level: { type: 'string' },
|
||
target: { type: 'string' },
|
||
source: { type: 'string' },
|
||
where: { type: 'string' },
|
||
any_of: { type: 'string' },
|
||
title: { type: 'string' },
|
||
text: { type: 'string' },
|
||
process: { type: 'string' },
|
||
state: { type: 'string' },
|
||
condition: { type: 'string' },
|
||
// 媒体键定向投递的目标进程 PID(v1.2)
|
||
pid: { type: 'string' },
|
||
hold: { type: 'string' },
|
||
route: { type: 'string' },
|
||
'auth-mode': { type: 'string' },
|
||
since: { type: 'string' },
|
||
type: { type: 'string' },
|
||
'client-id': { type: 'string' },
|
||
wait: { type: 'string' },
|
||
'discovery-port': { type: 'string' },
|
||
node: { type: 'string' },
|
||
name: { type: 'string' },
|
||
recursive: { type: 'boolean', default: false },
|
||
pattern: { type: 'string' },
|
||
'create-dirs': { type: 'boolean', default: false },
|
||
'hub-token': { type: 'string' },
|
||
'hub-node': { type: 'string' },
|
||
// v7 受控重启
|
||
delay: { type: 'string' },
|
||
// v10 后台任务 / 剪贴板
|
||
'timeout-ms': { type: 'string' },
|
||
// ⚠️ L-01(v2.0.3 修):`--timeout` 曾经**没在这里声明**。因为 `strict: false`,
|
||
// 未声明选项会被当成 boolean 塞进 `values.timeout`(`true`),而它的值
|
||
// `30000` 则掉进 `positionals` —— 于是 `--timeout` 静默失效,
|
||
// 并且**污染位置参数**(可能把子命令/主机名解析错)。
|
||
// 帮助文本与 `cmd/gui.ts` 的 await 命令用的都是 `--timeout`,故必须显式声明。
|
||
timeout: { type: 'string' },
|
||
kill: { type: 'boolean', default: false },
|
||
offset: { type: 'string' },
|
||
set: { type: 'string' },
|
||
'image-file': { type: 'string' },
|
||
fps: { type: 'string' },
|
||
nodes: { type: 'string' },
|
||
path: { type: 'string' },
|
||
kind: { type: 'string' },
|
||
var: { type: 'string', multiple: true },
|
||
mode: { type: 'string' },
|
||
iface: { type: 'string' },
|
||
ip: { type: 'string' },
|
||
mask: { type: 'string' },
|
||
gateway: { type: 'string' },
|
||
dns: { type: 'string' },
|
||
command: { type: 'string' },
|
||
'confirm-within': { type: 'string' },
|
||
'task-name': { type: 'string' },
|
||
yes: { type: 'boolean', default: false },
|
||
url: { type: 'string' },
|
||
sha256: { type: 'string' },
|
||
'dry-run': { type: 'boolean', default: false },
|
||
psk: { type: 'boolean', default: false },
|
||
'discover-secret': { type: 'string' },
|
||
'forget-cert': { type: 'boolean', default: false },
|
||
check: { type: 'boolean', default: false },
|
||
force: { type: 'boolean', default: false },
|
||
// v2.1.0 rollback 逃生门(--backup <远端备份路径>)
|
||
backup: { type: 'string' },
|
||
mute: { type: 'string' },
|
||
volume: { type: 'string' },
|
||
seconds: { type: 'string' },
|
||
'interval-ms': { type: 'string' },
|
||
},
|
||
allowPositionals: true,
|
||
strict: false,
|
||
});
|
||
return {
|
||
opts: {
|
||
help: Boolean(values['help']),
|
||
noTls: Boolean(values['no-tls']),
|
||
json: Boolean(values['json']),
|
||
insecure: Boolean(values['insecure']),
|
||
port: values['port'] as string | undefined,
|
||
key: values['key'] as string | undefined,
|
||
id: values['id'] as string | undefined,
|
||
limit: values['limit'] as string | undefined,
|
||
args: values['args'] as string | undefined,
|
||
out: values['out'] as string | undefined,
|
||
format: values['format'] as string | undefined,
|
||
scale: values['scale'] as string | undefined,
|
||
region: values['region'] as string | undefined,
|
||
button: values['button'] as string | undefined,
|
||
duration: values['duration'] as string | undefined,
|
||
interval: values['interval'] as string | undefined,
|
||
tail: values['tail'] as boolean | undefined,
|
||
compare: values['compare'] as boolean | undefined,
|
||
anchorPath: values['anchor-path'] as string | undefined,
|
||
note: values['note'] as string | undefined,
|
||
level: values['level'] as string | undefined,
|
||
target: values['target'] as string | undefined,
|
||
source: values['source'] as string | undefined,
|
||
where: values['where'] as string | undefined,
|
||
any_of: values['any_of'] as string | undefined,
|
||
condition: values['condition'] as string | undefined,
|
||
state: values['state'] as string | undefined,
|
||
process: values['process'] as string | undefined,
|
||
text: values['text'] as string | undefined,
|
||
title: values['title'] as string | undefined,
|
||
pid: values['pid'] as string | undefined,
|
||
hold: values['hold'] as string | undefined,
|
||
route: values['route'] as string | undefined,
|
||
authMode: values['auth-mode'] as string | undefined,
|
||
since: values['since'] as string | undefined,
|
||
type: values['type'] as string | undefined,
|
||
clientId: values['client-id'] as string | undefined,
|
||
wait: values['wait'] as string | undefined,
|
||
discoveryPort: values['discovery-port'] as string | undefined,
|
||
node: values['node'] as string | undefined,
|
||
delay: values['delay'] as string | undefined,
|
||
// 两个写法都接受:`--timeout` 是帮助文本/`await` 命令里的正式写法,
|
||
// `--timeout-ms` 是早期在 `bg` 命令里引入的别名。二者同时给时以 `--timeout` 为准。
|
||
timeoutMs: (values['timeout'] ?? values['timeout-ms']) as string | undefined,
|
||
kill: Boolean(values['kill']),
|
||
offset: values['offset'] as string | undefined,
|
||
set: values['set'] as string | undefined,
|
||
imageFile: values['image-file'] as string | undefined,
|
||
fps: values['fps'] as string | undefined,
|
||
nodes: values['nodes'] as string | undefined,
|
||
path: values['path'] as string | undefined,
|
||
kind: values['kind'] as string | undefined,
|
||
vars: (values['var'] as string[] | undefined) ?? [],
|
||
mode: values['mode'] as string | undefined,
|
||
iface: values['iface'] as string | undefined,
|
||
ip: values['ip'] as string | undefined,
|
||
mask: values['mask'] as string | undefined,
|
||
gateway: values['gateway'] as string | undefined,
|
||
dns: values['dns'] as string | undefined,
|
||
command: values['command'] as string | undefined,
|
||
confirmWithin: values['confirm-within'] as string | undefined,
|
||
taskName: values['task-name'] as string | undefined,
|
||
yes: Boolean(values['yes']),
|
||
url: values['url'] as string | undefined,
|
||
sha256: values['sha256'] as string | undefined,
|
||
dryRun: Boolean(values['dry-run']),
|
||
psk: Boolean(values['psk']),
|
||
discoverSecret: values['discover-secret'] as string | undefined,
|
||
forgetCert: Boolean(values['forget-cert']),
|
||
check: Boolean(values['check']),
|
||
force: Boolean(values['force']),
|
||
backup: values['backup'] as string | undefined,
|
||
mute: values['mute'] as string | undefined,
|
||
volume: values['volume'] as string | undefined,
|
||
seconds: values['seconds'] as string | undefined,
|
||
intervalMs: values['interval-ms'] as string | undefined,
|
||
name: values['name'] as string | undefined,
|
||
recursive: Boolean(values['recursive']),
|
||
pattern: values['pattern'] as string | undefined,
|
||
createDirs: Boolean(values['create-dirs']),
|
||
hubToken: values['hub-token'] as string | undefined,
|
||
hubNode: values['hub-node'] as string | undefined,
|
||
},
|
||
positionals,
|
||
};
|
||
}
|