lshed 0.1.0 → 0.2.1
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/CHANGELOG.md +29 -0
- package/README.md +53 -6
- package/dist/cli.js +520 -62
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,34 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.1 — 2026-09-02
|
|
4
|
+
|
|
5
|
+
- `lshed list [--unused]` shows everything in the shed and which profiles use it.
|
|
6
|
+
- `lshed remove <key>` deletes a component or package from the shed. Refused while any profile still references it. Packages leave the manifest and lock only; the local clone stays.
|
|
7
|
+
- `lshed prune [--yes]` removes everything no profile uses. Lists without `--yes`.
|
|
8
|
+
- Manifest edits preserve your comments. Empty `packages:` is no longer written.
|
|
9
|
+
|
|
10
|
+
## 0.2.0 — 2026-09-02
|
|
11
|
+
|
|
12
|
+
A harness holds three kinds of things, and 0.1 treated them all the same. Running against a real `~/.claude` showed that 55 of 62 "skills" were files generated by one toolkit's installer, and the toolkit itself was a git clone.
|
|
13
|
+
|
|
14
|
+
- **Packages.** A `packages:` list in `lshed.yaml` records things you *installed* by source and version instead of copying them: `source: github:owner/repo@ref` (or `git:<url>#ref`), `into: <path under the agent root>`, optional `install: <command>`. `restore` clones a missing package at the commit pinned in `lshed.lock`; a package already present is never touched.
|
|
15
|
+
- **Generated files are skipped.** `init` recognises a directory as a package when it contains a `.git` with a remote, and recognises a stub as generated when one of its symlinks points inside a package. Both are left out of the shed and the managed set. Aliases that an installer creates without symlinks are not detected; use `init --exclude`.
|
|
16
|
+
- **`lshed.lock`** pins each package to a commit. Written by `init` from the existing clone, by `restore` on first clone, and by `update`.
|
|
17
|
+
- **`lshed update [ids...]`** fast-forwards packages and refreshes the lock.
|
|
18
|
+
- **Install commands run only with `--yes`.** Without it, `restore` and `update` print the commands and stop. A shed can be cloned from anywhere; running its shell commands should be a deliberate act.
|
|
19
|
+
- `git:` source scheme for non-GitHub remotes.
|
|
20
|
+
|
|
21
|
+
Packages are additive: switching to a profile that does not list one leaves it on disk. Removing a clone with a backup would mean copying a repository, which is the wrong tool for that job.
|
|
22
|
+
|
|
23
|
+
## 0.1.1 — 2026-09-02
|
|
24
|
+
|
|
25
|
+
Both fixes came from running 0.1.0 against a real 62-skill `~/.claude`.
|
|
26
|
+
|
|
27
|
+
- Skip regenerable directories when copying: `node_modules`, `.git`, `__pycache__`, `.venv`, cache dirs, `*.log`. A real harness went from 1.6 GB to 6.2 MB. Build output such as `dist/` is **not** ignored by default, because for some skills it is the deliverable.
|
|
28
|
+
- Extend the list with `ignore:` in `lshed.yaml`; the same list applies to `diff`, `save` and backups, so ignored files never show up as drift.
|
|
29
|
+
- Follow symlinks. A skill symlinked into `~/.claude/skills` used to be skipped silently; it is now captured and copied by content. Broken links are skipped.
|
|
30
|
+
- `init --exclude <id...>` leaves out components that do not belong in a shed, such as a toolkit with its own installer.
|
|
31
|
+
|
|
3
32
|
## 0.1.0 — 2026-09-02
|
|
4
33
|
|
|
5
34
|
First working release. Claude Code only.
|
package/README.md
CHANGED
|
@@ -76,24 +76,70 @@ profiles:
|
|
|
76
76
|
instructions: [base]
|
|
77
77
|
```
|
|
78
78
|
|
|
79
|
-
- `source` accepts `file:<path relative to the shed>`. `github:owner/repo@ref`
|
|
79
|
+
- Component `source` accepts `file:<path relative to the shed>`. Package `source` accepts `github:owner/repo@ref` or `git:<url>#ref`.
|
|
80
80
|
- Category names come from the adapter. For Claude Code: `skills`, `agents`, `commands`, `instructions`.
|
|
81
|
+
- `ignore:` at the top level adds to the built-in list of things never copied: `node_modules`, `.git`, `__pycache__`, `.venv`, cache directories, `*.log`. Build output like `dist/` is not ignored by default, since some skills ship it. Add it yourself if your parts rebuild from source.
|
|
81
82
|
- Instructions are not merged. `restore` writes a `CLAUDE.md` that `@`-imports each fragment in order, so a fragment edit shows up without re-running anything. Your original `CLAUDE.md` is backed up the first time.
|
|
82
83
|
|
|
84
|
+
## Three kinds of things
|
|
85
|
+
|
|
86
|
+
A real `~/.claude` mixes three kinds of content, and they need different handling:
|
|
87
|
+
|
|
88
|
+
| Kind | Example | What lshed does |
|
|
89
|
+
|---|---|---|
|
|
90
|
+
| **Authored** | a skill you wrote, your `CLAUDE.md` | copies it into the shed |
|
|
91
|
+
| **Installed** | a toolkit you `git clone`d, a plugin | records source + commit; `restore` clones it back |
|
|
92
|
+
| **Generated** | stub skills an installer wrote for you | skips them; they return when the installer runs |
|
|
93
|
+
|
|
94
|
+
`init` sorts this out for you. A directory with a `.git` and a remote becomes a **package**. A skill whose symlink points inside a package is treated as generated and skipped. Everything else is authored and copied.
|
|
95
|
+
|
|
96
|
+
```yaml
|
|
97
|
+
packages:
|
|
98
|
+
- id: gstack
|
|
99
|
+
source: github:garrytan/gstack@main # git:<url>#ref for other hosts
|
|
100
|
+
into: skills/gstack # where it lives under ~/.claude
|
|
101
|
+
install: ./setup # optional; run after clone, only with --yes
|
|
102
|
+
|
|
103
|
+
profiles:
|
|
104
|
+
default:
|
|
105
|
+
packages: [gstack]
|
|
106
|
+
skills: [add-drivers, domain-modeling]
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
`lshed.lock` pins each package to a commit, so a fresh machine gets the same version you had. `lshed update` moves it forward.
|
|
110
|
+
|
|
111
|
+
Rules that keep this safe:
|
|
112
|
+
|
|
113
|
+
- A package that is already present is never touched by `restore`. Your local checkout is yours.
|
|
114
|
+
- `install:` is a shell command. `restore` and `update` **print it and stop** unless you pass `--yes`.
|
|
115
|
+
- Packages are not part of the managed set. Switching profiles never deletes a clone.
|
|
116
|
+
- Installers sometimes create aliases without symlinks, which `init` cannot tell from authored skills. Leave those out with `--exclude`:
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
lshed init --shed ~/harness --exclude _gstack-command connect-chrome
|
|
120
|
+
```
|
|
121
|
+
|
|
83
122
|
## Commands
|
|
84
123
|
|
|
85
124
|
```
|
|
86
|
-
lshed init [--shed <dir>] [--profile <name>]
|
|
87
|
-
lshed restore [profile] [--dry-run] [--no-backup]
|
|
88
|
-
lshed
|
|
125
|
+
lshed init [--shed <dir>] [--profile <name>] [--exclude <id...>]
|
|
126
|
+
lshed restore [profile] [--dry-run] [--no-backup] [--yes]
|
|
127
|
+
lshed update [ids...] [--dry-run] [--yes] pull packages forward, refresh lshed.lock
|
|
128
|
+
lshed status applied profile, managed paths, drift, packages
|
|
89
129
|
lshed diff files that differ between local and shed
|
|
90
130
|
lshed save [ids...] copy local edits back into the shed
|
|
131
|
+
lshed list [--unused] what is in the shed, and which profiles use it
|
|
132
|
+
lshed remove <key> drop a component or package from the shed
|
|
133
|
+
lshed prune [--yes] drop everything no profile uses
|
|
91
134
|
```
|
|
92
135
|
|
|
136
|
+
`remove` and `prune` delete from the shed without a backup. The shed is meant to live in git; commit before you prune. Both refuse to touch anything a profile still lists, so the way to retire a part is to take it out of the profiles first.
|
|
137
|
+
|
|
93
138
|
Global options: `--shed <dir>` (or `LSHED_HOME`; after the first restore lshed remembers it), `--root <dir>` (agent config root, default `~/.claude`).
|
|
94
139
|
|
|
95
140
|
### What `restore` does
|
|
96
141
|
|
|
142
|
+
0. Clones any package in the profile that is missing, at the commit in `lshed.lock`.
|
|
97
143
|
1. Removes paths that the **previous** profile placed and the new one doesn't need.
|
|
98
144
|
2. Copies every part of the new profile into place.
|
|
99
145
|
3. Regenerates the instructions file.
|
|
@@ -102,7 +148,7 @@ Anything it overwrites or removes is backed up first under `~/.claude/lshed/back
|
|
|
102
148
|
|
|
103
149
|
### Ownership
|
|
104
150
|
|
|
105
|
-
The shed is the source of truth
|
|
151
|
+
The shed is the source of truth for authored parts: `save` copies local edits back for `file:` components. Packages are owned by their upstream: `update` pulls them, `save` ignores them.
|
|
106
152
|
|
|
107
153
|
## Where things live
|
|
108
154
|
|
|
@@ -124,7 +170,8 @@ The shed is the source of truth. `save` copies local edits back for `file:` part
|
|
|
124
170
|
|
|
125
171
|
- MCP servers and secrets. Planned: the manifest names the keys, values are injected locally, nothing secret enters the shed.
|
|
126
172
|
- `settings.json` merging (hooks, permissions).
|
|
127
|
-
-
|
|
173
|
+
- `sync` (a git pull/push wrapper). Use git in the shed directly for now.
|
|
174
|
+
- Plugins installed through Claude Code's marketplace. They are packages too; recording them is next.
|
|
128
175
|
- Windows and macOS have not been tested. The code avoids platform-specific paths, but treat 0.1 as Linux/WSL.
|
|
129
176
|
|
|
130
177
|
## Troubleshooting
|
package/dist/cli.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import { Command } from "commander";
|
|
5
5
|
import { createRequire } from "module";
|
|
6
6
|
import os2 from "os";
|
|
7
|
-
import
|
|
7
|
+
import path13 from "path";
|
|
8
8
|
|
|
9
9
|
// src/adapters/claude-code.ts
|
|
10
10
|
import { promises as fs } from "fs";
|
|
@@ -43,10 +43,17 @@ var ClaudeCodeAdapter = class {
|
|
|
43
43
|
}
|
|
44
44
|
for (const e of entries) {
|
|
45
45
|
if (e.name.startsWith(".")) continue;
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
46
|
+
const full = path.join(dir, e.name);
|
|
47
|
+
let st;
|
|
48
|
+
try {
|
|
49
|
+
st = await fs.stat(full);
|
|
50
|
+
} catch {
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (cat.kind === "dir" && st.isDirectory()) {
|
|
54
|
+
out.push({ category: cat.name, id: e.name, path: full });
|
|
55
|
+
} else if (cat.kind === "file" && st.isFile() && e.name.endsWith(".md")) {
|
|
56
|
+
out.push({ category: cat.name, id: e.name.slice(0, -3), path: full });
|
|
50
57
|
}
|
|
51
58
|
}
|
|
52
59
|
}
|
|
@@ -55,8 +62,8 @@ var ClaudeCodeAdapter = class {
|
|
|
55
62
|
};
|
|
56
63
|
|
|
57
64
|
// src/core/init.ts
|
|
58
|
-
import { promises as
|
|
59
|
-
import
|
|
65
|
+
import { promises as fs7 } from "fs";
|
|
66
|
+
import path10 from "path";
|
|
60
67
|
|
|
61
68
|
// src/core/context.ts
|
|
62
69
|
import { promises as fs3 } from "fs";
|
|
@@ -85,10 +92,37 @@ function parseSource(raw) {
|
|
|
85
92
|
const [, owner, repo, ref, subpath] = m;
|
|
86
93
|
return { scheme, owner, repo, ref, subpath };
|
|
87
94
|
}
|
|
95
|
+
case "git": {
|
|
96
|
+
const hash = rest.lastIndexOf("#");
|
|
97
|
+
const url = hash >= 0 ? rest.slice(0, hash) : rest;
|
|
98
|
+
const ref = hash >= 0 ? rest.slice(hash + 1) : void 0;
|
|
99
|
+
if (!url) throw new Error(`git: \uB4A4\uC5D0 URL \uC774 \uC5C6\uC2B5\uB2C8\uB2E4: "${raw}"`);
|
|
100
|
+
return { scheme, url, ref: ref || void 0 };
|
|
101
|
+
}
|
|
88
102
|
default:
|
|
89
103
|
throw new Error(`\uC54C \uC218 \uC5C6\uB294 \uC2A4\uD0B4 "${scheme}": "${raw}"`);
|
|
90
104
|
}
|
|
91
105
|
}
|
|
106
|
+
function formatSource(s) {
|
|
107
|
+
switch (s.scheme) {
|
|
108
|
+
case "file":
|
|
109
|
+
return `file:${s.path}`;
|
|
110
|
+
case "github":
|
|
111
|
+
return `github:${s.owner}/${s.repo}${s.ref ? `@${s.ref}` : ""}${s.subpath ? `#${s.subpath}` : ""}`;
|
|
112
|
+
case "git":
|
|
113
|
+
return `git:${s.url}${s.ref ? `#${s.ref}` : ""}`;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
function cloneTarget(s) {
|
|
117
|
+
if (s.scheme === "github") return { url: `https://github.com/${s.owner}/${s.repo}.git`, ref: s.ref };
|
|
118
|
+
if (s.scheme === "git") return { url: s.url, ref: s.ref };
|
|
119
|
+
throw new Error(`file: \uCD9C\uCC98\uB294 clone \uB300\uC0C1\uC774 \uC544\uB2D9\uB2C8\uB2E4`);
|
|
120
|
+
}
|
|
121
|
+
function sourceFromRemote(url, ref) {
|
|
122
|
+
const m = /^(?:https?:\/\/github\.com\/|git@github\.com:)([\w.-]+)\/([\w.-]+?)(?:\.git)?\/?$/.exec(url);
|
|
123
|
+
if (m) return formatSource({ scheme: "github", owner: m[1], repo: m[2], ref });
|
|
124
|
+
return formatSource({ scheme: "git", url, ref });
|
|
125
|
+
}
|
|
92
126
|
|
|
93
127
|
// src/manifest.ts
|
|
94
128
|
var ComponentSchema = z.object({
|
|
@@ -96,12 +130,22 @@ var ComponentSchema = z.object({
|
|
|
96
130
|
source: z.string().optional(),
|
|
97
131
|
tags: z.array(z.string()).optional()
|
|
98
132
|
});
|
|
133
|
+
var PackageSchema = z.object({
|
|
134
|
+
id: z.string().regex(/^[\w.-]+$/, "id\uB294 \uC601\uBB38\xB7\uC22B\uC790\xB7._- \uB9CC \uD5C8\uC6A9"),
|
|
135
|
+
source: z.string(),
|
|
136
|
+
into: z.string().regex(/^[^/\\][^\\]*$/, "into \uB294 \uB8E8\uD2B8 \uAE30\uC900 \uC0C1\uB300 \uACBD\uB85C (POSIX)"),
|
|
137
|
+
install: z.string().optional()
|
|
138
|
+
});
|
|
139
|
+
var PACKAGES = "packages";
|
|
99
140
|
var ComponentsSchema = z.record(z.string(), z.array(ComponentSchema));
|
|
100
141
|
var ProfileSchema = z.record(z.string(), z.array(z.string()));
|
|
101
142
|
var ManifestSchema = z.object({
|
|
102
143
|
version: z.literal(1),
|
|
103
144
|
agent: z.string().default("claude-code"),
|
|
145
|
+
/** 창고에 담지 않을 이름들. 기본값(DEFAULT_IGNORE)에 더해진다. */
|
|
146
|
+
ignore: z.array(z.string()).optional(),
|
|
104
147
|
components: ComponentsSchema.default({}),
|
|
148
|
+
packages: z.array(PackageSchema).default([]),
|
|
105
149
|
profiles: z.record(z.string(), ProfileSchema).default({})
|
|
106
150
|
});
|
|
107
151
|
var ManifestError = class extends Error {
|
|
@@ -131,8 +175,22 @@ ${lines.join("\n")}`);
|
|
|
131
175
|
}
|
|
132
176
|
}
|
|
133
177
|
}
|
|
178
|
+
const pkgIds = /* @__PURE__ */ new Set();
|
|
179
|
+
for (const p of m.packages) {
|
|
180
|
+
if (pkgIds.has(p.id)) problems.push(`packages: id "${p.id}" \uC911\uBCF5`);
|
|
181
|
+
pkgIds.add(p.id);
|
|
182
|
+
try {
|
|
183
|
+
if (parseSource(p.source).scheme === "file") problems.push(`packages/${p.id}: \uD328\uD0A4\uC9C0 \uCD9C\uCC98\uB294 github: \uB610\uB294 git: \uC774\uC5B4\uC57C \uD569\uB2C8\uB2E4`);
|
|
184
|
+
} catch (e) {
|
|
185
|
+
problems.push(`packages/${p.id}: ${e.message}`);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
134
188
|
for (const [profile, cats] of Object.entries(m.profiles)) {
|
|
135
189
|
for (const [cat, ids] of Object.entries(cats)) {
|
|
190
|
+
if (cat === PACKAGES) {
|
|
191
|
+
for (const id of ids) if (!pkgIds.has(id)) problems.push(`profiles.${profile}.packages: "${id}" \uB294 packages \uC5D0 \uC5C6\uC74C`);
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
136
194
|
const available = new Set((m.components[cat] ?? []).map((c) => c.id));
|
|
137
195
|
for (const id of ids) {
|
|
138
196
|
if (!available.has(id)) problems.push(`profiles.${profile}.${cat}: "${id}" \uB294 components\uC5D0 \uC5C6\uC74C`);
|
|
@@ -147,7 +205,14 @@ function effectiveSource(category, c, kind = "dir") {
|
|
|
147
205
|
return c.source ?? `file:./${category}/${c.id}${kind === "file" ? ".md" : ""}`;
|
|
148
206
|
}
|
|
149
207
|
function stringifyManifest(m) {
|
|
150
|
-
|
|
208
|
+
const out = { ...m };
|
|
209
|
+
if (!m.packages.length) delete out.packages;
|
|
210
|
+
if (!m.ignore?.length) delete out.ignore;
|
|
211
|
+
return YAML.stringify(out, { lineWidth: 0 });
|
|
212
|
+
}
|
|
213
|
+
function packagesOf(m, profile) {
|
|
214
|
+
const ids = m.profiles[profile]?.[PACKAGES] ?? [];
|
|
215
|
+
return ids.map((id) => m.packages.find((p) => p.id === id));
|
|
151
216
|
}
|
|
152
217
|
|
|
153
218
|
// src/resolvers/file.ts
|
|
@@ -191,6 +256,26 @@ async function writeState(adapter, state) {
|
|
|
191
256
|
await fs2.writeFile(p, JSON.stringify(state, null, 2) + "\n");
|
|
192
257
|
}
|
|
193
258
|
|
|
259
|
+
// src/ignore.ts
|
|
260
|
+
var DEFAULT_IGNORE = [
|
|
261
|
+
"node_modules",
|
|
262
|
+
".git",
|
|
263
|
+
"__pycache__",
|
|
264
|
+
".venv",
|
|
265
|
+
".mypy_cache",
|
|
266
|
+
".pytest_cache",
|
|
267
|
+
".DS_Store",
|
|
268
|
+
"*.log"
|
|
269
|
+
];
|
|
270
|
+
function matches(name, pattern) {
|
|
271
|
+
if (pattern.startsWith("*.")) return name.endsWith(pattern.slice(1));
|
|
272
|
+
return name === pattern;
|
|
273
|
+
}
|
|
274
|
+
function isIgnored(rel, patterns) {
|
|
275
|
+
if (!rel) return false;
|
|
276
|
+
return rel.split("/").some((seg) => patterns.some((p) => matches(seg, p)));
|
|
277
|
+
}
|
|
278
|
+
|
|
194
279
|
// src/core/context.ts
|
|
195
280
|
var MANIFEST_FILE = "lshed.yaml";
|
|
196
281
|
var INSTRUCTIONS = "instructions";
|
|
@@ -209,7 +294,12 @@ async function loadManifest(ctx) {
|
|
|
209
294
|
throw new Error(`\uCC3D\uACE0\uC5D0 ${MANIFEST_FILE} \uC774 \uC5C6\uC2B5\uB2C8\uB2E4: ${ctx.shed}
|
|
210
295
|
\uBA3C\uC800 'lshed init --shed ${ctx.shed}' \uB97C \uC2E4\uD589\uD558\uC138\uC694.`);
|
|
211
296
|
}
|
|
212
|
-
|
|
297
|
+
const m = parseManifest(text, knownCategories(ctx.adapter));
|
|
298
|
+
ctx.ignore = [...DEFAULT_IGNORE, ...m.ignore ?? []];
|
|
299
|
+
return m;
|
|
300
|
+
}
|
|
301
|
+
function ignoreOf(ctx) {
|
|
302
|
+
return ctx.ignore ?? DEFAULT_IGNORE;
|
|
213
303
|
}
|
|
214
304
|
function targetRel(cat, id) {
|
|
215
305
|
if (cat === INSTRUCTIONS) return `${FRAGMENTS_DIR}/${id}.md`;
|
|
@@ -221,7 +311,7 @@ function sourcePath(ctx, category, c) {
|
|
|
221
311
|
return resolveSource(ctx.shed, effectiveSource(category, c, kind));
|
|
222
312
|
}
|
|
223
313
|
function findComponent(m, category, id) {
|
|
224
|
-
const c = (m.components[category] ?? []).find((
|
|
314
|
+
const c = (m.components[category] ?? []).find((x2) => x2.id === id);
|
|
225
315
|
if (!c) throw new Error(`${category}/${id} \uB294 components \uC5D0 \uC5C6\uC2B5\uB2C8\uB2E4`);
|
|
226
316
|
return c;
|
|
227
317
|
}
|
|
@@ -233,6 +323,7 @@ function planProfile(ctx, m, profile) {
|
|
|
233
323
|
}
|
|
234
324
|
const items = [];
|
|
235
325
|
for (const [category, ids] of Object.entries(p)) {
|
|
326
|
+
if (category === PACKAGES) continue;
|
|
236
327
|
const cat = category === INSTRUCTIONS ? INSTRUCTIONS : ctx.adapter.categories().find((k) => k.name === category);
|
|
237
328
|
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`);
|
|
238
329
|
for (const id of ids) {
|
|
@@ -265,7 +356,7 @@ async function isDir(p) {
|
|
|
265
356
|
return false;
|
|
266
357
|
}
|
|
267
358
|
}
|
|
268
|
-
async function listFiles(root) {
|
|
359
|
+
async function listFiles(root, ignore = DEFAULT_IGNORE) {
|
|
269
360
|
if (!await exists(root)) return [];
|
|
270
361
|
if (!await isDir(root)) return [""];
|
|
271
362
|
const out = [];
|
|
@@ -273,8 +364,16 @@ async function listFiles(root) {
|
|
|
273
364
|
const entries = await fs4.readdir(dir, { withFileTypes: true });
|
|
274
365
|
for (const e of entries) {
|
|
275
366
|
const r = rel ? `${rel}/${e.name}` : e.name;
|
|
276
|
-
if (
|
|
277
|
-
|
|
367
|
+
if (isIgnored(r, ignore)) continue;
|
|
368
|
+
const full = path5.join(dir, e.name);
|
|
369
|
+
let st;
|
|
370
|
+
try {
|
|
371
|
+
st = await fs4.stat(full);
|
|
372
|
+
} catch {
|
|
373
|
+
continue;
|
|
374
|
+
}
|
|
375
|
+
if (st.isDirectory()) await walk(full, r);
|
|
376
|
+
else if (st.isFile()) out.push(r);
|
|
278
377
|
}
|
|
279
378
|
}
|
|
280
379
|
await walk(root, "");
|
|
@@ -283,25 +382,39 @@ async function listFiles(root) {
|
|
|
283
382
|
async function hashFile(p) {
|
|
284
383
|
return createHash("sha256").update(await fs4.readFile(p)).digest("hex");
|
|
285
384
|
}
|
|
286
|
-
async function hashTree(root) {
|
|
385
|
+
async function hashTree(root, ignore = DEFAULT_IGNORE) {
|
|
287
386
|
if (!await exists(root)) return null;
|
|
288
387
|
const h = createHash("sha256");
|
|
289
|
-
for (const rel of await listFiles(root)) {
|
|
388
|
+
for (const rel of await listFiles(root, ignore)) {
|
|
290
389
|
h.update(rel).update("\0").update(await fs4.readFile(rel ? path5.join(root, rel) : root)).update("\0");
|
|
291
390
|
}
|
|
292
391
|
return h.digest("hex");
|
|
293
392
|
}
|
|
294
|
-
async function copyTree(src, dst) {
|
|
393
|
+
async function copyTree(src, dst, ignore = DEFAULT_IGNORE) {
|
|
295
394
|
await fs4.rm(dst, { recursive: true, force: true });
|
|
296
395
|
await fs4.mkdir(path5.dirname(dst), { recursive: true });
|
|
297
|
-
|
|
396
|
+
const srcRoot = path5.resolve(src);
|
|
397
|
+
await fs4.cp(src, dst, {
|
|
398
|
+
recursive: true,
|
|
399
|
+
dereference: true,
|
|
400
|
+
filter: async (from) => {
|
|
401
|
+
const rel = path5.relative(srcRoot, path5.resolve(from)).split(path5.sep).join("/");
|
|
402
|
+
if (isIgnored(rel, ignore)) return false;
|
|
403
|
+
try {
|
|
404
|
+
if ((await fs4.lstat(from)).isSymbolicLink()) await fs4.stat(from);
|
|
405
|
+
} catch {
|
|
406
|
+
return false;
|
|
407
|
+
}
|
|
408
|
+
return true;
|
|
409
|
+
}
|
|
410
|
+
});
|
|
298
411
|
}
|
|
299
412
|
async function removeTree(p) {
|
|
300
413
|
await fs4.rm(p, { recursive: true, force: true });
|
|
301
414
|
}
|
|
302
|
-
async function diffTrees(local, shed) {
|
|
303
|
-
const l = new Set(await listFiles(local));
|
|
304
|
-
const s = new Set(await listFiles(shed));
|
|
415
|
+
async function diffTrees(local, shed, ignore = DEFAULT_IGNORE) {
|
|
416
|
+
const l = new Set(await listFiles(local, ignore));
|
|
417
|
+
const s = new Set(await listFiles(shed, ignore));
|
|
305
418
|
const out = [];
|
|
306
419
|
for (const f of [.../* @__PURE__ */ new Set([...l, ...s])].sort()) {
|
|
307
420
|
const lp = f ? path5.join(local, f) : local;
|
|
@@ -323,37 +436,229 @@ function isGenerated(text) {
|
|
|
323
436
|
return text.trimStart().startsWith(MARKER);
|
|
324
437
|
}
|
|
325
438
|
function renderInstructions(ctx, profile, fragments) {
|
|
326
|
-
const
|
|
439
|
+
const head2 = `${MARKER}; profile: ${profile} -->
|
|
327
440
|
<!-- Do not edit this file. Edit the fragments in your shed and run 'lshed restore'. -->
|
|
328
441
|
|
|
329
442
|
`;
|
|
330
443
|
if (ctx.adapter.instructionsStrategy() === "import") {
|
|
331
|
-
return
|
|
444
|
+
return head2 + fragments.map((f) => `@${FRAGMENTS_DIR}/${f.id}.md`).join("\n") + "\n";
|
|
332
445
|
}
|
|
333
|
-
return
|
|
446
|
+
return head2 + fragments.map((f) => `<!-- ${f.id} -->
|
|
334
447
|
${f.content.trimEnd()}
|
|
335
448
|
`).join("\n");
|
|
336
449
|
}
|
|
337
450
|
|
|
451
|
+
// src/core/packages.ts
|
|
452
|
+
import { promises as fs6 } from "fs";
|
|
453
|
+
import path9 from "path";
|
|
454
|
+
|
|
455
|
+
// src/git.ts
|
|
456
|
+
import { execFile, spawn } from "child_process";
|
|
457
|
+
import { promisify } from "util";
|
|
458
|
+
import path7 from "path";
|
|
459
|
+
var x = promisify(execFile);
|
|
460
|
+
async function git(args, cwd) {
|
|
461
|
+
const { stdout } = await x("git", args, { cwd, maxBuffer: 1 << 24 });
|
|
462
|
+
return stdout.trim();
|
|
463
|
+
}
|
|
464
|
+
var isRepo = (dir) => exists(path7.join(dir, ".git"));
|
|
465
|
+
var remoteUrl = (dir) => git(["remote", "get-url", "origin"], dir).catch(() => null);
|
|
466
|
+
var head = (dir) => git(["rev-parse", "HEAD"], dir);
|
|
467
|
+
var branch = async (dir) => {
|
|
468
|
+
const b = await git(["rev-parse", "--abbrev-ref", "HEAD"], dir);
|
|
469
|
+
return b === "HEAD" ? void 0 : b;
|
|
470
|
+
};
|
|
471
|
+
var clone = (url, dir, ref) => git(["clone", "--quiet", ...ref ? ["--branch", ref] : [], url, dir]);
|
|
472
|
+
var resetHard = (dir, sha) => git(["reset", "--hard", "--quiet", sha], dir);
|
|
473
|
+
var pullFf = (dir) => git(["pull", "--ff-only", "--quiet"], dir);
|
|
474
|
+
function runShell(cmd, cwd) {
|
|
475
|
+
return new Promise((resolve, reject) => {
|
|
476
|
+
const p = spawn("sh", ["-c", cmd], { cwd, stdio: "inherit" });
|
|
477
|
+
p.on("error", reject);
|
|
478
|
+
p.on("close", (code) => code === 0 ? resolve() : reject(new Error(`\uBA85\uB839\uC774 ${code} \uB85C \uB05D\uB0AC\uC2B5\uB2C8\uB2E4: ${cmd}`)));
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// src/lock.ts
|
|
483
|
+
import { promises as fs5 } from "fs";
|
|
484
|
+
import path8 from "path";
|
|
485
|
+
import YAML2 from "yaml";
|
|
486
|
+
import { z as z3 } from "zod";
|
|
487
|
+
var LockSchema = z3.object({
|
|
488
|
+
version: z3.literal(1),
|
|
489
|
+
packages: z3.record(z3.string(), z3.object({ source: z3.string(), commit: z3.string() })).default({})
|
|
490
|
+
});
|
|
491
|
+
var LOCK_FILE = "lshed.lock";
|
|
492
|
+
async function readLock(shed) {
|
|
493
|
+
try {
|
|
494
|
+
return LockSchema.parse(YAML2.parse(await fs5.readFile(path8.join(shed, LOCK_FILE), "utf8")));
|
|
495
|
+
} catch (e) {
|
|
496
|
+
if (e.code === "ENOENT") return { version: 1, packages: {} };
|
|
497
|
+
throw new Error(`${LOCK_FILE} \uC744 \uC77D\uC744 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4: ${e.message}`);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
async function writeLock(shed, lock) {
|
|
501
|
+
const sorted = { version: 1, packages: Object.fromEntries(Object.entries(lock.packages).sort()) };
|
|
502
|
+
await fs5.writeFile(path8.join(shed, LOCK_FILE), "# generated by lshed \u2014 do not edit; 'lshed update' refreshes it\n" + YAML2.stringify(sorted));
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// src/core/packages.ts
|
|
506
|
+
async function detectPackages(ctx, found) {
|
|
507
|
+
const out = [];
|
|
508
|
+
for (const f of found) {
|
|
509
|
+
if (!await isRepo(f.path)) continue;
|
|
510
|
+
const url = await remoteUrl(f.path);
|
|
511
|
+
if (!url) continue;
|
|
512
|
+
const into = path9.relative(ctx.adapter.root, f.path).split(path9.sep).join("/");
|
|
513
|
+
out.push({ id: f.id, into, source: sourceFromRemote(url, await branch(f.path)), commit: await head(f.path), path: f.path });
|
|
514
|
+
}
|
|
515
|
+
return out;
|
|
516
|
+
}
|
|
517
|
+
async function detectGenerated(found, pkgs) {
|
|
518
|
+
const out = /* @__PURE__ */ new Map();
|
|
519
|
+
if (!pkgs.length) return out;
|
|
520
|
+
const roots = await Promise.all(pkgs.map(async (p) => ({ id: p.id, real: await fs6.realpath(p.path) })));
|
|
521
|
+
for (const f of found) {
|
|
522
|
+
if (pkgs.some((p) => p.path === f.path)) continue;
|
|
523
|
+
let entries;
|
|
524
|
+
try {
|
|
525
|
+
if (!(await fs6.stat(f.path)).isDirectory()) continue;
|
|
526
|
+
entries = await fs6.readdir(f.path);
|
|
527
|
+
} catch {
|
|
528
|
+
continue;
|
|
529
|
+
}
|
|
530
|
+
for (const name of entries) {
|
|
531
|
+
const p = path9.join(f.path, name);
|
|
532
|
+
let target;
|
|
533
|
+
try {
|
|
534
|
+
if (!(await fs6.lstat(p)).isSymbolicLink()) continue;
|
|
535
|
+
target = await fs6.realpath(p).catch(async () => path9.resolve(f.path, await fs6.readlink(p)));
|
|
536
|
+
} catch {
|
|
537
|
+
continue;
|
|
538
|
+
}
|
|
539
|
+
const owner = roots.find((r) => target === r.real || target.startsWith(r.real + path9.sep));
|
|
540
|
+
if (owner) {
|
|
541
|
+
out.set(`${f.category}/${f.id}`, owner.id);
|
|
542
|
+
break;
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
return out;
|
|
547
|
+
}
|
|
548
|
+
async function packageStatus(ctx, pkg, lock) {
|
|
549
|
+
const dir = abs(ctx, pkg.into);
|
|
550
|
+
const present = await isRepo(dir);
|
|
551
|
+
return { pkg, dir, present, head: present ? await head(dir) : void 0, locked: lock.packages[pkg.id]?.commit };
|
|
552
|
+
}
|
|
553
|
+
async function ensurePackages(ctx, pkgs, opts = {}) {
|
|
554
|
+
const lock = await readLock(ctx.shed);
|
|
555
|
+
const res = { cloned: [], pendingInstalls: [], lockChanged: false };
|
|
556
|
+
for (const pkg of pkgs) {
|
|
557
|
+
const st = await packageStatus(ctx, pkg, lock);
|
|
558
|
+
if (st.present) {
|
|
559
|
+
const note = st.locked && st.head !== st.locked ? ` (HEAD ${st.head.slice(0, 7)} \u2260 lock ${st.locked.slice(0, 7)})` : "";
|
|
560
|
+
ctx.log(` = package ${pkg.id}${note}`);
|
|
561
|
+
continue;
|
|
562
|
+
}
|
|
563
|
+
if (await exists(st.dir)) throw new Error(`package ${pkg.id}: ${st.dir} \uAC00 \uC788\uC9C0\uB9CC git \uC800\uC7A5\uC18C\uAC00 \uC544\uB2D9\uB2C8\uB2E4. \uCE58\uC6B0\uAC70\uB098 into \uB97C \uBC14\uAFB8\uC138\uC694.`);
|
|
564
|
+
const { url, ref } = cloneTarget(parseSource(pkg.source));
|
|
565
|
+
ctx.log(` + package ${pkg.id} (clone ${url}${ref ? ` @${ref}` : ""}${st.locked ? ` \u2192 ${st.locked.slice(0, 7)}` : ""})`);
|
|
566
|
+
if (opts.dryRun) continue;
|
|
567
|
+
await fs6.mkdir(path9.dirname(st.dir), { recursive: true });
|
|
568
|
+
await clone(url, st.dir, ref);
|
|
569
|
+
if (st.locked) {
|
|
570
|
+
await resetHard(st.dir, st.locked).catch(() => {
|
|
571
|
+
throw new Error(`package ${pkg.id}: \uB77D\uC758 \uCEE4\uBC0B ${st.locked.slice(0, 7)} \uC744 \uCC3E\uC744 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4. 'lshed update ${pkg.id}' \uB85C \uB77D\uC744 \uAC31\uC2E0\uD558\uC138\uC694.`);
|
|
572
|
+
});
|
|
573
|
+
} else {
|
|
574
|
+
lock.packages[pkg.id] = { source: pkg.source, commit: await head(st.dir) };
|
|
575
|
+
res.lockChanged = true;
|
|
576
|
+
}
|
|
577
|
+
res.cloned.push(pkg.id);
|
|
578
|
+
if (pkg.install) await maybeInstall(ctx, pkg, st.dir, opts, res);
|
|
579
|
+
}
|
|
580
|
+
if (res.lockChanged) await writeLock(ctx.shed, lock);
|
|
581
|
+
return res;
|
|
582
|
+
}
|
|
583
|
+
async function maybeInstall(ctx, pkg, dir, opts, res) {
|
|
584
|
+
if (!pkg.install) return;
|
|
585
|
+
if (opts.yes) {
|
|
586
|
+
ctx.log(` $ (${pkg.into}) ${pkg.install}`);
|
|
587
|
+
await runShell(pkg.install, dir);
|
|
588
|
+
} else {
|
|
589
|
+
res.pendingInstalls.push({ id: pkg.id, dir, cmd: pkg.install });
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
function reportPending(ctx, res) {
|
|
593
|
+
if (!res.pendingInstalls.length) return;
|
|
594
|
+
ctx.log(`
|
|
595
|
+
\uC124\uCE58 \uBA85\uB839 ${res.pendingInstalls.length}\uAC1C\uB97C \uC2E4\uD589\uD558\uC9C0 \uC54A\uC558\uC2B5\uB2C8\uB2E4. \uD655\uC778 \uD6C4 '--yes' \uB85C \uB2E4\uC2DC \uC2E4\uD589\uD558\uAC70\uB098 \uC9C1\uC811 \uB3CC\uB9AC\uC138\uC694:`);
|
|
596
|
+
for (const p of res.pendingInstalls) ctx.log(` cd ${p.dir} && ${p.cmd}`);
|
|
597
|
+
}
|
|
598
|
+
async function updatePackages(ctx, pkgs, opts = {}) {
|
|
599
|
+
const lock = await readLock(ctx.shed);
|
|
600
|
+
const res = { cloned: [], pendingInstalls: [], lockChanged: false };
|
|
601
|
+
for (const pkg of pkgs) {
|
|
602
|
+
const st = await packageStatus(ctx, pkg, lock);
|
|
603
|
+
if (!st.present) {
|
|
604
|
+
ctx.log(` ! package ${pkg.id}: \uC124\uCE58\uB418\uC5B4 \uC788\uC9C0 \uC54A\uC74C. \uBA3C\uC800 restore \uD558\uC138\uC694`);
|
|
605
|
+
continue;
|
|
606
|
+
}
|
|
607
|
+
if (opts.dryRun) {
|
|
608
|
+
ctx.log(` ~ package ${pkg.id} (git pull --ff-only)`);
|
|
609
|
+
continue;
|
|
610
|
+
}
|
|
611
|
+
await pullFf(st.dir);
|
|
612
|
+
const now = await head(st.dir);
|
|
613
|
+
const before = lock.packages[pkg.id]?.commit;
|
|
614
|
+
if (now !== before) {
|
|
615
|
+
lock.packages[pkg.id] = { source: pkg.source, commit: now };
|
|
616
|
+
res.lockChanged = true;
|
|
617
|
+
ctx.log(` \u2191 package ${pkg.id} ${before ? before.slice(0, 7) : "(\uC5C6\uC74C)"} \u2192 ${now.slice(0, 7)}`);
|
|
618
|
+
await maybeInstall(ctx, pkg, st.dir, opts, res);
|
|
619
|
+
} else {
|
|
620
|
+
ctx.log(` = package ${pkg.id} ${now.slice(0, 7)} (\uCD5C\uC2E0)`);
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
if (res.lockChanged) await writeLock(ctx.shed, lock);
|
|
624
|
+
return res;
|
|
625
|
+
}
|
|
626
|
+
|
|
338
627
|
// src/core/init.ts
|
|
339
628
|
async function init(ctx, opts = {}) {
|
|
340
629
|
const profileName = opts.profile ?? "default";
|
|
630
|
+
const exclude = opts.exclude ?? [];
|
|
631
|
+
const skipped = [];
|
|
341
632
|
if (await exists(manifestPath(ctx))) {
|
|
342
633
|
throw new Error(`\uC774\uBBF8 \uCD08\uAE30\uD654\uB41C \uCC3D\uACE0\uC785\uB2C8\uB2E4: ${manifestPath(ctx)}
|
|
343
634
|
\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.`);
|
|
344
635
|
}
|
|
345
|
-
const
|
|
346
|
-
const m = { version: 1, agent: ctx.adapter.name, components: {}, profiles: { [profileName]: {} } };
|
|
636
|
+
const all = await ctx.adapter.scan();
|
|
637
|
+
const m = { version: 1, agent: ctx.adapter.name, components: {}, packages: [], profiles: { [profileName]: {} } };
|
|
638
|
+
const isExcluded = (cat, id) => exclude.some((e) => e === id || e === `${cat}/${id}`);
|
|
639
|
+
const pkgs = (await detectPackages(ctx, all)).filter((p) => !isExcluded("", p.id));
|
|
640
|
+
const generated = await detectGenerated(all, pkgs);
|
|
641
|
+
const found = all.filter((f) => !pkgs.some((p) => p.path === f.path) && !generated.has(`${f.category}/${f.id}`));
|
|
642
|
+
for (const p of pkgs) {
|
|
643
|
+
m.packages.push({ id: p.id, source: p.source, into: p.into });
|
|
644
|
+
ctx.log(` \u2261 package ${p.id} ${p.source} @${p.commit.slice(0, 7)} (\uCC38\uC870\uB9CC \uAE30\uB85D)`);
|
|
645
|
+
}
|
|
646
|
+
if (pkgs.length) m.profiles[profileName][PACKAGES] = pkgs.map((p) => p.id);
|
|
647
|
+
for (const [key, by] of generated) ctx.log(` \xB7 ${key} (${by} \uAC00 \uC0DD\uC131\uD55C \uAC83 \u2192 \uAC74\uB108\uB700)`);
|
|
347
648
|
const managed = [];
|
|
348
649
|
let copied = 0;
|
|
349
650
|
for (const cat of ctx.adapter.categories()) {
|
|
350
|
-
const mine = found.filter((f) => f.category === cat.name);
|
|
651
|
+
const mine = found.filter((f) => f.category === cat.name && !isExcluded(f.category, f.id));
|
|
652
|
+
for (const f of found.filter((f2) => f2.category === cat.name && isExcluded(f2.category, f2.id))) {
|
|
653
|
+
skipped.push(`${f.category}/${f.id}`);
|
|
654
|
+
ctx.log(` - ${f.category}/${f.id} (--exclude)`);
|
|
655
|
+
}
|
|
351
656
|
if (!mine.length) continue;
|
|
352
657
|
m.components[cat.name] = [];
|
|
353
658
|
m.profiles[profileName][cat.name] = [];
|
|
354
659
|
for (const f of mine) {
|
|
355
|
-
const dst =
|
|
356
|
-
await copyTree(f.path, dst);
|
|
660
|
+
const dst = path10.join(ctx.shed, cat.root, cat.kind === "dir" ? f.id : `${f.id}.md`);
|
|
661
|
+
await copyTree(f.path, dst, ignoreOf(ctx));
|
|
357
662
|
m.components[cat.name].push({ id: f.id });
|
|
358
663
|
m.profiles[profileName][cat.name].push(f.id);
|
|
359
664
|
managed.push(targetRel(cat, f.id));
|
|
@@ -363,32 +668,47 @@ async function init(ctx, opts = {}) {
|
|
|
363
668
|
}
|
|
364
669
|
const instr = instructionsFile(ctx);
|
|
365
670
|
if (await exists(instr)) {
|
|
366
|
-
const text = await
|
|
671
|
+
const text = await fs7.readFile(instr, "utf8");
|
|
367
672
|
if (!isGenerated(text)) {
|
|
368
|
-
const dst =
|
|
369
|
-
await
|
|
370
|
-
await
|
|
673
|
+
const dst = path10.join(ctx.shed, INSTRUCTIONS, "main.md");
|
|
674
|
+
await fs7.mkdir(path10.dirname(dst), { recursive: true });
|
|
675
|
+
await fs7.writeFile(dst, text);
|
|
371
676
|
const fragRel = targetRel(INSTRUCTIONS, "main");
|
|
372
|
-
await copyTree(dst, abs(ctx, fragRel));
|
|
677
|
+
await copyTree(dst, abs(ctx, fragRel), ignoreOf(ctx));
|
|
373
678
|
managed.push(fragRel);
|
|
374
679
|
m.components[INSTRUCTIONS] = [{ id: "main" }];
|
|
375
680
|
m.profiles[profileName][INSTRUCTIONS] = ["main"];
|
|
376
681
|
copied++;
|
|
377
|
-
ctx.log(` + ${INSTRUCTIONS}/main (${
|
|
682
|
+
ctx.log(` + ${INSTRUCTIONS}/main (${path10.basename(instr)})`);
|
|
378
683
|
}
|
|
379
684
|
}
|
|
380
|
-
await
|
|
381
|
-
|
|
382
|
-
|
|
685
|
+
await fs7.mkdir(ctx.shed, { recursive: true });
|
|
686
|
+
let yamlText = stringifyManifest(m);
|
|
687
|
+
for (const p of pkgs) {
|
|
688
|
+
yamlText = yamlText.replace(` into: ${p.into}
|
|
689
|
+
`, ` into: ${p.into}
|
|
690
|
+
# install: ./setup # \u2190 \uBCF5\uC6D0 \uD6C4 \uC2E4\uD589\uD560 \uBA85\uB839\uC774 \uC788\uC73C\uBA74 \uCC44\uC6B0\uC138\uC694 (--yes \uB85C \uC2E4\uD589)
|
|
691
|
+
`);
|
|
692
|
+
}
|
|
693
|
+
await fs7.writeFile(manifestPath(ctx), `# lshed manifest \u2014 edit freely. Reference: https://github.com/LeeSongHeon-LSH/lshed
|
|
694
|
+
` + yamlText);
|
|
695
|
+
if (pkgs.length) {
|
|
696
|
+
await writeLock(ctx.shed, { version: 1, packages: Object.fromEntries(pkgs.map((p) => [p.id, { source: p.source, commit: p.commit }])) });
|
|
697
|
+
}
|
|
383
698
|
await writeState(ctx.adapter, { profile: profileName, shed: ctx.shed, managed, appliedAt: (/* @__PURE__ */ new Date()).toISOString() });
|
|
699
|
+
const parts = [`\uBD80\uD488 ${copied}\uAC1C`];
|
|
700
|
+
if (pkgs.length) parts.push(`\uD328\uD0A4\uC9C0 ${pkgs.length}\uAC1C`);
|
|
701
|
+
if (generated.size) parts.push(`\uC0DD\uC131\uBB3C ${generated.size}\uAC1C \uAC74\uB108\uB700`);
|
|
702
|
+
if (skipped.length) parts.push(`\uC81C\uC678 ${skipped.length}\uAC1C`);
|
|
384
703
|
ctx.log(`
|
|
385
|
-
${MANIFEST_FILE} \uC0DD\uC131: ${manifestPath(ctx)} (
|
|
386
|
-
|
|
704
|
+
${MANIFEST_FILE} \uC0DD\uC131: ${manifestPath(ctx)} (${parts.join(", ")}, \uD504\uB85C\uD544 "${profileName}")`);
|
|
705
|
+
if (pkgs.length) ctx.log(`\uD328\uD0A4\uC9C0\uC758 \uC124\uCE58 \uBA85\uB839(install:)\uC740 lshed.yaml \uC5D0\uC11C \uC9C1\uC811 \uCC44\uC6B0\uC138\uC694.`);
|
|
706
|
+
return { manifest: m, copied, skipped, packages: pkgs.map((p) => p.id), generated: [...generated.keys()] };
|
|
387
707
|
}
|
|
388
708
|
|
|
389
709
|
// src/core/restore.ts
|
|
390
|
-
import { promises as
|
|
391
|
-
import
|
|
710
|
+
import { promises as fs8 } from "fs";
|
|
711
|
+
import path11 from "path";
|
|
392
712
|
async function restore(ctx, profileArg, opts = {}) {
|
|
393
713
|
const backup = opts.backup ?? true;
|
|
394
714
|
const state = await readState(ctx.adapter);
|
|
@@ -399,6 +719,7 @@ async function restore(ctx, profileArg, opts = {}) {
|
|
|
399
719
|
for (const it of plan) {
|
|
400
720
|
if (!await exists(it.src)) throw new Error(`${it.category}/${it.id}: \uCC3D\uACE0\uC5D0 \uD30C\uC77C\uC774 \uC5C6\uC2B5\uB2C8\uB2E4: ${it.src}`);
|
|
401
721
|
}
|
|
722
|
+
const pkgRes = await ensurePackages(ctx, packagesOf(m, profile), { dryRun: opts.dryRun, yes: opts.yes });
|
|
402
723
|
const instrRel = ctx.adapter.instructionsFileName();
|
|
403
724
|
const fragments = plan.filter((p) => p.category === INSTRUCTIONS);
|
|
404
725
|
const newManaged = new Set(plan.map((p) => p.rel));
|
|
@@ -406,7 +727,7 @@ async function restore(ctx, profileArg, opts = {}) {
|
|
|
406
727
|
const oldManaged = new Set(state?.managed ?? []);
|
|
407
728
|
const toRemove = [...oldManaged].filter((r) => !newManaged.has(r)).sort();
|
|
408
729
|
const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
|
409
|
-
const backupDir =
|
|
730
|
+
const backupDir = path11.join(ctx.adapter.root, LSHED_DIR, "backups", stamp);
|
|
410
731
|
const backedUp = [];
|
|
411
732
|
const placed = [];
|
|
412
733
|
async function backUp(rel) {
|
|
@@ -414,7 +735,7 @@ async function restore(ctx, profileArg, opts = {}) {
|
|
|
414
735
|
if (!await exists(from)) return;
|
|
415
736
|
backedUp.push(rel);
|
|
416
737
|
if (opts.dryRun || !backup) return;
|
|
417
|
-
await copyTree(from,
|
|
738
|
+
await copyTree(from, path11.join(backupDir, ...rel.split("/")), ignoreOf(ctx));
|
|
418
739
|
}
|
|
419
740
|
for (const rel of toRemove) {
|
|
420
741
|
ctx.log(` - ${rel}`);
|
|
@@ -423,7 +744,7 @@ async function restore(ctx, profileArg, opts = {}) {
|
|
|
423
744
|
}
|
|
424
745
|
for (const it of plan) {
|
|
425
746
|
const target = abs(ctx, it.rel);
|
|
426
|
-
const same = await hashTree(target) === await hashTree(it.src);
|
|
747
|
+
const same = await hashTree(target, ignoreOf(ctx)) === await hashTree(it.src, ignoreOf(ctx));
|
|
427
748
|
const mark = same ? "=" : await exists(target) ? "~" : "+";
|
|
428
749
|
ctx.log(` ${mark} ${it.rel}`);
|
|
429
750
|
if (same) {
|
|
@@ -431,20 +752,20 @@ async function restore(ctx, profileArg, opts = {}) {
|
|
|
431
752
|
continue;
|
|
432
753
|
}
|
|
433
754
|
if (await exists(target)) await backUp(it.rel);
|
|
434
|
-
if (!opts.dryRun) await copyTree(it.src, target);
|
|
755
|
+
if (!opts.dryRun) await copyTree(it.src, target, ignoreOf(ctx));
|
|
435
756
|
placed.push(it.rel);
|
|
436
757
|
}
|
|
437
758
|
const instrPath = instructionsFile(ctx);
|
|
438
759
|
if (fragments.length) {
|
|
439
760
|
const contents = [];
|
|
440
|
-
for (const f of fragments) contents.push({ id: f.id, content: await
|
|
761
|
+
for (const f of fragments) contents.push({ id: f.id, content: await fs8.readFile(f.src, "utf8") });
|
|
441
762
|
const rendered = renderInstructions(ctx, profile, contents);
|
|
442
|
-
const existing = await exists(instrPath) ? await
|
|
763
|
+
const existing = await exists(instrPath) ? await fs8.readFile(instrPath, "utf8") : null;
|
|
443
764
|
if (existing !== rendered) {
|
|
444
765
|
const mark = existing === null ? "+" : "~";
|
|
445
766
|
ctx.log(` ${mark} ${instrRel}${existing !== null && !isGenerated(existing) ? " (\uAE30\uC874 \uD30C\uC77C\uC740 lshed \uC0DD\uC131\uBB3C\uC774 \uC544\uB2D8 \u2192 \uBC31\uC5C5)" : ""}`);
|
|
446
767
|
if (existing !== null) await backUp(instrRel);
|
|
447
|
-
if (!opts.dryRun) await
|
|
768
|
+
if (!opts.dryRun) await fs8.writeFile(instrPath, rendered);
|
|
448
769
|
} else {
|
|
449
770
|
ctx.log(` = ${instrRel}`);
|
|
450
771
|
}
|
|
@@ -453,12 +774,14 @@ async function restore(ctx, profileArg, opts = {}) {
|
|
|
453
774
|
if (opts.dryRun) {
|
|
454
775
|
ctx.log(`
|
|
455
776
|
(dry-run) \uBCC0\uACBD \uC5C6\uC74C. \uBC30\uCE58 ${placed.length}, \uC81C\uAC70 ${toRemove.length}, \uBC31\uC5C5 \uC608\uC815 ${backedUp.length}`);
|
|
777
|
+
reportPending(ctx, pkgRes);
|
|
456
778
|
return { profile, placed, removed: toRemove, backedUp, backupDir: null };
|
|
457
779
|
}
|
|
458
780
|
await writeState(ctx.adapter, { profile, shed: ctx.shed, managed: [...newManaged].sort(), appliedAt: (/* @__PURE__ */ new Date()).toISOString() });
|
|
459
781
|
const bdir = backup && backedUp.length ? backupDir : null;
|
|
460
782
|
ctx.log(`
|
|
461
|
-
\uD504\uB85C\uD544 "${profile}" \uC801\uC6A9: \uBC30\uCE58 ${placed.length}, \uC81C\uAC70 ${toRemove.length}${bdir ? `, \uBC31\uC5C5 ${backedUp.length} \u2192 ${bdir}` : ""}`);
|
|
783
|
+
\uD504\uB85C\uD544 "${profile}" \uC801\uC6A9: \uBC30\uCE58 ${placed.length}, \uC81C\uAC70 ${toRemove.length}${pkgRes.cloned.length ? `, \uD328\uD0A4\uC9C0 clone ${pkgRes.cloned.length}` : ""}${bdir ? `, \uBC31\uC5C5 ${backedUp.length} \u2192 ${bdir}` : ""}`);
|
|
784
|
+
reportPending(ctx, pkgRes);
|
|
462
785
|
return { profile, placed, removed: toRemove, backedUp, backupDir: bdir };
|
|
463
786
|
}
|
|
464
787
|
|
|
@@ -469,7 +792,7 @@ async function diff(ctx) {
|
|
|
469
792
|
const m = await loadManifest(ctx);
|
|
470
793
|
const out = [];
|
|
471
794
|
for (const item of planProfile(ctx, m, state.profile)) {
|
|
472
|
-
const changes = await diffTrees(abs(ctx, item.rel), item.src);
|
|
795
|
+
const changes = await diffTrees(abs(ctx, item.rel), item.src, ignoreOf(ctx));
|
|
473
796
|
if (changes.length) out.push({ item, changes });
|
|
474
797
|
}
|
|
475
798
|
return out;
|
|
@@ -488,9 +811,12 @@ function formatDiff(diffs) {
|
|
|
488
811
|
// src/core/status.ts
|
|
489
812
|
async function status(ctx) {
|
|
490
813
|
const state = await readState(ctx.adapter);
|
|
491
|
-
if (!state) return { state: null, drifted: [] };
|
|
814
|
+
if (!state) return { state: null, drifted: [], packages: [] };
|
|
492
815
|
const d = await diff(ctx);
|
|
493
|
-
|
|
816
|
+
const m = await loadManifest(ctx);
|
|
817
|
+
const lock = await readLock(ctx.shed);
|
|
818
|
+
const packages = await Promise.all(packagesOf(m, state.profile).map((p) => packageStatus(ctx, p, lock)));
|
|
819
|
+
return { state, drifted: d.map((x2) => `${x2.item.category}/${x2.item.id}`), packages };
|
|
494
820
|
}
|
|
495
821
|
function formatStatus(s, adapterRoot) {
|
|
496
822
|
if (!s.state) return `\uC801\uC6A9\uB41C \uD504\uB85C\uD544\uC774 \uC5C6\uC2B5\uB2C8\uB2E4 (${adapterRoot}).
|
|
@@ -502,6 +828,10 @@ function formatStatus(s, adapterRoot) {
|
|
|
502
828
|
`\uAD00\uB9AC \uC911 ${s.state.managed.length}\uAC1C \uACBD\uB85C (${adapterRoot})`
|
|
503
829
|
];
|
|
504
830
|
lines.push(s.drifted.length ? `\uB4DC\uB9AC\uD504\uD2B8 ${s.drifted.length}\uAC1C: ${s.drifted.join(", ")} \u2192 lshed diff` : "\uB4DC\uB9AC\uD504\uD2B8 \uC5C6\uC74C");
|
|
831
|
+
for (const p of s.packages) {
|
|
832
|
+
const where = !p.present ? "\uC124\uCE58 \uC548 \uB428 \u2192 lshed restore" : !p.locked ? `${p.head.slice(0, 7)} (\uB77D \uC5C6\uC74C)` : p.head === p.locked ? `${p.head.slice(0, 7)} = lock` : `${p.head.slice(0, 7)} \u2260 lock ${p.locked.slice(0, 7)} \u2192 lshed update`;
|
|
833
|
+
lines.push(`\uD328\uD0A4\uC9C0 ${p.pkg.id} ${where}`);
|
|
834
|
+
}
|
|
505
835
|
return lines.join("\n");
|
|
506
836
|
}
|
|
507
837
|
|
|
@@ -533,8 +863,8 @@ async function save(ctx, ids = []) {
|
|
|
533
863
|
ctx.log(` ! ${it.category}/${it.id}: \uB85C\uCEEC\uC5D0 \uC5C6\uC74C (\uAC74\uB108\uB700)`);
|
|
534
864
|
continue;
|
|
535
865
|
}
|
|
536
|
-
if (await hashTree(local) === await hashTree(it.src)) continue;
|
|
537
|
-
await copyTree(local, it.src);
|
|
866
|
+
if (await hashTree(local, ignoreOf(ctx)) === await hashTree(it.src, ignoreOf(ctx))) continue;
|
|
867
|
+
await copyTree(local, it.src, ignoreOf(ctx));
|
|
538
868
|
saved.push(`${it.category}/${it.id}`);
|
|
539
869
|
ctx.log(` \u2713 ${it.category}/${it.id} \u2192 \uCC3D\uACE0`);
|
|
540
870
|
}
|
|
@@ -543,21 +873,115 @@ ${saved.length}\uAC1C \uBC18\uC601. \uCC3D\uACE0\uB97C \uCEE4\uBC0B/\uB3D9\uAE30
|
|
|
543
873
|
return saved;
|
|
544
874
|
}
|
|
545
875
|
|
|
876
|
+
// src/core/list.ts
|
|
877
|
+
function listRows(m) {
|
|
878
|
+
const usedBy = (cat, id) => Object.entries(m.profiles).filter(([, cats]) => (cats[cat] ?? []).includes(id)).map(([name]) => name);
|
|
879
|
+
const rows = [];
|
|
880
|
+
for (const [cat, comps] of Object.entries(m.components)) {
|
|
881
|
+
for (const c of comps) rows.push({ kind: "component", category: cat, id: c.id, usedBy: usedBy(cat, c.id) });
|
|
882
|
+
}
|
|
883
|
+
for (const p of m.packages) rows.push({ kind: "package", category: PACKAGES, id: p.id, usedBy: usedBy(PACKAGES, p.id) });
|
|
884
|
+
return rows;
|
|
885
|
+
}
|
|
886
|
+
function formatRows(rows, m) {
|
|
887
|
+
if (!rows.length) return "(\uBE44\uC5B4 \uC788\uC74C)";
|
|
888
|
+
const w = Math.max(...rows.map((r) => `${r.category}/${r.id}`.length));
|
|
889
|
+
const lines = rows.map((r) => {
|
|
890
|
+
const key = `${r.category}/${r.id}`.padEnd(w);
|
|
891
|
+
const use = r.usedBy.length ? r.usedBy.join(", ") : "(\uBBF8\uC0AC\uC6A9)";
|
|
892
|
+
return `${r.kind === "package" ? "\u2261" : " "} ${key} ${use}`;
|
|
893
|
+
});
|
|
894
|
+
const unused = rows.filter((r) => !r.usedBy.length).length;
|
|
895
|
+
lines.push("", `${rows.length}\uAC1C, \uD504\uB85C\uD544 ${Object.keys(m.profiles).length}\uAC1C${unused ? `, \uBBF8\uC0AC\uC6A9 ${unused}\uAC1C \u2192 lshed prune` : ""}`);
|
|
896
|
+
return lines.join("\n");
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
// src/core/remove.ts
|
|
900
|
+
import { promises as fs9 } from "fs";
|
|
901
|
+
import path12 from "path";
|
|
902
|
+
import YAML3, { isSeq, isMap } from "yaml";
|
|
903
|
+
function resolveKey(m, raw) {
|
|
904
|
+
const rows = listRows(m);
|
|
905
|
+
const [a, b] = raw.includes("/") ? raw.split("/", 2) : [void 0, raw];
|
|
906
|
+
const hits = rows.filter((r) => r.id === b && (a === void 0 || r.category === a));
|
|
907
|
+
if (!hits.length) throw new Error(`"${raw}" \uB294 \uCC3D\uACE0\uC5D0 \uC5C6\uC2B5\uB2C8\uB2E4`);
|
|
908
|
+
if (hits.length > 1) throw new Error(`"${raw}" \uAC00 \uBAA8\uD638\uD569\uB2C8\uB2E4: ${hits.map((h) => `${h.category}/${h.id}`).join(", ")}`);
|
|
909
|
+
return { category: hits[0].category, id: hits[0].id };
|
|
910
|
+
}
|
|
911
|
+
async function remove(ctx, raw) {
|
|
912
|
+
const m = await loadManifest(ctx);
|
|
913
|
+
const { category, id } = resolveKey(m, raw);
|
|
914
|
+
const users = listRows(m).find((r) => r.category === category && r.id === id).usedBy;
|
|
915
|
+
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.`);
|
|
916
|
+
const text = await fs9.readFile(manifestPath(ctx), "utf8");
|
|
917
|
+
const doc = YAML3.parseDocument(text);
|
|
918
|
+
let deleted;
|
|
919
|
+
if (category === PACKAGES) {
|
|
920
|
+
const seq = doc.get(PACKAGES);
|
|
921
|
+
if (!isSeq(seq)) throw new Error("packages \uAC00 \uBAA9\uB85D\uC774 \uC544\uB2D9\uB2C8\uB2E4");
|
|
922
|
+
const idx = seq.items.findIndex((it) => isMap(it) && it.get("id") === id);
|
|
923
|
+
seq.delete(idx);
|
|
924
|
+
if (!seq.items.length) doc.delete(PACKAGES);
|
|
925
|
+
const lock = await readLock(ctx.shed);
|
|
926
|
+
if (lock.packages[id]) {
|
|
927
|
+
delete lock.packages[id];
|
|
928
|
+
await writeLock(ctx.shed, lock);
|
|
929
|
+
}
|
|
930
|
+
ctx.log(` - package ${id} (\uB9E4\uB2C8\uD398\uC2A4\uD2B8\xB7\uB77D\uC5D0\uC11C \uC81C\uAC70. \uB85C\uCEEC clone \uC740 \uADF8\uB300\uB85C)`);
|
|
931
|
+
} else {
|
|
932
|
+
const seq = doc.getIn(["components", category]);
|
|
933
|
+
if (!isSeq(seq)) throw new Error(`components.${category} \uAC00 \uBAA9\uB85D\uC774 \uC544\uB2D9\uB2C8\uB2E4`);
|
|
934
|
+
const idx = seq.items.findIndex((it) => isMap(it) && it.get("id") === id);
|
|
935
|
+
seq.delete(idx);
|
|
936
|
+
if (!seq.items.length) doc.deleteIn(["components", category]);
|
|
937
|
+
const src = sourcePath(ctx, category, findComponent(m, category, id));
|
|
938
|
+
const inside = !path12.relative(ctx.shed, src).startsWith("..");
|
|
939
|
+
if (inside && await exists(src)) {
|
|
940
|
+
await removeTree(src);
|
|
941
|
+
deleted = src;
|
|
942
|
+
}
|
|
943
|
+
ctx.log(` - ${category}/${id}${deleted ? "" : " (\uCC3D\uACE0 \uBC16 \uACBD\uB85C\uB77C \uD30C\uC77C\uC740 \uB450\uC5C8\uC74C)"}`);
|
|
944
|
+
}
|
|
945
|
+
await fs9.writeFile(manifestPath(ctx), doc.toString());
|
|
946
|
+
return { category, id, deleted };
|
|
947
|
+
}
|
|
948
|
+
async function prune(ctx, opts = {}) {
|
|
949
|
+
const m = await loadManifest(ctx);
|
|
950
|
+
const unused = listRows(m).filter((r) => !r.usedBy.length);
|
|
951
|
+
if (!unused.length) {
|
|
952
|
+
ctx.log("\uBBF8\uC0AC\uC6A9 \uD56D\uBAA9\uC774 \uC5C6\uC2B5\uB2C8\uB2E4.");
|
|
953
|
+
return [];
|
|
954
|
+
}
|
|
955
|
+
if (!opts.yes) {
|
|
956
|
+
ctx.log(`\uBBF8\uC0AC\uC6A9 ${unused.length}\uAC1C (\uC9C0\uC6B0\uB824\uBA74 --yes):`);
|
|
957
|
+
for (const r of unused) ctx.log(` ${r.category}/${r.id}`);
|
|
958
|
+
return [];
|
|
959
|
+
}
|
|
960
|
+
const removed = [];
|
|
961
|
+
for (const r of unused) {
|
|
962
|
+
await remove(ctx, `${r.category}/${r.id}`);
|
|
963
|
+
removed.push(`${r.category}/${r.id}`);
|
|
964
|
+
}
|
|
965
|
+
ctx.log(`
|
|
966
|
+
${removed.length}\uAC1C \uC81C\uAC70. \uCC3D\uACE0\uB97C \uCEE4\uBC0B\uD558\uC138\uC694: ${ctx.shed}`);
|
|
967
|
+
return removed;
|
|
968
|
+
}
|
|
969
|
+
|
|
546
970
|
// src/cli.ts
|
|
547
971
|
var { version } = createRequire(import.meta.url)("../package.json");
|
|
548
972
|
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)");
|
|
549
973
|
function adapterFromOpts() {
|
|
550
974
|
const { root } = program.opts();
|
|
551
|
-
return new ClaudeCodeAdapter(root ?
|
|
975
|
+
return new ClaudeCodeAdapter(root ? path13.resolve(root) : void 0);
|
|
552
976
|
}
|
|
553
977
|
async function ctxFor(cmd) {
|
|
554
978
|
const adapter = adapterFromOpts();
|
|
555
979
|
const { shed: flag } = program.opts();
|
|
556
980
|
let shed = flag ?? process.env.LSHED_HOME;
|
|
557
981
|
if (!shed && cmd === "other") shed = (await readState(adapter))?.shed;
|
|
558
|
-
if (!shed && cmd === "init") shed =
|
|
982
|
+
if (!shed && cmd === "init") shed = path13.join(os2.homedir(), "lshed");
|
|
559
983
|
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.");
|
|
560
|
-
return { adapter, shed:
|
|
984
|
+
return { adapter, shed: path13.resolve(shed), log: (l) => console.log(l) };
|
|
561
985
|
}
|
|
562
986
|
async function run(fn) {
|
|
563
987
|
try {
|
|
@@ -567,22 +991,42 @@ async function run(fn) {
|
|
|
567
991
|
process.exitCode = 1;
|
|
568
992
|
}
|
|
569
993
|
}
|
|
570
|
-
program.command("init").description("scan the current environment into a shed and write lshed.yaml").option("--profile <name>", "name of the initial profile", "default").action((o) => run(async () => {
|
|
994
|
+
program.command("init").description("scan the current environment into a shed and write lshed.yaml").option("--profile <name>", "name of the initial profile", "default").option("--exclude <id...>", "components to leave out (id or category/id)").action((o) => run(async () => {
|
|
571
995
|
const ctx = await ctxFor("init");
|
|
572
996
|
console.log(`\uC2A4\uCE94: ${ctx.adapter.root} \u2192 \uCC3D\uACE0: ${ctx.shed}`);
|
|
573
|
-
await init(ctx, { profile: o.profile });
|
|
997
|
+
await init(ctx, { profile: o.profile, exclude: o.exclude });
|
|
574
998
|
console.log(`
|
|
575
999
|
\uB2E4\uC74C: \uCC3D\uACE0\uB97C git \uC73C\uB85C \uAD00\uB9AC\uD558\uC138\uC694. cd ${ctx.shed} && git init`);
|
|
576
1000
|
}));
|
|
577
|
-
program.command("restore [profile]").description("apply a profile (defaults to the last applied one)").option("--dry-run", "print what would change without touching anything").option("--no-backup", "skip backing up files that get replaced or removed").action((profile, o) => run(async () => {
|
|
1001
|
+
program.command("restore [profile]").description("apply a profile (defaults to the last applied one)").option("--dry-run", "print what would change without touching anything").option("--no-backup", "skip backing up files that get replaced or removed").option("--yes", "run package install commands (they are shown, not run, without this)").action((profile, o) => run(async () => {
|
|
578
1002
|
const ctx = await ctxFor("other");
|
|
579
|
-
await restore(ctx, profile, { dryRun: o.dryRun, backup: o.backup });
|
|
1003
|
+
await restore(ctx, profile, { dryRun: o.dryRun, backup: o.backup, yes: o.yes });
|
|
1004
|
+
}));
|
|
1005
|
+
program.command("update [ids...]").description("pull packages to their latest upstream and refresh lshed.lock").option("--dry-run", "show what would be updated").option("--yes", "run package install commands after updating").action((ids, o) => run(async () => {
|
|
1006
|
+
const ctx = await ctxFor("other");
|
|
1007
|
+
const state = await readState(ctx.adapter);
|
|
1008
|
+
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.");
|
|
1009
|
+
const m = await loadManifest(ctx);
|
|
1010
|
+
let pkgs = packagesOf(m, state.profile);
|
|
1011
|
+
if (ids.length) {
|
|
1012
|
+
pkgs = ids.map((id) => {
|
|
1013
|
+
const p = m.packages.find((x2) => x2.id === id);
|
|
1014
|
+
if (!p) throw new Error(`\uD328\uD0A4\uC9C0 "${id}" \uAC00 \uC5C6\uC2B5\uB2C8\uB2E4`);
|
|
1015
|
+
return p;
|
|
1016
|
+
});
|
|
1017
|
+
}
|
|
1018
|
+
if (!pkgs.length) {
|
|
1019
|
+
console.log("\uAC31\uC2E0\uD560 \uD328\uD0A4\uC9C0\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4.");
|
|
1020
|
+
return;
|
|
1021
|
+
}
|
|
1022
|
+
const res = await updatePackages(ctx, pkgs, { dryRun: o.dryRun, yes: o.yes });
|
|
1023
|
+
reportPending(ctx, res);
|
|
580
1024
|
}));
|
|
581
1025
|
program.command("status").description("show the applied profile, managed paths and drift").action(() => run(async () => {
|
|
582
1026
|
const adapter = adapterFromOpts();
|
|
583
1027
|
const state = await readState(adapter);
|
|
584
1028
|
if (!state) {
|
|
585
|
-
console.log(formatStatus({ state: null, drifted: [] }, adapter.root));
|
|
1029
|
+
console.log(formatStatus({ state: null, drifted: [], packages: [] }, adapter.root));
|
|
586
1030
|
return;
|
|
587
1031
|
}
|
|
588
1032
|
const ctx = await ctxFor("other");
|
|
@@ -596,6 +1040,20 @@ program.command("save [ids...]").description("copy local edits back into the she
|
|
|
596
1040
|
const ctx = await ctxFor("other");
|
|
597
1041
|
await save(ctx, ids);
|
|
598
1042
|
}));
|
|
1043
|
+
program.command("list").description("everything in the shed and which profiles use it").option("--unused", "only things no profile uses").action((o) => run(async () => {
|
|
1044
|
+
const ctx = await ctxFor("other");
|
|
1045
|
+
const m = await loadManifest(ctx);
|
|
1046
|
+
const rows = listRows(m).filter((r) => !o.unused || !r.usedBy.length);
|
|
1047
|
+
console.log(o.unused && !rows.length ? "\uBBF8\uC0AC\uC6A9 \uD56D\uBAA9\uC774 \uC5C6\uC2B5\uB2C8\uB2E4." : formatRows(rows, m));
|
|
1048
|
+
}));
|
|
1049
|
+
program.command("remove <key>").description("delete a component or package from the shed (refused while a profile uses it)").action((key) => run(async () => {
|
|
1050
|
+
const ctx = await ctxFor("other");
|
|
1051
|
+
await remove(ctx, key);
|
|
1052
|
+
}));
|
|
1053
|
+
program.command("prune").description("remove everything no profile uses").option("--yes", "actually delete; without it, just list").action((o) => run(async () => {
|
|
1054
|
+
const ctx = await ctxFor("other");
|
|
1055
|
+
await prune(ctx, { yes: o.yes });
|
|
1056
|
+
}));
|
|
599
1057
|
program.command("scan").description("(debug) list components found in the agent config root").action(() => run(async () => {
|
|
600
1058
|
const adapter = adapterFromOpts();
|
|
601
1059
|
const found = await adapter.scan();
|