memcue-dsh 0.4.0 → 0.5.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 +4 -1
- package/package.json +1 -1
- package/src/index.mjs +30 -12
- package/src/install.mjs +120 -0
package/README.md
CHANGED
|
@@ -57,7 +57,10 @@ directory's `cordis.patch.yml`. The plugin directory is also its own package, fo
|
|
|
57
57
|
development. The entry is plain ESM (`src/index.mjs`) on purpose: dsh installs a bundle under `node_modules`, where
|
|
58
58
|
Node refuses to strip TypeScript types, so a `.ts` entry cannot be loaded from an installed package.
|
|
59
59
|
|
|
60
|
-
1.
|
|
60
|
+
1. Optional: put the `memcue` binary where the dsh process can run it and note its absolute path. Without a
|
|
61
|
+
configured `binary` and without a current `memcue` on `PATH`, the plugin installs the binary of its own release
|
|
62
|
+
into `${XDG_DATA_HOME:-~/.local/share}/memcue/<version>/`, the directory the Claude Code plugin uses
|
|
63
|
+
(`MEMCUE_AUTO_INSTALL=0` turns that off; see [Binary from the plugin](../../README.md#binary-from-the-plugin)).
|
|
61
64
|
|
|
62
65
|
2. Install the bundle from the repository:
|
|
63
66
|
|
package/package.json
CHANGED
package/src/index.mjs
CHANGED
|
@@ -18,12 +18,13 @@
|
|
|
18
18
|
* @module memcue-dsh
|
|
19
19
|
*/
|
|
20
20
|
import { spawn } from 'node:child_process'
|
|
21
|
-
import { createHash } from 'node:crypto'
|
|
21
|
+
import { createHash, randomUUID } from 'node:crypto'
|
|
22
22
|
import { mkdir, readFile, readdir, rename, writeFile } from 'node:fs/promises'
|
|
23
23
|
import { homedir } from 'node:os'
|
|
24
24
|
import { dirname, isAbsolute, join, resolve } from 'node:path'
|
|
25
25
|
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
|
26
26
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
27
|
+
import { install, pluginVersion } from './install.mjs'
|
|
27
28
|
|
|
28
29
|
/** Cordis plugin name used by loader diagnostics. */
|
|
29
30
|
export const name = 'memcue'
|
|
@@ -135,6 +136,8 @@ function settingsOf(config) {
|
|
|
135
136
|
}
|
|
136
137
|
return {
|
|
137
138
|
binary: expandHome(textOf(raw.binary, 'binary', process.env.MEMCUE_BIN || DEFAULTS.binary)) || DEFAULTS.binary,
|
|
139
|
+
// A configured binary is used as it is; the default one may be replaced by the release this plugin installs.
|
|
140
|
+
pinned: Boolean(textOf(raw.binary, 'binary', process.env.MEMCUE_BIN || '')),
|
|
138
141
|
facts,
|
|
139
142
|
factsKey: resolve(facts || defaultFacts()),
|
|
140
143
|
// The global off switch wins over the profile: it is the one setting a user adds to a container or a launcher
|
|
@@ -406,10 +409,11 @@ async function readState(path) {
|
|
|
406
409
|
}
|
|
407
410
|
|
|
408
411
|
/** Write the checkpoint atomically: a crash leaves the old file or the new one, never a half-written one.
|
|
409
|
-
* The batch holds raw messages, so the directory and file are private to the user.
|
|
412
|
+
* The batch holds raw messages, so the directory and file are private to the user. Each write has its own temporary
|
|
413
|
+
* file: two plugin instances on one session would otherwise rename each other's file and fail with ENOENT (#261). */
|
|
410
414
|
async function writeState(path, state) {
|
|
411
415
|
await mkdir(dirname(path), { recursive: true, mode: 0o700 })
|
|
412
|
-
const tmp = `${path}.tmp`
|
|
416
|
+
const tmp = `${path}.${randomUUID()}.tmp`
|
|
413
417
|
await writeFile(tmp, JSON.stringify(state), { mode: 0o600 })
|
|
414
418
|
await rename(tmp, path)
|
|
415
419
|
}
|
|
@@ -512,21 +516,35 @@ export function apply(ctx, config) {
|
|
|
512
516
|
interpolate: false,
|
|
513
517
|
})
|
|
514
518
|
|
|
515
|
-
// The
|
|
516
|
-
|
|
517
|
-
const checkBinary = async () => {
|
|
519
|
+
// The version the binary reports, and whether there is a binary at all.
|
|
520
|
+
const versionOf = async () => {
|
|
518
521
|
// `-json` is 0.4.0 too: an older binary answers the flag with an error, so ask for the plain form as well.
|
|
519
522
|
let { code, out } = await runMemcue(settings, ['version', '-json'], { timeoutMs: 5_000 })
|
|
520
523
|
if (code !== 0) ({ code, out } = await runMemcue(settings, ['version'], { timeoutMs: 5_000 }))
|
|
521
|
-
|
|
522
|
-
|
|
524
|
+
if (code !== 0) return { found: false, reported: '' }
|
|
525
|
+
try {
|
|
526
|
+
return { found: true, reported: String(JSON.parse(out)?.version ?? '') }
|
|
527
|
+
} catch {
|
|
528
|
+
return { found: true, reported: out.trim().split(/\s+/)[0] ?? '' }
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// Check once that the binary has the commands the plugin uses. Without a configured binary, a missing or older
|
|
533
|
+
// one is replaced by the release of this plugin, installed or downloaded; until that is done recall uses what
|
|
534
|
+
// PATH has. Anything else turns the card and extraction off with one clear line instead of failing every turn.
|
|
535
|
+
const checkBinary = async () => {
|
|
536
|
+
let { found, reported } = await versionOf()
|
|
537
|
+
if (!settings.pinned && !(found && versionAtLeast(reported, MIN_BINARY))) {
|
|
538
|
+
let version = ''
|
|
523
539
|
try {
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
reported =
|
|
540
|
+
version = await pluginVersion()
|
|
541
|
+
settings.binary = await install(version)
|
|
542
|
+
;({ found, reported } = await versionOf())
|
|
543
|
+
} catch (error) {
|
|
544
|
+
if (!found) console.warn(`memcue: no memcue binary on PATH, and the plugin could not install ${version || 'its release'}: ${error.message}`)
|
|
527
545
|
}
|
|
528
546
|
}
|
|
529
|
-
if (versionAtLeast(reported, MIN_BINARY)) return
|
|
547
|
+
if (!found || versionAtLeast(reported, MIN_BINARY)) return
|
|
530
548
|
compatible = false
|
|
531
549
|
extraction = false
|
|
532
550
|
console.warn(`memcue: binary ${reported || 'unknown'} is older than ${MIN_BINARY}; the session card and extraction are off, recall still works. Update memcue.`)
|
package/src/install.mjs
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Installs the memcue binary of this plugin's release when none is configured, the way the Claude Code plugin does:
|
|
3
|
+
* into `${XDG_DATA_HOME:-~/.local/share}/memcue/<version>/memcue`, a directory the two plugins share, checked
|
|
4
|
+
* against the release's `checksums.txt`. `MEMCUE_AUTO_INSTALL=0` turns it off.
|
|
5
|
+
*
|
|
6
|
+
* @module memcue-dsh/install
|
|
7
|
+
*/
|
|
8
|
+
import { spawn } from 'node:child_process'
|
|
9
|
+
import { createHash } from 'node:crypto'
|
|
10
|
+
import { access, constants, chmod, mkdir, mkdtemp, readFile, rename, rm, rmdir, stat, writeFile } from 'node:fs/promises'
|
|
11
|
+
import { homedir } from 'node:os'
|
|
12
|
+
import { join } from 'node:path'
|
|
13
|
+
|
|
14
|
+
/** Where the releases are downloaded from; MEMCUE_RELEASE_URL replaces it, for a mirror or a test. */
|
|
15
|
+
const RELEASES = 'https://github.com/juev/memcue/releases/download'
|
|
16
|
+
|
|
17
|
+
/** A lock left by a crashed install stops blocking after this long. */
|
|
18
|
+
const STALE_LOCK_MS = 10 * 60_000
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The version of this plugin, which is the release of the binary it installs.
|
|
22
|
+
* @returns the version from package.json.
|
|
23
|
+
*/
|
|
24
|
+
export async function pluginVersion() {
|
|
25
|
+
const manifest = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8'))
|
|
26
|
+
return String(manifest.version)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Path of the installed binary of a release.
|
|
31
|
+
* @param version - release version without the leading v.
|
|
32
|
+
* @returns the absolute path, whether or not it exists.
|
|
33
|
+
*/
|
|
34
|
+
export function installedPath(version) {
|
|
35
|
+
const data = process.env.XDG_DATA_HOME || join(homedir(), '.local', 'share')
|
|
36
|
+
return join(data, 'memcue', version, 'memcue')
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Whether path is an executable file.
|
|
41
|
+
* @param path - file to check.
|
|
42
|
+
* @returns true when it can be run.
|
|
43
|
+
*/
|
|
44
|
+
export async function executable(path) {
|
|
45
|
+
try {
|
|
46
|
+
await access(path, constants.X_OK)
|
|
47
|
+
return true
|
|
48
|
+
} catch {
|
|
49
|
+
return false
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function target() {
|
|
54
|
+
const os = { darwin: 'darwin', linux: 'linux' }[process.platform]
|
|
55
|
+
const arch = { x64: 'amd64', arm64: 'arm64' }[process.arch]
|
|
56
|
+
if (!os || !arch) throw new Error(`no memcue release for ${process.platform}/${process.arch}`)
|
|
57
|
+
return `${os}_${arch}`
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function download(url, timeoutMs) {
|
|
61
|
+
const response = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) })
|
|
62
|
+
if (!response.ok) throw new Error(`${url}: ${response.status} ${response.statusText}`)
|
|
63
|
+
return Buffer.from(await response.arrayBuffer())
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function untar(archive, dir) {
|
|
67
|
+
return new Promise((resolve, reject) => {
|
|
68
|
+
const child = spawn('tar', ['-xzf', archive, '-C', dir, 'memcue'], { stdio: ['ignore', 'ignore', 'pipe'] })
|
|
69
|
+
let err = ''
|
|
70
|
+
child.stderr.on('data', (chunk) => { err += chunk })
|
|
71
|
+
child.on('error', reject)
|
|
72
|
+
child.on('close', (code) => (code === 0 ? resolve() : reject(new Error(`tar: ${err.trim()}`))))
|
|
73
|
+
})
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Install the binary of a release unless it is there already. The archive must match its line in the release's
|
|
78
|
+
* checksums.txt; it is unpacked next to the target and moved into place, so a reader never sees half a binary. A lock
|
|
79
|
+
* directory, the same one the Claude Code plugin takes, keeps two installs from racing.
|
|
80
|
+
* @param version - release version without the leading v.
|
|
81
|
+
* @returns the path of the installed binary.
|
|
82
|
+
* @throws when auto-install is off, the platform has no release, or the download or the checksum fails.
|
|
83
|
+
*/
|
|
84
|
+
export async function install(version) {
|
|
85
|
+
const path = installedPath(version)
|
|
86
|
+
if (await executable(path)) return path
|
|
87
|
+
if (process.env.MEMCUE_AUTO_INSTALL === '0') throw new Error('MEMCUE_AUTO_INSTALL=0')
|
|
88
|
+
const archive = `memcue_${version}_${target()}.tar.gz`
|
|
89
|
+
const dir = join(path, '..')
|
|
90
|
+
await mkdir(dir, { recursive: true })
|
|
91
|
+
const lock = join(dir, '.lock')
|
|
92
|
+
try {
|
|
93
|
+
if (Date.now() - (await stat(lock)).mtimeMs > STALE_LOCK_MS) await rmdir(lock)
|
|
94
|
+
} catch {
|
|
95
|
+
// no lock
|
|
96
|
+
}
|
|
97
|
+
try {
|
|
98
|
+
await mkdir(lock)
|
|
99
|
+
} catch {
|
|
100
|
+
throw new Error('another install of memcue is running')
|
|
101
|
+
}
|
|
102
|
+
const tmp = await mkdtemp(join(dir, '.tmp.'))
|
|
103
|
+
try {
|
|
104
|
+
const base = `${process.env.MEMCUE_RELEASE_URL || RELEASES}/v${version}`
|
|
105
|
+
const [data, sums] = await Promise.all([download(`${base}/${archive}`, 45_000),
|
|
106
|
+
download(`${base}/checksums.txt`, 15_000)])
|
|
107
|
+
const want = sums.toString('utf8').split('\n').map((line) => line.trim().split(/\s+/))
|
|
108
|
+
.find(([, file]) => file === archive)?.[0]
|
|
109
|
+
const got = createHash('sha256').update(data).digest('hex')
|
|
110
|
+
if (!want || want !== got) throw new Error(`checksum of ${archive} does not match checksums.txt`)
|
|
111
|
+
await writeFile(join(tmp, archive), data)
|
|
112
|
+
await untar(join(tmp, archive), tmp)
|
|
113
|
+
await chmod(join(tmp, 'memcue'), 0o755)
|
|
114
|
+
await rename(join(tmp, 'memcue'), path)
|
|
115
|
+
return path
|
|
116
|
+
} finally {
|
|
117
|
+
await rm(tmp, { recursive: true, force: true })
|
|
118
|
+
await rmdir(lock).catch(() => {})
|
|
119
|
+
}
|
|
120
|
+
}
|