@jackss119/shelf 1.0.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/LICENSE +21 -0
- package/README.md +44 -0
- package/bin/shelf.mjs +78 -0
- package/lib/commands/shelf.mjs +953 -0
- package/lib/manifest.mjs +62 -0
- package/lib/paths.mjs +22 -0
- package/lib/prompt.mjs +17 -0
- package/lib/shelfnames.mjs +69 -0
- package/lib/transport.mjs +268 -0
- package/lib/version.mjs +33 -0
- package/package.json +40 -0
|
@@ -0,0 +1,953 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import readline from "node:readline/promises";
|
|
5
|
+
import { stdin, stdout } from "node:process";
|
|
6
|
+
import { spawnSync } from "node:child_process";
|
|
7
|
+
import {
|
|
8
|
+
resolveShelfContext,
|
|
9
|
+
headCommit,
|
|
10
|
+
remoteUrl,
|
|
11
|
+
commitAndPush,
|
|
12
|
+
refreshManagedHome,
|
|
13
|
+
managedHomeDir,
|
|
14
|
+
DEFAULT_REMOTE,
|
|
15
|
+
} from "../transport.mjs";
|
|
16
|
+
import { displayName, displayPath, resolveShelfPath, findByBasename } from "../shelfnames.mjs";
|
|
17
|
+
import { contentHash } from "../version.mjs";
|
|
18
|
+
import {
|
|
19
|
+
loadManifest,
|
|
20
|
+
saveManifest,
|
|
21
|
+
setShelfEntry,
|
|
22
|
+
findShelfEntryByLocalPath,
|
|
23
|
+
} from "../manifest.mjs";
|
|
24
|
+
import { choose } from "../prompt.mjs";
|
|
25
|
+
|
|
26
|
+
const IGNORE_NAMES = new Set(["node_modules", ".git", ".DS_Store"]);
|
|
27
|
+
const INTERACTIVE = stdin.isTTY === true;
|
|
28
|
+
|
|
29
|
+
// 非 TTY(agent/脚本驱动)时不能挂在交互提问上:pull 冲突自动选安全项
|
|
30
|
+
async function safeAsk(question, choices) {
|
|
31
|
+
if (INTERACTIVE) return choose(question, choices);
|
|
32
|
+
const fallback = choices.some((c) => c.key === "k") ? "k" : "s";
|
|
33
|
+
console.log(`${question}→ 非交互环境,自动选 ${fallback}(安全项)`);
|
|
34
|
+
return fallback;
|
|
35
|
+
}
|
|
36
|
+
const SECRET_PATTERNS = [/^\.env(\..+)?$/i, /\.key$/i, /\.pem$/i, /^auth\.json$/i, /^credentials/i];
|
|
37
|
+
const BIG_FILE_BYTES = 50 * 1024 * 1024;
|
|
38
|
+
|
|
39
|
+
function todayISO() {
|
|
40
|
+
return new Date().toISOString().slice(0, 10);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function toPosix(p) {
|
|
44
|
+
return p.replaceAll("\\", "/");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function fmtSize(bytes) {
|
|
48
|
+
if (bytes < 1024) return `${bytes}B`;
|
|
49
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
|
|
50
|
+
return `${(bytes / 1024 / 1024).toFixed(1)}MB`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ---- 文件枚举与复制(统一忽略 IGNORE_NAMES)----
|
|
54
|
+
|
|
55
|
+
function listFilesRecursive(target, base = target) {
|
|
56
|
+
const st = fs.statSync(target);
|
|
57
|
+
if (st.isFile()) return [{ rel: path.basename(target), abs: target, size: st.size }];
|
|
58
|
+
const out = [];
|
|
59
|
+
for (const entry of fs.readdirSync(target, { withFileTypes: true })) {
|
|
60
|
+
if (IGNORE_NAMES.has(entry.name)) continue;
|
|
61
|
+
const full = path.join(target, entry.name);
|
|
62
|
+
if (entry.isDirectory()) out.push(...listFilesRecursive(full, base));
|
|
63
|
+
else if (entry.isFile()) {
|
|
64
|
+
out.push({ rel: toPosix(path.relative(base, full)), abs: full, size: entry.isFile() ? fs.statSync(full).size : 0 });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function copyFiltered(src, dest) {
|
|
71
|
+
fs.cpSync(src, dest, {
|
|
72
|
+
recursive: true,
|
|
73
|
+
filter: (source) => !IGNORE_NAMES.has(path.basename(source)),
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function fileHashMap(target) {
|
|
78
|
+
const map = new Map();
|
|
79
|
+
const st = fs.statSync(target);
|
|
80
|
+
if (st.isFile()) {
|
|
81
|
+
map.set(path.basename(target), contentHash(target));
|
|
82
|
+
return map;
|
|
83
|
+
}
|
|
84
|
+
for (const f of listFilesRecursive(target)) map.set(f.rel, contentHash(f.abs));
|
|
85
|
+
return map;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function diffSummary(fromTarget, toTarget) {
|
|
89
|
+
const a = fs.existsSync(fromTarget) ? fileHashMap(fromTarget) : new Map();
|
|
90
|
+
const b = fs.existsSync(toTarget) ? fileHashMap(toTarget) : new Map();
|
|
91
|
+
const added = [...b.keys()].filter((k) => !a.has(k));
|
|
92
|
+
const removed = [...a.keys()].filter((k) => !b.has(k));
|
|
93
|
+
const changed = [...b.keys()].filter((k) => a.has(k) && a.get(k) !== b.get(k));
|
|
94
|
+
return { added, removed, changed };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ---- 目录条目 ----
|
|
98
|
+
|
|
99
|
+
function listEntries(absDir) {
|
|
100
|
+
const entries = fs.readdirSync(absDir, { withFileTypes: true })
|
|
101
|
+
.filter((e) => !IGNORE_NAMES.has(e.name))
|
|
102
|
+
.map((e) => {
|
|
103
|
+
const full = path.join(absDir, e.name);
|
|
104
|
+
if (e.isDirectory()) {
|
|
105
|
+
const items = fs.readdirSync(full).filter((n) => !IGNORE_NAMES.has(n)).length;
|
|
106
|
+
return { name: e.name, display: displayName(e.name), isDir: true, info: `${items} 项` };
|
|
107
|
+
}
|
|
108
|
+
return { name: e.name, display: displayName(e.name), isDir: false, info: fmtSize(fs.statSync(full).size) };
|
|
109
|
+
});
|
|
110
|
+
entries.sort((x, y) => (x.isDir === y.isDir ? x.display.localeCompare(y.display) : x.isDir ? -1 : 1));
|
|
111
|
+
return entries;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// "1,3-5" → 0 基索引数组;非法输入返回 null
|
|
115
|
+
function parseIndices(spec, max) {
|
|
116
|
+
const out = new Set();
|
|
117
|
+
for (const part of spec.split(",").map((s) => s.trim()).filter(Boolean)) {
|
|
118
|
+
const range = part.match(/^(\d+)-(\d+)$/);
|
|
119
|
+
const single = part.match(/^(\d+)$/);
|
|
120
|
+
if (range) {
|
|
121
|
+
const [a, b] = [Number(range[1]), Number(range[2])];
|
|
122
|
+
if (a < 1 || b > max || a > b) return null;
|
|
123
|
+
for (let i = a; i <= b; i++) out.add(i - 1);
|
|
124
|
+
} else if (single) {
|
|
125
|
+
const n = Number(single[1]);
|
|
126
|
+
if (n < 1 || n > max) return null;
|
|
127
|
+
out.add(n - 1);
|
|
128
|
+
} else return null;
|
|
129
|
+
}
|
|
130
|
+
return [...out].sort((a, b) => a - b);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ---- pull ----
|
|
134
|
+
|
|
135
|
+
function buildShelfEntry(ctx, srcAbs, destAbs) {
|
|
136
|
+
return {
|
|
137
|
+
sourceCommit: headCommit(ctx.root),
|
|
138
|
+
contentHash: contentHash(srcAbs),
|
|
139
|
+
pulledAt: todayISO(),
|
|
140
|
+
localPath: toPosix(path.relative(process.cwd(), destAbs)),
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function pullEntry(ctx, realRel, manifest, counters, ask, destOverride = null) {
|
|
145
|
+
const src = path.join(ctx.shelfDir, ...realRel.split("/"));
|
|
146
|
+
const dest = destOverride ?? path.join(process.cwd(), path.basename(realRel));
|
|
147
|
+
const shown = displayPath(realRel);
|
|
148
|
+
|
|
149
|
+
if (!fs.existsSync(dest)) {
|
|
150
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
151
|
+
copyFiltered(src, dest);
|
|
152
|
+
setShelfEntry(manifest, realRel, buildShelfEntry(ctx, src, dest));
|
|
153
|
+
console.log(`✓ pulled ${shown}`);
|
|
154
|
+
counters.pulled++;
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const recorded = manifest.shelf[realRel];
|
|
159
|
+
const upstreamHash = contentHash(src);
|
|
160
|
+
const localHash = contentHash(dest);
|
|
161
|
+
const upstreamChanged = !recorded || recorded.contentHash !== upstreamHash;
|
|
162
|
+
const localChanged = !recorded || recorded.contentHash !== localHash;
|
|
163
|
+
|
|
164
|
+
if (!upstreamChanged && !localChanged) {
|
|
165
|
+
console.log(`= up to date ${shown}`);
|
|
166
|
+
counters.upToDate++;
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
let prompt;
|
|
171
|
+
if (upstreamChanged && !localChanged) {
|
|
172
|
+
prompt = `↑ ${shown} 货架有更新。[u]pdate / [s]kip / [q]uit? `;
|
|
173
|
+
} else if (!upstreamChanged && localChanged) {
|
|
174
|
+
prompt = `! ${shown} 本地有改动,货架无更新。[k]eep / [o]verwrite / [q]uit? `;
|
|
175
|
+
} else {
|
|
176
|
+
prompt = `⚠ ${shown} 货架和本地都改过。[u]pdate(覆盖本地) / [s]kip / [q]uit? `;
|
|
177
|
+
}
|
|
178
|
+
const keys = prompt.includes("[k]eep")
|
|
179
|
+
? [{ key: "k" }, { key: "o" }, { key: "q" }]
|
|
180
|
+
: [{ key: "u" }, { key: "s" }, { key: "q" }];
|
|
181
|
+
const action = await ask(prompt, keys);
|
|
182
|
+
|
|
183
|
+
if (action === "u" || action === "o") {
|
|
184
|
+
fs.rmSync(dest, { recursive: true, force: true });
|
|
185
|
+
copyFiltered(src, dest);
|
|
186
|
+
setShelfEntry(manifest, realRel, buildShelfEntry(ctx, src, dest));
|
|
187
|
+
console.log(`✓ updated ${shown}`);
|
|
188
|
+
counters.updated++;
|
|
189
|
+
} else if (action === "s" || action === "k") {
|
|
190
|
+
console.log(`- skipped ${shown}`);
|
|
191
|
+
counters.skipped++;
|
|
192
|
+
} else {
|
|
193
|
+
counters.quit = true;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function newCounters() {
|
|
198
|
+
return { pulled: 0, updated: 0, skipped: 0, upToDate: 0, quit: false };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function printSummary(c) {
|
|
202
|
+
console.log("");
|
|
203
|
+
console.log(
|
|
204
|
+
`Summary: ${c.pulled} pulled, ${c.updated} updated, ${c.skipped} skipped, ${c.upToDate} up-to-date` +
|
|
205
|
+
(c.quit ? " (quit early)" : ""),
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ---- 子命令:非交互 pull ----
|
|
210
|
+
|
|
211
|
+
export async function cmdShelfPull(args) {
|
|
212
|
+
const destFlag = args.indexOf("--dest");
|
|
213
|
+
let destRoot = null;
|
|
214
|
+
if (destFlag !== -1) {
|
|
215
|
+
destRoot = path.resolve(process.cwd(), args[destFlag + 1] ?? "");
|
|
216
|
+
args = args.filter((_, i) => i !== destFlag && i !== destFlag + 1);
|
|
217
|
+
}
|
|
218
|
+
if (args.length === 0) {
|
|
219
|
+
console.error("用法: shelf pull <shelf路径> [...] [--dest <目录>]");
|
|
220
|
+
process.exit(1);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const ctx = resolveShelfContext();
|
|
224
|
+
try {
|
|
225
|
+
const manifest = loadManifest();
|
|
226
|
+
manifest.source ??= remoteUrl(ctx.root) || toPosix(ctx.root);
|
|
227
|
+
const counters = newCounters();
|
|
228
|
+
for (const input of args) {
|
|
229
|
+
if (counters.quit) break;
|
|
230
|
+
const hit = resolveShelfPath(ctx.shelfDir, input);
|
|
231
|
+
if (!hit) {
|
|
232
|
+
console.error(`✗ 货架上没有 '${input}'`);
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
const destOverride = destRoot ? path.join(destRoot, path.basename(hit.realRel)) : null;
|
|
236
|
+
await pullEntry(ctx, hit.realRel, manifest, counters, safeAsk, destOverride);
|
|
237
|
+
}
|
|
238
|
+
saveManifest(manifest);
|
|
239
|
+
printSummary(counters);
|
|
240
|
+
} finally {
|
|
241
|
+
ctx.cleanup();
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ---- 子命令:交互浏览器 ----
|
|
246
|
+
|
|
247
|
+
// readline 在管道输入下会丢弃"无人等待时"到达的行;自带队列保证脚本化驱动可用,EOF 返回 null
|
|
248
|
+
function makeLineReader() {
|
|
249
|
+
const rl = readline.createInterface({ input: stdin, output: stdout });
|
|
250
|
+
const queue = [];
|
|
251
|
+
const waiters = [];
|
|
252
|
+
let closed = false;
|
|
253
|
+
rl.on("line", (line) => {
|
|
254
|
+
if (waiters.length) waiters.shift()(line);
|
|
255
|
+
else queue.push(line);
|
|
256
|
+
});
|
|
257
|
+
rl.on("close", () => {
|
|
258
|
+
closed = true;
|
|
259
|
+
while (waiters.length) waiters.shift()(null);
|
|
260
|
+
});
|
|
261
|
+
return {
|
|
262
|
+
async question(prompt) {
|
|
263
|
+
if (queue.length) {
|
|
264
|
+
const line = queue.shift();
|
|
265
|
+
stdout.write(prompt + line + "\n");
|
|
266
|
+
return line;
|
|
267
|
+
}
|
|
268
|
+
if (closed) return null;
|
|
269
|
+
stdout.write(prompt);
|
|
270
|
+
return new Promise((resolve) => waiters.push(resolve));
|
|
271
|
+
},
|
|
272
|
+
close() {
|
|
273
|
+
rl.close();
|
|
274
|
+
},
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export async function cmdShelfBrowse() {
|
|
279
|
+
const ctx = resolveShelfContext();
|
|
280
|
+
const rl = makeLineReader();
|
|
281
|
+
const askWithRl = async (question, choices) => {
|
|
282
|
+
const keys = choices.map((c) => c.key.toLowerCase());
|
|
283
|
+
while (true) {
|
|
284
|
+
const raw = await rl.question(question);
|
|
285
|
+
if (raw === null) return "q";
|
|
286
|
+
const ans = raw.trim().toLowerCase();
|
|
287
|
+
if (keys.includes(ans)) return ans;
|
|
288
|
+
stdout.write(` please type one of: ${keys.join(", ")}\n`);
|
|
289
|
+
}
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
try {
|
|
293
|
+
const manifest = loadManifest();
|
|
294
|
+
manifest.source ??= remoteUrl(ctx.root) || toPosix(ctx.root);
|
|
295
|
+
const segs = [];
|
|
296
|
+
|
|
297
|
+
while (true) {
|
|
298
|
+
const absDir = path.join(ctx.shelfDir, ...segs);
|
|
299
|
+
const entries = listEntries(absDir);
|
|
300
|
+
const here = segs.length ? displayPath(segs.join("/")) : "";
|
|
301
|
+
|
|
302
|
+
console.log("");
|
|
303
|
+
console.log(`shelf:/${here}`);
|
|
304
|
+
if (entries.length === 0) console.log(" (空)");
|
|
305
|
+
entries.forEach((e, i) => {
|
|
306
|
+
console.log(` ${String(i + 1).padStart(2)}. ${e.isDir ? e.display + "/" : e.display} (${e.info})`);
|
|
307
|
+
});
|
|
308
|
+
console.log(" [数字]=进入目录 · p 1,3-5=拉取所选 · a=全部拉取 · ..=上级 · q=退出");
|
|
309
|
+
|
|
310
|
+
const raw = await rl.question("> ");
|
|
311
|
+
if (raw === null) break;
|
|
312
|
+
const ans = raw.trim();
|
|
313
|
+
if (ans === "q") break;
|
|
314
|
+
if (ans === "..") {
|
|
315
|
+
segs.pop();
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
if (/^\d+$/.test(ans)) {
|
|
319
|
+
const idx = Number(ans) - 1;
|
|
320
|
+
if (idx < 0 || idx >= entries.length) {
|
|
321
|
+
console.log(" 无此编号");
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
if (!entries[idx].isDir) {
|
|
325
|
+
console.log(` '${entries[idx].display}' 是文件,用 p ${ans} 拉取`);
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
segs.push(entries[idx].name);
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
const pullMatch = ans.match(/^(?:p\s+(.+)|a)$/);
|
|
332
|
+
if (pullMatch) {
|
|
333
|
+
const indices = ans === "a"
|
|
334
|
+
? entries.map((_, i) => i)
|
|
335
|
+
: parseIndices(pullMatch[1], entries.length);
|
|
336
|
+
if (!indices || indices.length === 0) {
|
|
337
|
+
console.log(" 选择无效,例如: p 1,3-5");
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
const counters = newCounters();
|
|
341
|
+
for (const i of indices) {
|
|
342
|
+
if (counters.quit) break;
|
|
343
|
+
const realRel = [...segs, entries[i].name].join("/");
|
|
344
|
+
await pullEntry(ctx, realRel, manifest, counters, askWithRl);
|
|
345
|
+
}
|
|
346
|
+
saveManifest(manifest);
|
|
347
|
+
printSummary(counters);
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
console.log(" 没看懂。数字进入目录,p 加编号拉取,a 全部,.. 上级,q 退出");
|
|
351
|
+
}
|
|
352
|
+
} finally {
|
|
353
|
+
rl.close();
|
|
354
|
+
ctx.cleanup();
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// ---- 上架/更新共用 ----
|
|
359
|
+
|
|
360
|
+
function takeFlag(args, name, hasValue = false) {
|
|
361
|
+
const i = args.indexOf(name);
|
|
362
|
+
if (i === -1) return { args, value: undefined };
|
|
363
|
+
const value = hasValue ? args[i + 1] : true;
|
|
364
|
+
return { args: args.filter((_, j) => j !== i && (!hasValue || j !== i + 1)), value };
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function guardScan(localAbs) {
|
|
368
|
+
const files = listFilesRecursive(localAbs);
|
|
369
|
+
return {
|
|
370
|
+
secrets: files.filter((f) => SECRET_PATTERNS.some((re) => re.test(path.basename(f.rel)))),
|
|
371
|
+
bigs: files.filter((f) => f.size > BIG_FILE_BYTES),
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// 凭据/大文件安全阀;违规直接退出
|
|
376
|
+
function guardFiles(localAbs, forceSecret) {
|
|
377
|
+
const { secrets, bigs } = guardScan(localAbs);
|
|
378
|
+
if (secrets.length > 0 && !forceSecret) {
|
|
379
|
+
console.error(`✗ 疑似凭据文件,已拒绝(--force-secret 可放行):`);
|
|
380
|
+
for (const s of secrets) console.error(` ${s.rel}`);
|
|
381
|
+
process.exit(1);
|
|
382
|
+
}
|
|
383
|
+
for (const b of bigs) {
|
|
384
|
+
console.warn(`! 大文件 ${b.rel} (${fmtSize(b.size)}),GitHub 单文件上限 100MB`);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function printChangeList(targetAbs, localAbs, shown) {
|
|
389
|
+
const d = diffSummary(targetAbs, localAbs);
|
|
390
|
+
const total = d.added.length + d.removed.length + d.changed.length;
|
|
391
|
+
console.log(`将写入 shelf/${shown}:新增 ${d.added.length} / 删除 ${d.removed.length} / 修改 ${d.changed.length}`);
|
|
392
|
+
for (const f of d.added) console.log(` + ${f}`);
|
|
393
|
+
for (const f of d.removed) console.log(` - ${f}`);
|
|
394
|
+
for (const f of d.changed) console.log(` ~ ${f}`);
|
|
395
|
+
return total;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
async function confirmOrExit(promptText, yes) {
|
|
399
|
+
if (yes) return true;
|
|
400
|
+
if (!INTERACTIVE) {
|
|
401
|
+
console.error(`✗ 非交互环境:清单如上,确认无误后加 --yes 重跑。已中止。`);
|
|
402
|
+
process.exit(2);
|
|
403
|
+
}
|
|
404
|
+
const act = await choose(promptText, [{ key: "y" }, { key: "n" }]);
|
|
405
|
+
if (act === "n") {
|
|
406
|
+
console.log("已中止。");
|
|
407
|
+
return false;
|
|
408
|
+
}
|
|
409
|
+
return true;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// 覆盖货架条目并提交;返回是否需要保留临时 clone
|
|
413
|
+
function applyAndCommit(ctx, key, localAbs, manifest, verb) {
|
|
414
|
+
const targetAbs = path.join(ctx.shelfDir, ...key.split("/"));
|
|
415
|
+
const shown = displayPath(key);
|
|
416
|
+
|
|
417
|
+
fs.rmSync(targetAbs, { recursive: true, force: true });
|
|
418
|
+
fs.mkdirSync(path.dirname(targetAbs), { recursive: true });
|
|
419
|
+
copyFiltered(localAbs, targetAbs);
|
|
420
|
+
|
|
421
|
+
const message = `shelf: ${verb} ${shown} (from ${os.hostname()})`;
|
|
422
|
+
const result = commitAndPush(ctx.root, [toPosix(path.join("shelf", key))], message);
|
|
423
|
+
|
|
424
|
+
let keepEphemeral = false;
|
|
425
|
+
if (result.failed) {
|
|
426
|
+
console.error(`✗ 提交失败,货架已回滚到改动前: ${result.pushError}`);
|
|
427
|
+
process.exit(4);
|
|
428
|
+
} else if (!result.committed) {
|
|
429
|
+
console.log("= 内容与货架一致,无需提交");
|
|
430
|
+
} else if (result.pushed) {
|
|
431
|
+
console.log(`✓ 已推送 ${shown} (${result.sha.slice(0, 7)})`);
|
|
432
|
+
} else if (result.pushError === "no-remote") {
|
|
433
|
+
console.log(`✓ 已提交 ${shown} (${result.sha.slice(0, 7)}),仓库还没配 remote,配好后 git push 即同步`);
|
|
434
|
+
} else {
|
|
435
|
+
console.warn(`! 已提交 (${result.sha.slice(0, 7)}) 但 push 失败: ${result.pushError}`);
|
|
436
|
+
if (ctx.mode === "ephemeral") {
|
|
437
|
+
keepEphemeral = true;
|
|
438
|
+
ctx.keep?.();
|
|
439
|
+
console.warn(`! 临时 clone 保留在 ${ctx.root},手动处理后可删除`);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
setShelfEntry(manifest, key, {
|
|
444
|
+
sourceCommit: result.sha ?? headCommit(ctx.root),
|
|
445
|
+
contentHash: contentHash(targetAbs),
|
|
446
|
+
pulledAt: todayISO(),
|
|
447
|
+
localPath: toPosix(path.relative(process.cwd(), localAbs)),
|
|
448
|
+
});
|
|
449
|
+
manifest.source ??= remoteUrl(ctx.root) || toPosix(ctx.root);
|
|
450
|
+
saveManifest(manifest);
|
|
451
|
+
return keepEphemeral;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// 多个同名命中时让用户挑一个;非交互直接中止
|
|
455
|
+
async function pickAmong(hits, promptLabel) {
|
|
456
|
+
console.log(promptLabel);
|
|
457
|
+
hits.forEach((h, i) => console.log(` ${i + 1}. ${displayPath(h)}`));
|
|
458
|
+
if (!INTERACTIVE) {
|
|
459
|
+
console.error(`✗ 非交互环境无法选择,已中止。`);
|
|
460
|
+
process.exit(3);
|
|
461
|
+
}
|
|
462
|
+
const act = await choose(
|
|
463
|
+
`选择编号(或 [q]uit)? `,
|
|
464
|
+
[...hits.map((_, i) => ({ key: String(i + 1) })), { key: "q" }],
|
|
465
|
+
);
|
|
466
|
+
if (act === "q") return null;
|
|
467
|
+
return hits[Number(act) - 1];
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// ---- 子命令:push(更新已有货,SHELF 决策 #15)----
|
|
471
|
+
|
|
472
|
+
export async function cmdShelfPush(argv) {
|
|
473
|
+
if (argv.includes("--to")) {
|
|
474
|
+
console.error("✗ push 不再接受 --to:更新已有条目会自动定位;新内容上架用 shelf create <路径> [--to <目录>]");
|
|
475
|
+
process.exit(1);
|
|
476
|
+
}
|
|
477
|
+
let rest = argv;
|
|
478
|
+
let yes, force, forceSecret;
|
|
479
|
+
({ args: rest, value: yes } = takeFlag(rest, "--yes"));
|
|
480
|
+
({ args: rest, value: force } = takeFlag(rest, "--force"));
|
|
481
|
+
({ args: rest, value: forceSecret } = takeFlag(rest, "--force-secret"));
|
|
482
|
+
|
|
483
|
+
const local = rest[0];
|
|
484
|
+
if (!local) {
|
|
485
|
+
console.error("用法: shelf push <本地文件/文件夹> [--yes] [--force] [--force-secret]");
|
|
486
|
+
process.exit(1);
|
|
487
|
+
}
|
|
488
|
+
const localAbs = path.resolve(process.cwd(), local);
|
|
489
|
+
if (!fs.existsSync(localAbs)) {
|
|
490
|
+
console.error(`✗ 本地路径不存在: ${local}`);
|
|
491
|
+
process.exit(1);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
const ctx = resolveShelfContext({ forWrite: true });
|
|
495
|
+
let keepEphemeral = false;
|
|
496
|
+
try {
|
|
497
|
+
const manifest = loadManifest();
|
|
498
|
+
|
|
499
|
+
// 定位链:记账原位 → 原位失效按名字找回 → 无记账按名字匹配 → 指路 create
|
|
500
|
+
const found = findShelfEntryByLocalPath(manifest, toPosix(path.relative(process.cwd(), localAbs)));
|
|
501
|
+
let key;
|
|
502
|
+
let record = null;
|
|
503
|
+
let relocatedFrom = null;
|
|
504
|
+
|
|
505
|
+
if (found) {
|
|
506
|
+
record = found.entry;
|
|
507
|
+
key = found.shelfPath;
|
|
508
|
+
if (!fs.existsSync(path.join(ctx.shelfDir, ...key.split("/")))) {
|
|
509
|
+
const name = path.basename(key);
|
|
510
|
+
const hits = findByBasename(ctx.shelfDir, name);
|
|
511
|
+
if (hits.length === 0) {
|
|
512
|
+
console.error(`✗ 原路径 ${displayPath(key)} 已不存在,货架上也没有同名 '${displayName(name)}';如是新内容用 shelf create`);
|
|
513
|
+
process.exit(1);
|
|
514
|
+
}
|
|
515
|
+
const target = hits.length === 1
|
|
516
|
+
? hits[0]
|
|
517
|
+
: await pickAmong(hits, `货架上有多个同名 '${displayName(name)}':`);
|
|
518
|
+
if (!target) {
|
|
519
|
+
console.log("已中止。");
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
const sameContent = record.contentHash === contentHash(path.join(ctx.shelfDir, ...target.split("/")));
|
|
523
|
+
console.log(`↪ ${displayPath(key)} 已被移动到 ${displayPath(target)}${sameContent ? "(内容一致,纯搬家)" : "(且货架侧内容有差异)"}`);
|
|
524
|
+
if (INTERACTIVE && !yes) {
|
|
525
|
+
const act = await choose(`推到新位置并更新记账? [y]es / [n]o? `, [{ key: "y" }, { key: "n" }]);
|
|
526
|
+
if (act === "n") {
|
|
527
|
+
console.log("已中止。");
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
relocatedFrom = key;
|
|
532
|
+
key = target;
|
|
533
|
+
}
|
|
534
|
+
} else {
|
|
535
|
+
const name = path.basename(localAbs);
|
|
536
|
+
const hits = findByBasename(ctx.shelfDir, name);
|
|
537
|
+
if (hits.length === 0) {
|
|
538
|
+
console.error(`✗ 货架上没有名为 '${name}' 的条目;新内容上架用 shelf create ${local}`);
|
|
539
|
+
process.exit(1);
|
|
540
|
+
}
|
|
541
|
+
key = hits.length === 1
|
|
542
|
+
? hits[0]
|
|
543
|
+
: await pickAmong(hits, `货架上有多个同名 '${name}':`);
|
|
544
|
+
if (!key) {
|
|
545
|
+
console.log("已中止。");
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
console.log(`≈ 按名字匹配到货架条目 ${displayPath(key)}(本工作区无 pull 记录)`);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
const targetAbs = path.join(ctx.shelfDir, ...key.split("/"));
|
|
552
|
+
const shown = displayPath(key);
|
|
553
|
+
|
|
554
|
+
guardFiles(localAbs, forceSecret);
|
|
555
|
+
|
|
556
|
+
// 冲突保护:货架在我们上次 pull 之后被别的设备改过?
|
|
557
|
+
if (fs.existsSync(targetAbs)) {
|
|
558
|
+
const currentHash = contentHash(targetAbs);
|
|
559
|
+
if (record && record.contentHash !== currentHash && !force) {
|
|
560
|
+
if (!INTERACTIVE) {
|
|
561
|
+
const d = diffSummary(localAbs, targetAbs);
|
|
562
|
+
console.error(`✗ 货架上的 ${shown} 在你上次 pull 之后已被修改(可能来自其他设备),已中止。`);
|
|
563
|
+
console.error(` 货架相对本地:新增 ${d.added.length} / 删除 ${d.removed.length} / 不同 ${d.changed.length}`);
|
|
564
|
+
for (const f of [...d.added.map((x) => "+ " + x), ...d.removed.map((x) => "- " + x), ...d.changed.map((x) => "~ " + x)]) {
|
|
565
|
+
console.error(` ${f}`);
|
|
566
|
+
}
|
|
567
|
+
console.error(` 人工确认要覆盖后,加 --force 重跑。`);
|
|
568
|
+
process.exit(3);
|
|
569
|
+
}
|
|
570
|
+
while (true) {
|
|
571
|
+
const act = await choose(
|
|
572
|
+
`⚠ 货架上的 ${shown} 在你上次 pull 之后已被修改(可能来自其他设备)。[d]iff / [f]orce / [a]bort? `,
|
|
573
|
+
[{ key: "d" }, { key: "f" }, { key: "a" }],
|
|
574
|
+
);
|
|
575
|
+
if (act === "a") {
|
|
576
|
+
console.log("已中止,什么都没改。");
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
if (act === "f") break;
|
|
580
|
+
const d = diffSummary(localAbs, targetAbs);
|
|
581
|
+
console.log(` 货架相对本地:新增 ${d.added.length} / 删除 ${d.removed.length} / 不同 ${d.changed.length}`);
|
|
582
|
+
for (const f of d.added) console.log(` + ${f}`);
|
|
583
|
+
for (const f of d.removed) console.log(` - ${f}`);
|
|
584
|
+
for (const f of d.changed) console.log(` ~ ${f}`);
|
|
585
|
+
}
|
|
586
|
+
} else if (!record && !yes && !force) {
|
|
587
|
+
if (!INTERACTIVE) {
|
|
588
|
+
console.error(`✗ 货架上已存在 ${shown}(本工作区没有它的 pull 记录),覆盖需 --yes。已中止。`);
|
|
589
|
+
process.exit(2);
|
|
590
|
+
}
|
|
591
|
+
const act = await choose(`货架上已存在 ${shown},本次 push 会整体覆盖。[y]es / [n]o? `, [{ key: "y" }, { key: "n" }]);
|
|
592
|
+
if (act === "n") {
|
|
593
|
+
console.log("已中止。");
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// 变更清单确认
|
|
600
|
+
const total = printChangeList(targetAbs, localAbs, shown);
|
|
601
|
+
if (total === 0 && fs.existsSync(targetAbs)) {
|
|
602
|
+
console.log(`= ${shown} 与货架一致,无需 push`);
|
|
603
|
+
if (relocatedFrom) {
|
|
604
|
+
delete manifest.shelf[relocatedFrom];
|
|
605
|
+
setShelfEntry(manifest, key, { ...record, localPath: toPosix(path.relative(process.cwd(), localAbs)) });
|
|
606
|
+
saveManifest(manifest);
|
|
607
|
+
console.log(`(记账已更新到新位置 ${shown})`);
|
|
608
|
+
}
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
if (!(await confirmOrExit(`确认 push? [y]es / [n]o? `, yes))) return;
|
|
612
|
+
|
|
613
|
+
if (relocatedFrom) delete manifest.shelf[relocatedFrom];
|
|
614
|
+
keepEphemeral = applyAndCommit(ctx, key, localAbs, manifest, "update");
|
|
615
|
+
} finally {
|
|
616
|
+
if (!keepEphemeral) ctx.cleanup();
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// ---- 子命令:create(上架新货,SHELF 决策 #14)----
|
|
621
|
+
|
|
622
|
+
// 选位浏览器:只逛目录,m <名> 新建目录并进入,d 放在当前位置;返回目录相对路径或 null(取消)
|
|
623
|
+
async function placementBrowse(ctx) {
|
|
624
|
+
const rl = makeLineReader();
|
|
625
|
+
try {
|
|
626
|
+
const segs = [];
|
|
627
|
+
while (true) {
|
|
628
|
+
const absDir = path.join(ctx.shelfDir, ...segs);
|
|
629
|
+
const exists = fs.existsSync(absDir);
|
|
630
|
+
const entries = exists ? listEntries(absDir).filter((e) => e.isDir) : [];
|
|
631
|
+
const here = segs.length ? displayPath(segs.join("/")) : "";
|
|
632
|
+
|
|
633
|
+
console.log("");
|
|
634
|
+
console.log(`放到: shelf:/${here}${exists ? "" : "(新目录,放下时创建)"}`);
|
|
635
|
+
entries.forEach((e, i) => {
|
|
636
|
+
console.log(` ${String(i + 1).padStart(2)}. ${e.display}/ (${e.info})`);
|
|
637
|
+
});
|
|
638
|
+
console.log(" [数字]=进入 · m <名>=新建目录并进入 · d=放在这里 · ..=上级 · q=取消");
|
|
639
|
+
|
|
640
|
+
const raw = await rl.question("> ");
|
|
641
|
+
if (raw === null) return null;
|
|
642
|
+
const ans = raw.trim();
|
|
643
|
+
if (ans === "q") return null;
|
|
644
|
+
if (ans === "d") return segs.join("/");
|
|
645
|
+
if (ans === "..") {
|
|
646
|
+
segs.pop();
|
|
647
|
+
continue;
|
|
648
|
+
}
|
|
649
|
+
if (/^\d+$/.test(ans)) {
|
|
650
|
+
const idx = Number(ans) - 1;
|
|
651
|
+
if (idx < 0 || idx >= entries.length) {
|
|
652
|
+
console.log(" 无此编号");
|
|
653
|
+
continue;
|
|
654
|
+
}
|
|
655
|
+
segs.push(entries[idx].name);
|
|
656
|
+
continue;
|
|
657
|
+
}
|
|
658
|
+
const mk = ans.match(/^m\s+(\S+)$/);
|
|
659
|
+
if (mk) {
|
|
660
|
+
segs.push(mk[1]);
|
|
661
|
+
continue;
|
|
662
|
+
}
|
|
663
|
+
console.log(" 没看懂。数字进入,m <名> 新建目录,d 放这里,.. 上级,q 取消");
|
|
664
|
+
}
|
|
665
|
+
} finally {
|
|
666
|
+
rl.close();
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
export async function cmdShelfCreate(argv) {
|
|
671
|
+
let rest = argv;
|
|
672
|
+
let to, yes, forceSecret;
|
|
673
|
+
({ args: rest, value: to } = takeFlag(rest, "--to", true));
|
|
674
|
+
({ args: rest, value: yes } = takeFlag(rest, "--yes"));
|
|
675
|
+
({ args: rest, value: forceSecret } = takeFlag(rest, "--force-secret"));
|
|
676
|
+
|
|
677
|
+
const local = rest[0];
|
|
678
|
+
if (!local) {
|
|
679
|
+
console.error("用法: shelf create <本地文件/文件夹> [--to <货架目录>] [--yes] [--force-secret]");
|
|
680
|
+
process.exit(1);
|
|
681
|
+
}
|
|
682
|
+
const localAbs = path.resolve(process.cwd(), local);
|
|
683
|
+
if (!fs.existsSync(localAbs)) {
|
|
684
|
+
console.error(`✗ 本地路径不存在: ${local}`);
|
|
685
|
+
process.exit(1);
|
|
686
|
+
}
|
|
687
|
+
const name = path.basename(localAbs);
|
|
688
|
+
|
|
689
|
+
const ctx = resolveShelfContext({ forWrite: true });
|
|
690
|
+
let keepEphemeral = false;
|
|
691
|
+
try {
|
|
692
|
+
// 名字即 ID:全架查重,重名拒绝
|
|
693
|
+
const hits = findByBasename(ctx.shelfDir, name);
|
|
694
|
+
if (hits.length > 0) {
|
|
695
|
+
console.error(`✗ 货架上已有同名条目:`);
|
|
696
|
+
for (const h of hits) console.error(` ${displayPath(h)}`);
|
|
697
|
+
console.error(` 想更新它 → shelf push ${local};想另起一件 → 改个名字再 create。`);
|
|
698
|
+
process.exit(1);
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// 选位:--to 直达(可新建目录),否则交互浏览
|
|
702
|
+
let destDirRel;
|
|
703
|
+
if (to !== undefined) {
|
|
704
|
+
const hit = resolveShelfPath(ctx.shelfDir, to, { allowCreate: true });
|
|
705
|
+
if (!hit.created && fs.statSync(hit.abs).isFile()) {
|
|
706
|
+
console.error(`✗ --to 必须是货架目录,不能是文件: ${to}`);
|
|
707
|
+
process.exit(1);
|
|
708
|
+
}
|
|
709
|
+
if (hit.created) console.log(`(货架上将新建目录 ${displayPath(hit.realRel)})`);
|
|
710
|
+
destDirRel = hit.realRel;
|
|
711
|
+
} else {
|
|
712
|
+
if (!INTERACTIVE) {
|
|
713
|
+
console.error(`✗ 非交互环境请用 --to <货架目录> 指定位置(如 --to templates)。已中止。`);
|
|
714
|
+
process.exit(2);
|
|
715
|
+
}
|
|
716
|
+
destDirRel = await placementBrowse(ctx);
|
|
717
|
+
if (destDirRel === null) {
|
|
718
|
+
console.log("已取消。");
|
|
719
|
+
return;
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
const key = destDirRel ? `${destDirRel}/${name}` : name;
|
|
723
|
+
|
|
724
|
+
guardFiles(localAbs, forceSecret);
|
|
725
|
+
printChangeList(path.join(ctx.shelfDir, ...key.split("/")), localAbs, displayPath(key));
|
|
726
|
+
if (!(await confirmOrExit(`确认上架? [y]es / [n]o? `, yes))) return;
|
|
727
|
+
|
|
728
|
+
const manifest = loadManifest();
|
|
729
|
+
keepEphemeral = applyAndCommit(ctx, key, localAbs, manifest, "add");
|
|
730
|
+
} finally {
|
|
731
|
+
if (!keepEphemeral) ctx.cleanup();
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
// ---- 子命令:init ----
|
|
736
|
+
|
|
737
|
+
export async function cmdShelfInit() {
|
|
738
|
+
const ctx = resolveShelfContext();
|
|
739
|
+
try {
|
|
740
|
+
const manifest = loadManifest();
|
|
741
|
+
manifest.source ??= remoteUrl(ctx.root) || toPosix(ctx.root);
|
|
742
|
+
|
|
743
|
+
const hit = resolveShelfPath(ctx.shelfDir, "skills/common/shelf-ops");
|
|
744
|
+
if (!hit) {
|
|
745
|
+
console.error("✗ 货架上找不到 skills/common/shelf-ops(操作手册 skill),检查 shelf 是否最新");
|
|
746
|
+
process.exit(1);
|
|
747
|
+
}
|
|
748
|
+
const counters = newCounters();
|
|
749
|
+
const dest = path.join(process.cwd(), ".claude", "skills", "shelf-ops");
|
|
750
|
+
await pullEntry(ctx, hit.realRel, manifest, counters, safeAsk, dest);
|
|
751
|
+
saveManifest(manifest);
|
|
752
|
+
|
|
753
|
+
console.log("");
|
|
754
|
+
console.log("工作区已就绪:");
|
|
755
|
+
console.log(" .shelf.json 版本追踪 manifest");
|
|
756
|
+
console.log(" .claude/skills/shelf-ops 货架操作手册(agent 据此执行 pull/push)");
|
|
757
|
+
} finally {
|
|
758
|
+
ctx.cleanup();
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
// ---- 子命令:home(查看/更新档口)----
|
|
763
|
+
|
|
764
|
+
export async function cmdShelfHome(argv) {
|
|
765
|
+
if (argv.includes("--update")) {
|
|
766
|
+
const ok = refreshManagedHome({ force: true });
|
|
767
|
+
if (!ok && !fs.existsSync(managedHomeDir)) {
|
|
768
|
+
console.log("(本机没有托管档口——你用的是自己的 clone 或 npx 快照,更新请用 git pull)");
|
|
769
|
+
} else if (ok) {
|
|
770
|
+
console.log("✓ 托管档口已更新到最新");
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
const ctx = resolveShelfContext();
|
|
774
|
+
try {
|
|
775
|
+
const modeText = {
|
|
776
|
+
home: "你自己的 clone",
|
|
777
|
+
managed: "托管档口(shelf 自动维护)",
|
|
778
|
+
snapshot: "npx 包内快照(只读,写操作会走临时 clone)",
|
|
779
|
+
ephemeral: "一次性临时 clone",
|
|
780
|
+
}[ctx.mode] ?? ctx.mode;
|
|
781
|
+
console.log(`货架位置: ${ctx.shelfDir}`);
|
|
782
|
+
console.log(`模式: ${modeText}`);
|
|
783
|
+
console.log(`来源: ${remoteUrl(ctx.root) ?? DEFAULT_REMOTE}`);
|
|
784
|
+
const entries = fs.readdirSync(ctx.shelfDir, { withFileTypes: true })
|
|
785
|
+
.filter((e) => e.isDirectory() && !IGNORE_NAMES.has(e.name))
|
|
786
|
+
.map((e) => displayName(e.name));
|
|
787
|
+
console.log(`根分类: ${entries.join(" · ") || "(空)"}`);
|
|
788
|
+
} finally {
|
|
789
|
+
ctx.cleanup();
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
// ---- 子命令:sync(按账本与货架对账,SHELF 决策 #19)----
|
|
794
|
+
|
|
795
|
+
// 逐行内容 diff 直通终端(git 自带着色),方向:库上 → 本地
|
|
796
|
+
function showLineDiff(shelfAbs, localAbs, shown) {
|
|
797
|
+
console.log(`—— ${shown} diff(库上 → 本地)——`);
|
|
798
|
+
spawnSync("git", ["diff", "--no-index", "--color=always", "--", shelfAbs, localAbs], { stdio: "inherit" });
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
export async function cmdShelfSync(argv) {
|
|
802
|
+
let rest = argv;
|
|
803
|
+
let dryRun;
|
|
804
|
+
({ args: rest, value: dryRun } = takeFlag(rest, "--dry-run"));
|
|
805
|
+
|
|
806
|
+
const ctx = resolveShelfContext({ forWrite: true }); // 对账必须对着真最新:等同写操作,强制刷新
|
|
807
|
+
let keepEphemeral = false;
|
|
808
|
+
try {
|
|
809
|
+
const manifest = loadManifest();
|
|
810
|
+
const entries = Object.entries(manifest.shelf ?? {});
|
|
811
|
+
if (entries.length === 0) {
|
|
812
|
+
console.log("账本为空(.shelf.json 没有 shelf 段记录)——先 shelf pull / create。");
|
|
813
|
+
return;
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
const c = { same: 0, updated: 0, pushed: 0, skipped: 0, cleaned: 0 };
|
|
817
|
+
const pending = [];
|
|
818
|
+
|
|
819
|
+
for (const [key, rec] of entries) {
|
|
820
|
+
let effKey = key;
|
|
821
|
+
let target = path.join(ctx.shelfDir, ...effKey.split("/"));
|
|
822
|
+
let shown = displayPath(effKey);
|
|
823
|
+
const localAbs = path.resolve(process.cwd(), rec.localPath ?? "");
|
|
824
|
+
const localExists = !!rec.localPath && fs.existsSync(localAbs);
|
|
825
|
+
|
|
826
|
+
// 情形 5:库上路径没了 → 按名字找回(搬家)或报告下架
|
|
827
|
+
if (!fs.existsSync(target)) {
|
|
828
|
+
const name = path.basename(effKey);
|
|
829
|
+
const hits = findByBasename(ctx.shelfDir, name);
|
|
830
|
+
if (hits.length === 1) {
|
|
831
|
+
if (dryRun) {
|
|
832
|
+
console.log(`↪ ${shown} 已被移动到 ${displayPath(hits[0])}(--dry-run,暂不改账)`);
|
|
833
|
+
} else {
|
|
834
|
+
delete manifest.shelf[effKey];
|
|
835
|
+
manifest.shelf[hits[0]] = rec;
|
|
836
|
+
console.log(`↪ ${shown} 已被移动到 ${displayPath(hits[0])},记账已更新`);
|
|
837
|
+
}
|
|
838
|
+
effKey = hits[0];
|
|
839
|
+
target = path.join(ctx.shelfDir, ...effKey.split("/"));
|
|
840
|
+
shown = displayPath(effKey);
|
|
841
|
+
} else {
|
|
842
|
+
const label = hits.length === 0 ? "已从货架移除" : `同名多义(${hits.length} 处)`;
|
|
843
|
+
if (!INTERACTIVE || dryRun) {
|
|
844
|
+
console.log(`⚠ 待决 ${shown}:${label}`);
|
|
845
|
+
pending.push(`${shown}(${label})`);
|
|
846
|
+
continue;
|
|
847
|
+
}
|
|
848
|
+
const act = await choose(`⚠ ${shown} ${label}。[r]清记账 / [s]跳过? `, [{ key: "r" }, { key: "s" }]);
|
|
849
|
+
if (act === "r") {
|
|
850
|
+
delete manifest.shelf[effKey];
|
|
851
|
+
console.log(`✓ 已清记账 ${shown}(本地文件未动;想重新上架用 shelf create)`);
|
|
852
|
+
c.cleaned++;
|
|
853
|
+
} else c.skipped++;
|
|
854
|
+
continue;
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
// 情形 6:本地文件没了 → 孤儿记账
|
|
859
|
+
if (!localExists) {
|
|
860
|
+
if (!INTERACTIVE || dryRun) {
|
|
861
|
+
console.log(`⚠ 待决 ${shown}:本地文件不存在(${rec.localPath ?? "无 localPath"})`);
|
|
862
|
+
pending.push(`${shown}(本地文件不存在)`);
|
|
863
|
+
continue;
|
|
864
|
+
}
|
|
865
|
+
const act = await choose(
|
|
866
|
+
`⚠ ${shown} 的本地文件不存在(${rec.localPath ?? "无 localPath"})。[p]重新拉取 / [r]清记账 / [s]跳过? `,
|
|
867
|
+
[{ key: "p" }, { key: "r" }, { key: "s" }],
|
|
868
|
+
);
|
|
869
|
+
if (act === "p") {
|
|
870
|
+
fs.mkdirSync(path.dirname(localAbs), { recursive: true });
|
|
871
|
+
copyFiltered(target, localAbs);
|
|
872
|
+
manifest.shelf[effKey] = { ...rec, contentHash: contentHash(target), sourceCommit: headCommit(ctx.root), pulledAt: todayISO() };
|
|
873
|
+
console.log(`↓ 已重新拉取 ${shown}`);
|
|
874
|
+
c.updated++;
|
|
875
|
+
} else if (act === "r") {
|
|
876
|
+
delete manifest.shelf[effKey];
|
|
877
|
+
console.log(`✓ 已清记账 ${shown}`);
|
|
878
|
+
c.cleaned++;
|
|
879
|
+
} else c.skipped++;
|
|
880
|
+
continue;
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
const S = contentHash(target);
|
|
884
|
+
const L = contentHash(localAbs);
|
|
885
|
+
const R = rec.contentHash;
|
|
886
|
+
|
|
887
|
+
if (S === R && L === R) { c.same++; continue; }
|
|
888
|
+
|
|
889
|
+
// 情形 2:库上有新版、本地没动 → 自动覆盖本地(零损失)
|
|
890
|
+
if (S !== R && L === R) {
|
|
891
|
+
if (dryRun) {
|
|
892
|
+
console.log(`↓ 将更新本地:${shown}`);
|
|
893
|
+
c.updated++;
|
|
894
|
+
continue;
|
|
895
|
+
}
|
|
896
|
+
fs.rmSync(localAbs, { recursive: true, force: true });
|
|
897
|
+
copyFiltered(target, localAbs);
|
|
898
|
+
manifest.shelf[effKey] = { ...rec, contentHash: S, sourceCommit: headCommit(ctx.root), pulledAt: todayISO() };
|
|
899
|
+
console.log(`↓ 已更新本地:${shown}`);
|
|
900
|
+
c.updated++;
|
|
901
|
+
continue;
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
// 情形 3/4:本地有改动(本地领先,或双方都改)
|
|
905
|
+
const both = S !== R;
|
|
906
|
+
const tag = both ? "双方都改过" : "本地领先";
|
|
907
|
+
if (!INTERACTIVE || dryRun) {
|
|
908
|
+
const d = diffSummary(target, localAbs);
|
|
909
|
+
console.log(`⚠ 待决 ${shown}(${tag}):本地相对库上 新增 ${d.added.length} / 删除 ${d.removed.length} / 修改 ${d.changed.length}`);
|
|
910
|
+
pending.push(`${shown}(${tag})`);
|
|
911
|
+
continue;
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
showLineDiff(target, localAbs, shown);
|
|
915
|
+
const act = await choose(`${shown}(${tag})。[p]本地推上库 / [o]库覆盖本地 / [s]跳过? `, [{ key: "p" }, { key: "o" }, { key: "s" }]);
|
|
916
|
+
if (act === "s") { c.skipped++; continue; }
|
|
917
|
+
if (act === "o") {
|
|
918
|
+
fs.rmSync(localAbs, { recursive: true, force: true });
|
|
919
|
+
copyFiltered(target, localAbs);
|
|
920
|
+
manifest.shelf[effKey] = { ...rec, contentHash: S, sourceCommit: headCommit(ctx.root), pulledAt: todayISO() };
|
|
921
|
+
console.log(`↓ 已用库上版本覆盖本地:${shown}`);
|
|
922
|
+
c.updated++;
|
|
923
|
+
continue;
|
|
924
|
+
}
|
|
925
|
+
// p:本地推上库
|
|
926
|
+
if (both) {
|
|
927
|
+
const confirm = await choose(`库上的改动将被你的本地版本覆盖,确认? [y]es / [n]o? `, [{ key: "y" }, { key: "n" }]);
|
|
928
|
+
if (confirm === "n") { c.skipped++; continue; }
|
|
929
|
+
}
|
|
930
|
+
const { secrets } = guardScan(localAbs);
|
|
931
|
+
if (secrets.length > 0) {
|
|
932
|
+
console.log(`✗ ${shown} 含疑似凭据文件(${secrets.map((x) => x.rel).join(", ")}),sync 不代推,已跳过——确要推请单独 shelf push --force-secret`);
|
|
933
|
+
c.skipped++;
|
|
934
|
+
continue;
|
|
935
|
+
}
|
|
936
|
+
keepEphemeral = applyAndCommit(ctx, effKey, localAbs, manifest, "update") || keepEphemeral;
|
|
937
|
+
c.pushed++;
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
if (!dryRun) saveManifest(manifest);
|
|
941
|
+
console.log("");
|
|
942
|
+
console.log(
|
|
943
|
+
`Sync${dryRun ? "(dry-run)" : ""}: ${c.same} 一致, ${c.updated} 更新本地, ${c.pushed} 推上库, ` +
|
|
944
|
+
`${c.cleaned} 清账, ${c.skipped} 跳过, ${pending.length} 待决`,
|
|
945
|
+
);
|
|
946
|
+
if (pending.length) {
|
|
947
|
+
for (const x of pending) console.log(` · ${x}`);
|
|
948
|
+
if (!INTERACTIVE && !dryRun) process.exit(2);
|
|
949
|
+
}
|
|
950
|
+
} finally {
|
|
951
|
+
if (!keepEphemeral) ctx.cleanup();
|
|
952
|
+
}
|
|
953
|
+
}
|