@qic-9821/dsh-plugin-sync 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js ADDED
@@ -0,0 +1,210 @@
1
+ import { Engine, readVersion } from './lib/engine.js'
2
+ import { createRoutes } from './lib/http.js'
3
+
4
+ export const name = '@qic-9821/dsh-plugin-sync'
5
+
6
+ /**
7
+ * Config is optional: hosts without @deepseek-ai/schemastery must still load
8
+ * the plugin (they just lose the generated settings form).
9
+ */
10
+ let z = null
11
+ try {
12
+ const mod = await import('@deepseek-ai/schemastery')
13
+ z = mod?.default ?? mod ?? null
14
+ } catch {
15
+ z = null
16
+ }
17
+
18
+ const field = (schema) => (typeof schema?.extra === 'function' ? schema.extra('volatile', true) : schema)
19
+
20
+ export const Config = z
21
+ ? z.object({
22
+ enabled: field(z.boolean().default(true)),
23
+ autoStart: field(z.boolean().default(true)),
24
+ autoRestart: field(z.boolean().default(true)),
25
+ intervalMinutes: field(z.natural().default(0)),
26
+ startupDelaySec: field(z.natural().default(25)),
27
+ direction: field(z.string().default('push')),
28
+ conflict: field(z.string().default('local')),
29
+ peerMode: field(z.string().default('off')),
30
+ peerUrl: field(z.string().default('')),
31
+ peerToken: field(z.string().default('')),
32
+ peerName: field(z.string().default('')),
33
+ localName: field(z.string().default('')),
34
+ peerHost: field(z.string().default('')),
35
+ peerUser: field(z.string().default('')),
36
+ peerPort: field(z.natural().default(22)),
37
+ peerKey: field(z.string().default('')),
38
+ peerHttpPort: field(z.natural().default(18777)),
39
+ capPlugins: field(z.boolean().default(true)),
40
+ capConfig: field(z.boolean().default(false)),
41
+ capInstall: field(z.boolean().default(false)),
42
+ capFiles: field(z.boolean().default(false)),
43
+ capRemoteApply: field(z.boolean().default(true)),
44
+ capSecrets: field(z.boolean().default(false)),
45
+ include: field(z.array(z.string()).default([])),
46
+ exclude: field(z.array(z.string()).default([])),
47
+ profileDir: field(z.string().default('')),
48
+ maxBundleBytes: field(z.natural().default(48 * 1024 * 1024)),
49
+ timeoutMs: field(z.natural().default(60000)),
50
+ })
51
+ : undefined
52
+
53
+ export const inject = ['tools']
54
+
55
+ function pickLogger(logger) {
56
+ if (typeof logger?.info === 'function') return logger
57
+ return {
58
+ info: (...args) => console.log('[plugin-sync]', ...args),
59
+ warn: (...args) => console.warn('[plugin-sync]', ...args),
60
+ error: (...args) => console.error('[plugin-sync]', ...args),
61
+ }
62
+ }
63
+
64
+ export async function apply(ctx, config = {}) {
65
+ const logger = pickLogger(ctx?.logger)
66
+ const engine = new Engine({ ctx, config, env: process.env, logger })
67
+ engine.version = readVersion(new URL('./package.json', import.meta.url))
68
+
69
+ const disposers = []
70
+
71
+ if (typeof ctx?.tools?.register === 'function') {
72
+ try {
73
+ disposers.push(ctx.tools.register(pluginSyncToolSpec(engine)))
74
+ disposers.push(ctx.tools.register(pluginSyncConfigToolSpec(engine)))
75
+ } catch (error) {
76
+ logger.warn?.(`tool registration failed: ${error?.message || error}`)
77
+ }
78
+ }
79
+
80
+ const mountRoutes = (hostCtx) => {
81
+ const webServer = hostCtx?.webServer ?? hostCtx?.get?.('webServer')
82
+ if (!webServer || typeof webServer.register !== 'function') return
83
+ try {
84
+ const origin = typeof webServer.getOrigin === 'function' ? webServer.getOrigin() : ''
85
+ const routes = createRoutes({ engine, ctx, origin })
86
+ disposers.push(webServer.register({ kind: 'prefix', path: '/plugin-sync', handler: routes.handle }))
87
+ } catch (error) {
88
+ logger.warn?.(`web route registration failed: ${error?.message || error}`)
89
+ }
90
+ }
91
+
92
+ // On a cold boot a bundle row can be instantiated before the web server
93
+ // exists (the server rides a later row); acquiring it lazily keeps the row
94
+ // loading on profiles without a web UI too.
95
+ if (typeof ctx?.inject === 'function') {
96
+ disposers.push(ctx.inject(['webServer'], (hostCtx) => mountRoutes(hostCtx)))
97
+ } else {
98
+ mountRoutes(ctx)
99
+ }
100
+
101
+ try {
102
+ engine.schedule()
103
+ } catch (error) {
104
+ logger.warn?.(`schedule failed: ${error?.message || error}`)
105
+ }
106
+
107
+ const cfg = engine.cfg()
108
+ engine.log(`ready: profile=${engine.profileDir(cfg) || '?'} peer=${cfg.peerMode} caps=${['capPlugins', 'capConfig', 'capInstall', 'capFiles'].filter((key) => cfg[key]).join('+') || 'none'}`)
109
+
110
+ const dispose = async () => {
111
+ engine.dispose()
112
+ for (const disposer of disposers.reverse()) {
113
+ try {
114
+ disposer?.()
115
+ } catch {
116
+ // ignore teardown errors
117
+ }
118
+ }
119
+ }
120
+
121
+ ctx.effect?.(() => () => dispose(), 'dsh-plugin-sync: teardown')
122
+ return dispose
123
+ }
124
+
125
+ function pluginSyncToolSpec(engine) {
126
+ return {
127
+ name: 'plugin_sync',
128
+ description: [
129
+ 'Inspect or run the cross-machine DSH plugin sync (inventory, configs, enable state, package installs, local plugin folders).',
130
+ 'action="status" reports this machine, the peer and the last run.',
131
+ 'action="plan" shows what a sync would change, without changing anything.',
132
+ 'action="run" performs the sync now (respects the capability switches and the direction setting).',
133
+ 'Use it when the user asks whether plugins are in sync, what would change, or asks to sync now.',
134
+ ].join('\n'),
135
+ parameters: {
136
+ type: 'object',
137
+ properties: {
138
+ action: { type: 'string', enum: ['status', 'plan', 'run'], description: 'status | plan | run' },
139
+ direction: {
140
+ type: 'string',
141
+ enum: ['push', 'pull', 'both', 'off'],
142
+ description: 'Override the configured direction for this call.',
143
+ },
144
+ },
145
+ required: ['action'],
146
+ additionalProperties: false,
147
+ },
148
+ timeoutMs: 600000,
149
+ output: {
150
+ schema: { type: 'object', additionalProperties: true },
151
+ render(_args, value) {
152
+ return [{ type: 'text', text: typeof value === 'string' ? value : JSON.stringify(value, null, 2) }]
153
+ },
154
+ },
155
+ async execute(args) {
156
+ const action = String(args?.action || 'status')
157
+ const direction = typeof args?.direction === 'string' ? args.direction : null
158
+ if (action === 'status') return engine.status()
159
+ if (action === 'plan') return engine.plan({ direction })
160
+ if (action === 'run') return engine.sync({ trigger: 'tool', direction })
161
+ return { ok: false, error: `unknown action: ${action}` }
162
+ },
163
+ }
164
+ }
165
+
166
+ function pluginSyncConfigToolSpec(engine) {
167
+ return {
168
+ name: 'plugin_sync_config',
169
+ description: [
170
+ 'Read or update the plugin-sync settings: peer (http/ssh), capability switches, direction/conflict and schedule.',
171
+ 'action="get" returns the current effective settings (the peer token is never returned).',
172
+ 'action="set" merges a patch (any of: enabled, direction, conflict, peerMode, peerUrl, peerHost, peerUser, peerPort, peerKey, peerHttpPort, peerToken, capPlugins, capConfig, capInstall, capFiles, capRemoteApply, capSecrets, autoStart, autoRestart, intervalMinutes, startupDelaySec, include, exclude).',
173
+ ].join('\n'),
174
+ parameters: {
175
+ type: 'object',
176
+ properties: {
177
+ action: { type: 'string', enum: ['get', 'set'] },
178
+ patch: { type: 'object', additionalProperties: true, description: 'Settings to merge (action="set").' },
179
+ },
180
+ required: ['action'],
181
+ additionalProperties: false,
182
+ },
183
+ timeoutMs: 60000,
184
+ output: {
185
+ schema: { type: 'object', additionalProperties: true },
186
+ render(_args, value) {
187
+ return [{ type: 'text', text: typeof value === 'string' ? value : JSON.stringify(value, null, 2) }]
188
+ },
189
+ },
190
+ async execute(args) {
191
+ const action = String(args?.action || 'get')
192
+ if (action === 'get') {
193
+ const cfg = engine.cfg()
194
+ return {
195
+ ...cfg,
196
+ peerToken: cfg.peerToken ? '[set]' : '',
197
+ profileDir: engine.profileDir(cfg),
198
+ }
199
+ }
200
+ if (action === 'set') {
201
+ const patch = args?.patch && typeof args.patch === 'object' ? args.patch : {}
202
+ engine.updateState(patch)
203
+ engine.schedule()
204
+ const cfg = engine.cfg()
205
+ return { ok: true, ...cfg, peerToken: cfg.peerToken ? '[set]' : '' }
206
+ }
207
+ return { ok: false, error: `unknown action: ${action}` }
208
+ },
209
+ }
210
+ }
package/lib/apply.js ADDED
@@ -0,0 +1,120 @@
1
+ import { backupPatch, readPatchText, setRowConfig, writePatchText } from './patch.js'
2
+
3
+ /**
4
+ * Executes merge ops against the *local* machine through the pluginManager
5
+ * service, so pnpm, the profile manifest and the loader stay authoritative.
6
+ * One failing op never aborts the rest; every result is reported.
7
+ */
8
+ export async function applyPlan({ ctx, ops = [], cfg = {}, profileDir = null, log = () => {} }) {
9
+ const results = []
10
+ const pm = ctx?.get?.('pluginManager')
11
+ let patchBackedUp = false
12
+
13
+ for (const op of ops) {
14
+ const started = Date.now()
15
+ try {
16
+ if (op.kind === 'config') {
17
+ if (!cfg.capConfig) throw new Error('配置同步权限未开启')
18
+ if (!profileDir) throw new Error('找不到 profile 目录')
19
+ if (!patchBackedUp) {
20
+ const backup = backupPatch(profileDir, `${cfg.__backupDir || ''}`)
21
+ patchBackedUp = true
22
+ if (backup) log(`patch backup: ${backup}`)
23
+ }
24
+ const text = readPatchText(profileDir)
25
+ const rowId = op.rowId || op.name
26
+ const next = setRowConfig(text, rowId, op.config)
27
+ if (!next.changed) throw new Error(`cordis.patch.yml 里没有 ${rowId} 这一行`)
28
+ writePatchText(profileDir, next.text)
29
+ results.push(ok(op, '重启后生效'))
30
+ continue
31
+ }
32
+
33
+ if (!pm) throw new Error('pluginManager 服务不可用')
34
+
35
+ if (op.kind === 'install') {
36
+ if (!cfg.capInstall) throw new Error('安装/升级权限未开启')
37
+ const result = await pm.installBundle(op.spec, { enabled: op.enabled !== false })
38
+ results.push(fromChange(op, result))
39
+ continue
40
+ }
41
+ if (op.kind === 'upgrade') {
42
+ if (!cfg.capInstall) throw new Error('安装/升级权限未开启')
43
+ const result = await pm.updateBundle(op.name, op.version || undefined)
44
+ results.push(fromChange(op, result))
45
+ continue
46
+ }
47
+ if (op.kind === 'remove') {
48
+ if (!cfg.capInstall) throw new Error('卸载权限未开启')
49
+ const result = await pm.removeBundle(op.name)
50
+ results.push(fromChange(op, result))
51
+ continue
52
+ }
53
+ if (op.kind === 'enable' || op.kind === 'disable') {
54
+ if (!cfg.capPlugins) throw new Error('插件启停权限未开启')
55
+ const result = await pm.setBundleEnabled(op.name, op.kind === 'enable')
56
+ results.push(fromChange(op, result))
57
+ continue
58
+ }
59
+ if (op.kind === 'plugin-enable' || op.kind === 'plugin-disable') {
60
+ if (!cfg.capPlugins) throw new Error('插件启停权限未开启')
61
+ const result = await pm.setPluginEnabled(op.entryId, op.kind === 'plugin-enable')
62
+ results.push(fromChange(op, result))
63
+ continue
64
+ }
65
+ results.push({ ...fail(op, `未知操作: ${op.kind}`), ms: Date.now() - started })
66
+ } catch (error) {
67
+ results.push({ ...fail(op, describeError(error)), ms: Date.now() - started })
68
+ }
69
+ }
70
+
71
+ const failed = results.filter((item) => !item.ok)
72
+ return {
73
+ ok: failed.length === 0,
74
+ applied: results.filter((item) => item.ok).length,
75
+ failed: failed.length,
76
+ results,
77
+ restartRequired: results.some((item) => item.application === 'restart-required'),
78
+ }
79
+ }
80
+
81
+ function ok(op, note = null) {
82
+ return { ok: true, op: pick(op), application: 'applied', note }
83
+ }
84
+
85
+ function fail(op, error) {
86
+ return { ok: false, op: pick(op), error }
87
+ }
88
+
89
+ function pick(op) {
90
+ return { kind: op.kind, name: op.name ?? null, entryId: op.entryId ?? null, version: op.version ?? null }
91
+ }
92
+
93
+ /** pluginManager rejects with plain `{ code }` objects, so String() is useless. */
94
+ export function describeError(error) {
95
+ if (!error) return '未知错误'
96
+ if (typeof error === 'string') return error
97
+ if (error instanceof Error) return error.message || error.name
98
+ if (typeof error.message === 'string' && error.message) return error.message
99
+ if (typeof error.code === 'string') return error.code
100
+ try {
101
+ const text = JSON.stringify(error)
102
+ return text && text !== '{}' ? (text.length > 300 ? `${text.slice(0, 300)}…` : text) : String(error)
103
+ } catch {
104
+ return String(error)
105
+ }
106
+ }
107
+
108
+ function fromChange(op, result) {
109
+ const r = result && typeof result === 'object' ? result : {}
110
+ return {
111
+ ok: r.application !== 'failed' && r.application !== 'cancelled',
112
+ op: pick(op),
113
+ application: r.application ?? 'applied',
114
+ restartReason: r.restartReason ?? null,
115
+ error: r.error ? describeError(r.error) : null,
116
+ warnings: Array.isArray(r.warnings) ? r.warnings.map(String) : [],
117
+ pendingBuilds: Array.isArray(r.pendingBuilds) ? r.pendingBuilds : [],
118
+ changed: r.changed !== false,
119
+ }
120
+ }
package/lib/config.js ADDED
@@ -0,0 +1,228 @@
1
+ import { homedir } from 'node:os'
2
+ import { existsSync, readdirSync, readFileSync, mkdirSync, renameSync, writeFileSync } from 'node:fs'
3
+ import { join } from 'node:path'
4
+
5
+ export const PACKAGE_NAME = '@qic-9821/dsh-plugin-sync'
6
+ export const LEGACY_PACKAGE_NAME = 'dsh-plugin-sync'
7
+
8
+ const SECRET_KEY = /(secret|token|passw|apikey|api_key|credential|cookie|private_key|privatekey|authorization|access_key)/i
9
+
10
+ export function dshHome(env = process.env) {
11
+ const explicit = env.DSH_HOME?.trim()
12
+ return explicit || join(homedir(), '.dsh')
13
+ }
14
+
15
+ export function stateDir(env = process.env) {
16
+ return join(dshHome(env), 'plugin-sync')
17
+ }
18
+
19
+ export function statePath(env = process.env) {
20
+ return join(stateDir(env), 'state.json')
21
+ }
22
+
23
+ export function backupDir(env = process.env) {
24
+ return join(stateDir(env), 'backups')
25
+ }
26
+
27
+ /**
28
+ * The running profile is the one whose package.json depends on this package.
29
+ * (A `file:` install keeps the module inside D:\dsh-plugins, so the module path
30
+ * says nothing about the profile.)
31
+ */
32
+ export function resolveProfileDir(config = {}, env = process.env) {
33
+ if (typeof config.profileDir === 'string' && config.profileDir.trim()) return config.profileDir.trim()
34
+ if (env.DSH_PROFILE_DIR?.trim()) return env.DSH_PROFILE_DIR.trim()
35
+ const root = join(dshHome(env), 'profiles')
36
+ if (!existsSync(root)) return null
37
+ const candidates = []
38
+ for (const name of readdirSync(root)) {
39
+ const dir = join(root, name)
40
+ const manifestPath = join(dir, 'package.json')
41
+ if (!existsSync(manifestPath)) continue
42
+ try {
43
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
44
+ const deps = { ...(manifest.dependencies || {}), ...(manifest.devDependencies || {}) }
45
+ const hit = Boolean(deps[PACKAGE_NAME] || deps[PACKAGE_NAME] === '' || deps[LEGACY_PACKAGE_NAME] || deps[LEGACY_PACKAGE_NAME] === '')
46
+ candidates.push({ dir, name, hit, mtime: 0 })
47
+ } catch {
48
+ // ignore unreadable manifests
49
+ }
50
+ }
51
+ const hit = candidates.find((entry) => entry.hit)
52
+ if (hit) return hit.dir
53
+ if (config !== null && candidates.length === 1) return candidates[0].dir
54
+ return candidates.length > 0 ? candidates[candidates.length - 1].dir : null
55
+ }
56
+
57
+ export const DEFAULTS = {
58
+ enabled: true,
59
+ // schedule
60
+ autoStart: true,
61
+ autoRestart: true,
62
+ intervalMinutes: 0,
63
+ startupDelaySec: 25,
64
+ // direction / conflict
65
+ direction: 'push',
66
+ conflict: 'local',
67
+ // peer
68
+ peerMode: 'off',
69
+ peerUrl: '',
70
+ peerToken: '',
71
+ peerName: '',
72
+ localName: '',
73
+ peerHost: '',
74
+ peerUser: '',
75
+ peerPort: 22,
76
+ peerKey: '',
77
+ peerHttpPort: 18777,
78
+ // capabilities
79
+ capPlugins: true,
80
+ capConfig: false,
81
+ capInstall: false,
82
+ capFiles: false,
83
+ capRemoteApply: true,
84
+ capSecrets: false,
85
+ // scope
86
+ include: [],
87
+ exclude: [],
88
+ profileDir: '',
89
+ maxBundleBytes: 48 * 1024 * 1024,
90
+ timeoutMs: 60000,
91
+ }
92
+
93
+ export const CAPABILITY_KEYS = [
94
+ 'capPlugins',
95
+ 'capConfig',
96
+ 'capInstall',
97
+ 'capFiles',
98
+ 'capRemoteApply',
99
+ 'capSecrets',
100
+ ]
101
+
102
+ export const CAPABILITY_LABELS = {
103
+ capPlugins: ['插件清单与启停', '同步安装了哪些插件、哪些开着', 'Plugin inventory and enable state'],
104
+ capConfig: ['插件配置', '同步每个插件在 cordis.patch.yml 里的配置项', 'Plugin config in cordis.patch.yml'],
105
+ capInstall: ['安装 / 升级 / 卸载', '在另一端跑 pnpm、改 node_modules、增删插件包', 'Install, upgrade and remove packages (pnpm)'],
106
+ capFiles: ['复制插件二进制', '把本机 file: 插件的源码目录打包传过去', 'Copy local plugin source folders'],
107
+ capRemoteApply: ['允许对端改动本机', '对端推过来的变更可以在本机落地', 'Allow the peer to change this machine'],
108
+ capSecrets: ['同步密钥字段', '(默认关)同步 appSecret / token 之类字段', 'Include secret-looking config fields'],
109
+ }
110
+
111
+ export const DIRECTION_LABELS = {
112
+ push: ['推送到对端', 'Push this machine to the peer'],
113
+ pull: ['从对端拉取', 'Pull the peer to this machine'],
114
+ both: ['双向合并', 'Merge both ways'],
115
+ off: ['只比较不改', 'Compare only'],
116
+ }
117
+
118
+ export function normalizeConfig(config = {}, env = process.env) {
119
+ const cfg = { ...DEFAULTS }
120
+ for (const [key, value] of Object.entries(config || {})) {
121
+ if (value === undefined || value === null) continue
122
+ if (key === 'peer' && typeof value === 'object') {
123
+ Object.assign(cfg, normalizePeer(value))
124
+ continue
125
+ }
126
+ cfg[key] = value
127
+ }
128
+ cfg.direction = ['push', 'pull', 'both', 'off'].includes(cfg.direction) ? cfg.direction : 'push'
129
+ cfg.conflict = ['local', 'peer', 'newest'].includes(cfg.conflict) ? cfg.conflict : 'peer'
130
+ cfg.peerMode = ['off', 'http', 'ssh'].includes(cfg.peerMode) ? cfg.peerMode : 'off'
131
+ cfg.intervalMinutes = clampNumber(cfg.intervalMinutes, 0, 24 * 60, 0)
132
+ cfg.startupDelaySec = clampNumber(cfg.startupDelaySec, 0, 600, 25)
133
+ cfg.peerPort = clampNumber(cfg.peerPort, 1, 65535, 22)
134
+ cfg.peerHttpPort = clampNumber(cfg.peerHttpPort, 1, 65535, 18777)
135
+ cfg.maxBundleBytes = clampNumber(cfg.maxBundleBytes, 1024 * 1024, 512 * 1024 * 1024, 48 * 1024 * 1024)
136
+ cfg.timeoutMs = clampNumber(cfg.timeoutMs, 5000, 600000, 60000)
137
+ cfg.include = Array.isArray(cfg.include) ? cfg.include.map(String).filter(Boolean) : []
138
+ cfg.exclude = Array.isArray(cfg.exclude) ? cfg.exclude.map(String).filter(Boolean) : []
139
+ return cfg
140
+ }
141
+
142
+ function normalizePeer(peer) {
143
+ const out = {}
144
+ if (typeof peer.mode === 'string') out.peerMode = peer.mode
145
+ if (typeof peer.url === 'string') out.peerUrl = peer.url
146
+ if (typeof peer.token === 'string') out.peerToken = peer.token
147
+ if (typeof peer.name === 'string') out.peerName = peer.name
148
+ if (typeof peer.host === 'string') out.peerHost = peer.host
149
+ if (typeof peer.user === 'string') out.peerUser = peer.user
150
+ if (peer.port !== undefined) out.peerPort = peer.port
151
+ if (typeof peer.key === 'string') out.peerKey = peer.key
152
+ if (peer.httpPort !== undefined) out.peerHttpPort = peer.httpPort
153
+ return out
154
+ }
155
+
156
+ export function clampNumber(value, min, max, fallback) {
157
+ const n = Number(value)
158
+ if (!Number.isFinite(n)) return fallback
159
+ return Math.min(max, Math.max(min, Math.trunc(n)))
160
+ }
161
+
162
+ export function isSecretKey(key) {
163
+ return SECRET_KEY.test(String(key))
164
+ }
165
+
166
+ /** Deep-clone a config object, replacing secret-looking leaves unless allowed. */
167
+ export function filterConfig(value, { includeSecrets = false } = {}) {
168
+ if (Array.isArray(value)) return value.map((item) => filterConfig(item, { includeSecrets }))
169
+ if (!value || typeof value !== 'object') return value
170
+ const out = {}
171
+ for (const [key, item] of Object.entries(value)) {
172
+ if (!includeSecrets && isSecretKey(key)) {
173
+ out[key] = '[redacted]'
174
+ continue
175
+ }
176
+ out[key] = filterConfig(item, { includeSecrets })
177
+ }
178
+ return out
179
+ }
180
+
181
+ export function redactDeep(value) {
182
+ return filterConfig(value, { includeSecrets: false })
183
+ }
184
+
185
+ export function readState(env = process.env) {
186
+ const path = statePath(env)
187
+ try {
188
+ const parsed = JSON.parse(readFileSync(path, 'utf8'))
189
+ return parsed && typeof parsed === 'object' ? parsed : {}
190
+ } catch {
191
+ return {}
192
+ }
193
+ }
194
+
195
+ export function writeState(state, env = process.env) {
196
+ const dir = stateDir(env)
197
+ mkdirSync(dir, { recursive: true })
198
+ const path = statePath(env)
199
+ const tmp = `${path}.tmp`
200
+ writeFileSync(tmp, `${JSON.stringify(state, null, 2)}\n`, 'utf8')
201
+ renameSync(tmp, path)
202
+ return path
203
+ }
204
+
205
+ export function saveStatePatch(patch, env = process.env) {
206
+ const next = { ...readState(env), ...(patch || {}) }
207
+ writeState(next, env)
208
+ return next
209
+ }
210
+
211
+ export function effectiveConfig(config = {}, env = process.env) {
212
+ const state = readState(env)
213
+ const merged = { ...normalizeConfig(config, env) }
214
+ const allowed = new Set(Object.keys(DEFAULTS))
215
+ for (const [key, value] of Object.entries(state)) {
216
+ if (!allowed.has(key)) continue
217
+ if (typeof value === 'function') continue
218
+ merged[key] = value
219
+ }
220
+ return normalizeConfig(merged, env)
221
+ }
222
+
223
+ export function ensureStateDirs(env = process.env) {
224
+ const dir = stateDir(env)
225
+ mkdirSync(dir, { recursive: true })
226
+ mkdirSync(backupDir(env), { recursive: true })
227
+ return dir
228
+ }