@mnstry/atelier 0.2.0-alpha.10 → 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 +385 -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 +681 -39
  9. package/docs/obsidian-plugin.md +585 -0
  10. package/docs/obsidian.md +292 -48
  11. package/docs/release-engineering.md +7 -1
  12. package/docs/upgrade.md +32 -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 +124 -17
  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 +115 -14
  60. package/src/runtime/obsidian/app-production-seams.mjs +126 -37
  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 +240 -51
  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 +19 -4
  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
@@ -1,9 +1,12 @@
1
1
  import { execFile, execFileSync } from 'node:child_process'
2
+ import { readlinkSync } from 'node:fs'
3
+ import path from 'node:path'
2
4
  import { POLICY_SETTINGS_PATH, buildEvalCode, createInProcessHost, runInProcess, validatePayload } from './bridge-script.mjs'
5
+ import { obsidianUserDataDir, readObsidianSettings, vaultRoute } from './vault-list.mjs'
3
6
 
4
7
  // Editor coordination adapter.
5
8
  //
6
- // adapter.probe({ vaultRoot }) -> { state: 'coordinated' | 'absent' | 'uncoordinated', reason }
9
+ // adapter.probe({ vaultRoot }) -> { state: 'coordinated' | 'absent' | 'uncoordinated', reason, code? }
7
10
  // adapter.inspect(payload) -> reply read-only, may retry
8
11
  // adapter.collect(payload) -> reply read-only, may retry
9
12
  // adapter.publish(payload) -> reply sent at most once
@@ -24,11 +27,28 @@ export class TransportTimeout extends Error {
24
27
  }
25
28
  }
26
29
 
30
+ // A call that no route can take: the app's list says no call reaches only
31
+ // this vault. `code` says why; the call is never made.
32
+ export class RouteRefusal extends Error {
33
+ constructor(code, message) {
34
+ super(`${code}: ${message}`)
35
+ this.name = 'RouteRefusal'
36
+ this.code = code
37
+ }
38
+ }
39
+
27
40
  const READ_RETRIES = 2
28
41
 
29
- export function createEditorAdapter({ call, processProbe, kind = 'custom' }) {
42
+ // `qualification`, when given, is the app qualification this adapter was
43
+ // constructed under (`qualifyApp`). An adapter qualified while no app ran was
44
+ // never shown a version, so it never coordinates with an app found running
45
+ // later, whose version nobody checked: it is `uncoordinated` until an adapter
46
+ // qualified with a checked version replaces it. Without a qualification the
47
+ // adapter coordinates as before.
48
+ export function createEditorAdapter({ call, processProbe, kind = 'custom', qualification }) {
30
49
  if (typeof call !== 'function') throw new TypeError('createEditorAdapter needs a call function')
31
50
  if (typeof processProbe !== 'function') throw new TypeError('createEditorAdapter needs a processProbe function')
51
+ const versionUnchecked = qualification !== undefined && qualification?.versionChecked !== true
32
52
  const retries = []
33
53
  let tail = Promise.resolve()
34
54
  const serialized = (operation) => {
@@ -62,18 +82,21 @@ export function createEditorAdapter({ call, processProbe, kind = 'custom' }) {
62
82
  }),
63
83
  // Path selection. `absent` is returned only when the process probe says,
64
84
  // positively, that no Obsidian is running. A running or unknowable app
65
- // that does not answer for this exact vault is `uncoordinated`.
85
+ // that does not answer for this exact vault is `uncoordinated`, and so is
86
+ // any app while this adapter's qualification checked no version. When no
87
+ // call could be routed to only this vault, `code` names the route's refusal.
66
88
  probe: ({ vaultRoot }) => serialized(async () => {
67
89
  let processes
68
90
  try { processes = await processProbe() } catch { processes = 'unknown' }
69
91
  if (processes === 'absent') return { state: 'absent', reason: 'no Obsidian process is running' }
92
+ if (versionUnchecked) return { state: 'uncoordinated', reason: 'app-version-unchecked: an Obsidian process may be running, and this adapter was qualified while none ran, so its version was never checked' }
70
93
  try {
71
94
  const reply = await readOnly({ op: 'inspect', vaultRoot, path: POLICY_SETTINGS_PATH })
72
95
  // `path-unsafe` for the probe path still proves the app answered for this vault; the settings unit reports the path.
73
96
  if (['inspected', 'path-unsafe'].includes(reply.status) && reply.vaultBasePath === vaultRoot) return { state: 'coordinated', reason: 'the app answered for this vault' }
74
97
  return { state: 'uncoordinated', reason: reply.status === 'vault-mismatch' ? 'the app answered for another vault' : `the app answered ${reply.status}` }
75
98
  } catch (error) {
76
- return { state: 'uncoordinated', reason: `an Obsidian process may be running and the bridge did not answer: ${String(error.message || error).slice(0, 200)}` }
99
+ return { state: 'uncoordinated', ...(error instanceof RouteRefusal ? { code: error.code } : {}), reason: `an Obsidian process may be running and the bridge did not answer: ${String(error.message || error).slice(0, 200)}` }
77
100
  }
78
101
  }),
79
102
  }
@@ -101,13 +124,55 @@ export function defaultCliPath(platform = process.platform) {
101
124
  return 'obsidian-cli'
102
125
  }
103
126
 
127
+ // A directory that is no vault, for a call that must not choose one by where it runs.
128
+ export const NEUTRAL_DIRECTORY = (() => { try { return path.parse(process.cwd()).root } catch { return path.sep } })()
129
+
130
+ // The working directory and leading arguments of a call that goes where a
131
+ // `vaultRoute` says: into the vault's folder, or naming the vault's id first
132
+ // from a directory that is no vault (`neutral`), or, for any other route, from
133
+ // that directory naming no vault.
134
+ export function routedCall(route, neutral = NEUTRAL_DIRECTORY) {
135
+ if (route?.how === 'folder') return { cwd: route.cwd, args: [] }
136
+ if (route?.how === 'id') return { cwd: neutral, args: [`vault=${route.id}`] }
137
+ return { cwd: neutral, args: [] }
138
+ }
139
+
140
+ // Which window a publication call reaches. The app answers a command in the
141
+ // window of the vault its list routes the call to (see vault-list.mjs),
142
+ // opening that vault first when it is closed, and otherwise in the vault
143
+ // window that had focus last, which may be any vault the person uses. While
144
+ // the app lists this vault as open, a call therefore runs in its folder, or,
145
+ // when a vault listed at a folder above it would take a call run there, names
146
+ // its id; when neither reaches only this vault, no call is made (the error
147
+ // says `vault-inside-another-vault`), nor when the app has the vault open in
148
+ // more than one window, one per entry of its list that names this folder
149
+ // (`vault-open-in-several-windows`): a call would coordinate with one of them
150
+ // only. Otherwise a call runs in a directory
151
+ // that is no vault and names none: a closed vault is never reopened by
152
+ // maintenance, no other vault is reached through this one's folder, and the
153
+ // working directory of whoever started the service never picks a vault. The
154
+ // script still checks that it answered for exactly this vault.
155
+ export function publicationRoute({ env = process.env, platform = process.platform } = {}) {
156
+ const userDataDir = obsidianUserDataDir({ platform, env })
157
+ return (payload) => {
158
+ const settings = readObsidianSettings({ userDataDir })
159
+ const route = settings.ok ? vaultRoute({ vaults: settings.vaults, vaultRoot: payload.vaultRoot, open: true }) : { how: 'unlisted' }
160
+ if (route.how === 'ambiguous') throw new RouteRefusal('vault-inside-another-vault', 'Obsidian lists a vault at a folder above this one, and no call reaches only this vault; no call was made')
161
+ if (route.how === 'duplicated') throw new RouteRefusal('vault-open-in-several-windows', `Obsidian has this vault open in ${route.entries.length} windows (${route.entries.map((entry) => entry.id).join(', ')}), and a call reaches one of them only; no call was made`)
162
+ return routedCall(route)
163
+ }
164
+ }
165
+
104
166
  // The CLI reaches the app through a socket under $HOME, so `env` selects the
105
167
  // app instance. It is passed explicitly and never edited here. A call that
106
168
  // outlives its timeout is killed with SIGKILL: the CLI ignores SIGTERM while
107
- // it waits on the app.
108
- export function createObsidianCliCall({ cliPath = defaultCliPath(), env = process.env, timeoutMs = 20000 } = {}) {
169
+ // it waits on the app. `route(payload)` says where a call runs and what it
170
+ // names first ({ cwd, args }); a route that throws makes no call.
171
+ export function createObsidianCliCall({ cliPath = defaultCliPath(), env = process.env, timeoutMs = 20000, route = () => routedCall(null) } = {}) {
109
172
  return (payload) => new Promise((resolve, reject) => {
110
- execFile(cliPath, ['eval', `code=${buildEvalCode(payload)}`], { env, timeout: timeoutMs, killSignal: 'SIGKILL', maxBuffer: 16 * 1024 * 1024 }, (error, stdout, stderr) => {
173
+ let where
174
+ try { where = route(payload) } catch (error) { return reject(error) }
175
+ execFile(cliPath, [...where.args, 'eval', `code=${buildEvalCode(payload)}`], { env, cwd: where.cwd, timeout: timeoutMs, killSignal: 'SIGKILL', maxBuffer: 16 * 1024 * 1024 }, (error, stdout, stderr) => {
111
176
  if (error) return reject(error.killed ? new TransportTimeout(`CLI call timed out: ${payload.op}`) : new Error(`CLI call failed: ${String(error.message).slice(0, 300)}`))
112
177
  const out = stdout || stderr || ''
113
178
  const start = out.indexOf('=> ')
@@ -127,13 +192,48 @@ export function createObsidianCliCall({ cliPath = defaultCliPath(), env = proces
127
192
  // directory: a moved or renamed bundle still counts, and so does a
128
193
  // process whose path ps cannot read and names by its short name.
129
194
  // Linux `obsidian`, the executable of the .deb, snap, AppImage and Flatpak
130
- // builds. A distribution that runs the app under a system Electron
131
- // shows only `electron`, and which app it hosts is not known.
195
+ // builds, by name or, for a name that is not recognised, by the
196
+ // executable it resolves to. A distribution that runs the app under
197
+ // a system Electron shows only `electron`, and which app it hosts is
198
+ // not known.
132
199
  //
133
200
  // The command-line tool is a client of the app, not the app: counting it would
134
201
  // make every call this package makes through it look like a running app.
135
202
  const APP_EXECUTABLE = Object.freeze({ darwin: /^obsidian(?: helper\b.*)?$/i, linux: /^obsidian$/i })
136
203
  const ELECTRON_HOST = /^\.?electron(?:\d+|-wrap.*)?$/i
204
+ // A `ps` that has not answered by then is killed, and the reading is unknown.
205
+ const PS_TIMEOUT_MS = 5000
206
+ // A process that exited since the table was read, or whose executable this
207
+ // user may not read: another user's process, or one of this user's that hides
208
+ // it from tracing (ssh-agent, a sandboxed renderer). Such a process is judged
209
+ // by its name alone.
210
+ const EXECUTABLE_UNREADABLE = new Set(['ENOENT', 'ESRCH', 'EACCES', 'EPERM'])
211
+
212
+ const baseName = (text) => text.slice(text.lastIndexOf('/') + 1)
213
+ const readProcessExecutable = (pid) => readlinkSync(`/proc/${pid}/exe`)
214
+
215
+ // Linux names a process after the path it was started through, so an app
216
+ // started through a differently named link or launcher shows another name.
217
+ // The executable `/proc/<pid>/exe` resolves to does not change with the name.
218
+ // Any failure other than the expected ones is unknown, and so is a table in
219
+ // which no executable could be resolved at all: without `/proc`, nothing
220
+ // below the names can be established.
221
+ function resolveLinuxExecutables(rows, readExe) {
222
+ let resolved = 0
223
+ let unknown = false
224
+ for (const { pid } of rows) {
225
+ let target
226
+ try { target = String(readExe(pid)) } catch (error) {
227
+ if (!EXECUTABLE_UNREADABLE.has(error?.code)) unknown = true
228
+ continue
229
+ }
230
+ resolved += 1
231
+ const name = baseName(target.replace(/ \(deleted\)$/, ''))
232
+ if (APP_EXECUTABLE.linux.test(name)) return 'running'
233
+ if (ELECTRON_HOST.test(name)) unknown = true
234
+ }
235
+ return unknown || resolved === 0 ? 'unknown' : 'absent'
236
+ }
137
237
 
138
238
  // Default process probe: 'absent' only when the process table was read and
139
239
  // holds no Obsidian. Limits: it sees this machine's processes as this user
@@ -141,20 +241,27 @@ const ELECTRON_HOST = /^\.?electron(?:\d+|-wrap.*)?$/i
141
241
  // vault through a shared or synchronized folder, an app packaged under another
142
242
  // executable name, or an app that starts after the probe. Anything it cannot
143
243
  // establish is 'unknown', which the adapter treats as a running app: an empty
144
- // table, a failed read, and on Linux a system Electron process.
145
- export function defaultObsidianProcessProbe({ platform = process.platform, run = execFileSync } = {}) {
244
+ // table, a failed or slow read, and on Linux a system Electron process or an
245
+ // executable that cannot be resolved for an unexpected reason.
246
+ export function defaultObsidianProcessProbe({ platform = process.platform, run = execFileSync, readExe = readProcessExecutable } = {}) {
146
247
  if (!Object.hasOwn(APP_EXECUTABLE, platform)) return 'unknown'
248
+ const linux = platform === 'linux'
147
249
  try {
148
- const table = run('/bin/ps', ['-A', '-o', 'comm='], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 16 * 1024 * 1024 })
250
+ const table = run('/bin/ps', ['-A', '-o', linux ? 'pid=,comm=' : 'comm='], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 16 * 1024 * 1024, timeout: PS_TIMEOUT_MS, killSignal: 'SIGKILL' })
149
251
  if (typeof table !== 'string' || table.trim() === '') return 'unknown'
150
- const names = table.split('\n').map((line) => line.trim()).filter((line) => line !== '').map((line) => line.slice(line.lastIndexOf('/') + 1))
151
- if (names.some((name) => APP_EXECUTABLE[platform].test(name))) return 'running'
152
- return platform === 'linux' && names.some((name) => ELECTRON_HOST.test(name)) ? 'unknown' : 'absent'
252
+ const rows = table.split('\n').map((line) => line.trim()).filter((line) => line !== '').map((line) => {
253
+ const match = linux ? /^(\d+)\s+(.*)$/.exec(line) : null
254
+ return match ? { pid: Number(match[1]), comm: match[2] } : { pid: null, comm: line }
255
+ })
256
+ if (rows.some((row) => APP_EXECUTABLE[platform].test(baseName(row.comm)))) return 'running'
257
+ if (!linux) return 'absent'
258
+ if (rows.some((row) => row.pid === null || ELECTRON_HOST.test(baseName(row.comm)))) return 'unknown'
259
+ return resolveLinuxExecutables(rows, readExe)
153
260
  } catch {
154
261
  return 'unknown'
155
262
  }
156
263
  }
157
264
 
158
- export function createObsidianCliAdapter({ cliPath, env, timeoutMs, processProbe = () => defaultObsidianProcessProbe() } = {}) {
159
- return createEditorAdapter({ call: createObsidianCliCall({ cliPath, env, timeoutMs }), processProbe, kind: 'obsidian-cli' })
265
+ export function createObsidianCliAdapter({ cliPath, env = process.env, timeoutMs, processProbe = () => defaultObsidianProcessProbe(), qualification, route = publicationRoute({ env }) } = {}) {
266
+ return createEditorAdapter({ call: createObsidianCliCall({ cliPath, env, timeoutMs, route }), processProbe, kind: 'obsidian-cli', qualification })
160
267
  }
@@ -0,0 +1,199 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { openRegularFileNoFollow, realPathAsStored } from '../../../project/private-state.mjs'
4
+
5
+ // Obsidian's own list of the folders it knows as vaults, read only.
6
+ //
7
+ // The app keeps the list in `obsidian.json` in its user-data directory, as
8
+ // `{ "vaults": { "<id>": { "path", "ts", "open"? } }, ...other settings }`.
9
+ // It reads the file when it starts and rewrites the whole file whenever the
10
+ // list changes. `open` is true while a window of that vault is open, and stays
11
+ // true for the vaults that were open when the app quit, which it reopens on its
12
+ // next start. `obsidian://open?path=` finds a vault only through this list.
13
+ //
14
+ // Nothing here writes the file. The one write Atelier makes to it is in
15
+ // src/runtime/obsidian/app-registration.mjs, and only while no Obsidian runs.
16
+ //
17
+ // The list also decides which window answers a command-line call (1.13.7): a
18
+ // first argument `vault=<value>` names the first listed vault whose id is the
19
+ // value, or whose folder's name is the value in any letter case; without one,
20
+ // the first listed vault whose folder is the tool's working directory or
21
+ // contains it takes the call, the folders compared as written; failing both,
22
+ // the vault window that had focus last. The first in list order, not the
23
+ // deepest: a vault listed at a folder above another one takes the calls run
24
+ // inside it. The app opens a vault that takes a call when it is closed.
25
+
26
+ export const OBSIDIAN_SETTINGS_FILE = 'obsidian.json'
27
+ // Far more than a list of vaults and a few settings; a larger file is not read.
28
+ export const MAX_OBSIDIAN_SETTINGS_BYTES = 4 * 1024 * 1024
29
+
30
+ // The user-data directory of the app for this account: Electron's, for an app
31
+ // named `obsidian`. Null where it is not known (another platform, no HOME). A
32
+ // Flatpak or snap build keeps its own elsewhere and is not found here (see
33
+ // obsidianSandboxedBuild). Both known platforms have POSIX paths, whatever
34
+ // platform asks.
35
+ export function obsidianUserDataDir({ platform = process.platform, env = process.env } = {}) {
36
+ const home = env.HOME
37
+ if (platform !== 'darwin' && platform !== 'linux') return null
38
+ if (typeof home !== 'string' || !path.posix.isAbsolute(home)) return null
39
+ if (platform === 'darwin') return path.posix.join(home, 'Library', 'Application Support', 'obsidian')
40
+ const config = typeof env.XDG_CONFIG_HOME === 'string' && path.posix.isAbsolute(env.XDG_CONFIG_HOME) ? env.XDG_CONFIG_HOME : path.posix.join(home, '.config')
41
+ return path.posix.join(config, 'obsidian')
42
+ }
43
+
44
+ // A Flatpak or snap build of Obsidian on Linux keeps its user-data directory
45
+ // inside its sandbox (`~/.var/app/md.obsidian.Obsidian/config/obsidian`,
46
+ // `~/snap/obsidian/current/.config/obsidian`), where Atelier does not write,
47
+ // and reads no file outside it. Which build this account uses: the one whose
48
+ // vault list was written last, since every build rewrites its own whenever a
49
+ // vault window opens or closes ('flatpak', 'snap', or null for the native
50
+ // build); only when no build wrote one, a Flatpak or snap installation, or a
51
+ // Flatpak or snap left by one. Null on any other platform. `exists` answers
52
+ // whether a path exists, `modified` when a file was last written (or null).
53
+ const modifiedAt = (file) => { try { return fs.statSync(file).mtimeMs } catch { return null } }
54
+ export function obsidianSandboxedBuild({ platform = process.platform, env = process.env, exists = fs.existsSync, modified = modifiedAt } = {}) {
55
+ if (platform !== 'linux') return null
56
+ const home = typeof env.HOME === 'string' && path.posix.isAbsolute(env.HOME) ? env.HOME : null
57
+ const under = (...parts) => (home === null ? [] : [path.posix.join(home, ...parts)])
58
+ const native = obsidianUserDataDir({ platform, env })
59
+ const lists = [
60
+ [null, native === null ? [] : [path.posix.join(native, OBSIDIAN_SETTINGS_FILE)]],
61
+ ['flatpak', under('.var', 'app', 'md.obsidian.Obsidian', 'config', 'obsidian', OBSIDIAN_SETTINGS_FILE)],
62
+ ['snap', under('snap', 'obsidian', 'current', '.config', 'obsidian', OBSIDIAN_SETTINGS_FILE)],
63
+ ].flatMap(([build, files]) => files.map((file) => { let at; try { at = modified(file) } catch { at = null } return { build, at } })).filter(({ at }) => typeof at === 'number')
64
+ if (lists.length > 0) return lists.reduce((latest, list) => (list.at > latest.at ? list : latest)).build
65
+ const found = (paths) => paths.some((candidate) => { try { return exists(candidate) } catch { return false } })
66
+ if (found([...under('.var', 'app', 'md.obsidian.Obsidian'), ...under('.local', 'share', 'flatpak', 'app', 'md.obsidian.Obsidian'), '/var/lib/flatpak/app/md.obsidian.Obsidian'])) return 'flatpak'
67
+ if (found([...under('snap', 'obsidian'), '/snap/obsidian', '/var/lib/snapd/snap/obsidian'])) return 'snap'
68
+ return null
69
+ }
70
+
71
+ const isPlainObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value)
72
+ // The real path as stored (realPathAsStored): what the operating system reports as a working directory.
73
+ const realOrResolved = (target) => { try { return realPathAsStored(target) } catch { return path.resolve(target) } }
74
+ const listedFolders = (vaults) => (isPlainObject(vaults) ? Object.entries(vaults).filter(([, entry]) => isPlainObject(entry) && typeof entry.path === 'string' && path.isAbsolute(entry.path)) : [])
75
+
76
+ // A folder the list names, against a vault root spelled as given (`spelled`)
77
+ // and as its real path (`target`). Compared as written first; the real path of
78
+ // a listed folder is read only when its last component is the vault root's,
79
+ // as it is for the root reached through a linked parent. So a vault on a
80
+ // mount that does not answer is never waited on; a link to the vault root
81
+ // under another name is not recognised.
82
+ const namesFolder = (entryPath, { spelled, target }) => {
83
+ const written = path.resolve(entryPath)
84
+ if (written === spelled || written === target) return true
85
+ return path.basename(written) === path.basename(target) && realOrResolved(entryPath) === target
86
+ }
87
+ const rootOf = (vaultRoot) => ({ spelled: path.resolve(vaultRoot), target: realOrResolved(vaultRoot) })
88
+
89
+ // The listed entries (listedFolders) whose folder is this vault root (see
90
+ // namesFolder), in list order. One folder can be listed more than once, under
91
+ // another letter case or through a link, say.
92
+ const entriesOf = (listed, root) => listed.filter(([, entry]) => namesFolder(entry.path, root))
93
+
94
+ // The entry of the app's vault map whose folder is this vault root (see
95
+ // namesFolder), one the app lists open first; null when there is none. `vaults`
96
+ // is the map as the app keeps it, from its file or from the app itself.
97
+ export function findVaultEntry(vaults, vaultRoot) {
98
+ if (!isPlainObject(vaults) || typeof vaultRoot !== 'string' || !path.isAbsolute(vaultRoot)) return null
99
+ const entries = entriesOf(listedFolders(vaults), rootOf(vaultRoot))
100
+ const found = entries.find(([, entry]) => entry.open === true) ?? entries[0]
101
+ return found === undefined ? null : { id: found[0], path: found[1].path, open: found[1].open === true }
102
+ }
103
+
104
+ const refused = (code, message) => ({ ok: false, code, message })
105
+ const currentUid = () => (typeof process.getuid === 'function' ? process.getuid() : null)
106
+
107
+ // Reads the settings file without following a link and without writing:
108
+ // { ok: true, file, document, vaults, bytes, mode } or a typed refusal
109
+ // { ok: false, code, message }. The user-data directory and the file must be
110
+ // this user's own, a real directory and a regular file; the file must be a
111
+ // JSON object whose `vaults`, when present, is an object. A missing directory
112
+ // or file means Obsidian has not run on this account.
113
+ export function readObsidianSettings({ userDataDir, uid = currentUid() } = {}) {
114
+ if (typeof userDataDir !== 'string' || !path.isAbsolute(userDataDir)) return refused('obsidian-settings-location-unknown', 'where Obsidian keeps its settings on this system is not known')
115
+ const owned = (stat) => uid === null || stat.uid === uid
116
+ let directory
117
+ try { directory = fs.lstatSync(userDataDir) } catch (error) {
118
+ if (error.code === 'ENOENT') return refused('obsidian-settings-missing', 'Obsidian has no settings directory on this account; it has not run here')
119
+ return refused('obsidian-settings-unreadable', 'the Obsidian settings directory cannot be read')
120
+ }
121
+ if (directory.isSymbolicLink() || !directory.isDirectory()) return refused('obsidian-settings-unsafe', 'the Obsidian settings directory is a link or not a directory')
122
+ if (!owned(directory)) return refused('obsidian-settings-not-owned', 'the Obsidian settings directory belongs to another user')
123
+ const file = path.join(userDataDir, OBSIDIAN_SETTINGS_FILE)
124
+ let leaf
125
+ try { leaf = fs.lstatSync(file) } catch (error) {
126
+ if (error.code === 'ENOENT') return refused('obsidian-settings-missing', 'Obsidian has no settings file on this account; it has not run here')
127
+ return refused('obsidian-settings-unreadable', 'the Obsidian settings file cannot be read')
128
+ }
129
+ if (leaf.isSymbolicLink() || !leaf.isFile()) return refused('obsidian-settings-unsafe', 'the Obsidian settings file is a link or not a regular file')
130
+ if (!owned(leaf)) return refused('obsidian-settings-not-owned', 'the Obsidian settings file belongs to another user')
131
+ if (leaf.size > MAX_OBSIDIAN_SETTINGS_BYTES) return refused('obsidian-settings-unreadable', 'the Obsidian settings file is larger than a settings file can be')
132
+ let bytes
133
+ let descriptor
134
+ try {
135
+ descriptor = openRegularFileNoFollow(file)
136
+ const opened = fs.fstatSync(descriptor)
137
+ if (!owned(opened)) return refused('obsidian-settings-not-owned', 'the Obsidian settings file belongs to another user')
138
+ bytes = fs.readFileSync(descriptor)
139
+ } catch {
140
+ return refused('obsidian-settings-unreadable', 'the Obsidian settings file cannot be read')
141
+ } finally {
142
+ if (descriptor !== undefined) try { fs.closeSync(descriptor) } catch { /* read already, or refused */ }
143
+ }
144
+ let document
145
+ try { document = JSON.parse(bytes.toString('utf8')) } catch { return refused('obsidian-settings-unreadable', 'the Obsidian settings file is not JSON') }
146
+ if (!isPlainObject(document)) return refused('obsidian-settings-not-object', 'the Obsidian settings file is not a JSON object')
147
+ if (document.vaults !== undefined && !isPlainObject(document.vaults)) return refused('obsidian-settings-not-object', 'the vault list in the Obsidian settings file is not an object')
148
+ return { ok: true, file, document, vaults: document.vaults ?? {}, bytes, mode: leaf.mode & 0o777, links: leaf.nlink }
149
+ }
150
+
151
+ // Where a command-line call about this vault reaches the app, predicted from
152
+ // the app's list as the app routes a call (see above):
153
+ //
154
+ // { how: 'id', id } run in a directory that is no vault, with `vault=<id>` first: the id names this vault first.
155
+ // Preferred: it does not depend on how a working directory is spelled;
156
+ // { how: 'folder', cwd } run in the vault's folder (its real path as stored, which the tool reports), when the id
157
+ // names another vault first and the first listed vault that is or contains the folder is this one;
158
+ // { how: 'unlisted' } the list has no entry for this folder (with `open`, none that the app lists open);
159
+ // { how: 'ambiguous' } the id names another vault first, and a vault listed above it takes a call run in its folder;
160
+ // { how: 'duplicated', entries }
161
+ // the list has this folder open more than once, one window per entry ({ id, path }): each
162
+ // window holds the vault, and a call reaches one of them only.
163
+ //
164
+ // With `open`, only an entry the app lists open may take the call, so a
165
+ // closed vault window is never reopened. Without it, so does an entry the app
166
+ // lists open when there is one: another entry of the same folder is not opened
167
+ // beside it.
168
+ export function vaultRoute({ vaults, vaultRoot, open = false } = {}) {
169
+ if (typeof vaultRoot !== 'string' || !path.isAbsolute(vaultRoot)) return { how: 'unlisted' }
170
+ const root = rootOf(vaultRoot)
171
+ const { target } = root
172
+ const listed = listedFolders(vaults)
173
+ const entries = entriesOf(listed, root)
174
+ const windows = entries.filter(([, entry]) => entry.open === true)
175
+ if (windows.length > 1) return { how: 'duplicated', entries: windows.map(([id, entry]) => ({ id, path: entry.path })) }
176
+ const own = open || windows.length > 0 ? windows : entries
177
+ if (own.length === 0) return { how: 'unlisted' }
178
+ const takes = ([id]) => own.some(([ownId]) => ownId === id)
179
+ const byName = (value) => listed.find(([id, entry]) => id === value || path.basename(entry.path).toUpperCase() === value.toUpperCase())
180
+ const named = own.find(([id]) => byName(id)?.[0] === id)
181
+ if (named !== undefined) return { how: 'id', id: named[0] }
182
+ const byFolder = listed.find(([, entry]) => { const folder = path.resolve(entry.path); return target === folder || target.startsWith(folder + path.sep) })
183
+ return byFolder !== undefined && takes(byFolder) ? { how: 'folder', cwd: target } : { how: 'ambiguous' }
184
+ }
185
+
186
+ // The listed vaults whose folder contains this vault root, as written, against
187
+ // the root as given and as its real path: the app would show this vault's
188
+ // notes in their windows too, and a call run in this vault's folder may reach
189
+ // them. No vault is added inside another one. No listed folder's real path is
190
+ // read, so a vault above the root only through a link is not found.
191
+ export function enclosingVaults({ vaults, vaultRoot } = {}) {
192
+ if (typeof vaultRoot !== 'string' || !path.isAbsolute(vaultRoot)) return []
193
+ const { spelled, target } = rootOf(vaultRoot)
194
+ const inside = (folder, root) => folder !== root && root.startsWith(folder.endsWith(path.sep) ? folder : folder + path.sep)
195
+ return listedFolders(vaults).filter(([, entry]) => {
196
+ const folder = path.resolve(entry.path)
197
+ return !namesFolder(entry.path, { spelled, target }) && (inside(folder, target) || inside(folder, spelled))
198
+ }).map(([id, entry]) => ({ id, path: entry.path }))
199
+ }
@@ -300,6 +300,8 @@ export function recoverPublications({ store, clock = () => new Date() }) {
300
300
  // Digests this view has published since its trusted manifest was committed,
301
301
  // by journals that did not reach a commit. They are valid bases: without them
302
302
  // a note published by an interrupted run would look like an outside edit.
303
+ // Settings and Atelier's plugin files are planned from the disk, never from a
304
+ // base, so they have no place here.
303
305
  export function publishedSinceCommit({ store }) {
304
306
  const pointer = store.readCurrent()
305
307
  const ledger = new Map()
@@ -308,7 +310,7 @@ export function publishedSinceCommit({ store }) {
308
310
  if (document.state === 'committed' || document.expectedGeneration !== (pointer?.generationId ?? null)) continue
309
311
  for (const entry of document.entries) {
310
312
  const detail = journalDetail(entry)
311
- if (entry.step !== 'conditional-update' || entry.outcome !== 'ok' || detail.kind === 'settings') continue
313
+ if (entry.step !== 'conditional-update' || entry.outcome !== 'ok' || detail.kind === 'settings' || detail.kind === 'plugin') continue
312
314
  ledger.set(entry.notePath, detail.op === 'remove' ? null : entry.afterDigest)
313
315
  }
314
316
  }
@@ -3,7 +3,7 @@ import fs from 'node:fs'
3
3
  import path from 'node:path'
4
4
  import { acquirePrivateLock, publishPrivateFile } from '../../../project/durable-state.mjs'
5
5
  import { checkManagedRoots } from '../../../project/file-class.mjs'
6
- import { atomicReplacePrivateText, ensureContainedPrivateDirectory, openRegularFileNoFollow, readRegularTextNoFollow } from '../../../project/private-state.mjs'
6
+ import { atomicReplacePrivateText, ensureContainedPrivateDirectory, openRegularFileNoFollow, readRegularTextNoFollow, realPathAsStored } from '../../../project/private-state.mjs'
7
7
 
8
8
  // Private per-workspace state for one Obsidian view:
9
9
  //
@@ -128,11 +128,14 @@ export function createRecoveryStore({ workspaceRoot, workspaceId, scopeId, vault
128
128
  const guard = checkManagedRoots({ managedRoots: [workspaceRoot, ...(vaultRoot === undefined ? [] : [vaultRoot])], repositoryRoots })
129
129
  if (!guard.ok) refuse(guard.refusals[0].code, guard.refusals[0].message, { refusals: guard.refusals })
130
130
  fs.mkdirSync(workspaceRoot, { recursive: true, mode: 0o700 })
131
- const root = fs.realpathSync(workspaceRoot)
131
+ // Both as the file system stores them: the vault root is what the app is told, and what it compares its own
132
+ // working-directory spelling with.
133
+ const root = realPathAsStored(workspaceRoot)
132
134
  const privateDir = (...parts) => ensureContainedPrivateDirectory({ workspaceRoot: root, directory: path.join(root, ...parts), label: 'Obsidian publication state' })
133
135
  const requestedVault = vaultRoot ?? path.join(root, 'vaults', segment(scopeId))
134
- fs.mkdirSync(requestedVault, { recursive: true })
135
- const vault = fs.realpathSync(requestedVault)
136
+ // A vault this store places itself is private to this user from the start: it will hold the plugin's bearer.
137
+ fs.mkdirSync(requestedVault, { recursive: true, ...(vaultRoot === undefined ? { mode: 0o700 } : {}) })
138
+ const vault = realPathAsStored(requestedVault)
136
139
  const inside = (parent, child) => { const relative = path.relative(parent, child); return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative)) }
137
140
  for (const area of ['state', 'recovery', 'staging']) {
138
141
  if (inside(path.join(root, area), vault) || inside(vault, path.join(root, area))) throw new TypeError('the vault may not overlap private publication state')
@@ -149,6 +152,10 @@ export function createRecoveryStore({ workspaceRoot, workspaceId, scopeId, vault
149
152
  workspaceId,
150
153
  scopeId,
151
154
  vaultRoot: vault,
155
+ // Placed by this store under the workspace state, rather than named by the caller, and really there: a vault path
156
+ // that is a link leads to a folder someone else chose, which is only looked at and never receives the bearer.
157
+ managedVaultRoot: vaultRoot === undefined && vault === requestedVault,
158
+ linkedVaultRoot: vaultRoot === undefined && vault !== requestedVault,
152
159
  journalsRoot: journals,
153
160
  lockPath: path.join(locks, `${segment(scopeId)}.lock`),
154
161
  // One publisher per vault, whichever view or workspace state it belongs to.
@@ -3,8 +3,11 @@ import { refuse } from '../../../runtime/obsidian/errors.mjs'
3
3
 
4
4
  // Focus: the full vault stays on disk and the native Graph view is filtered
5
5
  // with a "Search files" query derived from the selected set. The query names
6
- // every selected note by its exact vault-relative path, quoted and escaped
7
- // the way the app's search syntax reads a quoted term, joined with OR.
6
+ // every selected note by its exact vault-relative path, as an anchored regular
7
+ // expression in the app's `path:/…/` search, joined with OR: a path search
8
+ // matches substrings, and with titles as names one path can lie inside
9
+ // another (`r/a/Plan.md` inside `qr/a/Plan.md`), so only an anchored term
10
+ // matches exactly one note.
8
11
  //
9
12
  // Nothing here writes a file. The query and the core-Bookmark payload are
10
13
  // values a person, an agent or the acceptance procedure applies in the app;
@@ -13,7 +16,9 @@ import { refuse } from '../../../runtime/obsidian/errors.mjs'
13
16
  // Atelier. Whether the installed app applies the query as built is an
14
17
  // acceptance question (AP-02), not something this module can answer.
15
18
 
16
- export const FOCUS_QUERY_VERSION = 'obsidian-graph-search-paths/v1'
19
+ export const FOCUS_QUERY_VERSION = 'obsidian-graph-search-paths/v2'
20
+ // Versions a persisted focus may carry and still be read: v1 quoted each path as a substring term.
21
+ export const FOCUS_QUERY_VERSIONS_READ = Object.freeze(['obsidian-graph-search-paths/v1', FOCUS_QUERY_VERSION])
17
22
  export const FOCUS_BOOKMARK_TYPE = 'graph'
18
23
  // The files under .obsidian/ the app's UI owns. Atelier reads none of them
19
24
  // and writes none of them; a focus is applied through the app, never by
@@ -21,14 +26,16 @@ export const FOCUS_BOOKMARK_TYPE = 'graph'
21
26
  export const UI_OWNED_SETTINGS_FILES = Object.freeze(['workspace.json', 'graph.json', 'bookmarks.json'])
22
27
  export const MAX_FOCUS_PATHS = 100000
23
28
 
24
- // A quoted search term: backslash and double quote are escaped; anything
25
- // that is not printable text cannot be typed into a search box and refuses.
29
+ // An anchored regular-expression term: every metacharacter and the `/`
30
+ // delimiter escaped, a space and a double quote written as hexadecimal escapes
31
+ // so the search box neither splits nor quotes the term; anything that is not
32
+ // printable text cannot be typed into a search box and refuses.
26
33
  export const FOCUS_QUERY_PRIMITIVES = Object.freeze({
27
34
  escape(value) {
28
- return value.replace(/[\\"]/g, (character) => `\\${character}`)
35
+ return value.replace(/[\\^$.*+?()[\]{}|/]/g, (character) => `\\${character}`).replace(/ /g, '\\x20').replace(/"/g, '\\x22')
29
36
  },
30
37
  term(path) {
31
- return `path:"${path}"`
38
+ return `path:/^${path}$/`
32
39
  },
33
40
  join(terms) {
34
41
  return terms.join(' OR ')
@@ -6,7 +6,7 @@ import { refuse } from '../../../runtime/obsidian/errors.mjs'
6
6
  import { assertOutsideRepositories } from '../../../runtime/obsidian/machine-settings.mjs'
7
7
  import { SCOPE_MODES, assertObsidianContract } from '../contracts.mjs'
8
8
  import { SETTINGS_ROOT } from '../materialize/settings.mjs'
9
- import { FOCUS_BOOKMARK_TYPE, FOCUS_QUERY_VERSION } from './focus.mjs'
9
+ import { FOCUS_BOOKMARK_TYPE, FOCUS_QUERY_VERSIONS_READ } from './focus.mjs'
10
10
  import { SELECTION_SCHEMA } from './selection.mjs'
11
11
 
12
12
  // Where a selector is kept: Atelier's own private state of the workspace,
@@ -38,7 +38,7 @@ export function validateSelectionState(document, workspaceId) {
38
38
  assertObsidianContract('scope', { schema: 'atelier-obsidian-scope/v1', scopeId: document.scopeId, mode: document.mode, selector: document.selector, ...(document.expansion === null ? {} : { expansion: document.expansion }) })
39
39
  if (document.focus !== null) {
40
40
  closedObject(document.focus, { required: ['version', 'query', 'queryDigest', 'paths', 'bookmark'] }, code, 'the persisted focus')
41
- if (document.focus.version !== FOCUS_QUERY_VERSION || typeof document.focus.query !== 'string' || document.focus.query === '' || !DIGEST.test(String(document.focus.queryDigest))) refuse(code, 'the persisted focus is malformed')
41
+ if (!FOCUS_QUERY_VERSIONS_READ.includes(document.focus.version) || typeof document.focus.query !== 'string' || document.focus.query === '' || !DIGEST.test(String(document.focus.queryDigest))) refuse(code, 'the persisted focus is malformed')
42
42
  if (!Array.isArray(document.focus.paths) || document.focus.paths.length === 0 || document.focus.paths.some((item) => typeof item !== 'string')) refuse(code, 'the persisted focus names no path')
43
43
  closedObject(document.focus.bookmark, { required: ['type', 'title', 'options'] }, code, 'the persisted bookmark payload')
44
44
  if (document.focus.bookmark.type !== FOCUS_BOOKMARK_TYPE) refuse(code, 'the persisted bookmark payload is not a graph bookmark')
@@ -1,6 +1,6 @@
1
1
  import { refuse } from '../../../runtime/obsidian/errors.mjs'
2
2
  import { EXPANSION_DIRECTIONS, EXPANSION_ORDERS, MAX_EXPANSION_DEPTH, ObsidianContractRefusal, SCOPE_MODES, assertObsidianContract, selectScope } from '../contracts.mjs'
3
- import { allocateWorkspacePaths } from '../materialize/path-registry.mjs'
3
+ import { allocateViewPaths, viewsOfRegistry } from '../materialize/path-registry.mjs'
4
4
  import { buildFocusQuery, focusBookmarkPayload } from './focus.mjs'
5
5
 
6
6
  // Selection: a requested scope (full, scoped or focus, with an optional
@@ -78,9 +78,14 @@ export function resolveSelection({ canonicalSnapshot, profile, scope, allowEmpty
78
78
 
79
79
  const workspaceId = typeof profile?.workspaceId === 'string' ? profile.workspaceId : 'ws-unnamed'
80
80
  const nodeById = new Map(canonicalSnapshot.nodes.map((node) => [node.id, node]))
81
- const vaultNodes = result.vaultNodes.map((id) => nodeById.get(id))
81
+ // Paths are allocated among the view's own notes, as a prepared view allocates them, seeded from the view's last
82
+ // allocation in the registry: a name that collides takes a qualifier, so which name a note gets depends on the notes
83
+ // of the view.
82
84
  let pathOf
83
- try { ({ pathOf } = allocateWorkspacePaths({ registry: pathRegistry, workspaceId, nodes: vaultNodes })) } catch (error) { contractRefusalToTyped(error) }
85
+ try {
86
+ const section = viewsOfRegistry(pathRegistry, workspaceId)[document.scopeId] ?? null
87
+ ;({ pathOf } = allocateViewPaths({ published: section, nodes: result.vaultNodes.map((id) => nodeById.get(id)) }))
88
+ } catch (error) { contractRefusalToTyped(error) }
84
89
  const notePaths = Object.fromEntries(result.nodes.map((id) => [id, pathOf(nodeById.get(id).repo, id)]))
85
90
 
86
91
  let focus = null