@khorsheed/dsh-local-agent-dsh 0.2.0-rc.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -0
- package/README.en.md +23 -136
- package/README.i18n.yaml +2 -2
- package/README.md +23 -136
- package/lib/client.js +155 -83
- package/lib/{dsh-cli-provider-UhWvivte.js → dsh-cli-provider-B7s3zH0m.js} +108 -27
- package/lib/dsh-cli-provider-DYRuRpVz.js +1920 -0
- package/lib/dsh-cli-provider.js +1 -1
- package/lib/index.js +40 -27
- package/lib/provision.js +327 -152
- package/lib/types/client/SettingsCard.d.ts +41 -13
- package/lib/types/client/SettingsCard.js +22 -6
- package/lib/types/client/icons.d.ts +1 -1
- package/lib/types/client/index.d.ts +5 -27
- package/lib/types/client/index.js +87 -44
- package/lib/types/client/locales.js +2 -2
- package/lib/types/dsh-cli-provider.js +82 -47
- package/lib/types/index.d.ts +9 -1
- package/lib/types/index.js +35 -21
- package/lib/types/live-driver.js +72 -54
- package/lib/types/model-catalog.d.ts +1 -0
- package/lib/types/model-catalog.js +9 -4
- package/lib/types/model-launch.d.ts +14 -0
- package/lib/types/model-launch.js +58 -0
- package/lib/types/provision.d.ts +166 -119
- package/lib/types/provision.js +376 -166
- package/lib/types/session-mirror.d.ts +55 -0
- package/lib/types/session-mirror.js +155 -1
- package/locale/en.json +2 -2
- package/locale/zh.json +2 -2
- package/package.json +27 -25
- package/lib/invariant.js +0 -33
- package/lib/types/invariant.d.ts +0 -16
- package/lib/types/invariant.js +0 -37
package/lib/types/provision.js
CHANGED
|
@@ -5,29 +5,38 @@
|
|
|
5
5
|
* `<scoped home>/profiles/<name>` — a manifest listing the official
|
|
6
6
|
* `@deepseek-ai/dsh-base` layer, the family headless composition as the
|
|
7
7
|
* profile's own patch layer (copied verbatim from the headless bundle's
|
|
8
|
-
* `cordis.patch.yml`), and
|
|
9
|
-
*
|
|
10
|
-
*
|
|
8
|
+
* `cordis.patch.yml`), and `@khorsheed/dsh-local-agent-dsh-headless` in
|
|
9
|
+
* the sub-profile's node_modules (the loader resolves the patch's insert
|
|
10
|
+
* rows through it) — a copy of the bundle for the host profile, a symlink
|
|
11
|
+
* for the `-unit` twin and for the pinned single-profile form (see
|
|
12
|
+
* `HeadlessBundleMount`).
|
|
11
13
|
* Everything else the boot needs (dsh-base and its whole dependency graph,
|
|
12
14
|
* cordis, the healed `profiles/node_modules` fallback) is resolved from the
|
|
13
15
|
* dsh installation anchor automatically, so provisioning costs no pnpm
|
|
14
16
|
* install.
|
|
15
17
|
*
|
|
16
|
-
* A sub-profile may additionally
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
18
|
+
* A sub-profile may additionally compose an AGENT PRESET (host 0.2.0's
|
|
19
|
+
* declarative form): the bundle package that declares the preset — an
|
|
20
|
+
* `@deepseek-ai/dsh-agent-preset` row in its own bundle patch — joins the
|
|
21
|
+
* manifest's bundle list, and one appended patch layer inserts
|
|
22
|
+
* `@deepseek-ai/dsh-agent-preset-registry` with that preset as its
|
|
23
|
+
* `default`. Without it the sub-dsh's model-facing rows come from the host
|
|
24
|
+
* plane and the agent reads them off the global layer — which is why "the
|
|
25
|
+
* same harness under two presets" was not a thing a condition could ask for.
|
|
26
|
+
* With it, the scope directory IS the capability face: two scopes naming two
|
|
27
|
+
* presets are two subjects, and the difference is files a person can read.
|
|
23
28
|
*
|
|
24
|
-
* Where the preset
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
29
|
+
* Where the preset PACKAGE lives is the scope's business too. The scope keeps
|
|
30
|
+
* its own copy at `<scoped home>/preset-packages/<id>` and the sub-profile
|
|
31
|
+
* resolves it through a RELATIVE symlink in its own `node_modules`, so the
|
|
32
|
+
* package is found identically on the host and inside an evaluation unit
|
|
33
|
+
* (which bind-mounts the scope, and nothing else). The copy comes from the
|
|
34
|
+
* evaluation instance's own installation of the same package, which is also
|
|
35
|
+
* what the instance's capability catalog measures. `provisionDshScope` below
|
|
36
|
+
* is the entry that makes the copy, records what the scope composes in a
|
|
37
|
+
* file of its own, and composes it. (0.1.x wrote a roster row for
|
|
38
|
+
* `@deepseek-ai/dsh-agent-presets` and a `<scoped home>/.agent-presets/<id>`
|
|
39
|
+
* directory instead; 0.2.0 ships neither the roster nor the directory scan.)
|
|
31
40
|
*
|
|
32
41
|
* It may also carry a PERMISSION BOUNDARY: one appended patch layer pinning
|
|
33
42
|
* `sandbox-policy`'s mode and `user-approval`'s policy to one of dsh's own
|
|
@@ -36,7 +45,7 @@
|
|
|
36
45
|
* sub-dsh on a developer's machine and the wrong one inside an evaluation
|
|
37
46
|
* unit, where the container IS the boundary and nobody is there to answer an
|
|
38
47
|
* approval prompt. The layer is a FILE in the scope directory for the same
|
|
39
|
-
* reason the
|
|
48
|
+
* reason the preset layer is: a scoped home bind-mounted into a unit carries its
|
|
40
49
|
* own boundary with it, and the value never has to ride the spawn env (where
|
|
41
50
|
* every name enters the unit's composite fingerprint) or be baked into an
|
|
42
51
|
* image.
|
|
@@ -51,9 +60,8 @@
|
|
|
51
60
|
* @module @khorsheed/dsh-local-agent-dsh/provision
|
|
52
61
|
*/
|
|
53
62
|
import { createRequire } from 'node:module';
|
|
54
|
-
import { copyFileSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
|
55
|
-
import {
|
|
56
|
-
import { dirname, isAbsolute, join } from 'node:path';
|
|
63
|
+
import { copyFileSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
|
64
|
+
import { dirname, isAbsolute, join, relative } from 'node:path';
|
|
57
65
|
import yaml from 'js-yaml';
|
|
58
66
|
/** Default name of the sub-dsh profile under the scoped home. */
|
|
59
67
|
export const DEFAULT_SUB_PROFILE_NAME = 'headless-local-agent-dsh';
|
|
@@ -73,28 +81,31 @@ const APPROVAL_FOR = {
|
|
|
73
81
|
'workspace-write': 'ask',
|
|
74
82
|
'danger-full-access': 'never',
|
|
75
83
|
};
|
|
76
|
-
/** The
|
|
77
|
-
const
|
|
84
|
+
/** The registry row's id inside the sub-profile patch. */
|
|
85
|
+
const PRESET_REGISTRY_ROW_ID = 'agent-preset-registry';
|
|
78
86
|
/**
|
|
79
|
-
* The
|
|
87
|
+
* The registry package the registry row names.
|
|
80
88
|
*
|
|
81
89
|
* Deliberately NOT symlinked into the sub-profile the way the headless bundle
|
|
82
90
|
* is. The headless bundle is a `@khorsheed` package the dsh installation does
|
|
83
|
-
* not carry, so it must be linked in from the plugin installation; the
|
|
84
|
-
* is an OFFICIAL package, already in the installation anchor's
|
|
85
|
-
* `@deepseek-ai/dsh-base`. Linking a second copy would give it
|
|
86
|
-
* `@deepseek-ai/cordis` — and cordis does service lookup and type
|
|
87
|
-
* instance identity, so the symptom would be silently missing
|
|
88
|
-
* than an error
|
|
89
|
-
*
|
|
90
|
-
* roster gets the loader's own "cannot resolve" message, which names the
|
|
91
|
+
* not carry, so it must be linked in from the plugin installation; the
|
|
92
|
+
* registry is an OFFICIAL package, already in the installation anchor's
|
|
93
|
+
* closure beside `@deepseek-ai/dsh-base`. Linking a second copy would give it
|
|
94
|
+
* a second `@deepseek-ai/cordis` — and cordis does service lookup and type
|
|
95
|
+
* checks by instance identity, so the symptom would be silently missing
|
|
96
|
+
* services rather than an error. A deployment whose anchor genuinely lacks the
|
|
97
|
+
* registry gets the loader's own "cannot resolve" message, which names the
|
|
91
98
|
* module better than provisioning could.
|
|
92
99
|
*/
|
|
93
|
-
export const
|
|
94
|
-
/**
|
|
100
|
+
export const PRESET_REGISTRY_MODULE = '@deepseek-ai/dsh-agent-preset-registry';
|
|
101
|
+
/** The declarative preset row a preset bundle package carries in its patch. */
|
|
102
|
+
export const PRESET_ROW_MODULE = '@deepseek-ai/dsh-agent-preset';
|
|
103
|
+
/** A preset id doubles as a directory name inside the scope. */
|
|
95
104
|
export const SUB_PROFILE_PRESET_ID_RE = /^[a-z0-9][a-z0-9-]*$/;
|
|
96
|
-
/**
|
|
97
|
-
const
|
|
105
|
+
/** An npm package name the sub-profile manifest may list as a bundle. */
|
|
106
|
+
const PACKAGE_NAME_RE = /^(?:@[a-z0-9][a-z0-9._~-]*\/)?[a-z0-9][a-z0-9._~-]*$/;
|
|
107
|
+
/** Header line that opens the generated preset layer (the parse anchor). */
|
|
108
|
+
const PRESET_BLOCK_HEADER = '# --- agent preset (written by local-agent-dsh provisioning) ---';
|
|
98
109
|
/** Header line that opens the generated permission layer (the parse anchor). */
|
|
99
110
|
const PERMISSION_BLOCK_HEADER = '# --- permission boundary (written by local-agent-dsh provisioning) ---';
|
|
100
111
|
/** The sub-profile's bundle layer list: the official base layer. The headless
|
|
@@ -103,12 +114,13 @@ const SUB_PROFILE_BUNDLES = [
|
|
|
103
114
|
'@deepseek-ai/dsh-base',
|
|
104
115
|
];
|
|
105
116
|
/** The sub-profile manifest the boot reads. */
|
|
106
|
-
function subProfileManifest(profileName) {
|
|
117
|
+
function subProfileManifest(profileName, presetPackage) {
|
|
118
|
+
const bundles = presetPackage === undefined ? [...SUB_PROFILE_BUNDLES] : [...SUB_PROFILE_BUNDLES, presetPackage];
|
|
107
119
|
return JSON.stringify({
|
|
108
120
|
name: `dsh-profile-${profileName}`,
|
|
109
121
|
private: true,
|
|
110
122
|
dependencies: {},
|
|
111
|
-
dsh: { profile: { bundles
|
|
123
|
+
dsh: { profile: { bundles } },
|
|
112
124
|
}, undefined, 2) + '\n';
|
|
113
125
|
}
|
|
114
126
|
/**
|
|
@@ -125,22 +137,28 @@ export function resolveHeadlessBundleDir(config = {}) {
|
|
|
125
137
|
const manifestPath = require.resolve('@khorsheed/dsh-local-agent-dsh-headless/package.json');
|
|
126
138
|
return dirname(manifestPath);
|
|
127
139
|
}
|
|
128
|
-
/** YAML-quote a scalar the
|
|
140
|
+
/** YAML-quote a scalar the generated layers carry (ids are operator input). */
|
|
129
141
|
function yamlString(value) {
|
|
130
142
|
return JSON.stringify(value);
|
|
131
143
|
}
|
|
132
144
|
/**
|
|
133
|
-
* The
|
|
145
|
+
* The preset layer appended to the sub-profile's patch, or `''` when the
|
|
134
146
|
* sub-profile composes no preset.
|
|
135
147
|
*
|
|
148
|
+
* One insert of the 0.2.0 preset registry with the preset as its `default`:
|
|
149
|
+
* every sub-dsh session that names no preset joins this one (the headless
|
|
150
|
+
* agent loader mounts it through the registry's `mount`). The preset's own
|
|
151
|
+
* declaration rides the bundle layer — the package that declares it is in
|
|
152
|
+
* the manifest's bundle list — so this layer never repeats the composition.
|
|
153
|
+
*
|
|
136
154
|
* It is a SEPARATE patch operation appended after the headless bundle's own
|
|
137
155
|
* list, which is what keeps the two independent: an upgraded headless bundle
|
|
138
|
-
* rewrites its half and the
|
|
156
|
+
* rewrites its half and the preset survives, and dropping the preset drops
|
|
139
157
|
* exactly these lines.
|
|
140
|
-
* @param preset - the
|
|
158
|
+
* @param preset - the preset to compose, or undefined for none.
|
|
141
159
|
* @returns the YAML text to append (already newline-terminated), or `''`.
|
|
142
160
|
*/
|
|
143
|
-
export function
|
|
161
|
+
export function presetRegistryLayer(preset) {
|
|
144
162
|
if (preset === undefined)
|
|
145
163
|
return '';
|
|
146
164
|
if (!SUB_PROFILE_PRESET_ID_RE.test(preset.id)) {
|
|
@@ -150,36 +168,24 @@ export function presetRosterLayer(preset) {
|
|
|
150
168
|
'',
|
|
151
169
|
PRESET_BLOCK_HEADER,
|
|
152
170
|
'# The sub-dsh composes every session from this preset; the headless agent',
|
|
153
|
-
'# loader
|
|
154
|
-
'#
|
|
171
|
+
'# loader mounts it through the registry. Remove this layer and the agent',
|
|
172
|
+
'# falls back to the global layer.',
|
|
155
173
|
'- insert:',
|
|
156
|
-
` - id: ${
|
|
157
|
-
` name: '${
|
|
174
|
+
` - id: ${PRESET_REGISTRY_ROW_ID}`,
|
|
175
|
+
` name: '${PRESET_REGISTRY_MODULE}'`,
|
|
158
176
|
' config:',
|
|
159
177
|
` default: ${yamlString(preset.id)}`,
|
|
160
178
|
];
|
|
161
|
-
if (preset.includeShippedRoot !== undefined)
|
|
162
|
-
lines.push(` includeShippedRoot: ${String(preset.includeShippedRoot)}`);
|
|
163
|
-
if (preset.includeUserRoot !== undefined)
|
|
164
|
-
lines.push(` includeUserRoot: ${String(preset.includeUserRoot)}`);
|
|
165
|
-
if (preset.roots !== undefined && preset.roots.length > 0) {
|
|
166
|
-
lines.push(' roots:');
|
|
167
|
-
for (const root of preset.roots) {
|
|
168
|
-
lines.push(` - path: ${yamlString(root.path)}`);
|
|
169
|
-
if (root.trust !== undefined)
|
|
170
|
-
lines.push(` trust: ${root.trust}`);
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
179
|
return `${lines.join('\n')}\n`;
|
|
174
180
|
}
|
|
175
181
|
/**
|
|
176
182
|
* The preset id a provisioned sub-profile's patch composes, read back from
|
|
177
|
-
* the file. The
|
|
183
|
+
* the file. The preset layer is generated, so it is also parseable: a lock
|
|
178
184
|
* check or a status surface can answer "which preset does this scope run?"
|
|
179
185
|
* without booting the sub-dsh.
|
|
180
186
|
* @param homeDir - the harness scoped home.
|
|
181
187
|
* @param profileName - the sub-profile name (default {@link DEFAULT_SUB_PROFILE_NAME}).
|
|
182
|
-
* @returns the preset id, or undefined when the sub-profile composes no
|
|
188
|
+
* @returns the preset id, or undefined when the sub-profile composes no preset.
|
|
183
189
|
*/
|
|
184
190
|
export function readSubProfilePreset(homeDir, profileName = DEFAULT_SUB_PROFILE_NAME) {
|
|
185
191
|
const patchPath = join(homeDir, 'profiles', profileName, PROFILE_PATCH_FILENAME);
|
|
@@ -294,64 +300,140 @@ function ensureSymlink(link, target) {
|
|
|
294
300
|
rmSync(link, { force: true });
|
|
295
301
|
symlinkSync(target, link);
|
|
296
302
|
}
|
|
303
|
+
/** Suffix of the evaluation-unit twin of a sub-profile (see `unitHeadlessBundleDir`). */
|
|
304
|
+
export const UNIT_PROFILE_SUFFIX = '-unit';
|
|
305
|
+
/**
|
|
306
|
+
* The profile name an evaluation UNIT launches for this config: the
|
|
307
|
+
* `-unit` twin when one is configured, else the one sub-profile.
|
|
308
|
+
* @param config - provisioning config.
|
|
309
|
+
* @returns the profile name a container round passes to `--profile`.
|
|
310
|
+
*/
|
|
311
|
+
export function unitSubProfileName(config = {}) {
|
|
312
|
+
const profileName = config.profileName ?? DEFAULT_SUB_PROFILE_NAME;
|
|
313
|
+
return config.unitHeadlessBundleDir === undefined ? profileName : `${profileName}${UNIT_PROFILE_SUFFIX}`;
|
|
314
|
+
}
|
|
315
|
+
/** Write one file only when its content differs. */
|
|
316
|
+
function writeIfChanged(path, content) {
|
|
317
|
+
if (!existsSync(path) || readFileSync(path, 'utf8') !== content)
|
|
318
|
+
writeFileSync(path, content);
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* Make `target` a byte-for-byte copy of the headless bundle `source`. A
|
|
322
|
+
* symlink left by an earlier scheme is replaced; an identical copy is left
|
|
323
|
+
* untouched, so re-provisioning before every round costs one comparison.
|
|
324
|
+
*/
|
|
325
|
+
function ensureBundleCopy(target, source) {
|
|
326
|
+
let stat;
|
|
327
|
+
try {
|
|
328
|
+
stat = lstatSync(target);
|
|
329
|
+
}
|
|
330
|
+
catch (error) {
|
|
331
|
+
if (error.code !== 'ENOENT')
|
|
332
|
+
throw error;
|
|
333
|
+
}
|
|
334
|
+
if (stat?.isSymbolicLink() === true)
|
|
335
|
+
rmSync(target, { force: true });
|
|
336
|
+
else if (stat !== undefined && presetTreesEqual(source, target))
|
|
337
|
+
return;
|
|
338
|
+
if (presetTreeFiles(source) === undefined) {
|
|
339
|
+
throw new Error(`provision-dsh: headless bundle ${source} is missing or not a plain tree of files`);
|
|
340
|
+
}
|
|
341
|
+
mirrorPresetTree(source, target);
|
|
342
|
+
}
|
|
343
|
+
/** Provision one sub-profile directory against one headless bundle directory. */
|
|
344
|
+
function provisionOneSubProfile(homeDir, profileName, bundleDir, patchSourceDir, config, mount) {
|
|
345
|
+
const profileDir = join(homeDir, 'profiles', profileName);
|
|
346
|
+
mkdirSync(profileDir, { recursive: true });
|
|
347
|
+
const presetPackage = config.preset?.package;
|
|
348
|
+
if (presetPackage !== undefined && !PACKAGE_NAME_RE.test(presetPackage)) {
|
|
349
|
+
throw new Error(`provision-dsh: preset package name ${JSON.stringify(presetPackage)} is not an npm package name`);
|
|
350
|
+
}
|
|
351
|
+
writeIfChanged(join(profileDir, 'package.json'), subProfileManifest(profileName, presetPackage));
|
|
352
|
+
writeIfChanged(join(profileDir, PROFILE_PATCH_FILENAME), readFileSync(join(patchSourceDir, HEADLESS_PATCH_FILENAME), 'utf8')
|
|
353
|
+
+ presetRegistryLayer(config.preset)
|
|
354
|
+
+ permissionBoundaryLayer(config.permissions)
|
|
355
|
+
+ (config.inheritModelConfiguration ? '\n# Selected local model; values and credentials stay in the launch environment.\n'
|
|
356
|
+
+ '- id: llm-pi-ai\n config:\n providers: !!js JSON.parse(process.env.DSH_LOCAL_AGENT_MODEL_CONFIG || "{}").piAi?.providers ?? {}\n'
|
|
357
|
+
+ '- id: llm-deepseek\n config: !!js JSON.parse(process.env.DSH_LOCAL_AGENT_MODEL_CONFIG || "{}").deepseek ?? {}\n' : ''));
|
|
358
|
+
const bundleLink = join(profileDir, 'node_modules', '@khorsheed', 'dsh-local-agent-dsh-headless');
|
|
359
|
+
mkdirSync(dirname(bundleLink), { recursive: true });
|
|
360
|
+
if (mount === 'copy')
|
|
361
|
+
ensureBundleCopy(bundleLink, bundleDir);
|
|
362
|
+
else
|
|
363
|
+
ensureSymlink(bundleLink, bundleDir);
|
|
364
|
+
if (presetPackage !== undefined && config.preset !== undefined) {
|
|
365
|
+
// RELATIVE, so the link resolves the same on the host and inside a unit
|
|
366
|
+
// that bind-mounts the scope at a different absolute path.
|
|
367
|
+
const presetLink = join(profileDir, 'node_modules', ...presetPackage.split('/'));
|
|
368
|
+
mkdirSync(dirname(presetLink), { recursive: true });
|
|
369
|
+
ensureSymlink(presetLink, relative(dirname(presetLink), scopePresetPackageDir(homeDir, config.preset.id)));
|
|
370
|
+
}
|
|
371
|
+
return profileDir;
|
|
372
|
+
}
|
|
297
373
|
/**
|
|
298
374
|
* Provision (idempotently) the sub-dsh headless profile in the harness
|
|
299
375
|
* scoped home: manifest, the headless patch as the profile's own patch
|
|
300
|
-
* layer, and the bundle symlink
|
|
301
|
-
*
|
|
302
|
-
*
|
|
303
|
-
*
|
|
304
|
-
*
|
|
376
|
+
* layer, and the headless bundle (a copy; a symlink in the `-unit` twin or under a lone `headlessBundleDir` pin) — plus, when a preset package is composed,
|
|
377
|
+
* the relative link resolving it from the scope's own copy, and, when
|
|
378
|
+
* `unitHeadlessBundleDir` is configured, the `-unit` twin a container round
|
|
379
|
+
* launches. A file whose content already matches is left untouched, so
|
|
380
|
+
* re-running after a restart is a no-op; a drifted file (an upgraded headless
|
|
381
|
+
* bundle, or a profile provisioned by an earlier scheme) is rewritten, which
|
|
382
|
+
* heals it before the sub-dsh boots.
|
|
305
383
|
* @param homeDir - the `dsh` harness's scoped home (its `$DSH_HOME` root).
|
|
306
384
|
* @param config - profile naming and bundle-dir overrides.
|
|
307
|
-
* @returns the absolute sub-profile directory.
|
|
385
|
+
* @returns the absolute (host) sub-profile directory.
|
|
308
386
|
*/
|
|
309
387
|
export function provisionDshSubProfile(homeDir, config = {}) {
|
|
310
388
|
const profileName = config.profileName ?? DEFAULT_SUB_PROFILE_NAME;
|
|
311
|
-
const
|
|
312
|
-
const
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
writeFileSync(patchPath, patchContent);
|
|
389
|
+
const hostBundleDir = resolveHeadlessBundleDir(config);
|
|
390
|
+
const unitDir = config.unitHeadlessBundleDir;
|
|
391
|
+
// The pinned single-profile form (a `headlessBundleDir` shared by host and
|
|
392
|
+
// unit, no twin) keeps its link: the pin's whole point is one path both
|
|
393
|
+
// filesystems resolve, and that form predates 0.2.0's linked-root routing.
|
|
394
|
+
const hostMount = config.headlessBundleDir !== undefined && unitDir === undefined ? 'link' : 'copy';
|
|
395
|
+
const profileDir = provisionOneSubProfile(homeDir, profileName, hostBundleDir, hostBundleDir, config, hostMount);
|
|
396
|
+
if (unitDir !== undefined) {
|
|
397
|
+
// The twin's patch comes from the unit's bundle when this machine can
|
|
398
|
+
// read it (a host-side mirror of the image's install, as `/opt` is on a
|
|
399
|
+
// dataset machine), else from the host bundle — the link is what moves.
|
|
400
|
+
const patchSource = existsSync(join(unitDir, HEADLESS_PATCH_FILENAME)) ? unitDir : hostBundleDir;
|
|
401
|
+
provisionOneSubProfile(homeDir, unitSubProfileName(config), unitDir, patchSource, config, 'link');
|
|
325
402
|
}
|
|
326
|
-
const bundleLink = join(profileDir, 'node_modules', '@khorsheed', 'dsh-local-agent-dsh-headless');
|
|
327
|
-
mkdirSync(dirname(bundleLink), { recursive: true });
|
|
328
|
-
ensureSymlink(bundleLink, bundleDir);
|
|
329
403
|
return profileDir;
|
|
330
404
|
}
|
|
331
405
|
// ── the scope's own preset ───────────────────────────────────────────────
|
|
332
406
|
/**
|
|
333
|
-
*
|
|
334
|
-
*
|
|
407
|
+
* Where a scope keeps its own copy of each preset PACKAGE it composes:
|
|
408
|
+
* `<scoped home>/preset-packages/<id>`. (0.1.x used `.agent-presets/<id>`,
|
|
409
|
+
* the directory roster's derived user root; 0.2.0 has no directory scan.)
|
|
410
|
+
*/
|
|
411
|
+
export const SCOPE_PRESET_PACKAGES_DIR = 'preset-packages';
|
|
412
|
+
/**
|
|
413
|
+
* The scope's copy of one preset package.
|
|
414
|
+
* @param homeDir - the harness scoped home.
|
|
415
|
+
* @param id - the preset id.
|
|
416
|
+
* @returns the directory, whether or not it exists.
|
|
335
417
|
*/
|
|
336
|
-
export
|
|
337
|
-
|
|
338
|
-
|
|
418
|
+
export function scopePresetPackageDir(homeDir, id) {
|
|
419
|
+
return join(homeDir, SCOPE_PRESET_PACKAGES_DIR, id);
|
|
420
|
+
}
|
|
339
421
|
/**
|
|
340
422
|
* The scope's own declaration of what its sub-profile composes.
|
|
341
423
|
*
|
|
342
424
|
* A FILE in the scope directory, for the third time and the same reason as
|
|
343
|
-
* the
|
|
425
|
+
* the preset layer and the permission boundary: a scoped home bind-mounted
|
|
344
426
|
* into an evaluation unit carries its own composition with it, and nothing
|
|
345
427
|
* has to ride the spawn env or be baked into an image.
|
|
346
428
|
*
|
|
347
429
|
* It exists because the plugin's own `config` is instance-global — one
|
|
348
430
|
* deployment, one answer — while a preset is a property of the SUBJECT. Two
|
|
349
|
-
* scopes
|
|
350
|
-
* to build it was to hand-append the
|
|
431
|
+
* scopes composing two presets was therefore unexpressible, and the only way
|
|
432
|
+
* to build it was to hand-append the preset layer to each scope's patch. That
|
|
351
433
|
* hand edit does not survive: this module regenerates the patch WHOLE, and
|
|
352
434
|
* the registry re-provisions a scope the first time anything names it in a
|
|
353
|
-
* fresh host process — so the
|
|
354
|
-
* and the next read-back reports a scope that
|
|
435
|
+
* fresh host process — so the layer silently disappears on the next restart,
|
|
436
|
+
* and the next read-back reports a scope that composes nothing.
|
|
355
437
|
*/
|
|
356
438
|
export const SCOPE_SUB_PROFILE_FILENAME = 'sub-profile.json';
|
|
357
439
|
/**
|
|
@@ -416,32 +498,30 @@ const EXPRESSION_SCHEMA = yaml.DEFAULT_SCHEMA.extend(['scalar', 'sequence', 'map
|
|
|
416
498
|
/** A quoted absolute path inside an expression's source text. */
|
|
417
499
|
const QUOTED_ABSOLUTE_RE = /(['"])(\/[^'"\n]*)\1/g;
|
|
418
500
|
/**
|
|
419
|
-
* Every absolute filesystem path a preset
|
|
501
|
+
* Every absolute filesystem path a preset bundle patch names.
|
|
420
502
|
*
|
|
421
503
|
* An absolute path is the one thing in a composition that cannot be true in
|
|
422
504
|
* two places at once, and a preset used as an evaluation factor is copied:
|
|
423
|
-
* it is read on the host from the
|
|
505
|
+
* it is read on the host from the instance's installation, again from the
|
|
424
506
|
* scope's own copy, and a third time inside a unit where the scope is bound
|
|
425
507
|
* at a different mount point. `skill-filesystem` resolves each
|
|
426
508
|
* `customSkillDirs` entry with `resolve()`, so an absolute entry is taken as
|
|
427
509
|
* written and a bare relative one resolves against the process's cwd —
|
|
428
510
|
* neither travels.
|
|
429
511
|
*
|
|
430
|
-
* What does travel is the
|
|
431
|
-
* `
|
|
512
|
+
* What does travel is resolving the package's own directory through the
|
|
513
|
+
* loader's `baseUrl` (the declaring profile's root), the form the official
|
|
514
|
+
* web-app presets use:
|
|
432
515
|
*
|
|
433
516
|
* ```yaml
|
|
434
517
|
* customSkillDirs:
|
|
435
|
-
* - !!js "process.getBuiltinModule('node:
|
|
518
|
+
* - !!js "process.getBuiltinModule('node:path').join(process.getBuiltinModule('node:path').dirname(process.getBuiltinModule('node:module').createRequire(baseUrl).resolve('<pkg>/package.json')), 'skills')"
|
|
436
519
|
* ```
|
|
437
520
|
*
|
|
438
|
-
* `baseUrl` is the composition's own directory (the include rewrites it), so
|
|
439
|
-
* the root resolves wherever the preset is installed.
|
|
440
|
-
*
|
|
441
521
|
* Unparsable YAML reports NO findings: the loader is the authority on whether
|
|
442
522
|
* a composition loads, and refusing a preset because this reader could not
|
|
443
523
|
* parse it would be this module overruling it.
|
|
444
|
-
* @param text - the
|
|
524
|
+
* @param text - the bundle patch content.
|
|
445
525
|
* @returns the offending literals, in document order, deduplicated.
|
|
446
526
|
*/
|
|
447
527
|
export function compositionAbsolutePaths(text) {
|
|
@@ -485,7 +565,7 @@ export function compositionAbsolutePaths(text) {
|
|
|
485
565
|
walk(document);
|
|
486
566
|
return found;
|
|
487
567
|
}
|
|
488
|
-
/** Every regular file under `dir`, as posix-ish relative paths, sorted. */
|
|
568
|
+
/** Every regular file under `dir`, as posix-ish relative paths, sorted (a top-level `node_modules` is not part of a preset). */
|
|
489
569
|
function presetTreeFiles(dir) {
|
|
490
570
|
const files = [];
|
|
491
571
|
const walk = (absDir, rel) => {
|
|
@@ -497,6 +577,8 @@ function presetTreeFiles(dir) {
|
|
|
497
577
|
return false;
|
|
498
578
|
}
|
|
499
579
|
for (const entry of entries) {
|
|
580
|
+
if (rel === '' && entry.name === 'node_modules')
|
|
581
|
+
continue;
|
|
500
582
|
const relChild = rel === '' ? entry.name : `${rel}/${entry.name}`;
|
|
501
583
|
// A symlink is refused rather than followed: it does not survive the
|
|
502
584
|
// bind mount into a unit, and the scoped home's own hash counts one as
|
|
@@ -519,12 +601,13 @@ function presetTreeFiles(dir) {
|
|
|
519
601
|
return files.sort();
|
|
520
602
|
}
|
|
521
603
|
/**
|
|
522
|
-
* Whether two preset directories are the same directory, byte for
|
|
604
|
+
* Whether two preset package directories are the same directory, byte for
|
|
605
|
+
* byte.
|
|
523
606
|
*
|
|
524
607
|
* This is the whole claim the evaluation rests on: a capability face carries
|
|
525
608
|
* no filesystem path, so the fingerprint taken against the deployment's copy
|
|
526
609
|
* describes the scope's copy exactly when the two hold the same bytes.
|
|
527
|
-
* @param a - one preset directory.
|
|
610
|
+
* @param a - one preset package directory.
|
|
528
611
|
* @param b - the other.
|
|
529
612
|
* @returns true when both hold the same files with the same contents.
|
|
530
613
|
*/
|
|
@@ -544,11 +627,11 @@ export function presetTreesEqual(a, b) {
|
|
|
544
627
|
}
|
|
545
628
|
});
|
|
546
629
|
}
|
|
547
|
-
/** Copy one preset directory onto another, replacing whatever was there. */
|
|
630
|
+
/** Copy one preset package directory onto another, replacing whatever was there. */
|
|
548
631
|
function mirrorPresetTree(source, target) {
|
|
549
632
|
const files = presetTreeFiles(source);
|
|
550
633
|
if (files === undefined) {
|
|
551
|
-
throw new Error(`provision-dsh: preset
|
|
634
|
+
throw new Error(`provision-dsh: preset package ${source} is not a plain tree of files`
|
|
552
635
|
+ ' (a symlink or a device node cannot be copied into a scope — an evaluation unit resolves neither)');
|
|
553
636
|
}
|
|
554
637
|
rmSync(target, { recursive: true, force: true });
|
|
@@ -558,84 +641,209 @@ function mirrorPresetTree(source, target) {
|
|
|
558
641
|
copyFileSync(join(source, name), destination);
|
|
559
642
|
}
|
|
560
643
|
}
|
|
644
|
+
/** A JSON object read off disk, or undefined. */
|
|
645
|
+
function readJsonObject(path) {
|
|
646
|
+
try {
|
|
647
|
+
const value = JSON.parse(readFileSync(path, 'utf8'));
|
|
648
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value) ? value : undefined;
|
|
649
|
+
}
|
|
650
|
+
catch {
|
|
651
|
+
return undefined;
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
/** The bundle patch files a package manifest declares (`dsh.bundle.patch`, a string or a list). */
|
|
655
|
+
function bundlePatchFiles(manifest) {
|
|
656
|
+
const dsh = manifest['dsh'];
|
|
657
|
+
if (typeof dsh !== 'object' || dsh === null)
|
|
658
|
+
return [];
|
|
659
|
+
const bundle = dsh['bundle'];
|
|
660
|
+
if (typeof bundle !== 'object' || bundle === null)
|
|
661
|
+
return [];
|
|
662
|
+
const patch = bundle['patch'];
|
|
663
|
+
if (typeof patch === 'string')
|
|
664
|
+
return [patch];
|
|
665
|
+
return Array.isArray(patch) ? patch.filter((item) => typeof item === 'string') : [];
|
|
666
|
+
}
|
|
667
|
+
/** The preset ids a bundle patch document declares through `@deepseek-ai/dsh-agent-preset` rows. */
|
|
668
|
+
function declaredPresetIds(text) {
|
|
669
|
+
let document;
|
|
670
|
+
try {
|
|
671
|
+
document = yaml.load(text, { schema: EXPRESSION_SCHEMA });
|
|
672
|
+
}
|
|
673
|
+
catch {
|
|
674
|
+
return [];
|
|
675
|
+
}
|
|
676
|
+
if (!Array.isArray(document))
|
|
677
|
+
return [];
|
|
678
|
+
const ids = [];
|
|
679
|
+
const visit = (row) => {
|
|
680
|
+
if (typeof row !== 'object' || row === null || Array.isArray(row))
|
|
681
|
+
return;
|
|
682
|
+
const record = row;
|
|
683
|
+
if (record['name'] !== PRESET_ROW_MODULE)
|
|
684
|
+
return;
|
|
685
|
+
const config = record['config'];
|
|
686
|
+
if (typeof config !== 'object' || config === null)
|
|
687
|
+
return;
|
|
688
|
+
const id = config['id'];
|
|
689
|
+
if (typeof id === 'string')
|
|
690
|
+
ids.push(id);
|
|
691
|
+
};
|
|
692
|
+
for (const operation of document) {
|
|
693
|
+
if (typeof operation !== 'object' || operation === null)
|
|
694
|
+
continue;
|
|
695
|
+
const insert = operation['insert'];
|
|
696
|
+
if (Array.isArray(insert))
|
|
697
|
+
insert.forEach(visit);
|
|
698
|
+
else
|
|
699
|
+
visit(operation);
|
|
700
|
+
}
|
|
701
|
+
return ids;
|
|
702
|
+
}
|
|
703
|
+
/**
|
|
704
|
+
* Find the bundle package, among a profile's direct dependencies, whose
|
|
705
|
+
* bundle patch declares preset `id`.
|
|
706
|
+
*
|
|
707
|
+
* This is how the 0.2.0 host itself learns the preset: a profile's direct
|
|
708
|
+
* `dsh.bundle`-declaring dependency mounts its patch, and the patch's
|
|
709
|
+
* `@deepseek-ai/dsh-agent-preset` row registers the id. Reading the same files
|
|
710
|
+
* is what lets a scope copy exactly the package the evaluation instance's
|
|
711
|
+
* capability catalog measures.
|
|
712
|
+
* @param profileDir - the instance profile directory (holding `package.json`).
|
|
713
|
+
* @param id - the preset id.
|
|
714
|
+
* @returns the declaring package, or undefined when none (or more than one) declares it.
|
|
715
|
+
*/
|
|
716
|
+
export function findPresetPackage(profileDir, id) {
|
|
717
|
+
const manifest = readJsonObject(join(profileDir, 'package.json'));
|
|
718
|
+
if (manifest === undefined)
|
|
719
|
+
return undefined;
|
|
720
|
+
const names = new Set();
|
|
721
|
+
for (const field of ['dependencies', 'optionalDependencies', 'devDependencies']) {
|
|
722
|
+
const deps = manifest[field];
|
|
723
|
+
if (typeof deps === 'object' && deps !== null)
|
|
724
|
+
for (const name of Object.keys(deps))
|
|
725
|
+
names.add(name);
|
|
726
|
+
}
|
|
727
|
+
const found = [];
|
|
728
|
+
for (const name of [...names].sort()) {
|
|
729
|
+
if (!PACKAGE_NAME_RE.test(name))
|
|
730
|
+
continue;
|
|
731
|
+
let dir;
|
|
732
|
+
try {
|
|
733
|
+
dir = realpathSync(join(profileDir, 'node_modules', ...name.split('/')));
|
|
734
|
+
}
|
|
735
|
+
catch {
|
|
736
|
+
continue;
|
|
737
|
+
}
|
|
738
|
+
const packageManifest = readJsonObject(join(dir, 'package.json'));
|
|
739
|
+
if (packageManifest === undefined)
|
|
740
|
+
continue;
|
|
741
|
+
const declares = bundlePatchFiles(packageManifest).some(file => {
|
|
742
|
+
try {
|
|
743
|
+
return declaredPresetIds(readFileSync(join(dir, file), 'utf8')).includes(id);
|
|
744
|
+
}
|
|
745
|
+
catch {
|
|
746
|
+
return false;
|
|
747
|
+
}
|
|
748
|
+
});
|
|
749
|
+
if (declares)
|
|
750
|
+
found.push({ name, dir });
|
|
751
|
+
}
|
|
752
|
+
return found.length === 1 ? found[0] : undefined;
|
|
753
|
+
}
|
|
754
|
+
/**
|
|
755
|
+
* Why a preset package directory cannot be copied into a scope, or undefined
|
|
756
|
+
* when it can: it must name itself, declare its bundle patch, and the patch
|
|
757
|
+
* must declare the preset without naming an absolute path.
|
|
758
|
+
*/
|
|
759
|
+
function presetPackageProblem(dir, id) {
|
|
760
|
+
const manifest = readJsonObject(join(dir, 'package.json'));
|
|
761
|
+
const name = manifest?.['name'];
|
|
762
|
+
if (manifest === undefined || typeof name !== 'string' || !PACKAGE_NAME_RE.test(name)) {
|
|
763
|
+
return `${dir} has no package.json naming the package`;
|
|
764
|
+
}
|
|
765
|
+
const files = bundlePatchFiles(manifest);
|
|
766
|
+
if (files.length === 0)
|
|
767
|
+
return `${name} declares no dsh.bundle.patch`;
|
|
768
|
+
let declares = false;
|
|
769
|
+
for (const file of files) {
|
|
770
|
+
let text;
|
|
771
|
+
try {
|
|
772
|
+
text = readFileSync(join(dir, file), 'utf8');
|
|
773
|
+
}
|
|
774
|
+
catch {
|
|
775
|
+
return `${name} declares bundle patch ${file}, which is missing`;
|
|
776
|
+
}
|
|
777
|
+
const offenders = compositionAbsolutePaths(text);
|
|
778
|
+
if (offenders.length > 0) {
|
|
779
|
+
return `${name}'s ${file} names absolute path(s) ${offenders.map(path => JSON.stringify(path)).join(', ')}`
|
|
780
|
+
+ ' — a preset that is copied into a scope and bind-mounted into a unit is read from three different directories,'
|
|
781
|
+
+ ' so an absolute path is wrong in at least two of them. Resolve the package\'s own directory through baseUrl instead:'
|
|
782
|
+
+ ` !!js "…dirname(createRequire(baseUrl).resolve('${name}/package.json'))…"`;
|
|
783
|
+
}
|
|
784
|
+
if (declaredPresetIds(text).includes(id))
|
|
785
|
+
declares = true;
|
|
786
|
+
}
|
|
787
|
+
return declares ? undefined : `${name} declares no ${PRESET_ROW_MODULE} row with id ${JSON.stringify(id)}`;
|
|
788
|
+
}
|
|
561
789
|
/**
|
|
562
|
-
* Give one scope its own copy of a preset, from the
|
|
790
|
+
* Give one scope its own copy of a preset package, from the evaluation
|
|
791
|
+
* instance's own installation of it.
|
|
563
792
|
*
|
|
564
|
-
* The copy is what makes a preset reachable on BOTH paths. The
|
|
565
|
-
*
|
|
566
|
-
*
|
|
567
|
-
*
|
|
568
|
-
* one directory, because the unit binds the scope. A roster pointed at the
|
|
569
|
-
* deployment's root instead resolves on the host and names a path the unit
|
|
570
|
-
* does not have.
|
|
793
|
+
* The copy is what makes a preset reachable on BOTH paths. The sub-profile
|
|
794
|
+
* resolves the package through a relative link to
|
|
795
|
+
* `<scope>/preset-packages/<id>`, which is one directory on the host and
|
|
796
|
+
* inside an evaluation unit, because the unit binds the scope.
|
|
571
797
|
*
|
|
572
798
|
* `refresh` is the difference between provisioning a condition and healing a
|
|
573
|
-
* scope. A deliberate provision re-syncs from the
|
|
799
|
+
* scope. A deliberate provision re-syncs from the instance's copy, because
|
|
574
800
|
* that is the moment a new lock is minted and an edited preset should be
|
|
575
801
|
* picked up. Every other provisioning leaves an existing copy alone: nothing
|
|
576
|
-
* may move the subject under a run
|
|
802
|
+
* may move the subject under a run — and a healing call whose instance no
|
|
803
|
+
* longer carries the package keeps the copy, reporting `matchesSource: false`.
|
|
577
804
|
* @param homeDir - the harness scoped home.
|
|
578
|
-
* @param
|
|
579
|
-
* @param id - the preset id
|
|
805
|
+
* @param instanceProfileDir - the instance profile directory, or undefined when unknown.
|
|
806
|
+
* @param id - the preset id.
|
|
580
807
|
* @param options - `refresh` re-syncs an existing copy.
|
|
581
808
|
* @returns where the copy is and whether it matches the source.
|
|
582
|
-
* @throws when
|
|
809
|
+
* @throws when no copy can be made: no installed package declares the id, or it is not a usable preset package.
|
|
583
810
|
*/
|
|
584
|
-
export function snapshotScopePreset(homeDir,
|
|
811
|
+
export function snapshotScopePreset(homeDir, instanceProfileDir, id, options = {}) {
|
|
585
812
|
if (!SUB_PROFILE_PRESET_ID_RE.test(id)) {
|
|
586
813
|
throw new Error(`provision-dsh: preset id ${JSON.stringify(id)} must match ${String(SUB_PROFILE_PRESET_ID_RE)} (it is a directory name)`);
|
|
587
814
|
}
|
|
588
|
-
const
|
|
589
|
-
const
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
const
|
|
595
|
-
if (
|
|
596
|
-
|
|
597
|
-
+ ' — a preset that is copied into a scope and bind-mounted into a unit is read from three different directories,'
|
|
598
|
-
+ ' so an absolute path is wrong in at least two of them. Use the loader expression the shipped `cordis` preset uses:'
|
|
599
|
-
+ ' !!js "process.getBuiltinModule(\'node:url\').fileURLToPath(new URL(\'skills/\', baseUrl))"');
|
|
815
|
+
const dir = scopePresetPackageDir(homeDir, id);
|
|
816
|
+
const copyName = () => {
|
|
817
|
+
const name = readJsonObject(join(dir, 'package.json'))?.['name'];
|
|
818
|
+
return typeof name === 'string' && PACKAGE_NAME_RE.test(name) ? name : undefined;
|
|
819
|
+
};
|
|
820
|
+
const source = instanceProfileDir === undefined ? undefined : findPresetPackage(instanceProfileDir, id);
|
|
821
|
+
const existing = existsSync(dir) ? copyName() : undefined;
|
|
822
|
+
if (existing !== undefined && options.refresh !== true) {
|
|
823
|
+
return { dir, packageName: existing, matchesSource: source !== undefined && presetTreesEqual(source.dir, dir), copied: false };
|
|
600
824
|
}
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
825
|
+
if (source === undefined) {
|
|
826
|
+
throw new Error(`provision-dsh: no package installed in ${instanceProfileDir ?? 'this instance\'s profile'} declares preset ${JSON.stringify(id)}`
|
|
827
|
+
+ ` (an ${PRESET_ROW_MODULE} row in its dsh.bundle.patch) — add the preset package to the instance profile before a scope can compose it`);
|
|
604
828
|
}
|
|
605
|
-
|
|
606
|
-
|
|
829
|
+
const problem = presetPackageProblem(source.dir, id);
|
|
830
|
+
if (problem !== undefined)
|
|
831
|
+
throw new Error(`provision-dsh: preset ${JSON.stringify(id)}: ${problem}`);
|
|
832
|
+
if (existing !== undefined && presetTreesEqual(source.dir, dir)) {
|
|
833
|
+
return { dir, packageName: existing, matchesSource: true, copied: false };
|
|
607
834
|
}
|
|
608
|
-
mirrorPresetTree(source, dir);
|
|
609
|
-
return { dir, matchesSource: true, copied: true };
|
|
610
|
-
}
|
|
611
|
-
/**
|
|
612
|
-
* The deployment's own preset root, when the caller names no other.
|
|
613
|
-
*
|
|
614
|
-
* The roster's own derivation, repeated here because the provisioning paths
|
|
615
|
-
* that heal a scope (a host round, a live runtime) have no settings service
|
|
616
|
-
* in reach: a non-blank `$DSH_HOME`, else the `~/.dsh` installation default
|
|
617
|
-
* (the precedence of `resolveDshHome` in `@deepseek-ai/dsh-home-paths` —
|
|
618
|
-
* the default was once hardcoded to `.dsh-official`, pointing a desktop
|
|
619
|
-
* instance at another instance's home; 2026-09-30).
|
|
620
|
-
*/
|
|
621
|
-
export function defaultPresetRoot() {
|
|
622
|
-
const fromEnv = process.env['DSH_HOME'];
|
|
623
|
-
const home = fromEnv !== undefined && fromEnv.trim().length > 0 ? fromEnv : join(homedir(), '.dsh');
|
|
624
|
-
return join(home, USER_PRESET_DIR);
|
|
835
|
+
mirrorPresetTree(source.dir, dir);
|
|
836
|
+
return { dir, packageName: source.name, matchesSource: true, copied: true };
|
|
625
837
|
}
|
|
626
838
|
/**
|
|
627
|
-
* Provision one scope: its own preset copy, its declaration, and the
|
|
628
|
-
* sub-profile that
|
|
839
|
+
* Provision one scope: its own preset package copy, its declaration, and the
|
|
840
|
+
* sub-profile that composes it.
|
|
629
841
|
*
|
|
630
842
|
* The preset resolves in one order, and the order is the decision: the
|
|
631
843
|
* CALLER's explicit request, then the SCOPE's own declaration, then the
|
|
632
844
|
* deployment's plugin config. The first is how a condition says which subject
|
|
633
845
|
* this scope is; the second is how that survives a restart; the third is the
|
|
634
846
|
* deployment-wide answer every scope had before either existed.
|
|
635
|
-
*
|
|
636
|
-
* A scope-owned preset rosters with NO `roots`. The roster's derived user
|
|
637
|
-
* root is exactly the scope's copy on both paths, so leaving the roots alone
|
|
638
|
-
* is what gives the host and the unit one resolution rule instead of two.
|
|
639
847
|
* @param homeDir - the harness scoped home.
|
|
640
848
|
* @param config - the deployment's sub-profile config.
|
|
641
849
|
* @param options - per-scope inputs.
|
|
@@ -644,13 +852,15 @@ export function defaultPresetRoot() {
|
|
|
644
852
|
export function provisionDshScope(homeDir, config = {}, options = {}) {
|
|
645
853
|
const requested = options.preset;
|
|
646
854
|
const declared = requested ?? readScopeSubProfile(homeDir)?.preset;
|
|
647
|
-
if (requested !== undefined)
|
|
648
|
-
writeScopeSubProfile(homeDir, { preset: requested });
|
|
649
855
|
let snapshot;
|
|
650
856
|
if (declared !== undefined) {
|
|
651
|
-
snapshot = snapshotScopePreset(homeDir, options.
|
|
857
|
+
snapshot = snapshotScopePreset(homeDir, options.instanceProfileDir, declared, { refresh: requested !== undefined });
|
|
652
858
|
}
|
|
653
|
-
|
|
859
|
+
if (requested !== undefined)
|
|
860
|
+
writeScopeSubProfile(homeDir, { preset: requested });
|
|
861
|
+
const preset = declared === undefined || snapshot === undefined
|
|
862
|
+
? config.preset
|
|
863
|
+
: { id: declared, package: snapshot.packageName };
|
|
654
864
|
const profileDir = provisionDshSubProfile(homeDir, { ...config, ...(preset === undefined ? {} : { preset }) });
|
|
655
865
|
return {
|
|
656
866
|
profileDir,
|