create-principles-disciple 1.147.3 → 1.147.5
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/_release/product-identity.json +1 -1
- package/console/dist/server/routes/update.js +5 -8
- package/console/dist/web/assets/app.js +3 -3
- package/dist/i18n.d.ts.map +1 -1
- package/dist/i18n.js +6 -0
- package/dist/i18n.js.map +1 -1
- package/dist/installer.d.ts +21 -0
- package/dist/installer.d.ts.map +1 -1
- package/dist/installer.js +214 -26
- package/dist/installer.js.map +1 -1
- package/package.json +2 -2
- package/release-manager/dist/i18n.js +6 -0
- package/release-manager/dist/installer.js +214 -26
- package/release-manager/package.json +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-principles-disciple",
|
|
3
|
-
"version": "1.147.
|
|
3
|
+
"version": "1.147.5",
|
|
4
4
|
"description": "Interactive CLI installer for Principles Disciple OpenClaw plugin",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -101,5 +101,5 @@
|
|
|
101
101
|
"pd": {
|
|
102
102
|
"bundledPluginVersion": "2.5.0"
|
|
103
103
|
},
|
|
104
|
-
"gitHead": "
|
|
104
|
+
"gitHead": "29d6fbfd56adcc2d71280438b0bcaf8ee8ef3bd8"
|
|
105
105
|
}
|
|
@@ -105,10 +105,14 @@ const translations = {
|
|
|
105
105
|
// Install failure / rollback result strings (catch block). EP-11.
|
|
106
106
|
rollback_no_changes: 'No changes were made — the existing install was not modified (no backup was created).',
|
|
107
107
|
rollback_restored: 'Previous install has been restored.',
|
|
108
|
+
// PRI-1102: the rollback target itself can be incomplete — say so instead
|
|
109
|
+
// of reporting a restore that leaves the operator with a broken install.
|
|
110
|
+
rollback_restored_incomplete: 'Rolled back to the state that existed before this run, but that state is NOT usable — incomplete: {paths}.',
|
|
108
111
|
rollback_failed: 'CRITICAL: Rollback also failed — installation state is uncertain. {restoreError} Resolve manually: check {extDir} and {backupDir}',
|
|
109
112
|
next_no_changes_lock: 'A file lock blocked the backup (EPERM). Stop the OpenClaw gateway (openclaw gateway stop, or re-run with --stop-gateway) and retry.',
|
|
110
113
|
next_no_changes_other: 'Fix the error above and re-run the installer. The existing install was not modified.',
|
|
111
114
|
next_restored: 'Check the error above. Previous install has been restored. Fix the issue and re-run the installer.',
|
|
115
|
+
next_restored_incomplete: 'Check the error above. Rollback returned the pre-run state, but it is incomplete: {paths}. Re-run the installer so the missing components are rebuilt.',
|
|
112
116
|
next_restore_failed: 'Installation and rollback both failed. Check {extDir} and {backupDir} manually. Error: {errorMsg}',
|
|
113
117
|
// CP-6: fresh-install failure mid-deployment (no pre-existing install → no backup).
|
|
114
118
|
rollback_fresh_cleaned: 'The install failed before activation — the partially deployed runtime files were removed. Nothing else was modified.',
|
|
@@ -225,10 +229,12 @@ const translations = {
|
|
|
225
229
|
// 安装失败 / 回滚结果字符串(catch 块)。EP-11。
|
|
226
230
|
rollback_no_changes: '未做任何改动 —— 现有安装未被修改(未创建备份)。',
|
|
227
231
|
rollback_restored: '已恢复到之前的安装。',
|
|
232
|
+
rollback_restored_incomplete: '已回滚到本次运行之前的状态,但该状态本身不可用 —— 不完整:{paths}。',
|
|
228
233
|
rollback_failed: '严重:回滚也失败了 —— 安装状态不确定。{restoreError} 请手动处理:检查 {extDir} 和 {backupDir}',
|
|
229
234
|
next_no_changes_lock: '文件锁阻塞了备份 (EPERM)。请停止 OpenClaw gateway(openclaw gateway stop,或用 --stop-gateway 重新运行)后重试。',
|
|
230
235
|
next_no_changes_other: '请修复上方错误后重新运行安装器。现有安装未被修改。',
|
|
231
236
|
next_restored: '请检查上方错误。已恢复到之前的安装。修复问题后重新运行安装器。',
|
|
237
|
+
next_restored_incomplete: '请检查上方错误。回滚返回的是本次运行之前的状态,但该状态不完整:{paths}。请重新运行安装器,让它重建缺失的组件。',
|
|
232
238
|
next_restore_failed: '安装与回滚均失败。请手动检查 {extDir} 和 {backupDir}。错误:{errorMsg}',
|
|
233
239
|
// CP-6:全新安装部署中途失败(无既有安装 → 无备份)。
|
|
234
240
|
rollback_fresh_cleaned: '安装未激活即失败 —— 已移除本次部署的部分运行时文件。其余内容未被修改。',
|
|
@@ -1553,10 +1553,79 @@ export async function checkBuiltPlugin(pluginDir) {
|
|
|
1553
1553
|
}
|
|
1554
1554
|
}
|
|
1555
1555
|
}
|
|
1556
|
+
/**
|
|
1557
|
+
* PRI-1102: a target that exists as a DANGLING link (an interrupted install
|
|
1558
|
+
* left a reparse point whose target is gone) is not a directory the installer
|
|
1559
|
+
* can copy into. `existsSync` follows the link and reports "missing", so the
|
|
1560
|
+
* backup step never moves it, and `ensureDir` then treats it as present —
|
|
1561
|
+
* the follow-up mkdir fails with ENOENT and aborts the whole install.
|
|
1562
|
+
*
|
|
1563
|
+
* Absent targets are created. A dangling link is removed (the link itself only,
|
|
1564
|
+
* never a target), and the caller recreates the real directory. Everything
|
|
1565
|
+
* else — a real directory, or a link that still resolves — is left untouched,
|
|
1566
|
+
* so an existing install is never destroyed by this step.
|
|
1567
|
+
*/
|
|
1568
|
+
export async function preparePluginInstallTarget(targetDir) {
|
|
1569
|
+
let stats;
|
|
1570
|
+
try {
|
|
1571
|
+
stats = lstatSync(targetDir, { throwIfNoEntry: false });
|
|
1572
|
+
}
|
|
1573
|
+
catch (error) {
|
|
1574
|
+
throw new Error(`Cannot inspect plugin install target ${targetDir}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
1575
|
+
}
|
|
1576
|
+
if (stats !== undefined && stats.isSymbolicLink()) {
|
|
1577
|
+
let resolves = true;
|
|
1578
|
+
try {
|
|
1579
|
+
realpathSync(targetDir);
|
|
1580
|
+
}
|
|
1581
|
+
catch {
|
|
1582
|
+
resolves = false;
|
|
1583
|
+
}
|
|
1584
|
+
if (!resolves) {
|
|
1585
|
+
// fs-extra's remove unlinks the reparse point instead of traversing it.
|
|
1586
|
+
await fse.remove(targetDir);
|
|
1587
|
+
logger.warn(`Removed dangling link at ${targetDir} (target missing) — rebuilding the plugin install directory.`);
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
await fse.ensureDir(targetDir);
|
|
1591
|
+
}
|
|
1592
|
+
/**
|
|
1593
|
+
* PRI-1102: a rollback is only as good as the state it returns to. The
|
|
1594
|
+
* pre-run state can itself be incomplete (a component directory the previous
|
|
1595
|
+
* run never finished, a link whose target is gone), so "restored" must not be
|
|
1596
|
+
* reported as a healthy install without checking what the restored paths
|
|
1597
|
+
* actually resolve to.
|
|
1598
|
+
*/
|
|
1599
|
+
export function describeRestoredStateGaps() {
|
|
1600
|
+
const gaps = [];
|
|
1601
|
+
for (const dir of [getPluginExtDir(), getPdRuntimeDir()]) {
|
|
1602
|
+
let stats;
|
|
1603
|
+
try {
|
|
1604
|
+
stats = lstatSync(dir, { throwIfNoEntry: false });
|
|
1605
|
+
}
|
|
1606
|
+
catch (error) {
|
|
1607
|
+
gaps.push(`${dir} (unreadable: ${error instanceof Error ? error.message : String(error)})`);
|
|
1608
|
+
continue;
|
|
1609
|
+
}
|
|
1610
|
+
if (stats === undefined) {
|
|
1611
|
+
gaps.push(`${dir} (missing)`);
|
|
1612
|
+
continue;
|
|
1613
|
+
}
|
|
1614
|
+
if (stats.isSymbolicLink()) {
|
|
1615
|
+
try {
|
|
1616
|
+
realpathSync(dir);
|
|
1617
|
+
}
|
|
1618
|
+
catch {
|
|
1619
|
+
gaps.push(`${dir} (dangling link)`);
|
|
1620
|
+
}
|
|
1621
|
+
}
|
|
1622
|
+
}
|
|
1623
|
+
return gaps;
|
|
1624
|
+
}
|
|
1556
1625
|
export async function installPluginToStaging(pluginDir, language) {
|
|
1557
1626
|
const builtPluginDir = path.join(pluginDir, 'plugin');
|
|
1558
1627
|
for (const targetDir of pluginInstallDirs()) {
|
|
1559
|
-
await
|
|
1628
|
+
await preparePluginInstallTarget(targetDir);
|
|
1560
1629
|
// PRI-1037 payload fix: npm-era payloads carried a ~16k-file node_modules
|
|
1561
1630
|
// (the whole pi-ai/openai/anthropic tree, dragged in through the plugin's
|
|
1562
1631
|
// historical file:./core dependency) plus a `core` junction, in BOTH plugin
|
|
@@ -2187,6 +2256,101 @@ function installBundledCodexAdapter(pluginDir) {
|
|
|
2187
2256
|
rmSync(codexAdapterDest, { recursive: true, force: true });
|
|
2188
2257
|
cpSync(codexAdapterSrc, codexAdapterDest, { recursive: true });
|
|
2189
2258
|
}
|
|
2259
|
+
/**
|
|
2260
|
+
* P1: structural package-name validation. Unscoped names have exactly one
|
|
2261
|
+
* segment; scoped names exactly `@scope/pkg`. This is deliberately NOT one
|
|
2262
|
+
* regex: "." / ".." pass a character-class test, yet path.join() normalizes
|
|
2263
|
+
* them onto node_modules / the scope parent / the component root.
|
|
2264
|
+
*/
|
|
2265
|
+
function isSafeNameSegment(segment) {
|
|
2266
|
+
return segment.length > 0 && segment !== '.' && segment !== '..' && /^[A-Za-z0-9._-]+$/.test(segment);
|
|
2267
|
+
}
|
|
2268
|
+
function parseDependencyName(name) {
|
|
2269
|
+
const scoped = name.startsWith('@');
|
|
2270
|
+
const raw = (scoped ? name.slice(1) : name).split('/');
|
|
2271
|
+
if (scoped ? raw.length !== 2 : raw.length !== 1)
|
|
2272
|
+
return undefined;
|
|
2273
|
+
if (!raw.every(isSafeNameSegment))
|
|
2274
|
+
return undefined;
|
|
2275
|
+
if (!scoped)
|
|
2276
|
+
return raw;
|
|
2277
|
+
const [scope, packageName] = raw;
|
|
2278
|
+
return scope !== undefined && packageName !== undefined ? [`@${scope}`, packageName] : undefined;
|
|
2279
|
+
}
|
|
2280
|
+
/**
|
|
2281
|
+
* P1: `target` must be a STRICT descendant of `root` (never root itself, never
|
|
2282
|
+
* `..`, never an absolute escape). Applied to the lexical link path, the real
|
|
2283
|
+
* node_modules directory (which must stay inside the component) and any
|
|
2284
|
+
* symlinked ancestor we are about to create or delete through.
|
|
2285
|
+
*/
|
|
2286
|
+
function assertStrictlyInside(root, target, componentLabel) {
|
|
2287
|
+
const relative = path.relative(root, target);
|
|
2288
|
+
if (relative === '' || relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
|
2289
|
+
throw new Error(`Installed ${componentLabel} dependency link path escapes the install tree (${target} is not strictly inside ${root}).`);
|
|
2290
|
+
}
|
|
2291
|
+
}
|
|
2292
|
+
/** P1: an existing path used as a link parent must be a real directory, not a symlink. */
|
|
2293
|
+
function assertRealParentDirectory(dir, componentLabel) {
|
|
2294
|
+
let stat;
|
|
2295
|
+
try {
|
|
2296
|
+
stat = lstatSync(dir);
|
|
2297
|
+
}
|
|
2298
|
+
catch (error) {
|
|
2299
|
+
throw new Error(`Installed ${componentLabel} dependency link parent is missing (${dir}): ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
2300
|
+
}
|
|
2301
|
+
if (stat.isSymbolicLink()) {
|
|
2302
|
+
throw new Error(`Installed ${componentLabel} dependency link parent must not be a symbolic link (${dir}).`);
|
|
2303
|
+
}
|
|
2304
|
+
if (!stat.isDirectory()) {
|
|
2305
|
+
throw new Error(`Installed ${componentLabel} dependency link parent must be a directory (${dir}).`);
|
|
2306
|
+
}
|
|
2307
|
+
}
|
|
2308
|
+
/**
|
|
2309
|
+
* P1 two-phase planning: resolve + validate every `file:../` dependency and
|
|
2310
|
+
* its final link path without mutating anything. Only a fully valid plan is
|
|
2311
|
+
* returned, so an invalid later entry cannot leave earlier links half-applied.
|
|
2312
|
+
*/
|
|
2313
|
+
function planComponentFileDependencyLinks(componentDir, componentLabel, dependencies) {
|
|
2314
|
+
const runtimeDir = getPdRuntimeDir();
|
|
2315
|
+
const runtimeDirReal = realpathSync(runtimeDir);
|
|
2316
|
+
const nodeModulesDir = path.join(componentDir, 'node_modules');
|
|
2317
|
+
const operations = [];
|
|
2318
|
+
for (const [name, ref] of Object.entries(dependencies)) {
|
|
2319
|
+
if (typeof ref !== 'string' || !ref.startsWith('file:../'))
|
|
2320
|
+
continue;
|
|
2321
|
+
const siblingName = ref.slice('file:../'.length);
|
|
2322
|
+
// rc-1: only a single ordinary sibling segment; "." / ".." and separators
|
|
2323
|
+
// must not resolve the ref outside the runtime dir.
|
|
2324
|
+
if (!isSafeNameSegment(siblingName)) {
|
|
2325
|
+
throw new Error(`Installed ${componentLabel} declares an unsupported file dependency ref "${ref}" for ${name}.`);
|
|
2326
|
+
}
|
|
2327
|
+
const segments = parseDependencyName(name);
|
|
2328
|
+
if (segments === undefined) {
|
|
2329
|
+
throw new Error(`Installed ${componentLabel} declares an unsupported dependency name "${name}".`);
|
|
2330
|
+
}
|
|
2331
|
+
const siblingDir = path.join(runtimeDir, siblingName);
|
|
2332
|
+
if (!existsSync(siblingDir)) {
|
|
2333
|
+
throw new Error(`${componentLabel} depends on ${ref} but the sibling runtime component is missing: ${siblingDir}`);
|
|
2334
|
+
}
|
|
2335
|
+
const siblingReal = realpathSync(siblingDir);
|
|
2336
|
+
// The sibling must resolve to exactly <runtime>/<siblingName>: a symlink
|
|
2337
|
+
// that redirects the link target outside the runtime is not a sibling.
|
|
2338
|
+
if (path.relative(runtimeDirReal, siblingReal) !== siblingName) {
|
|
2339
|
+
throw new Error(`Installed ${componentLabel} declares a file dependency ref that resolves outside the runtime dir: "${ref}".`);
|
|
2340
|
+
}
|
|
2341
|
+
const linkPath = path.join(nodeModulesDir, ...segments);
|
|
2342
|
+
// Lexical boundary + segment-count: path.join() collapses "." / ".." and
|
|
2343
|
+
// repeated separators, so the normalized path must still have exactly the
|
|
2344
|
+
// declared number of segments and be a strict descendant of node_modules.
|
|
2345
|
+
const relative = path.relative(nodeModulesDir, linkPath);
|
|
2346
|
+
if (relative.split(path.sep).length !== segments.length) {
|
|
2347
|
+
throw new Error(`Installed ${componentLabel} declares an unsupported dependency name "${name}".`);
|
|
2348
|
+
}
|
|
2349
|
+
assertStrictlyInside(nodeModulesDir, linkPath, componentLabel);
|
|
2350
|
+
operations.push({ segments, siblingReal, linkPath });
|
|
2351
|
+
}
|
|
2352
|
+
return operations;
|
|
2353
|
+
}
|
|
2190
2354
|
/**
|
|
2191
2355
|
* PRI-711 (generalized for ADR-0020 host adapters): materialize node_modules
|
|
2192
2356
|
* links for an INSTALLED runtime component so its declared `file:../<component>`
|
|
@@ -2213,26 +2377,40 @@ function ensureComponentFileDependencyLinks(componentDir, componentLabel) {
|
|
|
2213
2377
|
if (manifest.dependencies !== undefined && !isRecord(manifest.dependencies)) {
|
|
2214
2378
|
throw new Error(`Installed ${componentLabel} package.json dependencies must be an object (${manifestPath}).`);
|
|
2215
2379
|
}
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
|
|
2221
|
-
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
if (
|
|
2233
|
-
|
|
2380
|
+
// P1: validate EVERYTHING before touching the filesystem. A manifest is
|
|
2381
|
+
// untrusted input, and a name that normalizes to node_modules / the scope
|
|
2382
|
+
// parent / the component root must never reach a recursive delete.
|
|
2383
|
+
const operations = planComponentFileDependencyLinks(componentDir, componentLabel, manifest.dependencies ?? {});
|
|
2384
|
+
if (operations.length === 0)
|
|
2385
|
+
return;
|
|
2386
|
+
const componentDirReal = realpathSync(componentDir);
|
|
2387
|
+
const nodeModulesDir = path.join(componentDir, 'node_modules');
|
|
2388
|
+
// P1: node_modules itself must be a real directory inside the component —
|
|
2389
|
+
// a symlinked parent would redirect every link/delete outside the install.
|
|
2390
|
+
mkdirSync(nodeModulesDir, { recursive: true });
|
|
2391
|
+
assertRealParentDirectory(nodeModulesDir, componentLabel);
|
|
2392
|
+
const nodeModulesReal = realpathSync(nodeModulesDir);
|
|
2393
|
+
assertStrictlyInside(componentDirReal, nodeModulesReal, componentLabel);
|
|
2394
|
+
for (const operation of operations) {
|
|
2395
|
+
const { segments, siblingReal, linkPath } = operation;
|
|
2396
|
+
if (segments.length === 2) {
|
|
2397
|
+
// scoped parent: create only when absent; never replace an existing
|
|
2398
|
+
// real directory (npm may legitimately share it) and never follow a
|
|
2399
|
+
// symlinked parent.
|
|
2400
|
+
const scopeSegment = segments[0];
|
|
2401
|
+
const scopeDir = path.join(nodeModulesDir, scopeSegment);
|
|
2402
|
+
let scopeStat;
|
|
2403
|
+
try {
|
|
2404
|
+
scopeStat = lstatSync(scopeDir);
|
|
2405
|
+
}
|
|
2406
|
+
catch {
|
|
2407
|
+
scopeStat = undefined;
|
|
2408
|
+
}
|
|
2409
|
+
if (scopeStat === undefined)
|
|
2410
|
+
mkdirSync(scopeDir);
|
|
2411
|
+
assertRealParentDirectory(scopeDir, componentLabel);
|
|
2412
|
+
assertStrictlyInside(nodeModulesReal, realpathSync(scopeDir), componentLabel);
|
|
2234
2413
|
}
|
|
2235
|
-
const linkPath = path.join(componentDir, 'node_modules', name);
|
|
2236
2414
|
let stat;
|
|
2237
2415
|
try {
|
|
2238
2416
|
stat = lstatSync(linkPath);
|
|
@@ -2243,21 +2421,23 @@ function ensureComponentFileDependencyLinks(componentDir, componentLabel) {
|
|
|
2243
2421
|
if (stat !== undefined) {
|
|
2244
2422
|
if (stat.isSymbolicLink()) {
|
|
2245
2423
|
try {
|
|
2246
|
-
if (realpathSync(linkPath) ===
|
|
2424
|
+
if (realpathSync(linkPath) === siblingReal)
|
|
2247
2425
|
continue;
|
|
2248
2426
|
}
|
|
2249
2427
|
catch {
|
|
2250
2428
|
// Unreadable link target — fall through to replace.
|
|
2251
2429
|
}
|
|
2252
2430
|
}
|
|
2431
|
+
// linkPath was proven to be a strict descendant of node_modules during
|
|
2432
|
+
// planning, and both parent levels were re-verified as real directories
|
|
2433
|
+
// above, so the only recursive delete stays inside the component tree.
|
|
2253
2434
|
rmSync(linkPath, { recursive: stat.isDirectory() && !stat.isSymbolicLink(), force: true });
|
|
2254
2435
|
}
|
|
2255
|
-
mkdirSync(path.dirname(linkPath), { recursive: true });
|
|
2256
2436
|
if (isWindows()) {
|
|
2257
|
-
symlinkSync(
|
|
2437
|
+
symlinkSync(siblingReal, linkPath, 'junction');
|
|
2258
2438
|
}
|
|
2259
2439
|
else {
|
|
2260
|
-
symlinkSync(path.relative(path.dirname(linkPath),
|
|
2440
|
+
symlinkSync(path.relative(path.dirname(linkPath), siblingReal), linkPath, 'dir');
|
|
2261
2441
|
}
|
|
2262
2442
|
}
|
|
2263
2443
|
}
|
|
@@ -4244,6 +4424,10 @@ transaction) {
|
|
|
4244
4424
|
// pre-date this run, so it is NOT wholesale-removed. Report its retention
|
|
4245
4425
|
// honestly instead of claiming "nothing else was modified".
|
|
4246
4426
|
const workspaceTouched = !hasBackup && mutationStarted && journal?.lastState !== 'planned';
|
|
4427
|
+
// PRI-1102: `restored` describes the rollback operation, not the health of
|
|
4428
|
+
// the state it returned to. Name the gaps so "restored" is never read as
|
|
4429
|
+
// "usable install".
|
|
4430
|
+
const restoredGaps = hasBackup && restoreResult.restored ? describeRestoredStateGaps() : [];
|
|
4247
4431
|
const rollbackSuffix = !hasBackup
|
|
4248
4432
|
? freshCleanup
|
|
4249
4433
|
? freshCleanup.failed.length === 0
|
|
@@ -4251,7 +4435,9 @@ transaction) {
|
|
|
4251
4435
|
: t('rollback_fresh_clean_failed').replace('{dirs}', freshCleanup.failed.map((f) => f.dir).join(', '))
|
|
4252
4436
|
: t('rollback_no_changes')
|
|
4253
4437
|
: restoreResult.restored
|
|
4254
|
-
?
|
|
4438
|
+
? restoredGaps.length === 0
|
|
4439
|
+
? t('rollback_restored')
|
|
4440
|
+
: t('rollback_restored_incomplete').replace('{paths}', restoredGaps.join(', '))
|
|
4255
4441
|
: t('rollback_failed')
|
|
4256
4442
|
.replace('{restoreError}', restoreResult.error ?? '')
|
|
4257
4443
|
.replace('{extDir}', extDir)
|
|
@@ -4268,7 +4454,9 @@ transaction) {
|
|
|
4268
4454
|
? t('next_no_changes_lock')
|
|
4269
4455
|
: t('next_no_changes_other'))
|
|
4270
4456
|
: restoreResult.restored
|
|
4271
|
-
?
|
|
4457
|
+
? restoredGaps.length === 0
|
|
4458
|
+
? t('next_restored')
|
|
4459
|
+
: t('next_restored_incomplete').replace('{paths}', restoredGaps.join(', '))
|
|
4272
4460
|
: t('next_restore_failed')
|
|
4273
4461
|
.replace('{extDir}', extDir)
|
|
4274
4462
|
.replace('{backupDir}', backupDir ?? runtimeBackupDir ?? '')
|