@avantf/dsh-plugin-base 0.3.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/LICENSE +21 -0
- package/README.md +303 -0
- package/dist/artifact.d.ts +29 -0
- package/dist/artifact.d.ts.map +1 -0
- package/dist/artifact.js +60 -0
- package/dist/artifact.js.map +1 -0
- package/dist/bootstrap.d.ts +54 -0
- package/dist/bootstrap.d.ts.map +1 -0
- package/dist/bootstrap.js +334 -0
- package/dist/bootstrap.js.map +1 -0
- package/dist/cli.d.ts +4 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +37 -0
- package/dist/cli.js.map +1 -0
- package/dist/compat.d.ts +413 -0
- package/dist/compat.d.ts.map +1 -0
- package/dist/compat.js +742 -0
- package/dist/compat.js.map +1 -0
- package/dist/conformance.d.ts +55 -0
- package/dist/conformance.d.ts.map +1 -0
- package/dist/conformance.js +702 -0
- package/dist/conformance.js.map +1 -0
- package/dist/errors.d.ts +26 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +28 -0
- package/dist/errors.js.map +1 -0
- package/dist/fs.d.ts +18 -0
- package/dist/fs.d.ts.map +1 -0
- package/dist/fs.js +152 -0
- package/dist/fs.js.map +1 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +35 -0
- package/dist/index.js.map +1 -0
- package/dist/integrity.d.ts +5 -0
- package/dist/integrity.d.ts.map +1 -0
- package/dist/integrity.js +33 -0
- package/dist/integrity.js.map +1 -0
- package/dist/interface.d.ts +262 -0
- package/dist/interface.d.ts.map +1 -0
- package/dist/interface.js +136 -0
- package/dist/interface.js.map +1 -0
- package/dist/interface_gate.d.ts +61 -0
- package/dist/interface_gate.d.ts.map +1 -0
- package/dist/interface_gate.js +103 -0
- package/dist/interface_gate.js.map +1 -0
- package/dist/internal.d.ts +23 -0
- package/dist/internal.d.ts.map +1 -0
- package/dist/internal.js +18 -0
- package/dist/internal.js.map +1 -0
- package/dist/kit/family.d.ts +52 -0
- package/dist/kit/family.d.ts.map +1 -0
- package/dist/kit/family.js +65 -0
- package/dist/kit/family.js.map +1 -0
- package/dist/kit/index.d.ts +26 -0
- package/dist/kit/index.d.ts.map +1 -0
- package/dist/kit/index.js +26 -0
- package/dist/kit/index.js.map +1 -0
- package/dist/kit/logger.d.ts +41 -0
- package/dist/kit/logger.d.ts.map +1 -0
- package/dist/kit/logger.js +42 -0
- package/dist/kit/logger.js.map +1 -0
- package/dist/kit/prompt_files.d.ts +90 -0
- package/dist/kit/prompt_files.d.ts.map +1 -0
- package/dist/kit/prompt_files.js +121 -0
- package/dist/kit/prompt_files.js.map +1 -0
- package/dist/kit/typert.d.ts +40 -0
- package/dist/kit/typert.d.ts.map +1 -0
- package/dist/kit/typert.js +17 -0
- package/dist/kit/typert.js.map +1 -0
- package/dist/layout.d.ts +28 -0
- package/dist/layout.d.ts.map +1 -0
- package/dist/layout.js +84 -0
- package/dist/layout.js.map +1 -0
- package/dist/lint.d.ts +33 -0
- package/dist/lint.d.ts.map +1 -0
- package/dist/lint.js +271 -0
- package/dist/lint.js.map +1 -0
- package/dist/lock.d.ts +12 -0
- package/dist/lock.d.ts.map +1 -0
- package/dist/lock.js +93 -0
- package/dist/lock.js.map +1 -0
- package/dist/manifest.d.ts +12 -0
- package/dist/manifest.d.ts.map +1 -0
- package/dist/manifest.js +75 -0
- package/dist/manifest.js.map +1 -0
- package/dist/net.d.ts +23 -0
- package/dist/net.d.ts.map +1 -0
- package/dist/net.js +112 -0
- package/dist/net.js.map +1 -0
- package/dist/package-name.d.ts +3 -0
- package/dist/package-name.d.ts.map +1 -0
- package/dist/package-name.js +13 -0
- package/dist/package-name.js.map +1 -0
- package/dist/preset.d.ts +46 -0
- package/dist/preset.d.ts.map +1 -0
- package/dist/preset.js +230 -0
- package/dist/preset.js.map +1 -0
- package/dist/providers/archive.d.ts +32 -0
- package/dist/providers/archive.d.ts.map +1 -0
- package/dist/providers/archive.js +248 -0
- package/dist/providers/archive.js.map +1 -0
- package/dist/providers/model.d.ts +25 -0
- package/dist/providers/model.d.ts.map +1 -0
- package/dist/providers/model.js +550 -0
- package/dist/providers/model.js.map +1 -0
- package/dist/providers/npm.d.ts +21 -0
- package/dist/providers/npm.d.ts.map +1 -0
- package/dist/providers/npm.js +458 -0
- package/dist/providers/npm.js.map +1 -0
- package/dist/provisioner.d.ts +21 -0
- package/dist/provisioner.d.ts.map +1 -0
- package/dist/provisioner.js +962 -0
- package/dist/provisioner.js.map +1 -0
- package/dist/semver.d.ts +31 -0
- package/dist/semver.d.ts.map +1 -0
- package/dist/semver.js +244 -0
- package/dist/semver.js.map +1 -0
- package/dist/state.d.ts +71 -0
- package/dist/state.d.ts.map +1 -0
- package/dist/state.js +225 -0
- package/dist/state.js.map +1 -0
- package/dist/tar.d.ts +10 -0
- package/dist/tar.d.ts.map +1 -0
- package/dist/tar.js +178 -0
- package/dist/tar.js.map +1 -0
- package/dist/types.d.ts +431 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/zip.d.ts +8 -0
- package/dist/zip.d.ts.map +1 -0
- package/dist/zip.js +109 -0
- package/dist/zip.js.map +1 -0
- package/package.json +84 -0
|
@@ -0,0 +1,962 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The provisioner orchestration core.
|
|
3
|
+
* @module provisioner
|
|
4
|
+
*/
|
|
5
|
+
import { homedir } from 'node:os';
|
|
6
|
+
import { delimiter, dirname, join } from 'node:path';
|
|
7
|
+
import { ProvisionError, reasonOf } from './errors.js';
|
|
8
|
+
import { defaultFs, exists } from './fs.js';
|
|
9
|
+
import { assertSafeRelativePath, assertSafeRelativeRoot, isInside, lockPath, quarantineDir, stagingDir, versionSegment, } from './layout.js';
|
|
10
|
+
import { VERSION as FRAMEWORK_VERSION } from './bootstrap.js';
|
|
11
|
+
import { defaultLock } from './lock.js';
|
|
12
|
+
import { INSTALL_MANIFEST_FILE, MANIFEST_SCHEMA_VERSION, decodeJson, encodeInstallManifest, readInstallManifest, } from './manifest.js';
|
|
13
|
+
import { ITEM_FIELDS, MANIFEST_FIELDS, UNIMPLEMENTED_POLICY_KEYS, UNIMPLEMENTED_PROVISION_POLICY_KEYS, unknownFields } from './lint.js';
|
|
14
|
+
import { parseVersion, satisfiesRange } from './semver.js';
|
|
15
|
+
import { persistStatus, readLayout, readStatus, recordDeclared, writeLayout, } from './state.js';
|
|
16
|
+
/** The capability names this copy implements. @stable */
|
|
17
|
+
export const CAPABILITIES = ['events', 'resolve-record', 'progress'];
|
|
18
|
+
/** Startup critical-path budget in ms. @stable */
|
|
19
|
+
export const DEFAULT_DEADLINE_MS = 15_000;
|
|
20
|
+
/** Descriptor version this build understands. @stable */
|
|
21
|
+
export const ITEM_SCHEMA_VERSION = 1;
|
|
22
|
+
/** Staging/quarantine sequence, monotonic per process. */
|
|
23
|
+
let stagingSequence = 0;
|
|
24
|
+
/** How long a publish waits for the family lock before reporting `lock/timeout`. */
|
|
25
|
+
const LOCK_TIMEOUT_MS = 15_000;
|
|
26
|
+
/** A lock older than this whose pid is gone is reclaimed. */
|
|
27
|
+
const STALE_LOCK_MS = 60_000;
|
|
28
|
+
/** `onMissing` defaults, per axis: `degrade` / `error` (see `docs/DESIGN.md`). Absent and `{}` are the
|
|
29
|
+
* SAME request — the two used to disagree on `atUse`, so "omit the object" quietly meant something
|
|
30
|
+
* other than "declare no preference". (`atUse` is declarative today: nothing consumes it yet, which
|
|
31
|
+
* is exactly why the documented default has to be the one the code reports.) */
|
|
32
|
+
export function normalizeOnMissing(value) {
|
|
33
|
+
if (value === undefined)
|
|
34
|
+
return { atStartup: 'degrade', atUse: 'error' };
|
|
35
|
+
const atStartup = value.atStartup === 'degrade' || value.atStartup === 'refuse' ? value.atStartup : 'degrade';
|
|
36
|
+
const atUse = value.atUse === 'degrade' || value.atUse === 'error' ? value.atUse : 'error';
|
|
37
|
+
return { atStartup, atUse };
|
|
38
|
+
}
|
|
39
|
+
const SILENT_LOGGER = {
|
|
40
|
+
debug: () => undefined,
|
|
41
|
+
info: () => undefined,
|
|
42
|
+
warn: () => undefined,
|
|
43
|
+
error: () => undefined,
|
|
44
|
+
};
|
|
45
|
+
/** Resource key: `kind+name`, or a best-effort `kind+id` when no provider claims the kind. */
|
|
46
|
+
function resourceKey(kind, identity, itemId) {
|
|
47
|
+
return `${kind}+${identity?.name ?? itemId}`;
|
|
48
|
+
}
|
|
49
|
+
/** A timer that never keeps the process alive on its own. */
|
|
50
|
+
function delay(ms) {
|
|
51
|
+
return new Promise(resolve => {
|
|
52
|
+
const timer = setTimeout(() => resolve(undefined), ms);
|
|
53
|
+
timer.unref?.();
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
function rowKeyOf(key, version) {
|
|
57
|
+
return `${key}\u0000${version}`;
|
|
58
|
+
}
|
|
59
|
+
/** The provisioner-wide signal, plus a per-call signal when supplied. */
|
|
60
|
+
function combineSignals(base, extra) {
|
|
61
|
+
return extra === undefined ? base : AbortSignal.any([base, extra]);
|
|
62
|
+
}
|
|
63
|
+
/** Create a provisioner instance.
|
|
64
|
+
* @stable */
|
|
65
|
+
export function createProvisioner(options) {
|
|
66
|
+
const fs = options.fs ?? defaultFs();
|
|
67
|
+
const clock = options.clock ?? Date.now;
|
|
68
|
+
const lock = options.lock ?? defaultLock({ clock });
|
|
69
|
+
const logger = options.logger ?? SILENT_LOGGER;
|
|
70
|
+
const home = options.home ?? join(homedir(), '.avantf', 'env');
|
|
71
|
+
const layout = options.layout ?? 'v1';
|
|
72
|
+
const policy = options.policy ?? {};
|
|
73
|
+
const warnedUnimplemented = new Set();
|
|
74
|
+
/** Warn once per present unimplemented setting key. */
|
|
75
|
+
const noteUnimplemented = (known, present, where) => {
|
|
76
|
+
for (const key of known) {
|
|
77
|
+
if (!present.includes(key))
|
|
78
|
+
continue;
|
|
79
|
+
const id = `${where}.${key}`;
|
|
80
|
+
if (warnedUnimplemented.has(id))
|
|
81
|
+
continue;
|
|
82
|
+
warnedUnimplemented.add(id);
|
|
83
|
+
logger.warn(`ignored-field: ${id} 本实现不读取,设置它不会生效`);
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
noteUnimplemented(UNIMPLEMENTED_PROVISION_POLICY_KEYS, Object.keys(policy), 'policy');
|
|
87
|
+
/** Whether the loaded framework satisfies the declared range; missing passes. */
|
|
88
|
+
const unsupported = (() => {
|
|
89
|
+
const range = options.envinitRange;
|
|
90
|
+
if (range === undefined)
|
|
91
|
+
return undefined;
|
|
92
|
+
try {
|
|
93
|
+
return satisfiesRange(FRAMEWORK_VERSION, range) ? undefined : 'unsupported-envinit';
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return 'unsupported-envinit';
|
|
97
|
+
}
|
|
98
|
+
})();
|
|
99
|
+
let unsupportedWarned = false;
|
|
100
|
+
/** Auto-download gate, per kind. */
|
|
101
|
+
const autoDownload = (kind) => {
|
|
102
|
+
const value = policy.autoDownload;
|
|
103
|
+
if (value === undefined)
|
|
104
|
+
return true;
|
|
105
|
+
if (typeof value === 'boolean')
|
|
106
|
+
return value;
|
|
107
|
+
return value[kind] ?? true;
|
|
108
|
+
};
|
|
109
|
+
// ── provider registry ──────────────────────────────────────────────────────
|
|
110
|
+
const claims = new Map();
|
|
111
|
+
const providers = new Map();
|
|
112
|
+
const refresh = (kind) => {
|
|
113
|
+
const list = claims.get(kind) ?? [];
|
|
114
|
+
if (list.length === 0) {
|
|
115
|
+
providers.delete(kind);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
if (list.length === 1) {
|
|
119
|
+
const only = list[0];
|
|
120
|
+
if (only !== undefined)
|
|
121
|
+
providers.set(kind, only);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
const chosen = policy.kindProviders?.[kind];
|
|
125
|
+
const picked = chosen === undefined ? undefined : list.find(provider => provider.id === chosen);
|
|
126
|
+
if (picked === undefined) {
|
|
127
|
+
logger.warn(`provider/conflict: kind "${kind}" is claimed by ${list.map(p => p.id).join(', ')}; failing closed`);
|
|
128
|
+
providers.delete(kind);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
providers.set(kind, picked);
|
|
132
|
+
};
|
|
133
|
+
const lookup = (kind) => {
|
|
134
|
+
const list = claims.get(kind) ?? [];
|
|
135
|
+
if (list.length === 0)
|
|
136
|
+
return { code: 'unknown-provider' };
|
|
137
|
+
const provider = providers.get(kind);
|
|
138
|
+
if (provider === undefined)
|
|
139
|
+
return { code: 'provider/conflict' };
|
|
140
|
+
return { provider };
|
|
141
|
+
};
|
|
142
|
+
const register = (provider) => {
|
|
143
|
+
for (const kind of provider.kinds) {
|
|
144
|
+
const list = claims.get(kind) ?? [];
|
|
145
|
+
if (!list.includes(provider))
|
|
146
|
+
list.push(provider);
|
|
147
|
+
claims.set(kind, list);
|
|
148
|
+
refresh(kind);
|
|
149
|
+
}
|
|
150
|
+
return {
|
|
151
|
+
dispose: () => {
|
|
152
|
+
for (const kind of provider.kinds) {
|
|
153
|
+
const list = (claims.get(kind) ?? []).filter(candidate => candidate !== provider);
|
|
154
|
+
if (list.length === 0)
|
|
155
|
+
claims.delete(kind);
|
|
156
|
+
else
|
|
157
|
+
claims.set(kind, list);
|
|
158
|
+
refresh(kind);
|
|
159
|
+
}
|
|
160
|
+
},
|
|
161
|
+
};
|
|
162
|
+
};
|
|
163
|
+
// ── declared items ─────────────────────────────────────────────────────────
|
|
164
|
+
const items = new Map();
|
|
165
|
+
const rows = new Map();
|
|
166
|
+
const itemRows = new Map();
|
|
167
|
+
const listeners = new Set();
|
|
168
|
+
/** Layout read-only state, decided once per process. */
|
|
169
|
+
let readOnly;
|
|
170
|
+
let homeChecked = false;
|
|
171
|
+
const pendingDeclared = [];
|
|
172
|
+
/** Decide the home's layout once. */
|
|
173
|
+
const checkHome = async () => {
|
|
174
|
+
if (homeChecked)
|
|
175
|
+
return;
|
|
176
|
+
homeChecked = true;
|
|
177
|
+
if (unsupported !== undefined && !unsupportedWarned) {
|
|
178
|
+
unsupportedWarned = true;
|
|
179
|
+
logger.warn(`unsupported-envinit: 本副本 ${FRAMEWORK_VERSION} 不满足插件声明的区间 "${String(options.envinitRange)}";跳过该清单、降级挂载`);
|
|
180
|
+
}
|
|
181
|
+
try {
|
|
182
|
+
const result = await readLayout(fs, home, layout);
|
|
183
|
+
if (result.kind === 'none') {
|
|
184
|
+
await writeLayout(fs, home, layout, 'dsh-plugin-base');
|
|
185
|
+
}
|
|
186
|
+
else if (result.kind === 'too-new') {
|
|
187
|
+
readOnly = 'layout/too-new';
|
|
188
|
+
logger.warn(`layout/too-new: ${home} 由更新的布局写入;本次只读不写`);
|
|
189
|
+
}
|
|
190
|
+
else if (result.kind === 'mismatch') {
|
|
191
|
+
readOnly = 'layout/mismatch';
|
|
192
|
+
logger.warn(`layout/mismatch: ${home} 的布局是 "${result.file.layout}",本次是 "${layout}";本次只读不写`);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
catch (error) {
|
|
196
|
+
// A read failure keeps the read-only CONSEQUENCE but gets its own code: every item's report
|
|
197
|
+
// would otherwise say `layout/too-new` — "a NEWER layout wrote this, leave it alone" — about a
|
|
198
|
+
// home whose layout nobody managed to read at all.
|
|
199
|
+
logger.warn(`layout: 无法读写 .layout.json(${reasonOf(error).message});按只读继续`);
|
|
200
|
+
readOnly = 'layout/unreadable';
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
/** The persisted view of the current rows (identity `key × version`). */
|
|
204
|
+
const statusRows = () => [...rows.values()].map(row => ({
|
|
205
|
+
key: row.key,
|
|
206
|
+
version: row.version,
|
|
207
|
+
items: row.items,
|
|
208
|
+
plugins: row.plugins,
|
|
209
|
+
...(row.source === undefined ? {} : { source: row.source }),
|
|
210
|
+
updated_at: row.updatedAt,
|
|
211
|
+
...(row.lastError === undefined
|
|
212
|
+
? {}
|
|
213
|
+
: {
|
|
214
|
+
last_error: {
|
|
215
|
+
code: row.lastError.code,
|
|
216
|
+
...(row.lastError.detail === undefined ? {} : { detail: row.lastError.detail }),
|
|
217
|
+
...(row.lastError.retryAfter === undefined ? {} : { retry_after: row.lastError.retryAfter }),
|
|
218
|
+
},
|
|
219
|
+
}),
|
|
220
|
+
}));
|
|
221
|
+
const emit = (event) => {
|
|
222
|
+
for (const entry of listeners) {
|
|
223
|
+
// One subscription receives one event kind.
|
|
224
|
+
if (entry.event !== event.type)
|
|
225
|
+
continue;
|
|
226
|
+
try {
|
|
227
|
+
entry.listener(event);
|
|
228
|
+
}
|
|
229
|
+
catch (error) {
|
|
230
|
+
logger.warn(`a provision listener threw: ${reasonOf(error).message}`);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
const putRow = (declared, version, state, source) => {
|
|
235
|
+
const key = rowKeyOf(declared.key, version);
|
|
236
|
+
const previous = rows.get(key);
|
|
237
|
+
// A version-less row is the resource's unresolved face.
|
|
238
|
+
const unresolvedKey = version === '' ? undefined : rowKeyOf(declared.key, '');
|
|
239
|
+
const unresolved = unresolvedKey === undefined ? undefined : rows.get(unresolvedKey);
|
|
240
|
+
if (unresolvedKey !== undefined && unresolved !== undefined) {
|
|
241
|
+
rows.delete(unresolvedKey);
|
|
242
|
+
// Items that read the unresolved row now read the versioned one.
|
|
243
|
+
for (const id of unresolved.items)
|
|
244
|
+
itemRows.set(id, key);
|
|
245
|
+
}
|
|
246
|
+
const plugins = new Set([...(previous?.plugins ?? []), ...(unresolved?.plugins ?? [])]);
|
|
247
|
+
plugins.add(declared.plugin);
|
|
248
|
+
const itemIds = new Set([...(previous?.items ?? []), ...(unresolved?.items ?? [])]);
|
|
249
|
+
itemIds.add(declared.item.id);
|
|
250
|
+
rows.set(key, {
|
|
251
|
+
key: declared.key,
|
|
252
|
+
version,
|
|
253
|
+
items: [...itemIds].sort(),
|
|
254
|
+
plugins: [...plugins].sort(),
|
|
255
|
+
state,
|
|
256
|
+
...(source === undefined ? {} : { source }),
|
|
257
|
+
updatedAt: clock(),
|
|
258
|
+
});
|
|
259
|
+
itemRows.set(declared.item.id, key);
|
|
260
|
+
emit({ type: 'availability', key: declared.key, state });
|
|
261
|
+
};
|
|
262
|
+
const declare = (manifest) => {
|
|
263
|
+
if (manifest.plugin === '')
|
|
264
|
+
throw new ProvisionError('invalid-option', 'Manifest.plugin 不能为空');
|
|
265
|
+
// Unknown fields are ignored, never fatal; they are surfaced as `ignored-field`.
|
|
266
|
+
for (const key of unknownFields(manifest, MANIFEST_FIELDS)) {
|
|
267
|
+
logger.warn(`ignored-field: manifest(${manifest.plugin}) 携带框架不认识的字段:${key}`);
|
|
268
|
+
}
|
|
269
|
+
for (const capability of manifest.requires?.capabilities ?? []) {
|
|
270
|
+
if (!CAPABILITIES.includes(capability)) {
|
|
271
|
+
logger.warn(`ignored-field: manifest(${manifest.plugin}) 声明了本副本不具备的能力:${capability}`);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
const reaches = (from, target, seen) => {
|
|
275
|
+
for (const need of items.get(from)?.item.needs ?? []) {
|
|
276
|
+
if (need === target)
|
|
277
|
+
return true;
|
|
278
|
+
if (seen.has(need))
|
|
279
|
+
continue;
|
|
280
|
+
seen.add(need);
|
|
281
|
+
if (reaches(need, target, seen))
|
|
282
|
+
return true;
|
|
283
|
+
}
|
|
284
|
+
return false;
|
|
285
|
+
};
|
|
286
|
+
for (const item of manifest.items) {
|
|
287
|
+
for (const key of unknownFields(item, ITEM_FIELDS)) {
|
|
288
|
+
logger.warn(`ignored-field: ${item.id} 携带框架不认识的字段:${key}`);
|
|
289
|
+
}
|
|
290
|
+
noteUnimplemented(UNIMPLEMENTED_POLICY_KEYS, Object.keys(item.policy ?? {}), `${item.id}.policy`);
|
|
291
|
+
// An illegal `onMissing` value falls back to that axis's default after a warning.
|
|
292
|
+
if (item.onMissing !== undefined) {
|
|
293
|
+
const normalized = normalizeOnMissing(item.onMissing);
|
|
294
|
+
const raw = item.onMissing;
|
|
295
|
+
for (const [axis, fallback] of [
|
|
296
|
+
['atStartup', normalized.atStartup],
|
|
297
|
+
['atUse', normalized.atUse],
|
|
298
|
+
]) {
|
|
299
|
+
if (raw[axis] !== undefined && raw[axis] !== fallback) {
|
|
300
|
+
logger.warn(`invalid-option: ${item.id}.onMissing.${axis} 取值非法(${String(raw[axis])});按缺省 ${fallback} 处理`);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
const startup = item.startup;
|
|
305
|
+
if (startup !== undefined && startup !== 'blocking' && startup !== 'background') {
|
|
306
|
+
logger.warn(`invalid-option: ${item.id}.startup 取值非法(${String(startup)});按缺省 blocking 处理`);
|
|
307
|
+
}
|
|
308
|
+
if (items.has(item.id)) {
|
|
309
|
+
throw new ProvisionError('invalid-option', `item id 重复:${item.id}`);
|
|
310
|
+
}
|
|
311
|
+
if ((item.needs ?? []).some(need => need === item.id || reaches(need, item.id, new Set()))) {
|
|
312
|
+
throw new ProvisionError('invalid-option', `needs 成环:${item.id}`);
|
|
313
|
+
}
|
|
314
|
+
assertSafeRelativeRoot(item.target.root);
|
|
315
|
+
const found = lookup(item.kind);
|
|
316
|
+
const identity = 'provider' in found ? found.provider.identify(item) : undefined;
|
|
317
|
+
const key = resourceKey(item.kind, identity, item.id);
|
|
318
|
+
// `requires.providers` maps an absent provider to `provider/unavailable`.
|
|
319
|
+
const expectedProvider = (manifest.requires?.providers ?? []).find(entry => entry.kinds.includes(item.kind))?.package;
|
|
320
|
+
items.set(item.id, { plugin: manifest.plugin, item, identity, key, expectedProvider });
|
|
321
|
+
// Declaration registry: one entry per (key, plugin), appended under the status short lock —
|
|
322
|
+
// but NOT written here. The home's layout verdict is the authority on whether this home may be
|
|
323
|
+
// touched at all, and `declare` is the synchronous collecting pass, so it has not been reached
|
|
324
|
+
// yet. Writing eagerly meant the ONE case the invariant exists for — a home written by a NEWER
|
|
325
|
+
// layout — still got a declaration registry appended to it. The write now waits for the verdict
|
|
326
|
+
// and skips when the verdict is read-only.
|
|
327
|
+
pendingDeclared.push((async () => {
|
|
328
|
+
await checkHome();
|
|
329
|
+
if (readOnly !== undefined)
|
|
330
|
+
return;
|
|
331
|
+
await recordDeclared(fs, lock, home, key, manifest.plugin, logger);
|
|
332
|
+
})());
|
|
333
|
+
}
|
|
334
|
+
};
|
|
335
|
+
const select = (only) => {
|
|
336
|
+
const invalid = [];
|
|
337
|
+
let chosen;
|
|
338
|
+
if (only === undefined) {
|
|
339
|
+
chosen = [...items.values()];
|
|
340
|
+
}
|
|
341
|
+
else {
|
|
342
|
+
const wanted = new Set();
|
|
343
|
+
for (const id of only) {
|
|
344
|
+
if (!items.has(id)) {
|
|
345
|
+
invalid.push(id);
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
wanted.add(id);
|
|
349
|
+
}
|
|
350
|
+
// `only` pulls in the `needs` closure among declared items.
|
|
351
|
+
let grew = true;
|
|
352
|
+
while (grew) {
|
|
353
|
+
grew = false;
|
|
354
|
+
for (const id of [...wanted]) {
|
|
355
|
+
const declared = items.get(id);
|
|
356
|
+
for (const need of declared?.item.needs ?? []) {
|
|
357
|
+
if (items.has(need) && !wanted.has(need)) {
|
|
358
|
+
wanted.add(need);
|
|
359
|
+
grew = true;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
chosen = [...wanted].map(id => items.get(id)).filter((entry) => entry !== undefined);
|
|
365
|
+
}
|
|
366
|
+
// Topological order over `needs` (stable by declaration order).
|
|
367
|
+
const ordered = [];
|
|
368
|
+
const visiting = new Set();
|
|
369
|
+
const done = new Set();
|
|
370
|
+
const byId = new Map(chosen.map(entry => [entry.item.id, entry]));
|
|
371
|
+
const visit = (entry) => {
|
|
372
|
+
if (done.has(entry.item.id) || visiting.has(entry.item.id))
|
|
373
|
+
return;
|
|
374
|
+
visiting.add(entry.item.id);
|
|
375
|
+
for (const need of entry.item.needs ?? []) {
|
|
376
|
+
const next = byId.get(need);
|
|
377
|
+
if (next !== undefined)
|
|
378
|
+
visit(next);
|
|
379
|
+
}
|
|
380
|
+
visiting.delete(entry.item.id);
|
|
381
|
+
done.add(entry.item.id);
|
|
382
|
+
ordered.push(entry);
|
|
383
|
+
};
|
|
384
|
+
for (const entry of chosen)
|
|
385
|
+
visit(entry);
|
|
386
|
+
return { ordered, invalid };
|
|
387
|
+
};
|
|
388
|
+
// ── item pipeline ─────────────────────────────────────────────────────────
|
|
389
|
+
/** The provisioner's own controller: `dispose()` aborts everything still in flight. */
|
|
390
|
+
const shutdown = new AbortController();
|
|
391
|
+
/** The policy a provider sees for one item; item-level mirrors win per field. */
|
|
392
|
+
const policyFor = (item) => {
|
|
393
|
+
const scoped = item.policy?.mirrors;
|
|
394
|
+
if (scoped === undefined)
|
|
395
|
+
return policy;
|
|
396
|
+
const archive = scoped.archive ?? policy.mirrors?.archive ?? [];
|
|
397
|
+
const npm = scoped.npm ?? policy.mirrors?.npm;
|
|
398
|
+
const model = scoped.model ?? policy.mirrors?.model;
|
|
399
|
+
return {
|
|
400
|
+
...policy,
|
|
401
|
+
mirrors: {
|
|
402
|
+
archive,
|
|
403
|
+
...(npm === undefined ? {} : { npm }),
|
|
404
|
+
...(model === undefined ? {} : { model }),
|
|
405
|
+
},
|
|
406
|
+
};
|
|
407
|
+
};
|
|
408
|
+
const contextOf = (signal, item) => ({
|
|
409
|
+
home,
|
|
410
|
+
logger,
|
|
411
|
+
policy: item === undefined ? policy : policyFor(item),
|
|
412
|
+
fs,
|
|
413
|
+
...(options.fetch === undefined ? {} : { fetch: options.fetch }),
|
|
414
|
+
lock,
|
|
415
|
+
signal: combineSignals(shutdown.signal, signal),
|
|
416
|
+
});
|
|
417
|
+
const entryOf = (declared, fields) => ({
|
|
418
|
+
plugin: declared.plugin,
|
|
419
|
+
id: declared.item.id,
|
|
420
|
+
key: declared.key,
|
|
421
|
+
action: fields.action,
|
|
422
|
+
source: fields.source,
|
|
423
|
+
ms: clock() - fields.started,
|
|
424
|
+
...(fields.version === undefined ? {} : { version: fields.version }),
|
|
425
|
+
...(fields.code === undefined ? {} : { code: fields.code }),
|
|
426
|
+
...(fields.reason === undefined ? {} : { reason: fields.reason }),
|
|
427
|
+
});
|
|
428
|
+
const readManifestAt = async (directory, kind) => {
|
|
429
|
+
const path = join(directory, INSTALL_MANIFEST_FILE);
|
|
430
|
+
const info = await fs.stat(path);
|
|
431
|
+
if (info === undefined)
|
|
432
|
+
return undefined;
|
|
433
|
+
return readInstallManifest(decodeJson(await fs.readFile(path)), kind);
|
|
434
|
+
};
|
|
435
|
+
/** Move a version directory aside; renaming is never deletion. */
|
|
436
|
+
const quarantine = async (directory) => {
|
|
437
|
+
const destination = quarantineDir(home, (stagingSequence += 1));
|
|
438
|
+
await fs.mkdir(dirname(destination));
|
|
439
|
+
await fs.rename(directory, destination);
|
|
440
|
+
};
|
|
441
|
+
/** Move a published directory aside while holding the publish lock. */
|
|
442
|
+
const quarantineLocked = async (directory) => {
|
|
443
|
+
const handle = await lock.acquire(lockPath(home), { timeoutMs: LOCK_TIMEOUT_MS, staleMs: STALE_LOCK_MS });
|
|
444
|
+
try {
|
|
445
|
+
await quarantine(directory);
|
|
446
|
+
}
|
|
447
|
+
finally {
|
|
448
|
+
handle.dispose();
|
|
449
|
+
}
|
|
450
|
+
};
|
|
451
|
+
const publish = async (declared, staging, meta) => {
|
|
452
|
+
const { item } = declared;
|
|
453
|
+
const segment = versionSegment(home, item.target.root, meta.name, meta.version);
|
|
454
|
+
const provider = providers.get(item.kind);
|
|
455
|
+
if (provider === undefined)
|
|
456
|
+
throw new ProvisionError('unknown-provider', `没有 provider 认领 ${item.kind}`);
|
|
457
|
+
const rel = provider.targetDir(item, { name: meta.name, version: meta.version, segment });
|
|
458
|
+
assertSafeRelativePath(rel);
|
|
459
|
+
const target = join(home, item.target.root, ...rel.split('/'));
|
|
460
|
+
if (!isInside(home, target)) {
|
|
461
|
+
throw new ProvisionError('invalid-option', `落盘目标跳出 home:${target}`);
|
|
462
|
+
}
|
|
463
|
+
const manifest = {
|
|
464
|
+
schemaVersion: MANIFEST_SCHEMA_VERSION,
|
|
465
|
+
name: meta.name,
|
|
466
|
+
version: meta.version,
|
|
467
|
+
dir: segment,
|
|
468
|
+
...(meta.integrity === undefined ? {} : { integrity: meta.integrity }),
|
|
469
|
+
...(meta.tarball === undefined ? {} : { tarball: meta.tarball }),
|
|
470
|
+
...(meta.entry === undefined ? {} : { entry: meta.entry }),
|
|
471
|
+
installed_at: new Date(clock()).toISOString(),
|
|
472
|
+
source: meta.source ?? 'installed',
|
|
473
|
+
layout,
|
|
474
|
+
...(meta.entryDir === undefined ? {} : { entryDir: meta.entryDir }),
|
|
475
|
+
};
|
|
476
|
+
// The manifest is written into staging before the rename.
|
|
477
|
+
await fs.writeFile(join(staging, INSTALL_MANIFEST_FILE), encodeInstallManifest(manifest));
|
|
478
|
+
const handle = await lock.acquire(lockPath(home), { timeoutMs: LOCK_TIMEOUT_MS, staleMs: STALE_LOCK_MS });
|
|
479
|
+
try {
|
|
480
|
+
if (await exists(fs, target)) {
|
|
481
|
+
const existing = await readManifestAt(target, item.kind);
|
|
482
|
+
// Reuse only a manifest that describes *this* version; anything else is renamed aside.
|
|
483
|
+
if (existing !== undefined && existing.version === meta.version) {
|
|
484
|
+
if (existing.integrity === undefined) {
|
|
485
|
+
logger.warn(`unverifiable: ${target} 的 install.json 没有 integrity(旧形状或未校验来源);按可用处理,不隔离`);
|
|
486
|
+
}
|
|
487
|
+
return resolvedOf(declared, target, existing);
|
|
488
|
+
}
|
|
489
|
+
// Both branches are logged rather than renamed silently.
|
|
490
|
+
logger.warn(existing === undefined
|
|
491
|
+
? `target 已存在但没有可用的 install.json:隔离后重取(${target})`
|
|
492
|
+
: `target 已存在但描述的是 ${existing.version},期望 ${meta.version}:隔离后重取(${target})`);
|
|
493
|
+
await quarantine(target);
|
|
494
|
+
}
|
|
495
|
+
await fs.mkdir(dirname(target));
|
|
496
|
+
try {
|
|
497
|
+
await fs.rename(staging, target);
|
|
498
|
+
}
|
|
499
|
+
catch (error) {
|
|
500
|
+
if (error.code === 'EXDEV') {
|
|
501
|
+
throw new ProvisionError('publish/cross-device', `staging 与目标不在同一文件系统:${staging} → ${target}`);
|
|
502
|
+
}
|
|
503
|
+
throw error;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
finally {
|
|
507
|
+
handle.dispose();
|
|
508
|
+
}
|
|
509
|
+
return resolvedOf(declared, target, manifest);
|
|
510
|
+
};
|
|
511
|
+
const resolvedOf = (declared, target, manifest) => {
|
|
512
|
+
const entryDir = manifest.entryDir;
|
|
513
|
+
const dir = entryDir === undefined ? target : join(target, ...entryDir.split('/'));
|
|
514
|
+
// Only an archive carries an executable, so only it contributes PATH.
|
|
515
|
+
const pathEnv = manifest.entry === undefined || manifest.entryDir === undefined
|
|
516
|
+
? undefined
|
|
517
|
+
: { PATH: `${dir}${delimiter}${process.env['PATH'] ?? ''}` };
|
|
518
|
+
return {
|
|
519
|
+
key: declared.key,
|
|
520
|
+
name: manifest.name,
|
|
521
|
+
version: manifest.version,
|
|
522
|
+
dir: target,
|
|
523
|
+
entryDir: dir,
|
|
524
|
+
...(manifest.integrity === undefined ? {} : { integrity: manifest.integrity }),
|
|
525
|
+
source: manifest.source,
|
|
526
|
+
...(pathEnv === undefined ? {} : { env: pathEnv }),
|
|
527
|
+
};
|
|
528
|
+
};
|
|
529
|
+
/** Set one terminal state and build its matching report entry. */
|
|
530
|
+
const settle = (declared, started, kind, code, detail) => {
|
|
531
|
+
const extra = detail === undefined ? {} : { detail };
|
|
532
|
+
const state = kind === 'skipped' ? { state: 'skipped', code, ...extra } : { state: 'failed', code, ...extra };
|
|
533
|
+
putRow(declared, '', state);
|
|
534
|
+
return entryOf(declared, { action: kind, source: 'managed', started, code, ...(detail === undefined ? {} : { reason: detail }) });
|
|
535
|
+
};
|
|
536
|
+
const ensureOne = async (declared, signal, offline = false, onProgress) => {
|
|
537
|
+
const started = clock();
|
|
538
|
+
const { item } = declared;
|
|
539
|
+
// Decisions made before any provider is consulted.
|
|
540
|
+
if (readOnly !== undefined)
|
|
541
|
+
return settle(declared, started, 'skipped', readOnly);
|
|
542
|
+
if (unsupported !== undefined)
|
|
543
|
+
return settle(declared, started, 'skipped', unsupported);
|
|
544
|
+
if (item.schemaVersion > ITEM_SCHEMA_VERSION) {
|
|
545
|
+
return settle(declared, started, 'skipped', 'unsupported-item-schema');
|
|
546
|
+
}
|
|
547
|
+
// A `need` this build could not read makes its dependents fail too.
|
|
548
|
+
const unmetNeeds = (item.needs ?? []).filter(need => {
|
|
549
|
+
const rowKey = itemRows.get(need);
|
|
550
|
+
const state = rowKey === undefined ? undefined : rows.get(rowKey)?.state;
|
|
551
|
+
return state?.state === 'skipped' && state.code === 'unsupported-item-schema';
|
|
552
|
+
});
|
|
553
|
+
const missingNeeds = [...(item.needs ?? []).filter(need => !items.has(need)), ...unmetNeeds];
|
|
554
|
+
if (missingNeeds.length > 0) {
|
|
555
|
+
return settle(declared, started, 'failed', 'missing-need', missingNeeds.join(', '));
|
|
556
|
+
}
|
|
557
|
+
const found = lookup(item.kind);
|
|
558
|
+
if (!('provider' in found)) {
|
|
559
|
+
const code = found.code === 'unknown-provider' && declared.expectedProvider !== undefined ? 'provider/unavailable' : found.code;
|
|
560
|
+
return settle(declared, started, 'failed', code, code === 'provider/unavailable' ? `声明的 provider ${String(declared.expectedProvider)} 未注册` : undefined);
|
|
561
|
+
}
|
|
562
|
+
const provider = found.provider;
|
|
563
|
+
const ctx = contextOf(signal, item);
|
|
564
|
+
let probed;
|
|
565
|
+
try {
|
|
566
|
+
probed = await provider.probe(item, ctx);
|
|
567
|
+
}
|
|
568
|
+
catch (error) {
|
|
569
|
+
const { code, message } = reasonOf(error);
|
|
570
|
+
return settle(declared, started, 'failed', code, message);
|
|
571
|
+
}
|
|
572
|
+
if (probed.found) {
|
|
573
|
+
const version = probed.version ?? '';
|
|
574
|
+
const state = {
|
|
575
|
+
state: 'ready',
|
|
576
|
+
handle: {
|
|
577
|
+
id: item.id,
|
|
578
|
+
key: declared.key,
|
|
579
|
+
kind: item.kind,
|
|
580
|
+
...(probed.version === undefined ? {} : { version: probed.version }),
|
|
581
|
+
source: probed.source,
|
|
582
|
+
dir: probed.dir,
|
|
583
|
+
env: probed.env ?? {},
|
|
584
|
+
},
|
|
585
|
+
};
|
|
586
|
+
putRow(declared, version, state, probed.source);
|
|
587
|
+
return entryOf(declared, {
|
|
588
|
+
action: 'present',
|
|
589
|
+
source: probed.source,
|
|
590
|
+
started,
|
|
591
|
+
...(probed.version === undefined ? {} : { version: probed.version }),
|
|
592
|
+
});
|
|
593
|
+
}
|
|
594
|
+
// Two policy skips: the auto-download gate and `--offline`.
|
|
595
|
+
if (!autoDownload(item.kind))
|
|
596
|
+
return settle(declared, started, 'skipped', 'policy/download-disabled');
|
|
597
|
+
if (offline)
|
|
598
|
+
return settle(declared, started, 'skipped', 'policy/offline');
|
|
599
|
+
const staging = stagingDir(home, item.id, (stagingSequence += 1));
|
|
600
|
+
let published;
|
|
601
|
+
const installCtx = {
|
|
602
|
+
...ctx,
|
|
603
|
+
stage: async () => {
|
|
604
|
+
await fs.mkdir(staging);
|
|
605
|
+
return staging;
|
|
606
|
+
},
|
|
607
|
+
publish: async (dir, meta) => {
|
|
608
|
+
const resolved = await publish(declared, dir, meta);
|
|
609
|
+
published = resolved;
|
|
610
|
+
return resolved;
|
|
611
|
+
},
|
|
612
|
+
...(onProgress === undefined
|
|
613
|
+
? {}
|
|
614
|
+
: {
|
|
615
|
+
onProgress: (event) => {
|
|
616
|
+
try {
|
|
617
|
+
onProgress(event);
|
|
618
|
+
}
|
|
619
|
+
catch (error) {
|
|
620
|
+
logger.warn(`an onProgress callback threw: ${reasonOf(error).message}`);
|
|
621
|
+
}
|
|
622
|
+
},
|
|
623
|
+
}),
|
|
624
|
+
};
|
|
625
|
+
try {
|
|
626
|
+
await fs.mkdir(staging);
|
|
627
|
+
const installed = await provider.install(item, installCtx);
|
|
628
|
+
// The core fills `Resolved.key`.
|
|
629
|
+
const resolved = { ...installed, key: declared.key };
|
|
630
|
+
published = resolved;
|
|
631
|
+
await provider.verify(item, resolved, ctx);
|
|
632
|
+
const state = {
|
|
633
|
+
state: 'ready',
|
|
634
|
+
handle: {
|
|
635
|
+
id: item.id,
|
|
636
|
+
key: declared.key,
|
|
637
|
+
kind: item.kind,
|
|
638
|
+
version: resolved.version,
|
|
639
|
+
source: resolved.source,
|
|
640
|
+
dir: resolved.entryDir,
|
|
641
|
+
env: resolved.env ?? {},
|
|
642
|
+
},
|
|
643
|
+
};
|
|
644
|
+
putRow(declared, resolved.version, state, resolved.source);
|
|
645
|
+
return entryOf(declared, { action: 'installed', source: resolved.source, started, version: resolved.version });
|
|
646
|
+
}
|
|
647
|
+
catch (error) {
|
|
648
|
+
const { code, message } = reasonOf(error);
|
|
649
|
+
// A published-but-unusable directory is quarantined, never deleted.
|
|
650
|
+
if (published !== undefined) {
|
|
651
|
+
try {
|
|
652
|
+
await quarantineLocked(published.dir);
|
|
653
|
+
logger.warn(`quarantined ${published.dir}: ${message}`);
|
|
654
|
+
}
|
|
655
|
+
catch (renameError) {
|
|
656
|
+
logger.warn(`could not quarantine ${published.dir}: ${reasonOf(renameError).message}`);
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
const state = { state: 'failed', code, detail: message };
|
|
660
|
+
putRow(declared, published?.version ?? '', state);
|
|
661
|
+
return entryOf(declared, {
|
|
662
|
+
action: 'failed',
|
|
663
|
+
source: published?.source ?? 'managed',
|
|
664
|
+
started,
|
|
665
|
+
...(published === undefined ? {} : { version: published.version }),
|
|
666
|
+
code,
|
|
667
|
+
reason: message,
|
|
668
|
+
});
|
|
669
|
+
}
|
|
670
|
+
finally {
|
|
671
|
+
await fs.rm(staging, { recursive: true }).catch(() => undefined);
|
|
672
|
+
}
|
|
673
|
+
};
|
|
674
|
+
const inflight = new Map();
|
|
675
|
+
const ensureOneDeduped = async (declared, signal, offline = false, onProgress) => {
|
|
676
|
+
const running = inflight.get(declared.item.id);
|
|
677
|
+
if (running !== undefined)
|
|
678
|
+
return running;
|
|
679
|
+
const promise = ensureOne(declared, signal, offline, onProgress);
|
|
680
|
+
inflight.set(declared.item.id, promise);
|
|
681
|
+
try {
|
|
682
|
+
return await promise;
|
|
683
|
+
}
|
|
684
|
+
finally {
|
|
685
|
+
inflight.delete(declared.item.id);
|
|
686
|
+
}
|
|
687
|
+
};
|
|
688
|
+
/** `{state:'pending'}` for an item that ran past the startup budget. */
|
|
689
|
+
const putPending = (declared) => {
|
|
690
|
+
const current = itemRows.get(declared.item.id);
|
|
691
|
+
const row = current === undefined ? undefined : rows.get(current);
|
|
692
|
+
// A ready row is never downgraded by a later `pending` write.
|
|
693
|
+
if (row?.state.state === 'ready')
|
|
694
|
+
return;
|
|
695
|
+
putRow(declared, '', { state: 'pending', since: clock() });
|
|
696
|
+
};
|
|
697
|
+
/** Start every selected item and wait only for the shared startup budget. */
|
|
698
|
+
const runEnsure = async (ensureOptions) => {
|
|
699
|
+
await checkHome();
|
|
700
|
+
await Promise.all(pendingDeclared);
|
|
701
|
+
pendingDeclared.length = 0;
|
|
702
|
+
const selection = select(ensureOptions?.only);
|
|
703
|
+
const entries = [];
|
|
704
|
+
for (const invalid of selection.invalid) {
|
|
705
|
+
const separator = invalid.indexOf(':');
|
|
706
|
+
const entry = {
|
|
707
|
+
plugin: separator > 0 ? invalid.slice(0, separator) : '',
|
|
708
|
+
id: invalid,
|
|
709
|
+
key: invalid,
|
|
710
|
+
action: 'skipped',
|
|
711
|
+
source: 'managed',
|
|
712
|
+
ms: 0,
|
|
713
|
+
code: 'invalid-option',
|
|
714
|
+
reason: 'only 指向未声明的 item id',
|
|
715
|
+
};
|
|
716
|
+
entries.push(entry);
|
|
717
|
+
notifySettled(ensureOptions, entry);
|
|
718
|
+
}
|
|
719
|
+
const background = [];
|
|
720
|
+
// `background` items are dispatched, not waited for.
|
|
721
|
+
const blocking = new Set();
|
|
722
|
+
for (const declared of selection.ordered) {
|
|
723
|
+
if (declared.item.startup !== 'background')
|
|
724
|
+
blocking.add(declared.item.id);
|
|
725
|
+
}
|
|
726
|
+
let grew = true;
|
|
727
|
+
while (grew) {
|
|
728
|
+
grew = false;
|
|
729
|
+
for (const declared of selection.ordered) {
|
|
730
|
+
if (!blocking.has(declared.item.id))
|
|
731
|
+
continue;
|
|
732
|
+
for (const need of declared.item.needs ?? []) {
|
|
733
|
+
if (items.has(need) && !blocking.has(need)) {
|
|
734
|
+
blocking.add(need);
|
|
735
|
+
grew = true;
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
// One budget for the whole startup critical path; a caller may override it for this call.
|
|
741
|
+
const deadline = clock() + Math.max(0, ensureOptions?.deadlineMs ?? policy.deadlineMs ?? DEFAULT_DEADLINE_MS);
|
|
742
|
+
let settled = 0;
|
|
743
|
+
for (const declared of selection.ordered) {
|
|
744
|
+
const promise = ensureOneDeduped(declared, ensureOptions?.signal, ensureOptions?.offline === true, ensureOptions?.onProgress);
|
|
745
|
+
if (!blocking.has(declared.item.id)) {
|
|
746
|
+
// Dispatched, not awaited: mark it `pending` and spend no budget on it.
|
|
747
|
+
putPending(declared);
|
|
748
|
+
background.push(promise);
|
|
749
|
+
continue;
|
|
750
|
+
}
|
|
751
|
+
let done = false;
|
|
752
|
+
const tracked = promise.then(entry => {
|
|
753
|
+
done = true;
|
|
754
|
+
return entry;
|
|
755
|
+
}, (error) => {
|
|
756
|
+
done = true;
|
|
757
|
+
throw error;
|
|
758
|
+
});
|
|
759
|
+
const remaining = deadline - clock();
|
|
760
|
+
const entry = remaining <= 0 ? undefined : await Promise.race([tracked, delay(remaining)]);
|
|
761
|
+
if (entry === undefined) {
|
|
762
|
+
// Only a genuinely unfinished item becomes `pending`.
|
|
763
|
+
if (!done)
|
|
764
|
+
putPending(declared);
|
|
765
|
+
background.push(tracked);
|
|
766
|
+
continue;
|
|
767
|
+
}
|
|
768
|
+
entries.push(entry);
|
|
769
|
+
notifySettled(ensureOptions, entry);
|
|
770
|
+
settled += 1;
|
|
771
|
+
}
|
|
772
|
+
// One read-modify-write for the whole batch.
|
|
773
|
+
if (settled > 0 && readOnly === undefined)
|
|
774
|
+
await persistStatus(fs, lock, home, statusRows(), logger);
|
|
775
|
+
return { entries, background, invalid: selection.invalid };
|
|
776
|
+
};
|
|
777
|
+
/** Whether a recorded version still fits this item's range. */
|
|
778
|
+
const recordSatisfies = (declared, version) => {
|
|
779
|
+
const range = declared.identity?.range;
|
|
780
|
+
if (range === undefined)
|
|
781
|
+
return true;
|
|
782
|
+
// A recorded value that is not a semver cannot be judged, so the record is kept.
|
|
783
|
+
if (parseVersion(version) === undefined)
|
|
784
|
+
return true;
|
|
785
|
+
try {
|
|
786
|
+
return satisfiesRange(version, range);
|
|
787
|
+
}
|
|
788
|
+
catch {
|
|
789
|
+
// An out-of-subset range cannot be judged: keep the record rather than guessing.
|
|
790
|
+
return true;
|
|
791
|
+
}
|
|
792
|
+
};
|
|
793
|
+
/** Hand one settled item to the caller's callback; a throwing callback is logged, not fatal. */
|
|
794
|
+
const notifySettled = (options, entry) => {
|
|
795
|
+
const callback = options?.onSettled;
|
|
796
|
+
if (callback === undefined)
|
|
797
|
+
return;
|
|
798
|
+
try {
|
|
799
|
+
callback(entry);
|
|
800
|
+
}
|
|
801
|
+
catch (error) {
|
|
802
|
+
logger.warn(`an onSettled callback threw: ${reasonOf(error).message}`);
|
|
803
|
+
}
|
|
804
|
+
};
|
|
805
|
+
/** Persisted resolution records by key; newest `updated_at` wins. */
|
|
806
|
+
const readRecords = async () => {
|
|
807
|
+
const map = new Map();
|
|
808
|
+
try {
|
|
809
|
+
const read = await readStatus(fs, home);
|
|
810
|
+
if (read.kind !== 'ok')
|
|
811
|
+
return map;
|
|
812
|
+
for (const row of read.rows) {
|
|
813
|
+
if (row.version === '')
|
|
814
|
+
continue;
|
|
815
|
+
const previous = map.get(row.key);
|
|
816
|
+
if (previous !== undefined && previous.updatedAt >= row.updated_at)
|
|
817
|
+
continue;
|
|
818
|
+
map.set(row.key, {
|
|
819
|
+
version: row.version,
|
|
820
|
+
...(row.source === undefined ? {} : { source: row.source }),
|
|
821
|
+
updatedAt: row.updated_at,
|
|
822
|
+
});
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
catch {
|
|
826
|
+
// No readable records is not an error; `plan()` falls back to probing.
|
|
827
|
+
}
|
|
828
|
+
return map;
|
|
829
|
+
};
|
|
830
|
+
const plan = async (planOptions) => {
|
|
831
|
+
await checkHome();
|
|
832
|
+
await Promise.all(pendingDeclared);
|
|
833
|
+
const selection = select(planOptions?.only);
|
|
834
|
+
const entries = [];
|
|
835
|
+
for (const invalid of selection.invalid)
|
|
836
|
+
entries.push({ key: invalid, action: 'unknown' });
|
|
837
|
+
// With a resolution record the plan reuses its version with no network.
|
|
838
|
+
const records = await readRecords();
|
|
839
|
+
for (const declared of selection.ordered) {
|
|
840
|
+
if (unsupported !== undefined) {
|
|
841
|
+
entries.push({ key: declared.key, action: 'skip' });
|
|
842
|
+
continue;
|
|
843
|
+
}
|
|
844
|
+
const found = lookup(declared.item.kind);
|
|
845
|
+
if (!('provider' in found)) {
|
|
846
|
+
entries.push({ key: declared.key, action: 'unknown' });
|
|
847
|
+
continue;
|
|
848
|
+
}
|
|
849
|
+
let probed;
|
|
850
|
+
try {
|
|
851
|
+
probed = await found.provider.probe(declared.item, contextOf(undefined, declared.item));
|
|
852
|
+
}
|
|
853
|
+
catch {
|
|
854
|
+
probed = undefined; // a probe that cannot answer is "unknown", not a failed plan
|
|
855
|
+
}
|
|
856
|
+
if (probed?.found === true) {
|
|
857
|
+
entries.push({
|
|
858
|
+
key: declared.key,
|
|
859
|
+
action: 'present',
|
|
860
|
+
...(probed.version === undefined ? {} : { version: probed.version }),
|
|
861
|
+
source: probed.source,
|
|
862
|
+
});
|
|
863
|
+
continue;
|
|
864
|
+
}
|
|
865
|
+
// The provider's pure plan; consulted only when the record cannot answer.
|
|
866
|
+
let planned;
|
|
867
|
+
try {
|
|
868
|
+
planned = await found.provider.plan(declared.item, contextOf(undefined, declared.item));
|
|
869
|
+
}
|
|
870
|
+
catch {
|
|
871
|
+
planned = undefined; // a provider that cannot plan does not fail the plan
|
|
872
|
+
}
|
|
873
|
+
const plannedUrls = planned?.urls === undefined || planned.urls.length === 0 ? undefined : planned.urls;
|
|
874
|
+
const record = records.get(declared.key);
|
|
875
|
+
// A record is filtered by this item's range before it is reused.
|
|
876
|
+
if (record !== undefined && recordSatisfies(declared, record.version)) {
|
|
877
|
+
entries.push({
|
|
878
|
+
key: declared.key,
|
|
879
|
+
action: 'install',
|
|
880
|
+
version: record.version,
|
|
881
|
+
...(record.source === undefined ? {} : { source: record.source }),
|
|
882
|
+
...(plannedUrls === undefined ? {} : { urls: plannedUrls }),
|
|
883
|
+
});
|
|
884
|
+
continue;
|
|
885
|
+
}
|
|
886
|
+
// Only an explicit `install` counts as an answer; anything else leaves the entry `unknown`.
|
|
887
|
+
if (planned?.action === 'install' && planned.version !== undefined) {
|
|
888
|
+
entries.push({
|
|
889
|
+
key: declared.key,
|
|
890
|
+
action: 'install',
|
|
891
|
+
version: planned.version,
|
|
892
|
+
...(plannedUrls === undefined ? {} : { urls: plannedUrls }),
|
|
893
|
+
});
|
|
894
|
+
continue;
|
|
895
|
+
}
|
|
896
|
+
entries.push({ key: declared.key, action: 'unknown' });
|
|
897
|
+
}
|
|
898
|
+
return { entries, offline: planOptions?.offline === true };
|
|
899
|
+
};
|
|
900
|
+
const ensure = async (ensureOptions) => {
|
|
901
|
+
const { entries, background } = await runEnsure(ensureOptions);
|
|
902
|
+
for (const entry of entries)
|
|
903
|
+
emit({ type: 'report', key: entry.key, entry });
|
|
904
|
+
// Late items report out of band; `resolve()` flips to ready as their `onSettled` fires.
|
|
905
|
+
for (const promise of background) {
|
|
906
|
+
void promise
|
|
907
|
+
.then(async (entry) => {
|
|
908
|
+
emit({ type: 'report', key: entry.key, entry });
|
|
909
|
+
notifySettled(ensureOptions, entry);
|
|
910
|
+
if (readOnly === undefined)
|
|
911
|
+
await persistStatus(fs, lock, home, statusRows(), logger);
|
|
912
|
+
})
|
|
913
|
+
.catch((error) => {
|
|
914
|
+
logger.warn(`后台预装失败:${reasonOf(error).message}`);
|
|
915
|
+
});
|
|
916
|
+
}
|
|
917
|
+
return { entries, ok: entries.every(entry => entry.action !== 'failed') };
|
|
918
|
+
};
|
|
919
|
+
const resolve = (itemId) => {
|
|
920
|
+
if (!items.has(itemId))
|
|
921
|
+
return { state: 'missing' };
|
|
922
|
+
const key = itemRows.get(itemId);
|
|
923
|
+
if (key === undefined)
|
|
924
|
+
return { state: 'missing' };
|
|
925
|
+
const row = rows.get(key);
|
|
926
|
+
return row?.state ?? { state: 'missing' };
|
|
927
|
+
};
|
|
928
|
+
const status = () => [...rows.values()].sort((a, b) => (a.key === b.key ? a.version.localeCompare(b.version) : a.key.localeCompare(b.key)));
|
|
929
|
+
const repair = async (itemId) => {
|
|
930
|
+
await checkHome();
|
|
931
|
+
await Promise.all(pendingDeclared);
|
|
932
|
+
pendingDeclared.length = 0;
|
|
933
|
+
const declared = items.get(itemId);
|
|
934
|
+
if (declared === undefined) {
|
|
935
|
+
return { entries: [], ok: false };
|
|
936
|
+
}
|
|
937
|
+
const entry = await ensureOneDeduped(declared);
|
|
938
|
+
emit({ type: 'report', key: entry.key, entry });
|
|
939
|
+
if (readOnly === undefined)
|
|
940
|
+
await persistStatus(fs, lock, home, statusRows(), logger);
|
|
941
|
+
return { entries: [entry], ok: entry.action !== 'failed' };
|
|
942
|
+
};
|
|
943
|
+
const experimental = () => ({
|
|
944
|
+
// `prune` only warns and makes no changes.
|
|
945
|
+
prune: () => {
|
|
946
|
+
logger.warn('prune: 回收(prune/GC/配额)尚未实现,本次未做任何事');
|
|
947
|
+
return Promise.resolve({ moved: [], skipped: [], movedBytes: 0 });
|
|
948
|
+
},
|
|
949
|
+
on: (event, listener) => {
|
|
950
|
+
const entry = { event, listener };
|
|
951
|
+
listeners.add(entry);
|
|
952
|
+
return { dispose: () => listeners.delete(entry) };
|
|
953
|
+
},
|
|
954
|
+
});
|
|
955
|
+
/** Abort everything in flight and drop listeners; the instance is not reused afterwards. */
|
|
956
|
+
const dispose = () => {
|
|
957
|
+
shutdown.abort();
|
|
958
|
+
listeners.clear();
|
|
959
|
+
};
|
|
960
|
+
return { register, declare, plan, ensure, resolve, status, repair, experimental, dispose };
|
|
961
|
+
}
|
|
962
|
+
//# sourceMappingURL=provisioner.js.map
|