lshed 0.3.0 → 0.6.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.
Files changed (4) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +231 -60
  3. package/dist/cli.js +730 -234
  4. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -3,12 +3,12 @@
3
3
  // src/cli.ts
4
4
  import { Command } from "commander";
5
5
  import { createRequire } from "module";
6
- import os2 from "os";
7
- import path15 from "path";
6
+ import os3 from "os";
7
+ import path19 from "path";
8
8
 
9
9
  // src/adapters/claude-code.ts
10
- import { promises as fs2 } from "fs";
11
- import path2 from "path";
10
+ import { promises as fs4 } from "fs";
11
+ import path4 from "path";
12
12
  import os from "os";
13
13
 
14
14
  // src/installers/claude-plugin.ts
@@ -166,79 +166,14 @@ var pluginInstaller = {
166
166
  }
167
167
  };
168
168
 
169
- // src/adapters/claude-code.ts
170
- var CATEGORIES = [
171
- { name: "skills", root: "skills", kind: "dir" },
172
- { name: "agents", root: "agents", kind: "file" },
173
- { name: "commands", root: "commands", kind: "file" }
174
- // instructions 는 단일 파일(CLAUDE.md)이라 스캔 대상이 아니라 생성 대상이다 (§3.3)
175
- ];
176
- var ClaudeCodeAdapter = class {
177
- name = "claude-code";
178
- root;
179
- constructor(root) {
180
- this.root = root ?? path2.join(os.homedir(), ".claude");
181
- }
182
- categories() {
183
- return CATEGORIES;
184
- }
185
- instructionsStrategy() {
186
- return "import";
187
- }
188
- instructionsFileName() {
189
- return "CLAUDE.md";
190
- }
191
- installers() {
192
- return [marketplaceInstaller, pluginInstaller];
193
- }
194
- async scan() {
195
- const out = [];
196
- for (const cat of CATEGORIES) {
197
- const dir = path2.join(this.root, cat.root);
198
- let entries;
199
- try {
200
- entries = await fs2.readdir(dir, { withFileTypes: true });
201
- } catch {
202
- continue;
203
- }
204
- for (const e of entries) {
205
- if (e.name.startsWith(".")) continue;
206
- const full = path2.join(dir, e.name);
207
- let st;
208
- try {
209
- st = await fs2.stat(full);
210
- } catch {
211
- continue;
212
- }
213
- if (cat.kind === "dir" && st.isDirectory()) {
214
- out.push({ category: cat.name, id: e.name, path: full });
215
- } else if (cat.kind === "file" && st.isFile() && e.name.endsWith(".md")) {
216
- out.push({ category: cat.name, id: e.name.slice(0, -3), path: full });
217
- }
218
- }
219
- }
220
- return out;
221
- }
222
- };
223
-
224
- // src/core/context.ts
225
- import { promises as fs6 } from "fs";
226
- import path8 from "path";
227
- import { spawn as spawn2 } from "child_process";
228
-
229
- // src/installers/git.ts
230
- import { promises as fs4 } from "fs";
231
- import path5 from "path";
232
-
233
- // src/git.ts
234
- import { execFile, spawn } from "child_process";
235
- import { promisify } from "util";
236
- import path4 from "path";
169
+ // src/adapters/claude-mcp.ts
170
+ import { promises as fs3 } from "fs";
171
+ import path3 from "path";
237
172
 
238
173
  // src/fsutil.ts
239
- import { promises as fs3 } from "fs";
174
+ import { promises as fs2 } from "fs";
240
175
  import { createHash } from "crypto";
241
- import path3 from "path";
176
+ import path2 from "path";
242
177
 
243
178
  // src/ignore.ts
244
179
  var DEFAULT_IGNORE = [
@@ -263,7 +198,7 @@ function isIgnored(rel, patterns) {
263
198
  // src/fsutil.ts
264
199
  async function exists(p) {
265
200
  try {
266
- await fs3.access(p);
201
+ await fs2.access(p);
267
202
  return true;
268
203
  } catch {
269
204
  return false;
@@ -271,7 +206,7 @@ async function exists(p) {
271
206
  }
272
207
  async function isDir(p) {
273
208
  try {
274
- return (await fs3.stat(p)).isDirectory();
209
+ return (await fs2.stat(p)).isDirectory();
275
210
  } catch {
276
211
  return false;
277
212
  }
@@ -280,48 +215,48 @@ async function listFiles(root, ignore = DEFAULT_IGNORE) {
280
215
  if (!await exists(root)) return [];
281
216
  if (!await isDir(root)) return [""];
282
217
  const out = [];
283
- async function walk(dir, rel) {
284
- const entries = await fs3.readdir(dir, { withFileTypes: true });
218
+ async function walk2(dir, rel) {
219
+ const entries = await fs2.readdir(dir, { withFileTypes: true });
285
220
  for (const e of entries) {
286
221
  const r = rel ? `${rel}/${e.name}` : e.name;
287
222
  if (isIgnored(r, ignore)) continue;
288
- const full = path3.join(dir, e.name);
223
+ const full = path2.join(dir, e.name);
289
224
  let st;
290
225
  try {
291
- st = await fs3.stat(full);
226
+ st = await fs2.stat(full);
292
227
  } catch {
293
228
  continue;
294
229
  }
295
- if (st.isDirectory()) await walk(full, r);
230
+ if (st.isDirectory()) await walk2(full, r);
296
231
  else if (st.isFile()) out.push(r);
297
232
  }
298
233
  }
299
- await walk(root, "");
234
+ await walk2(root, "");
300
235
  return out.sort();
301
236
  }
302
237
  async function hashFile(p) {
303
- return createHash("sha256").update(await fs3.readFile(p)).digest("hex");
238
+ return createHash("sha256").update(await fs2.readFile(p)).digest("hex");
304
239
  }
305
240
  async function hashTree(root, ignore = DEFAULT_IGNORE) {
306
241
  if (!await exists(root)) return null;
307
242
  const h = createHash("sha256");
308
243
  for (const rel of await listFiles(root, ignore)) {
309
- h.update(rel).update("\0").update(await fs3.readFile(rel ? path3.join(root, rel) : root)).update("\0");
244
+ h.update(rel).update("\0").update(await fs2.readFile(rel ? path2.join(root, rel) : root)).update("\0");
310
245
  }
311
246
  return h.digest("hex");
312
247
  }
313
248
  async function copyTree(src, dst, ignore = DEFAULT_IGNORE) {
314
- await fs3.rm(dst, { recursive: true, force: true });
315
- await fs3.mkdir(path3.dirname(dst), { recursive: true });
316
- const srcRoot = path3.resolve(src);
317
- await fs3.cp(src, dst, {
249
+ await fs2.rm(dst, { recursive: true, force: true });
250
+ await fs2.mkdir(path2.dirname(dst), { recursive: true });
251
+ const srcRoot = path2.resolve(src);
252
+ await fs2.cp(src, dst, {
318
253
  recursive: true,
319
254
  dereference: true,
320
255
  filter: async (from) => {
321
- const rel = path3.relative(srcRoot, path3.resolve(from)).split(path3.sep).join("/");
256
+ const rel = path2.relative(srcRoot, path2.resolve(from)).split(path2.sep).join("/");
322
257
  if (isIgnored(rel, ignore)) return false;
323
258
  try {
324
- if ((await fs3.lstat(from)).isSymbolicLink()) await fs3.stat(from);
259
+ if ((await fs2.lstat(from)).isSymbolicLink()) await fs2.stat(from);
325
260
  } catch {
326
261
  return false;
327
262
  }
@@ -330,15 +265,15 @@ async function copyTree(src, dst, ignore = DEFAULT_IGNORE) {
330
265
  });
331
266
  }
332
267
  async function removeTree(p) {
333
- await fs3.rm(p, { recursive: true, force: true });
268
+ await fs2.rm(p, { recursive: true, force: true });
334
269
  }
335
270
  async function diffTrees(local, shed, ignore = DEFAULT_IGNORE) {
336
271
  const l = new Set(await listFiles(local, ignore));
337
272
  const s = new Set(await listFiles(shed, ignore));
338
273
  const out = [];
339
274
  for (const f of [.../* @__PURE__ */ new Set([...l, ...s])].sort()) {
340
- const lp = f ? path3.join(local, f) : local;
341
- const sp = f ? path3.join(shed, f) : shed;
275
+ const lp = f ? path2.join(local, f) : local;
276
+ const sp = f ? path2.join(shed, f) : shed;
342
277
  if (l.has(f) && !s.has(f)) out.push({ status: "A", file: f });
343
278
  else if (!l.has(f) && s.has(f)) out.push({ status: "D", file: f });
344
279
  else if (await hashFile(lp) !== await hashFile(sp)) out.push({ status: "M", file: f });
@@ -346,13 +281,127 @@ async function diffTrees(local, shed, ignore = DEFAULT_IGNORE) {
346
281
  return out;
347
282
  }
348
283
 
284
+ // src/adapters/claude-mcp.ts
285
+ var ClaudeMcpEntries = class {
286
+ constructor(root) {
287
+ this.root = root;
288
+ }
289
+ root;
290
+ name = "mcp";
291
+ kind = "entry";
292
+ secretKeys = ["env", "headers"];
293
+ expandsEnv = true;
294
+ /** ~/.claude 의 형제 ~/.claude.json. CLAUDE_CONFIG_DIR 처럼 루트 안에 있으면 그것을 쓴다. */
295
+ async file() {
296
+ const inside = path3.join(this.root, ".claude.json");
297
+ return await exists(inside) ? inside : `${this.root}.json`;
298
+ }
299
+ async load() {
300
+ const p = await this.file();
301
+ try {
302
+ return JSON.parse(await fs3.readFile(p, "utf8"));
303
+ } catch (e) {
304
+ if (e.code === "ENOENT") return {};
305
+ throw new Error(`${p} \uC744 \uC77D\uC744 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4: ${e.message}`);
306
+ }
307
+ }
308
+ async read() {
309
+ const servers = (await this.load()).mcpServers;
310
+ return servers && typeof servers === "object" ? { ...servers } : {};
311
+ }
312
+ async write(id, value) {
313
+ const p = await this.file();
314
+ const all = await this.load();
315
+ const servers = { ...all.mcpServers ?? {} };
316
+ if (value === null) delete servers[id];
317
+ else servers[id] = value;
318
+ all.mcpServers = servers;
319
+ await fs3.mkdir(path3.dirname(p), { recursive: true });
320
+ const tmp = `${p}.lshed-${process.pid}.tmp`;
321
+ await fs3.writeFile(tmp, JSON.stringify(all, null, 2) + "\n");
322
+ await fs3.rename(tmp, p);
323
+ }
324
+ };
325
+
326
+ // src/adapters/claude-code.ts
327
+ var CATEGORIES = [
328
+ { name: "skills", root: "skills", kind: "dir" },
329
+ { name: "agents", root: "agents", kind: "file" },
330
+ { name: "commands", root: "commands", kind: "file" }
331
+ // instructions 는 단일 파일(CLAUDE.md)이라 스캔 대상이 아니라 생성 대상이다 (§3.3)
332
+ ];
333
+ var ClaudeCodeAdapter = class {
334
+ name = "claude-code";
335
+ root;
336
+ mcp;
337
+ constructor(root) {
338
+ this.root = root ?? process.env.CLAUDE_CONFIG_DIR ?? path4.join(os.homedir(), ".claude");
339
+ this.mcp = new ClaudeMcpEntries(this.root);
340
+ }
341
+ entries() {
342
+ return [this.mcp];
343
+ }
344
+ categories() {
345
+ return CATEGORIES;
346
+ }
347
+ instructionsStrategy() {
348
+ return "import";
349
+ }
350
+ instructionsFileName() {
351
+ return "CLAUDE.md";
352
+ }
353
+ installers() {
354
+ return [marketplaceInstaller, pluginInstaller];
355
+ }
356
+ async scan() {
357
+ const out = [];
358
+ for (const cat of CATEGORIES) {
359
+ const dir = path4.join(this.root, cat.root);
360
+ let entries;
361
+ try {
362
+ entries = await fs4.readdir(dir, { withFileTypes: true });
363
+ } catch {
364
+ continue;
365
+ }
366
+ for (const e of entries) {
367
+ if (e.name.startsWith(".")) continue;
368
+ const full = path4.join(dir, e.name);
369
+ let st;
370
+ try {
371
+ st = await fs4.stat(full);
372
+ } catch {
373
+ continue;
374
+ }
375
+ if (cat.kind === "dir" && st.isDirectory()) {
376
+ out.push({ category: cat.name, id: e.name, path: full });
377
+ } else if (cat.kind === "file" && st.isFile() && e.name.endsWith(".md")) {
378
+ out.push({ category: cat.name, id: e.name.slice(0, -3), path: full });
379
+ }
380
+ }
381
+ }
382
+ return out;
383
+ }
384
+ };
385
+
386
+ // src/core/context.ts
387
+ import { promises as fs7 } from "fs";
388
+ import path9 from "path";
389
+ import { spawn as spawn2 } from "child_process";
390
+
391
+ // src/installers/git.ts
392
+ import { promises as fs5 } from "fs";
393
+ import path6 from "path";
394
+
349
395
  // src/git.ts
396
+ import { execFile, spawn } from "child_process";
397
+ import { promisify } from "util";
398
+ import path5 from "path";
350
399
  var x = promisify(execFile);
351
400
  async function git(args, cwd) {
352
401
  const { stdout } = await x("git", args, { cwd, maxBuffer: 1 << 24 });
353
402
  return stdout.trim();
354
403
  }
355
- var isRepo = (dir) => exists(path4.join(dir, ".git"));
404
+ var isRepo = (dir) => exists(path5.join(dir, ".git"));
356
405
  var remoteUrl = (dir) => git(["remote", "get-url", "origin"], dir).catch(() => null);
357
406
  var head = (dir) => git(["rev-parse", "HEAD"], dir);
358
407
  var branch = async (dir) => {
@@ -373,7 +422,7 @@ function runShell(cmd, cwd) {
373
422
  // src/installers/git.ts
374
423
  function dirOf(ctx, pkg) {
375
424
  if (!pkg.into) throw new Error(`package ${pkg.id}: git \uACC4\uC5F4 \uD328\uD0A4\uC9C0\uB294 into \uAC00 \uD544\uC694\uD569\uB2C8\uB2E4`);
376
- return path5.join(ctx.adapter.root, ...pkg.into.split("/"));
425
+ return path6.join(ctx.adapter.root, ...pkg.into.split("/"));
377
426
  }
378
427
  var gitInstaller = {
379
428
  name: "git",
@@ -385,7 +434,7 @@ var gitInstaller = {
385
434
  if (!await isRepo(f.path)) continue;
386
435
  const url = await remoteUrl(f.path);
387
436
  if (!url) continue;
388
- const into = path5.relative(ctx.adapter.root, f.path).split(path5.sep).join("/");
437
+ const into = path6.relative(ctx.adapter.root, f.path).split(path6.sep).join("/");
389
438
  out.push({ id: f.id, into, source: sourceFromRemote(url, await branch(f.path)), rev: await head(f.path), path: f.path });
390
439
  }
391
440
  return out;
@@ -399,7 +448,7 @@ var gitInstaller = {
399
448
  const dir = dirOf(ctx, pkg);
400
449
  if (await exists(dir)) throw new Error(`package ${pkg.id}: ${dir} \uAC00 \uC788\uC9C0\uB9CC git \uC800\uC7A5\uC18C\uAC00 \uC544\uB2D9\uB2C8\uB2E4. \uCE58\uC6B0\uAC70\uB098 into \uB97C \uBC14\uAFB8\uC138\uC694.`);
401
450
  const { url, ref } = cloneTarget(parseSource(pkg.source));
402
- await fs4.mkdir(path5.dirname(dir), { recursive: true });
451
+ await fs5.mkdir(path6.dirname(dir), { recursive: true });
403
452
  await clone(url, dir, ref);
404
453
  if (locked) {
405
454
  await resetHard(dir, locked).catch(() => {
@@ -444,6 +493,8 @@ var ManifestSchema = z.object({
444
493
  agent: z.string().default("claude-code"),
445
494
  /** 창고에 담지 않을 이름들. 기본값(DEFAULT_IGNORE)에 더해진다. */
446
495
  ignore: z.array(z.string()).optional(),
496
+ /** 로컬에 있어도 창고에 넣지 않을 부품 ("id" 또는 "category/id"). init --exclude 가 적고 add/status 가 따른다. */
497
+ exclude: z.array(z.string()).optional(),
447
498
  components: ComponentsSchema.default({}),
448
499
  packages: z.array(PackageSchema).default([]),
449
500
  profiles: z.record(z.string(), ProfileSchema).default({})
@@ -506,13 +557,8 @@ ${problems.map((p) => " " + p).join("\n")}`);
506
557
  return m;
507
558
  }
508
559
  function effectiveSource(category, c, kind = "dir") {
509
- return c.source ?? `file:./${category}/${c.id}${kind === "file" ? ".md" : ""}`;
510
- }
511
- function stringifyManifest(m) {
512
- const out = { ...m };
513
- if (!m.packages.length) delete out.packages;
514
- if (!m.ignore?.length) delete out.ignore;
515
- return YAML.stringify(out, { lineWidth: 0 });
560
+ const ext = kind === "file" ? ".md" : kind === "entry" ? ".json" : "";
561
+ return c.source ?? `file:./${category}/${c.id}${ext}`;
516
562
  }
517
563
  function packagesOf(m, profile) {
518
564
  const ids = m.profiles[profile]?.[PACKAGES] ?? [];
@@ -520,10 +566,10 @@ function packagesOf(m, profile) {
520
566
  }
521
567
 
522
568
  // src/resolvers/file.ts
523
- import path6 from "path";
569
+ import path7 from "path";
524
570
  function resolveSource(shed, raw) {
525
571
  const s = parseSource(raw);
526
- if (s.scheme === "file") return path6.resolve(shed, s.path);
572
+ if (s.scheme === "file") return path7.resolve(shed, s.path);
527
573
  throw new Error(`"${raw}": ${s.scheme}: \uCD9C\uCC98\uB294 v0.2\uC5D0\uC11C \uC9C0\uC6D0\uB429\uB2C8\uB2E4. \uC9C0\uAE08\uC740 file: \uB9CC \uC0AC\uC6A9\uD560 \uC218 \uC788\uC2B5\uB2C8\uB2E4.`);
528
574
  }
529
575
  function isSaveable(raw) {
@@ -531,8 +577,8 @@ function isSaveable(raw) {
531
577
  }
532
578
 
533
579
  // src/state.ts
534
- import { promises as fs5 } from "fs";
535
- import path7 from "path";
580
+ import { promises as fs6 } from "fs";
581
+ import path8 from "path";
536
582
  import { z as z2 } from "zod";
537
583
  var StateSchema = z2.object({
538
584
  profile: z2.string(),
@@ -543,11 +589,11 @@ var StateSchema = z2.object({
543
589
  });
544
590
  var LSHED_DIR = "lshed";
545
591
  function statePath(adapter) {
546
- return path7.join(adapter.root, LSHED_DIR, "state.json");
592
+ return path8.join(adapter.root, LSHED_DIR, "state.json");
547
593
  }
548
594
  async function readState(adapter) {
549
595
  try {
550
- const raw = JSON.parse(await fs5.readFile(statePath(adapter), "utf8"));
596
+ const raw = JSON.parse(await fs6.readFile(statePath(adapter), "utf8"));
551
597
  return StateSchema.parse(raw);
552
598
  } catch (e) {
553
599
  if (e.code === "ENOENT") return null;
@@ -556,8 +602,8 @@ async function readState(adapter) {
556
602
  }
557
603
  async function writeState(adapter, state) {
558
604
  const p = statePath(adapter);
559
- await fs5.mkdir(path7.dirname(p), { recursive: true });
560
- await fs5.writeFile(p, JSON.stringify(state, null, 2) + "\n");
605
+ await fs6.mkdir(path8.dirname(p), { recursive: true });
606
+ await fs6.writeFile(p, JSON.stringify(state, null, 2) + "\n");
561
607
  }
562
608
 
563
609
  // src/core/context.ts
@@ -582,15 +628,15 @@ var MANIFEST_FILE = "lshed.yaml";
582
628
  var INSTRUCTIONS = "instructions";
583
629
  var FRAGMENTS_DIR = `${LSHED_DIR}/instructions`;
584
630
  function manifestPath(ctx) {
585
- return path8.join(ctx.shed, MANIFEST_FILE);
631
+ return path9.join(ctx.shed, MANIFEST_FILE);
586
632
  }
587
633
  function knownCategories(adapter) {
588
- return [...adapter.categories().map((c) => c.name), INSTRUCTIONS];
634
+ return [...adapter.categories().map((c) => c.name), ...adapter.entries().map((e) => e.name), INSTRUCTIONS];
589
635
  }
590
636
  async function loadManifest(ctx) {
591
637
  let text;
592
638
  try {
593
- text = await fs6.readFile(manifestPath(ctx), "utf8");
639
+ text = await fs7.readFile(manifestPath(ctx), "utf8");
594
640
  } catch {
595
641
  throw new Error(`\uCC3D\uACE0\uC5D0 ${MANIFEST_FILE} \uC774 \uC5C6\uC2B5\uB2C8\uB2E4: ${ctx.shed}
596
642
  \uBA3C\uC800 'lshed init --shed ${ctx.shed}' \uB97C \uC2E4\uD589\uD558\uC138\uC694.`);
@@ -604,12 +650,22 @@ function ignoreOf(ctx) {
604
650
  }
605
651
  function targetRel(cat, id) {
606
652
  if (cat === INSTRUCTIONS) return `${FRAGMENTS_DIR}/${id}.md`;
653
+ if (cat.kind === "entry") return `${cat.name}:${id}`;
607
654
  return cat.kind === "dir" ? `${cat.root}/${id}` : `${cat.root}/${id}.md`;
608
655
  }
656
+ function entryOf(ctx, rel) {
657
+ const i = rel.indexOf(":");
658
+ if (i <= 0) return void 0;
659
+ const cat = ctx.adapter.entries().find((e) => e.name === rel.slice(0, i));
660
+ return cat ? { cat, id: rel.slice(i + 1) } : void 0;
661
+ }
662
+ function kindOf(ctx, category) {
663
+ if (category === INSTRUCTIONS) return "file";
664
+ if (ctx.adapter.entries().some((e) => e.name === category)) return "entry";
665
+ return ctx.adapter.categories().find((k) => k.name === category)?.kind ?? "dir";
666
+ }
609
667
  function sourcePath(ctx, category, c) {
610
- const cat = ctx.adapter.categories().find((k) => k.name === category);
611
- const kind = category === INSTRUCTIONS ? "file" : cat?.kind ?? "dir";
612
- return resolveSource(ctx.shed, effectiveSource(category, c, kind));
668
+ return resolveSource(ctx.shed, effectiveSource(category, c, kindOf(ctx, category)));
613
669
  }
614
670
  function findComponent(m, category, id) {
615
671
  const c = (m.components[category] ?? []).find((x2) => x2.id === id);
@@ -625,28 +681,30 @@ function planProfile(ctx, m, profile) {
625
681
  const items = [];
626
682
  for (const [category, ids] of Object.entries(p)) {
627
683
  if (category === PACKAGES) continue;
628
- const cat = category === INSTRUCTIONS ? INSTRUCTIONS : ctx.adapter.categories().find((k) => k.name === category);
684
+ const cat = category === INSTRUCTIONS ? INSTRUCTIONS : ctx.adapter.categories().find((k) => k.name === category) ?? ctx.adapter.entries().find((e) => e.name === category);
629
685
  if (!cat) throw new Error(`\uD504\uB85C\uD544 "${profile}": \uC5B4\uB311\uD130 ${ctx.adapter.name} \uC740 \uCE74\uD14C\uACE0\uB9AC "${category}" \uB97C \uBAA8\uB985\uB2C8\uB2E4`);
686
+ const entry = cat !== INSTRUCTIONS && cat.kind === "entry" ? cat : void 0;
630
687
  for (const id of ids) {
631
688
  const component = findComponent(m, category, id);
632
- items.push({ category, id, rel: targetRel(cat, id), src: sourcePath(ctx, category, component), component });
689
+ items.push({ category, id, rel: targetRel(cat, id), src: sourcePath(ctx, category, component), component, entry });
633
690
  }
634
691
  }
635
692
  return items;
636
693
  }
637
694
  function abs(ctx, rel) {
638
- return path8.join(ctx.adapter.root, ...rel.split("/"));
695
+ return path9.join(ctx.adapter.root, ...rel.split("/"));
639
696
  }
640
697
 
641
698
  // src/core/init.ts
642
- import { promises as fs9 } from "fs";
643
- import path12 from "path";
699
+ import { promises as fs11 } from "fs";
700
+ import path16 from "path";
701
+ import YAML4 from "yaml";
644
702
 
645
703
  // src/core/instructions.ts
646
- import path9 from "path";
704
+ import path10 from "path";
647
705
  var MARKER = "<!-- generated by lshed";
648
706
  function instructionsFile(ctx) {
649
- return path9.join(ctx.adapter.root, ctx.adapter.instructionsFileName());
707
+ return path10.join(ctx.adapter.root, ctx.adapter.instructionsFileName());
650
708
  }
651
709
  function isGenerated(text) {
652
710
  return text.trimStart().startsWith(MARKER);
@@ -664,13 +722,9 @@ ${f.content.trimEnd()}
664
722
  `).join("\n");
665
723
  }
666
724
 
667
- // src/core/packages.ts
725
+ // src/lock.ts
668
726
  import { promises as fs8 } from "fs";
669
727
  import path11 from "path";
670
-
671
- // src/lock.ts
672
- import { promises as fs7 } from "fs";
673
- import path10 from "path";
674
728
  import YAML2 from "yaml";
675
729
  import { z as z3 } from "zod";
676
730
  var EntrySchema = z3.object({ source: z3.string(), rev: z3.string().optional(), commit: z3.string().optional() }).transform((e) => ({ source: e.source, rev: e.rev ?? e.commit ?? "" }));
@@ -681,7 +735,7 @@ var LockSchema = z3.object({
681
735
  var LOCK_FILE = "lshed.lock";
682
736
  async function readLock(shed) {
683
737
  try {
684
- return LockSchema.parse(YAML2.parse(await fs7.readFile(path10.join(shed, LOCK_FILE), "utf8")));
738
+ return LockSchema.parse(YAML2.parse(await fs8.readFile(path11.join(shed, LOCK_FILE), "utf8")));
685
739
  } catch (e) {
686
740
  if (e.code === "ENOENT") return { version: 1, packages: {} };
687
741
  throw new Error(`${LOCK_FILE} \uC744 \uC77D\uC744 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4: ${e.message}`);
@@ -689,10 +743,15 @@ async function readLock(shed) {
689
743
  }
690
744
  async function writeLock(shed, lock) {
691
745
  const sorted = { version: 1, packages: Object.fromEntries(Object.entries(lock.packages).sort()) };
692
- await fs7.writeFile(path10.join(shed, LOCK_FILE), "# generated by lshed \u2014 do not edit; 'lshed update' refreshes it\n" + YAML2.stringify(sorted));
746
+ await fs8.writeFile(path11.join(shed, LOCK_FILE), "# generated by lshed \u2014 do not edit; 'lshed update' refreshes it\n" + YAML2.stringify(sorted));
693
747
  }
694
748
 
749
+ // src/core/discover.ts
750
+ import path13 from "path";
751
+
695
752
  // src/core/packages.ts
753
+ import { promises as fs9 } from "fs";
754
+ import path12 from "path";
696
755
  async function detectPackages(ctx, found) {
697
756
  const out = [];
698
757
  for (const inst of installersFor(ctx)) out.push(...await inst.detect(ctx, found));
@@ -702,26 +761,26 @@ async function detectGenerated(found, pkgs) {
702
761
  const out = /* @__PURE__ */ new Map();
703
762
  const located = pkgs.filter((p) => p.path);
704
763
  if (!located.length) return out;
705
- const roots = await Promise.all(located.map(async (p) => ({ id: p.id, real: await fs8.realpath(p.path) })));
764
+ const roots = await Promise.all(located.map(async (p) => ({ id: p.id, real: await fs9.realpath(p.path) })));
706
765
  for (const f of found) {
707
766
  if (located.some((p) => p.path === f.path)) continue;
708
767
  let entries;
709
768
  try {
710
- if (!(await fs8.stat(f.path)).isDirectory()) continue;
711
- entries = await fs8.readdir(f.path);
769
+ if (!(await fs9.stat(f.path)).isDirectory()) continue;
770
+ entries = await fs9.readdir(f.path);
712
771
  } catch {
713
772
  continue;
714
773
  }
715
774
  for (const name of entries) {
716
- const p = path11.join(f.path, name);
775
+ const p = path12.join(f.path, name);
717
776
  let target;
718
777
  try {
719
- if (!(await fs8.lstat(p)).isSymbolicLink()) continue;
720
- target = await fs8.realpath(p).catch(async () => path11.resolve(f.path, await fs8.readlink(p)));
778
+ if (!(await fs9.lstat(p)).isSymbolicLink()) continue;
779
+ target = await fs9.realpath(p).catch(async () => path12.resolve(f.path, await fs9.readlink(p)));
721
780
  } catch {
722
781
  continue;
723
782
  }
724
- const owner = roots.find((r) => target === r.real || target.startsWith(r.real + path11.sep));
783
+ const owner = roots.find((r) => target === r.real || target.startsWith(r.real + path12.sep));
725
784
  if (owner) {
726
785
  out.set(`${f.category}/${f.id}`, owner.id);
727
786
  break;
@@ -766,7 +825,7 @@ async function ensurePackages(ctx, pkgs, opts = {}) {
766
825
  async function maybeInstall(ctx, pkg, dir, opts, res) {
767
826
  if (!pkg.install) return;
768
827
  if (opts.yes) {
769
- ctx.log(` $ (${path11.relative(ctx.adapter.root, dir) || "."}) ${pkg.install}`);
828
+ ctx.log(` $ (${path12.relative(ctx.adapter.root, dir) || "."}) ${pkg.install}`);
770
829
  await runShell(pkg.install, dir);
771
830
  } else {
772
831
  res.pendingInstalls.push({ id: pkg.id, dir, cmd: pkg.install });
@@ -807,93 +866,296 @@ async function updatePackages(ctx, pkgs, opts = {}) {
807
866
  return res;
808
867
  }
809
868
 
810
- // src/core/init.ts
811
- async function init(ctx, opts = {}) {
812
- const profileName = opts.profile ?? "default";
813
- const exclude = opts.exclude ?? [];
814
- const skipped = [];
815
- if (await exists(manifestPath(ctx))) {
816
- throw new Error(`\uC774\uBBF8 \uCD08\uAE30\uD654\uB41C \uCC3D\uACE0\uC785\uB2C8\uB2E4: ${manifestPath(ctx)}
817
- \uB2E4\uB978 \uD658\uACBD\uC758 \uC124\uC815\uC744 \uC774 \uCC3D\uACE0\uB85C \uAC00\uC838\uC624\uB824\uBA74 'lshed restore' \uD6C4 'lshed save' \uB97C \uC4F0\uC138\uC694.`);
818
- }
819
- const all = await ctx.adapter.scan();
820
- const m = { version: 1, agent: ctx.adapter.name, components: {}, packages: [], profiles: { [profileName]: {} } };
869
+ // src/core/discover.ts
870
+ var keyOf = (f) => `${f.category}/${f.id}`;
871
+ async function discover(ctx, exclude = []) {
821
872
  const isExcluded = (cat, id) => exclude.some((e) => e === id || e === `${cat}/${id}`);
822
- const pkgs = (await detectPackages(ctx, all)).filter((p) => !isExcluded("", p.id));
823
- const generated = await detectGenerated(all, pkgs);
824
- const found = all.filter((f) => !pkgs.some((p) => p.path === f.path) && !generated.has(`${f.category}/${f.id}`));
825
- for (const p of pkgs) {
826
- m.packages.push(p.into ? { id: p.id, source: p.source, into: p.into } : { id: p.id, source: p.source });
827
- const rev = /^[0-9a-f]{40}$/.test(p.rev) ? p.rev.slice(0, 7) : p.rev;
828
- ctx.log(` \u2261 package ${p.id} ${p.source} @${rev} (\uCC38\uC870\uB9CC \uAE30\uB85D)`);
829
- }
830
- if (pkgs.length) m.profiles[profileName][PACKAGES] = pkgs.map((p) => p.id);
831
- for (const [key, by] of generated) ctx.log(` \xB7 ${key} (${by} \uAC00 \uC0DD\uC131\uD55C \uAC83 \u2192 \uAC74\uB108\uB700)`);
832
- const managed = [];
833
- let copied = 0;
873
+ const excluded = [];
874
+ const items = [];
875
+ const all = await ctx.adapter.scan();
876
+ const pkgs = await detectPackages(ctx, all);
877
+ const kept = pkgs.filter((p) => {
878
+ if (isExcluded("packages", p.id)) {
879
+ excluded.push(`packages/${p.id}`);
880
+ return false;
881
+ }
882
+ return true;
883
+ });
884
+ const generated = await detectGenerated(all, kept);
885
+ for (const p of kept) items.push({ kind: "package", category: "packages", id: p.id, pkg: p });
834
886
  for (const cat of ctx.adapter.categories()) {
835
- const mine = found.filter((f) => f.category === cat.name && !isExcluded(f.category, f.id));
836
- for (const f of found.filter((f2) => f2.category === cat.name && isExcluded(f2.category, f2.id))) {
837
- skipped.push(`${f.category}/${f.id}`);
838
- ctx.log(` - ${f.category}/${f.id} (--exclude)`);
887
+ for (const f of all.filter((f2) => f2.category === cat.name)) {
888
+ if (pkgs.some((p) => p.path === f.path) || generated.has(keyOf(f))) continue;
889
+ if (isExcluded(f.category, f.id)) {
890
+ excluded.push(keyOf(f));
891
+ continue;
892
+ }
893
+ items.push({ kind: "component", category: cat.name, id: f.id, path: f.path, cat });
894
+ }
895
+ }
896
+ for (const cat of ctx.adapter.entries()) {
897
+ const all2 = await cat.read();
898
+ for (const id of Object.keys(all2).sort()) {
899
+ if (isExcluded(cat.name, id)) {
900
+ excluded.push(`${cat.name}/${id}`);
901
+ continue;
902
+ }
903
+ if (!/^[\w.-]+$/.test(id)) {
904
+ ctx.log(` ! ${cat.name}/${id}: \uC774\uB984\uC5D0 \uC4F8 \uC218 \uC5C6\uB294 \uBB38\uC790\uAC00 \uC788\uC5B4 \uAC74\uB108\uB700`);
905
+ continue;
906
+ }
907
+ items.push({ kind: "entry", category: cat.name, id, value: all2[id], cat });
908
+ }
909
+ }
910
+ return { items, generated, excluded };
911
+ }
912
+ function notInManifest(m, d) {
913
+ return d.items.filter(
914
+ (f) => f.kind === "package" ? !m.packages.some((p) => p.id === f.id) : !(m.components[f.category] ?? []).some((c) => c.id === f.id)
915
+ );
916
+ }
917
+ function inManifestNotInProfile(m, profile, d) {
918
+ const p = m.profiles[profile] ?? {};
919
+ return d.items.filter((f) => !notInManifest(m, d).includes(f)).filter((f) => !(p[f.category] ?? []).includes(f.id)).map(keyOf);
920
+ }
921
+ function shortRev(rev) {
922
+ return /^[0-9a-f]{40}$/.test(rev) ? rev.slice(0, 7) : rev;
923
+ }
924
+
925
+ // src/core/ingest.ts
926
+ import path15 from "path";
927
+ import { isSeq, isMap } from "yaml";
928
+
929
+ // src/core/entries.ts
930
+ import { promises as fs10 } from "fs";
931
+ import path14 from "path";
932
+ var SECRET_KEY_RE = /key|token|secret|pass|auth|credential|cookie|session/i;
933
+ var PLACEHOLDER_RE = /\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}/g;
934
+ function placeholdersIn(v) {
935
+ const out = /* @__PURE__ */ new Set();
936
+ walk(v, (s) => {
937
+ for (const m of s.matchAll(PLACEHOLDER_RE)) out.add(m[1]);
938
+ return s;
939
+ });
940
+ return [...out];
941
+ }
942
+ function walk(v, onString, keyPath = []) {
943
+ if (typeof v === "string") return onString(v, keyPath);
944
+ if (Array.isArray(v)) return v.map((x2, i) => walk(x2, onString, [...keyPath, String(i)]));
945
+ if (v && typeof v === "object") return Object.fromEntries(Object.entries(v).map(([k, x2]) => [k, walk(x2, onString, [...keyPath, k])]));
946
+ return v;
947
+ }
948
+ var envName = (...parts) => parts.join("_").replace(/[^A-Za-z0-9]+/g, "_").replace(/^_+|_+$/g, "").toUpperCase();
949
+ function mask(id, entry, cat) {
950
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) return entry;
951
+ const out = { ...entry };
952
+ for (const sk of cat.secretKeys) {
953
+ const sect = out[sk];
954
+ if (!sect || typeof sect !== "object" || Array.isArray(sect)) continue;
955
+ const masked = {};
956
+ for (const [k, v] of Object.entries(sect)) {
957
+ if (typeof v !== "string" || !SECRET_KEY_RE.test(k) || PLACEHOLDER_RE.test(v)) {
958
+ masked[k] = v;
959
+ PLACEHOLDER_RE.lastIndex = 0;
960
+ continue;
961
+ }
962
+ const name = sk === "env" ? envName(k) : envName(id, k);
963
+ const scheme = /^(\w+) \S+$/.exec(v);
964
+ masked[k] = scheme ? `${scheme[1]} \${${name}}` : `\${${name}}`;
965
+ }
966
+ out[sk] = masked;
967
+ }
968
+ return out;
969
+ }
970
+ function suspiciousStrings(entry) {
971
+ const out = [];
972
+ walk(entry, (s, kp) => {
973
+ if (/^(sk-|ghp_|github_pat_|xox[abp]-|AKIA|glpat-|ntn_|secret_)[A-Za-z0-9_-]{8,}/.test(s)) out.push(kp.join("."));
974
+ return s;
975
+ });
976
+ return out;
977
+ }
978
+ function expand(entry, env = process.env) {
979
+ const missing = /* @__PURE__ */ new Set();
980
+ const value = walk(
981
+ entry,
982
+ (s) => s.replace(PLACEHOLDER_RE, (whole, name, def) => {
983
+ if (env[name] !== void 0) return env[name];
984
+ if (def !== void 0) return def;
985
+ missing.add(name);
986
+ return whole;
987
+ })
988
+ );
989
+ return { value, missing: [...missing] };
990
+ }
991
+ function stringMatches(shed, local) {
992
+ if (shed === local) return true;
993
+ if (!PLACEHOLDER_RE.test(shed)) {
994
+ PLACEHOLDER_RE.lastIndex = 0;
995
+ return false;
996
+ }
997
+ PLACEHOLDER_RE.lastIndex = 0;
998
+ const re = "^" + shed.split(PLACEHOLDER_RE).map((part, i) => i % 3 === 0 ? part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") : i % 3 === 1 ? ".+" : "").join("") + "$";
999
+ return new RegExp(re).test(local);
1000
+ }
1001
+ function matches2(shed, local) {
1002
+ if (typeof shed === "string" && typeof local === "string") return stringMatches(shed, local);
1003
+ if (Array.isArray(shed) && Array.isArray(local)) return shed.length === local.length && shed.every((x2, i) => matches2(x2, local[i]));
1004
+ if (shed && local && typeof shed === "object" && typeof local === "object" && !Array.isArray(shed) && !Array.isArray(local)) {
1005
+ const a = Object.keys(shed).sort(), b = Object.keys(local).sort();
1006
+ return a.length === b.length && a.every((k, i) => k === b[i] && matches2(shed[k], local[k]));
1007
+ }
1008
+ return shed === local;
1009
+ }
1010
+ function remask(id, local, shed, cat) {
1011
+ const keep = (l, s) => {
1012
+ if (typeof l === "string" && typeof s === "string" && stringMatches(s, l)) return s;
1013
+ if (Array.isArray(l) && Array.isArray(s)) return l.map((x2, i) => keep(x2, s[i]));
1014
+ if (l && s && typeof l === "object" && typeof s === "object" && !Array.isArray(l) && !Array.isArray(s)) {
1015
+ return Object.fromEntries(Object.entries(l).map(([k, x2]) => [k, keep(x2, s[k])]));
839
1016
  }
840
- if (!mine.length) continue;
841
- m.components[cat.name] = [];
842
- m.profiles[profileName][cat.name] = [];
843
- for (const f of mine) {
844
- const dst = path12.join(ctx.shed, cat.root, cat.kind === "dir" ? f.id : `${f.id}.md`);
1017
+ return l;
1018
+ };
1019
+ return mask(id, shed === null ? local : keep(local, shed), cat);
1020
+ }
1021
+ function diffEntry(shed, local) {
1022
+ const out = [];
1023
+ const go = (s, l, kp) => {
1024
+ if (s === void 0) {
1025
+ out.push({ status: "A", file: kp || "(entry)" });
1026
+ return;
1027
+ }
1028
+ if (l === void 0) {
1029
+ out.push({ status: "D", file: kp || "(entry)" });
1030
+ return;
1031
+ }
1032
+ const objs = s && l && typeof s === "object" && typeof l === "object" && !Array.isArray(s) && !Array.isArray(l);
1033
+ if (objs) {
1034
+ for (const k of (/* @__PURE__ */ new Set([...Object.keys(s), ...Object.keys(l)])).values()) go(s[k], l[k], kp ? `${kp}.${k}` : k);
1035
+ return;
1036
+ }
1037
+ if (!matches2(s, l)) out.push({ status: "M", file: kp || "(entry)" });
1038
+ };
1039
+ go(shed, local, "");
1040
+ return out.sort((a, b) => a.file.localeCompare(b.file));
1041
+ }
1042
+ async function readEntryFile(p) {
1043
+ try {
1044
+ return JSON.parse(await fs10.readFile(p, "utf8"));
1045
+ } catch (e) {
1046
+ if (e.code === "ENOENT") return null;
1047
+ throw new Error(`${p}: JSON \uC744 \uC77D\uC744 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4: ${e.message}`);
1048
+ }
1049
+ }
1050
+ async function writeEntryFile(p, v) {
1051
+ await fs10.mkdir(path14.dirname(p), { recursive: true });
1052
+ await fs10.writeFile(p, JSON.stringify(v, null, 2) + "\n");
1053
+ }
1054
+
1055
+ // src/core/ingest.ts
1056
+ function seqAt(doc, p) {
1057
+ let node = doc.getIn(p);
1058
+ if (!isSeq(node)) {
1059
+ node = doc.createNode([]);
1060
+ doc.setIn(p, node);
1061
+ }
1062
+ return node;
1063
+ }
1064
+ function pushUnique(seq, value) {
1065
+ if (!seq.items.some((it) => (isMap(it) ? it.get("id") : String(it)) === value)) seq.add(value);
1066
+ }
1067
+ async function ingest(ctx, doc, profile, items, lock) {
1068
+ const out = { managed: [], copied: 0, packages: [] };
1069
+ for (const f of items) {
1070
+ if (f.kind === "package") {
1071
+ const p = f.pkg;
1072
+ const node = doc.createNode(p.into ? { id: p.id, source: p.source, into: p.into } : { id: p.id, source: p.source });
1073
+ if (p.into) node.comment = " install: ./setup # \u2190 \uBCF5\uC6D0 \uD6C4 \uC2E4\uD589\uD560 \uBA85\uB839\uC774 \uC788\uC73C\uBA74 \uCC44\uC6B0\uC138\uC694 (--yes \uB85C \uC2E4\uD589)";
1074
+ const seq = seqAt(doc, [PACKAGES]);
1075
+ if (!seq.items.some((it) => isMap(it) && it.get("id") === p.id)) seq.add(node);
1076
+ pushUnique(seqAt(doc, ["profiles", profile, PACKAGES]), p.id);
1077
+ lock.packages[p.id] = { source: p.source, rev: p.rev };
1078
+ out.packages.push(p.id);
1079
+ ctx.log(` \u2261 package ${p.id} ${p.source} @${shortRev(p.rev)} (\uCC38\uC870\uB9CC \uAE30\uB85D)`);
1080
+ continue;
1081
+ }
1082
+ if (f.kind === "component") {
1083
+ const dst = path15.join(ctx.shed, f.cat.root, f.cat.kind === "dir" ? f.id : `${f.id}.md`);
845
1084
  await copyTree(f.path, dst, ignoreOf(ctx));
846
- m.components[cat.name].push({ id: f.id });
847
- m.profiles[profileName][cat.name].push(f.id);
848
- managed.push(targetRel(cat, f.id));
849
- copied++;
850
- ctx.log(` + ${cat.name}/${f.id}`);
1085
+ ctx.log(` + ${f.category}/${f.id}`);
1086
+ } else {
1087
+ const masked = mask(f.id, f.value, f.cat);
1088
+ await writeEntryFile(path15.join(ctx.shed, f.category, `${f.id}.json`), masked);
1089
+ const vars = placeholdersIn(masked);
1090
+ ctx.log(` + ${f.category}/${f.id}${vars.length ? ` (\uC2DC\uD06C\uB9BF \u2192 ${vars.map((v) => "${" + v + "}").join(", ")})` : ""}`);
1091
+ for (const where of suspiciousStrings(masked)) ctx.log(` ! ${where} \uAC00 \uC2DC\uD06C\uB9BF\uCC98\uB7FC \uBCF4\uC785\uB2C8\uB2E4. \uCC3D\uACE0\uC758 ${f.category}/${f.id}.json \uC5D0\uC11C \${VAR} \uB85C \uBC14\uAFB8\uC138\uC694`);
851
1092
  }
1093
+ const comps = seqAt(doc, ["components", f.category]);
1094
+ if (!comps.items.some((it) => isMap(it) && it.get("id") === f.id)) comps.add(doc.createNode({ id: f.id }));
1095
+ pushUnique(seqAt(doc, ["profiles", profile, f.category]), f.id);
1096
+ out.managed.push(targetRel(f.cat, f.id));
1097
+ out.copied++;
852
1098
  }
1099
+ return out;
1100
+ }
1101
+ function tidy(doc) {
1102
+ const pk = doc.get(PACKAGES);
1103
+ if (isSeq(pk) && !pk.items.length) doc.delete(PACKAGES);
1104
+ }
1105
+
1106
+ // src/core/init.ts
1107
+ var MANIFEST_HEADER = "# lshed manifest \u2014 edit freely. Reference: https://github.com/LeeSongHeon-LSH/lshed\n";
1108
+ async function init(ctx, opts = {}) {
1109
+ const profileName = opts.profile ?? "default";
1110
+ if (await exists(manifestPath(ctx))) {
1111
+ throw new Error(`\uC774\uBBF8 \uCD08\uAE30\uD654\uB41C \uCC3D\uACE0\uC785\uB2C8\uB2E4: ${manifestPath(ctx)}
1112
+ \uC774 \uD658\uACBD\uC5D0 \uC0C8\uB85C \uC0DD\uAE34 \uAC83\uC744 \uAE30\uC874 \uCC3D\uACE0\uC5D0 \uB123\uC73C\uB824\uBA74 'lshed add' \uB97C, \uB2E4\uB978 \uD658\uACBD\uC758 \uC124\uC815\uC744 \uAC00\uC838\uC624\uB824\uBA74 'lshed restore' \uD6C4 'lshed save' \uB97C \uC4F0\uC138\uC694.`);
1113
+ }
1114
+ const d = await discover(ctx, opts.exclude);
1115
+ for (const [key, by] of d.generated) ctx.log(` \xB7 ${key} (${by} \uAC00 \uC0DD\uC131\uD55C \uAC83 \u2192 \uAC74\uB108\uB700)`);
1116
+ for (const key of d.excluded) ctx.log(` - ${key} (--exclude)`);
1117
+ const exclude = opts.exclude?.length ? { exclude: [...opts.exclude] } : {};
1118
+ const doc = new YAML4.Document({ version: 1, agent: ctx.adapter.name, ...exclude, components: {}, packages: [], profiles: { [profileName]: {} } });
1119
+ const lock = { version: 1, packages: {} };
1120
+ await fs11.mkdir(ctx.shed, { recursive: true });
1121
+ const res = await ingest(ctx, doc, profileName, d.items, lock);
1122
+ const managed = [...res.managed];
1123
+ let copied = res.copied;
853
1124
  const instr = instructionsFile(ctx);
854
1125
  if (await exists(instr)) {
855
- const text = await fs9.readFile(instr, "utf8");
1126
+ const text = await fs11.readFile(instr, "utf8");
856
1127
  if (!isGenerated(text)) {
857
- const dst = path12.join(ctx.shed, INSTRUCTIONS, "main.md");
858
- await fs9.mkdir(path12.dirname(dst), { recursive: true });
859
- await fs9.writeFile(dst, text);
1128
+ const dst = path16.join(ctx.shed, INSTRUCTIONS, "main.md");
1129
+ await fs11.mkdir(path16.dirname(dst), { recursive: true });
1130
+ await fs11.writeFile(dst, text);
860
1131
  const fragRel = targetRel(INSTRUCTIONS, "main");
861
1132
  await copyTree(dst, abs(ctx, fragRel), ignoreOf(ctx));
862
1133
  managed.push(fragRel);
863
- m.components[INSTRUCTIONS] = [{ id: "main" }];
864
- m.profiles[profileName][INSTRUCTIONS] = ["main"];
1134
+ doc.setIn(["components", INSTRUCTIONS], [{ id: "main" }]);
1135
+ doc.setIn(["profiles", profileName, INSTRUCTIONS], ["main"]);
865
1136
  copied++;
866
- ctx.log(` + ${INSTRUCTIONS}/main (${path12.basename(instr)})`);
1137
+ ctx.log(` + ${INSTRUCTIONS}/main (${path16.basename(instr)})`);
867
1138
  }
868
1139
  }
869
- await fs9.mkdir(ctx.shed, { recursive: true });
870
- let yamlText = stringifyManifest(m);
871
- for (const p of pkgs) {
872
- if (!p.into) continue;
873
- yamlText = yamlText.replace(` into: ${p.into}
874
- `, ` into: ${p.into}
875
- # install: ./setup # \u2190 \uBCF5\uC6D0 \uD6C4 \uC2E4\uD589\uD560 \uBA85\uB839\uC774 \uC788\uC73C\uBA74 \uCC44\uC6B0\uC138\uC694 (--yes \uB85C \uC2E4\uD589)
876
- `);
877
- }
878
- await fs9.writeFile(manifestPath(ctx), `# lshed manifest \u2014 edit freely. Reference: https://github.com/LeeSongHeon-LSH/lshed
879
- ` + yamlText);
880
- if (pkgs.length) {
881
- await writeLock(ctx.shed, { version: 1, packages: Object.fromEntries(pkgs.map((p) => [p.id, { source: p.source, rev: p.rev }])) });
882
- }
1140
+ tidy(doc);
1141
+ const yamlText = MANIFEST_HEADER + doc.toString({ lineWidth: 0 });
1142
+ await fs11.writeFile(manifestPath(ctx), yamlText);
1143
+ if (res.packages.length) await writeLock(ctx.shed, lock);
883
1144
  await writeState(ctx.adapter, { profile: profileName, shed: ctx.shed, managed, appliedAt: (/* @__PURE__ */ new Date()).toISOString() });
884
1145
  const parts = [`\uBD80\uD488 ${copied}\uAC1C`];
885
- if (pkgs.length) parts.push(`\uD328\uD0A4\uC9C0 ${pkgs.length}\uAC1C`);
886
- if (generated.size) parts.push(`\uC0DD\uC131\uBB3C ${generated.size}\uAC1C \uAC74\uB108\uB700`);
887
- if (skipped.length) parts.push(`\uC81C\uC678 ${skipped.length}\uAC1C`);
1146
+ if (res.packages.length) parts.push(`\uD328\uD0A4\uC9C0 ${res.packages.length}\uAC1C`);
1147
+ if (d.generated.size) parts.push(`\uC0DD\uC131\uBB3C ${d.generated.size}\uAC1C \uAC74\uB108\uB700`);
1148
+ if (d.excluded.length) parts.push(`\uC81C\uC678 ${d.excluded.length}\uAC1C`);
888
1149
  ctx.log(`
889
1150
  ${MANIFEST_FILE} \uC0DD\uC131: ${manifestPath(ctx)} (${parts.join(", ")}, \uD504\uB85C\uD544 "${profileName}")`);
890
- if (pkgs.some((p) => p.into)) ctx.log(`git \uD328\uD0A4\uC9C0\uC758 \uC124\uCE58 \uBA85\uB839(install:)\uC740 lshed.yaml \uC5D0\uC11C \uC9C1\uC811 \uCC44\uC6B0\uC138\uC694.`);
891
- return { manifest: m, copied, skipped, packages: pkgs.map((p) => p.id), generated: [...generated.keys()] };
1151
+ if (d.items.some((f) => f.kind === "package" && f.pkg.into)) ctx.log(`git \uD328\uD0A4\uC9C0\uC758 \uC124\uCE58 \uBA85\uB839(install:)\uC740 lshed.yaml \uC5D0\uC11C \uC9C1\uC811 \uCC44\uC6B0\uC138\uC694.`);
1152
+ const manifest = parseManifest(yamlText);
1153
+ return { manifest, copied, skipped: d.excluded, packages: res.packages, generated: [...d.generated.keys()] };
892
1154
  }
893
1155
 
894
1156
  // src/core/restore.ts
895
- import { promises as fs10 } from "fs";
896
- import path13 from "path";
1157
+ import { promises as fs12 } from "fs";
1158
+ import path17 from "path";
897
1159
  async function restore(ctx, profileArg, opts = {}) {
898
1160
  const backup = opts.backup ?? true;
899
1161
  const state = await readState(ctx.adapter);
@@ -912,22 +1174,59 @@ async function restore(ctx, profileArg, opts = {}) {
912
1174
  const oldManaged = new Set(state?.managed ?? []);
913
1175
  const toRemove = [...oldManaged].filter((r) => !newManaged.has(r)).sort();
914
1176
  const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
915
- const backupDir = path13.join(ctx.adapter.root, LSHED_DIR, "backups", stamp);
1177
+ const backupDir = path17.join(ctx.adapter.root, LSHED_DIR, "backups", stamp);
916
1178
  const backedUp = [];
917
1179
  const placed = [];
1180
+ const missingEnv = [];
1181
+ const localEntries = /* @__PURE__ */ new Map();
1182
+ const entriesOf = async (cat) => {
1183
+ if (!localEntries.has(cat.name)) localEntries.set(cat.name, await cat.read());
1184
+ return localEntries.get(cat.name);
1185
+ };
918
1186
  async function backUp(rel) {
1187
+ const en = entryOf(ctx, rel);
1188
+ if (en) {
1189
+ const cur = (await entriesOf(en.cat))[en.id];
1190
+ if (cur === void 0) return;
1191
+ backedUp.push(rel);
1192
+ if (opts.dryRun || !backup) return;
1193
+ await writeEntryFile(path17.join(backupDir, en.cat.name, `${en.id}.json`), cur);
1194
+ return;
1195
+ }
919
1196
  const from = abs(ctx, rel);
920
1197
  if (!await exists(from)) return;
921
1198
  backedUp.push(rel);
922
1199
  if (opts.dryRun || !backup) return;
923
- await copyTree(from, path13.join(backupDir, ...rel.split("/")), ignoreOf(ctx));
1200
+ await copyTree(from, path17.join(backupDir, ...rel.split("/")), ignoreOf(ctx));
924
1201
  }
925
1202
  for (const rel of toRemove) {
926
1203
  ctx.log(` - ${rel}`);
927
1204
  await backUp(rel);
928
- if (!opts.dryRun) await removeTree(abs(ctx, rel));
1205
+ if (opts.dryRun) continue;
1206
+ const en = entryOf(ctx, rel);
1207
+ if (en) await en.cat.write(en.id, null);
1208
+ else await removeTree(abs(ctx, rel));
929
1209
  }
930
1210
  for (const it of plan) {
1211
+ if (it.entry) {
1212
+ const shed = await readEntryFile(it.src);
1213
+ const local = (await entriesOf(it.entry))[it.id];
1214
+ const vars = placeholdersIn(shed);
1215
+ const ex = expand(shed);
1216
+ if (ex.missing.length) missingEnv.push({ rel: it.rel, vars: ex.missing });
1217
+ const value = it.entry.expandsEnv ? shed : ex.value;
1218
+ const same2 = local !== void 0 && matches2(shed, local);
1219
+ const mark2 = same2 ? "=" : local !== void 0 ? "~" : "+";
1220
+ ctx.log(` ${mark2} ${it.rel}${vars.length ? ` (${vars.map((v) => "${" + v + "}").join(", ")})` : ""}`);
1221
+ if (same2) {
1222
+ placed.push(it.rel);
1223
+ continue;
1224
+ }
1225
+ if (local !== void 0) await backUp(it.rel);
1226
+ if (!opts.dryRun) await it.entry.write(it.id, value);
1227
+ placed.push(it.rel);
1228
+ continue;
1229
+ }
931
1230
  const target = abs(ctx, it.rel);
932
1231
  const same = await hashTree(target, ignoreOf(ctx)) === await hashTree(it.src, ignoreOf(ctx));
933
1232
  const mark = same ? "=" : await exists(target) ? "~" : "+";
@@ -943,14 +1242,14 @@ async function restore(ctx, profileArg, opts = {}) {
943
1242
  const instrPath = instructionsFile(ctx);
944
1243
  if (fragments.length) {
945
1244
  const contents = [];
946
- for (const f of fragments) contents.push({ id: f.id, content: await fs10.readFile(f.src, "utf8") });
1245
+ for (const f of fragments) contents.push({ id: f.id, content: await fs12.readFile(f.src, "utf8") });
947
1246
  const rendered = renderInstructions(ctx, profile, contents);
948
- const existing = await exists(instrPath) ? await fs10.readFile(instrPath, "utf8") : null;
1247
+ const existing = await exists(instrPath) ? await fs12.readFile(instrPath, "utf8") : null;
949
1248
  if (existing !== rendered) {
950
1249
  const mark = existing === null ? "+" : "~";
951
1250
  ctx.log(` ${mark} ${instrRel}${existing !== null && !isGenerated(existing) ? " (\uAE30\uC874 \uD30C\uC77C\uC740 lshed \uC0DD\uC131\uBB3C\uC774 \uC544\uB2D8 \u2192 \uBC31\uC5C5)" : ""}`);
952
1251
  if (existing !== null) await backUp(instrRel);
953
- if (!opts.dryRun) await fs10.writeFile(instrPath, rendered);
1252
+ if (!opts.dryRun) await fs12.writeFile(instrPath, rendered);
954
1253
  } else {
955
1254
  ctx.log(` = ${instrRel}`);
956
1255
  }
@@ -960,14 +1259,22 @@ async function restore(ctx, profileArg, opts = {}) {
960
1259
  ctx.log(`
961
1260
  (dry-run) \uBCC0\uACBD \uC5C6\uC74C. \uBC30\uCE58 ${placed.length}, \uC81C\uAC70 ${toRemove.length}, \uBC31\uC5C5 \uC608\uC815 ${backedUp.length}`);
962
1261
  reportPending(ctx, pkgRes);
963
- return { profile, placed, removed: toRemove, backedUp, backupDir: null };
1262
+ reportMissingEnv(ctx, missingEnv);
1263
+ return { profile, placed, removed: toRemove, backedUp, backupDir: null, missingEnv };
964
1264
  }
965
1265
  await writeState(ctx.adapter, { profile, shed: ctx.shed, managed: [...newManaged].sort(), appliedAt: (/* @__PURE__ */ new Date()).toISOString() });
966
1266
  const bdir = backup && backedUp.length ? backupDir : null;
967
1267
  ctx.log(`
968
1268
  \uD504\uB85C\uD544 "${profile}" \uC801\uC6A9: \uBC30\uCE58 ${placed.length}, \uC81C\uAC70 ${toRemove.length}${pkgRes.installed.length ? `, \uD328\uD0A4\uC9C0 \uC124\uCE58 ${pkgRes.installed.length}` : ""}${bdir ? `, \uBC31\uC5C5 ${backedUp.length} \u2192 ${bdir}` : ""}`);
969
1269
  reportPending(ctx, pkgRes);
970
- return { profile, placed, removed: toRemove, backedUp, backupDir: bdir };
1270
+ reportMissingEnv(ctx, missingEnv);
1271
+ return { profile, placed, removed: toRemove, backedUp, backupDir: bdir, missingEnv };
1272
+ }
1273
+ function reportMissingEnv(ctx, missing) {
1274
+ if (!missing.length) return;
1275
+ ctx.log(`
1276
+ \uD658\uACBD\uBCC0\uC218\uAC00 \uC5C6\uB294 \uD56D\uBAA9\uC774 \uC788\uC2B5\uB2C8\uB2E4. \uC2DC\uD06C\uB9BF \uAC12\uC740 \uCC3D\uACE0\uC5D0 \uB2F4\uC9C0 \uC54A\uC73C\uBBC0\uB85C \uC774 \uAE30\uAE30\uC758 \uC178 \uD658\uACBD\uC5D0 \uB123\uC73C\uC138\uC694 (\uC608: ~/.zshrc \uC758 export):`);
1277
+ for (const m of missing) ctx.log(` ${m.rel}: ${m.vars.join(", ")}`);
971
1278
  }
972
1279
 
973
1280
  // src/core/diff.ts
@@ -976,8 +1283,16 @@ async function diff(ctx) {
976
1283
  if (!state) throw new Error("\uC801\uC6A9\uB41C \uD504\uB85C\uD544\uC774 \uC5C6\uC2B5\uB2C8\uB2E4. \uBA3C\uC800 'lshed restore <profile>' \uC744 \uC2E4\uD589\uD558\uC138\uC694.");
977
1284
  const m = await loadManifest(ctx);
978
1285
  const out = [];
1286
+ const localEntries = /* @__PURE__ */ new Map();
979
1287
  for (const item of planProfile(ctx, m, state.profile)) {
980
- const changes = await diffTrees(abs(ctx, item.rel), item.src, ignoreOf(ctx));
1288
+ let changes;
1289
+ if (item.entry) {
1290
+ if (!localEntries.has(item.category)) localEntries.set(item.category, await item.entry.read());
1291
+ const shed = await readEntryFile(item.src);
1292
+ changes = shed === null ? [{ status: "A", file: "(entry)" }] : diffEntry(shed, localEntries.get(item.category)[item.id]);
1293
+ } else {
1294
+ changes = await diffTrees(abs(ctx, item.rel), item.src, ignoreOf(ctx));
1295
+ }
981
1296
  if (changes.length) out.push({ item, changes });
982
1297
  }
983
1298
  return out;
@@ -993,15 +1308,83 @@ function formatDiff(diffs) {
993
1308
  return lines.join("\n");
994
1309
  }
995
1310
 
1311
+ // src/core/add.ts
1312
+ import { promises as fs13 } from "fs";
1313
+ import YAML5 from "yaml";
1314
+ async function candidates(ctx, m, profile, d) {
1315
+ d ??= await discover(ctx, m.exclude);
1316
+ return { fresh: notInManifest(m, d), notInProfile: inManifestNotInProfile(m, profile, d), generated: d.generated };
1317
+ }
1318
+ async function add(ctx, keys = [], opts = {}) {
1319
+ const state = await readState(ctx.adapter);
1320
+ if (!state) throw new Error("\uC801\uC6A9\uB41C \uD504\uB85C\uD544\uC774 \uC5C6\uC2B5\uB2C8\uB2E4. \uBA3C\uC800 'lshed init' \uB610\uB294 'lshed restore <profile>' \uC744 \uC2E4\uD589\uD558\uC138\uC694.");
1321
+ const m = await loadManifest(ctx);
1322
+ const c = await candidates(ctx, m, state.profile);
1323
+ if (!keys.length && !opts.all) {
1324
+ if (!c.fresh.length) ctx.log("\uCC3D\uACE0\uC5D0 \uC5C6\uB294 \uC0C8 \uD56D\uBAA9\uC774 \uC5C6\uC2B5\uB2C8\uB2E4.");
1325
+ else {
1326
+ ctx.log(`\uCC3D\uACE0\uC5D0 \uC5C6\uB294 \uD56D\uBAA9 ${c.fresh.length}\uAC1C (\uB123\uC73C\uB824\uBA74 lshed add <key...> \uB610\uB294 --all):`);
1327
+ for (const f of c.fresh) ctx.log(` ${f.kind === "package" ? "\u2261" : " "} ${keyOf(f)}${f.kind === "package" ? ` ${f.pkg.source}` : ""}`);
1328
+ }
1329
+ hint(ctx, c, state.profile);
1330
+ return [];
1331
+ }
1332
+ let chosen = c.fresh;
1333
+ if (keys.length) {
1334
+ chosen = keys.map((raw) => {
1335
+ const [a, b] = raw.includes("/") ? raw.split("/", 2) : [void 0, raw];
1336
+ const hits = c.fresh.filter((f) => f.id === b && (a === void 0 || f.category === a));
1337
+ if (!hits.length) {
1338
+ const known = (m.components[a ?? ""] ?? []).some((x2) => x2.id === b) || Object.values(m.components).some((cs) => cs.some((x2) => x2.id === b)) || m.packages.some((p) => p.id === b);
1339
+ throw new Error(known ? `"${raw}" \uB294 \uC774\uBBF8 \uCC3D\uACE0\uC5D0 \uC788\uC2B5\uB2C8\uB2E4. \uD504\uB85C\uD544\uC5D0 \uB123\uC73C\uB824\uBA74 lshed.yaml \uC758 profiles \uB97C \uACE0\uCE58\uC138\uC694.` : `"${raw}" \uB294 \uB85C\uCEEC\uC5D0\uC11C \uCC3E\uC9C0 \uBABB\uD588\uC2B5\uB2C8\uB2E4. 'lshed add' \uB85C \uD6C4\uBCF4\uB97C \uBCF4\uC138\uC694.`);
1340
+ }
1341
+ if (hits.length > 1) throw new Error(`"${raw}" \uAC00 \uBAA8\uD638\uD569\uB2C8\uB2E4: ${hits.map(keyOf).join(", ")}`);
1342
+ return hits[0];
1343
+ });
1344
+ }
1345
+ if (!chosen.length) {
1346
+ ctx.log("\uCC3D\uACE0\uC5D0 \uC5C6\uB294 \uC0C8 \uD56D\uBAA9\uC774 \uC5C6\uC2B5\uB2C8\uB2E4.");
1347
+ hint(ctx, c, state.profile);
1348
+ return [];
1349
+ }
1350
+ const doc = YAML5.parseDocument(await fs13.readFile(manifestPath(ctx), "utf8"));
1351
+ const lock = await readLock(ctx.shed);
1352
+ const res = await ingest(ctx, doc, state.profile, chosen, lock);
1353
+ tidy(doc);
1354
+ await fs13.writeFile(manifestPath(ctx), doc.toString({ lineWidth: 0 }));
1355
+ if (res.packages.length) await writeLock(ctx.shed, lock);
1356
+ const managed = [.../* @__PURE__ */ new Set([...state.managed, ...res.managed])].sort();
1357
+ await writeState(ctx.adapter, { ...state, managed, appliedAt: (/* @__PURE__ */ new Date()).toISOString() });
1358
+ const added = chosen.map(keyOf);
1359
+ ctx.log(`
1360
+ ${added.length}\uAC1C\uB97C \uCC3D\uACE0\uC5D0 \uB123\uACE0 \uD504\uB85C\uD544 "${state.profile}" \uC5D0 \uCD94\uAC00\uD588\uC2B5\uB2C8\uB2E4. \uCC3D\uACE0\uB97C \uCEE4\uBC0B\uD558\uC138\uC694: ${ctx.shed}`);
1361
+ if (res.packages.some((id) => chosen.find((f) => f.id === id && f.kind === "package" && f.pkg.into))) ctx.log(`git \uD328\uD0A4\uC9C0\uC758 \uC124\uCE58 \uBA85\uB839(install:)\uC740 lshed.yaml \uC5D0\uC11C \uC9C1\uC811 \uCC44\uC6B0\uC138\uC694.`);
1362
+ hint(ctx, { ...c, fresh: c.fresh.filter((f) => !chosen.includes(f)) }, state.profile);
1363
+ return added;
1364
+ }
1365
+ function hint(ctx, c, profile) {
1366
+ const byPkg = /* @__PURE__ */ new Map();
1367
+ for (const by of c.generated.values()) byPkg.set(by, (byPkg.get(by) ?? 0) + 1);
1368
+ for (const [by, n] of byPkg) ctx.log(` \xB7 \uD328\uD0A4\uC9C0 ${by} \uAC00 \uC0DD\uC131\uD55C \uAC83 ${n}\uAC1C\uB294 \uB2F4\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4`);
1369
+ if (c.notInProfile.length) ctx.log(`\uCC3D\uACE0\uC5D0\uB294 \uC788\uC9C0\uB9CC \uD504\uB85C\uD544 "${profile}" \uC774 \uC548 \uC4F0\uB294 \uAC83 ${c.notInProfile.length}\uAC1C: ${c.notInProfile.join(", ")} \u2192 lshed.yaml \uC758 profiles \uC5D0 \uCD94\uAC00`);
1370
+ }
1371
+
996
1372
  // src/core/status.ts
997
1373
  async function status(ctx) {
998
1374
  const state = await readState(ctx.adapter);
999
- if (!state) return { state: null, drifted: [], packages: [] };
1375
+ if (!state) return { state: null, drifted: [], packages: [], missingEnv: [], fresh: [] };
1000
1376
  const d = await diff(ctx);
1001
1377
  const m = await loadManifest(ctx);
1002
1378
  const lock = await readLock(ctx.shed);
1003
1379
  const packages = await Promise.all(packagesOf(m, state.profile).map((p) => packageStatus(ctx, p, lock)));
1004
- return { state, drifted: d.map((x2) => `${x2.item.category}/${x2.item.id}`), packages };
1380
+ const missingEnv = [];
1381
+ for (const it of planProfile(ctx, m, state.profile).filter((p) => p.entry)) {
1382
+ const shed = await readEntryFile(it.src);
1383
+ const missing = shed === null ? [] : expand(shed).missing;
1384
+ if (missing.length) missingEnv.push({ rel: it.rel, vars: missing });
1385
+ }
1386
+ const fresh = (await candidates(ctx, m, state.profile)).fresh.map(keyOf);
1387
+ return { state, drifted: d.map((x2) => `${x2.item.category}/${x2.item.id}`), packages, missingEnv, fresh };
1005
1388
  }
1006
1389
  function formatStatus(s, adapterRoot) {
1007
1390
  if (!s.state) return `\uC801\uC6A9\uB41C \uD504\uB85C\uD544\uC774 \uC5C6\uC2B5\uB2C8\uB2E4 (${adapterRoot}).
@@ -1018,6 +1401,8 @@ function formatStatus(s, adapterRoot) {
1018
1401
  const where = !p.present ? "\uC124\uCE58 \uC548 \uB428 \u2192 lshed restore" : !p.locked ? `${short2(p.rev)} (\uB77D \uC5C6\uC74C)` : p.rev === p.locked ? `${short2(p.rev)} = lock` : `${short2(p.rev)} \u2260 lock ${short2(p.locked)} \u2192 lshed update`;
1019
1402
  lines.push(`\uD328\uD0A4\uC9C0 ${p.pkg.id} ${where}`);
1020
1403
  }
1404
+ for (const m of s.missingEnv) lines.push(`\uD658\uACBD\uBCC0\uC218 ${m.rel}: ${m.vars.join(", ")} \uC5C6\uC74C \u2192 \uC178\uC5D0\uC11C export \uD558\uC138\uC694`);
1405
+ if (s.fresh.length) lines.push(`\uCC3D\uACE0 \uBC16 ${s.fresh.length}\uAC1C: ${s.fresh.join(", ")} \u2192 lshed add`);
1021
1406
  return lines.join("\n");
1022
1407
  }
1023
1408
 
@@ -1037,13 +1422,27 @@ async function save(ctx, ids = []) {
1037
1422
  });
1038
1423
  }
1039
1424
  const saved = [];
1425
+ const localEntries = /* @__PURE__ */ new Map();
1040
1426
  for (const it of plan) {
1041
- const kind = it.category === INSTRUCTIONS ? "file" : ctx.adapter.categories().find((c) => c.name === it.category).kind;
1042
- const src = effectiveSource(it.category, it.component, kind);
1427
+ const src = effectiveSource(it.category, it.component, kindOf(ctx, it.category));
1043
1428
  if (!isSaveable(src)) {
1044
1429
  ctx.log(` ! ${it.category}/${it.id}: \uC6D0\uACA9 \uCD9C\uCC98(${src})\uB294 save \uD560 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4`);
1045
1430
  continue;
1046
1431
  }
1432
+ if (it.entry) {
1433
+ if (!localEntries.has(it.category)) localEntries.set(it.category, await it.entry.read());
1434
+ const local2 = localEntries.get(it.category)[it.id];
1435
+ if (local2 === void 0) {
1436
+ ctx.log(` ! ${it.category}/${it.id}: \uB85C\uCEEC\uC5D0 \uC5C6\uC74C (\uAC74\uB108\uB700)`);
1437
+ continue;
1438
+ }
1439
+ const shed = await readEntryFile(it.src);
1440
+ if (shed !== null && matches2(shed, local2)) continue;
1441
+ await writeEntryFile(it.src, remask(it.id, local2, shed, it.entry));
1442
+ saved.push(`${it.category}/${it.id}`);
1443
+ ctx.log(` \u2713 ${it.category}/${it.id} \u2192 \uCC3D\uACE0 (\uC2DC\uD06C\uB9BF\uC740 \uC790\uB9AC\uD45C\uC2DC\uC790\uB85C)`);
1444
+ continue;
1445
+ }
1047
1446
  const local = abs(ctx, it.rel);
1048
1447
  if (!await exists(local)) {
1049
1448
  ctx.log(` ! ${it.category}/${it.id}: \uB85C\uCEEC\uC5D0 \uC5C6\uC74C (\uAC74\uB108\uB700)`);
@@ -1083,9 +1482,9 @@ function formatRows(rows, m) {
1083
1482
  }
1084
1483
 
1085
1484
  // src/core/remove.ts
1086
- import { promises as fs11 } from "fs";
1087
- import path14 from "path";
1088
- import YAML3, { isSeq, isMap } from "yaml";
1485
+ import { promises as fs14 } from "fs";
1486
+ import path18 from "path";
1487
+ import YAML6, { isSeq as isSeq2, isMap as isMap2 } from "yaml";
1089
1488
  function resolveKey(m, raw) {
1090
1489
  const rows = listRows(m);
1091
1490
  const [a, b] = raw.includes("/") ? raw.split("/", 2) : [void 0, raw];
@@ -1099,13 +1498,13 @@ async function remove(ctx, raw) {
1099
1498
  const { category, id } = resolveKey(m, raw);
1100
1499
  const users = listRows(m).find((r) => r.category === category && r.id === id).usedBy;
1101
1500
  if (users.length) throw new Error(`${category}/${id} \uB294 \uD504\uB85C\uD544 ${users.join(", ")} \uC774 \uC4F0\uACE0 \uC788\uC2B5\uB2C8\uB2E4. \uBA3C\uC800 \uD504\uB85C\uD544\uC5D0\uC11C \uBE7C\uC138\uC694.`);
1102
- const text = await fs11.readFile(manifestPath(ctx), "utf8");
1103
- const doc = YAML3.parseDocument(text);
1501
+ const text = await fs14.readFile(manifestPath(ctx), "utf8");
1502
+ const doc = YAML6.parseDocument(text);
1104
1503
  let deleted;
1105
1504
  if (category === PACKAGES) {
1106
1505
  const seq = doc.get(PACKAGES);
1107
- if (!isSeq(seq)) throw new Error("packages \uAC00 \uBAA9\uB85D\uC774 \uC544\uB2D9\uB2C8\uB2E4");
1108
- const idx = seq.items.findIndex((it) => isMap(it) && it.get("id") === id);
1506
+ if (!isSeq2(seq)) throw new Error("packages \uAC00 \uBAA9\uB85D\uC774 \uC544\uB2D9\uB2C8\uB2E4");
1507
+ const idx = seq.items.findIndex((it) => isMap2(it) && it.get("id") === id);
1109
1508
  seq.delete(idx);
1110
1509
  if (!seq.items.length) doc.delete(PACKAGES);
1111
1510
  const lock = await readLock(ctx.shed);
@@ -1116,19 +1515,19 @@ async function remove(ctx, raw) {
1116
1515
  ctx.log(` - package ${id} (\uB9E4\uB2C8\uD398\uC2A4\uD2B8\xB7\uB77D\uC5D0\uC11C \uC81C\uAC70. \uB85C\uCEEC clone \uC740 \uADF8\uB300\uB85C)`);
1117
1516
  } else {
1118
1517
  const seq = doc.getIn(["components", category]);
1119
- if (!isSeq(seq)) throw new Error(`components.${category} \uAC00 \uBAA9\uB85D\uC774 \uC544\uB2D9\uB2C8\uB2E4`);
1120
- const idx = seq.items.findIndex((it) => isMap(it) && it.get("id") === id);
1518
+ if (!isSeq2(seq)) throw new Error(`components.${category} \uAC00 \uBAA9\uB85D\uC774 \uC544\uB2D9\uB2C8\uB2E4`);
1519
+ const idx = seq.items.findIndex((it) => isMap2(it) && it.get("id") === id);
1121
1520
  seq.delete(idx);
1122
1521
  if (!seq.items.length) doc.deleteIn(["components", category]);
1123
1522
  const src = sourcePath(ctx, category, findComponent(m, category, id));
1124
- const inside = !path14.relative(ctx.shed, src).startsWith("..");
1523
+ const inside = !path18.relative(ctx.shed, src).startsWith("..");
1125
1524
  if (inside && await exists(src)) {
1126
1525
  await removeTree(src);
1127
1526
  deleted = src;
1128
1527
  }
1129
1528
  ctx.log(` - ${category}/${id}${deleted ? "" : " (\uCC3D\uACE0 \uBC16 \uACBD\uB85C\uB77C \uD30C\uC77C\uC740 \uB450\uC5C8\uC74C)"}`);
1130
1529
  }
1131
- await fs11.writeFile(manifestPath(ctx), doc.toString());
1530
+ await fs14.writeFile(manifestPath(ctx), doc.toString());
1132
1531
  return { category, id, deleted };
1133
1532
  }
1134
1533
  async function prune(ctx, opts = {}) {
@@ -1153,21 +1552,105 @@ ${removed.length}\uAC1C \uC81C\uAC70. \uCC3D\uACE0\uB97C \uCEE4\uBC0B\uD558\uC13
1153
1552
  return removed;
1154
1553
  }
1155
1554
 
1555
+ // src/core/sync.ts
1556
+ import os2 from "os";
1557
+ async function sync(ctx, opts = {}) {
1558
+ const shed = ctx.shed;
1559
+ const push = opts.push ?? true;
1560
+ if (!await isRepo(shed)) {
1561
+ throw new Error(`\uCC3D\uACE0\uAC00 git \uC800\uC7A5\uC18C\uAC00 \uC544\uB2D9\uB2C8\uB2E4: ${shed}
1562
+ cd ${shed} && git init && git add -A && git commit -m "my harness"
1563
+ \uC6D0\uACA9\uC5D0 \uB450\uB824\uBA74: git remote add origin <url> && git push -u origin HEAD`);
1564
+ }
1565
+ const res = { committed: [], pulled: 0, pushed: false, unsaved: [] };
1566
+ if (await readState(ctx.adapter)) {
1567
+ try {
1568
+ res.unsaved = (await diff(ctx)).map((d) => `${d.item.category}/${d.item.id}`);
1569
+ } catch {
1570
+ }
1571
+ if (res.unsaved.length) ctx.log(` ! \uB85C\uCEEC \uD3B8\uC9D1 ${res.unsaved.length}\uAC1C\uAC00 \uCC3D\uACE0\uC5D0 \uC5C6\uC2B5\uB2C8\uB2E4: ${res.unsaved.join(", ")} \u2192 lshed save \uD6C4 \uB2E4\uC2DC sync`);
1572
+ }
1573
+ const dirty = (await git(["status", "--porcelain", "--untracked-files=all"], shed)).split("\n").filter(Boolean).map((l) => l.slice(3).trim());
1574
+ if (dirty.length) {
1575
+ const msg = opts.message ?? defaultMessage(dirty, (await readState(ctx.adapter))?.profile);
1576
+ ctx.log(` ${opts.dryRun ? "(dry-run) " : ""}commit ${dirty.length}\uAC1C: ${dirty.slice(0, 5).join(", ")}${dirty.length > 5 ? ` \uC678 ${dirty.length - 5}` : ""}`);
1577
+ if (!opts.dryRun) {
1578
+ await git(["add", "-A"], shed);
1579
+ await git(["commit", "--quiet", "-m", msg], shed);
1580
+ }
1581
+ res.committed = dirty;
1582
+ } else {
1583
+ ctx.log(" = \uCC3D\uACE0\uC5D0 \uCEE4\uBC0B\uD560 \uBCC0\uACBD \uC5C6\uC74C");
1584
+ }
1585
+ const remote = await git(["remote", "get-url", "origin"], shed).catch(() => null);
1586
+ if (!remote) {
1587
+ ctx.log(" \xB7 origin \uC774 \uC5C6\uC5B4 pull/push \uB294 \uAC74\uB108\uB700 (git remote add origin <url>)");
1588
+ return res;
1589
+ }
1590
+ if (opts.dryRun) {
1591
+ ctx.log(` (dry-run) pull --rebase, push \u2192 ${remote}`);
1592
+ return res;
1593
+ }
1594
+ const before = await git(["rev-parse", "HEAD"], shed);
1595
+ const branch2 = await git(["rev-parse", "--abbrev-ref", "HEAD"], shed);
1596
+ const hasUpstream = await git(["rev-parse", "--abbrev-ref", "@{upstream}"], shed).then(() => true, () => false);
1597
+ if (hasUpstream) {
1598
+ try {
1599
+ await git(["pull", "--rebase", "--quiet"], shed);
1600
+ } catch (e) {
1601
+ await git(["rebase", "--abort"], shed).catch(() => {
1602
+ });
1603
+ throw new Error(`pull \uC911 \uCDA9\uB3CC\uC774 \uB098\uC11C \uB418\uB3CC\uB838\uC2B5\uB2C8\uB2E4. \uCC3D\uACE0\uC5D0\uC11C \uC9C1\uC811 \uD574\uACB0\uD558\uC138\uC694:
1604
+ cd ${shed} && git pull --rebase
1605
+ (${firstLine(e.message)})`);
1606
+ }
1607
+ const after = await git(["rev-parse", "HEAD"], shed);
1608
+ if (after !== before) {
1609
+ const n = Number(await git(["rev-list", "--count", `${before}..${after}`], shed).catch(() => "0"));
1610
+ res.pulled = Math.max(0, n - (res.committed.length ? 1 : 0));
1611
+ if (res.pulled) ctx.log(` \u2193 \uC6D0\uACA9 \uCEE4\uBC0B ${res.pulled}\uAC1C \uBC1B\uC74C`);
1612
+ }
1613
+ } else {
1614
+ ctx.log(` \xB7 \uBE0C\uB79C\uCE58 ${branch2} \uC5D0 upstream \uC774 \uC5C6\uC5B4 pull \uC740 \uAC74\uB108\uB700`);
1615
+ }
1616
+ if (push) {
1617
+ const ahead = hasUpstream ? Number(await git(["rev-list", "--count", "@{upstream}..HEAD"], shed)) : 1;
1618
+ if (ahead > 0) {
1619
+ await git(hasUpstream ? ["push", "--quiet"] : ["push", "--quiet", "-u", "origin", branch2], shed);
1620
+ res.pushed = true;
1621
+ ctx.log(` \u2191 push ${hasUpstream ? `${ahead}\uAC1C \uCEE4\uBC0B` : `(upstream \uC124\uC815: origin/${branch2})`}`);
1622
+ } else {
1623
+ ctx.log(" = \uC6D0\uACA9\uACFC \uAC19\uC74C");
1624
+ }
1625
+ }
1626
+ if (res.pulled) ctx.log(`
1627
+ \uCC3D\uACE0\uAC00 \uBC14\uB00C\uC5C8\uC2B5\uB2C8\uB2E4. \uC774 \uAE30\uAE30\uC5D0 \uC801\uC6A9\uD558\uB824\uBA74: lshed restore`);
1628
+ return res;
1629
+ }
1630
+ function defaultMessage(paths, profile) {
1631
+ const parts = [...new Set(paths.map((p) => p.split("/").slice(0, 2).join("/")))];
1632
+ const head2 = parts.slice(0, 3).join(", ") + (parts.length > 3 ? ` +${parts.length - 3}` : "");
1633
+ return `lshed sync: ${head2}
1634
+
1635
+ ${os2.hostname()}${profile ? ` \xB7 profile ${profile}` : ""}`;
1636
+ }
1637
+ var firstLine = (s) => s.split("\n").find((l) => l.trim() && !l.startsWith("Command failed")) ?? s;
1638
+
1156
1639
  // src/cli.ts
1157
1640
  var { version } = createRequire(import.meta.url)("../package.json");
1158
1641
  var program = new Command().name("lshed").description("Keep your coding-agent harness (skills, agents, commands, instructions) in a shed and restore it anywhere by profile.").version(version).option("--shed <dir>", "shed directory (default: $LSHED_HOME, then the shed recorded by the last restore)").option("--root <dir>", "agent config root (default: ~/.claude)");
1159
1642
  function adapterFromOpts() {
1160
1643
  const { root } = program.opts();
1161
- return new ClaudeCodeAdapter(root ? path15.resolve(root) : void 0);
1644
+ return new ClaudeCodeAdapter(root ? path19.resolve(root) : void 0);
1162
1645
  }
1163
1646
  async function ctxFor(cmd) {
1164
1647
  const adapter = adapterFromOpts();
1165
1648
  const { shed: flag } = program.opts();
1166
1649
  let shed = flag ?? process.env.LSHED_HOME;
1167
1650
  if (!shed && cmd === "other") shed = (await readState(adapter))?.shed;
1168
- if (!shed && cmd === "init") shed = path15.join(os2.homedir(), "lshed");
1651
+ if (!shed && cmd === "init") shed = path19.join(os3.homedir(), "lshed");
1169
1652
  if (!shed) throw new Error("\uCC3D\uACE0 \uC704\uCE58\uB97C \uBAA8\uB985\uB2C8\uB2E4. --shed <dir> \uB610\uB294 LSHED_HOME \uC744 \uC9C0\uC815\uD558\uC138\uC694.");
1170
- return { adapter, shed: path15.resolve(shed), log: (l) => console.log(l), exec: spawnExec };
1653
+ return { adapter, shed: path19.resolve(shed), log: (l) => console.log(l), exec: spawnExec };
1171
1654
  }
1172
1655
  async function run(fn) {
1173
1656
  try {
@@ -1212,7 +1695,7 @@ program.command("status").description("show the applied profile, managed paths a
1212
1695
  const adapter = adapterFromOpts();
1213
1696
  const state = await readState(adapter);
1214
1697
  if (!state) {
1215
- console.log(formatStatus({ state: null, drifted: [], packages: [] }, adapter.root));
1698
+ console.log(formatStatus({ state: null, drifted: [], packages: [], missingEnv: [], fresh: [] }, adapter.root));
1216
1699
  return;
1217
1700
  }
1218
1701
  const ctx = await ctxFor("other");
@@ -1226,6 +1709,14 @@ program.command("save [ids...]").description("copy local edits back into the she
1226
1709
  const ctx = await ctxFor("other");
1227
1710
  await save(ctx, ids);
1228
1711
  }));
1712
+ program.command("add [keys...]").description("put things that appeared locally since init into the shed and the current profile (lists candidates without keys)").option("--all", "add every candidate").action((keys, o) => run(async () => {
1713
+ const ctx = await ctxFor("other");
1714
+ await add(ctx, keys, { all: o.all });
1715
+ }));
1716
+ program.command("sync").description("commit the shed, pull --rebase and push (the shed must be a git repo with origin)").option("-m, --message <msg>", "commit message (default: names the changed parts)").option("--no-push", "commit and pull only").option("--dry-run", "show what would be committed and pushed").action((o) => run(async () => {
1717
+ const ctx = await ctxFor("other");
1718
+ await sync(ctx, { message: o.message, push: o.push, dryRun: o.dryRun });
1719
+ }));
1229
1720
  program.command("list").description("everything in the shed and which profiles use it").option("--unused", "only things no profile uses").action((o) => run(async () => {
1230
1721
  const ctx = await ctxFor("other");
1231
1722
  const m = await loadManifest(ctx);
@@ -1244,6 +1735,11 @@ program.command("scan").description("(debug) list components found in the agent
1244
1735
  const adapter = adapterFromOpts();
1245
1736
  const found = await adapter.scan();
1246
1737
  for (const c of found) console.log(`${c.category}/${c.id} ${c.path}`);
1247
- console.error(`${found.length}\uAC1C \uBC1C\uACAC (root: ${adapter.root})`);
1738
+ let n = found.length;
1739
+ for (const e of adapter.entries()) for (const id of Object.keys(await e.read())) {
1740
+ console.log(`${e.name}/${id} (entry)`);
1741
+ n++;
1742
+ }
1743
+ console.error(`${n}\uAC1C \uBC1C\uACAC (root: ${adapter.root})`);
1248
1744
  }));
1249
1745
  program.parseAsync();