@whyshu/argent 0.1.26 → 0.2.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/README.md +16 -65
- package/cordis.patch.yml +6 -0
- package/dist/client.js +691 -0
- package/mcp/billing-upload.mjs +77 -0
- package/mcp/erp-server.mjs +296 -0
- package/mcp/gateway.mjs +336 -0
- package/mcp/meter.mjs +101 -0
- package/mcp/vault.mjs +78 -0
- package/mcp/vendor/account-token.mjs +37 -0
- package/mcp/vendor/redact.mjs +34 -0
- package/package.json +28 -18
- package/src/account-token.mjs +40 -0
- package/src/actions.mjs +114 -0
- package/src/config.mjs +47 -0
- package/src/error-log.mjs +56 -0
- package/src/fulfillment-result.mjs +84 -0
- package/src/health.mjs +231 -0
- package/src/index.js +231 -0
- package/src/kernel.mjs +111 -0
- package/src/lease.mjs +90 -0
- package/src/llm-whyshu.mjs +155 -0
- package/src/office-runtime.js +1102 -0
- package/src/skuflow-runner.mjs +57 -0
- package/src/timer-status.mjs +160 -0
- package/src/whyshu-auth.mjs +277 -0
- package/bin/argent.mjs +0 -385
- package/lib/index.mjs +0 -604
- package/runner/argent-runner.mjs +0 -223
package/lib/index.mjs
DELETED
|
@@ -1,604 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @whyshu/argent 启动器内核(纯函数,便于单测;bin 只做 CLI 编排)。
|
|
3
|
-
*
|
|
4
|
-
* 职责:
|
|
5
|
-
* · exact pin 的 dsh 版本(0.2.1-rc.2)——启动前校验,避免混合版本事故;
|
|
6
|
-
* · 生成/更新 $DSH_HOME/profiles/argent(bundles + 用户层 patch)——本地仓库用 file: 装配;
|
|
7
|
-
* · runner 配置路径与 launchd/systemd 常驻单元生成;
|
|
8
|
-
* · 状态自检(版本/profile/runner 配置/端口)。
|
|
9
|
-
* ⛔ 不读写任何凭据内容(runner.json 由首跑引导写,权限 600)。
|
|
10
|
-
*/
|
|
11
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync, statSync, cpSync, readdirSync, chmodSync } from 'node:fs';
|
|
12
|
-
import { spawnSync } from 'node:child_process';
|
|
13
|
-
import { homedir, hostname as osHostname } from 'node:os';
|
|
14
|
-
import { dirname, join, resolve } from 'node:path';
|
|
15
|
-
import { fileURLToPath } from 'node:url';
|
|
16
|
-
import { createRequire } from 'node:module';
|
|
17
|
-
|
|
18
|
-
export const DSH_EXACT_VERSION = '0.2.1-rc.2';
|
|
19
|
-
export const PROFILE_NAME = 'argent';
|
|
20
|
-
export const BASE_BUNDLES = [
|
|
21
|
-
'@deepseek-ai/dsh-base',
|
|
22
|
-
'@deepseek-ai/dsh-web-app',
|
|
23
|
-
'@whyshu/argent-presets',
|
|
24
|
-
];
|
|
25
|
-
export const DEFAULT_PRESET = 'argent-general';
|
|
26
|
-
/** 生产 profile 追加 bundles(--bundles prod 展开;版本见启动器 package.json 依赖) */
|
|
27
|
-
export const PROD_BUNDLES = [
|
|
28
|
-
'@whyshu/argent-core', '@whyshu/argent-dsh', '@whyshu/argent-skin',
|
|
29
|
-
'@whyshu/argent-gateway', '@whyshu/argent-flow', '@whyshu/argent-ml',
|
|
30
|
-
'@michengai/dsh-im-connect', '@linxin666/dsh-client-ui-task-board',
|
|
31
|
-
];
|
|
32
|
-
export const RUNNER_LABEL = 'com.whyshu.argent-runner';
|
|
33
|
-
|
|
34
|
-
/** 启动器自身版本(写进 argent.json 的 writtenBy,便于事后追溯是谁写的契约) */
|
|
35
|
-
export const LAUNCHER_VERSION = (() => {
|
|
36
|
-
try { return JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf8')).version } catch { return '' }
|
|
37
|
-
})();
|
|
38
|
-
|
|
39
|
-
/** 仓库根:launcher/lib → ../../(本地联调);发布包内由 ARGENT_REPO 覆盖 */
|
|
40
|
-
export function repoRoot({ repo } = {}) {
|
|
41
|
-
if (repo) return resolve(repo);
|
|
42
|
-
if (process.env.ARGENT_REPO) return resolve(process.env.ARGENT_REPO);
|
|
43
|
-
return resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
export function dshHomeOf(value) {
|
|
47
|
-
return resolve(value || process.env.DSH_HOME || join(homedir(), '.dsh'));
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/** 解析 `dsh --version` 输出(只取第一行,去掉 v 前缀) */
|
|
51
|
-
export function parseVersion(text) {
|
|
52
|
-
const line = String(text || '').trim().split('\n')[0] || '';
|
|
53
|
-
return line.replace(/^v/, '').trim();
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
export function versionMatches(version, expected = DSH_EXACT_VERSION) {
|
|
57
|
-
return parseVersion(version) === parseVersion(expected);
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/** 跑 `dsh --version`(可注入 bin;默认 PATH 上的 dsh) */
|
|
61
|
-
export function probeDshVersion({ dshBin = 'dsh', spawn = spawnSync } = {}) {
|
|
62
|
-
const r = spawn(dshBin, ['--version'], { encoding: 'utf8', timeout: 15000 });
|
|
63
|
-
if (r.error || r.status !== 0) return { ok: false, version: '', error: r.error?.message || `exit ${r.status}` };
|
|
64
|
-
return { ok: true, version: parseVersion(r.stdout), error: '' };
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/** 仓库内插件目录(本地 file: 装配用;发布包改为其自身 node_modules) */
|
|
68
|
-
export function pluginDirs({ repo = repoRoot() } = {}) {
|
|
69
|
-
const candidates = {
|
|
70
|
-
'@whyshu/argent-presets': join(repo, 'plugins', 'argent-presets'),
|
|
71
|
-
};
|
|
72
|
-
const out = {};
|
|
73
|
-
for (const [name, dir] of Object.entries(candidates)) {
|
|
74
|
-
if (existsSync(join(dir, 'package.json'))) out[name] = dir;
|
|
75
|
-
}
|
|
76
|
-
return out;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/** 已发布插件的版本(发布模式装配 profile 用;与 npm 上实际可见版本对齐) */
|
|
80
|
-
export const PUBLISHED_BUNDLE_VERSIONS = {
|
|
81
|
-
// ⚠️ 每个包**只允许出现一次**(2026-11-02 修:argent-desktop 曾同时写 0.2.2/0.2.3,
|
|
82
|
-
// 后者静默覆盖前者 ⇒ 改上面那行的人会以为生效了)
|
|
83
|
-
'@whyshu/argent-presets': '0.1.2',
|
|
84
|
-
'@whyshu/argent-core': '0.1.6',
|
|
85
|
-
'@whyshu/argent-dsh': '0.1.10',
|
|
86
|
-
'@whyshu/argent-skin': '0.1.14',
|
|
87
|
-
'@whyshu/argent-gateway': '0.2.3',
|
|
88
|
-
'@whyshu/argent-flow': '0.1.1',
|
|
89
|
-
'@whyshu/argent-ml': '0.1.1',
|
|
90
|
-
'@michengai/dsh-im-connect': '1.0.2',
|
|
91
|
-
'@linxin666/dsh-client-ui-task-board': '0.4.7',
|
|
92
|
-
'@whyshu/argent-desktop': '0.2.4',
|
|
93
|
-
};
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
/**
|
|
97
|
-
* 发布模式随启动器安装的 runtime(@whyshu/argent-engine)根目录;本地联调回退 dist。
|
|
98
|
-
*
|
|
99
|
-
* ⚠️ 2026-10-30 事故(客户机 0.1.18):npm 11 **没有把引擎提升到顶层**,而是嵌套在
|
|
100
|
-
* `<prefix>/node_modules/@whyshu/argent/node_modules/@whyshu/argent-engine/`。
|
|
101
|
-
* 旧实现只探测"提升后"的 4 个固定路径 ⇒ `argent runtime install` 直接 exit 2,
|
|
102
|
-
* 客户机升级链路第一步就断(rc.5 的日报定时器脚本落不了地)。
|
|
103
|
-
*
|
|
104
|
-
* 修法:**先用模块解析**(`require.resolve`,提升/嵌套/符号链接/pnpm 布局都天然命中),
|
|
105
|
-
* 再保留显式候选(含"嵌套在启动器自己的 node_modules")作为兜底。
|
|
106
|
-
* 两者都按同一判据校验(`engine/scripts` + `engine/VERSION`),⛔ 不靠"目录看起来像"。
|
|
107
|
-
*/
|
|
108
|
-
export function engineRoot({ env = process.env, launcherLib = dirname(fileURLToPath(import.meta.url)) } = {}) {
|
|
109
|
-
const candidates = [];
|
|
110
|
-
if (env.ARGENT_ENGINE_ROOT) candidates.push(resolve(env.ARGENT_ENGINE_ROOT));
|
|
111
|
-
// ① 模块解析(最可靠):以 launcher/lib 为锚,让 Node 自己按 npm 布局找包
|
|
112
|
-
try {
|
|
113
|
-
const req = createRequire(join(launcherLib, 'index.mjs'));
|
|
114
|
-
const pkgJson = req.resolve('@whyshu/argent-engine/package.json');
|
|
115
|
-
candidates.push(join(dirname(pkgJson), 'engine'));
|
|
116
|
-
} catch { /* 引擎未装/不可解析 ⇒ 继续用显式候选 */ }
|
|
117
|
-
// ② 显式候选(提升布局 / 嵌套在启动器自己 node_modules / 本地联调 dist)
|
|
118
|
-
candidates.push(resolve(launcherLib, '..', '..', '..', '@whyshu', 'argent-engine', 'engine'));
|
|
119
|
-
candidates.push(resolve(launcherLib, '..', 'node_modules', '@whyshu', 'argent-engine', 'engine'));
|
|
120
|
-
candidates.push(resolve(launcherLib, '..', '..', 'node_modules', '@whyshu', 'argent-engine', 'engine'));
|
|
121
|
-
candidates.push(resolve(launcherLib, '..', '..', 'dist', 'argent-engine', 'engine'));
|
|
122
|
-
for (const c of candidates) {
|
|
123
|
-
if (existsSync(join(c, 'scripts')) && existsSync(join(c, 'VERSION'))) return c;
|
|
124
|
-
}
|
|
125
|
-
// 找不到时给出可执行的诊断(别再让客户只看到一句"请用新版启动器")
|
|
126
|
-
if (env.ARGENT_ENGINE_ROOT) {
|
|
127
|
-
console.error(`[engineRoot] ARGENT_ENGINE_ROOT=${env.ARGENT_ENGINE_ROOT} 不是有效的引擎根(需含 scripts/ 与 VERSION)`)
|
|
128
|
-
}
|
|
129
|
-
console.error(`[engineRoot] 已尝试 ${candidates.length} 个位置(模块解析 + 显式路径)均未命中;`
|
|
130
|
-
+ '可执行 `npm ls -g @whyshu/argent-engine --all` 看真实布局,或用 ARGENT_ENGINE_ROOT 显式指定')
|
|
131
|
-
return '';
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
/**
|
|
135
|
-
* 引擎定位(方案 C 契约的**写入侧**)。
|
|
136
|
-
*
|
|
137
|
-
* 判据与 engineRoot 一致但不猜:`<dir>/VERSION` + `<dir>/core/lib/argent-datadir.mjs`。
|
|
138
|
-
* 优先级:本地仓库(开发联调)→ npm 包 `engine/` → 已物化 runtime(~/.argent-dsh)。
|
|
139
|
-
* 找不到就返回 ok:false —— 由调用方决定写空值还是报错,⛔ 不要在这里编一个路径。
|
|
140
|
-
*/
|
|
141
|
-
export function engineInfo({ repo = repoRoot(), target = process.env.ARGENT_RUNTIME_TARGET, env = process.env } = {}) {
|
|
142
|
-
const valid = (d) => Boolean(d) && existsSync(join(d, 'VERSION')) && existsSync(join(d, 'core', 'lib', 'argent-datadir.mjs'));
|
|
143
|
-
const cands = [];
|
|
144
|
-
// ① 显式 runtime 目标(update run --target):客户机上**唯一稳定**的引擎位置
|
|
145
|
-
if (valid(target)) cands.push({ dir: target, source: 'target' });
|
|
146
|
-
// ② 本地仓库(开发联调):必须真的是 git 检出,⛔ 不要把 npx 缓存里的包当"仓库"
|
|
147
|
-
if (valid(repo) && existsSync(join(repo, '.git'))) cands.push({ dir: repo, source: 'repo' });
|
|
148
|
-
// ③ 已物化 runtime(默认 ~/.argent-dsh)
|
|
149
|
-
const runtime = join(homedir(), '.argent-dsh');
|
|
150
|
-
if (valid(runtime)) cands.push({ dir: runtime, source: 'runtime' });
|
|
151
|
-
// ④ 最后才是 npm 包(npx 缓存路径,随时会被清 —— 只作兜底)
|
|
152
|
-
const pkg = engineRoot({ env });
|
|
153
|
-
if (valid(pkg)) cands.push({ dir: pkg, source: 'package' });
|
|
154
|
-
for (const c of cands) {
|
|
155
|
-
let version = '';
|
|
156
|
-
try { version = readFileSync(join(c.dir, 'VERSION'), 'utf8').trim(); } catch { /* 保留空串 */ }
|
|
157
|
-
return { ok: true, dir: c.dir, source: c.source, version };
|
|
158
|
-
}
|
|
159
|
-
return { ok: false, dir: '', source: 'none', version: '' };
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
/** 读引擎契约版本(唯一真源 = `<engine>/core/lib/contract.mjs`;读不到返回 null,⛔ 不猜) */
|
|
163
|
-
export function engineContract(dir) {
|
|
164
|
-
try {
|
|
165
|
-
const m = readFileSync(join(dir, 'core', 'lib', 'contract.mjs'), 'utf8').match(/export const CONTRACT\s*=\s*(\d+)/);
|
|
166
|
-
return m ? Number(m[1]) : null;
|
|
167
|
-
} catch { return null; }
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
/** 写 `<profile>/argent.json`:宿主壳只读这个文件,不再猜引擎在哪(方案 C · P1) */
|
|
171
|
-
export function writeEngineContract(dir, { repo = repoRoot(), target = process.env.ARGENT_RUNTIME_TARGET, env = process.env } = {}) {
|
|
172
|
-
const info = engineInfo({ repo, target, env });
|
|
173
|
-
const payload = {
|
|
174
|
-
engineDir: info.ok ? info.dir : '',
|
|
175
|
-
engineVersion: info.ok ? info.version : '',
|
|
176
|
-
contract: info.ok ? engineContract(info.dir) : null,
|
|
177
|
-
source: info.source,
|
|
178
|
-
writtenBy: LAUNCHER_VERSION ? `@whyshu/argent@${LAUNCHER_VERSION}` : '@whyshu/argent',
|
|
179
|
-
writtenAt: new Date().toISOString(),
|
|
180
|
-
};
|
|
181
|
-
writeFileSync(join(dir, 'argent.json'), JSON.stringify(payload, null, 2) + '\n');
|
|
182
|
-
return payload;
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
function walkFiles(dir, base = dir) {
|
|
186
|
-
const out = [];
|
|
187
|
-
for (const e of readdirSync(dir)) {
|
|
188
|
-
const p = join(dir, e);
|
|
189
|
-
const rel = p.slice(base.length + 1).split('\\').join('/');
|
|
190
|
-
const st = statSync(p);
|
|
191
|
-
if (st.isDirectory()) out.push(...walkFiles(p, base));
|
|
192
|
-
else out.push(rel);
|
|
193
|
-
}
|
|
194
|
-
return out;
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
/**
|
|
198
|
-
* 把 npm runtime 包物化到客户机既有资产目录(默认 ~/.argent-dsh)。
|
|
199
|
-
* 只覆盖 engine 包内的文件;客户配置/凭据/.git 不动;被覆盖文件先备份。
|
|
200
|
-
*/
|
|
201
|
-
export function materializeRuntime({ target = join(homedir(), '.argent-dsh'), engine = engineRoot(), skillsTarget = '', now = new Date() } = {}) {
|
|
202
|
-
if (!engine) return { ok: false, code: 'engine_missing', message: '未找到 @whyshu/argent-engine(启动器依赖未安装)' };
|
|
203
|
-
if (!existsSync(target)) mkdirSync(target, { recursive: true });
|
|
204
|
-
const version = readFileSync(join(engine, 'VERSION'), 'utf8').trim();
|
|
205
|
-
const stamp = now.toISOString().replace(/[:.]/g, '-');
|
|
206
|
-
const backupDir = join(target, '.runtime-backup', stamp);
|
|
207
|
-
const files = walkFiles(engine);
|
|
208
|
-
let copied = 0;
|
|
209
|
-
const copyFile = (src, dst, rel) => {
|
|
210
|
-
if (existsSync(dst)) {
|
|
211
|
-
const b = join(backupDir, rel);
|
|
212
|
-
mkdirSync(dirname(b), { recursive: true });
|
|
213
|
-
cpSync(dst, b);
|
|
214
|
-
}
|
|
215
|
-
mkdirSync(dirname(dst), { recursive: true });
|
|
216
|
-
cpSync(src, dst);
|
|
217
|
-
// 2026-10-30:引擎 tgz 里 .sh 曾是 0644(打包器丢了执行位)⇒ 落地后不可执行。
|
|
218
|
-
// 包侧已修;这里再做一次落地归一,保证**旧包**装出来的 runtime 也能跑(幂等,不改内容)。
|
|
219
|
-
if (rel.endsWith('.sh')) { try { chmodSync(dst, (statSync(dst).mode & 0o777) | 0o111) } catch { /* noop */ } }
|
|
220
|
-
copied += 1;
|
|
221
|
-
};
|
|
222
|
-
for (const rel of files) copyFile(join(engine, rel), join(target, rel), rel);
|
|
223
|
-
let skillsFiles = 0;
|
|
224
|
-
if (skillsTarget) {
|
|
225
|
-
const skillsSrc = join(engine, 'skills');
|
|
226
|
-
if (existsSync(skillsSrc)) {
|
|
227
|
-
for (const rel of walkFiles(skillsSrc)) copyFile(join(skillsSrc, rel), join(skillsTarget, rel), `skills/${rel}`);
|
|
228
|
-
skillsFiles = walkFiles(skillsSrc).length;
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
mkdirSync(join(target, '.runtime-backup'), { recursive: true });
|
|
232
|
-
writeFileSync(join(target, 'RUNTIME_VERSION.json'), JSON.stringify({ package: '@whyshu/argent-engine', version, installedAt: now.toISOString(), files: copied, skillsFiles, skillsTarget: skillsTarget || null, backupDir }, null, 2) + '\n');
|
|
233
|
-
return { ok: true, version, files: copied, skillsFiles, target, skillsTarget, backupDir };
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
export function profileDir(dshHome, profile = PROFILE_NAME) {
|
|
237
|
-
return join(dshHome, 'profiles', profile);
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
/** 生成/更新 profile(幂等):package.json(file: 依赖 + bundles)+ cordis.patch.yml(用户层) */
|
|
241
|
-
export function buildProfile({ dshHome = dshHomeOf(), profile = PROFILE_NAME, repo = repoRoot(), extraBundles = [] } = {}) {
|
|
242
|
-
const dir = profileDir(dshHome, profile);
|
|
243
|
-
mkdirSync(dir, { recursive: true });
|
|
244
|
-
const plugins = pluginDirs({ repo });
|
|
245
|
-
const npmVersions = PUBLISHED_BUNDLE_VERSIONS;
|
|
246
|
-
const bundles = [...BASE_BUNDLES, ...(extraBundles || [])];
|
|
247
|
-
const deps = {};
|
|
248
|
-
const missing = [];
|
|
249
|
-
for (const name of bundles.filter((b) => !b.startsWith('@deepseek-ai/'))) {
|
|
250
|
-
if (plugins[name]) deps[name] = `file:${plugins[name]}`;
|
|
251
|
-
else if (npmVersions[name]) deps[name] = npmVersions[name];
|
|
252
|
-
else missing.push(name);
|
|
253
|
-
}
|
|
254
|
-
if (missing.length > 0) {
|
|
255
|
-
return { ok: false, code: 'plugins_missing', message: `找不到插件:${missing.join('、')}(本地无 plugins/ 且启动器 package.json 未声明依赖)`, dir };
|
|
256
|
-
}
|
|
257
|
-
// 非 @whyshu 的 bundle(dsh 官方)由宿主管,不进 profile dependencies
|
|
258
|
-
const pkg = {
|
|
259
|
-
name: `dsh-profile-${profile}`,
|
|
260
|
-
private: true,
|
|
261
|
-
dependencies: deps,
|
|
262
|
-
dsh: { profile: { bundles } },
|
|
263
|
-
};
|
|
264
|
-
writeFileSync(join(dir, 'package.json'), JSON.stringify(pkg, null, 2) + '\n');
|
|
265
|
-
// 方案 C · P1:把"引擎在哪、什么版本、契约几"写成机器可读的契约文件(宿主壳只读它)
|
|
266
|
-
const engine = writeEngineContract(dir, { repo });
|
|
267
|
-
const patchLines = [
|
|
268
|
-
'# @whyshu/argent 生成(幂等;手工改动会在此文件重生成时被覆盖)',
|
|
269
|
-
'- id: agent-preset-registry',
|
|
270
|
-
' config:',
|
|
271
|
-
` default: ${DEFAULT_PRESET}`,
|
|
272
|
-
];
|
|
273
|
-
if (bundles.includes('@linxin666/dsh-client-ui-task-board')) {
|
|
274
|
-
patchLines.push('- insert:', ' - id: ui-task-board', " name: '@linxin666/dsh-client-ui-task-board'");
|
|
275
|
-
}
|
|
276
|
-
const patch = patchLines.join('\n') + '\n';
|
|
277
|
-
writeFileSync(join(dir, 'cordis.patch.yml'), patch);
|
|
278
|
-
// pnpm 配置必须与现有 web profile 同口径:否则 pnpm 会尝试从 registry 自动装 peer(含未发布版本)⇒ 安装失败
|
|
279
|
-
writeFileSync(join(dir, 'pnpm-workspace.yaml'), 'packages:\n - .\nnodeLinker: hoisted\nautoInstallPeers: false\nallowBuilds:\n protobufjs: true\n');
|
|
280
|
-
return { ok: true, dir, plugins: Object.keys(plugins), bundles: [...bundles], preset: DEFAULT_PRESET, engine };
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
export function depsInstalled(dshHome = dshHomeOf(), profile = PROFILE_NAME) {
|
|
284
|
-
return existsSync(join(profileDir(dshHome, profile), 'node_modules'));
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
/** 装配 profile 依赖:`dsh plugin --profile <p> install`(pnpm 在 profile 目录内执行) */
|
|
288
|
-
export function installDeps({ dshHome = dshHomeOf(), profile = PROFILE_NAME, dshBin = 'dsh', spawn = spawnSync } = {}) {
|
|
289
|
-
const r = spawn(dshBin, ['plugin', '--profile', profile, 'install'], {
|
|
290
|
-
cwd: profileDir(dshHome, profile), env: { ...process.env, DSH_HOME: dshHome }, encoding: 'utf8', timeout: 300000,
|
|
291
|
-
});
|
|
292
|
-
const out = `${r.stdout || ''}${r.stderr || ''}`.trim();
|
|
293
|
-
if (r.error || r.status !== 0) return { ok: false, status: r.status, message: out.slice(-500) || r.error?.message || 'install failed' };
|
|
294
|
-
return { ok: true, status: 0, tail: out.slice(-300) };
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
export function authPath(dshHome = dshHomeOf()) {
|
|
298
|
-
return join(dshHome, 'argent', 'auth.json');
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
/** 读取已登录账号 token(600 文件;不存在/损坏返回 null) */
|
|
302
|
-
export function readAuthToken(dshHome = dshHomeOf()) {
|
|
303
|
-
try {
|
|
304
|
-
const doc = JSON.parse(readFileSync(authPath(dshHome), 'utf8'));
|
|
305
|
-
return doc?.token ? doc : null;
|
|
306
|
-
} catch { return null; }
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
/** 发送短信验证码(首跑引导;开发环境服务端会返回 devCode) */
|
|
310
|
-
export async function sendLoginCode({ serverUrl, phone, fetchImpl = globalThis.fetch }) {
|
|
311
|
-
const base = String(serverUrl || '').replace(/\/+$/, '');
|
|
312
|
-
const res = await fetchImpl(`${base}/api/user/sms/send`, {
|
|
313
|
-
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ phone }),
|
|
314
|
-
});
|
|
315
|
-
const text = await res.text(); let data = null; try { data = JSON.parse(text); } catch { data = null; }
|
|
316
|
-
if (!res.ok) return { ok: false, code: 'send_failed', message: data?.error || `HTTP ${res.status}`, cooldown: data?.cooldown };
|
|
317
|
-
return { ok: true, cooldown: data?.cooldown, devCode: data?.devCode || data?.code || '' };
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
/** 手机号+验证码登录(保存 token 到 600 auth.json,供 pair 使用) */
|
|
321
|
-
export async function loginWithPhone({ serverUrl, phone, code, dshHome = dshHomeOf(), fetchImpl = globalThis.fetch }) {
|
|
322
|
-
const base = String(serverUrl || '').replace(/\/+$/, '');
|
|
323
|
-
const res = await fetchImpl(`${base}/api/user/login/phone`, {
|
|
324
|
-
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ phone, code }),
|
|
325
|
-
});
|
|
326
|
-
const text = await res.text(); let data = null; try { data = JSON.parse(text); } catch { data = null; }
|
|
327
|
-
const token = data?.token || data?.data?.token || '';
|
|
328
|
-
if (!res.ok || !token) return { ok: false, code: 'login_failed', message: data?.error || `HTTP ${res.status}` };
|
|
329
|
-
const path = authPath(dshHome);
|
|
330
|
-
mkdirSync(dirname(path), { recursive: true });
|
|
331
|
-
writeFileSync(path, JSON.stringify({ serverUrl: base, phone, token, savedAt: new Date().toISOString() }, null, 2) + '\n', { mode: 0o600 });
|
|
332
|
-
return { ok: true, path, phone, serverUrl: base, tokenTail: token.slice(-6) };
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
export function runnerConfigPath(dshHome = dshHomeOf()) {
|
|
336
|
-
return join(dshHome, 'argent', 'runner.json');
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
export function runnerScriptPath({ repo = repoRoot() } = {}) {
|
|
340
|
-
// 发布包内置 runner(0.1.3 起);本地仓库联调回退 scripts/argent-runner.mjs
|
|
341
|
-
const bundled = join(dirname(fileURLToPath(import.meta.url)), '..', 'runner', 'argent-runner.mjs');
|
|
342
|
-
if (existsSync(bundled)) return bundled;
|
|
343
|
-
const candidate = join(repo, 'scripts', 'argent-runner.mjs');
|
|
344
|
-
return existsSync(candidate) ? candidate : '';
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
/** P4 runner 的 launchd plist(macOS);Linux 可换 systemd unit(TODO 发布前补) */
|
|
348
|
-
export function buildLaunchdPlist({ dshHome = dshHomeOf(), repo = repoRoot(), nodeBin = process.execPath, runnerPath = '' } = {}) {
|
|
349
|
-
const script = runnerPath || runnerScriptPath({ repo });
|
|
350
|
-
const logDir = join(dshHome, 'argent', 'runner', 'logs');
|
|
351
|
-
return {
|
|
352
|
-
file: `${RUNNER_LABEL}.plist`,
|
|
353
|
-
content: `<?xml version="1.0" encoding="UTF-8"?>
|
|
354
|
-
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
355
|
-
<plist version="1.0">
|
|
356
|
-
<dict>
|
|
357
|
-
<key>Label</key><string>${RUNNER_LABEL}</string>
|
|
358
|
-
<key>ProgramArguments</key>
|
|
359
|
-
<array>
|
|
360
|
-
<string>${nodeBin}</string>
|
|
361
|
-
<string>${script || '<argent-runner.mjs>'}</string>
|
|
362
|
-
<string>--config</string>
|
|
363
|
-
<string>${runnerConfigPath(dshHome)}</string>
|
|
364
|
-
</array>
|
|
365
|
-
<key>RunAtLoad</key><true/>
|
|
366
|
-
<key>KeepAlive</key><true/>
|
|
367
|
-
<key>StandardOutPath</key><string>${join(logDir, 'launchd.out.log')}</string>
|
|
368
|
-
<key>StandardErrorPath</key><string>${join(logDir, 'launchd.err.log')}</string>
|
|
369
|
-
</dict>
|
|
370
|
-
</plist>
|
|
371
|
-
`,
|
|
372
|
-
label: RUNNER_LABEL,
|
|
373
|
-
script,
|
|
374
|
-
};
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
/** 状态自检(不启动任何东西;JSON 友好) */
|
|
378
|
-
/**
|
|
379
|
-
* 两构件对齐检查(方案 C · P3):`<profile>/argent.json` ⇄ 实际引擎 ⇄ 插件声明。
|
|
380
|
-
* ⛔ 只报告,不修;`problems` 为空才算对齐。
|
|
381
|
-
*/
|
|
382
|
-
export function contractStatus({ dshHome = dshHomeOf(), profile = PROFILE_NAME, repo = repoRoot() } = {}) {
|
|
383
|
-
const dir = profileDir(dshHome, profile);
|
|
384
|
-
const file = join(dir, 'argent.json');
|
|
385
|
-
let contract = null;
|
|
386
|
-
try { contract = JSON.parse(readFileSync(file, 'utf8')); } catch { /* 缺文件/坏 JSON 都算未建立 */ }
|
|
387
|
-
const info = engineInfo({ repo });
|
|
388
|
-
const expected = info.ok ? engineContract(info.dir) : null;
|
|
389
|
-
let plugin = null;
|
|
390
|
-
try {
|
|
391
|
-
const pkg = JSON.parse(readFileSync(join(dir, 'node_modules', '@whyshu', 'argent-desktop', 'package.json'), 'utf8'));
|
|
392
|
-
plugin = { version: pkg.version || '', requires: pkg.argentEngine || '', contract: pkg.argentContract ?? null };
|
|
393
|
-
} catch { /* 未装插件(web profile)属正常 */ }
|
|
394
|
-
const problems = [];
|
|
395
|
-
if (!contract) problems.push(`缺少 ${file}(运行 argent setup 生成)`);
|
|
396
|
-
else {
|
|
397
|
-
if (contract.contract == null) problems.push('argent.json 缺 contract');
|
|
398
|
-
else if (expected !== null && contract.contract !== expected) problems.push(`契约不符:argent.json=${contract.contract},引擎=${expected}`);
|
|
399
|
-
if (!contract.engineDir) problems.push('argent.json 未指向 engineDir');
|
|
400
|
-
else if (info.ok && contract.engineVersion !== info.version) problems.push(`引擎版本漂移:argent.json=${contract.engineVersion},实际=${info.version}`);
|
|
401
|
-
}
|
|
402
|
-
if (plugin && plugin.contract != null && expected !== null && plugin.contract !== expected) {
|
|
403
|
-
problems.push(`插件要求契约 ${plugin.contract},引擎提供 ${expected}`);
|
|
404
|
-
}
|
|
405
|
-
return { ok: problems.length === 0, file, contract, engine: info, plugin, expectedContract: expected, problems };
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
export function health({ dshHome = dshHomeOf(), profile = PROFILE_NAME, repo = repoRoot(), dshBin = 'dsh', port = 3080 } = {}) {
|
|
409
|
-
const version = probeDshVersion({ dshBin });
|
|
410
|
-
const dir = profileDir(dshHome, profile);
|
|
411
|
-
const profilePackage = join(dir, 'package.json');
|
|
412
|
-
const profilePatch = join(dir, 'cordis.patch.yml');
|
|
413
|
-
const runnerCfg = runnerConfigPath(dshHome);
|
|
414
|
-
const runnerCfgMode = existsSync(runnerCfg) ? (statSync(runnerCfg).mode & 0o777).toString(8) : '';
|
|
415
|
-
return {
|
|
416
|
-
ok: version.ok && versionMatches(version.version) && existsSync(profilePackage) && existsSync(profilePatch),
|
|
417
|
-
expectedDsh: DSH_EXACT_VERSION,
|
|
418
|
-
dsh: { ok: version.ok, version: version.ok ? version.version : '', matchesExact: version.ok && versionMatches(version.version), error: version.error },
|
|
419
|
-
profile: { dir, packageJson: existsSync(profilePackage), patch: existsSync(profilePatch) },
|
|
420
|
-
runner: { configPath: runnerCfg, configPresent: existsSync(runnerCfg), mode: runnerCfgMode },
|
|
421
|
-
webUrl: `http://127.0.0.1:${port}`,
|
|
422
|
-
contract: contractStatus({ dshHome, profile, repo }),
|
|
423
|
-
repo,
|
|
424
|
-
};
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
/**
|
|
428
|
-
* 首跑配对:把 runner 配置写到 `$DSH_HOME/argent/runner.json`(600)。
|
|
429
|
-
* 两种来源:
|
|
430
|
-
* · adminToken:调服务端 `POST /tenants/:appId/runner-tokens` 现场签发(90 天、绑定 appId);
|
|
431
|
-
* · runnerToken:直接使用已签发的 token(由管理员线下交付)。
|
|
432
|
-
* ⛔ 返回值/日志不回显 token;写盘前校验端口与响应。
|
|
433
|
-
*/
|
|
434
|
-
export async function pairRunner({
|
|
435
|
-
serverUrl, tenantAppId, adminToken = '', runnerToken = '',
|
|
436
|
-
runnerId = '', label = '', dshHome = dshHomeOf(), fetchImpl = globalThis.fetch,
|
|
437
|
-
}) {
|
|
438
|
-
const base = String(serverUrl || '').replace(/\/+$/, '');
|
|
439
|
-
const appId = String(tenantAppId || '').trim();
|
|
440
|
-
if (!base || !appId) return { ok: false, code: 'validation', message: 'serverUrl / tenantAppId 必填' };
|
|
441
|
-
if (!adminToken && !runnerToken) return { ok: false, code: 'validation', message: '需要 --admin-token 或 --runner-token 之一' };
|
|
442
|
-
let token = String(runnerToken || '').trim();
|
|
443
|
-
if (!token) {
|
|
444
|
-
const res = await fetchImpl(`${base}/api/argent/v1/tenants/${encodeURIComponent(appId)}/runner-tokens`, {
|
|
445
|
-
method: 'POST',
|
|
446
|
-
headers: { authorization: `Bearer ${String(adminToken).trim()}`, 'content-type': 'application/json' },
|
|
447
|
-
body: JSON.stringify({ label: String(label || 'argent-launcher') }),
|
|
448
|
-
});
|
|
449
|
-
const text = await res.text();
|
|
450
|
-
let data = null;
|
|
451
|
-
try { data = JSON.parse(text); } catch { data = null; }
|
|
452
|
-
if (!res.ok || !data?.data?.token) {
|
|
453
|
-
return { ok: false, code: 'issue_failed', message: `签发 runner token 失败:HTTP ${res.status} ${data?.error?.code || ''} ${data?.error?.message || ''}`.trim() };
|
|
454
|
-
}
|
|
455
|
-
token = data.data.token;
|
|
456
|
-
}
|
|
457
|
-
if (token.split('.').length !== 3) return { ok: false, code: 'token_shape', message: 'runner token 形态不正确(应为 JWT)' };
|
|
458
|
-
const cfgPath = runnerConfigPath(dshHome);
|
|
459
|
-
mkdirSync(dirname(cfgPath), { recursive: true });
|
|
460
|
-
const config = {
|
|
461
|
-
serverUrl: base, tenantAppId: appId, runnerToken: token,
|
|
462
|
-
runnerId: runnerId || `runner-${osHostname()}`,
|
|
463
|
-
leaseSec: 90, pollWaitMs: 20000, execTimeoutSec: 900,
|
|
464
|
-
};
|
|
465
|
-
writeFileSync(cfgPath, JSON.stringify(config, null, 2) + '\n', { mode: 0o600 });
|
|
466
|
-
return { ok: true, configPath: cfgPath, serverUrl: base, tenantAppId: appId, runnerId: config.runnerId, tokenTail: token.slice(-6) };
|
|
467
|
-
}
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
// ── 客户机 outbound 更新(npm 唯一制品源;2026-10-22)────────────────
|
|
471
|
-
/** 启动器包名与默认 registry。客户机只做出站 HTTPS;不依赖 /dl、SSH、组网。 */
|
|
472
|
-
export const LAUNCHER_PACKAGE = '@whyshu/argent'
|
|
473
|
-
export const DEFAULT_NPM_REGISTRY = 'https://registry.npmjs.org'
|
|
474
|
-
|
|
475
|
-
/** 读取启动器自身版本(发布包 / 本地仓库均适用)。 */
|
|
476
|
-
export function launcherVersion(launcherLib = dirname(fileURLToPath(import.meta.url))) {
|
|
477
|
-
try {
|
|
478
|
-
const pkg = JSON.parse(readFileSync(join(launcherLib, '..', 'package.json'), 'utf8'))
|
|
479
|
-
return String(pkg.version || '')
|
|
480
|
-
} catch { return '' }
|
|
481
|
-
}
|
|
482
|
-
|
|
483
|
-
/** 比较 x.y.z(支持 prerelease 简单后缀;返回 -1/0/1)。 */
|
|
484
|
-
export function compareVersions(a, b) {
|
|
485
|
-
const parse = (v) => String(v || '').trim().replace(/^v/, '').split('-')[0].split('.').map((x) => Number(x) || 0)
|
|
486
|
-
const pa = parse(a); const pb = parse(b)
|
|
487
|
-
for (let i = 0; i < Math.max(pa.length, pb.length); i += 1) {
|
|
488
|
-
const d = (pa[i] || 0) - (pb[i] || 0)
|
|
489
|
-
if (d) return d > 0 ? 1 : -1
|
|
490
|
-
}
|
|
491
|
-
return 0
|
|
492
|
-
}
|
|
493
|
-
|
|
494
|
-
/** 从 npm registry 读取 @whyshu/argent 的 latest 版本(公开包,无需 token)。 */
|
|
495
|
-
export async function latestLauncherVersion({
|
|
496
|
-
registry = process.env.ARGENT_NPM_REGISTRY || DEFAULT_NPM_REGISTRY,
|
|
497
|
-
packageName = LAUNCHER_PACKAGE,
|
|
498
|
-
fetchImpl = globalThis.fetch,
|
|
499
|
-
} = {}) {
|
|
500
|
-
const base = String(registry || DEFAULT_NPM_REGISTRY).replace(/\/+$/, '')
|
|
501
|
-
const encoded = String(packageName).replace('/', '%2F')
|
|
502
|
-
const url = `${base}/${encoded}/latest`
|
|
503
|
-
try {
|
|
504
|
-
const res = await fetchImpl(url, { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(15000) })
|
|
505
|
-
const text = await res.text()
|
|
506
|
-
let data = null
|
|
507
|
-
try { data = JSON.parse(text) } catch { data = null }
|
|
508
|
-
if (!res.ok) return { ok: false, code: 'registry_http', message: `registry HTTP ${res.status}`, url }
|
|
509
|
-
const version = String(data?.version || data?.['dist-tags']?.latest || '').trim()
|
|
510
|
-
if (!version) return { ok: false, code: 'registry_shape', message: 'registry 未返回 version', url }
|
|
511
|
-
return { ok: true, version, integrity: String(data?.dist?.integrity || ''), tarball: String(data?.dist?.tarball || ''), registry: base, url }
|
|
512
|
-
} catch (e) {
|
|
513
|
-
return { ok: false, code: 'registry_fetch', message: e?.message || String(e), url }
|
|
514
|
-
}
|
|
515
|
-
}
|
|
516
|
-
|
|
517
|
-
/** 读取已物化 runtime 的版本(没有则空串)。 */
|
|
518
|
-
export function readRuntimeVersion(target = join(homedir(), '.argent-dsh')) {
|
|
519
|
-
try {
|
|
520
|
-
const d = JSON.parse(readFileSync(join(target, 'RUNTIME_VERSION.json'), 'utf8'))
|
|
521
|
-
return String(d?.version || '')
|
|
522
|
-
} catch { return '' }
|
|
523
|
-
}
|
|
524
|
-
|
|
525
|
-
/** 读 profile 当前插件版本(用于判断“runtime 已是最新、但插件还是旧版”)。 */
|
|
526
|
-
export function readProfilePluginVersions({ dshHome = dshHomeOf(), profile = PROFILE_NAME } = {}) {
|
|
527
|
-
try {
|
|
528
|
-
const pkg = JSON.parse(readFileSync(join(profileDir(dshHome, profile), 'package.json'), 'utf8'))
|
|
529
|
-
const deps = pkg && typeof pkg.dependencies === 'object' ? pkg.dependencies : {}
|
|
530
|
-
const out = {}
|
|
531
|
-
for (const [name, ver] of Object.entries(deps)) {
|
|
532
|
-
if (!name.startsWith('@whyshu/argent-')) continue
|
|
533
|
-
const v = String(ver || '')
|
|
534
|
-
out[name] = v.startsWith('file:') ? '' : v
|
|
535
|
-
}
|
|
536
|
-
return out
|
|
537
|
-
} catch { return {} }
|
|
538
|
-
}
|
|
539
|
-
|
|
540
|
-
/** 检查更新(只读;不下载、不安装)。 */
|
|
541
|
-
export async function checkForUpdate({
|
|
542
|
-
launcherLib, dshHome = dshHomeOf(), profile = PROFILE_NAME,
|
|
543
|
-
target = join(homedir(), '.argent-dsh'), registry = process.env.ARGENT_NPM_REGISTRY || DEFAULT_NPM_REGISTRY,
|
|
544
|
-
fetchImpl = globalThis.fetch,
|
|
545
|
-
} = {}) {
|
|
546
|
-
const current = launcherVersion(launcherLib)
|
|
547
|
-
const remote = await latestLauncherVersion({ registry, fetchImpl })
|
|
548
|
-
const runtime = readRuntimeVersion(target)
|
|
549
|
-
const installedPlugins = readProfilePluginVersions({ dshHome, profile })
|
|
550
|
-
if (!remote.ok) return { ok: false, code: remote.code, message: remote.message, current, runtime, installedPlugins, registry }
|
|
551
|
-
const pluginUpdates = []
|
|
552
|
-
for (const [name, want] of Object.entries(PUBLISHED_BUNDLE_VERSIONS)) {
|
|
553
|
-
const have = installedPlugins[name]
|
|
554
|
-
// 本地 file: 装配(开发机)不算可更新客户机;只比较明确版本号。
|
|
555
|
-
if (!have || !want) continue
|
|
556
|
-
if (compareVersions(want, have) > 0) pluginUpdates.push({ name, current: have, latest: want })
|
|
557
|
-
}
|
|
558
|
-
const launcherUpdate = current ? compareVersions(remote.version, current) > 0 : true
|
|
559
|
-
return {
|
|
560
|
-
ok: true, current, latest: remote.version,
|
|
561
|
-
updateAvailable: Boolean(launcherUpdate || pluginUpdates.length),
|
|
562
|
-
launcherUpdate, pluginUpdates, installedPlugins,
|
|
563
|
-
registry: remote.registry, integrity: remote.integrity, tarball: remote.tarball,
|
|
564
|
-
runtime, runtimeTarget: target,
|
|
565
|
-
}
|
|
566
|
-
}
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
/** 解析客户机**真实的** dsh(npx 启动器的依赖里也带一个 dsh,PATH 会被 npx 改写,不能直接用裸 `dsh`)。 */
|
|
570
|
-
export function resolveDshBin({ env = process.env, home = homedir() } = {}) {
|
|
571
|
-
const explicit = String(env.ARGENT_DSH_BIN || env.DSH_BIN || '').trim()
|
|
572
|
-
if (explicit && existsSync(explicit)) return explicit
|
|
573
|
-
const pathEntries = String(env.PATH || '').split(':').filter(Boolean)
|
|
574
|
-
.filter((p) => !p.includes('_npx') && !p.replace(/\/+$/, '').endsWith('node_modules/.bin'))
|
|
575
|
-
for (const dir of pathEntries) {
|
|
576
|
-
const p = join(dir, process.platform === 'win32' ? 'dsh.cmd' : 'dsh')
|
|
577
|
-
if (existsSync(p)) return p
|
|
578
|
-
}
|
|
579
|
-
for (const p of [
|
|
580
|
-
join(home, '.local', 'node', 'bin', 'dsh'),
|
|
581
|
-
'/opt/homebrew/bin/dsh',
|
|
582
|
-
'/usr/local/bin/dsh',
|
|
583
|
-
]) if (existsSync(p)) return p
|
|
584
|
-
return 'dsh'
|
|
585
|
-
}
|
|
586
|
-
|
|
587
|
-
/** 更新 profile 里已存在的插件版本;不重建 profile、不丢 bundles/依赖结构。 */
|
|
588
|
-
export function updateProfilePluginVersions({
|
|
589
|
-
dshHome = dshHomeOf(), profile = PROFILE_NAME, versions = PUBLISHED_BUNDLE_VERSIONS,
|
|
590
|
-
} = {}) {
|
|
591
|
-
const file = join(profileDir(dshHome, profile), 'package.json')
|
|
592
|
-
let pkg = null
|
|
593
|
-
try { pkg = JSON.parse(readFileSync(file, 'utf8')) } catch { return { ok: false, code: 'profile_missing', message: `profile 不存在:${file}` } }
|
|
594
|
-
if (!pkg.dependencies || typeof pkg.dependencies !== 'object') return { ok: false, code: 'profile_shape', message: 'profile package.json 缺 dependencies' }
|
|
595
|
-
const changed = []
|
|
596
|
-
for (const [name, want] of Object.entries(versions || {})) {
|
|
597
|
-
const have = String(pkg.dependencies[name] || '')
|
|
598
|
-
if (!have || have.startsWith('file:') || have === want) continue
|
|
599
|
-
pkg.dependencies[name] = want
|
|
600
|
-
changed.push({ name, from: have, to: want })
|
|
601
|
-
}
|
|
602
|
-
if (changed.length) writeFileSync(file, JSON.stringify(pkg, null, 2) + '\n')
|
|
603
|
-
return { ok: true, file, changed }
|
|
604
|
-
}
|