agent-skillboard 0.2.15 → 0.2.16
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 +21 -0
- package/README.md +64 -35
- package/bin/postinstall.mjs +5 -3
- package/docs/ai-skill-routing-goal.md +15 -3
- package/docs/install.md +98 -68
- package/docs/positioning.md +8 -6
- package/docs/reference.md +22 -10
- package/docs/routing.md +15 -0
- package/docs/user-flow.md +61 -32
- package/docs/value-proof.md +6 -3
- package/package.json +3 -2
- package/src/advisor/guidance.mjs +23 -3
- package/src/agent-integration-cli.mjs +184 -0
- package/src/agent-integration-content.mjs +48 -0
- package/src/agent-integration-files.mjs +202 -0
- package/src/agent-integration-home.mjs +185 -0
- package/src/agent-skill-roots.mjs +1 -0
- package/src/brief-renderer.mjs +36 -57
- package/src/cli.mjs +168 -169
- package/src/control/can-use-guard.mjs +12 -0
- package/src/doctor.mjs +4 -1
- package/src/lifecycle-cli.mjs +23 -303
- package/src/lifecycle-content.mjs +4 -2
- package/src/route-advisory.mjs +214 -0
- package/src/route-renderer.mjs +146 -0
- package/src/route-selection.mjs +220 -0
- package/src/route-tokens.mjs +68 -0
- package/src/route.mjs +48 -413
- package/src/source-profiles.mjs +151 -150
- package/src/uninstall.mjs +56 -9
- package/src/workspace.mjs +79 -79
package/src/source-profiles.mjs
CHANGED
|
@@ -1,26 +1,26 @@
|
|
|
1
|
-
import { readdir, readFile } from "node:fs/promises";
|
|
2
|
-
import { basename, dirname, join, relative, resolve } from "node:path";
|
|
3
|
-
import YAML from "yaml";
|
|
1
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
2
|
+
import { basename, dirname, join, relative, resolve } from "node:path";
|
|
3
|
+
import YAML from "yaml";
|
|
4
4
|
import { readString, requireRecord } from "./config-helpers.mjs";
|
|
5
5
|
import { textChangePlan } from "./change-plan.mjs";
|
|
6
6
|
import { loadSourceProfile } from "./source-profile-loader.mjs";
|
|
7
|
-
|
|
8
|
-
export { loadSourceProfile };
|
|
9
|
-
|
|
10
|
-
export async function importSource(options) {
|
|
11
|
-
const sourceRoot = resolve(options.sourceRoot);
|
|
12
|
-
const profile = options.profile;
|
|
13
|
-
const files = await findSkillFiles(sourceRoot);
|
|
14
|
-
const matchedFiles = files
|
|
15
|
-
.map((file) => ({
|
|
16
|
-
file,
|
|
7
|
+
|
|
8
|
+
export { loadSourceProfile };
|
|
9
|
+
|
|
10
|
+
export async function importSource(options) {
|
|
11
|
+
const sourceRoot = resolve(options.sourceRoot);
|
|
12
|
+
const profile = options.profile;
|
|
13
|
+
const files = await findSkillFiles(sourceRoot);
|
|
14
|
+
const matchedFiles = files
|
|
15
|
+
.map((file) => ({
|
|
16
|
+
file,
|
|
17
17
|
relativeFile: relative(sourceRoot, file).replace(/\\/g, "/")
|
|
18
|
-
}))
|
|
19
|
-
.filter((entry) => matchesAnyProfilePattern(entry.relativeFile, profile.skillPaths))
|
|
20
|
-
.sort((left, right) => left.relativeFile.localeCompare(right.relativeFile));
|
|
21
|
-
const skills = [];
|
|
22
|
-
const warnings = [];
|
|
23
|
-
|
|
18
|
+
}))
|
|
19
|
+
.filter((entry) => matchesAnyProfilePattern(entry.relativeFile, profile.skillPaths))
|
|
20
|
+
.sort((left, right) => left.relativeFile.localeCompare(right.relativeFile));
|
|
21
|
+
const skills = [];
|
|
22
|
+
const warnings = [];
|
|
23
|
+
|
|
24
24
|
for (const entry of matchedFiles) {
|
|
25
25
|
const frontmatter = parseSkillFrontmatter(await readFile(entry.file, "utf8"));
|
|
26
26
|
const pathRule = matchingPathRule(entry.relativeFile, profile);
|
|
@@ -37,17 +37,17 @@ export async function importSource(options) {
|
|
|
37
37
|
ownerInstallUnit: profile.id,
|
|
38
38
|
description: frontmatter.description
|
|
39
39
|
});
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
if (skills.length === 0) {
|
|
43
|
-
warnings.push(`No SKILL.md files matched profile ${profile.id} under ${sourceRoot}`);
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
return {
|
|
47
|
-
profile,
|
|
48
|
-
sourceRoot,
|
|
49
|
-
skills,
|
|
50
|
-
installUnit: {
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (skills.length === 0) {
|
|
43
|
+
warnings.push(`No SKILL.md files matched profile ${profile.id} under ${sourceRoot}`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
profile,
|
|
48
|
+
sourceRoot,
|
|
49
|
+
skills,
|
|
50
|
+
installUnit: {
|
|
51
51
|
id: profile.id,
|
|
52
52
|
kind: profile.kind,
|
|
53
53
|
sourceClass: profile.sourceClass,
|
|
@@ -58,31 +58,31 @@ export async function importSource(options) {
|
|
|
58
58
|
publicKey: profile.publicKey,
|
|
59
59
|
verifiedAt: profile.verifiedAt,
|
|
60
60
|
source: profile.source || sourceRoot,
|
|
61
|
-
scope: profile.scope,
|
|
62
|
-
manifestPath: profile.manifestPath,
|
|
63
|
-
cachePath: profile.cachePath,
|
|
64
|
-
providedComponents: profile.providedComponents,
|
|
65
|
-
components: {
|
|
66
|
-
skills: skills.map((skill) => skill.id),
|
|
67
|
-
commands: profile.components.commands,
|
|
68
|
-
hooks: profile.components.hooks,
|
|
69
|
-
mcpServers: profile.components.mcpServers
|
|
70
|
-
},
|
|
71
|
-
modifiedConfigFiles: profile.modifiedConfigFiles,
|
|
72
|
-
autoUpdate: profile.autoUpdate,
|
|
73
|
-
enabled: profile.enabled,
|
|
74
|
-
workflowDependencies: profile.workflowDependencies,
|
|
75
|
-
permissionRisk: profile.permissionRisk,
|
|
76
|
-
rollback: profile.rollback
|
|
77
|
-
},
|
|
78
|
-
warnings
|
|
79
|
-
};
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
export function renderImportFragment(imported) {
|
|
83
|
-
return YAML.stringify(importFragment(imported));
|
|
84
|
-
}
|
|
85
|
-
|
|
61
|
+
scope: profile.scope,
|
|
62
|
+
manifestPath: profile.manifestPath,
|
|
63
|
+
cachePath: profile.cachePath,
|
|
64
|
+
providedComponents: profile.providedComponents,
|
|
65
|
+
components: {
|
|
66
|
+
skills: skills.map((skill) => skill.id),
|
|
67
|
+
commands: profile.components.commands,
|
|
68
|
+
hooks: profile.components.hooks,
|
|
69
|
+
mcpServers: profile.components.mcpServers
|
|
70
|
+
},
|
|
71
|
+
modifiedConfigFiles: profile.modifiedConfigFiles,
|
|
72
|
+
autoUpdate: profile.autoUpdate,
|
|
73
|
+
enabled: profile.enabled,
|
|
74
|
+
workflowDependencies: profile.workflowDependencies,
|
|
75
|
+
permissionRisk: profile.permissionRisk,
|
|
76
|
+
rollback: profile.rollback
|
|
77
|
+
},
|
|
78
|
+
warnings
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function renderImportFragment(imported) {
|
|
83
|
+
return YAML.stringify(importFragment(imported));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
86
|
export function mergeImportFragment(configText, imported, options = {}) {
|
|
87
87
|
const document = YAML.parseDocument(configText);
|
|
88
88
|
if (document.errors.length > 0) {
|
|
@@ -117,7 +117,7 @@ export function mergeImportFragment(configText, imported, options = {}) {
|
|
|
117
117
|
addedSkills: skillIds,
|
|
118
118
|
addedInstallUnits: unitIds,
|
|
119
119
|
replacedSkills: duplicateSkills,
|
|
120
|
-
replacedInstallUnits: duplicateUnits
|
|
120
|
+
replacedInstallUnits: duplicateUnits
|
|
121
121
|
};
|
|
122
122
|
}
|
|
123
123
|
|
|
@@ -141,20 +141,20 @@ function requireYamlMap(value, label) {
|
|
|
141
141
|
function preserveLineEndings(text, reference) {
|
|
142
142
|
return reference.includes("\r\n") ? text.replace(/\n/g, "\r\n") : text;
|
|
143
143
|
}
|
|
144
|
-
|
|
144
|
+
|
|
145
145
|
function importFragment(imported) {
|
|
146
|
-
const skills = {};
|
|
147
|
-
for (const skill of imported.skills) {
|
|
148
|
-
skills[skill.id] = {
|
|
149
|
-
path: skill.path,
|
|
150
|
-
status: skill.status,
|
|
151
|
-
invocation: skill.invocation,
|
|
152
|
-
exposure: skill.exposure,
|
|
153
|
-
category: skill.category,
|
|
154
|
-
owner_install_unit: skill.ownerInstallUnit
|
|
155
|
-
};
|
|
156
|
-
}
|
|
157
|
-
const unit = imported.installUnit;
|
|
146
|
+
const skills = {};
|
|
147
|
+
for (const skill of imported.skills) {
|
|
148
|
+
skills[skill.id] = {
|
|
149
|
+
path: skill.path,
|
|
150
|
+
status: skill.status,
|
|
151
|
+
invocation: skill.invocation,
|
|
152
|
+
exposure: skill.exposure,
|
|
153
|
+
category: skill.category,
|
|
154
|
+
owner_install_unit: skill.ownerInstallUnit
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
const unit = imported.installUnit;
|
|
158
158
|
return stripUndefined({
|
|
159
159
|
skills,
|
|
160
160
|
install_units: {
|
|
@@ -168,64 +168,64 @@ function importFragment(imported) {
|
|
|
168
168
|
public_key: unit.publicKey,
|
|
169
169
|
verified_at: unit.verifiedAt,
|
|
170
170
|
source: unit.source,
|
|
171
|
-
scope: unit.scope,
|
|
172
|
-
manifest_path: unit.manifestPath,
|
|
173
|
-
cache_path: unit.cachePath,
|
|
174
|
-
provided_components: unit.providedComponents,
|
|
175
|
-
components: {
|
|
176
|
-
skills: unit.components.skills,
|
|
177
|
-
commands: unit.components.commands,
|
|
178
|
-
hooks: unit.components.hooks,
|
|
179
|
-
mcp_servers: unit.components.mcpServers
|
|
180
|
-
},
|
|
181
|
-
modified_config_files: unit.modifiedConfigFiles,
|
|
182
|
-
auto_update: unit.autoUpdate,
|
|
183
|
-
enabled: unit.enabled,
|
|
184
|
-
workflow_dependencies: unit.workflowDependencies,
|
|
185
|
-
permission_risk: unit.permissionRisk,
|
|
171
|
+
scope: unit.scope,
|
|
172
|
+
manifest_path: unit.manifestPath,
|
|
173
|
+
cache_path: unit.cachePath,
|
|
174
|
+
provided_components: unit.providedComponents,
|
|
175
|
+
components: {
|
|
176
|
+
skills: unit.components.skills,
|
|
177
|
+
commands: unit.components.commands,
|
|
178
|
+
hooks: unit.components.hooks,
|
|
179
|
+
mcp_servers: unit.components.mcpServers
|
|
180
|
+
},
|
|
181
|
+
modified_config_files: unit.modifiedConfigFiles,
|
|
182
|
+
auto_update: unit.autoUpdate,
|
|
183
|
+
enabled: unit.enabled,
|
|
184
|
+
workflow_dependencies: unit.workflowDependencies,
|
|
185
|
+
permission_risk: unit.permissionRisk,
|
|
186
186
|
rollback: unit.rollback
|
|
187
187
|
}
|
|
188
188
|
}
|
|
189
189
|
});
|
|
190
190
|
}
|
|
191
|
-
|
|
192
|
-
function duplicateMessage(skillIds, unitIds) {
|
|
193
|
-
const parts = [];
|
|
194
|
-
if (skillIds.length > 0) {
|
|
195
|
-
parts.push(`skills already exist: ${skillIds.join(", ")}`);
|
|
196
|
-
}
|
|
197
|
-
if (unitIds.length > 0) {
|
|
198
|
-
parts.push(`install units already exist: ${unitIds.join(", ")}`);
|
|
199
|
-
}
|
|
200
|
-
return `${parts.join("; ")}. Re-run with --replace to overwrite.`;
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
async function findSkillFiles(root) {
|
|
204
|
-
const files = [];
|
|
205
|
-
const entries = await readdir(root, { withFileTypes: true }).catch(() => []);
|
|
206
|
-
for (const entry of entries) {
|
|
207
|
-
const path = join(root, entry.name);
|
|
208
|
-
if (entry.isDirectory()) {
|
|
209
|
-
files.push(...(await findSkillFiles(path)));
|
|
210
|
-
} else if (entry.isFile() && entry.name === "SKILL.md") {
|
|
211
|
-
files.push(path);
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
return files;
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
function parseSkillFrontmatter(text) {
|
|
218
|
-
const match = /^---[ \t]*\r?\n(?<body>[\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/.exec(text);
|
|
219
|
-
if (match?.groups === undefined) {
|
|
220
|
-
throw new Error("SKILL.md is missing YAML frontmatter");
|
|
221
|
-
}
|
|
222
|
-
const raw = requireRecord(YAML.parse(match.groups.body), "SKILL.md frontmatter");
|
|
223
|
-
return {
|
|
224
|
-
name: readString(raw, "name", ""),
|
|
225
|
-
description: readString(raw, "description", "")
|
|
226
|
-
};
|
|
227
|
-
}
|
|
228
|
-
|
|
191
|
+
|
|
192
|
+
function duplicateMessage(skillIds, unitIds) {
|
|
193
|
+
const parts = [];
|
|
194
|
+
if (skillIds.length > 0) {
|
|
195
|
+
parts.push(`skills already exist: ${skillIds.join(", ")}`);
|
|
196
|
+
}
|
|
197
|
+
if (unitIds.length > 0) {
|
|
198
|
+
parts.push(`install units already exist: ${unitIds.join(", ")}`);
|
|
199
|
+
}
|
|
200
|
+
return `${parts.join("; ")}. Re-run with --replace to overwrite.`;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async function findSkillFiles(root) {
|
|
204
|
+
const files = [];
|
|
205
|
+
const entries = await readdir(root, { withFileTypes: true }).catch(() => []);
|
|
206
|
+
for (const entry of entries) {
|
|
207
|
+
const path = join(root, entry.name);
|
|
208
|
+
if (entry.isDirectory()) {
|
|
209
|
+
files.push(...(await findSkillFiles(path)));
|
|
210
|
+
} else if (entry.isFile() && entry.name === "SKILL.md") {
|
|
211
|
+
files.push(path);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return files;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function parseSkillFrontmatter(text) {
|
|
218
|
+
const match = /^---[ \t]*\r?\n(?<body>[\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/.exec(text);
|
|
219
|
+
if (match?.groups === undefined) {
|
|
220
|
+
throw new Error("SKILL.md is missing YAML frontmatter");
|
|
221
|
+
}
|
|
222
|
+
const raw = requireRecord(YAML.parse(match.groups.body), "SKILL.md frontmatter");
|
|
223
|
+
return {
|
|
224
|
+
name: readString(raw, "name", ""),
|
|
225
|
+
description: readString(raw, "description", "")
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
229
|
function matchesAnyProfilePattern(file, patterns) {
|
|
230
230
|
const activePatterns = patterns.length === 0 ? ["**/SKILL.md"] : patterns;
|
|
231
231
|
return activePatterns.some((pattern) => patternToRegExp(pattern).test(file));
|
|
@@ -234,30 +234,31 @@ function matchesAnyProfilePattern(file, patterns) {
|
|
|
234
234
|
function matchingPathRule(file, profile) {
|
|
235
235
|
return (profile.pathRules ?? []).find((rule) => patternToRegExp(rule.pattern).test(file));
|
|
236
236
|
}
|
|
237
|
-
|
|
238
|
-
function patternToRegExp(pattern) {
|
|
239
|
-
const escaped = pattern
|
|
240
|
-
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
|
|
237
|
+
|
|
238
|
+
function patternToRegExp(pattern) {
|
|
239
|
+
const escaped = pattern
|
|
240
|
+
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
|
|
241
241
|
.split("**").join("\0")
|
|
242
242
|
.split("*").join("[^/]*")
|
|
243
243
|
.split("\0").join(".*");
|
|
244
|
-
return new RegExp(`^${escaped}$`);
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
function skillSlug(relativeFile, frontmatter, profile) {
|
|
248
|
-
if (profile.idStrategy === "frontmatter-name" && frontmatter.name.length > 0) {
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
}
|
|
260
|
-
|
|
244
|
+
return new RegExp(`^${escaped}$`);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function skillSlug(relativeFile, frontmatter, profile) {
|
|
248
|
+
if (profile.idStrategy === "frontmatter-name" && frontmatter.name.length > 0) {
|
|
249
|
+
const segments = frontmatter.name.split(".");
|
|
250
|
+
return normalizeSlug(segments[segments.length - 1]);
|
|
251
|
+
}
|
|
252
|
+
return normalizeSlug(basename(dirname(relativeFile)));
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function skillId(slug, frontmatter, profile) {
|
|
256
|
+
if (profile.idStrategy === "frontmatter-name" && frontmatter.name.includes(".")) {
|
|
257
|
+
return frontmatter.name;
|
|
258
|
+
}
|
|
259
|
+
return profile.namespace.length === 0 ? slug : `${profile.namespace}.${slug}`;
|
|
260
|
+
}
|
|
261
|
+
|
|
261
262
|
function skillTargetPath(slug, profile) {
|
|
262
263
|
return profile.targetPathPrefix.length === 0 ? slug : `${profile.targetPathPrefix}/${slug}`;
|
|
263
264
|
}
|
|
@@ -275,13 +276,13 @@ function categoryFromPath(relativeFile, profile) {
|
|
|
275
276
|
}
|
|
276
277
|
|
|
277
278
|
function normalizeSlug(value) {
|
|
278
|
-
return value
|
|
279
|
-
.trim()
|
|
280
|
-
.toLowerCase()
|
|
281
|
-
.replace(/[^a-z0-9._-]+/g, "-")
|
|
282
|
-
.replace(/^-+|-+$/g, "");
|
|
283
|
-
}
|
|
284
|
-
|
|
279
|
+
return value
|
|
280
|
+
.trim()
|
|
281
|
+
.toLowerCase()
|
|
282
|
+
.replace(/[^a-z0-9._-]+/g, "-")
|
|
283
|
+
.replace(/^-+|-+$/g, "");
|
|
284
|
+
}
|
|
285
|
+
|
|
285
286
|
function safeDefaultInvocation(invocation) {
|
|
286
287
|
return invocation === "global-auto" ? "blocked" : invocation;
|
|
287
288
|
}
|
package/src/uninstall.mjs
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
// allow: SIZE_OK - uninstall lifecycle split is deferred from the 0.2.7 release gate.
|
|
2
2
|
import { access, lstat, readFile, readdir, rm, rmdir, writeFile } from "node:fs/promises";
|
|
3
|
-
import { join } from "node:path";
|
|
3
|
+
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
4
4
|
import { BRIDGE_END, BRIDGE_START, defaultConfig, hookReadme, profileReadme } from "./lifecycle-content.mjs";
|
|
5
5
|
|
|
6
6
|
export async function uninstallProject(options) {
|
|
7
7
|
const root = options.root;
|
|
8
8
|
const dryRun = options.dryRun === true;
|
|
9
|
+
const keepBridge = options.keepBridge === true;
|
|
9
10
|
const removed = [];
|
|
10
11
|
const updated = [];
|
|
11
12
|
const preserved = [];
|
|
@@ -13,7 +14,7 @@ export async function uninstallProject(options) {
|
|
|
13
14
|
|
|
14
15
|
for (const filename of ["AGENTS.md", "CLAUDE.md"]) {
|
|
15
16
|
const path = join(root, filename);
|
|
16
|
-
const result = await removeBridge(path, dryRun);
|
|
17
|
+
const result = keepBridge ? await preserveExisting(path) : await removeBridge(path, dryRun);
|
|
17
18
|
if (result === "removed") {
|
|
18
19
|
plannedRemovedPaths.add(path);
|
|
19
20
|
}
|
|
@@ -47,7 +48,7 @@ export async function uninstallProject(options) {
|
|
|
47
48
|
recordFileResult(".skillboard", result, { removed, updated, preserved });
|
|
48
49
|
} else {
|
|
49
50
|
for (const entry of generatedFiles(root)) {
|
|
50
|
-
const result = await removeGeneratedFile(entry.path, entry.expected, dryRun);
|
|
51
|
+
const result = await removeGeneratedFile(entry.path, entry.expected, dryRun, root);
|
|
51
52
|
if (result === "removed") {
|
|
52
53
|
plannedRemovedPaths.add(entry.path);
|
|
53
54
|
}
|
|
@@ -57,7 +58,7 @@ export async function uninstallProject(options) {
|
|
|
57
58
|
|
|
58
59
|
if (options.removeReports === true && options.removeProjectState !== true) {
|
|
59
60
|
const path = join(root, ".skillboard", "reports");
|
|
60
|
-
const result = await removeGeneratedDir(path, dryRun);
|
|
61
|
+
const result = await removeGeneratedDir(path, dryRun, root);
|
|
61
62
|
if (result === "removed") {
|
|
62
63
|
plannedRemovedPaths.add(path);
|
|
63
64
|
}
|
|
@@ -66,7 +67,7 @@ export async function uninstallProject(options) {
|
|
|
66
67
|
|
|
67
68
|
if (options.removeHooks === true && options.removeProjectState !== true) {
|
|
68
69
|
const path = join(root, ".skillboard", "hooks");
|
|
69
|
-
const result = await removeGeneratedDir(path, dryRun);
|
|
70
|
+
const result = await removeGeneratedDir(path, dryRun, root);
|
|
70
71
|
if (result === "removed") {
|
|
71
72
|
plannedRemovedPaths.add(path);
|
|
72
73
|
}
|
|
@@ -79,7 +80,7 @@ export async function uninstallProject(options) {
|
|
|
79
80
|
skipHooks: options.removeHooks === true,
|
|
80
81
|
skipSkillboard: options.removeProjectState === true
|
|
81
82
|
})) {
|
|
82
|
-
const result = await removeEmptyDir(dir.path, dryRun, plannedRemovedPaths);
|
|
83
|
+
const result = await removeEmptyDir(dir.path, dryRun, plannedRemovedPaths, root);
|
|
83
84
|
if (result === "removed") {
|
|
84
85
|
plannedRemovedPaths.add(dir.path);
|
|
85
86
|
}
|
|
@@ -178,7 +179,10 @@ function withoutBridgeBlock(text) {
|
|
|
178
179
|
return `${before}${after}`;
|
|
179
180
|
}
|
|
180
181
|
|
|
181
|
-
async function removeGeneratedFile(path, expected, dryRun) {
|
|
182
|
+
async function removeGeneratedFile(path, expected, dryRun, root = null) {
|
|
183
|
+
if (root !== null && await hasUnsafeAncestor(path, root)) {
|
|
184
|
+
return "preserved";
|
|
185
|
+
}
|
|
182
186
|
const stats = await pathStats(path);
|
|
183
187
|
if (stats === null) {
|
|
184
188
|
return "absent";
|
|
@@ -210,7 +214,10 @@ async function removeConfigFile(path, dryRun) {
|
|
|
210
214
|
return "removed";
|
|
211
215
|
}
|
|
212
216
|
|
|
213
|
-
async function removeGeneratedDir(path, dryRun) {
|
|
217
|
+
async function removeGeneratedDir(path, dryRun, root = null) {
|
|
218
|
+
if (root !== null && await hasUnsafeAncestor(path, root)) {
|
|
219
|
+
return "preserved";
|
|
220
|
+
}
|
|
214
221
|
const stats = await pathStats(path);
|
|
215
222
|
if (stats === null) {
|
|
216
223
|
return "absent";
|
|
@@ -244,7 +251,10 @@ async function removeProjectStateDir(path, dryRun) {
|
|
|
244
251
|
return "removed";
|
|
245
252
|
}
|
|
246
253
|
|
|
247
|
-
async function removeEmptyDir(path, dryRun, plannedRemovedPaths) {
|
|
254
|
+
async function removeEmptyDir(path, dryRun, plannedRemovedPaths, root = null) {
|
|
255
|
+
if (root !== null && await hasUnsafeAncestor(path, root)) {
|
|
256
|
+
return "preserved";
|
|
257
|
+
}
|
|
248
258
|
const stats = await pathStats(path);
|
|
249
259
|
if (stats === null) {
|
|
250
260
|
return "absent";
|
|
@@ -280,6 +290,43 @@ async function exists(path) {
|
|
|
280
290
|
return access(path).then(() => true, () => false);
|
|
281
291
|
}
|
|
282
292
|
|
|
293
|
+
async function preserveExisting(path) {
|
|
294
|
+
return await exists(path) ? "preserved" : "absent";
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
async function hasUnsafeAncestor(path, root) {
|
|
298
|
+
const resolvedRoot = resolve(root);
|
|
299
|
+
const resolvedPath = resolve(path);
|
|
300
|
+
if (resolvedPath !== resolvedRoot && !isInside(resolvedPath, resolvedRoot)) {
|
|
301
|
+
return true;
|
|
302
|
+
}
|
|
303
|
+
const ancestors = [];
|
|
304
|
+
let current = dirname(resolvedPath);
|
|
305
|
+
while (current !== resolvedRoot) {
|
|
306
|
+
ancestors.push(current);
|
|
307
|
+
const parent = dirname(current);
|
|
308
|
+
if (parent === current) {
|
|
309
|
+
break;
|
|
310
|
+
}
|
|
311
|
+
current = parent;
|
|
312
|
+
}
|
|
313
|
+
for (const ancestor of ancestors.reverse()) {
|
|
314
|
+
const stats = await pathStats(ancestor);
|
|
315
|
+
if (stats === null) {
|
|
316
|
+
return false;
|
|
317
|
+
}
|
|
318
|
+
if (stats.isSymbolicLink() || !stats.isDirectory()) {
|
|
319
|
+
return true;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
return false;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function isInside(path, parent) {
|
|
326
|
+
const relativePath = relative(parent, path);
|
|
327
|
+
return relativePath !== "" && !relativePath.startsWith("..") && !isAbsolute(relativePath);
|
|
328
|
+
}
|
|
329
|
+
|
|
283
330
|
async function pathStats(path) {
|
|
284
331
|
try {
|
|
285
332
|
return await lstat(path);
|