@mnstry/atelier 0.2.0-alpha.11 → 0.2.0-alpha.12

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 (81) hide show
  1. package/CHANGELOG.md +344 -0
  2. package/README.md +10 -6
  3. package/contracts/atelier-obsidian-generation-manifest.v2.schema.json +372 -0
  4. package/contracts/compat-baseline.json +1 -1
  5. package/docs/assurance-controls.md +3 -2
  6. package/docs/install.md +3 -3
  7. package/docs/local-services.md +62 -11
  8. package/docs/obsidian-contract.md +669 -35
  9. package/docs/obsidian-plugin.md +585 -0
  10. package/docs/obsidian.md +233 -50
  11. package/docs/release-engineering.md +7 -1
  12. package/docs/upgrade.md +23 -3
  13. package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/case-only-collision.v2.json +160 -0
  14. package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/flat-note-path.v2.json +160 -0
  15. package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/hidden-folder.v2.json +160 -0
  16. package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/identity-in-the-body.v2.json +160 -0
  17. package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/link-breaking-name.v2.json +160 -0
  18. package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/missing-identity-region.v2.json +156 -0
  19. package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/missing-layout-version.v2.json +159 -0
  20. package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/note-named-like-a-folder.v2.json +160 -0
  21. package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/unknown-region-kind.v2.json +160 -0
  22. package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/v1-contract-version.v2.json +161 -0
  23. package/fixtures/obsidian/contracts/generation-manifest-v2/valid/three-notes.v2.json +160 -0
  24. package/fixtures/obsidian/edits/workspace.json +1 -1
  25. package/fixtures/obsidian/materialization/expected-full.json +21 -21
  26. package/fixtures/obsidian/materialization/expected-full.layout-1.json +99 -0
  27. package/fixtures/obsidian/materialization/expected-scoped.json +7 -7
  28. package/fixtures/obsidian/materialization/expected-scoped.layout-1.json +39 -0
  29. package/package.json +7 -3
  30. package/plugins/obsidian/main.js +444 -0
  31. package/plugins/obsidian/manifest.json +9 -0
  32. package/plugins/obsidian/styles.css +22 -0
  33. package/src/commands/obsidian.mjs +89 -10
  34. package/src/contracts/corpus.mjs +6 -0
  35. package/src/egress/forbidden-egress.mjs +2 -0
  36. package/src/project/private-state.mjs +10 -1
  37. package/src/projection/obsidian/contracts.mjs +224 -18
  38. package/src/projection/obsidian/edits/apply.mjs +12 -7
  39. package/src/projection/obsidian/edits/regions.mjs +137 -42
  40. package/src/projection/obsidian/materialize/index.mjs +6 -3
  41. package/src/projection/obsidian/materialize/path-registry.mjs +443 -57
  42. package/src/projection/obsidian/materialize/prepare-view.mjs +382 -131
  43. package/src/projection/obsidian/materialize/redaction.mjs +327 -0
  44. package/src/projection/obsidian/materialize/settings.mjs +113 -34
  45. package/src/projection/obsidian/plugin-bridge/bundle.mjs +50 -0
  46. package/src/projection/obsidian/plugin-bridge/channel.mjs +174 -0
  47. package/src/projection/obsidian/plugin-bridge/index.mjs +10 -0
  48. package/src/projection/obsidian/proposals/observation.mjs +10 -5
  49. package/src/projection/obsidian/publication/bridge-script.mjs +11 -5
  50. package/src/projection/obsidian/publication/index.mjs +2 -1
  51. package/src/projection/obsidian/publication/publisher.mjs +173 -26
  52. package/src/projection/obsidian/publication/transport.mjs +63 -8
  53. package/src/projection/obsidian/publication/vault-list.mjs +199 -0
  54. package/src/projection/obsidian/recovery/restart.mjs +3 -1
  55. package/src/projection/obsidian/recovery/store.mjs +11 -4
  56. package/src/projection/obsidian/selection-ui/focus.mjs +14 -7
  57. package/src/projection/obsidian/selection-ui/selection-state.mjs +2 -2
  58. package/src/projection/obsidian/selection-ui/selection.mjs +8 -3
  59. package/src/runtime/obsidian/app-capability.mjs +104 -12
  60. package/src/runtime/obsidian/app-production-seams.mjs +126 -39
  61. package/src/runtime/obsidian/app-registration.mjs +166 -0
  62. package/src/runtime/obsidian/engine.mjs +127 -11
  63. package/src/runtime/obsidian/extension-points.mjs +1 -1
  64. package/src/runtime/obsidian/index.mjs +5 -1
  65. package/src/runtime/obsidian/launch-plan.mjs +88 -0
  66. package/src/runtime/obsidian/lifecycle.mjs +105 -14
  67. package/src/runtime/obsidian/opening.mjs +227 -55
  68. package/src/runtime/obsidian/pending-edits.mjs +10 -0
  69. package/src/runtime/obsidian/plugin-channel.mjs +281 -0
  70. package/src/runtime/obsidian/plugin-choice.mjs +170 -0
  71. package/src/runtime/obsidian/plugin-drift.mjs +76 -0
  72. package/src/runtime/obsidian/plugin-presence.mjs +48 -0
  73. package/src/runtime/obsidian/service-main.mjs +18 -5
  74. package/src/runtime/obsidian/service-record.mjs +36 -0
  75. package/src/runtime/obsidian/service-server.mjs +50 -12
  76. package/src/runtime/obsidian/service.mjs +100 -6
  77. package/src/runtime/obsidian/state-store.mjs +13 -1
  78. package/src/server/local-sidecar.mjs +24 -5
  79. package/src/server/server.mjs +18 -5
  80. package/src/upgrade/upgrade.mjs +1 -1
  81. package/templates/external-project-workspace/pack-lifecycle.json +1 -1
@@ -0,0 +1,281 @@
1
+ import { createHash, randomBytes as cryptoRandomBytes, timingSafeEqual } from 'node:crypto'
2
+ import fs from 'node:fs'
3
+ import path from 'node:path'
4
+ import { atomicReplacePrivateText, ensureContainedPrivateDirectory, readRegularTextNoFollow, realPathAsStored } from '../../project/private-state.mjs'
5
+ import {
6
+ PLUGIN_BEARER, PLUGIN_CHALLENGE_WINDOW_MS, PLUGIN_CHANNEL_PROTOCOL, PLUGIN_HANDSHAKE_TTL_MS, PLUGIN_LEASE_TTL_MS, PLUGIN_MAX_PENDING_HANDSHAKES_PER_SCOPE, PLUGIN_MAX_SESSION_AGE_MS, PLUGIN_MAX_SESSIONS_PER_SCOPE,
7
+ PLUGIN_RENEW_INTERVAL_MS, PLUGIN_STATUS_SCHEMA, pluginClientProof, pluginKeyHint, pluginRequestMac, pluginResponseMac, pluginServerProof, pluginSessionKey, pluginVaultProof,
8
+ } from '../../projection/obsidian/plugin-bridge/channel.mjs'
9
+ import { canonicalJson, compareText, isPlainObject, isoTime } from './documents.mjs'
10
+
11
+ // The service's half of the plugin channel (plugin-bridge/channel.mjs).
12
+ //
13
+ // bearers one random bearer per view, minted when the view's vault first
14
+ // receives the plugin, kept owner-only under state/plugin/ and
15
+ // written into that vault's plugin data file, nowhere else; it
16
+ // is the key of the handshake and never crosses the wire
17
+ // sessions who holds a view open right now: a plugin that proved it holds
18
+ // the vault's key and renews its lease; in memory, gone when the
19
+ // service stops
20
+ // commands challenge, hello, lease, release and status, answered from
21
+ // state the service already keeps; nothing here writes a vault, a
22
+ // source, a manifest or an edit, and no command decides anything
23
+ //
24
+ // A live session also tells the app version the plugin runs in, and an id of
25
+ // that launch of the plugin. That version counts as checked for app
26
+ // qualification (app-capability.mjs) while one launch alone holds the view:
27
+ // the plugin runs inside exactly that app.
28
+
29
+ export const PLUGIN_BEARER_SCHEMA = 'atelier-obsidian-plugin-bearer/v1'
30
+ export const PLUGIN_PRESENCE_SCHEMA = 'atelier-obsidian-plugin-presence/v1'
31
+ const MAX_BEARER_FILES = 256
32
+ const segment = (identifier) => identifier.replaceAll(':', '_')
33
+
34
+ export function pluginBearerDirectory(workspaceRoot) {
35
+ return path.join(workspaceRoot, 'state', 'plugin')
36
+ }
37
+
38
+ function readBearerFile(file, workspaceId) {
39
+ let document
40
+ try { document = JSON.parse(readRegularTextNoFollow(file)) } catch { return null }
41
+ if (!isPlainObject(document) || Object.keys(document).sort().join(',') !== 'bearer,createdAt,schema,scopeId,workspaceId') return null
42
+ if (document.schema !== PLUGIN_BEARER_SCHEMA || document.workspaceId !== workspaceId || typeof document.scopeId !== 'string' || !PLUGIN_BEARER.test(document.bearer ?? '')) return null
43
+ return document
44
+ }
45
+
46
+ // The bearer of one view's vault, minted on first use. A file that does not
47
+ // read as this view's bearer is replaced: it cannot authenticate anything, and
48
+ // the vault receives the new bearer with its next publication.
49
+ export function ensurePluginBearer({ workspaceRoot, workspaceId, scopeId, randomBytes = cryptoRandomBytes, clock = () => new Date() }) {
50
+ const directory = ensureContainedPrivateDirectory({ workspaceRoot, directory: pluginBearerDirectory(workspaceRoot), label: 'Obsidian plugin state' })
51
+ const file = path.join(directory, `${segment(scopeId)}.json`)
52
+ const existing = readBearerFile(file, workspaceId)
53
+ if (existing?.scopeId === scopeId) return existing.bearer
54
+ const bearer = randomBytes(32).toString('base64url')
55
+ atomicReplacePrivateText(file, canonicalJson({ schema: PLUGIN_BEARER_SCHEMA, workspaceId, scopeId, bearer, createdAt: isoTime(clock) }))
56
+ return bearer
57
+ }
58
+
59
+ // Every view's bearer this workspace has minted: scopeId -> bearer.
60
+ export function readPluginBearers({ workspaceRoot, workspaceId }) {
61
+ const directory = pluginBearerDirectory(workspaceRoot)
62
+ let names
63
+ try { names = fs.readdirSync(directory) } catch (error) { if (error.code === 'ENOENT') return new Map(); throw error }
64
+ const bearers = new Map()
65
+ for (const name of names.filter((item) => item.endsWith('.json')).sort().slice(0, MAX_BEARER_FILES)) {
66
+ const document = readBearerFile(path.join(directory, name), workspaceId)
67
+ if (document !== null && name === `${segment(document.scopeId)}.json`) bearers.set(document.scopeId, document.bearer)
68
+ }
69
+ return bearers
70
+ }
71
+
72
+ // The bearers in memory. They are read again only when the directory changed
73
+ // (a bearer minted, replaced, or deleted to rotate it), so a request costs a
74
+ // stat and a listing of the directory, not a read of every file. The names
75
+ // are part of what is compared: not every platform changes a directory's times
76
+ // when an entry goes away. A bearer the service replaces under the same name
77
+ // is announced with `invalidate()`.
78
+ export function createPluginBearerCache({ workspaceRoot, workspaceId, read = readPluginBearers }) {
79
+ const directory = pluginBearerDirectory(workspaceRoot)
80
+ const signature = () => {
81
+ try {
82
+ const stat = fs.statSync(directory, { bigint: true })
83
+ return `${stat.ino}:${stat.mtimeNs}:${stat.ctimeNs}:${fs.readdirSync(directory).sort().join('/')}`
84
+ } catch (error) { if (error.code === 'ENOENT') return 'absent'; throw error }
85
+ }
86
+ let seen = null
87
+ let bearers = new Map()
88
+ return {
89
+ current() {
90
+ // Taken before the read: a change during the read is seen by the next call.
91
+ const now = signature()
92
+ if (now !== seen) { bearers = read({ workspaceRoot, workspaceId }); seen = now }
93
+ return bearers
94
+ },
95
+ invalidate() { seen = null },
96
+ }
97
+ }
98
+
99
+ // The decisions the channel's oracles are sensitive to; tests substitute broken ones to prove the oracles can fail.
100
+ export const PLUGIN_CHANNEL_PRIMITIVES = Object.freeze({
101
+ // Two HMACs in hex of validated shape, compared in constant time.
102
+ macMatches: (presented, expected) => typeof presented === 'string' && presented.length === expected.length && timingSafeEqual(Buffer.from(presented, 'hex'), Buffer.from(expected, 'hex')),
103
+ counterIsNew: (counter, last) => counter > last,
104
+ handshakeUsedOnce: true,
105
+ // A challenge is answered only within this long of the time it names, and each of its nonces once.
106
+ challengeWindowMs: PLUGIN_CHALLENGE_WINDOW_MS,
107
+ noncesAnsweredOnce: true,
108
+ })
109
+
110
+ // Sessions of the plugin, per view. A session lives as long as its lease: a
111
+ // hello grants one, each renewal extends it by `ttlMs`, and a release, a lapse
112
+ // or `maxAgeMs` ends it. At most `maxPerScope` live sessions per view. A
113
+ // session holds the key its handshake derived; nothing it hands out carries it.
114
+ export function createPluginSessions({ now = () => Date.now(), ttlMs = PLUGIN_LEASE_TTL_MS, maxPerScope = PLUGIN_MAX_SESSIONS_PER_SCOPE, maxAgeMs = PLUGIN_MAX_SESSION_AGE_MS, randomBytes = cryptoRandomBytes } = {}) {
115
+ const sessions = new Map()
116
+ const prune = () => { const at = now(); for (const [sessionId, session] of sessions) if (session.expiresAt <= at || at - session.openedAt >= maxAgeMs) sessions.delete(sessionId) }
117
+ const liveFor = (scopeId) => { prune(); return [...sessions.values()].filter((session) => session.scopeId === scopeId) }
118
+ const shown = ({ sessionKey: _key, keyDigest: _digest, ...session }) => ({ ...session })
119
+ return {
120
+ open({ scopeId, pluginVersion, appVersion, instanceId, sessionKey, keyDigest }) {
121
+ if (liveFor(scopeId).length >= maxPerScope) return null
122
+ const at = now()
123
+ const session = { sessionId: `ps-${randomBytes(16).toString('hex')}`, scopeId, pluginVersion, appVersion, instanceId, sessionKey, keyDigest, counter: 0, openedAt: at, renewedAt: at, expiresAt: at + ttlMs }
124
+ sessions.set(session.sessionId, session)
125
+ return shown(session)
126
+ },
127
+ // The live session with this id, key included, for the channel only; null once it lapsed or was released.
128
+ held(sessionId) { prune(); return sessions.get(sessionId) ?? null },
129
+ renew(sessionId) {
130
+ const session = sessions.get(sessionId)
131
+ if (!session) return null
132
+ session.renewedAt = now()
133
+ session.expiresAt = session.renewedAt + ttlMs
134
+ return shown(session)
135
+ },
136
+ release: (sessionId) => sessions.delete(sessionId),
137
+ live: (scopeId) => liveFor(scopeId).map(shown),
138
+ // What the most recently renewed live session reports, or null when no plugin holds the view. `instances` counts
139
+ // the launches of the plugin behind the live sessions: more than one means more than one app holds the vault.
140
+ report(scopeId) {
141
+ const live = liveFor(scopeId).sort((left, right) => right.renewedAt - left.renewedAt)
142
+ if (live.length === 0) return null
143
+ const [newest] = live
144
+ return { scopeId, appVersion: newest.appVersion, pluginVersion: newest.pluginVersion, sessions: live.length, instances: new Set(live.map((session) => session.instanceId)).size, renewedAt: new Date(newest.renewedAt).toISOString() }
145
+ },
146
+ scopeIds() { prune(); return [...new Set([...sessions.values()].map((session) => session.scopeId))] },
147
+ }
148
+ }
149
+
150
+ // The commands of the plugin channel (plugin-bridge/channel.mjs). `statusOf(scopeId)`
151
+ // returns the view's freshness entry as the service reports it and its open
152
+ // pending edits; the vault a hello must prove is the view's vault under the
153
+ // workspace state. `authority` is the exact address the request reached.
154
+ export function createPluginChannelForOracleTests({
155
+ workspaceRoot, workspaceId, runtimeId, sessions, statusOf, serviceStatus, now = () => Date.now(), randomBytes = cryptoRandomBytes,
156
+ bearers = createPluginBearerCache({ workspaceRoot, workspaceId }),
157
+ // Told each time a plugin opened a session for a view: the app it runs in has the view's entry.
158
+ onSessionOpened = () => {},
159
+ }, primitives = PLUGIN_CHANNEL_PRIMITIVES) {
160
+ const rules = { ...PLUGIN_CHANNEL_PRIMITIVES, ...primitives }
161
+ // As the file system stores the path, as the store registers the vault in the app (realPathAsStored): a data root given
162
+ // in another letter case names the same vault.
163
+ const vaultRootOf = (scopeId) => { try { return realPathAsStored(path.join(workspaceRoot, 'vaults', segment(scopeId))) } catch { return null } }
164
+ const keyDigestOf = (bearer) => createHash('sha256').update(bearer, 'utf8').digest()
165
+ const handshakes = new Map()
166
+ const pruneHandshakes = () => { const at = now(); for (const [handshakeId, handshake] of handshakes) if (handshake.expiresAt <= at) handshakes.delete(handshakeId) }
167
+ // The nonces of the challenges each view's key answered, until their time has left the window: a challenge recorded
168
+ // by whoever squatted the address, sent later, is answered at most once and only while it is fresh.
169
+ const answered = new Map()
170
+ const seenBefore = (scopeId, clientNonce, issuedAt) => {
171
+ const at = now()
172
+ const nonces = answered.get(scopeId) ?? new Map()
173
+ for (const [nonce, until] of nonces) if (until <= at) nonces.delete(nonce)
174
+ const seen = nonces.has(clientNonce)
175
+ if (rules.noncesAnsweredOnce) nonces.set(clientNonce, issuedAt + rules.challengeWindowMs + 1)
176
+ answered.set(scopeId, nonces)
177
+ return seen
178
+ }
179
+ const answer = (statusCode, body) => ({ statusCode, body })
180
+ // An answer only the session's key can have made: the exact text of the document, and a MAC over it.
181
+ const sealed = ({ sessionKey, command, sessionId, counter }, document) => {
182
+ const payload = JSON.stringify(document)
183
+ return answer(200, { payload, mac: pluginResponseMac({ sessionKey, command, sessionId, counter, payload }) })
184
+ }
185
+ // A request of a session: its MAC verifies under the session's key and its counter was never seen. A session
186
+ // whose vault's bearer was rotated since its handshake ends: its key came from the old bearer.
187
+ const authenticated = (command, body) => {
188
+ const session = sessions.held(body.sessionId)
189
+ if (session === null) return { refusal: answer(409, { error: 'session-unknown' }) }
190
+ if (!rules.macMatches(body.mac, pluginRequestMac({ sessionKey: session.sessionKey, command, sessionId: session.sessionId, counter: body.counter }))) return { refusal: answer(401, { error: 'request-not-authenticated' }) }
191
+ if (!rules.counterIsNew(body.counter, session.counter)) return { refusal: answer(401, { error: 'request-replayed' }) }
192
+ session.counter = body.counter
193
+ const bearer = bearers.current().get(session.scopeId)
194
+ if (typeof bearer !== 'string' || !timingSafeEqual(keyDigestOf(bearer), session.keyDigest)) {
195
+ sessions.release(session.sessionId)
196
+ return { refusal: answer(409, { error: 'session-unknown' }) }
197
+ }
198
+ return { session, seal: (document) => sealed({ sessionKey: session.sessionKey, command, sessionId: session.sessionId, counter: body.counter }, document) }
199
+ }
200
+ const commands = {
201
+ challenge({ body, authority }) {
202
+ if (Math.abs(now() - body.issuedAt) > rules.challengeWindowMs) return answer(401, { error: 'challenge-stale' })
203
+ // The key the hint was made with: every key is tried, all of them, whichever matches.
204
+ let found = null
205
+ for (const [scopeId, bearer] of bearers.current()) {
206
+ if (rules.macMatches(body.keyHint, pluginKeyHint({ bearer, clientNonce: body.clientNonce, issuedAt: body.issuedAt })) && found === null) found = { scopeId, bearer }
207
+ }
208
+ if (found === null) return answer(401, { error: 'plugin-key-unknown' })
209
+ if (seenBefore(found.scopeId, body.clientNonce, body.issuedAt)) return answer(401, { error: 'challenge-replayed' })
210
+ pruneHandshakes()
211
+ // Bounded per view: one view's waiting handshakes never hold up another's.
212
+ if ([...handshakes.values()].filter((handshake) => handshake.scopeId === found.scopeId).length >= PLUGIN_MAX_PENDING_HANDSHAKES_PER_SCOPE) return answer(429, { error: 'too-many-handshakes' })
213
+ const handshake = { handshakeId: `ph-${randomBytes(16).toString('hex')}`, scopeId: found.scopeId, authority, clientNonce: body.clientNonce, serverNonce: randomBytes(32).toString('hex'), expiresAt: now() + PLUGIN_HANDSHAKE_TTL_MS }
214
+ handshakes.set(handshake.handshakeId, handshake)
215
+ return answer(200, { protocol: PLUGIN_CHANNEL_PROTOCOL, handshakeId: handshake.handshakeId, serverNonce: handshake.serverNonce, serverProof: pluginServerProof({ bearer: found.bearer, ...handshake }) })
216
+ },
217
+ hello({ body, authority }) {
218
+ pruneHandshakes()
219
+ const handshake = handshakes.get(body.handshakeId) ?? null
220
+ if (rules.handshakeUsedOnce) handshakes.delete(body.handshakeId)
221
+ const bearer = handshake === null ? undefined : bearers.current().get(handshake.scopeId)
222
+ if (handshake === null || handshake.authority !== authority || typeof bearer !== 'string') return answer(401, { error: 'handshake-unknown' })
223
+ const { scopeId } = handshake
224
+ const bound = { bearer, scopeId, authority, clientNonce: handshake.clientNonce, serverNonce: handshake.serverNonce, handshakeId: handshake.handshakeId }
225
+ const expected = pluginClientProof({ ...bound, pluginVersion: body.pluginVersion, appVersion: body.appVersion, instanceId: body.instanceId, vaultProof: body.vaultProof })
226
+ if (!rules.macMatches(body.clientProof, expected)) return answer(401, { error: 'plugin-not-authenticated' })
227
+ const sessionKey = pluginSessionKey(bound)
228
+ // Compared, never named: the vault the app has open, proven under this session's key.
229
+ const vaultRoot = vaultRootOf(scopeId)
230
+ if (vaultRoot === null || !rules.macMatches(body.vaultProof, pluginVaultProof({ sessionKey, vaultPath: vaultRoot }))) return answer(409, { error: 'wrong-vault' })
231
+ const session = sessions.open({ scopeId, pluginVersion: body.pluginVersion, appVersion: body.appVersion, instanceId: body.instanceId, sessionKey, keyDigest: keyDigestOf(bearer) })
232
+ if (session === null) return answer(429, { error: 'too-many-sessions' })
233
+ onSessionOpened(scopeId)
234
+ return sealed({ sessionKey, command: 'hello', sessionId: session.sessionId, counter: 0 }, {
235
+ schema: PLUGIN_CHANNEL_PROTOCOL, scopeId, sessionId: session.sessionId, runtimeId, leaseTtlMs: session.expiresAt - session.openedAt, renewEveryMs: PLUGIN_RENEW_INTERVAL_MS,
236
+ })
237
+ },
238
+ lease({ body }) {
239
+ const request = authenticated('lease', body)
240
+ if (request.refusal) return request.refusal
241
+ const session = sessions.renew(request.session.sessionId)
242
+ return request.seal({ schema: PLUGIN_CHANNEL_PROTOCOL, scopeId: session.scopeId, sessionId: session.sessionId, leaseTtlMs: session.expiresAt - session.renewedAt })
243
+ },
244
+ release({ body }) {
245
+ const request = authenticated('release', body)
246
+ if (request.refusal) return request.refusal
247
+ return request.seal({ schema: PLUGIN_CHANNEL_PROTOCOL, scopeId: request.session.scopeId, released: sessions.release(request.session.sessionId) })
248
+ },
249
+ status({ body }) {
250
+ const request = authenticated('status', body)
251
+ if (request.refusal) return request.refusal
252
+ const { scopeId } = request.session
253
+ const { view, pendingEdits } = statusOf(scopeId)
254
+ return request.seal({ schema: PLUGIN_STATUS_SCHEMA, scopeId, service: { status: serviceStatus() }, view, pendingEdits })
255
+ },
256
+ }
257
+ return {
258
+ bearers: () => bearers.current(),
259
+ // The service minted a bearer: whatever the directory's times say, the next lookup reads the bearers again.
260
+ bearersChanged: () => bearers.invalidate?.(),
261
+ handle: (command, request) => commands[command](request),
262
+ }
263
+ }
264
+
265
+ export function createPluginChannel(options) {
266
+ return createPluginChannelForOracleTests(options, PLUGIN_CHANNEL_PRIMITIVES)
267
+ }
268
+
269
+ // Which views a plugin holds open, for the service's status document, and
270
+ // whether the person wants the plugin in each vault (`entry`: undecided,
271
+ // requested, on or off; see plugin-choice.mjs). Counts, versions and states
272
+ // only: no vault path, no session identity.
273
+ export function pluginPresence({ sessions, scopeIds, entryOf = () => null }) {
274
+ const scopes = [...new Set([...scopeIds, ...sessions.scopeIds()])].sort(compareText).map((scopeId) => {
275
+ const report = sessions.report(scopeId)
276
+ const entry = entryOf(scopeId)
277
+ const presence = report === null ? { scopeId, present: false, sessions: 0 } : { scopeId, present: true, sessions: report.sessions, appVersion: report.appVersion, pluginVersion: report.pluginVersion, renewedAt: report.renewedAt }
278
+ return entry === null ? presence : { ...presence, entry }
279
+ })
280
+ return { schema: PLUGIN_PRESENCE_SCHEMA, leaseTtlMs: PLUGIN_LEASE_TTL_MS, scopes }
281
+ }
@@ -0,0 +1,170 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { atomicReplacePrivateText, ensureContainedPrivateDirectory, openRegularFileNoFollow, readRegularTextNoFollow } from '../../project/private-state.mjs'
4
+ import { PLUGIN_ID } from '../../projection/obsidian/plugin-bridge/channel.mjs'
5
+ import { COMMUNITY_PLUGINS_PATH } from '../../projection/obsidian/materialize/settings.mjs'
6
+ import { sha256Digest } from '../../projection/obsidian/materialize/byte-lens.mjs'
7
+ import { canonicalJson, isPlainObject, isoTime } from './documents.mjs'
8
+
9
+ // Whether the person wants Atelier's plugin in one vault, as the vault itself
10
+ // says it: the `atelier-projection` entry of .obsidian/community-plugins.json.
11
+ //
12
+ // (no record) nothing decided yet: Atelier offers the entry and the files
13
+ // requested `atelier obsidian plugin on` asked for it: offered again
14
+ // offered the entry is in place, but it was written while an app held
15
+ // the vault; that app keeps the list it read in memory and may
16
+ // write it back without the entry, so a list without it is no
17
+ // decision of the person's yet: the entry is offered again
18
+ // on the app has the entry: it was in place before an app next
19
+ // opened the vault (published while none ran), or the plugin
20
+ // ran in the app; from now on a list without it is the
21
+ // person's decision
22
+ // off the person removed the entry (turned the plugin off, or
23
+ // uninstalled it, in Obsidian): Atelier never adds it back and
24
+ // never creates a plugin file that is not there; it only keeps
25
+ // the files that are present current
26
+ //
27
+ // The decision is read before a view is prepared, from the bytes of the file
28
+ // on disk, and those bytes travel with the prepared view: the publisher writes
29
+ // the entry only over exactly those bytes, so a change the person makes while
30
+ // a publication runs is never written over, and the next preparation reads it.
31
+ // A file that is absent or is not a list decides nothing. A record that cannot
32
+ // be read counts as `off`: nothing is re-added on a guess. Status reads the
33
+ // same change without recording it (currentPluginChoice), so it says what the
34
+ // person did before the view's next preparation records it.
35
+
36
+ export const PLUGIN_CHOICE_SCHEMA = 'atelier-obsidian-plugin-choice/v1'
37
+ export const PLUGIN_CHOICE_STATES = Object.freeze(['requested', 'offered', 'on', 'off'])
38
+ const REASONS = Object.freeze(['requested-by-command', 'entry-offered-while-the-app-runs', 'entry-confirmed', 'entry-seen-by-the-app', 'entry-removed-by-person', 'entry-restored-by-person'])
39
+ // The states in which a list without the entry is not the person's decision: the entry is offered again.
40
+ const NOT_YET_CONFIRMED = Object.freeze(['undecided', 'requested', 'offered'])
41
+ const MAX_SETTINGS_BYTES = 256 * 1024
42
+ const segment = (identifier) => identifier.replaceAll(':', '_')
43
+
44
+ export const pluginChoiceDirectory = (workspaceRoot) => path.join(workspaceRoot, 'state', 'plugin', 'choices')
45
+ // Where the recovery store places a view's vault.
46
+ export const viewVaultRoot = (workspaceRoot, scopeId) => path.join(workspaceRoot, 'vaults', segment(scopeId))
47
+ const choiceFile = (workspaceRoot, scopeId) => path.join(pluginChoiceDirectory(workspaceRoot), `${segment(scopeId)}.json`)
48
+
49
+ function validChoice(document, { workspaceId, scopeId }) {
50
+ return isPlainObject(document) && Object.keys(document).sort().join(',') === 'reason,schema,scopeId,since,state,workspaceId'
51
+ && document.schema === PLUGIN_CHOICE_SCHEMA && document.workspaceId === workspaceId && document.scopeId === scopeId
52
+ && PLUGIN_CHOICE_STATES.includes(document.state) && REASONS.includes(document.reason) && typeof document.since === 'string'
53
+ }
54
+
55
+ // { state: 'undecided' | 'requested' | 'offered' | 'on' | 'off', reason, since, unreadable? }
56
+ export function readPluginChoice({ workspaceRoot, workspaceId, scopeId }) {
57
+ let text
58
+ try { text = readRegularTextNoFollow(choiceFile(workspaceRoot, scopeId)) } catch (error) {
59
+ if (error.code === 'ENOENT') return { state: 'undecided', reason: null, since: null }
60
+ return { state: 'off', reason: 'choice-unreadable', since: null, unreadable: true }
61
+ }
62
+ let document = null
63
+ try { document = JSON.parse(text) } catch { document = null }
64
+ if (!validChoice(document, { workspaceId, scopeId })) return { state: 'off', reason: 'choice-unreadable', since: null, unreadable: true }
65
+ return { state: document.state, reason: document.reason, since: document.since }
66
+ }
67
+
68
+ export function writePluginChoice({ workspaceRoot, workspaceId, scopeId, state, reason, clock = () => new Date() }) {
69
+ const document = { schema: PLUGIN_CHOICE_SCHEMA, workspaceId, scopeId, state, reason, since: isoTime(clock) }
70
+ if (!validChoice(document, { workspaceId, scopeId })) throw new TypeError('a plugin choice names a known state and reason')
71
+ ensureContainedPrivateDirectory({ workspaceRoot, directory: pluginChoiceDirectory(workspaceRoot), label: 'Obsidian plugin state' })
72
+ atomicReplacePrivateText(choiceFile(workspaceRoot, scopeId), canonicalJson(document))
73
+ return { state, reason, since: document.since }
74
+ }
75
+
76
+ // 'absent', 'unreadable' (a parent is a link or not a directory) or 'reachable'.
77
+ function parentsOf(vaultRoot, relativePath) {
78
+ let current = vaultRoot
79
+ for (const part of relativePath.split('/').slice(0, -1)) {
80
+ current = path.join(current, part)
81
+ const stat = fs.lstatSync(current, { throwIfNoEntry: false })
82
+ if (!stat) return 'absent'
83
+ if (stat.isSymbolicLink() || !stat.isDirectory()) return 'unreadable'
84
+ }
85
+ return 'reachable'
86
+ }
87
+
88
+ // Whether a vault path holds a regular file under real directories.
89
+ export function vaultFilePresent(vaultRoot, relativePath) {
90
+ return parentsOf(vaultRoot, relativePath) === 'reachable' && fs.lstatSync(path.join(vaultRoot, relativePath), { throwIfNoEntry: false })?.isFile() === true
91
+ }
92
+
93
+ // The bytes of a settings file of a vault, read without following a link and
94
+ // bounded: { state: 'absent' | 'present' | 'unreadable', bytes, digest }.
95
+ export function readVaultSettingsFile(vaultRoot, relativePath) {
96
+ const parents = parentsOf(vaultRoot, relativePath)
97
+ if (parents !== 'reachable') return { state: parents, bytes: null, digest: null }
98
+ let descriptor
99
+ try { descriptor = openRegularFileNoFollow(path.join(vaultRoot, relativePath)) } catch (error) {
100
+ return error.code === 'ENOENT' ? { state: 'absent', bytes: null, digest: null } : { state: 'unreadable', bytes: null, digest: null }
101
+ }
102
+ try {
103
+ if (fs.fstatSync(descriptor).size > MAX_SETTINGS_BYTES) return { state: 'unreadable', bytes: null, digest: null }
104
+ const bytes = fs.readFileSync(descriptor)
105
+ return { state: 'present', bytes, digest: sha256Digest(bytes) }
106
+ } finally {
107
+ fs.closeSync(descriptor)
108
+ }
109
+ }
110
+
111
+ // What the vault's community plugin list says about Atelier's entry:
112
+ // 'listed', 'not-listed' (a list without it), or 'no-list' (absent, unreadable
113
+ // or not a list), with the bytes read.
114
+ export function readCommunityEntry(vaultRoot) {
115
+ const file = readVaultSettingsFile(vaultRoot, COMMUNITY_PLUGINS_PATH)
116
+ if (file.state !== 'present') return { entry: 'no-list', file }
117
+ let list = null
118
+ try { list = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(file.bytes)) } catch { list = null }
119
+ if (!Array.isArray(list)) return { entry: 'no-list', file }
120
+ return { entry: list.includes(PLUGIN_ID) ? 'listed' : 'not-listed', file }
121
+ }
122
+
123
+ // A change of the decision the vault shows against the record, or null: an
124
+ // entry removed after it was confirmed is the person turning the plugin off; an
125
+ // entry the person put back turns it on again.
126
+ function changeShown(choice, entry) {
127
+ if (choice.state === 'on' && entry === 'not-listed') return { state: 'off', reason: 'entry-removed-by-person' }
128
+ if (choice.state === 'off' && !choice.unreadable && entry === 'listed') return { state: 'on', reason: 'entry-restored-by-person' }
129
+ return null
130
+ }
131
+
132
+ // The decision for one view, from its record and what its vault says now, as
133
+ // the view is prepared: a change is recorded here.
134
+ export function decidePluginChoice({ workspaceRoot, workspaceId, scopeId, vaultRoot, clock }) {
135
+ const recorded = readPluginChoice({ workspaceRoot, workspaceId, scopeId })
136
+ const community = readCommunityEntry(vaultRoot)
137
+ const change = changeShown(recorded, community.entry)
138
+ return { choice: change === null ? recorded : writePluginChoice({ workspaceRoot, workspaceId, scopeId, ...change, clock }), community }
139
+ }
140
+
141
+ // The same decision for status, read only: a change not recorded yet is marked
142
+ // `pending` (the view's next preparation records it).
143
+ export function currentPluginChoice({ workspaceRoot, workspaceId, scopeId }) {
144
+ const recorded = readPluginChoice({ workspaceRoot, workspaceId, scopeId })
145
+ const change = changeShown(recorded, readCommunityEntry(viewVaultRoot(workspaceRoot, scopeId)).entry)
146
+ return change === null ? recorded : { ...change, since: null, pending: true }
147
+ }
148
+
149
+ // After a publication: an offered entry that is now in place. Published while
150
+ // no app ran, it is confirmed: an app reads the list, entry and all, when it
151
+ // next opens the vault. Published through a running app, it is only offered:
152
+ // that app may not have read it, and the plugin running there confirms it
153
+ // (confirmPluginSeen).
154
+ const IN_PLACE = new Set(['policy-satisfied', 'created', 'published', 'published-external-captured', 'already-current'])
155
+ export function confirmPluginEntry({ workspaceRoot, workspaceId, scopeId, result, clock }) {
156
+ const unit = (result?.notes ?? []).find((entry) => entry.path === COMMUNITY_PLUGINS_PATH)
157
+ if (!unit || !IN_PLACE.has(unit.outcome) || unit.changedAfterPublication === true) return null
158
+ const choice = readPluginChoice({ workspaceRoot, workspaceId, scopeId })
159
+ if (!NOT_YET_CONFIRMED.includes(choice.state)) return null
160
+ if (result.mode === 'direct') return writePluginChoice({ workspaceRoot, workspaceId, scopeId, state: 'on', reason: 'entry-confirmed', clock })
161
+ if (choice.state === 'offered') return null
162
+ return writePluginChoice({ workspaceRoot, workspaceId, scopeId, state: 'offered', reason: 'entry-offered-while-the-app-runs', clock })
163
+ }
164
+
165
+ // The plugin said hello from the vault: the app it runs in has the entry, so it is confirmed.
166
+ export function confirmPluginSeen({ workspaceRoot, workspaceId, scopeId, clock }) {
167
+ const choice = readPluginChoice({ workspaceRoot, workspaceId, scopeId })
168
+ if (!NOT_YET_CONFIRMED.includes(choice.state)) return null
169
+ return writePluginChoice({ workspaceRoot, workspaceId, scopeId, state: 'on', reason: 'entry-seen-by-the-app', clock })
170
+ }
@@ -0,0 +1,76 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { readRegularTextNoFollow } from '../../project/private-state.mjs'
4
+ import { OBSIDIAN_EXT_KEY } from '../../projection/obsidian/contracts.mjs'
5
+ import { sha256Digest } from '../../projection/obsidian/materialize/byte-lens.mjs'
6
+ import { readFileBytes, readFileDigest } from '../../projection/obsidian/recovery/store.mjs'
7
+ import { viewVaultRoot } from './plugin-choice.mjs'
8
+
9
+ // Whether the plugin files a view's committed generation pins are on disk as
10
+ // pinned. A person may remove one, change one, or repair a path Atelier had to
11
+ // leave (a folder where a file goes, say). The publisher then publishes the
12
+ // same generation again (publisher.mjs), but only when the view is prepared
13
+ // again, which nothing else asks for while its sources are unchanged.
14
+ //
15
+ // `observe(scopeIds)` looks at each view once per tick and returns the views
16
+ // to prepare again: those whose files differ from their pins and look
17
+ // different from the last look. A drift Atelier leaves for the person (a link
18
+ // where a file goes) is asked about once, not at every tick; a change to it is
19
+ // asked about again. Where the plugin is turned off in the vault, a pinned
20
+ // file that is gone is no drift: the publisher never makes one again.
21
+
22
+ const segment = (identifier) => identifier.replaceAll(':', '_')
23
+ const TURNED_OFF = 'turned-off-in-this-vault'
24
+
25
+ // The plugin record of the view's committed manifest, read as the store reads
26
+ // it: the pointer names the manifest and its digest. Null when there is none.
27
+ function pinnedPlugin(workspaceRoot, workspaceId, scopeId) {
28
+ const manifests = path.join(workspaceRoot, 'state', 'manifests', segment(scopeId))
29
+ try {
30
+ const pointer = JSON.parse(readRegularTextNoFollow(path.join(manifests, 'current.json')))
31
+ if (pointer?.workspaceId !== workspaceId || pointer?.scopeId !== scopeId || typeof pointer.manifestFile !== 'string' || pointer.manifestFile.includes('/')) return null
32
+ const bytes = readFileBytes(path.join(manifests, pointer.manifestFile))
33
+ if (sha256Digest(bytes) !== pointer.manifestDigest) return null
34
+ const owned = JSON.parse(bytes.toString('utf8'))?.ext?.[OBSIDIAN_EXT_KEY]?.settings?.pluginOwned
35
+ if (!Array.isArray(owned?.files)) return null
36
+ return { files: owned.files.filter((file) => typeof file?.path === 'string' && typeof file?.digest === 'string'), off: owned.withheldBecause === TURNED_OFF }
37
+ } catch {
38
+ return null
39
+ }
40
+ }
41
+
42
+ // What one pinned path holds: its digest, 'absent', or 'unreadable' (a link, a
43
+ // folder where a file goes, a path nobody may read).
44
+ function onDisk(vaultRoot, relativePath) {
45
+ let current = vaultRoot
46
+ for (const part of relativePath.split('/').slice(0, -1)) {
47
+ current = path.join(current, part)
48
+ let stat
49
+ try { stat = fs.lstatSync(current, { throwIfNoEntry: false }) } catch { return 'unreadable' }
50
+ if (!stat) return 'absent'
51
+ if (stat.isSymbolicLink() || !stat.isDirectory()) return 'unreadable'
52
+ }
53
+ try { return readFileDigest(path.join(vaultRoot, relativePath)) ?? 'absent' } catch { return 'unreadable' }
54
+ }
55
+
56
+ export function createPluginDriftObserver({ workspaceRoot, workspaceId }) {
57
+ const looked = new Map()
58
+ return {
59
+ observe(scopeIds) {
60
+ const drifted = []
61
+ for (const scopeId of scopeIds) {
62
+ const pinned = pinnedPlugin(workspaceRoot, workspaceId, scopeId)
63
+ if (pinned === null || pinned.files.length === 0) { looked.delete(scopeId); continue }
64
+ const vaultRoot = viewVaultRoot(workspaceRoot, scopeId)
65
+ let root = 'absent'
66
+ try { const stat = fs.lstatSync(vaultRoot); root = stat.isSymbolicLink() ? 'link' : (stat.mode & 0o777).toString(8) } catch { root = 'absent' }
67
+ const disk = pinned.files.map((file) => onDisk(vaultRoot, file.path))
68
+ const differs = disk.some((found, index) => found !== pinned.files[index].digest && !(pinned.off && found === 'absent'))
69
+ const signature = JSON.stringify([root, pinned.files.map((file) => file.digest), disk])
70
+ if (differs && looked.get(scopeId) !== signature) drifted.push(scopeId)
71
+ looked.set(scopeId, signature)
72
+ }
73
+ return drifted
74
+ },
75
+ }
76
+ }
@@ -0,0 +1,48 @@
1
+ import { isPlainObject } from './documents.mjs'
2
+
3
+ // What `status` and `open` say about Atelier's plugin, read from the status
4
+ // document of the running service (`plugins`, see plugin-channel.mjs). The
5
+ // plugin is present for a view while it holds a live lease on it.
6
+
7
+ export const PLUGIN_PRESENCE_REASONS = Object.freeze(['live-lease', 'no-live-lease', 'turned-off-in-this-vault', 'service-not-running', 'service-reports-no-plugin-channel'])
8
+
9
+ // The two ways back, for a view whose vault turned the plugin off.
10
+ export const turnPluginOnNext = (scopeId) => `turn Atelier on in Obsidian (Settings, Community plugins), or run \`atelier obsidian plugin on --scope ${scopeId}\`, which restores it at the view's next publication`
11
+
12
+ export function pluginPresenceOf(statusDocument, scopeId) {
13
+ if (!isPlainObject(statusDocument)) return { present: false, reason: 'service-not-running' }
14
+ const scopes = isPlainObject(statusDocument.plugins) && Array.isArray(statusDocument.plugins.scopes) ? statusDocument.plugins.scopes : null
15
+ if (scopes === null) return { present: false, reason: 'service-reports-no-plugin-channel' }
16
+ const entry = scopes.find((item) => isPlainObject(item) && item.scopeId === scopeId)
17
+ if (entry?.present === true && typeof entry.appVersion === 'string') {
18
+ return { present: true, reason: 'live-lease', appVersion: entry.appVersion, pluginVersion: typeof entry.pluginVersion === 'string' ? entry.pluginVersion : null, sessions: Number.isInteger(entry.sessions) ? entry.sessions : 1 }
19
+ }
20
+ if (entry?.entry === 'off') return { present: false, reason: 'turned-off-in-this-vault', next: turnPluginOnNext(scopeId) }
21
+ return { present: false, reason: 'no-live-lease' }
22
+ }
23
+
24
+ // An app probe that takes the version from the plugin when one holds the view
25
+ // and the command-line tool gives none: that plugin runs inside the app, so its
26
+ // version is the app's. A version the tool did give decides, since the tool may
27
+ // reach another app that holds the same vault. Whether the app is installed and
28
+ // has its command-line capability is still the probe's answer; that capability
29
+ // opens and coordinates the vault in this phase. So is whether the app runs: a
30
+ // lease outlives a crashed app by up to its lease time.
31
+ // The presence is asked at most once per `maxAgeMs`: open's wait loop asks the
32
+ // app far more often, and each answer about the plugin costs a round trip to
33
+ // the service.
34
+ export function withPluginReportedVersion(appProbe, readPresence, { maxAgeMs = 1000, now = () => Date.now() } = {}) {
35
+ let last = null
36
+ const presenceNow = () => {
37
+ if (last === null || now() - last.at >= maxAgeMs) last = { at: now(), presence: Promise.resolve().then(readPresence).catch(() => null) }
38
+ return last.presence
39
+ }
40
+ return {
41
+ async inspect() {
42
+ const [observation, presence] = await Promise.all([appProbe.inspect(), presenceNow()])
43
+ if (presence?.present !== true || observation?.installed !== true || observation?.cli !== true || observation.running === false || typeof observation.version === 'string') return observation
44
+ return { ...observation, running: true, version: presence.appVersion, noVaultOpen: false, versionSource: 'plugin' }
45
+ },
46
+ vaultState: (input) => appProbe.vaultState(input),
47
+ }
48
+ }
@@ -23,15 +23,27 @@ import { runMaintenanceService } from './service.mjs'
23
23
  // selected them. The editor adapter is constructed only for an app that meets
24
24
  // the minimum version; below it, or when the version cannot be read, the
25
25
  // factory refuses, the engine records that reason and nothing is published.
26
- // An adapter qualified while no app ran carries that qualification and does
27
- // not coordinate with an app that starts before the next qualification.
26
+ // The app is asked for its version only when a view is published, without
27
+ // blocking the service. An adapter qualified while no app ran carries that
28
+ // qualification and does not coordinate with an app that starts before the
29
+ // next qualification. The engine is told what the app looks like from the
30
+ // process table and the app's vault list alone (whether it runs, which vaults
31
+ // its list shows open), so a view that did not settle is tried again as soon
32
+ // as that changes, and the app is not asked anything to find out; the list is
33
+ // only read here.
28
34
  const ADAPTERS = Object.freeze({
29
35
  'obsidian-cli': async () => {
30
- const [{ createObsidianCliAdapter }, { createProductionAppProbe }, { createQualifiedAdapterFactory }] = await Promise.all([
36
+ const [{ createObsidianCliAdapter, defaultObsidianProcessProbe }, { createProductionAppProbe }, { createQualifiedAdapterFactory }, { obsidianUserDataDir, readObsidianSettings }, { appStateSignature }] = await Promise.all([
31
37
  import('../../projection/obsidian/publication/transport.mjs'), import('./app-production-seams.mjs'), import('./app-capability.mjs'),
38
+ import('../../projection/obsidian/publication/vault-list.mjs'), import('./app-registration.mjs'),
32
39
  ])
33
40
  const adapterFactory = createQualifiedAdapterFactory({ appProbe: createProductionAppProbe(), createAdapter: ({ qualification }) => createObsidianCliAdapter({ qualification }) })
34
- return { adapterFactory, appStatus: () => { const known = adapterFactory.lastQualification(); return known === null ? null : { outcome: known.outcome, reason: known.reason, version: known.version, floor: known.floor } } }
41
+ const userDataDir = obsidianUserDataDir()
42
+ const observeApp = () => appStateSignature({ processes: defaultObsidianProcessProbe(), settings: userDataDir === null ? null : readObsidianSettings({ userDataDir }) })
43
+ return {
44
+ adapterFactory, engineOptions: { observeApp },
45
+ appStatus: () => { const known = adapterFactory.lastQualification(); return known === null ? null : { outcome: known.outcome, reason: known.reason, version: known.version, floor: known.floor } },
46
+ }
35
47
  },
36
48
  })
37
49
 
@@ -88,6 +100,7 @@ if (invokedDirectly) {
88
100
  const { adapter, ...rest } = options
89
101
  const [{ loadContributions }, { createObsidianRegistry }] = await Promise.all([import('./contributions.mjs'), import('./extension-points.mjs')])
90
102
  const registry = createObsidianRegistry({ contributions: await loadContributions() })
91
- await runServiceProcess({ ...rest, entryPath: SERVICE_ENTRY_PATH, ...(await ADAPTERS[adapter]()), engineOptions: { extensions: registry.extensions } })
103
+ const { engineOptions, ...seams } = await ADAPTERS[adapter]()
104
+ await runServiceProcess({ ...rest, entryPath: SERVICE_ENTRY_PATH, ...seams, engineOptions: { ...engineOptions, extensions: registry.extensions } })
92
105
  }
93
106
  }