quilltap 4.8.0-dev.191 → 4.8.0-dev.192
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.
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bind planning for filesystem-backed document stores.
|
|
3
|
+
*
|
|
4
|
+
* The planner decides what a container is allowed to see, so its edges matter
|
|
5
|
+
* more than its happy path: a nested store that shadows its parent, a missing
|
|
6
|
+
* path that Docker would helpfully materialise as an empty directory, or a
|
|
7
|
+
* Windows host where the whole path-identical scheme cannot work.
|
|
8
|
+
*
|
|
9
|
+
* @jest-environment node
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
'use strict';
|
|
13
|
+
|
|
14
|
+
const {
|
|
15
|
+
planStoreMounts,
|
|
16
|
+
toDockerArgs,
|
|
17
|
+
findMissingBinds,
|
|
18
|
+
normalisePath,
|
|
19
|
+
} = require('../docker-mounts');
|
|
20
|
+
|
|
21
|
+
/** Build a doc_mount_points-shaped row with sensible defaults. */
|
|
22
|
+
function store(overrides = {}) {
|
|
23
|
+
return {
|
|
24
|
+
id: 'id-' + (overrides.name || 'x'),
|
|
25
|
+
name: 'Store',
|
|
26
|
+
mountType: 'filesystem',
|
|
27
|
+
storeType: 'documents',
|
|
28
|
+
basePath: '/vaults/one',
|
|
29
|
+
enabled: 1,
|
|
30
|
+
...overrides,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** A path probe that treats the given list as the only existing directories. */
|
|
35
|
+
const existsIn = (paths) => (p) => paths.includes(p);
|
|
36
|
+
|
|
37
|
+
const linux = (rows, exists) => planStoreMounts(rows, { platform: 'linux', exists });
|
|
38
|
+
const macos = (rows, exists) => planStoreMounts(rows, { platform: 'darwin', exists });
|
|
39
|
+
|
|
40
|
+
describe('planStoreMounts', () => {
|
|
41
|
+
it('binds an enabled filesystem store at its own host path', () => {
|
|
42
|
+
const plan = linux([store({ name: 'Church', basePath: '/vaults/church' })], existsIn(['/vaults/church']));
|
|
43
|
+
|
|
44
|
+
expect(plan.binds).toEqual([
|
|
45
|
+
{ hostPath: '/vaults/church', containerPath: '/vaults/church', stores: ['Church'] },
|
|
46
|
+
]);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('treats obsidian stores as filesystem-backed', () => {
|
|
50
|
+
const plan = linux(
|
|
51
|
+
[store({ name: 'Malory', mountType: 'obsidian', basePath: '/vaults/malory' })],
|
|
52
|
+
existsIn(['/vaults/malory'])
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
expect(plan.binds.map((b) => b.hostPath)).toEqual(['/vaults/malory']);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('ignores database-backed and disabled stores', () => {
|
|
59
|
+
const plan = linux(
|
|
60
|
+
[
|
|
61
|
+
store({ name: 'Vault', mountType: 'database', basePath: '' }),
|
|
62
|
+
store({ name: 'Off', basePath: '/vaults/off', enabled: 0 }),
|
|
63
|
+
],
|
|
64
|
+
existsIn(['/vaults/off'])
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
expect(plan.binds).toEqual([]);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('emits one bind for several stores sharing a path', () => {
|
|
71
|
+
const plan = linux(
|
|
72
|
+
[
|
|
73
|
+
store({ name: 'Church', basePath: '/vaults/shared' }),
|
|
74
|
+
store({ name: 'Small Group', basePath: '/vaults/shared/' }),
|
|
75
|
+
],
|
|
76
|
+
existsIn(['/vaults/shared'])
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
expect(plan.binds).toHaveLength(1);
|
|
80
|
+
expect(plan.binds[0].stores).toEqual(['Church', 'Small Group']);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('drops a store nested inside another bound store', () => {
|
|
84
|
+
// Binding both would mount the child independently and shadow the
|
|
85
|
+
// parent's view of that subdirectory.
|
|
86
|
+
const plan = linux(
|
|
87
|
+
[
|
|
88
|
+
store({ name: 'Vault', basePath: '/vaults/obsidian' }),
|
|
89
|
+
store({ name: 'Notes', basePath: '/vaults/obsidian/notes' }),
|
|
90
|
+
],
|
|
91
|
+
existsIn(['/vaults/obsidian', '/vaults/obsidian/notes'])
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
expect(plan.binds.map((b) => b.hostPath)).toEqual(['/vaults/obsidian']);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('does not treat a sibling with a shared prefix as nested', () => {
|
|
98
|
+
const plan = linux(
|
|
99
|
+
[
|
|
100
|
+
store({ name: 'A', basePath: '/vaults/notes' }),
|
|
101
|
+
store({ name: 'B', basePath: '/vaults/notes-archive' }),
|
|
102
|
+
],
|
|
103
|
+
existsIn(['/vaults/notes', '/vaults/notes-archive'])
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
expect(plan.binds.map((b) => b.hostPath)).toEqual(['/vaults/notes', '/vaults/notes-archive']);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('skips a missing path rather than letting Docker fabricate it', () => {
|
|
110
|
+
const plan = linux([store({ name: 'Gone', basePath: '/vaults/gone' })], existsIn([]));
|
|
111
|
+
|
|
112
|
+
expect(plan.binds).toEqual([]);
|
|
113
|
+
expect(plan.skipped).toEqual([
|
|
114
|
+
{ hostPath: '/vaults/gone', stores: ['Gone'], reason: 'missing' },
|
|
115
|
+
]);
|
|
116
|
+
expect(plan.warnings.join(' ')).toContain('does not exist');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('rejects a relative base path', () => {
|
|
120
|
+
const plan = linux([store({ name: 'Rel', basePath: 'relative/path' })], existsIn(['relative/path']));
|
|
121
|
+
|
|
122
|
+
expect(plan.binds).toEqual([]);
|
|
123
|
+
expect(plan.warnings.join(' ')).toContain('not absolute');
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('refuses path-identical binds on Windows', () => {
|
|
127
|
+
const plan = planStoreMounts([store({ basePath: 'C:\\Users\\me\\Vault' })], {
|
|
128
|
+
platform: 'win32',
|
|
129
|
+
exists: () => true,
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
expect(plan.unsupported).toBe(true);
|
|
133
|
+
expect(plan.binds).toEqual([]);
|
|
134
|
+
expect(plan.warnings.join(' ')).toContain('not supported on Windows');
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('warns about macOS paths outside Docker Desktop default shares', () => {
|
|
138
|
+
const plan = macos([store({ name: 'Odd', basePath: '/data/vault' })], existsIn(['/data/vault']));
|
|
139
|
+
|
|
140
|
+
expect(plan.binds).toHaveLength(1);
|
|
141
|
+
expect(plan.warnings.join(' ')).toContain('File sharing');
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('does not warn for macOS paths under a shared prefix', () => {
|
|
145
|
+
const plan = macos([store({ name: 'Home', basePath: '/Users/me/Vault' })], existsIn(['/Users/me/Vault']));
|
|
146
|
+
|
|
147
|
+
expect(plan.warnings.join(' ')).not.toContain('File sharing');
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('warns about host ownership on Linux', () => {
|
|
151
|
+
const plan = linux([store({ basePath: '/vaults/one' })], existsIn(['/vaults/one']));
|
|
152
|
+
|
|
153
|
+
expect(plan.warnings.join(' ')).toContain('--user');
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
describe('toDockerArgs', () => {
|
|
158
|
+
it('renders each bind as a -v pair', () => {
|
|
159
|
+
const plan = linux(
|
|
160
|
+
[
|
|
161
|
+
store({ name: 'A', basePath: '/vaults/a' }),
|
|
162
|
+
store({ name: 'B', basePath: '/vaults/b' }),
|
|
163
|
+
],
|
|
164
|
+
existsIn(['/vaults/a', '/vaults/b'])
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
expect(toDockerArgs(plan)).toEqual([
|
|
168
|
+
'-v', '/vaults/a:/vaults/a',
|
|
169
|
+
'-v', '/vaults/b:/vaults/b',
|
|
170
|
+
]);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('keeps a path containing spaces in a single argument', () => {
|
|
174
|
+
// The argv is handed to execFileSync, so a space must not split the pair.
|
|
175
|
+
const plan = linux([store({ basePath: '/vaults/Local Obsidian' })], existsIn(['/vaults/Local Obsidian']));
|
|
176
|
+
|
|
177
|
+
expect(toDockerArgs(plan)).toEqual([
|
|
178
|
+
'-v', '/vaults/Local Obsidian:/vaults/Local Obsidian',
|
|
179
|
+
]);
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
describe('findMissingBinds', () => {
|
|
184
|
+
const plan = () =>
|
|
185
|
+
linux(
|
|
186
|
+
[
|
|
187
|
+
store({ name: 'A', basePath: '/vaults/a' }),
|
|
188
|
+
store({ name: 'B', basePath: '/vaults/b' }),
|
|
189
|
+
],
|
|
190
|
+
existsIn(['/vaults/a', '/vaults/b'])
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
it('reports binds a container was not created with', () => {
|
|
194
|
+
expect(findMissingBinds(plan(), ['/vaults/a']).map((b) => b.hostPath)).toEqual(['/vaults/b']);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('reports nothing when every bind is present', () => {
|
|
198
|
+
expect(findMissingBinds(plan(), ['/vaults/a', '/vaults/b', '/data'])).toEqual([]);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('ignores a trailing separator when comparing', () => {
|
|
202
|
+
expect(findMissingBinds(plan(), ['/vaults/a/', '/vaults/b/'])).toEqual([]);
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
describe('normalisePath', () => {
|
|
207
|
+
it.each([
|
|
208
|
+
['/a/b/', '/a/b'],
|
|
209
|
+
['/a//b', '/a/b'],
|
|
210
|
+
['/a/./b', '/a/b'],
|
|
211
|
+
['/a/c/../b', '/a/b'],
|
|
212
|
+
['/', '/'],
|
|
213
|
+
])('normalises %s to %s', (input, expected) => {
|
|
214
|
+
expect(normalisePath(input)).toBe(expected);
|
|
215
|
+
});
|
|
216
|
+
});
|
|
@@ -126,7 +126,7 @@ _quilltap_complete() {
|
|
|
126
126
|
fi
|
|
127
127
|
;;
|
|
128
128
|
docs)
|
|
129
|
-
local docs_verbs="list show files ls dir tree read export scan write delete mkdir move copy link rmdir mvdir status find grep reindex embed"
|
|
129
|
+
local docs_verbs="list show files ls dir tree read export scan write delete mkdir move copy link rmdir mvdir status docker-mounts find grep reindex embed"
|
|
130
130
|
local docs_flags="--mount --instance --data-dir --passphrase --port --json --help \
|
|
131
131
|
--force --rendered --links --folder --type --ext --limit --max --context --top --threshold \
|
|
132
132
|
--ignore-case -l --wait -R --recursive --sort -r --reverse --depth --max-nodes --long --semantic"
|
|
@@ -136,6 +136,7 @@ complete -c quilltap -n '__quilltap_using_subcommand docs' -f -a 'scan' -d 'Trig
|
|
|
136
136
|
complete -c quilltap -n '__quilltap_using_subcommand docs' -f -a 'find' -d 'Substring search'
|
|
137
137
|
complete -c quilltap -n '__quilltap_using_subcommand docs' -f -a 'grep' -d 'Pattern search in text'
|
|
138
138
|
complete -c quilltap -n '__quilltap_using_subcommand docs' -f -a 'status' -d 'Per-mount status'
|
|
139
|
+
complete -c quilltap -n '__quilltap_using_subcommand docs' -f -a 'docker-mounts' -d 'Binds needed under Docker'
|
|
139
140
|
complete -c quilltap -n '__quilltap_using_subcommand docs' -f -a 'reindex' -d 'Re-extract and chunk'
|
|
140
141
|
complete -c quilltap -n '__quilltap_using_subcommand docs' -f -a 'embed' -d 'Enqueue embeddings'
|
|
141
142
|
complete -c quilltap -n '__quilltap_using_subcommand docs' -f -a 'write' -d 'Write a file'
|
|
@@ -168,6 +168,7 @@ _quilltap_docs() {
|
|
|
168
168
|
'find:Substring search'
|
|
169
169
|
'grep:Pattern search inside text'
|
|
170
170
|
'status:Per-mount status'
|
|
171
|
+
'docker-mounts:Bind mounts needed under Docker'
|
|
171
172
|
'reindex:Re-extract and re-chunk'
|
|
172
173
|
'embed:Enqueue embedding jobs'
|
|
173
174
|
'write:Write a file'
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Docker Bind Planning for Filesystem Document Stores
|
|
5
|
+
*
|
|
6
|
+
* A container sees only the host paths handed to it at creation time. Database
|
|
7
|
+
* -backed stores live inside the data directory and ride along on the single
|
|
8
|
+
* bind every Quilltap container already has; filesystem and Obsidian stores
|
|
9
|
+
* point anywhere on the host and are, by default, simply absent inside the
|
|
10
|
+
* container — the store lists happily from the cached mount index while every
|
|
11
|
+
* read and write against the real bytes fails.
|
|
12
|
+
*
|
|
13
|
+
* This module turns "what stores does this instance have" into "what -v flags
|
|
14
|
+
* does the container need". It is deliberately pure: the caller supplies the
|
|
15
|
+
* store rows, the platform, and a path probe, so the planner can be tested
|
|
16
|
+
* without a database, a filesystem, or Docker.
|
|
17
|
+
*
|
|
18
|
+
* ## Why the binds are path-identical
|
|
19
|
+
*
|
|
20
|
+
* Each store is bound at its own host path (`-v /host/vault:/host/vault`) so
|
|
21
|
+
* the `basePath` recorded in the database resolves unchanged whether Quilltap
|
|
22
|
+
* runs natively or in a container. The alternative — mounting under some
|
|
23
|
+
* container-local prefix — would require a translation layer on every path in
|
|
24
|
+
* and out of the database, and would make the same instance directory
|
|
25
|
+
* unusable outside the container.
|
|
26
|
+
*
|
|
27
|
+
* Docker creates missing destination ancestors itself, as root and mode 0755,
|
|
28
|
+
* during mount setup — so binding `/Users/you/Vault` into an image that has no
|
|
29
|
+
* `/Users` works, and the unprivileged app user can still traverse in. Those
|
|
30
|
+
* ancestors are *not* writable by the app user, which is a feature: a store
|
|
31
|
+
* that was never bound stays structurally unwritable rather than quietly
|
|
32
|
+
* accumulating a fabricated directory tree.
|
|
33
|
+
*
|
|
34
|
+
* @module docker-mounts
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
const path = require('path');
|
|
38
|
+
const fs = require('fs');
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Prefixes Docker Desktop for macOS shares with the VM out of the box. A bind
|
|
42
|
+
* whose source falls outside these is accepted by `docker run` but arrives in
|
|
43
|
+
* the container as an empty directory, which is a uniquely confusing failure —
|
|
44
|
+
* so it earns a warning rather than silence.
|
|
45
|
+
*/
|
|
46
|
+
const MACOS_DEFAULT_SHARED_PREFIXES = ['/Users', '/Volumes', '/private', '/tmp', '/var/folders'];
|
|
47
|
+
|
|
48
|
+
/** Store types whose bytes live on the host filesystem rather than in the database. */
|
|
49
|
+
const FILESYSTEM_MOUNT_TYPES = new Set(['filesystem', 'obsidian']);
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Normalise a base path for comparison: resolve `.`/`..`, collapse separators,
|
|
53
|
+
* and drop any trailing separator so `/a/b` and `/a/b/` are one path.
|
|
54
|
+
*/
|
|
55
|
+
function normalisePath(basePath) {
|
|
56
|
+
const resolved = path.posix.normalize(String(basePath).trim());
|
|
57
|
+
if (resolved.length > 1 && resolved.endsWith('/')) {
|
|
58
|
+
return resolved.slice(0, -1);
|
|
59
|
+
}
|
|
60
|
+
return resolved;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** True when `candidate` sits inside `ancestor` (and is not `ancestor` itself). */
|
|
64
|
+
function isDescendantOf(candidate, ancestor) {
|
|
65
|
+
return candidate.startsWith(ancestor.endsWith('/') ? ancestor : ancestor + '/');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Plan the bind mounts an instance's filesystem-backed stores require.
|
|
70
|
+
*
|
|
71
|
+
* @param {Array<object>} rows - doc_mount_points rows (id, name, mountType, basePath, enabled)
|
|
72
|
+
* @param {object} [options]
|
|
73
|
+
* @param {string} [options.platform] - process.platform value; defaults to the current host
|
|
74
|
+
* @param {(p: string) => boolean} [options.exists] - path probe, injectable for tests
|
|
75
|
+
* @returns {{binds: Array<object>, skipped: Array<object>, warnings: Array<string>, unsupported: boolean}}
|
|
76
|
+
*/
|
|
77
|
+
function planStoreMounts(rows, options = {}) {
|
|
78
|
+
const platform = options.platform || process.platform;
|
|
79
|
+
const exists =
|
|
80
|
+
options.exists ||
|
|
81
|
+
((p) => {
|
|
82
|
+
try {
|
|
83
|
+
return fs.statSync(p).isDirectory();
|
|
84
|
+
} catch {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const warnings = [];
|
|
90
|
+
|
|
91
|
+
// Windows host paths (C:\Users\…) have no in-container equivalent, so the
|
|
92
|
+
// path-identical scheme this module depends on cannot work there. Say so
|
|
93
|
+
// plainly rather than emitting binds that would silently misbehave.
|
|
94
|
+
if (platform === 'win32') {
|
|
95
|
+
return {
|
|
96
|
+
binds: [],
|
|
97
|
+
skipped: [],
|
|
98
|
+
warnings: [
|
|
99
|
+
'Automatic store binds are not supported on Windows: container paths cannot mirror ' +
|
|
100
|
+
'Windows host paths. Filesystem document stores must be bound manually.',
|
|
101
|
+
],
|
|
102
|
+
unsupported: true,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const candidates = rows.filter(
|
|
107
|
+
(r) => FILESYSTEM_MOUNT_TYPES.has(r.mountType) && r.enabled && String(r.basePath || '').trim()
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
// Group stores by normalised path first — several stores commonly share one
|
|
111
|
+
// vault root, and they need exactly one bind between them.
|
|
112
|
+
const byPath = new Map();
|
|
113
|
+
for (const row of candidates) {
|
|
114
|
+
const normalised = normalisePath(row.basePath);
|
|
115
|
+
if (!path.posix.isAbsolute(normalised)) {
|
|
116
|
+
warnings.push(`Skipping store '${row.name}': base path '${row.basePath}' is not absolute.`);
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (!byPath.has(normalised)) {
|
|
120
|
+
byPath.set(normalised, []);
|
|
121
|
+
}
|
|
122
|
+
byPath.get(normalised).push(row.name);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const allPaths = [...byPath.keys()].sort();
|
|
126
|
+
|
|
127
|
+
const binds = [];
|
|
128
|
+
const skipped = [];
|
|
129
|
+
|
|
130
|
+
for (const hostPath of allPaths) {
|
|
131
|
+
const stores = byPath.get(hostPath);
|
|
132
|
+
|
|
133
|
+
// A path nested inside another selected path is already covered by that
|
|
134
|
+
// bind. Binding both is redundant, and Docker mounts them independently —
|
|
135
|
+
// which would shadow the parent's view of the child directory.
|
|
136
|
+
const ancestor = allPaths.find((other) => other !== hostPath && isDescendantOf(hostPath, other));
|
|
137
|
+
if (ancestor) {
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (!exists(hostPath)) {
|
|
142
|
+
// Never create the source. Docker would happily materialise a missing
|
|
143
|
+
// bind source as a root-owned empty directory, which presents an empty
|
|
144
|
+
// store as a healthy one — the exact failure this feature exists to end.
|
|
145
|
+
skipped.push({ hostPath, stores, reason: 'missing' });
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (platform === 'darwin' && !MACOS_DEFAULT_SHARED_PREFIXES.some((p) => hostPath === p || isDescendantOf(hostPath, p))) {
|
|
150
|
+
warnings.push(
|
|
151
|
+
`'${hostPath}' is outside Docker Desktop's default shared paths. Add it under ` +
|
|
152
|
+
'Settings → Resources → File sharing, or the store will appear empty in the container.'
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
binds.push({ hostPath, containerPath: hostPath, stores });
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (platform === 'linux' && binds.length > 0) {
|
|
160
|
+
warnings.push(
|
|
161
|
+
'On Linux, bind mounts preserve host ownership. If the container user cannot write to ' +
|
|
162
|
+
'these paths, start the container with --user "$(id -u):$(id -g)".'
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
for (const entry of skipped) {
|
|
167
|
+
warnings.push(
|
|
168
|
+
`Skipping '${entry.hostPath}' (${entry.stores.join(', ')}): the path does not exist on this host.`
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return { binds, skipped, warnings, unsupported: false };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Render a plan as `docker run` arguments. */
|
|
176
|
+
function toDockerArgs(plan) {
|
|
177
|
+
const args = [];
|
|
178
|
+
for (const bind of plan.binds) {
|
|
179
|
+
args.push('-v', `${bind.hostPath}:${bind.containerPath}`);
|
|
180
|
+
}
|
|
181
|
+
return args;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Compare a plan against the binds a container was actually created with.
|
|
186
|
+
* Returns the host paths a running container is missing, which is what tells
|
|
187
|
+
* an operator that a restart is owed.
|
|
188
|
+
*/
|
|
189
|
+
function findMissingBinds(plan, existingSources) {
|
|
190
|
+
const existing = new Set(existingSources.map(normalisePath));
|
|
191
|
+
return plan.binds.filter((b) => !existing.has(b.hostPath));
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
module.exports = {
|
|
195
|
+
planStoreMounts,
|
|
196
|
+
toDockerArgs,
|
|
197
|
+
findMissingBinds,
|
|
198
|
+
normalisePath,
|
|
199
|
+
FILESYSTEM_MOUNT_TYPES,
|
|
200
|
+
MACOS_DEFAULT_SHARED_PREFIXES,
|
|
201
|
+
};
|
package/lib/docs-commands.js
CHANGED
|
@@ -71,6 +71,8 @@ Read subcommands:
|
|
|
71
71
|
grep [--mount <name|id|all>] [--ignore-case] [-l] [--max N] [--context N] <pattern>
|
|
72
72
|
Substring search inside extracted text
|
|
73
73
|
status [--mount <name|id>] [--top N] Per-mount extraction + embedding rollup
|
|
74
|
+
docker-mounts [--format args|json] Bind mounts this instance's filesystem
|
|
75
|
+
stores need to be visible in Docker
|
|
74
76
|
|
|
75
77
|
Server-required subcommands (background-job queue lives in the running server):
|
|
76
78
|
grep --semantic [--mount <name|id|all>] [--top N] [--threshold 0..1] <query>
|
|
@@ -210,6 +212,8 @@ function parseFlags(args) {
|
|
|
210
212
|
threshold: -1,
|
|
211
213
|
// base64 read/write flag
|
|
212
214
|
base64: false,
|
|
215
|
+
// docker-mounts output shape: table (human), args (docker run flags), json
|
|
216
|
+
format: '',
|
|
213
217
|
};
|
|
214
218
|
const positional = [];
|
|
215
219
|
let i = 0;
|
|
@@ -229,6 +233,7 @@ function parseFlags(args) {
|
|
|
229
233
|
break;
|
|
230
234
|
}
|
|
231
235
|
case '--json': flags.json = true; break;
|
|
236
|
+
case '--format': flags.format = args[++i]; break;
|
|
232
237
|
case '--uri': flags.uri = true; break;
|
|
233
238
|
case '--rendered': flags.rendered = true; break;
|
|
234
239
|
case '--folder': flags.folder = args[++i]; break;
|
|
@@ -487,6 +492,95 @@ async function handleList(flags) {
|
|
|
487
492
|
}
|
|
488
493
|
}
|
|
489
494
|
|
|
495
|
+
// ----------------------------------------------------------------------------
|
|
496
|
+
// docker-mounts
|
|
497
|
+
// ----------------------------------------------------------------------------
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* Report the bind mounts this instance's filesystem-backed stores need in
|
|
501
|
+
* order to be reachable from inside a container.
|
|
502
|
+
*
|
|
503
|
+
* `--format args` prints only the flags, one per line, so a start script can
|
|
504
|
+
* splice them into a `docker run` argv without parsing prose. Everything
|
|
505
|
+
* advisory goes to stderr for exactly that reason: stdout stays machine-clean
|
|
506
|
+
* even when there are warnings worth a human's attention.
|
|
507
|
+
*/
|
|
508
|
+
async function handleDockerMounts(flags) {
|
|
509
|
+
const { planStoreMounts, toDockerArgs } = require('./docker-mounts');
|
|
510
|
+
const { db } = await openDb(flags);
|
|
511
|
+
|
|
512
|
+
let rows;
|
|
513
|
+
try {
|
|
514
|
+
rows = db.prepare(`
|
|
515
|
+
SELECT id, name, mountType, storeType, basePath, enabled
|
|
516
|
+
FROM doc_mount_points
|
|
517
|
+
WHERE mountType != 'database'
|
|
518
|
+
ORDER BY name COLLATE NOCASE
|
|
519
|
+
`).all();
|
|
520
|
+
} finally {
|
|
521
|
+
db.close();
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
const plan = planStoreMounts(rows);
|
|
525
|
+
const format = flags.format || (flags.json ? 'json' : 'table');
|
|
526
|
+
|
|
527
|
+
if (format === 'json') {
|
|
528
|
+
process.stdout.write(JSON.stringify(plan, null, 2) + '\n');
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
if (format === 'args') {
|
|
533
|
+
for (const arg of toDockerArgs(plan)) {
|
|
534
|
+
process.stdout.write(arg + '\n');
|
|
535
|
+
}
|
|
536
|
+
for (const warning of plan.warnings) {
|
|
537
|
+
process.stderr.write(`warning: ${warning}\n`);
|
|
538
|
+
}
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
if (plan.unsupported) {
|
|
543
|
+
for (const warning of plan.warnings) {
|
|
544
|
+
console.log(`${YELLOW}${warning}${RESET}`);
|
|
545
|
+
}
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
if (plan.binds.length === 0 && plan.skipped.length === 0) {
|
|
550
|
+
console.log('(no filesystem-backed document stores — nothing to bind)');
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
if (plan.binds.length > 0) {
|
|
555
|
+
console.log(`${BOLD}Bind mounts required:${RESET}`);
|
|
556
|
+
console.table(
|
|
557
|
+
plan.binds.map((b) => ({
|
|
558
|
+
'host path': b.hostPath,
|
|
559
|
+
stores: b.stores.join(', '),
|
|
560
|
+
}))
|
|
561
|
+
);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
if (plan.skipped.length > 0) {
|
|
565
|
+
console.log(`${BOLD}Skipped:${RESET}`);
|
|
566
|
+
console.table(
|
|
567
|
+
plan.skipped.map((s) => ({
|
|
568
|
+
'host path': s.hostPath,
|
|
569
|
+
stores: s.stores.join(', '),
|
|
570
|
+
reason: s.reason,
|
|
571
|
+
}))
|
|
572
|
+
);
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
for (const warning of plan.warnings) {
|
|
576
|
+
console.log(`${YELLOW}warning:${RESET} ${warning}`);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
console.log('');
|
|
580
|
+
console.log(`${DIM}Binds are applied when a container is created. Re-run the start script`);
|
|
581
|
+
console.log(`with --recreate to rebuild the container with these stores included.${RESET}`);
|
|
582
|
+
}
|
|
583
|
+
|
|
490
584
|
// ----------------------------------------------------------------------------
|
|
491
585
|
// show
|
|
492
586
|
// ----------------------------------------------------------------------------
|
|
@@ -3033,6 +3127,9 @@ async function docsCommand(args) {
|
|
|
3033
3127
|
case 'status':
|
|
3034
3128
|
await handleStatus(flags);
|
|
3035
3129
|
break;
|
|
3130
|
+
case 'docker-mounts':
|
|
3131
|
+
await handleDockerMounts(flags);
|
|
3132
|
+
break;
|
|
3036
3133
|
case 'find':
|
|
3037
3134
|
await handleFind(flags, positional);
|
|
3038
3135
|
break;
|