dsh-agent-plugins-market 0.6.2 → 0.7.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/README.md +117 -111
- package/README.zh.md +112 -98
- package/client/client.js +28533 -1511
- package/client/client.js.map +1 -1
- package/lib/application/catalog-context.js +188 -0
- package/lib/application/catalog.js +229 -1109
- package/lib/application/details.js +19 -8
- package/lib/application/install-store.js +95 -0
- package/lib/application/lsp-service.js +97 -0
- package/lib/application/mcp-service.js +263 -0
- package/lib/application/panel-resources.js +43 -14
- package/lib/application/ports.js +21 -0
- package/lib/application/project-agent-roles.js +8 -2
- package/lib/application/snapshot-cache.js +160 -0
- package/lib/application/source-store.js +521 -0
- package/lib/catalog/archive.js +35 -30
- package/lib/catalog/component-files.js +14 -6
- package/lib/catalog/fs-probes.js +43 -0
- package/lib/catalog/manifests.js +53 -92
- package/lib/catalog/native-project.js +2 -2
- package/lib/catalog/paths.js +74 -55
- package/lib/catalog/plugin-variables.js +38 -0
- package/lib/catalog/project-config.js +2 -1
- package/lib/catalog/project-hooks.js +23 -2
- package/lib/catalog/scan-resolvers.js +29 -45
- package/lib/catalog/skills-parse.js +61 -10
- package/lib/catalog/source-catalog.js +10 -4
- package/lib/catalog/suite-components.js +7 -7
- package/lib/catalog/suite-scanner.js +3 -1
- package/lib/catalog/surfaces.js +5 -26
- package/lib/catalog/validate.js +7 -11
- package/lib/contracts/market.js +2 -0
- package/lib/contracts/settings.js +63 -0
- package/lib/index.js +115 -217
- package/lib/model/layouts.js +10 -1
- package/lib/model/types.js +26 -3
- package/lib/routes.js +57 -15
- package/lib/runtime/agent-role-names.js +70 -0
- package/lib/runtime/agent-role-router.js +142 -106
- package/lib/runtime/commands-mounts.js +55 -11
- package/lib/runtime/deadline.js +54 -0
- package/lib/runtime/dynamic-context.js +140 -0
- package/lib/runtime/feedback-tool.js +101 -46
- package/lib/runtime/hooks-mounts.js +9 -11
- package/lib/runtime/host-locale.js +49 -24
- package/lib/runtime/lsp-direct-config.js +19 -19
- package/lib/runtime/lsp-mounts.js +182 -70
- package/lib/runtime/lsp-server-state.js +4 -4
- package/lib/runtime/lsp-status.js +1 -1
- package/lib/runtime/mcp-auth-record.js +42 -0
- package/lib/runtime/mcp-backend.js +14 -14
- package/lib/runtime/mcp-client/config.js +8 -1
- package/lib/runtime/mcp-client/connection.js +38 -43
- package/lib/runtime/mcp-client/host-contract.js +10 -0
- package/lib/runtime/mcp-client/host-seams.js +8 -88
- package/lib/runtime/mcp-client/oauth.js +5 -7
- package/lib/runtime/mcp-client/plugin-identity.js +28 -0
- package/lib/runtime/mcp-client/projection.js +202 -0
- package/lib/runtime/mcp-client/tools.js +4 -189
- package/lib/runtime/mcp-config.js +36 -14
- package/lib/runtime/mcp-direct-config.js +33 -16
- package/lib/runtime/mcp-mounts.js +35 -75
- package/lib/runtime/mcp-overrides.js +5 -5
- package/lib/runtime/mcp-redaction.js +3 -2
- package/lib/runtime/mcp-status.js +7 -8
- package/lib/runtime/mount-lifecycle.js +73 -0
- package/lib/runtime/profile-seam.js +321 -0
- package/lib/runtime/project-runtime.js +34 -17
- package/lib/runtime/reconcile-scheduler.js +97 -0
- package/lib/runtime/reconciler.js +6 -1
- package/lib/runtime/regions.js +0 -19
- package/lib/runtime/server-config.js +6 -4
- package/lib/runtime/settings-namespace.js +144 -0
- package/lib/runtime/skills-provider.js +38 -14
- package/lib/runtime/source-auto-update.js +66 -0
- package/lib/{model/state.js → runtime/state-store.js} +9 -11
- package/lib/runtime/storage-migration.js +35 -18
- package/lib/runtime/subagent-catalog.js +5 -4
- package/lib/runtime/timer-seat.js +80 -0
- package/lib/runtime/tool-registry-observer.js +20 -20
- package/lib/runtime/user-commands.js +7 -3
- package/lib/runtime/user-panels.js +125 -46
- package/lib/runtime/user-store.js +124 -69
- package/lib/types/application/catalog-context.d.ts +124 -0
- package/lib/types/application/catalog.d.ts +108 -304
- package/lib/types/application/install-store.d.ts +21 -0
- package/lib/types/application/lsp-service.d.ts +36 -0
- package/lib/types/application/mcp-service.d.ts +77 -0
- package/lib/types/application/panel-resources.d.ts +8 -0
- package/lib/types/application/ports.d.ts +72 -0
- package/lib/types/application/queries.d.ts +15 -26
- package/lib/types/application/snapshot-cache.d.ts +51 -0
- package/lib/types/application/source-store.d.ts +100 -0
- package/lib/types/catalog/archive.d.ts +3 -11
- package/lib/types/catalog/component-files.d.ts +4 -0
- package/lib/types/catalog/fs-probes.d.ts +10 -0
- package/lib/types/catalog/manifests.d.ts +17 -16
- package/lib/types/catalog/native-project.d.ts +2 -2
- package/lib/types/catalog/paths.d.ts +53 -15
- package/lib/types/catalog/plugin-variables.d.ts +16 -0
- package/lib/types/catalog/scan-pipeline.d.ts +3 -3
- package/lib/types/catalog/scan-resolvers.d.ts +3 -9
- package/lib/types/catalog/skills-parse.d.ts +13 -7
- package/lib/types/catalog/source-catalog.d.ts +10 -5
- package/lib/types/catalog/suite-scanner.d.ts +8 -6
- package/lib/types/catalog/validate.d.ts +0 -2
- package/lib/types/contracts/lsp-status.d.ts +35 -0
- package/lib/types/contracts/market.d.ts +13 -2
- package/lib/types/contracts/mcp-status.d.ts +4 -0
- package/lib/types/contracts/settings.d.ts +59 -0
- package/lib/types/model/layouts.d.ts +10 -0
- package/lib/types/model/types.d.ts +29 -5
- package/lib/types/runtime/agent-role-names.d.ts +6 -0
- package/lib/types/runtime/agent-role-router.d.ts +59 -52
- package/lib/types/runtime/commands-mounts.d.ts +15 -7
- package/lib/types/runtime/deadline.d.ts +17 -0
- package/lib/types/runtime/dynamic-context.d.ts +81 -0
- package/lib/types/runtime/feedback-tool.d.ts +33 -11
- package/lib/types/runtime/hooks-mounts.d.ts +1 -1
- package/lib/types/runtime/host-locale.d.ts +20 -2
- package/lib/types/runtime/lsp-direct-config.d.ts +3 -3
- package/lib/types/runtime/lsp-mounts.d.ts +69 -19
- package/lib/types/runtime/lsp-status.d.ts +2 -1
- package/lib/types/runtime/mcp-auth-record.d.ts +29 -0
- package/lib/types/runtime/mcp-backend.d.ts +10 -14
- package/lib/types/runtime/mcp-client/config.d.ts +8 -2
- package/lib/types/runtime/mcp-client/host-contract.d.ts +78 -0
- package/lib/types/runtime/mcp-client/host-seams.d.ts +20 -64
- package/lib/types/runtime/mcp-client/oauth.d.ts +1 -1
- package/lib/types/runtime/mcp-client/plugin-identity.d.ts +16 -0
- package/lib/types/runtime/mcp-client/projection.d.ts +45 -0
- package/lib/types/runtime/mcp-client/tools.d.ts +5 -72
- package/lib/types/runtime/mcp-config.d.ts +25 -4
- package/lib/types/runtime/mcp-direct-config.d.ts +18 -4
- package/lib/types/runtime/mcp-mounts.d.ts +10 -15
- package/lib/types/runtime/mcp-overrides.d.ts +1 -1
- package/lib/types/runtime/mcp-status.d.ts +1 -1
- package/lib/types/runtime/mount-lifecycle.d.ts +53 -0
- package/lib/types/runtime/profile-seam.d.ts +64 -0
- package/lib/types/runtime/project-runtime.d.ts +10 -3
- package/lib/types/runtime/reconcile-scheduler.d.ts +45 -0
- package/lib/types/runtime/reconciler.d.ts +2 -0
- package/lib/types/runtime/regions.d.ts +1 -4
- package/lib/types/runtime/settings-namespace.d.ts +76 -0
- package/lib/types/runtime/skills-provider.d.ts +11 -2
- package/lib/types/runtime/source-auto-update.d.ts +32 -0
- package/lib/types/{model/state.d.ts → runtime/state-store.d.ts} +2 -2
- package/lib/types/runtime/storage-migration.d.ts +5 -3
- package/lib/types/runtime/subagent-catalog.d.ts +2 -0
- package/lib/types/runtime/timer-seat.d.ts +51 -0
- package/lib/types/runtime/tool-registry-observer.d.ts +12 -6
- package/lib/types/runtime/user-panels.d.ts +69 -19
- package/lib/types/runtime/user-store.d.ts +67 -20
- package/package.json +68 -24
- package/schemas/README.md +1 -1
|
@@ -0,0 +1,521 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Source acquisition and source CRUD.
|
|
3
|
+
*
|
|
4
|
+
* A source is a configured Git repository, archive URL, or local directory the
|
|
5
|
+
* catalog reads suites from. This module owns everything that touches the
|
|
6
|
+
* `.sources/` storage: adoption of pre-existing checkouts, id selection,
|
|
7
|
+
* clone/archive/download, refresh, removal, and the progress state the market
|
|
8
|
+
* page polls while a long acquisition runs.
|
|
9
|
+
*/
|
|
10
|
+
import { mkdir, readdir, rename } from 'node:fs/promises';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
import { archiveInstall } from '../catalog/archive.js';
|
|
13
|
+
import { gitClone, gitCurrentBranch, gitHead, gitRemoteUrl, gitRemove, gitSetRemoteUrl, gitSync } from '../catalog/git.js';
|
|
14
|
+
import { repoName } from '../catalog/manifests.js';
|
|
15
|
+
import { deriveSourceIdCandidates, expandHome, sanitizeId, sourceCheckoutDir, sourcesDir } from '../catalog/paths.js';
|
|
16
|
+
import { isDirectory, pathExists } from '../catalog/fs-probes.js';
|
|
17
|
+
import { canonicalGitUrl } from '../catalog/scan-resolvers.js';
|
|
18
|
+
import { readLocalePreference } from '../runtime/host-locale.js';
|
|
19
|
+
import { githubCloneUrl, resolveRegion } from '../runtime/regions.js';
|
|
20
|
+
import { resolveSourceKind } from '../model/types.js';
|
|
21
|
+
export class SourceStore {
|
|
22
|
+
context;
|
|
23
|
+
ports;
|
|
24
|
+
headCache = new Map();
|
|
25
|
+
/** Progress snapshot of the source mutation currently in flight. */
|
|
26
|
+
currentSourceState;
|
|
27
|
+
constructor(context, ports) {
|
|
28
|
+
this.context = context;
|
|
29
|
+
this.ports = ports;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Add a source and acquire it immediately (clone, download, or in-place).
|
|
33
|
+
*
|
|
34
|
+
* Git sources with an already-present checkout whose `origin` matches the
|
|
35
|
+
* input URL are adopted in place — the manual-clone repair path — so no
|
|
36
|
+
* second clone is made and no `-2` suffixed id is invented. Adopted
|
|
37
|
+
* checkouts are never deleted on source removal.
|
|
38
|
+
*/
|
|
39
|
+
async add(input) {
|
|
40
|
+
return this.context.enqueue(async () => {
|
|
41
|
+
const kind = input.local === true ? 'local' : resolveSourceKind({ url: input.url, kind: input.kind });
|
|
42
|
+
if (kind === 'git') {
|
|
43
|
+
// Adoption first: a pre-existing checkout with a matching origin URL
|
|
44
|
+
// is registered as-is, skipping the clone entirely.
|
|
45
|
+
const adoptable = await this.findAdoptableCheckout(input.url, deriveSourceIdCandidates(input.url));
|
|
46
|
+
if (adoptable !== undefined) {
|
|
47
|
+
const source = {
|
|
48
|
+
id: adoptable,
|
|
49
|
+
url: input.url,
|
|
50
|
+
...(input.branch === undefined ? {} : { branch: input.branch }),
|
|
51
|
+
kind: 'git',
|
|
52
|
+
adopted: true
|
|
53
|
+
};
|
|
54
|
+
const head = await tryHead(sourceCheckoutDir(this.context.userRoot, adoptable));
|
|
55
|
+
if (head !== undefined)
|
|
56
|
+
this.headCache.set(source.id, head);
|
|
57
|
+
await this.context.commit({ ...this.context.state, sources: [...this.context.state.sources, source] });
|
|
58
|
+
await this.context.notifyChanged();
|
|
59
|
+
return source;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
const baseId = await this.pickSourceId(deriveSourceIdCandidates(input.url), kind === 'local');
|
|
63
|
+
const source = {
|
|
64
|
+
id: baseId,
|
|
65
|
+
url: input.url,
|
|
66
|
+
...(input.branch === undefined ? {} : { branch: input.branch }),
|
|
67
|
+
...(kind === 'local' ? { local: true } : { kind }),
|
|
68
|
+
...(input.kind === 'archive' && input.sha256 !== undefined && input.sha256 !== '' ? { sha256: input.sha256 } : {})
|
|
69
|
+
};
|
|
70
|
+
let checkout = this.checkoutPath(source);
|
|
71
|
+
if (kind === 'local') {
|
|
72
|
+
if (!(await isDirectory(checkout)))
|
|
73
|
+
throw new Error(`local source directory ${checkout} is missing`);
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
this.beginSourceState(baseId, kind === 'archive' ? 'downloading' : 'cloning', false);
|
|
77
|
+
try {
|
|
78
|
+
const lock = await this.acquire(source);
|
|
79
|
+
if (lock !== undefined)
|
|
80
|
+
this.headCache.set(source.id, lock);
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
this.endSourceState();
|
|
84
|
+
throw error;
|
|
85
|
+
}
|
|
86
|
+
this.updateSourceStep('reading');
|
|
87
|
+
}
|
|
88
|
+
// The repo's own manifest names the source; move the checkout along so
|
|
89
|
+
// the registered id and its `.sources/` directory stay coherent. URL
|
|
90
|
+
// derived candidates stay as readable fallbacks before numeric suffixes.
|
|
91
|
+
const named = [...new Set([sanitizeId(await repoName(checkout)), ...deriveSourceIdCandidates(input.url)])];
|
|
92
|
+
const finalId = await this.pickSourceId(named, kind === 'local', kind === 'local' ? undefined : checkout);
|
|
93
|
+
if (kind !== 'local' && finalId !== baseId) {
|
|
94
|
+
const targetDir = sourceCheckoutDir(this.context.userRoot, finalId);
|
|
95
|
+
await rename(checkout, targetDir);
|
|
96
|
+
checkout = targetDir;
|
|
97
|
+
}
|
|
98
|
+
source.id = finalId;
|
|
99
|
+
if (resolveSourceKind(source) === 'git') {
|
|
100
|
+
const head = await tryHead(checkout);
|
|
101
|
+
if (head !== undefined)
|
|
102
|
+
this.headCache.set(source.id, head);
|
|
103
|
+
}
|
|
104
|
+
this.endSourceState();
|
|
105
|
+
await this.context.commit({ ...this.context.state, sources: [...this.context.state.sources, source] });
|
|
106
|
+
await this.context.notifyChanged();
|
|
107
|
+
return source;
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
/** Register one unmanaged `.sources/` checkout as a source without touching its files. */
|
|
111
|
+
async adopt(id) {
|
|
112
|
+
return this.context.enqueue(async () => {
|
|
113
|
+
if (this.context.state.sources.some(source => source.id === id))
|
|
114
|
+
throw new Error(`source "${id}" is already registered`);
|
|
115
|
+
// The id becomes one path segment under `.sources/`: only flat safe
|
|
116
|
+
// names qualify. Anything else (separators, `..`, leading dots) could
|
|
117
|
+
// traverse out of the checkouts root and register an outside directory.
|
|
118
|
+
if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(id) || id.includes('..')) {
|
|
119
|
+
throw new Error(`invalid checkout id "${id}" — use letters, digits, dots, dashes, or underscores`);
|
|
120
|
+
}
|
|
121
|
+
const dir = sourceCheckoutDir(this.context.userRoot, id);
|
|
122
|
+
if (!(await isDirectory(dir)))
|
|
123
|
+
throw new Error(`no checkout directory at ${dir}`);
|
|
124
|
+
let source;
|
|
125
|
+
try {
|
|
126
|
+
const origin = await gitRemoteUrl(dir);
|
|
127
|
+
source = { id, url: origin, kind: 'git', adopted: true };
|
|
128
|
+
const head = await tryHead(dir);
|
|
129
|
+
if (head !== undefined)
|
|
130
|
+
this.headCache.set(id, head);
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
// Not a git checkout: read it in place, like a local source.
|
|
134
|
+
source = { id, url: dir, local: true, adopted: true };
|
|
135
|
+
}
|
|
136
|
+
await this.context.commit({ ...this.context.state, sources: [...this.context.state.sources, source] });
|
|
137
|
+
await this.context.notifyChanged();
|
|
138
|
+
return source;
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
/** Unmanaged `.sources/` checkouts: present on disk, absent from state. */
|
|
142
|
+
async unmanaged() {
|
|
143
|
+
const checkoutRoot = sourcesDir(this.context.userRoot);
|
|
144
|
+
let entries;
|
|
145
|
+
try {
|
|
146
|
+
entries = await readdir(checkoutRoot, { withFileTypes: true });
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
return [];
|
|
150
|
+
}
|
|
151
|
+
const registered = new Set(this.context.state.sources.map(source => source.id));
|
|
152
|
+
const unmanaged = [];
|
|
153
|
+
for (const entry of entries) {
|
|
154
|
+
if (!entry.isDirectory() || entry.name.startsWith('.') || registered.has(entry.name))
|
|
155
|
+
continue;
|
|
156
|
+
const dir = join(checkoutRoot, entry.name);
|
|
157
|
+
try {
|
|
158
|
+
unmanaged.push({ id: entry.name, url: await gitRemoteUrl(dir) });
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
unmanaged.push({ id: entry.name });
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return unmanaged;
|
|
165
|
+
}
|
|
166
|
+
/** Update one source's URL, branch, kind, or archive digest. */
|
|
167
|
+
async update(sourceId, patch) {
|
|
168
|
+
return this.context.enqueue(async () => {
|
|
169
|
+
const index = this.context.state.sources.findIndex(source => source.id === sourceId);
|
|
170
|
+
const current = this.context.state.sources[index];
|
|
171
|
+
if (current === undefined)
|
|
172
|
+
throw new Error(`unknown source "${sourceId}"`);
|
|
173
|
+
const nextLocal = patch.local !== undefined ? patch.local : current.local === true;
|
|
174
|
+
// A local source carries the legacy flag only; `kind` stays unwritten
|
|
175
|
+
// so the persisted shape matches the pre-kind records.
|
|
176
|
+
const nextKind = nextLocal ? undefined : (patch.kind ?? current.kind);
|
|
177
|
+
const next = {
|
|
178
|
+
id: sourceId,
|
|
179
|
+
url: patch.url ?? current.url,
|
|
180
|
+
...(patch.branch !== undefined ? { branch: patch.branch } : current.branch === undefined ? {} : { branch: current.branch }),
|
|
181
|
+
...(nextLocal ? { local: true } : {}),
|
|
182
|
+
...(nextKind === undefined ? {} : { kind: nextKind }),
|
|
183
|
+
...(patch.sha256 !== undefined ? (patch.sha256 === '' ? {} : { sha256: patch.sha256 }) : current.sha256 === undefined ? {} : { sha256: current.sha256 }),
|
|
184
|
+
...(current.adopted === true ? { adopted: true } : {})
|
|
185
|
+
};
|
|
186
|
+
const urlChanged = patch.url !== undefined && patch.url !== current.url;
|
|
187
|
+
// A URL change invalidates the old checkout — except for adopted (user
|
|
188
|
+
// cloned) and local sources, whose directories we never own.
|
|
189
|
+
if (urlChanged && current.local !== true && current.adopted !== true) {
|
|
190
|
+
this.headCache.delete(sourceId);
|
|
191
|
+
await gitRemove(sourceCheckoutDir(this.context.userRoot, sourceId));
|
|
192
|
+
}
|
|
193
|
+
await this.context.commit({ ...this.context.state, sources: this.context.state.sources.map((source, i) => (i === index ? next : source)) });
|
|
194
|
+
await this.context.notifyChanged();
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Remove a source and its install entries. `deleteCheckout` (the UI's
|
|
199
|
+
* "also delete the managed market directory" option) physically removes the
|
|
200
|
+
* `.sources/<id>` checkout — including an adopted one, since a directory
|
|
201
|
+
* under `.sources` is manager-owned storage. A `local` source whose URL
|
|
202
|
+
* points outside `.sources` is registration-only: its directory is never
|
|
203
|
+
* touched. Deleting the checkout is what keeps a removed source from
|
|
204
|
+
* reappearing as an unmanaged entry.
|
|
205
|
+
*/
|
|
206
|
+
async remove(sourceId, deleteCheckout = false) {
|
|
207
|
+
return this.context.enqueue(async () => {
|
|
208
|
+
const source = this.context.state.sources.find(entry => entry.id === sourceId);
|
|
209
|
+
await this.context.commit({
|
|
210
|
+
...this.context.state,
|
|
211
|
+
sources: this.context.state.sources.filter(entry => entry.id !== sourceId),
|
|
212
|
+
installed: Object.fromEntries(Object.entries(this.context.state.installed).filter(([key]) => !key.startsWith(`${sourceId}/`)))
|
|
213
|
+
});
|
|
214
|
+
this.headCache.delete(sourceId);
|
|
215
|
+
// A checkout under `.sources` is manager-owned even when it was first
|
|
216
|
+
// discovered/adopted. Only an explicit local path points outside the
|
|
217
|
+
// manager's storage and must remain untouched.
|
|
218
|
+
const externalLocal = source?.local === true && source.url !== sourceCheckoutDir(this.context.userRoot, sourceId);
|
|
219
|
+
if (!externalLocal && deleteCheckout)
|
|
220
|
+
await gitRemove(sourceCheckoutDir(this.context.userRoot, sourceId));
|
|
221
|
+
await this.context.notifyChanged();
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
/** Refresh one source checkout, or every source when sourceId is omitted. */
|
|
225
|
+
async refresh(sourceId) {
|
|
226
|
+
return this.context.enqueue(async () => {
|
|
227
|
+
const targets = sourceId === undefined ? this.context.state.sources : this.context.state.sources.filter(source => source.id === sourceId);
|
|
228
|
+
for (const source of targets) {
|
|
229
|
+
const kind = resolveSourceKind(source);
|
|
230
|
+
if (kind === 'local') {
|
|
231
|
+
if (!(await isDirectory(expandHome(source.url))))
|
|
232
|
+
throw new Error(`local source directory ${expandHome(source.url)} is missing`);
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
const checkout = sourceCheckoutDir(this.context.userRoot, source.id);
|
|
236
|
+
this.headCache.delete(source.id);
|
|
237
|
+
if (kind === 'archive') {
|
|
238
|
+
// Re-download and swap; the fresh digest becomes the lock value.
|
|
239
|
+
const lock = await this.acquire(source);
|
|
240
|
+
if (lock !== undefined)
|
|
241
|
+
this.headCache.set(source.id, lock);
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
// Adopted checkouts are user-owned working trees: no `reset --hard`
|
|
245
|
+
// (it would destroy uncommitted work), and no re-acquire either —
|
|
246
|
+
// a broken HEAD means the user repairs the checkout, not that the
|
|
247
|
+
// manager may clone or extract over their directory.
|
|
248
|
+
if (source.adopted === true) {
|
|
249
|
+
try {
|
|
250
|
+
this.headCache.set(source.id, await gitHead(checkout));
|
|
251
|
+
}
|
|
252
|
+
catch {
|
|
253
|
+
// A failed HEAD read means the next overview re-probes.
|
|
254
|
+
}
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
try {
|
|
258
|
+
await gitHead(checkout);
|
|
259
|
+
}
|
|
260
|
+
catch {
|
|
261
|
+
const lock = await this.acquire(source);
|
|
262
|
+
if (lock !== undefined) {
|
|
263
|
+
this.headCache.set(source.id, lock);
|
|
264
|
+
}
|
|
265
|
+
else {
|
|
266
|
+
try {
|
|
267
|
+
this.headCache.set(source.id, await gitHead(checkout));
|
|
268
|
+
}
|
|
269
|
+
catch {
|
|
270
|
+
// A failed HEAD read means the next overview re-probes.
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
// Shallow-friendly sync: fetch depth 1 into FETCH_HEAD, hard reset.
|
|
276
|
+
const branch = source.branch ?? (await gitCurrentBranch(checkout).catch(() => undefined));
|
|
277
|
+
// Keep origin on the region's route: a clone made before a region
|
|
278
|
+
// switch (or an explicit flip) re-points origin so the fetch follows.
|
|
279
|
+
// Only origins this plugin itself wrote (the direct or proxied
|
|
280
|
+
// source.url) are re-pointed — a foreign remote is left untouched.
|
|
281
|
+
if (source.kind === 'git' || (source.kind === undefined && source.local !== true)) {
|
|
282
|
+
try {
|
|
283
|
+
const region = resolveRegion(await this.ports.downloadRegion(), await readLocalePreference());
|
|
284
|
+
const routed = githubCloneUrl(region, source.url);
|
|
285
|
+
const origin = await gitRemoteUrl(checkout);
|
|
286
|
+
if (origin !== routed && (origin === source.url || origin === githubCloneUrl('china', source.url))) {
|
|
287
|
+
await gitSetRemoteUrl(checkout, routed);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
catch {
|
|
291
|
+
// Origin reconciliation is best effort; plain sync still runs.
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
await gitSync(checkout, branch, this.context.git);
|
|
295
|
+
try {
|
|
296
|
+
this.headCache.set(source.id, await gitHead(checkout));
|
|
297
|
+
}
|
|
298
|
+
catch {
|
|
299
|
+
// A failed HEAD read means the next overview re-probes.
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
await this.context.notifyChanged();
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
/** Progress snapshot for the progress route. */
|
|
306
|
+
progress() {
|
|
307
|
+
const state = this.currentSourceState;
|
|
308
|
+
return state === undefined ? { active: false, sourceId: '', step: '' } : { active: true, sourceId: state.sourceId, step: state.step };
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* Checkout status of one source for the market overview. A mutation owning
|
|
312
|
+
* the source right now short-circuits the probe: a Git read raced against
|
|
313
|
+
* that mutation's checkout would report a half-swapped directory.
|
|
314
|
+
*/
|
|
315
|
+
async describeCheckout(source) {
|
|
316
|
+
const inFlight = this.currentSourceState?.sourceId === source.id ? this.currentSourceState : undefined;
|
|
317
|
+
const checkout = this.checkoutPath(source);
|
|
318
|
+
const kind = resolveSourceKind(source);
|
|
319
|
+
let cloned = false;
|
|
320
|
+
let lockCommit;
|
|
321
|
+
let error;
|
|
322
|
+
if (kind === 'local') {
|
|
323
|
+
cloned = await isDirectory(checkout);
|
|
324
|
+
if (!cloned)
|
|
325
|
+
error = `local source directory ${checkout} is missing`;
|
|
326
|
+
}
|
|
327
|
+
else if (kind === 'archive') {
|
|
328
|
+
cloned = await isDirectory(checkout);
|
|
329
|
+
lockCommit = this.headCache.get(source.id);
|
|
330
|
+
}
|
|
331
|
+
else if (inFlight !== undefined) {
|
|
332
|
+
// A mutation owns this source right now: do not race git against its checkout.
|
|
333
|
+
cloned = inFlight.cloned || (await isDirectory(checkout));
|
|
334
|
+
lockCommit = inFlight.head;
|
|
335
|
+
}
|
|
336
|
+
else {
|
|
337
|
+
const cachedHead = this.headCache.get(source.id);
|
|
338
|
+
if (cachedHead !== undefined && (await isDirectory(checkout))) {
|
|
339
|
+
cloned = true;
|
|
340
|
+
lockCommit = cachedHead;
|
|
341
|
+
}
|
|
342
|
+
else {
|
|
343
|
+
try {
|
|
344
|
+
lockCommit = await gitHead(checkout);
|
|
345
|
+
cloned = true;
|
|
346
|
+
this.headCache.set(source.id, lockCommit);
|
|
347
|
+
}
|
|
348
|
+
catch {
|
|
349
|
+
// Not cloned yet or a broken checkout; refresh reports the actionable error.
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
return { cloned, ...(lockCommit === undefined ? {} : { lockCommit }), ...(error === undefined ? {} : { error }) };
|
|
354
|
+
}
|
|
355
|
+
/** Per-source rows of the market overview for one user-dimension snapshot. */
|
|
356
|
+
async overviewRows(snapshot) {
|
|
357
|
+
const rows = [];
|
|
358
|
+
for (const source of snapshot.sources) {
|
|
359
|
+
const status = await this.describeCheckout(source);
|
|
360
|
+
const sourceSuites = snapshot.suites.filter(suite => suite.sourceId === source.id);
|
|
361
|
+
const scanNotes = snapshot.scanNotes?.[source.id];
|
|
362
|
+
rows.push({
|
|
363
|
+
id: source.id,
|
|
364
|
+
url: source.url,
|
|
365
|
+
...(source.branch === undefined ? {} : { branch: source.branch }),
|
|
366
|
+
...(source.local === true ? { local: true } : {}),
|
|
367
|
+
kind: resolveSourceKind(source),
|
|
368
|
+
...(source.adopted === true ? { adopted: true } : {}),
|
|
369
|
+
cloned: status.cloned,
|
|
370
|
+
...(status.lockCommit === undefined ? {} : { lockCommit: status.lockCommit }),
|
|
371
|
+
...(status.error === undefined ? {} : { error: status.error }),
|
|
372
|
+
...(scanNotes === undefined ? {} : { scanNotes }),
|
|
373
|
+
suiteIds: sourceSuites.map(suite => suite.id)
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
return rows;
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
* Acquire one source's content by kind: clone (git), download+extract
|
|
380
|
+
* (archive), or an in-place existence check (local). Returns a lock value
|
|
381
|
+
* when the acquisition yields one (archive SHA-256, tarball fallback
|
|
382
|
+
* digest); git HEAD is read separately by the callers.
|
|
383
|
+
*/
|
|
384
|
+
async acquire(source) {
|
|
385
|
+
const kind = resolveSourceKind(source);
|
|
386
|
+
if (kind === 'local') {
|
|
387
|
+
if (!(await isDirectory(expandHome(source.url))))
|
|
388
|
+
throw new Error(`local source directory ${expandHome(source.url)} is missing`);
|
|
389
|
+
return undefined;
|
|
390
|
+
}
|
|
391
|
+
const checkout = sourceCheckoutDir(this.context.userRoot, source.id);
|
|
392
|
+
await mkdir(sourcesDir(this.context.userRoot), { recursive: true });
|
|
393
|
+
if (kind === 'archive') {
|
|
394
|
+
const { sha256 } = await archiveInstall(source.url, checkout, {
|
|
395
|
+
...(source.sha256 === undefined ? {} : { sha256: source.sha256 }),
|
|
396
|
+
...(this.context.git.timeoutMs === undefined ? {} : { timeoutMs: this.context.git.timeoutMs }),
|
|
397
|
+
...(this.context.git.allowHttpArchives === true ? { allowHttp: true } : {})
|
|
398
|
+
});
|
|
399
|
+
return sha256;
|
|
400
|
+
}
|
|
401
|
+
try {
|
|
402
|
+
// The region routes github.com clones through the China mirror prefix;
|
|
403
|
+
// the proxied URL becomes `origin`, so refreshes follow the same route.
|
|
404
|
+
const region = resolveRegion(await this.ports.downloadRegion(), await readLocalePreference());
|
|
405
|
+
await gitClone(githubCloneUrl(region, source.url), source.branch, checkout, this.context.git);
|
|
406
|
+
return undefined;
|
|
407
|
+
}
|
|
408
|
+
catch (error) {
|
|
409
|
+
if (this.context.git.fallbackTarball !== true)
|
|
410
|
+
throw error;
|
|
411
|
+
const tarballUrl = codeloadTarballUrl(source.url, source.branch);
|
|
412
|
+
if (tarballUrl === undefined)
|
|
413
|
+
throw error;
|
|
414
|
+
// The codeload URL ends in a branch path, not `.tar.gz`, so the format
|
|
415
|
+
// must be stated explicitly — extension detection cannot see it.
|
|
416
|
+
const { sha256 } = await archiveInstall(tarballUrl, checkout, {
|
|
417
|
+
...(this.context.git.timeoutMs === undefined ? {} : { timeoutMs: this.context.git.timeoutMs }),
|
|
418
|
+
format: 'targz'
|
|
419
|
+
});
|
|
420
|
+
return sha256;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
/** The filesystem location of one source. */
|
|
424
|
+
checkoutPath(source) {
|
|
425
|
+
return source.local === true ? expandHome(source.url) : sourceCheckoutDir(this.context.userRoot, source.id);
|
|
426
|
+
}
|
|
427
|
+
/** The last HEAD this store observed for one source, when it cached one. */
|
|
428
|
+
knownHead(sourceId) {
|
|
429
|
+
return this.headCache.get(sourceId);
|
|
430
|
+
}
|
|
431
|
+
/**
|
|
432
|
+
* First candidate id whose `.sources/` checkout already exists and whose
|
|
433
|
+
* `origin` remote matches the input URL (canonical-form equality) — the
|
|
434
|
+
* signature of a manual clone of the very same repository. Registered ids
|
|
435
|
+
* and checkouts of differently-origined directories never adopt.
|
|
436
|
+
*/
|
|
437
|
+
async findAdoptableCheckout(url, candidates) {
|
|
438
|
+
const registered = new Set(this.context.state.sources.map(source => source.id));
|
|
439
|
+
for (const candidate of candidates) {
|
|
440
|
+
if (registered.has(candidate))
|
|
441
|
+
continue;
|
|
442
|
+
const dir = sourceCheckoutDir(this.context.userRoot, candidate);
|
|
443
|
+
if (!(await isDirectory(dir)))
|
|
444
|
+
continue;
|
|
445
|
+
let origin;
|
|
446
|
+
try {
|
|
447
|
+
origin = await gitRemoteUrl(dir);
|
|
448
|
+
}
|
|
449
|
+
catch {
|
|
450
|
+
continue;
|
|
451
|
+
}
|
|
452
|
+
if (origin !== '' && canonicalGitUrl(origin) === canonicalGitUrl(url))
|
|
453
|
+
return candidate;
|
|
454
|
+
}
|
|
455
|
+
return undefined;
|
|
456
|
+
}
|
|
457
|
+
/**
|
|
458
|
+
* First candidate id not claimed by a registered source; for git candidates
|
|
459
|
+
* the checkout directory must also be free, so a stale or foreign directory
|
|
460
|
+
* under `.sources/` is never cloned over (it would fail `git clone` with a
|
|
461
|
+
* confusing "destination exists" error). `ownedCheckout` marks the directory
|
|
462
|
+
* the in-flight mutation already controls, keeping a rename onto it legal.
|
|
463
|
+
* Local sources skip the disk check: they read their own path in place and
|
|
464
|
+
* never occupy `.sources/`.
|
|
465
|
+
*/
|
|
466
|
+
async pickSourceId(candidates, skipDisk, ownedCheckout) {
|
|
467
|
+
const free = async (id) => {
|
|
468
|
+
if (this.context.state.sources.some(source => source.id === id))
|
|
469
|
+
return false;
|
|
470
|
+
if (skipDisk)
|
|
471
|
+
return true;
|
|
472
|
+
const dir = sourceCheckoutDir(this.context.userRoot, id);
|
|
473
|
+
return dir === ownedCheckout || !(await pathExists(dir));
|
|
474
|
+
};
|
|
475
|
+
for (const candidate of candidates) {
|
|
476
|
+
if (await free(candidate))
|
|
477
|
+
return candidate;
|
|
478
|
+
}
|
|
479
|
+
for (let suffix = 2;; suffix++) {
|
|
480
|
+
const candidate = `${candidates[0]}-${suffix}`;
|
|
481
|
+
if (await free(candidate))
|
|
482
|
+
return candidate;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
/** Begin reporting progress for a source mutation. */
|
|
486
|
+
beginSourceState(sourceId, step, cloned) {
|
|
487
|
+
this.currentSourceState = { sourceId, step, cloned };
|
|
488
|
+
}
|
|
489
|
+
/** Advance the in-flight source mutation step. */
|
|
490
|
+
updateSourceStep(step) {
|
|
491
|
+
if (this.currentSourceState !== undefined)
|
|
492
|
+
this.currentSourceState = { ...this.currentSourceState, step };
|
|
493
|
+
}
|
|
494
|
+
/** Stop reporting source mutation progress. */
|
|
495
|
+
endSourceState() {
|
|
496
|
+
this.currentSourceState = undefined;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
/** Read a checkout's HEAD when it is a Git repository. */
|
|
500
|
+
export async function tryHead(dir) {
|
|
501
|
+
try {
|
|
502
|
+
return await gitHead(dir);
|
|
503
|
+
}
|
|
504
|
+
catch {
|
|
505
|
+
return undefined;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
/**
|
|
509
|
+
* The codeload tarball URL of a GitHub repository, or undefined for any
|
|
510
|
+
* other host. `https://github.com/<owner>/<repo>` maps to
|
|
511
|
+
* `https://codeload.github.com/<owner>/<repo>/tar.gz/refs/heads/<branch>`
|
|
512
|
+
* (or `/tar.gz/HEAD` for the default branch) — the git-protocol fallback
|
|
513
|
+
* when a clone cannot get through but plain HTTPS file download can.
|
|
514
|
+
*/
|
|
515
|
+
export function codeloadTarballUrl(url, branch) {
|
|
516
|
+
const match = /^https:\/\/github\.com\/([\w.-]+)\/([\w.-]+?)(?:\.git)?\/?$/i.exec(url.trim());
|
|
517
|
+
if (match === null)
|
|
518
|
+
return undefined;
|
|
519
|
+
const [, owner, repo] = match;
|
|
520
|
+
return `https://codeload.github.com/${owner}/${repo}/tar.gz/${branch === undefined ? 'HEAD' : `refs/heads/${branch}`}`;
|
|
521
|
+
}
|
package/lib/catalog/archive.js
CHANGED
|
@@ -19,6 +19,8 @@ import { mkdir, open, readdir, readFile, readlink, rename, rm, stat } from 'node
|
|
|
19
19
|
import { join, resolve } from 'node:path';
|
|
20
20
|
import { promisify } from 'node:util';
|
|
21
21
|
import { Unzip, UnzipInflate } from 'fflate';
|
|
22
|
+
import { archiveFormatOf } from '../model/types.js';
|
|
23
|
+
import { isWithin } from './paths.js';
|
|
22
24
|
const run = promisify(execFile);
|
|
23
25
|
/** Hard cap on archive download size (mirrors Claude Code's archive limit). */
|
|
24
26
|
export const ARCHIVE_MAX_BYTES = 256 * 1024 * 1024;
|
|
@@ -29,17 +31,8 @@ export const ARCHIVE_MAX_ENTRIES = 20_000;
|
|
|
29
31
|
/** Hard cap on the decompressed size of a single archive entry. */
|
|
30
32
|
export const ARCHIVE_MAX_ENTRY_BYTES = 128 * 1024 * 1024;
|
|
31
33
|
export const DEFAULT_ARCHIVE_TIMEOUT_MS = 180_000;
|
|
32
|
-
/**
|
|
33
|
-
export
|
|
34
|
-
const clean = url.trim().toLowerCase();
|
|
35
|
-
if (clean.endsWith('.zip'))
|
|
36
|
-
return 'zip';
|
|
37
|
-
if (clean.endsWith('.tar.gz') || clean.endsWith('.tgz'))
|
|
38
|
-
return 'targz';
|
|
39
|
-
if (clean.endsWith('.tar'))
|
|
40
|
-
return 'tar';
|
|
41
|
-
return undefined;
|
|
42
|
-
}
|
|
34
|
+
/** Archive vocabulary (format kinds and URL classification) shared with the model layer. */
|
|
35
|
+
export { archiveFormatOf };
|
|
43
36
|
/** Download the payload to a temp file, enforcing the size cap and digest. */
|
|
44
37
|
export async function downloadArchive(url, tempFile, options = {}) {
|
|
45
38
|
const trimmed = url.trim();
|
|
@@ -143,7 +136,21 @@ async function extractZip(archiveFile, dest) {
|
|
|
143
136
|
};
|
|
144
137
|
const unzip = new Unzip();
|
|
145
138
|
unzip.register(UnzipInflate);
|
|
146
|
-
|
|
139
|
+
// Entry writes go through a fixed set of slots. The stream drives `onfile`
|
|
140
|
+
// synchronously, so an unbounded fan-out opens one descriptor per entry and
|
|
141
|
+
// hits the process limit before the entry cap does — on Windows, around the
|
|
142
|
+
// 8192nd member of a whole-archive zip. A failure recorded during the stream
|
|
143
|
+
// turns writes that have not started yet into no-ops, and a failed write lands
|
|
144
|
+
// in the same `failure` slot, so the first error is the one reported.
|
|
145
|
+
const WRITE_SLOTS = 32;
|
|
146
|
+
const writes = Array.from({ length: WRITE_SLOTS }, () => Promise.resolve());
|
|
147
|
+
let writeSlot = 0;
|
|
148
|
+
const queueWrite = (target, chunks) => {
|
|
149
|
+
const slot = writeSlot++ % WRITE_SLOTS;
|
|
150
|
+
writes[slot] = writes[slot].then(() => (failure === undefined ? writeZipEntry(target, chunks) : undefined)).catch((error) => {
|
|
151
|
+
fail(error instanceof Error ? error : new Error(String(error)));
|
|
152
|
+
});
|
|
153
|
+
};
|
|
147
154
|
unzip.onfile = file => {
|
|
148
155
|
if (failure !== undefined)
|
|
149
156
|
return;
|
|
@@ -163,7 +170,7 @@ async function extractZip(archiveFile, dest) {
|
|
|
163
170
|
return;
|
|
164
171
|
}
|
|
165
172
|
const target = join(dest, normalized);
|
|
166
|
-
if (
|
|
173
|
+
if (!isWithin(dest, target)) {
|
|
167
174
|
fail(new Error(`zip entry escapes the extraction root: ${file.name}`));
|
|
168
175
|
return;
|
|
169
176
|
}
|
|
@@ -186,7 +193,7 @@ async function extractZip(archiveFile, dest) {
|
|
|
186
193
|
}
|
|
187
194
|
chunks.push(Buffer.from(data));
|
|
188
195
|
if (final)
|
|
189
|
-
|
|
196
|
+
queueWrite(target, chunks);
|
|
190
197
|
};
|
|
191
198
|
file.start();
|
|
192
199
|
};
|
|
@@ -196,9 +203,13 @@ async function extractZip(archiveFile, dest) {
|
|
|
196
203
|
for (let offset = 0; offset < view.length && failure === undefined; offset += slice) {
|
|
197
204
|
unzip.push(view.subarray(offset, Math.min(offset + slice, view.length)), offset + slice >= view.length);
|
|
198
205
|
}
|
|
206
|
+
// Report only once every write has settled: the caller removes the staging
|
|
207
|
+
// directory in its own cleanup, and a write still in flight would recreate
|
|
208
|
+
// entries underneath that removal. A failure set during the stream makes the
|
|
209
|
+
// writes that have not started yet no-ops.
|
|
210
|
+
await Promise.all(writes);
|
|
199
211
|
if (failure !== undefined)
|
|
200
212
|
throw failure;
|
|
201
|
-
await Promise.all(writes);
|
|
202
213
|
}
|
|
203
214
|
/** Write one buffered zip entry to disk after its data completed inflating. */
|
|
204
215
|
async function writeZipEntry(target, chunks) {
|
|
@@ -237,6 +248,8 @@ async function assertBoundedTree(root) {
|
|
|
237
248
|
let totalBytes = 0;
|
|
238
249
|
while (stack.length > 0) {
|
|
239
250
|
const dir = stack.pop();
|
|
251
|
+
if (dir === undefined)
|
|
252
|
+
break;
|
|
240
253
|
let dirents;
|
|
241
254
|
try {
|
|
242
255
|
dirents = await readdir(dir, { withFileTypes: true });
|
|
@@ -256,7 +269,7 @@ async function assertBoundedTree(root) {
|
|
|
256
269
|
stack.push(path);
|
|
257
270
|
continue;
|
|
258
271
|
}
|
|
259
|
-
let size
|
|
272
|
+
let size;
|
|
260
273
|
try {
|
|
261
274
|
size = (await stat(path)).size;
|
|
262
275
|
}
|
|
@@ -278,6 +291,8 @@ async function assertNoEscapingSymlinks(root) {
|
|
|
278
291
|
const stack = [root];
|
|
279
292
|
while (stack.length > 0) {
|
|
280
293
|
const dir = stack.pop();
|
|
294
|
+
if (dir === undefined)
|
|
295
|
+
break;
|
|
281
296
|
let entries;
|
|
282
297
|
try {
|
|
283
298
|
entries = await readdir(dir, { withFileTypes: true });
|
|
@@ -293,7 +308,7 @@ async function assertNoEscapingSymlinks(root) {
|
|
|
293
308
|
// bogus in-root path and the escape survives extraction.
|
|
294
309
|
const target = await readlink(path, 'utf8').catch(() => '');
|
|
295
310
|
const resolved = resolve(dir, target);
|
|
296
|
-
if (
|
|
311
|
+
if (!isWithin(root, resolved)) {
|
|
297
312
|
throw new Error(`archive contains a symlink escaping the extraction root: ${entry.name} -> ${target}`);
|
|
298
313
|
}
|
|
299
314
|
continue;
|
|
@@ -303,13 +318,12 @@ async function assertNoEscapingSymlinks(root) {
|
|
|
303
318
|
}
|
|
304
319
|
}
|
|
305
320
|
}
|
|
306
|
-
/** Test seam: run the symlink-containment walk against a prepared tree. */
|
|
307
|
-
export const assertNoEscapingSymlinksForTest = assertNoEscapingSymlinks;
|
|
308
321
|
/** If the extraction produced exactly one top-level directory and nothing else, use it as the root. */
|
|
309
322
|
async function unwrapSingleRoot(extractDir) {
|
|
310
323
|
const entries = await readdir(extractDir, { withFileTypes: true });
|
|
311
|
-
|
|
312
|
-
|
|
324
|
+
const soleEntry = entries.length === 1 ? entries[0] : undefined;
|
|
325
|
+
if (soleEntry !== undefined && soleEntry.isDirectory())
|
|
326
|
+
return join(extractDir, soleEntry.name);
|
|
313
327
|
return extractDir;
|
|
314
328
|
}
|
|
315
329
|
async function copyTree(from, to) {
|
|
@@ -330,12 +344,3 @@ async function copyTree(from, to) {
|
|
|
330
344
|
}
|
|
331
345
|
}
|
|
332
346
|
}
|
|
333
|
-
/** Probe whether a path exists and is a directory. */
|
|
334
|
-
export async function isDirectory(path) {
|
|
335
|
-
try {
|
|
336
|
-
return (await stat(path)).isDirectory();
|
|
337
|
-
}
|
|
338
|
-
catch {
|
|
339
|
-
return false;
|
|
340
|
-
}
|
|
341
|
-
}
|