@deepseek-ai/dsh-app-boot 0.2.2-alpha.3 → 0.2.2-rc.2

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.
Files changed (73) hide show
  1. package/README.i18n.yaml +4 -4
  2. package/README.md +4 -2
  3. package/README.zh.md +4 -2
  4. package/lib/index.js +37 -17
  5. package/lib/types/bundle-platform.d.ts +4 -2
  6. package/lib/types/bundle-platform.d.ts.map +1 -0
  7. package/lib/types/bundle-platform.js.map +1 -0
  8. package/lib/types/compatibility-preflight.d.ts.map +1 -0
  9. package/lib/types/compatibility-preflight.js.map +1 -0
  10. package/lib/types/config-schema/collect.d.ts.map +1 -0
  11. package/lib/types/config-schema/collect.js.map +1 -0
  12. package/lib/types/config-schema/document.d.ts.map +1 -0
  13. package/lib/types/config-schema/document.js.map +1 -0
  14. package/lib/types/config-schema/index.d.ts.map +1 -0
  15. package/lib/types/config-schema/index.js.map +1 -0
  16. package/lib/types/config-schema/native.d.ts.map +1 -0
  17. package/lib/types/config-schema/native.js.map +1 -0
  18. package/lib/types/config-schema/pattern.d.ts.map +1 -0
  19. package/lib/types/config-schema/pattern.js.map +1 -0
  20. package/lib/types/config-schema/projector.d.ts.map +1 -0
  21. package/lib/types/config-schema/projector.js.map +1 -0
  22. package/lib/types/config-schema/types.d.ts.map +1 -0
  23. package/lib/types/config-schema/types.js.map +1 -0
  24. package/lib/types/index.d.ts +1 -1
  25. package/lib/types/index.d.ts.map +1 -0
  26. package/lib/types/index.js.map +1 -0
  27. package/lib/types/package-meta.d.ts.map +1 -0
  28. package/lib/types/package-meta.js.map +1 -0
  29. package/lib/types/plugin-compatibility.d.ts.map +1 -0
  30. package/lib/types/plugin-compatibility.js.map +1 -0
  31. package/lib/types/profile-compatibility.d.ts.map +1 -0
  32. package/lib/types/profile-compatibility.js.map +1 -0
  33. package/lib/types/profile-context.d.ts.map +1 -0
  34. package/lib/types/profile-context.js.map +1 -0
  35. package/lib/types/profile-plugins.d.ts.map +1 -0
  36. package/lib/types/profile-plugins.js.map +1 -0
  37. package/lib/types/profile-resolution/legacy-links.d.ts.map +1 -0
  38. package/lib/types/profile-resolution/legacy-links.js.map +1 -0
  39. package/lib/types/profile-resolution/resolver.d.ts.map +1 -0
  40. package/lib/types/profile-resolution/resolver.js.map +1 -0
  41. package/lib/types/profile-resolution/service.d.ts.map +1 -0
  42. package/lib/types/profile-resolution/service.js.map +1 -0
  43. package/lib/types/profile-resolution/worker-bootstrap.d.ts.map +1 -0
  44. package/lib/types/profile-resolution/worker-bootstrap.js.map +1 -0
  45. package/lib/types/profile-sanitize.d.ts.map +1 -0
  46. package/lib/types/profile-sanitize.js.map +1 -0
  47. package/lib/types/profile.d.ts.map +1 -0
  48. package/lib/types/profile.js.map +1 -0
  49. package/lib/types/startup-diagnostics.d.ts.map +1 -0
  50. package/lib/types/startup-diagnostics.js.map +1 -0
  51. package/package.json +25 -21
  52. package/src/bundle-platform.ts +35 -0
  53. package/src/compatibility-preflight.ts +187 -0
  54. package/src/config-schema/collect.ts +249 -0
  55. package/src/config-schema/document.ts +169 -0
  56. package/src/config-schema/index.ts +36 -0
  57. package/src/config-schema/native.ts +16 -0
  58. package/src/config-schema/pattern.ts +62 -0
  59. package/src/config-schema/projector.ts +455 -0
  60. package/src/config-schema/types.ts +95 -0
  61. package/src/index.ts +1286 -0
  62. package/src/package-meta.ts +200 -0
  63. package/src/plugin-compatibility.ts +117 -0
  64. package/src/profile-compatibility.ts +141 -0
  65. package/src/profile-context.ts +85 -0
  66. package/src/profile-plugins.ts +133 -0
  67. package/src/profile-resolution/legacy-links.ts +33 -0
  68. package/src/profile-resolution/resolver.ts +1072 -0
  69. package/src/profile-resolution/service.ts +148 -0
  70. package/src/profile-resolution/worker-bootstrap.ts +12 -0
  71. package/src/profile-sanitize.ts +34 -0
  72. package/src/profile.ts +762 -0
  73. package/src/startup-diagnostics.ts +104 -0
@@ -0,0 +1,200 @@
1
+ /** Read plugin display text and icons through exported resources without evaluating plugin code. */
2
+
3
+ import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from 'node:fs'
4
+ import { dirname, extname, isAbsolute, join, relative, resolve, sep, win32 } from 'node:path'
5
+ import { fileURLToPath } from 'node:url'
6
+ import { ModuleLoader } from '@deepseek-ai/cordis-plugin-loader'
7
+ import type { LocalizedText, PluginLocalizedMeta } from '@deepseek-ai/dsh-package-manifest'
8
+ import { barePackageName } from './profile-resolution/resolver.ts'
9
+ import { realModuleFile } from './profile-resolution/legacy-links.ts'
10
+
11
+ const LANGUAGE_ID = /^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$/u
12
+
13
+ type DisplayFields = Record<'title' | 'description', string | undefined>
14
+
15
+ /** Maximum raw icon bytes admitted to plugin metadata. */
16
+ const MAX_ICON_BYTES = 256 * 1024
17
+ const ICON_MEDIA_TYPES = new Map([
18
+ ['.svg', 'image/svg+xml'], ['.png', 'image/png'], ['.jpg', 'image/jpeg'],
19
+ ['.jpeg', 'image/jpeg'], ['.webp', 'image/webp'],
20
+ ])
21
+
22
+ function iconOf(
23
+ specifier: string, packageName: string, parentURL: string, manifestPath: string | undefined, value: unknown,
24
+ ): string | undefined {
25
+ const icon = manifestPath === undefined ? undefined : textOf(value, `${manifestPath}: icon`)
26
+ let target: string
27
+ // Diagnostics name the file the author edits: the declaring manifest or the exported image.
28
+ let label: string
29
+ if (manifestPath !== undefined && icon !== undefined) {
30
+ if (isAbsolute(icon) || win32.isAbsolute(icon) || /^[A-Za-z][A-Za-z\d+.-]*:/u.test(icon)) {
31
+ throw new Error(`${manifestPath}: icon must be a relative file path`)
32
+ }
33
+ label = manifestPath
34
+ const root = realpathSync(dirname(manifestPath))
35
+ target = resolve(root, icon)
36
+ const local = relative(root, realModuleFile(target))
37
+ if (local === '..' || local.startsWith(`..${sep}`) || isAbsolute(local)) {
38
+ throw new Error(`${label}: icon must remain inside its manifest directory`)
39
+ }
40
+ } else {
41
+ const exported = optionalResourcePath(`${specifier}/icon`, parentURL)
42
+ if (exported === undefined) return undefined
43
+ target = label = exported
44
+ assertPackageOwned(realModuleFile(target), packageName)
45
+ }
46
+ const mediaType = ICON_MEDIA_TYPES.get(extname(target).toLowerCase())
47
+ if (mediaType === undefined) throw new Error(`${label}: icon must be SVG, PNG, JPEG, or WebP`)
48
+ const file = realModuleFile(target)
49
+ const stat = statSync(file)
50
+ if (!stat.isFile()) throw new Error(`${label}: icon must be a regular file`)
51
+ if (stat.size > MAX_ICON_BYTES) throw new Error(`${label}: icon exceeds 256 KiB`)
52
+ const bytes = readFileSync(file)
53
+ if (bytes.length > MAX_ICON_BYTES) throw new Error(`${label}: icon exceeds 256 KiB`)
54
+ return `data:${mediaType};base64,${bytes.toString('base64')}`
55
+ }
56
+
57
+ /** Require an ancestor of the real file whose package.json names the owning package. */
58
+ function assertPackageOwned(file: string, name: string): void {
59
+ for (let directory = dirname(file); ; directory = dirname(directory)) {
60
+ const manifest = join(directory, 'package.json')
61
+ if (existsSync(manifest) && readObject(manifest).name === name) return
62
+ if (dirname(directory) === directory) throw new Error(`${file}: icon must remain inside its package directory`)
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Resolve a plugin resource through the active Node ESM resolver without evaluating it.
68
+ * @param specifier - plugin module or exported resource specifier.
69
+ * @param parentURL - owning module-resolution base.
70
+ * @returns the local filesystem path selected by Node and the active profile.
71
+ * @throws when the resolver is unavailable or the resource cannot resolve to a local file.
72
+ */
73
+ export function resolvePluginResource(specifier: string, parentURL: string): string {
74
+ const loader = ModuleLoader.fromInternal()
75
+ if (loader === undefined) throw new Error('Plugin metadata requires the Node module resolver')
76
+ const result = loader.version === 'v2'
77
+ ? loader.resolveSync(parentURL, { specifier, attributes: {} })
78
+ : loader.resolveSync(specifier, parentURL, {})
79
+ return fileURLToPath(result.url)
80
+ }
81
+
82
+ function missingResource(error: unknown): boolean {
83
+ const code = (error as NodeJS.ErrnoException | null)?.code
84
+ return code === 'ERR_PACKAGE_PATH_NOT_EXPORTED' || code === 'ERR_MODULE_NOT_FOUND'
85
+ || code === 'MODULE_NOT_FOUND' || code === 'ENOENT' || code === 'ENOTDIR'
86
+ }
87
+
88
+ function optionalResourcePath(specifier: string, parentURL: string): string | undefined {
89
+ try {
90
+ return resolvePluginResource(specifier, parentURL)
91
+ } catch (error) {
92
+ if (missingResource(error)) return undefined
93
+ throw error
94
+ }
95
+ }
96
+
97
+ function objectOf(value: unknown, path: string): Record<string, unknown> {
98
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
99
+ throw new Error(`${path} must be an object`)
100
+ }
101
+ return value as Record<string, unknown>
102
+ }
103
+
104
+ function textOf(value: unknown, path: string): string | undefined {
105
+ if (value === undefined) return undefined
106
+ if (typeof value !== 'string' || value.trim() === '') throw new Error(`${path} must be a non-empty string`)
107
+ return value
108
+ }
109
+
110
+ function readObject(file: string): Record<string, unknown> {
111
+ let contents: unknown
112
+ try {
113
+ contents = JSON.parse(readFileSync(file, 'utf8'))
114
+ } catch (error) {
115
+ throw new Error(`${file}: ${String(error)}`)
116
+ }
117
+ return objectOf(contents, file)
118
+ }
119
+
120
+ function fallbackText(value: unknown): string | undefined {
121
+ return typeof value === 'string' && value.trim() !== '' ? value : undefined
122
+ }
123
+
124
+ function dictionariesOf(englishPath: string, specifier: string, parentURL: string): Map<string, DisplayFields> {
125
+ const dictionaries = new Map<string, DisplayFields>()
126
+ for (const entry of readdirSync(dirname(englishPath), { withFileTypes: true })) {
127
+ if (!entry.name.endsWith('.json')) continue
128
+ const resource = `${specifier}/locale/${entry.name}`
129
+ const language = entry.name.slice(0, -5)
130
+ if (!LANGUAGE_ID.test(language)) throw new Error(`${resource} must use a language id as its filename`)
131
+ const id = language.toLowerCase()
132
+ if (dictionaries.has(id)) throw new Error(`${resource} duplicates locale ${id}`)
133
+ const file = resolvePluginResource(resource, parentURL)
134
+ if (dirname(file) !== dirname(englishPath)) {
135
+ throw new Error(`${resource}: ${file} must share the English locale directory ${dirname(englishPath)}`)
136
+ }
137
+ const parsed = readObject(file)
138
+ const meta = parsed.meta === undefined ? undefined : objectOf(parsed.meta, `${file}: meta`)
139
+ dictionaries.set(id, {
140
+ title: textOf(meta?.title, `${file}: meta.title`),
141
+ description: textOf(meta?.description, `${file}: meta.description`),
142
+ })
143
+ }
144
+ return dictionaries
145
+ }
146
+
147
+ function localizedText(
148
+ field: keyof DisplayFields, dictionaries: Map<string, DisplayFields>, fallback: string | undefined, finalFallback: string,
149
+ ): LocalizedText | undefined {
150
+ const entries = [...dictionaries].flatMap(([language, fields]): [string, string][] => {
151
+ const value = fields[field]
152
+ return value === undefined ? [] : [[language, value]]
153
+ })
154
+ if (entries.length === 0) return fallback
155
+ return { en: fallback ?? finalFallback, ...Object.fromEntries(entries) }
156
+ }
157
+
158
+ /**
159
+ * Read plugin locale JSON display text and icon without evaluating JavaScript entries.
160
+ * Only a package-root specifier reads its exported package.json: `name`/`description` fill missing
161
+ * locale fields, and a declared `icon` takes priority over the `<specifier>/icon` resource.
162
+ * A subpath specifier never reads package.json; its icon comes only from `<specifier>/icon`.
163
+ * Manifest icons remain inside their declaring directory; exported icons remain inside their
164
+ * owning package after realpath resolution. Both accept SVG, PNG, JPEG, or WebP up to 256 KiB.
165
+ * Icon failures retain display text without falling back.
166
+ * Non-package specifiers are skipped without invoking the resource resolver.
167
+ * Language files share the directory containing the resolved English resource;
168
+ * each file is resolved through the complete plugin specifier before reading.
169
+ * Translation maps retain an English fallback, ultimately the full module specifier
170
+ * for titles and empty for descriptions.
171
+ * @param specifier - configured plugin module name, including any package subpath.
172
+ * @param parentURL - owning Loader tree's module-resolution base.
173
+ * @returns display fields and any resource diagnostic, or undefined for non-package specifiers or absent metadata.
174
+ */
175
+ export function readPluginMeta(specifier: string, parentURL: string): PluginLocalizedMeta | undefined {
176
+ const packageName = barePackageName(specifier)
177
+ if (packageName === undefined) return undefined
178
+ try {
179
+ const englishPath = optionalResourcePath(`${specifier}/locale/en.json`, parentURL)
180
+ const dictionaries = englishPath === undefined ? new Map<string, DisplayFields>() : dictionariesOf(englishPath, specifier, parentURL)
181
+ const manifestPath = packageName === specifier ? optionalResourcePath(`${specifier}/package.json`, parentURL) : undefined
182
+ const manifest = manifestPath === undefined ? undefined : readObject(manifestPath)
183
+ const title = localizedText('title', dictionaries, fallbackText(manifest?.name), specifier)
184
+ const description = localizedText('description', dictionaries, fallbackText(manifest?.description), '')
185
+ const text = {
186
+ ...title === undefined ? {} : { title },
187
+ ...description === undefined ? {} : { description },
188
+ }
189
+ let icon: string | undefined
190
+ try {
191
+ icon = iconOf(specifier, packageName, parentURL, manifestPath, manifest?.icon)
192
+ } catch (error) {
193
+ return { ...text, error: `Plugin metadata for ${specifier}: ${String(error)}` }
194
+ }
195
+ if (title === undefined && description === undefined && icon === undefined) return undefined
196
+ return { ...text, ...icon === undefined ? {} : { icon } }
197
+ } catch (error) {
198
+ return { error: `Plugin metadata for ${specifier}: ${String(error)}` }
199
+ }
200
+ }
@@ -0,0 +1,117 @@
1
+ /** Evaluate plugin dsh requirements (`engines.dsh`, else dsh peers) without importing plugin code. */
2
+
3
+ import fs from 'node:fs'
4
+ import { fileURLToPath } from 'node:url'
5
+ import semver from 'semver'
6
+
7
+ /** Unmet dsh requirements and the exact plugin/runtime exemption decision. */
8
+ export interface PluginCompatibility {
9
+ name: string
10
+ version: string
11
+ runtimeVersion: string
12
+ /** Only requirements the running dsh version does not satisfy: `engines.dsh`, or dsh peer package names. */
13
+ requirements: Record<string, string>
14
+ exempted: boolean
15
+ }
16
+
17
+ function objectOf(value: unknown, field: string): Record<string, unknown> {
18
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
19
+ throw new Error(`${field} must be an object`)
20
+ }
21
+ return value as Record<string, unknown>
22
+ }
23
+
24
+ function runtimeVersionOf(value: unknown): string {
25
+ if (typeof value !== 'string' || semver.valid(value) === null) {
26
+ throw new Error(`Invalid dsh runtime version: ${JSON.stringify(value)}; expected a semantic version`)
27
+ }
28
+ return value
29
+ }
30
+
31
+ function identityField(manifest: Record<string, unknown>, field: 'name' | 'version'): string {
32
+ const value = Object.hasOwn(manifest, field) ? manifest[field] : undefined
33
+ if (typeof value !== 'string' || value.trim() === '') {
34
+ throw new Error(`Plugin manifest ${field} must be a non-empty string when dsh requirements are unmet`)
35
+ }
36
+ return value
37
+ }
38
+
39
+ /**
40
+ * Read this app-boot package's version in both source and bundled installations.
41
+ * @returns the validated runtime semantic version, preserving its exact spelling.
42
+ * @throws if package.json cannot be read or its version is missing or invalid.
43
+ */
44
+ export function getDshRuntimeVersion(): string {
45
+ // The executable's virtual filesystem intercepts string paths, not URL arguments.
46
+ const filename = fileURLToPath(new URL('../package.json', import.meta.url))
47
+ const manifest = objectOf(JSON.parse(fs.readFileSync(filename, 'utf8')), 'app-boot package.json')
48
+ return runtimeVersionOf(Object.hasOwn(manifest, 'version') ? manifest.version : undefined)
49
+ }
50
+
51
+ function satisfies(runtimeVersion: string, range: string): boolean {
52
+ return range.trim() !== '' && semver.satisfies(runtimeVersion, range, { includePrerelease: true })
53
+ }
54
+
55
+ function unmetRequirements(fields: Record<string, unknown>, runtimeVersion: string): Record<string, string> {
56
+ const engines = Object.hasOwn(fields, 'engines') ? objectOf(fields.engines, 'Plugin manifest engines') : {}
57
+ if (Object.hasOwn(engines, 'dsh')) {
58
+ const range = engines.dsh
59
+ if (typeof range !== 'string') throw new Error('Plugin manifest engines.dsh must be a string')
60
+ return satisfies(runtimeVersion, range) ? {} : { 'engines.dsh': range }
61
+ }
62
+ if (!Object.hasOwn(fields, 'peerDependencies')) return {}
63
+ const dependencies = objectOf(fields.peerDependencies, 'Plugin manifest peerDependencies')
64
+ const unmet: Record<string, string> = {}
65
+ for (const [name, range] of Object.entries(dependencies)) {
66
+ if (typeof range !== 'string') {
67
+ throw new Error(`Plugin manifest peerDependencies[${JSON.stringify(name)}] must be a string`)
68
+ }
69
+ if (name !== '@deepseek-ai/dsh' && !name.startsWith('@deepseek-ai/dsh-')) continue
70
+ const requirement = ['workspace:^', 'workspace:~', 'workspace:*'].includes(range) ? runtimeVersion : range
71
+ if (!satisfies(runtimeVersion, requirement)) unmet[name] = range
72
+ }
73
+ return unmet
74
+ }
75
+
76
+ /**
77
+ * Check the plugin's dsh requirements against the runtime. An own `engines.dsh` range decides alone and
78
+ * peerDependencies are ignored; otherwise every @deepseek-ai/dsh or @deepseek-ai/dsh-* peer must match.
79
+ * Prereleases participate in ranges. For peers only, workspace:^, workspace:~, and workspace:* refer to
80
+ * the current runtime; other empty or invalid ranges, including any workspace: `engines.dsh`, are unmet.
81
+ * @param manifest - parsed plugin package.json; inherited fields are ignored.
82
+ * @param exemptions - exact plugin name@version keys mapped to exact runtime versions.
83
+ * @param runtimeVersion - running dsh version, defaulting to this app-boot package.
84
+ * @returns unmet requirements and exemption status, or undefined when every requirement is met.
85
+ * @throws for malformed manifest engines or peer fields, invalid runtime versions, or missing identity on a mismatch.
86
+ */
87
+ export function evaluatePluginCompatibility(
88
+ manifest: object,
89
+ exemptions: Readonly<Record<string, readonly string[]>> = {},
90
+ runtimeVersion = getDshRuntimeVersion(),
91
+ ): PluginCompatibility | undefined {
92
+ runtimeVersionOf(runtimeVersion)
93
+ const fields = objectOf(manifest, 'Plugin manifest')
94
+ const requirements = unmetRequirements(fields, runtimeVersion)
95
+ if (Object.keys(requirements).length === 0) return undefined
96
+ const name = identityField(fields, 'name')
97
+ const version = identityField(fields, 'version')
98
+ const key = `${name}@${version}`
99
+ const exemptedVersions = Object.hasOwn(exemptions, key) ? exemptions[key] : undefined
100
+ const exempted = exemptedVersions?.includes(runtimeVersion) === true
101
+ return { name, version, runtimeVersion, requirements, exempted }
102
+ }
103
+
104
+ /**
105
+ * Describe unmet requirements, their risk, and the exact-version remedy.
106
+ * Surfaces with their own grant mechanism or locale render the structured result themselves.
107
+ * @param issue - incompatible plugin/runtime result, including exempted mismatches.
108
+ * @returns an English diagnostic for logs and stderr.
109
+ */
110
+ export function pluginCompatibilityWarning(issue: PluginCompatibility): string {
111
+ const key = `${issue.name}@${issue.version}`
112
+ return `Plugin ${key} is incompatible with dsh ${issue.runtimeVersion}: requirements ${JSON.stringify(issue.requirements)}. `
113
+ + 'Running it may cause crashes or data loss. '
114
+ + 'Update the plugin or install a plugin version compatible with this dsh runtime. '
115
+ + `To accept this risk explicitly, grant the exact-version exemption for ${key} on dsh ${issue.runtimeVersion} with \`dsh plugin allow-version\` or the plugin manager, then retry the installation or restart dsh. `
116
+ + `Exact-version exemption: ${issue.exempted ? 'active' : 'not active'}.`
117
+ }
@@ -0,0 +1,141 @@
1
+ /** Profile-local compatibility permissions readable before any Cordis plugins load. */
2
+ import { readFileSync } from 'node:fs'
3
+ import { mkdir } from 'node:fs/promises'
4
+ import { join } from 'node:path'
5
+ import { parse } from 'semver'
6
+ import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write'
7
+ import { getDshRuntimeVersion } from './plugin-compatibility.ts'
8
+
9
+ /** Independent profile metadata; neither package manifests nor Cordis patches carry grants. */
10
+ export const PROFILE_COMPATIBILITY_FILENAME = 'compatibility.json'
11
+
12
+ /** Published and scoped npm package names, as npm accepts them in a manifest dependency key. */
13
+ const PACKAGE_NAME = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/
14
+
15
+ /** Whether a decoded record accepts only exact DSH versions. */
16
+ function isVersionList(value: unknown): value is string[] {
17
+ return Array.isArray(value) && value.every(version => typeof version === 'string' && isExactPluginVersion(version))
18
+ }
19
+
20
+ /** Test whether an exemption names a canonical exact SemVer, including build metadata.
21
+ * @param value Version supplied by a manifest or user.
22
+ * @returns False for ranges, prefixes, whitespace, and malformed versions.
23
+ */
24
+ export function isExactPluginVersion(value: string): boolean {
25
+ const parsed = parse(value)
26
+ return parsed !== null && value === `${parsed.version}${parsed.build.length === 0 ? '' : `+${parsed.build.join('.')}`}`
27
+ }
28
+
29
+ /** Validate one explicit exemption without granting it.
30
+ * @param packageVersion Exact npm package-name@version, never an installation spec or range.
31
+ * @param runtimeVersion Exact DSH version, including prerelease and build metadata.
32
+ * @throws When either identity is not canonical.
33
+ */
34
+ export function validatePluginVersionExemption(packageVersion: string, runtimeVersion: string): void {
35
+ const separator = packageVersion.lastIndexOf('@')
36
+ if (separator <= 0 || !PACKAGE_NAME.test(packageVersion.slice(0, separator))
37
+ || !isExactPluginVersion(packageVersion.slice(separator + 1)) || !isExactPluginVersion(runtimeVersion)) {
38
+ throw new Error('Version exemptions require an exact npm package-name@version and an exact DSH runtime version')
39
+ }
40
+ }
41
+
42
+ /** What one profile's compatibility file currently authorizes, and what is wrong with it. */
43
+ export interface ProfileCompatibility {
44
+ /** Accepted exact package-name@version keys mapped to their allowed DSH versions. */
45
+ readonly exemptions: Record<string, string[]>
46
+ /** Human-readable problems; empty when every record was accepted. */
47
+ readonly warnings: string[]
48
+ /**
49
+ * Whether the file holds nothing this reader rejected, so a grant or revocation may rewrite it.
50
+ * A false value means writing would discard content the user must repair by hand.
51
+ */
52
+ readonly rewritable: boolean
53
+ }
54
+
55
+ /** Read the profile's independent compatibility file without loading plugins.
56
+ * A missing file authorizes nothing. An unreadable or unparsable file authorizes nothing and is
57
+ * reported instead of failing, so a bad file can never make the profile unusable; rejected records
58
+ * are skipped while the remaining valid ones still apply.
59
+ * @param profileDir Absolute profile directory.
60
+ * @returns Accepted exemptions plus every problem found; no manifest fallback is used.
61
+ */
62
+ export function readProfileCompatibility(profileDir: string): ProfileCompatibility {
63
+ const filename = join(profileDir, PROFILE_COMPATIBILITY_FILENAME)
64
+ const unreadable = (reason: string): ProfileCompatibility =>
65
+ ({ exemptions: {}, warnings: [`${filename} ${reason}; treating the profile as having no exemptions`], rewritable: false })
66
+ let text: string
67
+ try { text = readFileSync(filename, 'utf8') }
68
+ catch (error) {
69
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { exemptions: {}, warnings: [], rewritable: true }
70
+ return unreadable(`cannot be read (${String(error)})`)
71
+ }
72
+ let value: unknown
73
+ try { value = JSON.parse(text) }
74
+ catch (error) { return unreadable(`is not valid JSON (${String(error)})`) }
75
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
76
+ return unreadable('must map exact package@version keys to DSH version lists')
77
+ }
78
+ const exemptions: Record<string, string[]> = {}
79
+ const warnings: string[] = []
80
+ for (const [key, versions] of Object.entries(value as Record<string, unknown>)) {
81
+ const separator = key.lastIndexOf('@')
82
+ if (separator <= 0 || !PACKAGE_NAME.test(key.slice(0, separator)) || !isExactPluginVersion(key.slice(separator + 1))) {
83
+ warnings.push(`${filename}: ${JSON.stringify(key)} is not an exact package-name@version key; the record is ignored`)
84
+ continue
85
+ }
86
+ if (!isVersionList(versions)) {
87
+ warnings.push(`${filename}: ${key} must contain a list of exact DSH versions; the record is ignored`)
88
+ continue
89
+ }
90
+ exemptions[key] = versions
91
+ }
92
+ return { exemptions, warnings, rewritable: warnings.length === 0 }
93
+ }
94
+
95
+ /** Read only the accepted exemptions of a profile.
96
+ * @param profileDir Absolute profile directory.
97
+ * @returns Exact package-name@version keys mapped to their allowed DSH versions.
98
+ */
99
+ export function readProfileVersionExemptions(profileDir: string): Record<string, string[]> {
100
+ return readProfileCompatibility(profileDir).exemptions
101
+ }
102
+
103
+ /** Persist one informed grant or revocation under the compatibility file's own lock.
104
+ * @param profileDir Profile directory; no package manifest is created or modified.
105
+ * @param packageVersion Exact manifest package-name@version.
106
+ * @param runtimeVersion Exact DSH version; grants must name the current runtime, revocations may name historical ones.
107
+ * @param enabled Whether to grant rather than revoke.
108
+ * @param acceptRisk Required true for grants after explicit acknowledgement of possible crashes or data loss.
109
+ * @returns After the atomic write. Existing plugin instances are not reloaded by this operation.
110
+ * @throws For invalid identities, missing consent, a stale runtime, or a file the reader rejected,
111
+ * which the user must repair by hand because rewriting it would discard their content.
112
+ */
113
+ export async function setProfileVersionExemption(
114
+ profileDir: string, packageVersion: string, runtimeVersion: string, enabled: boolean, acceptRisk: boolean,
115
+ ): Promise<void> {
116
+ validatePluginVersionExemption(packageVersion, runtimeVersion)
117
+ if (enabled && !acceptRisk) {
118
+ throw new Error('Incompatible plugins may cause crashes or data loss. To grant this exact-version exemption, explicitly acknowledge the risk with --accept-risk (acceptRisk: true).')
119
+ }
120
+ const current = getDshRuntimeVersion()
121
+ if (enabled && runtimeVersion !== current) {
122
+ throw new Error(`Cannot approve DSH ${runtimeVersion}: this application runs DSH ${current}. Use --dsh-version ${current}.`)
123
+ }
124
+ await mkdir(profileDir, { recursive: true })
125
+ const filename = join(profileDir, PROFILE_COMPATIBILITY_FILENAME)
126
+ await withFileLock(filename, async () => {
127
+ const current = readProfileCompatibility(profileDir)
128
+ if (!current.rewritable) {
129
+ throw new Error(`${PROFILE_COMPATIBILITY_FILENAME} must be repaired before exemptions change:\n${current.warnings.join('\n')}`)
130
+ }
131
+ const exemptions = current.exemptions
132
+ const versions = exemptions[packageVersion] ?? []
133
+ if (enabled) exemptions[packageVersion] = [...new Set([...versions, runtimeVersion])]
134
+ else {
135
+ const retained = versions.filter(version => version !== runtimeVersion)
136
+ if (retained.length) exemptions[packageVersion] = retained
137
+ else Reflect.deleteProperty(exemptions, packageVersion)
138
+ }
139
+ await writeFileAtomic(filename, JSON.stringify(exemptions, undefined, 2) + '\n', { mode: 0o600 })
140
+ })
141
+ }
@@ -0,0 +1,85 @@
1
+ /** Launcher-owned profile locations and composition inputs. */
2
+ import { join } from 'node:path'
3
+ import { composeEntries, loadProfileDirectory, PROFILE_PATCH_FILENAME, type Profile } from './profile.ts'
4
+ import { loadOptionalPatches } from './index.ts'
5
+ import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
6
+
7
+ /** Application-owned package manager executable; environment applies only to package operations. */
8
+ export interface ProfilePnpmInvocation {
9
+ readonly command: string
10
+ readonly args: readonly string[]
11
+ readonly env: Readonly<Record<string, string>>
12
+ }
13
+
14
+ /** Current profile facts; scheduling and mutation belong to their callers. */
15
+ export interface ProfileContext {
16
+ readonly name: string
17
+ /** Packaged applications supply their bundled runtime instead of a PATH executable. */
18
+ readonly packageManager?: ProfilePnpmInvocation
19
+ readonly dir: string
20
+ readonly patchPath: string
21
+ readonly installAnchor: string
22
+ readonly cwd: string
23
+ readonly home: string
24
+ /** Bundle packages used to start this process, before any persisted edits. */
25
+ readonly startedBundles: readonly string[]
26
+ /** Parsed command-line overlays, applied above profile and home patches. */
27
+ readonly overlays: readonly PatchOptions[]
28
+ /** Launch-time DSH_TELEMETRY_DISABLED value; any non-empty value opts out. */
29
+ readonly telemetryDisabledEnv: string | undefined
30
+ }
31
+
32
+ declare module '@deepseek-ai/cordis' {
33
+ interface Context {
34
+ /** Present only in a profile launched by dsh. */
35
+ profileContext: ProfileContext
36
+ }
37
+ }
38
+
39
+ const TELEMETRY_ROW_ID = 'session-telemetry-otel'
40
+
41
+ /**
42
+ * Resolve the telemetry opt-out switch into its boot patch. ANY non-empty
43
+ * value (including `'0'`/`'false'`) disables: a privacy switch prefers
44
+ * off-by-mistake over on-by-mistake. A composition without the telemetry row
45
+ * exports nothing, so the switch is then trivially satisfied and no patch is
46
+ * generated — custom profiles need not mount telemetry to run with the
47
+ * switch set.
48
+ * @param disabledEnv - the raw `DSH_TELEMETRY_DISABLED` value (`undefined` when unset).
49
+ * @param hasRow - whether the composition carries the telemetry row.
50
+ * @returns the disable patch, or `undefined` when no hard-disable patch is required.
51
+ */
52
+ export function resolveTelemetryPatch(disabledEnv: string | undefined, hasRow: boolean): PatchOptions | undefined {
53
+ if ((disabledEnv ?? '') === '' || !hasRow) return undefined
54
+ return { id: TELEMETRY_ROW_ID, disabled: true }
55
+ }
56
+
57
+ /** Read the selected bundles' patches, which apply before the profile patch file.
58
+ * @param binName Diagnostic prefix for malformed or missing configuration.
59
+ * @param context Data supplied by the profile launcher.
60
+ * @param initialProfile Already loaded startup profile; omitted reads the current files.
61
+ * @returns Bundle patches in application order.
62
+ */
63
+ export function readBundlePatches(binName: string, context: ProfileContext, initialProfile?: Profile): PatchOptions[] {
64
+ const profile = initialProfile ?? loadProfileDirectory(binName, context.dir, context.installAnchor, { userLayer: false })
65
+ return profile.layers.flatMap(layer => layer.patches)
66
+ }
67
+
68
+ /** Read current bundle and user layers with the launch-time overlays.
69
+ * @param binName Diagnostic prefix for malformed or missing configuration.
70
+ * @param context Data supplied by the profile launcher.
71
+ * @param initialProfile Already loaded startup profile; omitted reads the current files.
72
+ * @returns Detached ordered patches; this function does not update the Loader.
73
+ */
74
+ export function readProfilePatches(binName: string, context: ProfileContext, initialProfile?: Profile): PatchOptions[] {
75
+ const patches = structuredClone([
76
+ ...readBundlePatches(binName, context, initialProfile),
77
+ ...(initialProfile?.patches ?? loadOptionalPatches(binName, context.patchPath) ?? []),
78
+ ...(loadOptionalPatches(binName, join(context.home, PROFILE_PATCH_FILENAME)) ?? []),
79
+ ...context.overlays,
80
+ ])
81
+ const telemetryPatch = resolveTelemetryPatch(context.telemetryDisabledEnv,
82
+ composeEntries([patches]).some(row => row.id === TELEMETRY_ROW_ID))
83
+ if (telemetryPatch !== undefined) patches.push(telemetryPatch)
84
+ return patches
85
+ }