dsh-perm-gate 4.1.2 → 4.2.0-beta.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.
@@ -1,7 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * Give this plugin's own permission tiers the composer glyphs the host will not
4
- * draw for them.
3
+ * Add `permissive-full` to the DSH composer's permission-glyph map.
5
4
  *
6
5
  * WHY THIS EXISTS
7
6
  * ---------------
@@ -10,40 +9,22 @@
10
9
  *
11
10
  * const permissionGlyphs = new Map([
12
11
  * ["read-only", <svg …/>],
12
+ * ["permissive", <svg …/>], // the 自动审查 tier's shield+eye
13
13
  * ["workspace-write", <svg …/>],
14
- * [FULL_ACCESS, <svg …/>], // const FULL_ACCESS = "danger-full-access"
14
+ * ["danger-full-access", <svg …/>],
15
15
  * ]);
16
16
  * function permissionGlyph(value) { return permissionGlyphs.get(value); }
17
17
  *
18
18
  * The source comment states the rule: "host-configured names outside the design
19
19
  * set get none." There is no plugin-facing seam — the option objects the host
20
20
  * supplies carry only `{value, name, description}`, and the map itself is a
21
- * closure variable. So a plugin-contributed tier renders with no icon in the
22
- * dropdown and falls back to plain text in the trigger.
21
+ * closure variable. So a plugin-contributed tier such as `permissive-full`
22
+ * renders with no icon in the dropdown and falls back to plain text in the
23
+ * trigger, while the built-in `permissive` tier shows the shield+eye.
23
24
  *
24
- * The only way to give our tiers the same glyph is to add entries to that map.
25
- * This script does exactly that — one glyph per tier, copied from the built-in
26
- * tier whose file sandbox the plugin tier shares:
27
- *
28
- * permissive sandbox: workspace-write <- the ["workspace-write",] entry
29
- * permissive-full sandbox: danger-full-access <- the [FULL_ACCESS,] entry
30
- *
31
- * WHY THE SOURCES ARE DECLARED AND THE ANCHOR IS RESOLVED
32
- * -------------------------------------------------------
33
- * DSH 0.1.2 shipped four glyphs, `permissive` among them; 0.1.5-rc.2 ships three
34
- * (`read-only` / `workspace-write` / `danger-full-access`) and the string
35
- * `permissive` no longer occurs anywhere in the bundle — so a splice anchored on
36
- * `["permissive",` dies with "anchor not found" on the current host. Two
37
- * properties keep this working across such a design-set change:
38
- *
39
- * 1. the source key is a declaration (`GLYPH_TARGETS`) rather than a literal
40
- * buried in the splice, so re-pointing a tier is a one-line edit; and
41
- * 2. the anchor for a key is RESOLVED, not assumed to be a string literal:
42
- * `["<key>",` first, then the constant alias the bundle may use instead
43
- * (`[FULL_ACCESS,` for `"danger-full-access"`).
44
- *
45
- * When a declared source key is gone, the refusal names the keys the map DOES
46
- * carry, so the next drift is diagnosed from the error text alone.
25
+ * The only way to give our tier the same glyph is to add an entry to that map.
26
+ * This script does exactly that: it duplicates the `permissive` entry under the
27
+ * `permissive-full` key, so the two tiers of one plugin look alike.
47
28
  *
48
29
  * VOLUNTARY, NOT AUTOMATIC
49
30
  * ------------------------
@@ -61,11 +42,8 @@
61
42
  * npx dsh-perm-gate-patch-glyph --check # report only; exit 1 if missing
62
43
  * node scripts/patch-permission-glyph.mjs <path/to/client.js>
63
44
  *
64
- * With no argument it probes the profile scopes first and the CLI install second,
65
- * and lists any other DSH install that carries the same bundle — on a host with
66
- * both, only one of them serves the UI, so the explicit-path form is the answer
67
- * when the probe picks the wrong one. The script is idempotent (a second run is a
68
- * no-op), backs the file up once, applies every target or none, and refuses to
45
+ * With no argument it probes the usual global-install locations. The script is
46
+ * idempotent (a second run is a no-op), backs the file up once, and refuses to
69
47
  * write a bundle it cannot slice correctly, so it can never half-apply.
70
48
  */
71
49
  import { existsSync, readFileSync, writeFileSync, copyFileSync } from 'node:fs'
@@ -74,23 +52,13 @@ import { homedir } from 'node:os'
74
52
  import { dirname, join, resolve } from 'node:path'
75
53
  import { pathToFileURL } from 'node:url'
76
54
 
55
+ /** The map key whose entry is cloned, and the key it is cloned onto. */
56
+ export const SOURCE_KEY = 'permissive'
57
+ export const TARGET_KEY = 'permissive-full'
58
+
77
59
  /** Relative path from a package root to the bundle holding the map. */
78
60
  const BUNDLE = join('@deepseek-ai', 'dsh-client-ui-conversation', 'lib', 'client.js')
79
61
 
80
- /**
81
- * The glyphs to add: each plugin tier, and the built-in tier it copies.
82
- *
83
- * The pairing is by file-sandbox meaning, not by name similarity — `permissive`
84
- * runs in the same sandbox as `workspace-write`, `permissive-full` in the same
85
- * sandbox as `danger-full-access` — so the icon a user sees matches the access
86
- * the tier actually grants. A tier listed here gets that built-in's entry
87
- * verbatim apart from its key.
88
- */
89
- export const GLYPH_TARGETS = [
90
- { target: 'permissive', source: 'workspace-write' },
91
- { target: 'permissive-full', source: 'danger-full-access' },
92
- ]
93
-
94
62
  /** Thrown for every refusal; the CLI turns it into an exit code 1. */
95
63
  export class GlyphPatchError extends Error {}
96
64
 
@@ -98,15 +66,6 @@ export class GlyphPatchError extends Error {}
98
66
  * Candidate bundle paths, most specific first. Pure: no filesystem access, so
99
67
  * the probe order is unit-testable.
100
68
  *
101
- * Profile scope before CLI-install scope, and that order is load-bearing on a
102
- * machine with more than one DSH: a profile's UI is served by the profile's own
103
- * dependency scope, not by whichever global CLI install happens to sit beside the
104
- * running `node`. Measured on this project's own host — a global install under
105
- * `dirname(node)` existed and shadowed the profile's bundle under an older probe
106
- * order, so the patch reported success while the picker stayed icon-free. On a
107
- * stock single-install machine every candidate is one inode, so the order decides
108
- * nothing there; it only matters exactly when guessing wrong is silent.
109
- *
110
69
  * `execDir` is `dirname(process.execPath)`. Deriving from the running node
111
70
  * binary — never from a hardcoded nvm version or install path — is what keeps
112
71
  * the script working across node upgrades and re-pointed install symlinks.
@@ -116,111 +75,19 @@ export class GlyphPatchError extends Error {}
116
75
  */
117
76
  export function candidateBundles({ execDir, cwd, dshHome }) {
118
77
  return [
119
- // 1. Whatever profile the caller is standing in.
120
- join(cwd, 'node_modules', BUNDLE),
121
- // 2. The hoisted scope every profile under DSH_HOME shares.
122
- join(dshHome, 'profiles', 'node_modules', BUNDLE),
123
- // 3. `@deepseek-ai` scope hoisted beside the running node binary.
78
+ // 1. `@deepseek-ai` scope hoisted beside the running node binary.
124
79
  join(execDir, 'node_modules', BUNDLE),
125
- // 4. The same scope nested inside the `dsh` package itself.
80
+ // 2. The same scope nested inside the `dsh` package itself.
126
81
  join(execDir, 'node_modules', '@deepseek-ai', 'dsh', 'node_modules', BUNDLE),
82
+ // 3. Whatever profile the caller is standing in.
83
+ join(cwd, 'node_modules', BUNDLE),
84
+ // 4. The hoisted scope shared by every profile under DSH_HOME.
85
+ join(dshHome, 'profiles', 'node_modules', BUNDLE),
127
86
  ]
128
87
  }
129
88
 
130
89
  /**
131
- * In-page string constants (`const FULL_ACCESS = "danger-full-access"`), as
132
- * name -> value. First declaration wins, so a later reassignment cannot shadow
133
- * the spelling the map was built with above it.
134
- *
135
- * The bundle may key a map entry by such a name rather than by a literal, which
136
- * is the whole reason the anchor is resolved instead of concatenated.
137
- *
138
- * @param {string} source
139
- * @returns {Map<string, string>} constant name -> its string value
140
- */
141
- export function pageConstants(source) {
142
- const constants = new Map()
143
- for (const match of source.matchAll(/(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*"([^"]*)"/g)) {
144
- if (!constants.has(match[1])) constants.set(match[1], match[2])
145
- }
146
- return constants
147
- }
148
-
149
- /**
150
- * The `permissionGlyphs` map's text, brackets included.
151
- *
152
- * Parsed rather than assumed: the map is a `new Map([ … ])` closure variable, so
153
- * its extent is found by bracket depth from its `[`. Reading the map (instead of
154
- * only probing for one anchor inside it) is what lets a refusal report the keys
155
- * the host actually renders.
156
- *
157
- * @param {string} source
158
- * @returns {string} the map array text, from `[` to the matching `]`
159
- */
160
- export function glyphMapText(source) {
161
- const start = source.search(/permissionGlyphs\s*=\s*new Map\(\s*\[/)
162
- if (start === -1) throw new GlyphPatchError('permissionGlyphs map not found in this bundle')
163
- const open = source.indexOf('[', start)
164
- let depth = 0
165
- for (let i = open; i < source.length; i += 1) {
166
- const ch = source[i]
167
- if (ch === '[' || ch === '(' || ch === '{') depth += 1
168
- else if (ch === ']' || ch === ')' || ch === '}') {
169
- depth -= 1
170
- if (depth === 0) return source.slice(open, i + 1)
171
- }
172
- }
173
- throw new GlyphPatchError('unterminated permissionGlyphs map')
174
- }
175
-
176
- /**
177
- * The preset values the map renders a glyph for — literals as-is, and entries
178
- * keyed by an in-page constant resolved to that constant's value.
179
- *
180
- * @param {string} source
181
- * @returns {string[]} e.g. `['read-only', 'workspace-write', 'danger-full-access']`
182
- */
183
- export function glyphKeys(source) {
184
- const constants = pageConstants(source)
185
- const keys = []
186
- for (const match of glyphMapText(source).matchAll(/\[\s*(?:"([^"]+)"|([A-Za-z_$][\w$]*))\s*,/g)) {
187
- if (match[1] !== undefined) keys.push(match[1])
188
- else keys.push(constants.get(match[2]) ?? match[2])
189
- }
190
- return keys
191
- }
192
-
193
- /**
194
- * Resolve the anchor text of one map entry, literal first, constant alias second.
195
- *
196
- * @param {string} source the bundle's text
197
- * @param {string} key the preset value, e.g. `danger-full-access`
198
- * @returns {{ anchor: string, index: number }}
199
- * @throws {GlyphPatchError} when the host renders no glyph for `key`
200
- */
201
- export function resolveAnchor(source, key) {
202
- for (const anchor of [`["${key}",`, `['${key}',`]) {
203
- const index = source.indexOf(anchor)
204
- if (index !== -1) return { anchor, index }
205
- }
206
- const name = [...pageConstants(source)].find(([, value]) => value === key)?.[0]
207
- if (name !== undefined) {
208
- const anchor = `[${name},`
209
- const index = source.indexOf(anchor)
210
- if (index !== -1) return { anchor, index }
211
- throw new GlyphPatchError(
212
- `anchor not found: ${anchor} (the alias for "${key}")`,
213
- )
214
- }
215
- throw new GlyphPatchError(
216
- `this host renders no glyph for "${key}" — the map carries: ` +
217
- `${glyphKeys(source).map((k) => `"${k}"`).join(', ')}\n` +
218
- ' Re-point GLYPH_TARGETS at a key the map does carry.',
219
- )
220
- }
221
-
222
- /**
223
- * Extract the full `["<key>", …]` (or `[CONST, …]`) map entry, by bracket depth.
90
+ * Extract the full `["<key>", …]` map entry, by bracket depth.
224
91
  *
225
92
  * The scan starts ON the entry's opening `[` (the one immediately before the
226
93
  * key), so depth returns to zero exactly at that entry's closing `]`. Starting
@@ -231,12 +98,12 @@ export function resolveAnchor(source, key) {
231
98
  * contain no brackets.
232
99
  *
233
100
  * @param {string} source
234
- * @param {string} anchor the text returned by {@link resolveAnchor}
101
+ * @param {string} key
235
102
  * @returns {string} the entry, brackets included
236
103
  */
237
- export function sliceEntry(source, anchor) {
238
- const start = source.indexOf(anchor)
239
- if (start === -1) throw new GlyphPatchError(`anchor not found: ${anchor}`)
104
+ export function sliceEntry(source, key) {
105
+ const start = source.indexOf(`["${key}",`)
106
+ if (start === -1) throw new GlyphPatchError(`anchor not found: ["${key}",`)
240
107
  let depth = 0
241
108
  for (let i = start; i < source.length; i += 1) {
242
109
  const ch = source[i]
@@ -246,41 +113,24 @@ export function sliceEntry(source, anchor) {
246
113
  if (depth === 0) return source.slice(start, i + 1)
247
114
  }
248
115
  }
249
- throw new GlyphPatchError(`unterminated entry for ${anchor}`)
116
+ throw new GlyphPatchError(`unterminated entry for ["${key}",`)
250
117
  }
251
118
 
252
119
  /**
253
- * Splice every declared glyph into the map.
254
- *
255
- * All or nothing: a source key this host does not carry throws before the caller
256
- * can write anything, so a partially-glyphed bundle is impossible. Idempotent
257
- * per target — a target already present is left untouched and reported as such.
120
+ * Clone the `permissive` glyph entry onto the `permissive-full` key.
258
121
  *
259
122
  * @param {string} source the bundle's text
260
- * @returns {{ changed: boolean, source: string, added: string[], already: string[] }}
123
+ * @returns {{ changed: boolean, source: string, entry: string | null }}
124
+ * `changed: false` means the target key was already present (idempotent no-op).
261
125
  */
262
126
  export function applyGlyphPatch(source) {
263
- let next = source
264
- const added = []
265
- const already = []
266
-
267
- for (const { target, source: sourceKey } of GLYPH_TARGETS) {
268
- if (next.includes(`["${target}",`)) {
269
- already.push(target)
270
- continue
271
- }
272
- const { anchor } = resolveAnchor(next, sourceKey)
273
- const sourceEntry = sliceEntry(next, anchor)
274
- if (!sourceEntry.includes('svg')) {
275
- throw new GlyphPatchError(`the ${anchor} entry does not look like a glyph`)
276
- }
277
- const clonedEntry = sourceEntry.replace(anchor, `["${target}",`)
278
- // A function replacement: the entry is spliced verbatim, `$` never expanded.
279
- next = next.replace(sourceEntry, () => `${sourceEntry},\n\t\t\t${clonedEntry}`)
280
- added.push(target)
127
+ if (source.includes(`["${TARGET_KEY}",`)) return { changed: false, source, entry: null }
128
+ const sourceEntry = sliceEntry(source, SOURCE_KEY)
129
+ if (!sourceEntry.includes('svg')) {
130
+ throw new GlyphPatchError(`the ["${SOURCE_KEY}",] entry does not look like a glyph`)
281
131
  }
282
-
283
- return { changed: added.length > 0, source: next, added, already }
132
+ const clonedEntry = sourceEntry.replace(`["${SOURCE_KEY}",`, `["${TARGET_KEY}",`)
133
+ return { changed: true, source: source.replace(sourceEntry, `${sourceEntry},\n\t\t\t${clonedEntry}`), entry: clonedEntry }
284
134
  }
285
135
 
286
136
  /** Resolve the bundle to patch, or throw with an explanation. */
@@ -301,29 +151,8 @@ export function findBundle(argv, where) {
301
151
  )
302
152
  }
303
153
 
304
- /**
305
- * A warning block naming the other DSH installs that carry this bundle.
306
- *
307
- * Reported rather than patched: on a host with a global CLI install and a
308
- * launcher-managed runtime, only one of them serves the UI in front of the user,
309
- * and picking the wrong one is invisible — the patch succeeds and the picker
310
- * stays icon-free. Naming the others turns that into a one-line fix (pass the
311
- * path explicitly).
312
- *
313
- * @param {string[]} others bundle paths that exist but were not chosen
314
- * @returns {string} an empty string when there is nothing to report
315
- */
316
- function describeOtherInstalls(others) {
317
- if (others.length === 0) return ''
318
- return ` note: ${others.length} other DSH install(s) carry this bundle — NOT patched:\n` +
319
- others.map((path) => ` ${path}\n`).join('') +
320
- ' If the tier still has no icon, the UI is served by one of those; re-run with\n' +
321
- ' that path as the argument.\n'
322
- }
323
-
324
154
  function main(argv) {
325
155
  const check = argv.includes('--check')
326
- const explicit = argv.slice(2).find((arg) => !arg.startsWith('-'))
327
156
  const where = {
328
157
  execDir: dirname(process.execPath),
329
158
  cwd: process.cwd(),
@@ -331,27 +160,17 @@ function main(argv) {
331
160
  }
332
161
 
333
162
  const bundle = findBundle(argv, where)
334
- const others = explicit === undefined
335
- ? candidateBundles(where).filter((candidate) => candidate !== bundle && existsSync(candidate))
336
- : []
337
- const otherNote = describeOtherInstalls(others)
338
163
  const original = readFileSync(bundle, 'utf8')
339
- const { changed, source: patched, added, already } = applyGlyphPatch(original)
164
+ const { changed, source: patched } = applyGlyphPatch(original)
340
165
 
341
166
  if (!changed) {
342
- process.stdout.write(
343
- `patch-permission-glyph: all glyphs already present in\n ${bundle}\n` +
344
- ` present: ${already.map((key) => `["${key}",]`).join(' ')}\n` +
345
- otherNote,
346
- )
167
+ process.stdout.write(`patch-permission-glyph: ["${TARGET_KEY}",] already present in\n ${bundle}\n`)
347
168
  return 0
348
169
  }
349
170
  if (check) {
350
171
  process.stdout.write(
351
- `patch-permission-glyph: MISSING ${added.map((key) => `["${key}",]`).join(' ')} from\n ${bundle}\n` +
352
- ` present: ${already.length > 0 ? already.map((key) => `["${key}",]`).join(' ') : '(none)'}\n` +
353
- ' (--check: nothing written; re-run without --check to apply)\n' +
354
- otherNote,
172
+ `patch-permission-glyph: ["${TARGET_KEY}",] is MISSING from\n ${bundle}\n` +
173
+ ' (--check: nothing written; re-run without --check to apply)\n',
355
174
  )
356
175
  return 1
357
176
  }
@@ -370,11 +189,9 @@ function main(argv) {
370
189
  }
371
190
 
372
191
  process.stdout.write(
373
- `patch-permission-glyph: added ${added.map((key) => `["${key}",]`).join(' ')} to\n ${bundle}\n` +
374
- already.map((key) => ` already present: ["${key}",]\n`).join('') +
192
+ `patch-permission-glyph: added ["${TARGET_KEY}",] to\n ${bundle}\n` +
375
193
  ` backup: ${backup}\n` +
376
- ' NOTE: this edits a DSH package — re-run after any DSH upgrade or reinstall.\n' +
377
- otherNote,
194
+ ' NOTE: this edits a DSH package — re-run after any DSH upgrade or reinstall.\n',
378
195
  )
379
196
  return 0
380
197
  }