@dsh-enhanced/assistant-policy 0.1.64 → 0.1.66
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +62 -2
- package/bin/dsh-plugin-setup.js +7 -0
- package/lib/index.d.ts +2 -1
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +1 -0
- package/lib/index.js.map +1 -1
- package/lib/plugin-setup.d.ts +137 -0
- package/lib/plugin-setup.d.ts.map +1 -0
- package/lib/plugin-setup.js +862 -0
- package/lib/plugin-setup.js.map +1 -0
- package/lib/profile-setup.d.ts +5 -1
- package/lib/profile-setup.d.ts.map +1 -1
- package/lib/profile-setup.js +6 -3
- package/lib/profile-setup.js.map +1 -1
- package/lib/service.d.ts +19 -0
- package/lib/service.d.ts.map +1 -1
- package/lib/service.js +85 -7
- package/lib/service.js.map +1 -1
- package/lib/types.d.ts +6 -1
- package/lib/types.d.ts.map +1 -1
- package/lib/version.d.ts +1 -1
- package/lib/version.js +1 -1
- package/package.json +13 -8
|
@@ -0,0 +1,862 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { chmod, mkdir, readdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { isAbsolute, join } from 'node:path';
|
|
4
|
+
import { createInterface } from 'node:readline/promises';
|
|
5
|
+
import { isMap, isScalar, isSeq, parseDocument, Scalar, stringify, YAMLMap, YAMLSeq } from 'yaml';
|
|
6
|
+
import { findRow, readEffectiveProfileDump } from './profile-setup.js';
|
|
7
|
+
/**
|
|
8
|
+
* `dsh-plugin-setup`: list, enable, disable and configure installed
|
|
9
|
+
* `@dsh-enhanced/*` plugins of one DSH profile without hand-editing YAML.
|
|
10
|
+
*
|
|
11
|
+
* Every write goes to `$DSH_HOME/profiles/<profile>/cordis.patch.yml` only.
|
|
12
|
+
* A profile row `config` replaces the inherited config as a whole, so the
|
|
13
|
+
* first edit of a row clones the native effective config (from
|
|
14
|
+
* `dsh --profile <p> --dump-config`, preserving `!!js` expressions) and then
|
|
15
|
+
* changes just the requested key. After writing, the profile is dumped again;
|
|
16
|
+
* if the dump fails or the requested values did not take effect the previous
|
|
17
|
+
* patch is restored.
|
|
18
|
+
*
|
|
19
|
+
* Boundaries: never writes secret values, never edits the Policy or plugin
|
|
20
|
+
* control-plane rows, never disables Policy, and never enables Lark before its
|
|
21
|
+
* own credential onboarding (`dsh-lark-setup`).
|
|
22
|
+
*/
|
|
23
|
+
const tag = 'assistant-policy plugin setup';
|
|
24
|
+
const profileNamePattern = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/u;
|
|
25
|
+
const keySegmentPattern = /^[A-Za-z0-9_-]{1,64}$/u;
|
|
26
|
+
const rowPrefix = 'dsh-enhanced-';
|
|
27
|
+
const packageScope = '@dsh-enhanced/';
|
|
28
|
+
/** Rows whose config/authorization surface this CLI refuses to edit. */
|
|
29
|
+
const protectedRows = new Map([
|
|
30
|
+
['dsh-enhanced-assistant-policy', 'Policy rules, default-deny and auto-review are access controls; edit them deliberately in the profile patch'],
|
|
31
|
+
['dsh-enhanced-plugin-control-plane', 'sealed-plugin trust and approval state is managed by dsh-plugin-control'],
|
|
32
|
+
]);
|
|
33
|
+
/** Rows whose config keys this CLI refuses to edit (authorization-bearing keys). */
|
|
34
|
+
const protectedKeys = new Map([
|
|
35
|
+
['dsh-enhanced-assistant-delivery', new Set(['ownerRoutes', 'policyRef'])],
|
|
36
|
+
]);
|
|
37
|
+
/**
|
|
38
|
+
* Documented optional keys that are absent from bundle defaults. `set` accepts
|
|
39
|
+
* them without `--force` and validates their type.
|
|
40
|
+
*/
|
|
41
|
+
const knownOptionalKeys = new Map([
|
|
42
|
+
['dsh-enhanced-assistant-work-items', new Map([
|
|
43
|
+
['mode', ['observe', 'active']],
|
|
44
|
+
['intakeLedger.enabled', 'boolean'],
|
|
45
|
+
['ownerReplyContinuation', 'boolean'],
|
|
46
|
+
['ownerReportFeedback', 'boolean'],
|
|
47
|
+
['follows.eventTriggers.enabled', 'boolean'],
|
|
48
|
+
['follows.eventTriggers.acceptHeadRef', 'boolean'],
|
|
49
|
+
['ownerReportRouting.prefer', ['origin', 'recent']],
|
|
50
|
+
['acceptance.autoSettle.enabled', 'boolean'],
|
|
51
|
+
['acceptance.autoSettle.maxPerTick', 'number'],
|
|
52
|
+
['acceptance.bindWorkerRun.enabled', 'boolean'],
|
|
53
|
+
['acceptance.bindWorkerRun.maxPerTick', 'number'],
|
|
54
|
+
['acceptance.verdictGate.enabled', 'boolean'],
|
|
55
|
+
['acceptance.holdReport.enabled', 'boolean'],
|
|
56
|
+
['acceptance.holdReport.holdTimeoutMs', 'number'],
|
|
57
|
+
])],
|
|
58
|
+
['dsh-enhanced-assistant-delivery', new Map([
|
|
59
|
+
['attentionAttachOverCap', 'boolean'],
|
|
60
|
+
['ownerReportRouting.orderAcrossLanes', 'boolean'],
|
|
61
|
+
])],
|
|
62
|
+
['dsh-enhanced-assistant-evaluation', new Map([
|
|
63
|
+
['notificationOutcomesEnabled', 'boolean'],
|
|
64
|
+
['notificationSyncIntervalMs', 'number'],
|
|
65
|
+
])],
|
|
66
|
+
['dsh-enhanced-event-triggers', new Map([['pollerEnabled', 'boolean']])],
|
|
67
|
+
]);
|
|
68
|
+
class PluginSetupError extends Error {
|
|
69
|
+
}
|
|
70
|
+
function fail(message) {
|
|
71
|
+
throw new PluginSetupError(`${tag}: ${message}`);
|
|
72
|
+
}
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
// Profile + dump helpers
|
|
75
|
+
export async function resolvePluginProfile(target) {
|
|
76
|
+
if (!isAbsolute(target.dshHome))
|
|
77
|
+
fail('DSH home must be an absolute path');
|
|
78
|
+
const explicit = target.profile ?? process.env.DSH_PROFILE;
|
|
79
|
+
if (explicit !== undefined && explicit !== '') {
|
|
80
|
+
if (!profileNamePattern.test(explicit))
|
|
81
|
+
fail('--profile must be a valid profile name');
|
|
82
|
+
return explicit;
|
|
83
|
+
}
|
|
84
|
+
let names = [];
|
|
85
|
+
try {
|
|
86
|
+
const entries = await readdir(join(target.dshHome, 'profiles'), { withFileTypes: true });
|
|
87
|
+
names = entries
|
|
88
|
+
.filter(entry => entry.isDirectory() && entry.name !== 'node_modules' && profileNamePattern.test(entry.name))
|
|
89
|
+
.map(entry => entry.name)
|
|
90
|
+
.sort();
|
|
91
|
+
}
|
|
92
|
+
catch (error) {
|
|
93
|
+
if (!(error instanceof Error) || !('code' in error) || error.code !== 'ENOENT')
|
|
94
|
+
throw error;
|
|
95
|
+
}
|
|
96
|
+
if (names.length === 1)
|
|
97
|
+
return names[0];
|
|
98
|
+
if (names.length === 0)
|
|
99
|
+
fail(`no profile found under ${join(target.dshHome, 'profiles')}; pass --profile`);
|
|
100
|
+
return fail(`several profiles found (${names.join(', ')}); pass --profile <name>`);
|
|
101
|
+
}
|
|
102
|
+
function collectPluginRows(sequence, into = []) {
|
|
103
|
+
for (const entry of sequence.items) {
|
|
104
|
+
if (!isMap(entry))
|
|
105
|
+
continue;
|
|
106
|
+
const id = entry.get('id');
|
|
107
|
+
if (typeof id === 'string' && id.startsWith(rowPrefix))
|
|
108
|
+
into.push(entry);
|
|
109
|
+
for (const key of ['config', 'insert']) {
|
|
110
|
+
const children = entry.get(key, true);
|
|
111
|
+
if (isSeq(children))
|
|
112
|
+
collectPluginRows(children, into);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return into;
|
|
116
|
+
}
|
|
117
|
+
function rowConfig(row) {
|
|
118
|
+
const config = row?.get('config', true);
|
|
119
|
+
if (config === undefined)
|
|
120
|
+
return new YAMLMap();
|
|
121
|
+
if (!isMap(config))
|
|
122
|
+
fail(`${String(row?.get('id'))} config must be a YAML mapping`);
|
|
123
|
+
return config;
|
|
124
|
+
}
|
|
125
|
+
function isJsScalar(node) {
|
|
126
|
+
return isScalar(node) && (node.tag === '!!js' || node.tag === 'tag:yaml.org,2002:js');
|
|
127
|
+
}
|
|
128
|
+
function nodeValue(node) {
|
|
129
|
+
if (node === undefined)
|
|
130
|
+
return undefined;
|
|
131
|
+
if (isScalar(node))
|
|
132
|
+
return isJsScalar(node) ? `!!js ${String(node.value)}` : node.value;
|
|
133
|
+
if (isMap(node) || isSeq(node))
|
|
134
|
+
return node.toJSON();
|
|
135
|
+
return node;
|
|
136
|
+
}
|
|
137
|
+
function splitKey(key) {
|
|
138
|
+
const segments = key.split('.');
|
|
139
|
+
if (segments.length > 8 || segments.some(segment => !keySegmentPattern.test(segment))) {
|
|
140
|
+
fail(`invalid config key ${JSON.stringify(key)}; use dotted names such as intakeLedger.enabled`);
|
|
141
|
+
}
|
|
142
|
+
return segments;
|
|
143
|
+
}
|
|
144
|
+
function camelWords(name) {
|
|
145
|
+
return name.replace(/([a-z0-9])([A-Z])/gu, '$1 $2').split(/[\s_-]+/u).map(word => word.toLowerCase()).filter(Boolean);
|
|
146
|
+
}
|
|
147
|
+
/** Secret-bearing key names; handles/env names/paths/ttls are not secrets. */
|
|
148
|
+
export function isSecretConfigKey(name) {
|
|
149
|
+
const words = camelWords(name);
|
|
150
|
+
const last = words.at(-1) ?? '';
|
|
151
|
+
if (['handle', 'env', 'ref', 'path', 'file', 'id', 'ttl', 'ms', 'name', 'url'].includes(last))
|
|
152
|
+
return false;
|
|
153
|
+
for (let index = 0; index < words.length; index += 1) {
|
|
154
|
+
const word = words[index];
|
|
155
|
+
if (['secret', 'password', 'passwd', 'token', 'cookie', 'apikey'].includes(word))
|
|
156
|
+
return true;
|
|
157
|
+
if ((word === 'api' || word === 'private' || word === 'access') && words[index + 1] === 'key')
|
|
158
|
+
return true;
|
|
159
|
+
}
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
162
|
+
function redact(node) {
|
|
163
|
+
if (isMap(node)) {
|
|
164
|
+
for (const pair of node.items) {
|
|
165
|
+
const key = isScalar(pair.key) ? String(pair.key.value) : String(pair.key);
|
|
166
|
+
if (isSecretConfigKey(key) && pair.value !== null && !(isScalar(pair.value) && (pair.value.value === null || pair.value.value === ''))) {
|
|
167
|
+
pair.value = new Scalar('[redacted]');
|
|
168
|
+
}
|
|
169
|
+
else {
|
|
170
|
+
redact(pair.value);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
else if (isSeq(node)) {
|
|
175
|
+
for (const item of node.items)
|
|
176
|
+
redact(item);
|
|
177
|
+
}
|
|
178
|
+
return node;
|
|
179
|
+
}
|
|
180
|
+
async function readInstalled(target, readDump) {
|
|
181
|
+
const profile = await resolvePluginProfile(target);
|
|
182
|
+
const dump = await readDump(target.dshHome, profile);
|
|
183
|
+
const rows = new Map();
|
|
184
|
+
for (const row of collectPluginRows(dump))
|
|
185
|
+
rows.set(String(row.get('id')), row);
|
|
186
|
+
return { profile, dump, rows };
|
|
187
|
+
}
|
|
188
|
+
/** Map `work-items`, `assistant-work-items`, `@dsh-enhanced/assistant-work-items` or the row id to an installed row id. */
|
|
189
|
+
export function resolvePluginId(name, installed) {
|
|
190
|
+
const ids = new Set(installed);
|
|
191
|
+
const trimmed = name.trim();
|
|
192
|
+
const base = trimmed.startsWith(packageScope) ? trimmed.slice(packageScope.length) : trimmed;
|
|
193
|
+
for (const candidate of [base, `${rowPrefix}${base}`, `${rowPrefix}assistant-${base}`]) {
|
|
194
|
+
if (ids.has(candidate))
|
|
195
|
+
return candidate;
|
|
196
|
+
}
|
|
197
|
+
return undefined;
|
|
198
|
+
}
|
|
199
|
+
function requireInstalled(name, installed) {
|
|
200
|
+
const id = resolvePluginId(name, installed.rows.keys());
|
|
201
|
+
if (id !== undefined)
|
|
202
|
+
return id;
|
|
203
|
+
const base = name.startsWith(packageScope) ? name.slice(packageScope.length) : name.replace(/^dsh-enhanced-/u, '');
|
|
204
|
+
return fail(`plugin ${JSON.stringify(name)} is not installed in profile ${installed.profile}; `
|
|
205
|
+
+ `add it first: dsh plugin --profile ${installed.profile} add ${packageScope}${base}`);
|
|
206
|
+
}
|
|
207
|
+
function summarize(id, row, overridden) {
|
|
208
|
+
const config = rowConfig(row);
|
|
209
|
+
const flags = {};
|
|
210
|
+
let enabled;
|
|
211
|
+
for (const pair of config.items) {
|
|
212
|
+
const key = isScalar(pair.key) ? String(pair.key.value) : String(pair.key);
|
|
213
|
+
const value = pair.value;
|
|
214
|
+
if (isScalar(value) && typeof value.value === 'boolean') {
|
|
215
|
+
if (key === 'enabled')
|
|
216
|
+
enabled = value.value;
|
|
217
|
+
else if (key.endsWith('Enabled') || key === 'attentionAttachOverCap')
|
|
218
|
+
flags[key] = value.value;
|
|
219
|
+
}
|
|
220
|
+
else if (isMap(value)) {
|
|
221
|
+
const nested = value.get('enabled');
|
|
222
|
+
if (typeof nested === 'boolean')
|
|
223
|
+
flags[`${key}.enabled`] = nested;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
const slug = id.slice(rowPrefix.length);
|
|
227
|
+
return {
|
|
228
|
+
id,
|
|
229
|
+
slug,
|
|
230
|
+
packageName: `${packageScope}${slug}`,
|
|
231
|
+
loaded: row.get('disabled') !== true,
|
|
232
|
+
...(enabled !== undefined ? { enabled } : {}),
|
|
233
|
+
flags,
|
|
234
|
+
overridden,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
async function loadProfilePatch(dshHome, profile) {
|
|
238
|
+
const path = join(dshHome, 'profiles', profile, 'cordis.patch.yml');
|
|
239
|
+
let source = '';
|
|
240
|
+
let existed = true;
|
|
241
|
+
try {
|
|
242
|
+
source = await readFile(path, 'utf8');
|
|
243
|
+
}
|
|
244
|
+
catch (error) {
|
|
245
|
+
if (!(error instanceof Error) || !('code' in error) || error.code !== 'ENOENT')
|
|
246
|
+
throw error;
|
|
247
|
+
existed = false;
|
|
248
|
+
}
|
|
249
|
+
const document = parseDocument(source);
|
|
250
|
+
if (document.errors.length > 0)
|
|
251
|
+
fail(`${path} is invalid YAML: ${document.errors[0]?.message}`);
|
|
252
|
+
if (document.contents === null)
|
|
253
|
+
document.contents = new YAMLSeq();
|
|
254
|
+
if (!isSeq(document.contents))
|
|
255
|
+
fail(`${path} must be a top-level YAML sequence of loader entries`);
|
|
256
|
+
return { path, existed, source, document, sequence: document.contents };
|
|
257
|
+
}
|
|
258
|
+
function profileRows(sequence, id) {
|
|
259
|
+
const rows = [];
|
|
260
|
+
for (const entry of sequence.items) {
|
|
261
|
+
if (!isMap(entry))
|
|
262
|
+
continue;
|
|
263
|
+
if (entry.get('id') === id)
|
|
264
|
+
rows.push(entry);
|
|
265
|
+
const inserted = entry.get('insert', true);
|
|
266
|
+
if (isSeq(inserted))
|
|
267
|
+
for (const row of inserted.items)
|
|
268
|
+
if (isMap(row) && row.get('id') === id)
|
|
269
|
+
rows.push(row);
|
|
270
|
+
}
|
|
271
|
+
return rows;
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Profile row for `id`. A newly created row always carries a clone of the whole
|
|
275
|
+
* native effective config, because a profile row config replaces inherited config.
|
|
276
|
+
*/
|
|
277
|
+
function profileRow(patch, id, effective) {
|
|
278
|
+
let row = profileRows(patch.sequence, id).at(-1);
|
|
279
|
+
if (row === undefined) {
|
|
280
|
+
row = patch.document.createNode({ id });
|
|
281
|
+
patch.sequence.add(row);
|
|
282
|
+
}
|
|
283
|
+
const current = row.get('config', true);
|
|
284
|
+
if (current === undefined) {
|
|
285
|
+
row.set('config', effective.clone());
|
|
286
|
+
}
|
|
287
|
+
else if (!isMap(current)) {
|
|
288
|
+
fail(`${id} config in ${patch.path} must be a YAML mapping`);
|
|
289
|
+
}
|
|
290
|
+
return row;
|
|
291
|
+
}
|
|
292
|
+
function profileRowConfig(patch, id, effective) {
|
|
293
|
+
return profileRow(patch, id, effective).get('config', true);
|
|
294
|
+
}
|
|
295
|
+
function parseBoolean(raw, key) {
|
|
296
|
+
const normalized = raw.trim().toLowerCase();
|
|
297
|
+
if (['true', 'on', 'yes', '1'].includes(normalized))
|
|
298
|
+
return true;
|
|
299
|
+
if (['false', 'off', 'no', '0'].includes(normalized))
|
|
300
|
+
return false;
|
|
301
|
+
return fail(`${key} expects a boolean (true/false), got ${JSON.stringify(raw)}`);
|
|
302
|
+
}
|
|
303
|
+
function parseNumber(raw, key) {
|
|
304
|
+
const value = Number(raw.trim());
|
|
305
|
+
if (raw.trim() === '' || !Number.isFinite(value))
|
|
306
|
+
fail(`${key} expects a number, got ${JSON.stringify(raw)}`);
|
|
307
|
+
return value;
|
|
308
|
+
}
|
|
309
|
+
function parseJson(raw, key, kind) {
|
|
310
|
+
let value;
|
|
311
|
+
try {
|
|
312
|
+
value = JSON.parse(raw);
|
|
313
|
+
}
|
|
314
|
+
catch {
|
|
315
|
+
fail(`${key} expects a JSON ${kind}, got ${JSON.stringify(raw)}`);
|
|
316
|
+
}
|
|
317
|
+
const ok = kind === 'array' ? Array.isArray(value) : value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
318
|
+
if (!ok)
|
|
319
|
+
fail(`${key} expects a JSON ${kind}`);
|
|
320
|
+
return value;
|
|
321
|
+
}
|
|
322
|
+
function coerceKnown(raw, key, type) {
|
|
323
|
+
if (type === 'boolean')
|
|
324
|
+
return parseBoolean(raw, key);
|
|
325
|
+
if (type === 'number')
|
|
326
|
+
return parseNumber(raw, key);
|
|
327
|
+
if (type === 'string')
|
|
328
|
+
return raw;
|
|
329
|
+
if (!type.includes(raw))
|
|
330
|
+
fail(`${key} must be one of ${type.join(', ')}`);
|
|
331
|
+
return raw;
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* Convert a CLI string to the type of the current effective value. Unknown keys
|
|
335
|
+
* need `force`; documented optional keys are typed from a small table.
|
|
336
|
+
*/
|
|
337
|
+
export function coerceConfigValue(id, key, raw, current, force = false) {
|
|
338
|
+
const known = knownOptionalKeys.get(id)?.get(key);
|
|
339
|
+
if (current === undefined) {
|
|
340
|
+
if (known !== undefined)
|
|
341
|
+
return coerceKnown(raw, key, known);
|
|
342
|
+
if (!force)
|
|
343
|
+
fail(`${id} has no effective key ${key}; check the plugin README for its name, or pass --force to add it`);
|
|
344
|
+
try {
|
|
345
|
+
return JSON.parse(raw);
|
|
346
|
+
}
|
|
347
|
+
catch {
|
|
348
|
+
return raw;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
if (isJsScalar(current) && !force) {
|
|
352
|
+
fail(`${key} is a !!js expression (${String(current.value)}); pass --force to replace it with a literal`);
|
|
353
|
+
}
|
|
354
|
+
if (isMap(current))
|
|
355
|
+
return parseJson(raw, key, 'object');
|
|
356
|
+
if (isSeq(current))
|
|
357
|
+
return parseJson(raw, key, 'array');
|
|
358
|
+
const value = isScalar(current) ? current.value : current;
|
|
359
|
+
if (known !== undefined)
|
|
360
|
+
return coerceKnown(raw, key, known);
|
|
361
|
+
if (typeof value === 'boolean')
|
|
362
|
+
return parseBoolean(raw, key);
|
|
363
|
+
if (typeof value === 'number')
|
|
364
|
+
return parseNumber(raw, key);
|
|
365
|
+
if (typeof value === 'string')
|
|
366
|
+
return raw;
|
|
367
|
+
try {
|
|
368
|
+
return JSON.parse(raw);
|
|
369
|
+
}
|
|
370
|
+
catch {
|
|
371
|
+
return raw;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
function assertEditable(id, segments) {
|
|
375
|
+
const reason = protectedRows.get(id);
|
|
376
|
+
if (reason !== undefined)
|
|
377
|
+
fail(`${id} is not editable with plugin setup: ${reason}`);
|
|
378
|
+
if (protectedKeys.get(id)?.has(segments[0]))
|
|
379
|
+
fail(`${id}.${segments[0]} is an authorization setting; edit it deliberately in the profile patch`);
|
|
380
|
+
if (segments.some(isSecretConfigKey)) {
|
|
381
|
+
fail(`${segments.join('.')} looks like a secret; plugin setup never writes secret values into cordis.patch.yml. `
|
|
382
|
+
+ 'Use the plugin\'s credential onboarding (for example dsh-lark-setup, or dsh-model-setup --store-key) instead');
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
function sameValue(left, right) {
|
|
386
|
+
return JSON.stringify(left) === JSON.stringify(right);
|
|
387
|
+
}
|
|
388
|
+
function larkPlaceholder(effective) {
|
|
389
|
+
const tenant = effective.get('tenant');
|
|
390
|
+
const appId = effective.get('appId');
|
|
391
|
+
return tenant === 'replace-with-tenant-key' || appId === 'cli_0000000000000000';
|
|
392
|
+
}
|
|
393
|
+
/** Shared write path: edit, atomically write, re-dump to verify, roll back on failure. */
|
|
394
|
+
async function commit(target, installed, patch, changes, skipped, verify, rowGates, options) {
|
|
395
|
+
const serialized = patch.document.toString({ lineWidth: 0 });
|
|
396
|
+
const changed = serialized !== patch.source && changes.length > 0;
|
|
397
|
+
const result = {
|
|
398
|
+
profile: installed.profile, patchPath: patch.path, changed, dryRun: options.dryRun === true, changes, skipped,
|
|
399
|
+
};
|
|
400
|
+
if (!changed || options.dryRun === true)
|
|
401
|
+
return result;
|
|
402
|
+
await mkdir(join(target.dshHome, 'profiles', installed.profile), { recursive: true });
|
|
403
|
+
const temporaryPath = `${patch.path}.${randomUUID()}.tmp`;
|
|
404
|
+
await writeFile(temporaryPath, serialized, { encoding: 'utf8', mode: 0o600 });
|
|
405
|
+
await chmod(temporaryPath, 0o600);
|
|
406
|
+
await rename(temporaryPath, patch.path);
|
|
407
|
+
try {
|
|
408
|
+
const after = await (options.readDump ?? readEffectiveProfileDump)(target.dshHome, installed.profile);
|
|
409
|
+
for (const gate of rowGates) {
|
|
410
|
+
const row = findRow(after, gate.id);
|
|
411
|
+
if (row === undefined || (row.get('disabled') !== true) !== gate.loaded)
|
|
412
|
+
fail(`${gate.id} loader state did not take effect`);
|
|
413
|
+
}
|
|
414
|
+
for (const edit of verify) {
|
|
415
|
+
const row = findRow(after, edit.id);
|
|
416
|
+
const actual = nodeValue(rowConfig(row).getIn(edit.segments, true));
|
|
417
|
+
if (!sameValue(actual, edit.value))
|
|
418
|
+
fail(`${edit.id}.${edit.segments.join('.')} did not take effect in the profile dump`);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
catch (error) {
|
|
422
|
+
if (patch.existed) {
|
|
423
|
+
const restorePath = `${patch.path}.${randomUUID()}.tmp`;
|
|
424
|
+
await writeFile(restorePath, patch.source, { encoding: 'utf8', mode: 0o600 });
|
|
425
|
+
await chmod(restorePath, 0o600);
|
|
426
|
+
await rename(restorePath, patch.path);
|
|
427
|
+
}
|
|
428
|
+
else {
|
|
429
|
+
await rm(patch.path, { force: true });
|
|
430
|
+
}
|
|
431
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
432
|
+
throw new PluginSetupError(`${tag}: verification failed, restored ${patch.path}: ${message}`);
|
|
433
|
+
}
|
|
434
|
+
return result;
|
|
435
|
+
}
|
|
436
|
+
// ---------------------------------------------------------------------------
|
|
437
|
+
// Public API
|
|
438
|
+
export async function listProfilePlugins(target, options = {}) {
|
|
439
|
+
const installed = await readInstalled(target, options.readDump ?? readEffectiveProfileDump);
|
|
440
|
+
const patch = await loadProfilePatch(target.dshHome, installed.profile);
|
|
441
|
+
const plugins = [...installed.rows.entries()]
|
|
442
|
+
.map(([id, row]) => summarize(id, row, profileRows(patch.sequence, id).length > 0))
|
|
443
|
+
.sort((left, right) => left.id.localeCompare(right.id));
|
|
444
|
+
return { profile: installed.profile, patchPath: patch.path, plugins };
|
|
445
|
+
}
|
|
446
|
+
/** Effective config of one plugin with secret-like values redacted. */
|
|
447
|
+
export async function showProfilePlugin(target, options = {}) {
|
|
448
|
+
const installed = await readInstalled(target, options.readDump ?? readEffectiveProfileDump);
|
|
449
|
+
const id = requireInstalled(target.plugin, installed);
|
|
450
|
+
const row = installed.rows.get(id);
|
|
451
|
+
const patch = await loadProfilePatch(target.dshHome, installed.profile);
|
|
452
|
+
const config = redact(rowConfig(row).clone());
|
|
453
|
+
return {
|
|
454
|
+
profile: installed.profile,
|
|
455
|
+
summary: summarize(id, row, profileRows(patch.sequence, id).length > 0),
|
|
456
|
+
config: config.items.length === 0 ? '{}\n' : stringify(config, { lineWidth: 0 }),
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
/**
|
|
460
|
+
* Enable or disable one plugin: flips the loader row gate and, when the plugin
|
|
461
|
+
* has a top-level `enabled` config gate, that gate too.
|
|
462
|
+
*/
|
|
463
|
+
export async function setPluginEnabled(target, options = {}) {
|
|
464
|
+
const installed = await readInstalled(target, options.readDump ?? readEffectiveProfileDump);
|
|
465
|
+
const id = requireInstalled(target.plugin, installed);
|
|
466
|
+
if (!target.enabled && id === 'dsh-enhanced-assistant-policy') {
|
|
467
|
+
fail('refusing to disable assistant-policy: it is the access-control layer other plugins inject; remove the plugin explicitly if that is really intended');
|
|
468
|
+
}
|
|
469
|
+
const effectiveRow = installed.rows.get(id);
|
|
470
|
+
const effective = rowConfig(effectiveRow);
|
|
471
|
+
if (target.enabled && id === 'dsh-enhanced-lark-channel' && larkPlaceholder(effective)) {
|
|
472
|
+
fail('lark-channel still has placeholder credentials; run dsh-lark-setup for this profile first (it enables the channel after onboarding)');
|
|
473
|
+
}
|
|
474
|
+
const patch = await loadProfilePatch(target.dshHome, installed.profile);
|
|
475
|
+
const changes = [];
|
|
476
|
+
const verify = [];
|
|
477
|
+
const wasLoaded = effectiveRow.get('disabled') !== true;
|
|
478
|
+
if (wasLoaded !== target.enabled) {
|
|
479
|
+
profileRow(patch, id, effective).set('disabled', !target.enabled);
|
|
480
|
+
changes.push({ id, key: 'disabled', from: !wasLoaded, to: !target.enabled });
|
|
481
|
+
}
|
|
482
|
+
const gate = effective.get('enabled');
|
|
483
|
+
if (typeof gate === 'boolean' && gate !== target.enabled && !protectedRows.has(id)) {
|
|
484
|
+
if (target.enabled && id === 'dsh-enhanced-assistant-work-items' && effective.get('mode') === 'active'
|
|
485
|
+
&& (effective.get('workerAutomationId') === undefined || effective.get('scope') === undefined)) {
|
|
486
|
+
fail('assistant-work-items mode: active needs workerAutomationId and scope; set mode observe first or complete the active config');
|
|
487
|
+
}
|
|
488
|
+
profileRowConfig(patch, id, effective).set('enabled', target.enabled);
|
|
489
|
+
changes.push({ id, key: 'enabled', from: gate, to: target.enabled });
|
|
490
|
+
verify.push({ id, segments: ['enabled'], value: target.enabled });
|
|
491
|
+
}
|
|
492
|
+
return commit(target, installed, patch, changes, [], verify, [{ id, loaded: target.enabled }], options);
|
|
493
|
+
}
|
|
494
|
+
/** Set one config key (dotted path) after validating it against the effective value type. */
|
|
495
|
+
export async function setPluginConfigValue(target, options = {}) {
|
|
496
|
+
const installed = await readInstalled(target, options.readDump ?? readEffectiveProfileDump);
|
|
497
|
+
const id = requireInstalled(target.plugin, installed);
|
|
498
|
+
const segments = splitKey(target.key);
|
|
499
|
+
assertEditable(id, segments);
|
|
500
|
+
const effective = rowConfig(installed.rows.get(id));
|
|
501
|
+
const current = effective.getIn(segments, true);
|
|
502
|
+
const parent = segments.length > 1 ? effective.getIn(segments.slice(0, -1), true) : effective;
|
|
503
|
+
if (parent !== undefined && !isMap(parent))
|
|
504
|
+
fail(`${segments.slice(0, -1).join('.')} is not a mapping`);
|
|
505
|
+
const value = coerceConfigValue(id, target.key, target.value, current, target.force === true);
|
|
506
|
+
const from = nodeValue(current);
|
|
507
|
+
const patch = await loadProfilePatch(target.dshHome, installed.profile);
|
|
508
|
+
const changes = [];
|
|
509
|
+
const verify = [];
|
|
510
|
+
if (!sameValue(from, value)) {
|
|
511
|
+
profileRowConfig(patch, id, effective).setIn(segments, patch.document.createNode(value));
|
|
512
|
+
changes.push({ id, key: target.key, from, to: value });
|
|
513
|
+
verify.push({ id, segments, value });
|
|
514
|
+
}
|
|
515
|
+
return commit(target, installed, patch, changes, [], verify, [], options);
|
|
516
|
+
}
|
|
517
|
+
/** Remove one config key from the profile override so the plugin default applies. */
|
|
518
|
+
export async function unsetPluginConfigValue(target, options = {}) {
|
|
519
|
+
const installed = await readInstalled(target, options.readDump ?? readEffectiveProfileDump);
|
|
520
|
+
const id = requireInstalled(target.plugin, installed);
|
|
521
|
+
const segments = splitKey(target.key);
|
|
522
|
+
assertEditable(id, segments);
|
|
523
|
+
const effective = rowConfig(installed.rows.get(id));
|
|
524
|
+
const current = effective.getIn(segments, true);
|
|
525
|
+
const patch = await loadProfilePatch(target.dshHome, installed.profile);
|
|
526
|
+
const changes = [];
|
|
527
|
+
if (current !== undefined) {
|
|
528
|
+
profileRowConfig(patch, id, effective).deleteIn(segments);
|
|
529
|
+
changes.push({ id, key: target.key, from: nodeValue(current), to: undefined });
|
|
530
|
+
}
|
|
531
|
+
return commit(target, installed, patch, changes, [], [], [], options);
|
|
532
|
+
}
|
|
533
|
+
/**
|
|
534
|
+
* The `recommended` preset deliberately turns on opt-in features that stay off
|
|
535
|
+
* by default. It edits only plugins that are installed and loaded, never loads
|
|
536
|
+
* a disabled plugin, never touches credentials, Policy, owner routes or sealed
|
|
537
|
+
* plugin state, and leaves the automations scheduler to the RSI/supervised setup.
|
|
538
|
+
*/
|
|
539
|
+
export const recommendedPluginPreset = Object.freeze([
|
|
540
|
+
{
|
|
541
|
+
id: 'dsh-enhanced-assistant-work-items',
|
|
542
|
+
purpose: 'work-item ledger in observe mode plus the deterministic intake ledger (WP20); no admission, worker wake, Delivery or model calls',
|
|
543
|
+
set: [['enabled', true], ['intakeLedger.enabled', true]],
|
|
544
|
+
},
|
|
545
|
+
{
|
|
546
|
+
id: 'dsh-enhanced-assistant-delivery',
|
|
547
|
+
purpose: 'over-cap background notices become a short body plus one text attachment (WP19 stage 2b)',
|
|
548
|
+
set: [['attentionAttachOverCap', true]],
|
|
549
|
+
},
|
|
550
|
+
{
|
|
551
|
+
id: 'dsh-enhanced-assistant-evaluation',
|
|
552
|
+
purpose: 'sync Delivery notification outcome facts into the evaluation ledger (WP22)',
|
|
553
|
+
set: [['notificationOutcomesEnabled', true]],
|
|
554
|
+
},
|
|
555
|
+
{
|
|
556
|
+
id: 'dsh-enhanced-event-triggers',
|
|
557
|
+
purpose: 'run the trigger poller so configured triggers fire without a second switch (allowlists stay empty until configured)',
|
|
558
|
+
set: [['pollerEnabled', true]],
|
|
559
|
+
},
|
|
560
|
+
]);
|
|
561
|
+
export const pluginPresets = new Map([['recommended', recommendedPluginPreset]]);
|
|
562
|
+
/**
|
|
563
|
+
* Apply a named plugin preset (default `recommended`) to one profile in a
|
|
564
|
+
* single verified write. Intended for onboarding flows:
|
|
565
|
+
*
|
|
566
|
+
* ```ts
|
|
567
|
+
* import { applyRecommendedPluginPreset } from '@dsh-enhanced/assistant-policy/plugin-setup'
|
|
568
|
+
* const result = await applyRecommendedPluginPreset({ dshHome, profile: 'web' })
|
|
569
|
+
* ```
|
|
570
|
+
*
|
|
571
|
+
* Idempotent: re-running reports no changes.
|
|
572
|
+
*/
|
|
573
|
+
export async function applyPluginPreset(target, options = {}) {
|
|
574
|
+
const presetName = target.preset ?? 'recommended';
|
|
575
|
+
const preset = pluginPresets.get(presetName);
|
|
576
|
+
if (preset === undefined)
|
|
577
|
+
fail(`unknown preset ${JSON.stringify(presetName)}; available: ${[...pluginPresets.keys()].join(', ')}`);
|
|
578
|
+
const installed = await readInstalled(target, options.readDump ?? readEffectiveProfileDump);
|
|
579
|
+
const patch = await loadProfilePatch(target.dshHome, installed.profile);
|
|
580
|
+
const changes = [];
|
|
581
|
+
const skipped = [];
|
|
582
|
+
const verify = [];
|
|
583
|
+
for (const item of preset) {
|
|
584
|
+
const row = installed.rows.get(item.id);
|
|
585
|
+
if (row === undefined) {
|
|
586
|
+
skipped.push({ id: item.id, reason: 'not installed' });
|
|
587
|
+
continue;
|
|
588
|
+
}
|
|
589
|
+
if (row.get('disabled') === true) {
|
|
590
|
+
skipped.push({ id: item.id, reason: 'plugin is disabled in this profile; enable it first' });
|
|
591
|
+
continue;
|
|
592
|
+
}
|
|
593
|
+
const effective = rowConfig(row);
|
|
594
|
+
if (item.id === 'dsh-enhanced-assistant-work-items' && effective.get('mode') === 'active' && effective.get('enabled') !== true) {
|
|
595
|
+
skipped.push({ id: item.id, reason: 'mode is active but not enabled; complete the active config deliberately' });
|
|
596
|
+
continue;
|
|
597
|
+
}
|
|
598
|
+
for (const [key, value] of item.set) {
|
|
599
|
+
const segments = splitKey(key);
|
|
600
|
+
const parent = segments.length > 1 ? effective.getIn(segments.slice(0, -1), true) : effective;
|
|
601
|
+
if (parent !== undefined && !isMap(parent)) {
|
|
602
|
+
skipped.push({ id: item.id, reason: `${key} parent is not a mapping` });
|
|
603
|
+
continue;
|
|
604
|
+
}
|
|
605
|
+
const from = nodeValue(effective.getIn(segments, true));
|
|
606
|
+
if (sameValue(from, value))
|
|
607
|
+
continue;
|
|
608
|
+
profileRowConfig(patch, item.id, effective).setIn(segments, value);
|
|
609
|
+
changes.push({ id: item.id, key, from, to: value });
|
|
610
|
+
verify.push({ id: item.id, segments, value });
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
return commit(target, installed, patch, changes, skipped, verify, [], options);
|
|
614
|
+
}
|
|
615
|
+
/** Onboarding entry point: apply the `recommended` plugin preset. */
|
|
616
|
+
export function applyRecommendedPluginPreset(target, options = {}) {
|
|
617
|
+
return applyPluginPreset({ ...target, preset: 'recommended' }, options);
|
|
618
|
+
}
|
|
619
|
+
const commands = new Set(['list', 'show', 'enable', 'disable', 'set', 'unset', 'preset']);
|
|
620
|
+
const arity = {
|
|
621
|
+
list: [0, 0], show: [1, 1], enable: [1, 1], disable: [1, 1], set: [2, 3], unset: [2, 2], preset: [0, 1],
|
|
622
|
+
};
|
|
623
|
+
export function parsePluginSetupArgs(argv) {
|
|
624
|
+
const result = {
|
|
625
|
+
command: 'guided',
|
|
626
|
+
positionals: [],
|
|
627
|
+
dshHome: process.env.DSH_HOME ?? join(process.env.HOME ?? '', '.dsh'),
|
|
628
|
+
force: false,
|
|
629
|
+
dryRun: false,
|
|
630
|
+
yes: false,
|
|
631
|
+
json: false,
|
|
632
|
+
help: false,
|
|
633
|
+
};
|
|
634
|
+
let command;
|
|
635
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
636
|
+
const token = argv[index];
|
|
637
|
+
if (token === '--help' || token === '-h')
|
|
638
|
+
result.help = true;
|
|
639
|
+
else if (token === '--force')
|
|
640
|
+
result.force = true;
|
|
641
|
+
else if (token === '--dry-run')
|
|
642
|
+
result.dryRun = true;
|
|
643
|
+
else if (token === '--yes' || token === '-y')
|
|
644
|
+
result.yes = true;
|
|
645
|
+
else if (token === '--json')
|
|
646
|
+
result.json = true;
|
|
647
|
+
else if (token === '--dsh-home' || token === '--profile') {
|
|
648
|
+
const value = argv[index + 1];
|
|
649
|
+
if (value === undefined || value.startsWith('--'))
|
|
650
|
+
fail(`${token} requires a value`);
|
|
651
|
+
index += 1;
|
|
652
|
+
if (token === '--dsh-home')
|
|
653
|
+
result.dshHome = value;
|
|
654
|
+
else
|
|
655
|
+
result.profile = value;
|
|
656
|
+
}
|
|
657
|
+
else if (token.startsWith('--'))
|
|
658
|
+
fail(`unknown option: ${token}`);
|
|
659
|
+
else if (command === undefined)
|
|
660
|
+
command = token;
|
|
661
|
+
else
|
|
662
|
+
result.positionals.push(token);
|
|
663
|
+
}
|
|
664
|
+
if (!isAbsolute(result.dshHome))
|
|
665
|
+
fail('DSH home must be an absolute path');
|
|
666
|
+
if (result.profile !== undefined && !profileNamePattern.test(result.profile))
|
|
667
|
+
fail('--profile must be a valid profile name');
|
|
668
|
+
if (command !== undefined) {
|
|
669
|
+
if (!commands.has(command))
|
|
670
|
+
fail(`unknown command: ${command} (see --help)`);
|
|
671
|
+
result.command = command;
|
|
672
|
+
const [min, max] = arity[result.command];
|
|
673
|
+
if (result.positionals.length > max || result.positionals.length < min) {
|
|
674
|
+
fail(`${command} expects ${min === max ? min : `${min}-${max}`} argument(s); see --help`);
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
return result;
|
|
678
|
+
}
|
|
679
|
+
export function pluginSetupUsage() {
|
|
680
|
+
return [
|
|
681
|
+
'Usage: dsh-plugin-setup [command] [args] [--profile <name>] [--dsh-home <absolute-path>]',
|
|
682
|
+
'',
|
|
683
|
+
'Commands:',
|
|
684
|
+
' (none) guided menu on a terminal; plain list otherwise',
|
|
685
|
+
' list installed @dsh-enhanced plugins, loader state and feature flags',
|
|
686
|
+
' show <plugin> effective config (secret-like values redacted)',
|
|
687
|
+
' enable <plugin> load the plugin and turn on its `enabled` gate if it has one',
|
|
688
|
+
' disable <plugin> stop loading the plugin',
|
|
689
|
+
' set <plugin> <key> [<value>] set one config key (dotted, e.g. intakeLedger.enabled);',
|
|
690
|
+
' the value is checked against the current type; prompts if omitted',
|
|
691
|
+
' unset <plugin> <key> drop one key from the profile override (plugin default applies)',
|
|
692
|
+
' preset [recommended] turn on the recommended opt-in features of installed plugins',
|
|
693
|
+
'',
|
|
694
|
+
'Options:',
|
|
695
|
+
' --profile <name> target profile (default: $DSH_PROFILE, or the only profile in the home)',
|
|
696
|
+
' --dry-run show the change without writing',
|
|
697
|
+
' --force allow keys absent from the effective config, or replace a !!js expression',
|
|
698
|
+
' --yes apply a preset without the confirmation prompt',
|
|
699
|
+
' --json machine-readable output',
|
|
700
|
+
'',
|
|
701
|
+
'<plugin> accepts work-items, assistant-work-items, @dsh-enhanced/assistant-work-items or the row id.',
|
|
702
|
+
'Writes only $DSH_HOME/profiles/<profile>/cordis.patch.yml, verifies with dsh --dump-config and restores',
|
|
703
|
+
'the previous file if verification fails. Restart the profile afterwards (dsh-rsi restart --profile <name>).',
|
|
704
|
+
'Never writes secrets and never edits Policy, owner routes or sealed plugin state.',
|
|
705
|
+
].join('\n');
|
|
706
|
+
}
|
|
707
|
+
function defaultIo() {
|
|
708
|
+
const isTTY = process.stdin.isTTY === true && process.stdout.isTTY === true;
|
|
709
|
+
return {
|
|
710
|
+
write: text => { process.stdout.write(text); },
|
|
711
|
+
isTTY,
|
|
712
|
+
ask: async (question) => {
|
|
713
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
714
|
+
try {
|
|
715
|
+
return (await rl.question(question)).trim();
|
|
716
|
+
}
|
|
717
|
+
finally {
|
|
718
|
+
rl.close();
|
|
719
|
+
}
|
|
720
|
+
},
|
|
721
|
+
};
|
|
722
|
+
}
|
|
723
|
+
function formatValue(value) {
|
|
724
|
+
return value === undefined ? '(unset)' : JSON.stringify(value);
|
|
725
|
+
}
|
|
726
|
+
function formatList(profile, patchPath, plugins) {
|
|
727
|
+
const lines = [`Profile ${profile} (${patchPath})`];
|
|
728
|
+
if (plugins.length === 0)
|
|
729
|
+
lines.push(' no @dsh-enhanced plugins installed; add one with dsh plugin --profile <name> add @dsh-enhanced/<plugin>');
|
|
730
|
+
const width = Math.max(10, ...plugins.map(plugin => plugin.slug.length));
|
|
731
|
+
plugins.forEach((plugin, index) => {
|
|
732
|
+
const state = !plugin.loaded ? 'off' : plugin.enabled === false ? 'idle' : 'on';
|
|
733
|
+
const flags = Object.entries(plugin.flags).map(([key, value]) => `${key}=${value}`);
|
|
734
|
+
if (plugin.enabled !== undefined)
|
|
735
|
+
flags.unshift(`enabled=${plugin.enabled}`);
|
|
736
|
+
lines.push(` ${String(index + 1).padStart(2)}. ${plugin.slug.padEnd(width)} ${state.padEnd(4)} ${flags.join(' ')}${plugin.overridden ? ' [profile override]' : ''}`);
|
|
737
|
+
});
|
|
738
|
+
lines.push('', 'on = loaded; idle = loaded with config enabled=false; off = disabled loader row');
|
|
739
|
+
return `${lines.join('\n')}\n`;
|
|
740
|
+
}
|
|
741
|
+
function formatWrite(result) {
|
|
742
|
+
const lines = [];
|
|
743
|
+
for (const change of result.changes)
|
|
744
|
+
lines.push(` ${change.id}.${change.key}: ${formatValue(change.from)} -> ${formatValue(change.to)}`);
|
|
745
|
+
for (const skip of result.skipped)
|
|
746
|
+
lines.push(` skipped ${skip.id}: ${skip.reason}`);
|
|
747
|
+
if (result.changes.length === 0)
|
|
748
|
+
lines.unshift(`No change for profile ${result.profile}.`);
|
|
749
|
+
else if (result.dryRun)
|
|
750
|
+
lines.unshift(`Dry run, would update ${result.patchPath}:`);
|
|
751
|
+
else
|
|
752
|
+
lines.unshift(`Updated ${result.patchPath}:`);
|
|
753
|
+
if (result.changed && !result.dryRun)
|
|
754
|
+
lines.push(`Restart the profile to apply: dsh-rsi restart --profile ${result.profile}`);
|
|
755
|
+
return `${lines.join('\n')}\n`;
|
|
756
|
+
}
|
|
757
|
+
async function guided(args, io, options) {
|
|
758
|
+
const ask = io.ask;
|
|
759
|
+
const target = { dshHome: args.dshHome, ...(args.profile !== undefined ? { profile: args.profile } : {}) };
|
|
760
|
+
for (;;) {
|
|
761
|
+
const listed = await listProfilePlugins(target, options);
|
|
762
|
+
io.write(formatList(listed.profile, listed.patchPath, listed.plugins));
|
|
763
|
+
const choice = await ask('Plugin number or name (p = recommended preset, q = quit): ');
|
|
764
|
+
if (choice === '' || choice === 'q')
|
|
765
|
+
return;
|
|
766
|
+
if (choice === 'p') {
|
|
767
|
+
const plan = await applyPluginPreset({ ...target, profile: listed.profile }, { ...options, dryRun: true });
|
|
768
|
+
io.write(formatWrite(plan));
|
|
769
|
+
if (plan.changes.length > 0 && /^y(es)?$/iu.test(await ask('Apply? [y/N] '))) {
|
|
770
|
+
io.write(formatWrite(await applyPluginPreset({ ...target, profile: listed.profile }, options)));
|
|
771
|
+
}
|
|
772
|
+
continue;
|
|
773
|
+
}
|
|
774
|
+
const numeric = Number(choice);
|
|
775
|
+
const plugin = Number.isInteger(numeric) && numeric >= 1 && numeric <= listed.plugins.length
|
|
776
|
+
? listed.plugins[numeric - 1].id
|
|
777
|
+
: choice;
|
|
778
|
+
try {
|
|
779
|
+
const shown = await showProfilePlugin({ ...target, profile: listed.profile, plugin }, options);
|
|
780
|
+
io.write(`\n${shown.summary.id}:\n${shown.config.replace(/^/gmu, ' ')}\n`);
|
|
781
|
+
const action = (await ask('[e]nable, [d]isable, [s]et key, [u]nset key, [b]ack: ')).toLowerCase();
|
|
782
|
+
let result;
|
|
783
|
+
if (action === 'e' || action === 'd') {
|
|
784
|
+
result = await setPluginEnabled({ ...target, profile: listed.profile, plugin, enabled: action === 'e' }, options);
|
|
785
|
+
}
|
|
786
|
+
else if (action === 's') {
|
|
787
|
+
const key = await ask('Key (dotted): ');
|
|
788
|
+
const value = await ask(`Value for ${key}: `);
|
|
789
|
+
result = await setPluginConfigValue({ ...target, profile: listed.profile, plugin, key, value, force: args.force }, options);
|
|
790
|
+
}
|
|
791
|
+
else if (action === 'u') {
|
|
792
|
+
result = await unsetPluginConfigValue({ ...target, profile: listed.profile, plugin, key: await ask('Key (dotted): ') }, options);
|
|
793
|
+
}
|
|
794
|
+
if (result !== undefined)
|
|
795
|
+
io.write(formatWrite(result));
|
|
796
|
+
}
|
|
797
|
+
catch (error) {
|
|
798
|
+
if (!(error instanceof PluginSetupError))
|
|
799
|
+
throw error;
|
|
800
|
+
io.write(`${error.message}\n`);
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
export async function runPluginSetup(argv = process.argv.slice(2), io = defaultIo(), options = {}) {
|
|
805
|
+
const args = parsePluginSetupArgs(argv);
|
|
806
|
+
if (args.help) {
|
|
807
|
+
io.write(`${pluginSetupUsage()}\n`);
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
810
|
+
const shared = { ...options, dryRun: args.dryRun };
|
|
811
|
+
const target = { dshHome: args.dshHome, ...(args.profile !== undefined ? { profile: args.profile } : {}) };
|
|
812
|
+
const emit = (value, text) => io.write(args.json ? `${JSON.stringify(value, null, 2)}\n` : text);
|
|
813
|
+
const [first, second, third] = args.positionals;
|
|
814
|
+
switch (args.command) {
|
|
815
|
+
case 'guided':
|
|
816
|
+
case 'list': {
|
|
817
|
+
if (args.command === 'guided' && io.isTTY && io.ask !== undefined && !args.json)
|
|
818
|
+
return guided(args, io, shared);
|
|
819
|
+
const listed = await listProfilePlugins(target, shared);
|
|
820
|
+
return emit(listed, formatList(listed.profile, listed.patchPath, listed.plugins));
|
|
821
|
+
}
|
|
822
|
+
case 'show': {
|
|
823
|
+
const shown = await showProfilePlugin({ ...target, plugin: first }, shared);
|
|
824
|
+
return emit(shown, `${shown.summary.id} (profile ${shown.profile}, ${shown.summary.loaded ? 'loaded' : 'disabled'}):\n${shown.config}`);
|
|
825
|
+
}
|
|
826
|
+
case 'enable':
|
|
827
|
+
case 'disable': {
|
|
828
|
+
const result = await setPluginEnabled({ ...target, plugin: first, enabled: args.command === 'enable' }, shared);
|
|
829
|
+
return emit(result, formatWrite(result));
|
|
830
|
+
}
|
|
831
|
+
case 'set': {
|
|
832
|
+
let value = third;
|
|
833
|
+
if (value === undefined) {
|
|
834
|
+
if (!io.isTTY || io.ask === undefined)
|
|
835
|
+
fail('set needs a value when not running on a terminal');
|
|
836
|
+
value = await io.ask(`Value for ${second}: `);
|
|
837
|
+
}
|
|
838
|
+
const result = await setPluginConfigValue({ ...target, plugin: first, key: second, value, force: args.force }, shared);
|
|
839
|
+
return emit(result, formatWrite(result));
|
|
840
|
+
}
|
|
841
|
+
case 'unset': {
|
|
842
|
+
const result = await unsetPluginConfigValue({ ...target, plugin: first, key: second }, shared);
|
|
843
|
+
return emit(result, formatWrite(result));
|
|
844
|
+
}
|
|
845
|
+
case 'preset': {
|
|
846
|
+
const preset = first ?? 'recommended';
|
|
847
|
+
if (!args.dryRun && !args.yes && io.isTTY && io.ask !== undefined && !args.json) {
|
|
848
|
+
const plan = await applyPluginPreset({ ...target, preset }, { ...shared, dryRun: true });
|
|
849
|
+
io.write(formatWrite(plan));
|
|
850
|
+
if (plan.changes.length === 0)
|
|
851
|
+
return;
|
|
852
|
+
if (!/^y(es)?$/iu.test(await io.ask('Apply? [y/N] '))) {
|
|
853
|
+
io.write('Not applied.\n');
|
|
854
|
+
return;
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
const result = await applyPluginPreset({ ...target, preset }, shared);
|
|
858
|
+
return emit(result, formatWrite(result));
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
//# sourceMappingURL=plugin-setup.js.map
|