@twinsearth/w2m-dsh-plugin 0.0.0-stage → 0.4.7
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/CHANGELOG.md +1309 -0
- package/LICENSE +21 -0
- package/PROTOCOL-v0.1.2.md +165 -0
- package/PROTOCOL-v0.3.0.md +454 -0
- package/PROTOCOL-v0.3.9.md +207 -0
- package/PROTOCOL-v0.4.0.md +255 -0
- package/PROTOCOL.md +339 -0
- package/README.md +665 -2
- package/bin/w2m-localside.mjs +303 -0
- package/bin/w2m-rabbit.mjs +396 -0
- package/bin/w2m-stun.mjs +231 -0
- package/cordis.patch.yml +94 -0
- package/deploy/caddy/Caddyfile.example +118 -0
- package/deploy/docker/Dockerfile +144 -0
- package/deploy/docker/README.md +338 -0
- package/deploy/docker/docker-compose.yml +179 -0
- package/deploy/nginx/w2m.conf.example +196 -0
- package/deploy/shared-server/README.md +118 -0
- package/deploy/shared-server/install.sh +255 -0
- package/deploy/systemd/w2m-rabbit.service +150 -0
- package/deploy/systemd/w2m-stun.service +69 -0
- package/deploy/windows/README.md +115 -0
- package/deploy/windows/install-service.ps1 +195 -0
- package/deploy/windows/w2m-agent-supervisor.mjs +205 -0
- package/deploy/windows/winsw/w2m-localside.xml +50 -0
- package/docs/DEPLOY.md +902 -0
- package/docs/LINT-AUDIT.md +492 -0
- package/docs/PIPELINE-DESIGN.md +95 -0
- package/docs/TROUBLESHOOTING.md +560 -0
- package/docs/UI-CARDS.md +81 -0
- package/lib/index.js +24 -0
- package/lib/tools.js +11 -0
- package/package.json +133 -4
- package/src/agent/agent.mjs +3317 -0
- package/src/agent/allowed-commands.mjs +116 -0
- package/src/agent/caps.mjs +437 -0
- package/src/agent/exec.mjs +356 -0
- package/src/agent/git.mjs +616 -0
- package/src/agent/identity.mjs +253 -0
- package/src/agent/p2p-node.mjs +1243 -0
- package/src/agent/p2p.mjs +605 -0
- package/src/agent/resolve.mjs +148 -0
- package/src/agent/spool.mjs +252 -0
- package/src/agent/stun.mjs +397 -0
- package/src/agent/url.mjs +127 -0
- package/src/plugin/auto-update.mjs +574 -0
- package/src/plugin/schedule.mjs +452 -0
- package/src/plugin/shared-config.mjs +264 -0
- package/src/plugin/tools.mjs +3891 -0
- package/src/plugin/update-install.mjs +764 -0
- package/src/plugin/update-source.mjs +686 -0
- package/src/relay/metrics.mjs +284 -0
- package/src/relay/peers.mjs +180 -0
- package/src/relay/persistence.mjs +207 -0
- package/src/relay/report.mjs +591 -0
- package/src/relay/server.mjs +1187 -0
- package/src/relay/state.mjs +2004 -0
- package/src/relay/stun-server.mjs +733 -0
- package/src/signing.mjs +359 -0
- package/src/util/cli.mjs +70 -0
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Executable resolution shared by capability probing and task execution.
|
|
3
|
+
*
|
|
4
|
+
* This file exists because those two used to disagree, and the disagreement was
|
|
5
|
+
* invisible until a task ran.
|
|
6
|
+
*
|
|
7
|
+
* The capability probe deliberately looks for a tool **on PATH first, in DSH's
|
|
8
|
+
* bundled runtime second** — that is how a machine with no system `node` still
|
|
9
|
+
* reports `caps.node = "v24.21.0"` and means it. The executor, by contrast,
|
|
10
|
+
* spawned `argv[0]` verbatim with `shell: false`, so it could only ever find what
|
|
11
|
+
* PATH held. On a machine where the tool exists *only* in the bundle, the probe
|
|
12
|
+
* advertised a capability and every task that used it died at spawn with a bare
|
|
13
|
+
* `crashed`. One search order has to serve both, so it lives here.
|
|
14
|
+
*
|
|
15
|
+
* Nothing in this module guesses: a name that cannot be found is returned
|
|
16
|
+
* unchanged, so the caller still gets the real ENOENT instead of a fake path.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { accessSync, constants, statSync } from 'node:fs';
|
|
20
|
+
import { homedir } from 'node:os';
|
|
21
|
+
import { delimiter, join } from 'node:path';
|
|
22
|
+
|
|
23
|
+
/** The DSH runtime directory layout we fall back to (task-8, measured). */
|
|
24
|
+
export const DSH_RUNTIME_RELATIVE = join('dsh-runtimes', 'dsh-primary-runtime', 'dependencies');
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* `%DSH_HOME%` (or `~/.dsh`) — the root the bundled runtimes live under.
|
|
28
|
+
*
|
|
29
|
+
* @param {Record<string,string|undefined>} [env]
|
|
30
|
+
* @returns {string}
|
|
31
|
+
*/
|
|
32
|
+
export function dshHome(env = process.env) {
|
|
33
|
+
const explicit = env.DSH_HOME && env.DSH_HOME.trim() !== '' ? env.DSH_HOME : null;
|
|
34
|
+
return explicit ?? join(homedir(), '.dsh');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Absolute path of the bundled DSH dependency directory, or `null` when absent.
|
|
39
|
+
*
|
|
40
|
+
* `DSH_RUNTIME_DEPS` overrides the guess, which keeps tests hermetic.
|
|
41
|
+
*
|
|
42
|
+
* @param {Record<string,string|undefined>} [env]
|
|
43
|
+
* @returns {string|null}
|
|
44
|
+
*/
|
|
45
|
+
export function dshDependenciesDir(env = process.env) {
|
|
46
|
+
const override = env.DSH_RUNTIME_DEPS && env.DSH_RUNTIME_DEPS.trim() !== '';
|
|
47
|
+
const dir = override ? env.DSH_RUNTIME_DEPS : join(dshHome(env), DSH_RUNTIME_RELATIVE);
|
|
48
|
+
try {
|
|
49
|
+
return statSync(dir).isDirectory() ? dir : null;
|
|
50
|
+
} catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Windows only: the extensions `CreateProcess` will accept for a bare name.
|
|
57
|
+
* (`.cmd`/`.bat` are listed so we can *report* them, but they cannot be
|
|
58
|
+
* started with `shell: false`, so they are never returned as executable.)
|
|
59
|
+
*/
|
|
60
|
+
const WIN_EXTENSIONS = ['.exe', '.com'];
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Find a real executable on PATH (then, optionally, in extra directories).
|
|
64
|
+
*
|
|
65
|
+
* @param {string} name Bare command name, e.g. `node`, `python3`.
|
|
66
|
+
* @param {object} [options]
|
|
67
|
+
* @param {Record<string,string|undefined>} [options.env]
|
|
68
|
+
* @param {string[]} [options.extraDirs] Absolute directories to try after PATH.
|
|
69
|
+
* @param {string[]} [options.extensions] Override the candidate extensions.
|
|
70
|
+
* @returns {string|null} Absolute path.
|
|
71
|
+
*/
|
|
72
|
+
export function findExecutable(name, options = {}) {
|
|
73
|
+
const env = options.env ?? process.env;
|
|
74
|
+
const isWin = process.platform === 'win32';
|
|
75
|
+
const extensions = options.extensions ?? (isWin ? ['', ...WIN_EXTENSIONS] : ['']);
|
|
76
|
+
const dirs = [
|
|
77
|
+
...String(env.PATH ?? '')
|
|
78
|
+
.split(delimiter)
|
|
79
|
+
.filter((d) => d.trim() !== ''),
|
|
80
|
+
...(options.extraDirs ?? []),
|
|
81
|
+
];
|
|
82
|
+
|
|
83
|
+
for (const dir of dirs) {
|
|
84
|
+
for (const ext of extensions) {
|
|
85
|
+
const candidate = join(dir, name + ext);
|
|
86
|
+
try {
|
|
87
|
+
accessSync(candidate, isWin ? constants.F_OK : constants.F_OK | constants.X_OK);
|
|
88
|
+
if (statSync(candidate).isFile()) return candidate;
|
|
89
|
+
} catch {
|
|
90
|
+
/* keep looking */
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The `bin` directories inside DSH's bundled runtime tree.
|
|
99
|
+
*
|
|
100
|
+
* This is the second half of the probe's search order, expressed once. A tool
|
|
101
|
+
* found here is a tool the probe would have reported a real version for, which
|
|
102
|
+
* is exactly why the executor must be able to start it.
|
|
103
|
+
*
|
|
104
|
+
* @param {Record<string,string|undefined>} [env]
|
|
105
|
+
* @returns {string[]} Existing directories, in priority order.
|
|
106
|
+
*/
|
|
107
|
+
export function bundledBinDirs(env = process.env) {
|
|
108
|
+
const depsDir = dshDependenciesDir(env);
|
|
109
|
+
if (!depsDir) return [];
|
|
110
|
+
const isWin = process.platform === 'win32';
|
|
111
|
+
const candidates = [
|
|
112
|
+
join(depsDir, 'node', 'bin'),
|
|
113
|
+
join(depsDir, 'node'),
|
|
114
|
+
join(depsDir, 'python', isWin ? '' : 'bin'),
|
|
115
|
+
join(depsDir, 'pnpm', 'bin'),
|
|
116
|
+
];
|
|
117
|
+
return candidates.filter((dir) => {
|
|
118
|
+
try {
|
|
119
|
+
return statSync(dir).isDirectory();
|
|
120
|
+
} catch {
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Resolve `argv[0]` the way the capability probe resolves a tool.
|
|
128
|
+
*
|
|
129
|
+
* * A name containing a path separator is the caller's own path: returned
|
|
130
|
+
* unchanged, because rewriting an explicit path would be second-guessing.
|
|
131
|
+
* * Otherwise: PATH first, then DSH's bundled runtime.
|
|
132
|
+
* * Not found anywhere: returned unchanged, so `spawn` still reports ENOENT and
|
|
133
|
+
* the result keeps saying `crashed` for the honest reason.
|
|
134
|
+
*
|
|
135
|
+
* @param {string} exe Bare command name or a path.
|
|
136
|
+
* @param {object} [options]
|
|
137
|
+
* @param {Record<string,string|undefined>} [options.env]
|
|
138
|
+
* @param {string[]} [options.extraDirs] Extra directories tried after PATH.
|
|
139
|
+
* @returns {string} Absolute path when one was found, else `exe`.
|
|
140
|
+
*/
|
|
141
|
+
export function resolveCommand(exe, options = {}) {
|
|
142
|
+
if (typeof exe !== 'string' || exe === '') return exe;
|
|
143
|
+
// Anything with a separator is a path the caller chose, not a name to look up.
|
|
144
|
+
if (exe.includes('/') || exe.includes('\\')) return exe;
|
|
145
|
+
const env = options.env ?? process.env;
|
|
146
|
+
const extraDirs = options.extraDirs ?? bundledBinDirs(env);
|
|
147
|
+
return findExecutable(exe, { env, extraDirs }) ?? exe;
|
|
148
|
+
}
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Write-ahead spool: "先落盘再回传" (§0).
|
|
3
|
+
*
|
|
4
|
+
* Ordering that the contract requires:
|
|
5
|
+
*
|
|
6
|
+
* 1. an offer is written to the spool **before** anything executes;
|
|
7
|
+
* 2. the finished envelope is written to the spool **before** it is POSTed;
|
|
8
|
+
* 3. the spool entry is deleted only after Rabbit confirms receipt.
|
|
9
|
+
*
|
|
10
|
+
* A crash between (2) and (3) therefore leaves a replayable envelope on disk,
|
|
11
|
+
* and a lost Rabbit (or a laptop lid closing mid-run) cannot silently swallow a
|
|
12
|
+
* result. Everything lives under the `--state` directory: read-only mode must
|
|
13
|
+
* never write into the project, and the spool is not part of the project.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
17
|
+
import { join } from 'node:path';
|
|
18
|
+
import { randomUUID } from 'node:crypto';
|
|
19
|
+
|
|
20
|
+
/** Sub-directory of the state dir that holds spooled tasks. */
|
|
21
|
+
export const SPOOL_DIR_NAME = 'spool';
|
|
22
|
+
|
|
23
|
+
/** Entry states, in the order they occur. */
|
|
24
|
+
export const SPOOL_STATES = ['claimed', 'completed', 'acked'];
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @typedef {object} SpoolRecord
|
|
28
|
+
* @property {string} task_id
|
|
29
|
+
* @property {number} attempt
|
|
30
|
+
* @property {string|null} dedupe_key
|
|
31
|
+
* @property {'claimed'|'completed'|'acked'} state
|
|
32
|
+
* @property {string} dir
|
|
33
|
+
* @property {string} created_at
|
|
34
|
+
* @property {string} updated_at
|
|
35
|
+
* @property {object|null} task
|
|
36
|
+
* @property {object|null} envelope
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
/** @param {string} value */
|
|
40
|
+
function sanitize(value) {
|
|
41
|
+
return String(value).replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 120);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Atomic file write: temp file in the same directory, then rename. */
|
|
45
|
+
function writeAtomic(file, text) {
|
|
46
|
+
const tmp = `${file}.tmp-${process.pid}-${randomUUID().slice(0, 8)}`;
|
|
47
|
+
writeFileSync(tmp, text, { encoding: 'utf8', mode: 0o600 });
|
|
48
|
+
renameSync(tmp, file);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Create a spool rooted at `<stateDir>/spool`.
|
|
53
|
+
*
|
|
54
|
+
* @param {string} stateDir
|
|
55
|
+
* @returns {object} Spool handle.
|
|
56
|
+
*/
|
|
57
|
+
export function createSpool(stateDir) {
|
|
58
|
+
if (typeof stateDir !== 'string' || stateDir.trim() === '') {
|
|
59
|
+
throw new TypeError('createSpool: stateDir must be a non-empty string');
|
|
60
|
+
}
|
|
61
|
+
const root = join(stateDir, SPOOL_DIR_NAME);
|
|
62
|
+
mkdirSync(root, { recursive: true, mode: 0o700 });
|
|
63
|
+
|
|
64
|
+
/** @param {string} taskId @param {number} attempt */
|
|
65
|
+
const taskDir = (taskId, attempt) => join(root, `${sanitize(taskId)}__a${Number(attempt) || 1}`);
|
|
66
|
+
|
|
67
|
+
/** @param {string} dir */
|
|
68
|
+
const readJson = (dir, name) => {
|
|
69
|
+
try {
|
|
70
|
+
return JSON.parse(readFileSync(join(dir, name), 'utf8'));
|
|
71
|
+
} catch {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/** @param {string} dir @returns {SpoolRecord|null} */
|
|
77
|
+
const readRecord = (dir) => {
|
|
78
|
+
const meta = readJson(dir, 'meta.json');
|
|
79
|
+
if (!meta || typeof meta.task_id !== 'string') return null;
|
|
80
|
+
return {
|
|
81
|
+
task_id: meta.task_id,
|
|
82
|
+
attempt: Number(meta.attempt) || 1,
|
|
83
|
+
dedupe_key: meta.dedupe_key ?? null,
|
|
84
|
+
state: meta.state ?? 'claimed',
|
|
85
|
+
dir,
|
|
86
|
+
created_at: meta.created_at ?? null,
|
|
87
|
+
updated_at: meta.updated_at ?? null,
|
|
88
|
+
task: readJson(dir, 'task.json'),
|
|
89
|
+
envelope: readJson(dir, 'envelope.json'),
|
|
90
|
+
};
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const spool = {
|
|
94
|
+
/** Absolute spool root. */
|
|
95
|
+
root,
|
|
96
|
+
/** State directory the spool belongs to. */
|
|
97
|
+
stateDir,
|
|
98
|
+
taskDir,
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Step 1: persist the offer before doing anything with it.
|
|
102
|
+
*
|
|
103
|
+
* @param {{task_id: string, attempt?: number, dedupe_key?: string|null, offer?: object|null}} entry
|
|
104
|
+
* @returns {SpoolRecord}
|
|
105
|
+
*/
|
|
106
|
+
saveTask(entry) {
|
|
107
|
+
if (!entry || typeof entry.task_id !== 'string' || entry.task_id === '') {
|
|
108
|
+
throw new TypeError('spool.saveTask: task_id is required');
|
|
109
|
+
}
|
|
110
|
+
const attempt = Number(entry.attempt) || 1;
|
|
111
|
+
const dir = taskDir(entry.task_id, attempt);
|
|
112
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
113
|
+
const now = new Date().toISOString();
|
|
114
|
+
const existing = readJson(dir, 'meta.json');
|
|
115
|
+
if (entry.offer) writeAtomic(join(dir, 'task.json'), `${JSON.stringify(entry.offer, null, 2)}\n`);
|
|
116
|
+
const meta = {
|
|
117
|
+
task_id: entry.task_id,
|
|
118
|
+
attempt,
|
|
119
|
+
dedupe_key: entry.dedupe_key ?? existing?.dedupe_key ?? null,
|
|
120
|
+
state: 'claimed',
|
|
121
|
+
created_at: existing?.created_at ?? now,
|
|
122
|
+
updated_at: now,
|
|
123
|
+
};
|
|
124
|
+
writeAtomic(join(dir, 'meta.json'), `${JSON.stringify(meta, null, 2)}\n`);
|
|
125
|
+
return readRecord(dir);
|
|
126
|
+
},
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Step 2: persist the envelope before the POST.
|
|
130
|
+
*
|
|
131
|
+
* @param {{task_id: string, attempt?: number, envelope: object}} entry
|
|
132
|
+
* @returns {SpoolRecord}
|
|
133
|
+
*/
|
|
134
|
+
saveEnvelope(entry) {
|
|
135
|
+
if (!entry || typeof entry.task_id !== 'string' || entry.task_id === '') {
|
|
136
|
+
throw new TypeError('spool.saveEnvelope: task_id is required');
|
|
137
|
+
}
|
|
138
|
+
if (!entry.envelope || typeof entry.envelope !== 'object') {
|
|
139
|
+
throw new TypeError('spool.saveEnvelope: envelope is required');
|
|
140
|
+
}
|
|
141
|
+
const attempt = Number(entry.attempt) || 1;
|
|
142
|
+
const dir = taskDir(entry.task_id, attempt);
|
|
143
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
144
|
+
writeAtomic(join(dir, 'envelope.json'), `${JSON.stringify(entry.envelope, null, 2)}\n`);
|
|
145
|
+
const existing = readJson(dir, 'meta.json') ?? {};
|
|
146
|
+
const now = new Date().toISOString();
|
|
147
|
+
const meta = {
|
|
148
|
+
task_id: entry.task_id,
|
|
149
|
+
attempt,
|
|
150
|
+
dedupe_key: entry.dedupe_key ?? entry.envelope.dedupe_key ?? existing.dedupe_key ?? null,
|
|
151
|
+
state: 'completed',
|
|
152
|
+
created_at: existing.created_at ?? now,
|
|
153
|
+
updated_at: now,
|
|
154
|
+
};
|
|
155
|
+
writeAtomic(join(dir, 'meta.json'), `${JSON.stringify(meta, null, 2)}\n`);
|
|
156
|
+
return readRecord(dir);
|
|
157
|
+
},
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Every spool entry, oldest first.
|
|
161
|
+
*
|
|
162
|
+
* @returns {SpoolRecord[]}
|
|
163
|
+
*/
|
|
164
|
+
list() {
|
|
165
|
+
let names;
|
|
166
|
+
try {
|
|
167
|
+
names = readdirSync(root);
|
|
168
|
+
} catch {
|
|
169
|
+
return [];
|
|
170
|
+
}
|
|
171
|
+
const records = [];
|
|
172
|
+
for (const name of names) {
|
|
173
|
+
const dir = join(root, name);
|
|
174
|
+
try {
|
|
175
|
+
if (!statSync(dir).isDirectory()) continue;
|
|
176
|
+
} catch {
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
const record = readRecord(dir);
|
|
180
|
+
if (record) records.push(record);
|
|
181
|
+
}
|
|
182
|
+
records.sort((a, b) => String(a.created_at).localeCompare(String(b.created_at)));
|
|
183
|
+
return records;
|
|
184
|
+
},
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Entries whose envelope has not been acknowledged yet.
|
|
188
|
+
*
|
|
189
|
+
* @returns {SpoolRecord[]}
|
|
190
|
+
*/
|
|
191
|
+
pendingResults() {
|
|
192
|
+
return spool.list().filter((record) => record.envelope !== null);
|
|
193
|
+
},
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Entries claimed but never completed (a crash mid-run).
|
|
197
|
+
*
|
|
198
|
+
* @returns {SpoolRecord[]}
|
|
199
|
+
*/
|
|
200
|
+
interruptedTasks() {
|
|
201
|
+
return spool.list().filter((record) => record.envelope === null);
|
|
202
|
+
},
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Step 3: drop the entry once Rabbit confirmed it.
|
|
206
|
+
*
|
|
207
|
+
* @param {{task_id: string, attempt?: number}} entry
|
|
208
|
+
* @returns {boolean} Whether an entry was removed.
|
|
209
|
+
*/
|
|
210
|
+
ack(entry) {
|
|
211
|
+
const dir = taskDir(entry.task_id, Number(entry.attempt) || 1);
|
|
212
|
+
try {
|
|
213
|
+
statSync(dir);
|
|
214
|
+
} catch {
|
|
215
|
+
return false;
|
|
216
|
+
}
|
|
217
|
+
rmSync(dir, { recursive: true, force: true });
|
|
218
|
+
return true;
|
|
219
|
+
},
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Acknowledge by dedupe key (Rabbit may answer `deduped: true`).
|
|
223
|
+
*
|
|
224
|
+
* @param {string} dedupeKey
|
|
225
|
+
* @returns {number} Count removed.
|
|
226
|
+
*/
|
|
227
|
+
ackByDedupeKey(dedupeKey) {
|
|
228
|
+
let removed = 0;
|
|
229
|
+
for (const record of spool.list()) {
|
|
230
|
+
if (record.dedupe_key && record.dedupe_key === dedupeKey) {
|
|
231
|
+
rmSync(record.dir, { recursive: true, force: true });
|
|
232
|
+
removed += 1;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return removed;
|
|
236
|
+
},
|
|
237
|
+
|
|
238
|
+
/** @returns {number} Entry count. */
|
|
239
|
+
size() {
|
|
240
|
+
return spool.list().length;
|
|
241
|
+
},
|
|
242
|
+
|
|
243
|
+
/** Remove everything (tests, `--reset-spool`). */
|
|
244
|
+
clear() {
|
|
245
|
+
const records = spool.list();
|
|
246
|
+
for (const record of records) rmSync(record.dir, { recursive: true, force: true });
|
|
247
|
+
return records.length;
|
|
248
|
+
},
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
return spool;
|
|
252
|
+
}
|