quilltap 4.5.0-dev → 4.5.0-dev.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +178 -0
- package/bin/quilltap.js +226 -20
- package/lib/completion/bash.template +121 -0
- package/lib/completion/fish.template +93 -0
- package/lib/completion/zsh.template +209 -0
- package/lib/completion-commands.js +77 -0
- package/lib/db-commands.js +1142 -0
- package/lib/db-helpers.js +173 -4
- package/lib/docs-commands.js +2157 -172
- package/lib/graph-integrity.js +105 -0
- package/lib/instances-commands.js +342 -0
- package/lib/instances.js +335 -0
- package/lib/lock-helpers.js +117 -0
- package/lib/logs-commands.js +383 -0
- package/lib/memories-commands.js +1374 -0
- package/lib/memory-diff-command.js +19 -3
- package/lib/migrations-commands.js +324 -0
- package/lib/theme-commands.js +18 -0
- package/package.json +1 -1
package/lib/instances.js
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Instance registry for the Quilltap CLI.
|
|
4
|
+
//
|
|
5
|
+
// Stores a per-user mapping of friendly instance names → (instance root path,
|
|
6
|
+
// optional database passphrase) so the CLI can be invoked with `--instance Foo`
|
|
7
|
+
// instead of `--data-dir <path> --passphrase <secret>`.
|
|
8
|
+
//
|
|
9
|
+
// File layout: ~/Library/Application Support/Quilltap/instances.json (macOS),
|
|
10
|
+
// ~/.quilltap/instances.json (Linux), %APPDATA%\Quilltap\instances.json
|
|
11
|
+
// (Windows). Mode 0o600 on POSIX, owned by the current user. The file may
|
|
12
|
+
// contain plaintext passphrases, so the read path refuses to load it if those
|
|
13
|
+
// invariants are broken.
|
|
14
|
+
|
|
15
|
+
const fs = require('fs');
|
|
16
|
+
const os = require('os');
|
|
17
|
+
const path = require('path');
|
|
18
|
+
|
|
19
|
+
const SCHEMA_VERSION = 1;
|
|
20
|
+
const FILENAME = 'instances.json';
|
|
21
|
+
|
|
22
|
+
function getAppDir() {
|
|
23
|
+
const home = os.homedir();
|
|
24
|
+
if (process.platform === 'darwin') {
|
|
25
|
+
return path.join(home, 'Library', 'Application Support', 'Quilltap');
|
|
26
|
+
}
|
|
27
|
+
if (process.platform === 'win32') {
|
|
28
|
+
return path.join(process.env.APPDATA || path.join(home, 'AppData', 'Roaming'), 'Quilltap');
|
|
29
|
+
}
|
|
30
|
+
return path.join(home, '.quilltap');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function getInstancesPath() {
|
|
34
|
+
return path.join(getAppDir(), FILENAME);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function expandPath(input) {
|
|
38
|
+
if (!input) return input;
|
|
39
|
+
let p = input;
|
|
40
|
+
if (p.startsWith('~')) {
|
|
41
|
+
p = path.join(os.homedir(), p.slice(1));
|
|
42
|
+
}
|
|
43
|
+
return path.resolve(p);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function emptyRegistry() {
|
|
47
|
+
return { version: SCHEMA_VERSION, instances: {}, defaultInstance: null };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Verify ownership + permissions for a passphrase-bearing file on POSIX.
|
|
51
|
+
// On Windows we cannot enforce POSIX bits, so we accept and rely on the
|
|
52
|
+
// user-profile location to limit access.
|
|
53
|
+
function assertSafePermissions(filePath) {
|
|
54
|
+
if (process.platform === 'win32') return;
|
|
55
|
+
const stat = fs.statSync(filePath);
|
|
56
|
+
const euid = typeof process.geteuid === 'function' ? process.geteuid() : null;
|
|
57
|
+
if (euid !== null && stat.uid !== euid) {
|
|
58
|
+
throw new Error(
|
|
59
|
+
`Refusing to read ${filePath}: file is owned by uid ${stat.uid}, ` +
|
|
60
|
+
`but current process is uid ${euid}. ` +
|
|
61
|
+
`Either delete the file or run: sudo chown ${euid} "${filePath}"`
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
const perms = stat.mode & 0o777;
|
|
65
|
+
if ((perms & 0o077) !== 0) {
|
|
66
|
+
const octal = perms.toString(8).padStart(3, '0');
|
|
67
|
+
throw new Error(
|
|
68
|
+
`Refusing to read ${filePath}: permissions are ${octal} (group/other can access). ` +
|
|
69
|
+
`Quilltap stores passphrases in this file. Restrict it with: chmod 600 "${filePath}"`
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function readInstances() {
|
|
75
|
+
const filePath = getInstancesPath();
|
|
76
|
+
if (!fs.existsSync(filePath)) {
|
|
77
|
+
return emptyRegistry();
|
|
78
|
+
}
|
|
79
|
+
assertSafePermissions(filePath);
|
|
80
|
+
const raw = fs.readFileSync(filePath, 'utf8');
|
|
81
|
+
let parsed;
|
|
82
|
+
try {
|
|
83
|
+
parsed = JSON.parse(raw);
|
|
84
|
+
} catch (err) {
|
|
85
|
+
throw new Error(`Failed to parse ${filePath}: ${err.message}`);
|
|
86
|
+
}
|
|
87
|
+
if (!parsed || typeof parsed !== 'object') {
|
|
88
|
+
throw new Error(`Invalid contents in ${filePath}: expected a JSON object.`);
|
|
89
|
+
}
|
|
90
|
+
if (!parsed.instances || typeof parsed.instances !== 'object') {
|
|
91
|
+
parsed.instances = {};
|
|
92
|
+
}
|
|
93
|
+
if (!parsed.version) parsed.version = SCHEMA_VERSION;
|
|
94
|
+
if (!('defaultInstance' in parsed)) {
|
|
95
|
+
parsed.defaultInstance = null;
|
|
96
|
+
}
|
|
97
|
+
return parsed;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function writeInstances(registry) {
|
|
101
|
+
const filePath = getInstancesPath();
|
|
102
|
+
const dir = path.dirname(filePath);
|
|
103
|
+
if (!fs.existsSync(dir)) {
|
|
104
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
105
|
+
}
|
|
106
|
+
const payload = JSON.stringify(
|
|
107
|
+
{
|
|
108
|
+
version: SCHEMA_VERSION,
|
|
109
|
+
instances: registry.instances || {},
|
|
110
|
+
defaultInstance: registry.defaultInstance || null,
|
|
111
|
+
},
|
|
112
|
+
null,
|
|
113
|
+
2
|
|
114
|
+
) + '\n';
|
|
115
|
+
|
|
116
|
+
// Write to a temp file with mode 0600, then rename. This guarantees the file
|
|
117
|
+
// exists with safe permissions from the moment it is visible by name.
|
|
118
|
+
const tmpPath = `${filePath}.tmp-${process.pid}-${Date.now()}`;
|
|
119
|
+
fs.writeFileSync(tmpPath, payload, { mode: 0o600 });
|
|
120
|
+
if (process.platform !== 'win32') {
|
|
121
|
+
fs.chmodSync(tmpPath, 0o600);
|
|
122
|
+
}
|
|
123
|
+
fs.renameSync(tmpPath, filePath);
|
|
124
|
+
if (process.platform !== 'win32') {
|
|
125
|
+
// rename preserves the tmp file's mode, but be defensive in case of weird
|
|
126
|
+
// umask or filesystem behaviour (e.g. some network mounts).
|
|
127
|
+
fs.chmodSync(filePath, 0o600);
|
|
128
|
+
}
|
|
129
|
+
return filePath;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Case-insensitive lookup so `--instance friday` works even if stored as `Friday`.
|
|
133
|
+
function findInstanceKey(registry, name) {
|
|
134
|
+
if (!name) return null;
|
|
135
|
+
const lower = name.toLowerCase();
|
|
136
|
+
for (const key of Object.keys(registry.instances || {})) {
|
|
137
|
+
if (key.toLowerCase() === lower) return key;
|
|
138
|
+
}
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function resolveInstance(name) {
|
|
143
|
+
const registry = readInstances();
|
|
144
|
+
const key = findInstanceKey(registry, name);
|
|
145
|
+
if (!key) {
|
|
146
|
+
const known = Object.keys(registry.instances || {});
|
|
147
|
+
const hint = known.length
|
|
148
|
+
? ` Known instances: ${known.join(', ')}.`
|
|
149
|
+
: ' No instances are registered yet — use `quilltap instances add <name>`.';
|
|
150
|
+
throw new Error(`Unknown instance "${name}".${hint}`);
|
|
151
|
+
}
|
|
152
|
+
const entry = registry.instances[key];
|
|
153
|
+
return {
|
|
154
|
+
name: key,
|
|
155
|
+
path: expandPath(entry.path),
|
|
156
|
+
passphrase: entry.passphrase || '',
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function listInstances() {
|
|
161
|
+
const registry = readInstances();
|
|
162
|
+
const defaultName = registry.defaultInstance;
|
|
163
|
+
return Object.entries(registry.instances || {}).map(([name, entry]) => ({
|
|
164
|
+
name,
|
|
165
|
+
path: entry.path,
|
|
166
|
+
expandedPath: expandPath(entry.path),
|
|
167
|
+
hasPassphrase: typeof entry.passphrase === 'string' && entry.passphrase.length > 0,
|
|
168
|
+
isDefault: name === defaultName,
|
|
169
|
+
}));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function upsertInstance(name, { instancePath, passphrase }) {
|
|
173
|
+
if (!name || !name.trim()) {
|
|
174
|
+
throw new Error('Instance name is required.');
|
|
175
|
+
}
|
|
176
|
+
if (!instancePath || !instancePath.trim()) {
|
|
177
|
+
throw new Error('Instance path is required.');
|
|
178
|
+
}
|
|
179
|
+
const registry = readInstances();
|
|
180
|
+
const existingKey = findInstanceKey(registry, name);
|
|
181
|
+
const key = existingKey || name.trim();
|
|
182
|
+
const stored = registry.instances[key] || {};
|
|
183
|
+
registry.instances[key] = {
|
|
184
|
+
path: instancePath.trim(),
|
|
185
|
+
...(passphrase ? { passphrase } : stored.passphrase ? {} : {}),
|
|
186
|
+
};
|
|
187
|
+
if (passphrase) {
|
|
188
|
+
registry.instances[key].passphrase = passphrase;
|
|
189
|
+
}
|
|
190
|
+
writeInstances(registry);
|
|
191
|
+
return key;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function removeInstance(name) {
|
|
195
|
+
const registry = readInstances();
|
|
196
|
+
const key = findInstanceKey(registry, name);
|
|
197
|
+
if (!key) {
|
|
198
|
+
throw new Error(`Unknown instance "${name}".`);
|
|
199
|
+
}
|
|
200
|
+
delete registry.instances[key];
|
|
201
|
+
writeInstances(registry);
|
|
202
|
+
return key;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function setInstancePassphrase(name, passphrase) {
|
|
206
|
+
const registry = readInstances();
|
|
207
|
+
const key = findInstanceKey(registry, name);
|
|
208
|
+
if (!key) {
|
|
209
|
+
throw new Error(`Unknown instance "${name}".`);
|
|
210
|
+
}
|
|
211
|
+
if (passphrase) {
|
|
212
|
+
registry.instances[key].passphrase = passphrase;
|
|
213
|
+
} else {
|
|
214
|
+
delete registry.instances[key].passphrase;
|
|
215
|
+
}
|
|
216
|
+
writeInstances(registry);
|
|
217
|
+
return key;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function setDefaultInstance(name) {
|
|
221
|
+
const registry = readInstances();
|
|
222
|
+
const key = findInstanceKey(registry, name);
|
|
223
|
+
if (!key) {
|
|
224
|
+
const known = Object.keys(registry.instances || {});
|
|
225
|
+
const hint = known.length
|
|
226
|
+
? ` Known instances: ${known.join(', ')}.`
|
|
227
|
+
: ' No instances are registered yet — use `quilltap instances add <name>`.';
|
|
228
|
+
throw new Error(`Unknown instance "${name}".${hint}`);
|
|
229
|
+
}
|
|
230
|
+
registry.defaultInstance = key;
|
|
231
|
+
writeInstances(registry);
|
|
232
|
+
return key;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function clearDefaultInstance() {
|
|
236
|
+
const registry = readInstances();
|
|
237
|
+
registry.defaultInstance = null;
|
|
238
|
+
writeInstances(registry);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function getDefaultInstance() {
|
|
242
|
+
const registry = readInstances();
|
|
243
|
+
return registry.defaultInstance || null;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function renameInstance(oldName, newName) {
|
|
247
|
+
if (!oldName || !oldName.trim()) {
|
|
248
|
+
throw new Error('Old instance name is required.');
|
|
249
|
+
}
|
|
250
|
+
if (!newName || !newName.trim()) {
|
|
251
|
+
throw new Error('New instance name is required.');
|
|
252
|
+
}
|
|
253
|
+
const registry = readInstances();
|
|
254
|
+
const oldKey = findInstanceKey(registry, oldName);
|
|
255
|
+
if (!oldKey) {
|
|
256
|
+
throw new Error(`Unknown instance "${oldName}".`);
|
|
257
|
+
}
|
|
258
|
+
const newTrimmed = newName.trim();
|
|
259
|
+
const existingKey = findInstanceKey(registry, newTrimmed);
|
|
260
|
+
if (existingKey) {
|
|
261
|
+
throw new Error(`Instance "${newTrimmed}" already exists.`);
|
|
262
|
+
}
|
|
263
|
+
const entry = registry.instances[oldKey];
|
|
264
|
+
delete registry.instances[oldKey];
|
|
265
|
+
registry.instances[newTrimmed] = entry;
|
|
266
|
+
if (registry.defaultInstance === oldKey) {
|
|
267
|
+
registry.defaultInstance = newTrimmed;
|
|
268
|
+
}
|
|
269
|
+
writeInstances(registry);
|
|
270
|
+
return { oldKey, newKey: newTrimmed };
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Verify a candidate passphrase against the .dbkey at <instancePath>/data.
|
|
274
|
+
// Returns one of:
|
|
275
|
+
// 'valid' — passphrase unlocks the encrypted pepper
|
|
276
|
+
// 'wrong' — dbkey requires a user passphrase but this one doesn't decrypt
|
|
277
|
+
// 'no-dbkey' — no .dbkey on disk yet (first-run instance)
|
|
278
|
+
// 'no-encryption'— dbkey is unlocked by the internal passphrase, no user one needed
|
|
279
|
+
async function verifyPassphrase(instanceRoot, passphrase) {
|
|
280
|
+
const crypto = require('crypto');
|
|
281
|
+
const dataDir = path.join(expandPath(instanceRoot), 'data');
|
|
282
|
+
const dbkeyPath = path.join(dataDir, 'quilltap.dbkey');
|
|
283
|
+
if (!fs.existsSync(dbkeyPath)) {
|
|
284
|
+
return 'no-dbkey';
|
|
285
|
+
}
|
|
286
|
+
const data = JSON.parse(fs.readFileSync(dbkeyPath, 'utf8'));
|
|
287
|
+
const INTERNAL = '__quilltap_no_passphrase__';
|
|
288
|
+
|
|
289
|
+
function tryDecrypt(pass) {
|
|
290
|
+
const salt = Buffer.from(data.salt, 'hex');
|
|
291
|
+
const key = crypto.pbkdf2Sync(pass, new Uint8Array(salt), data.kdfIterations, 32, data.kdfDigest);
|
|
292
|
+
const iv = Buffer.from(data.iv, 'hex');
|
|
293
|
+
const decipher = crypto.createDecipheriv(data.algorithm, new Uint8Array(key), new Uint8Array(iv));
|
|
294
|
+
decipher.setAuthTag(new Uint8Array(Buffer.from(data.authTag, 'hex')));
|
|
295
|
+
let plaintext = decipher.update(data.ciphertext, 'hex', 'utf8');
|
|
296
|
+
plaintext += decipher.final('utf8');
|
|
297
|
+
const hash = crypto.createHash('sha256').update(plaintext).digest('hex');
|
|
298
|
+
if (hash !== data.pepperHash) throw new Error('pepperHash mismatch');
|
|
299
|
+
return plaintext;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
try {
|
|
303
|
+
tryDecrypt(INTERNAL);
|
|
304
|
+
return 'no-encryption';
|
|
305
|
+
} catch {
|
|
306
|
+
// Falls through — dbkey needs a user passphrase.
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
try {
|
|
310
|
+
tryDecrypt(passphrase);
|
|
311
|
+
return 'valid';
|
|
312
|
+
} catch {
|
|
313
|
+
return 'wrong';
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
module.exports = {
|
|
318
|
+
SCHEMA_VERSION,
|
|
319
|
+
getAppDir,
|
|
320
|
+
getInstancesPath,
|
|
321
|
+
expandPath,
|
|
322
|
+
assertSafePermissions,
|
|
323
|
+
readInstances,
|
|
324
|
+
writeInstances,
|
|
325
|
+
listInstances,
|
|
326
|
+
resolveInstance,
|
|
327
|
+
upsertInstance,
|
|
328
|
+
removeInstance,
|
|
329
|
+
setInstancePassphrase,
|
|
330
|
+
setDefaultInstance,
|
|
331
|
+
clearDefaultInstance,
|
|
332
|
+
getDefaultInstance,
|
|
333
|
+
renameInstance,
|
|
334
|
+
verifyPassphrase,
|
|
335
|
+
};
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const os = require('os');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const { execSync } = require('child_process');
|
|
7
|
+
|
|
8
|
+
const HEARTBEAT_FRESH_MS = 5 * 60 * 1000;
|
|
9
|
+
const VM_ENVIRONMENTS = new Set(['docker', 'lima', 'wsl2']);
|
|
10
|
+
|
|
11
|
+
function isPidAlive(pid) {
|
|
12
|
+
try {
|
|
13
|
+
process.kill(pid, 0);
|
|
14
|
+
return true;
|
|
15
|
+
} catch (err) {
|
|
16
|
+
return err.code === 'EPERM';
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function verifyPidIsNode(pid) {
|
|
21
|
+
try {
|
|
22
|
+
if (process.platform === 'linux') {
|
|
23
|
+
try {
|
|
24
|
+
const cmdline = fs.readFileSync(`/proc/${pid}/cmdline`, 'utf8');
|
|
25
|
+
const cmd = cmdline.split('\0')[0] || '';
|
|
26
|
+
return /node|electron|quilltap|next-server/i.test(cmd);
|
|
27
|
+
} catch {
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
if (process.platform === 'darwin') {
|
|
32
|
+
const output = execSync(`ps -p ${pid} -o comm=`, {
|
|
33
|
+
encoding: 'utf8', timeout: 2000, stdio: ['pipe', 'pipe', 'pipe'],
|
|
34
|
+
}).trim();
|
|
35
|
+
return /node|electron|quilltap|next-server/i.test(output);
|
|
36
|
+
}
|
|
37
|
+
if (process.platform === 'win32') {
|
|
38
|
+
const output = execSync(`tasklist /FI "PID eq ${pid}" /NH`, {
|
|
39
|
+
encoding: 'utf8', timeout: 2000, stdio: ['pipe', 'pipe', 'pipe'],
|
|
40
|
+
}).trim();
|
|
41
|
+
return /node|electron|quilltap|next-server/i.test(output);
|
|
42
|
+
}
|
|
43
|
+
return true;
|
|
44
|
+
} catch {
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Inspect the instance lock at <dataDir>/quilltap.lock and decide whether it
|
|
51
|
+
* is *actively* held by a live Quilltap process.
|
|
52
|
+
*
|
|
53
|
+
* Returns:
|
|
54
|
+
* { state: 'absent' } — no lock file
|
|
55
|
+
* { state: 'corrupt', lockPath } — file exists but unreadable
|
|
56
|
+
* { state: 'active', lock, lockPath, reason } — held by a live owner
|
|
57
|
+
* { state: 'stale', lock, lockPath, reason } — owner is gone
|
|
58
|
+
* { state: 'suspect', lock, lockPath, reason } — PID alive but not Quilltap-shaped
|
|
59
|
+
*/
|
|
60
|
+
function getLockStatus(dataDir) {
|
|
61
|
+
const lockPath = path.join(dataDir, 'quilltap.lock');
|
|
62
|
+
|
|
63
|
+
if (!fs.existsSync(lockPath)) {
|
|
64
|
+
return { state: 'absent', lockPath };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
let lock;
|
|
68
|
+
try {
|
|
69
|
+
lock = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
|
|
70
|
+
} catch {
|
|
71
|
+
return { state: 'corrupt', lockPath };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const hostname = os.hostname();
|
|
75
|
+
const sameHost = lock.hostname === hostname;
|
|
76
|
+
|
|
77
|
+
if (sameHost) {
|
|
78
|
+
const alive = isPidAlive(lock.pid);
|
|
79
|
+
if (!alive) {
|
|
80
|
+
return { state: 'stale', lock, lockPath, reason: `PID ${lock.pid} is no longer running` };
|
|
81
|
+
}
|
|
82
|
+
const isNode = verifyPidIsNode(lock.pid);
|
|
83
|
+
if (!isNode) {
|
|
84
|
+
return {
|
|
85
|
+
state: 'suspect',
|
|
86
|
+
lock, lockPath,
|
|
87
|
+
reason: `PID ${lock.pid} is alive but does not look like a Quilltap process`,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
return { state: 'active', lock, lockPath, reason: `held by PID ${lock.pid} on this host` };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Different hostname — could be a VM/container sharing the data dir.
|
|
94
|
+
const isVM = VM_ENVIRONMENTS.has(lock.environment);
|
|
95
|
+
const heartbeatAgeMs = lock.lastHeartbeat
|
|
96
|
+
? Date.now() - new Date(lock.lastHeartbeat).getTime()
|
|
97
|
+
: Infinity;
|
|
98
|
+
if (isVM && heartbeatAgeMs < HEARTBEAT_FRESH_MS) {
|
|
99
|
+
const ageStr = Math.round(heartbeatAgeMs / 1000) + 's';
|
|
100
|
+
return {
|
|
101
|
+
state: 'active',
|
|
102
|
+
lock, lockPath,
|
|
103
|
+
reason: `held by ${lock.environment} instance on ${lock.hostname} (heartbeat ${ageStr} ago)`,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
return {
|
|
107
|
+
state: 'stale',
|
|
108
|
+
lock, lockPath,
|
|
109
|
+
reason: `held by ${lock.hostname} but no recent heartbeat`,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
module.exports = {
|
|
114
|
+
getLockStatus,
|
|
115
|
+
isPidAlive,
|
|
116
|
+
verifyPidIsNode,
|
|
117
|
+
};
|