@logicseek/hello-cc 0.1.8 → 1.0.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/CHANGELOG.md +117 -0
- package/README.md +26 -4
- package/README.zh-CN.md +22 -3
- package/bin/hcc.mjs +3 -5622
- package/docs/README.md +8 -0
- package/docs/README.zh-CN.md +6 -0
- package/docs/architecture.md +29 -18
- package/docs/architecture.zh-CN.md +20 -10
- package/docs/commands.md +16 -5
- package/docs/commands.zh-CN.md +16 -5
- package/docs/current-state-review-2026-08.md +341 -0
- package/docs/defect-review-2026-07.md +382 -0
- package/docs/guide.md +53 -5
- package/docs/guide.zh-CN.md +46 -3
- package/docs/superpowers/plans/2026-08-03-runtime-safety-plan.md +294 -0
- package/docs/superpowers/plans/2026-08-03-v1-data-identity-plan.md +400 -0
- package/docs/superpowers/plans/2026-08-03-v1-release-verification-plan.md +284 -0
- package/docs/superpowers/plans/2026-08-03-web-api-hardening-plan.md +303 -0
- package/docs/superpowers/plans/2026-08-04-runtime-buffer-gc-two-phase.md +71 -0
- package/docs/superpowers/specs/2026-08-03-runtime-safety-design.md +117 -0
- package/docs/superpowers/specs/2026-08-03-v1-data-identity-design.md +120 -0
- package/docs/superpowers/specs/2026-08-03-v1-release-verification-design.md +117 -0
- package/docs/superpowers/specs/2026-08-03-web-api-hardening-design.md +104 -0
- package/lib/cli/app.mjs +642 -0
- package/lib/cli/commands/coordination.mjs +247 -0
- package/lib/cli/commands/doctor.mjs +81 -0
- package/lib/cli/commands/gc.mjs +852 -0
- package/lib/cli/commands/hook.mjs +205 -0
- package/lib/cli/commands/install.mjs +294 -0
- package/lib/cli/commands/lifecycle.mjs +101 -0
- package/lib/cli/commands/lock.mjs +197 -0
- package/lib/cli/commands/misc.mjs +61 -0
- package/lib/cli/commands/msg.mjs +152 -0
- package/lib/cli/commands/peer.mjs +233 -0
- package/lib/cli/commands/query.mjs +96 -0
- package/lib/cli/commands/run.mjs +463 -0
- package/lib/cli/commands/task.mjs +405 -0
- package/lib/cli/commands/team.mjs +131 -0
- package/lib/cli/commands/tmux.mjs +838 -0
- package/lib/cli/commands/up.mjs +135 -0
- package/lib/cli-args.mjs +16 -2
- package/lib/coordination-state.mjs +112 -51
- package/lib/core/coordination/automation.mjs +5 -8
- package/lib/core/coordination/clock-safety.mjs +323 -0
- package/lib/core/coordination/event-retention.mjs +17 -0
- package/lib/core/coordination/gc-plan.mjs +614 -0
- package/lib/core/coordination/lease-renewal.mjs +102 -0
- package/lib/core/coordination/lock-evidence.mjs +77 -0
- package/lib/core/coordination/optimistic-evidence.mjs +26 -0
- package/lib/core/coordination/tasks.mjs +156 -36
- package/lib/core/peers/evidence-runtime.mjs +312 -0
- package/lib/core/peers/evidence.mjs +103 -0
- package/lib/core/peers/hook-owner.mjs +32 -0
- package/lib/core/peers/liveness.mjs +44 -4
- package/lib/core/peers/peer-helpers.mjs +121 -0
- package/lib/core/peers/session.mjs +8 -2
- package/lib/core/peers/tmux-safety.mjs +290 -0
- package/lib/core/sessions/launch.mjs +15 -1
- package/lib/db/connection.mjs +104 -0
- package/lib/db/events.mjs +34 -0
- package/lib/db/migration-backup.mjs +225 -0
- package/lib/db/schema.mjs +102 -5
- package/lib/db/stores/peers.mjs +34 -0
- package/lib/integrations/peers/identity.mjs +62 -16
- package/lib/integrations/shims/script.mjs +139 -15
- package/lib/process/identity.mjs +192 -0
- package/lib/process/pty-lifecycle.mjs +149 -0
- package/lib/runtime/buffer-directory-lease.mjs +47 -0
- package/lib/runtime/buffer-evidence.mjs +336 -0
- package/lib/runtime/buffer-gc-protocol.mjs +167 -0
- package/lib/runtime/buffer-gc.mjs +270 -0
- package/lib/runtime/client.mjs +61 -8
- package/lib/runtime/fatal-shutdown.mjs +54 -0
- package/lib/runtime/project-path.mjs +139 -0
- package/lib/runtime/projects.mjs +320 -40
- package/lib/runtime/state.mjs +232 -29
- package/lib/shared/cleanup-error.mjs +54 -0
- package/lib/shared/clock-grace.mjs +106 -0
- package/lib/shared/errors.mjs +9 -0
- package/lib/shared/file-lock.mjs +370 -0
- package/lib/shared/json-file.mjs +6 -2
- package/lib/shared/redact.mjs +60 -0
- package/lib/shared/socket-lock-worker.mjs +201 -0
- package/lib/terminal/tmux.mjs +78 -4
- package/lib/tmux.mjs +4 -0
- package/lib/ui/help.mjs +53 -15
- package/lib/web/api-version.mjs +42 -0
- package/lib/web/auto-attach.mjs +185 -0
- package/lib/web/buffer-gc-runtime.mjs +175 -0
- package/lib/web/cookie-auth.mjs +119 -0
- package/lib/web/external-sessions.mjs +254 -0
- package/lib/web/http-routes.mjs +542 -0
- package/lib/web/http.mjs +143 -7
- package/lib/web/liveness-reaper.mjs +145 -0
- package/lib/web/peer-actions.mjs +132 -23
- package/lib/web/project-contexts.mjs +178 -0
- package/lib/web/pty-sessions.mjs +203 -0
- package/lib/web/runtime-main.mjs +714 -0
- package/lib/web/runtime.mjs +91 -36
- package/lib/web/session-serialize.mjs +0 -0
- package/lib/web/startup.mjs +333 -0
- package/lib/web/tls.mjs +365 -0
- package/lib/web/tmux-sessions.mjs +1361 -0
- package/lib/web/tmux-stream.mjs +166 -0
- package/lib/web/ui-template.mjs +152 -20
- package/package.json +5 -2
- package/scripts/check-free-identifiers.mjs +290 -0
- package/scripts/regression.mjs +5646 -262
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
import { compareProcessIdentity } from '../../process/identity.mjs';
|
|
2
|
+
import { tx } from '../../db/schema.mjs';
|
|
3
|
+
import { CliError } from '../../shared/errors.mjs';
|
|
4
|
+
|
|
5
|
+
function completeIdentity(identity) {
|
|
6
|
+
return Boolean(
|
|
7
|
+
identity &&
|
|
8
|
+
Number.isInteger(Number(identity.pid)) &&
|
|
9
|
+
Number(identity.pid) > 0 &&
|
|
10
|
+
identity.startToken &&
|
|
11
|
+
/^[a-f0-9]{64}$/i.test(String(identity.commandHash || ''))
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function validateTmuxDestructiveEvidence(stored = {}, observed = {}, options = {}) {
|
|
16
|
+
const authority = validateTmuxAuthorityEvidence(stored, observed);
|
|
17
|
+
if (!authority.ok) return authority;
|
|
18
|
+
if (!completeIdentity(observed.process_identity) ||
|
|
19
|
+
compareProcessIdentity(stored.process_identity, observed.process_identity) !== 'live') {
|
|
20
|
+
return { ok: false, reason: 'tmux_process_changed' };
|
|
21
|
+
}
|
|
22
|
+
return validateTmuxClients(observed, options);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function validateTmuxAuthorityEvidence(stored, observed) {
|
|
26
|
+
if (!stored.session || !stored.session_created || !stored.session_id || !stored.root || !stored.pane || !completeIdentity(stored.process_identity)) {
|
|
27
|
+
return { ok: false, reason: 'tmux_stored_evidence_incomplete' };
|
|
28
|
+
}
|
|
29
|
+
if (!observed.session || observed.session !== stored.session) {
|
|
30
|
+
return { ok: false, reason: 'tmux_session_changed' };
|
|
31
|
+
}
|
|
32
|
+
if (!observed.session_created || observed.session_created !== stored.session_created) {
|
|
33
|
+
return { ok: false, reason: 'tmux_session_reused' };
|
|
34
|
+
}
|
|
35
|
+
if (!observed.session_id || observed.session_id !== stored.session_id) {
|
|
36
|
+
return { ok: false, reason: 'tmux_session_id_changed' };
|
|
37
|
+
}
|
|
38
|
+
if (!observed.root) return { ok: false, reason: 'tmux_root_unknown' };
|
|
39
|
+
if (observed.root !== stored.root) return { ok: false, reason: 'tmux_root_changed' };
|
|
40
|
+
if (!observed.pane || observed.pane !== stored.pane) {
|
|
41
|
+
return { ok: false, reason: 'tmux_pane_changed' };
|
|
42
|
+
}
|
|
43
|
+
return { ok: true };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function validateTmuxClients(observed, options) {
|
|
47
|
+
if (observed.clients?.state !== 'known') {
|
|
48
|
+
return { ok: false, reason: 'tmux_clients_unknown' };
|
|
49
|
+
}
|
|
50
|
+
if (!options.allowClients && Number(observed.clients.count || 0) > 0) {
|
|
51
|
+
return { ok: false, reason: 'tmux_has_clients' };
|
|
52
|
+
}
|
|
53
|
+
return { ok: true };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function validateTmuxGcDeadProcessEvidence(stored = {}, observed = {}, options = {}) {
|
|
57
|
+
const authority = validateTmuxAuthorityEvidence(stored, observed);
|
|
58
|
+
if (!authority.ok) return authority;
|
|
59
|
+
if (!observed.pane_dead) return { ok: false, reason: 'tmux_pane_not_dead' };
|
|
60
|
+
if (Number(observed.pane_pid) !== Number(stored.process_identity.pid)) {
|
|
61
|
+
return { ok: false, reason: 'tmux_dead_process_pid_changed' };
|
|
62
|
+
}
|
|
63
|
+
if (observed.process_inspection?.state !== 'dead' || observed.process_inspection.identity !== null) {
|
|
64
|
+
return { ok: false, reason: 'tmux_process_not_confirmed_dead' };
|
|
65
|
+
}
|
|
66
|
+
return validateTmuxClients(observed, options);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function validateTmuxGcBindingEvidence(subject = {}, observed = {}, options = {}) {
|
|
70
|
+
const authority = subject.authority;
|
|
71
|
+
if (!subject.peer || subject.transport !== 'tmux' || !subject.runtime_target ||
|
|
72
|
+
!subject.expected_session || !subject.expected_root || !authority) {
|
|
73
|
+
return { ok: false, reason: 'tmux_binding_subject_incomplete' };
|
|
74
|
+
}
|
|
75
|
+
if (authority.session !== subject.expected_session) {
|
|
76
|
+
return { ok: false, reason: 'tmux_binding_session_authority_changed' };
|
|
77
|
+
}
|
|
78
|
+
if (authority.root !== subject.expected_root) {
|
|
79
|
+
return { ok: false, reason: 'tmux_binding_root_authority_changed' };
|
|
80
|
+
}
|
|
81
|
+
if (authority.pane !== subject.runtime_target) {
|
|
82
|
+
return { ok: false, reason: 'tmux_binding_pane_authority_changed' };
|
|
83
|
+
}
|
|
84
|
+
if (subject.owner_evidence?.state === 'live') {
|
|
85
|
+
return { ok: false, reason: 'tmux_owner_process_live' };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const dead = validateTmuxGcDeadProcessEvidence(authority, observed, options);
|
|
89
|
+
if (dead.ok) return { ok: true, mode: 'dead_process' };
|
|
90
|
+
if (subject.status !== 'exited') return dead;
|
|
91
|
+
|
|
92
|
+
const live = validateTmuxDestructiveEvidence(authority, observed, options);
|
|
93
|
+
return live.ok ? { ok: true, mode: 'explicit_exit_live' } : live;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function finalizeTmuxGcBindingMutation(options = {}) {
|
|
97
|
+
const {
|
|
98
|
+
db,
|
|
99
|
+
plannedSubject,
|
|
100
|
+
readSubject,
|
|
101
|
+
sameSubject,
|
|
102
|
+
conditionalKill,
|
|
103
|
+
casBinding,
|
|
104
|
+
updatePeer,
|
|
105
|
+
beforeBegin
|
|
106
|
+
} = options;
|
|
107
|
+
for (const [name, fn] of Object.entries({ readSubject, sameSubject, conditionalKill, casBinding, updatePeer })) {
|
|
108
|
+
if (typeof fn !== 'function') throw new TypeError(`${name} must be a function`);
|
|
109
|
+
}
|
|
110
|
+
if (!db) throw new TypeError('db is required');
|
|
111
|
+
if (typeof beforeBegin === 'function') beforeBegin();
|
|
112
|
+
|
|
113
|
+
return tx(db, () => {
|
|
114
|
+
const currentSubject = readSubject();
|
|
115
|
+
if (!sameSubject(plannedSubject, currentSubject)) {
|
|
116
|
+
throw new CliError('TMUX_GC_BINDING_SUBJECT_CHANGED',
|
|
117
|
+
'tmux GC binding subject changed before final mutation');
|
|
118
|
+
}
|
|
119
|
+
conditionalKill();
|
|
120
|
+
const bindingResult = casBinding(currentSubject);
|
|
121
|
+
if (Number(bindingResult?.changes) !== 1) {
|
|
122
|
+
throw new CliError('TMUX_GC_BINDING_CAS_FAILED',
|
|
123
|
+
'tmux GC binding compare-and-swap did not update exactly one row');
|
|
124
|
+
}
|
|
125
|
+
updatePeer(currentSubject);
|
|
126
|
+
return currentSubject;
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function prepareTmuxRestartBinding(db, {
|
|
131
|
+
peer,
|
|
132
|
+
runtimeTarget,
|
|
133
|
+
nowSec
|
|
134
|
+
} = {}) {
|
|
135
|
+
if (!db || typeof db.prepare !== 'function') throw new TypeError('db is required');
|
|
136
|
+
if (typeof peer !== 'string' || !peer) throw new TypeError('peer is required');
|
|
137
|
+
if (typeof runtimeTarget !== 'string' || !runtimeTarget) {
|
|
138
|
+
throw new TypeError('runtimeTarget is required');
|
|
139
|
+
}
|
|
140
|
+
if (!Number.isSafeInteger(nowSec) || nowSec < 0) {
|
|
141
|
+
throw new TypeError('nowSec must be a non-negative safe integer');
|
|
142
|
+
}
|
|
143
|
+
const row = db.prepare(`
|
|
144
|
+
SELECT transport, runtime_target, updated_at
|
|
145
|
+
FROM peer_bindings
|
|
146
|
+
WHERE peer = ?
|
|
147
|
+
`).get(peer);
|
|
148
|
+
if (!row) return { ok: false, reason: 'tmux_binding_missing' };
|
|
149
|
+
if (row.transport !== 'tmux') return { ok: false, reason: 'tmux_binding_transport_changed' };
|
|
150
|
+
if (row.runtime_target === runtimeTarget) return { ok: true, restored: false };
|
|
151
|
+
if (row.runtime_target !== null) return { ok: false, reason: 'tmux_binding_target_changed' };
|
|
152
|
+
|
|
153
|
+
const previousUpdatedAt = Number(row.updated_at);
|
|
154
|
+
if (!Number.isSafeInteger(previousUpdatedAt) || previousUpdatedAt < 0) {
|
|
155
|
+
return { ok: false, reason: 'tmux_binding_timestamp_invalid' };
|
|
156
|
+
}
|
|
157
|
+
const preparedUpdatedAt = Math.max(nowSec, previousUpdatedAt + 1);
|
|
158
|
+
if (!Number.isSafeInteger(preparedUpdatedAt)) {
|
|
159
|
+
return { ok: false, reason: 'tmux_binding_timestamp_invalid' };
|
|
160
|
+
}
|
|
161
|
+
const updated = db.prepare(`
|
|
162
|
+
UPDATE peer_bindings
|
|
163
|
+
SET runtime_target = ?, updated_at = ?
|
|
164
|
+
WHERE peer = ? AND transport = 'tmux' AND runtime_target IS NULL
|
|
165
|
+
AND updated_at IS ?
|
|
166
|
+
`).run(runtimeTarget, preparedUpdatedAt, peer, previousUpdatedAt);
|
|
167
|
+
if (Number(updated.changes) !== 1) {
|
|
168
|
+
return { ok: false, reason: 'tmux_binding_subject_changed' };
|
|
169
|
+
}
|
|
170
|
+
return {
|
|
171
|
+
ok: true,
|
|
172
|
+
restored: true,
|
|
173
|
+
peer,
|
|
174
|
+
runtimeTarget,
|
|
175
|
+
previousUpdatedAt,
|
|
176
|
+
preparedUpdatedAt
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function rollbackTmuxRestartBinding(db, preparation) {
|
|
181
|
+
if (!preparation?.restored) return true;
|
|
182
|
+
const updated = db.prepare(`
|
|
183
|
+
UPDATE peer_bindings
|
|
184
|
+
SET runtime_target = NULL, updated_at = ?
|
|
185
|
+
WHERE peer = ? AND transport = 'tmux' AND runtime_target = ?
|
|
186
|
+
AND updated_at = ?
|
|
187
|
+
`).run(
|
|
188
|
+
preparation.previousUpdatedAt,
|
|
189
|
+
preparation.peer,
|
|
190
|
+
preparation.runtimeTarget,
|
|
191
|
+
preparation.preparedUpdatedAt
|
|
192
|
+
);
|
|
193
|
+
return Number(updated.changes) === 1;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const KILL_OK = 'HCC_CONDITIONAL_KILL_OK';
|
|
197
|
+
const KILL_MISMATCH = 'HCC_CONDITIONAL_KILL_MISMATCH';
|
|
198
|
+
const RENAME_OK = 'HCC_CONDITIONAL_RENAME_OK';
|
|
199
|
+
const RENAME_MISMATCH = 'HCC_CONDITIONAL_RENAME_MISMATCH';
|
|
200
|
+
|
|
201
|
+
function conditionalFields(stored, {
|
|
202
|
+
code = 'TMUX_CONDITIONAL_KILL_MISMATCH',
|
|
203
|
+
operation = 'conditional-kill'
|
|
204
|
+
} = {}) {
|
|
205
|
+
const fields = {
|
|
206
|
+
session: String(stored?.session || ''),
|
|
207
|
+
sessionCreated: String(stored?.session_created || ''),
|
|
208
|
+
sessionId: String(stored?.session_id || ''),
|
|
209
|
+
pane: String(stored?.pane || ''),
|
|
210
|
+
panePid: String(stored?.process_identity?.pid || '')
|
|
211
|
+
};
|
|
212
|
+
if (!/^[a-zA-Z0-9_.-]+$/.test(fields.session) ||
|
|
213
|
+
!/^\d+$/.test(fields.sessionCreated) ||
|
|
214
|
+
!/^\$\d+$/.test(fields.sessionId) ||
|
|
215
|
+
!/^%\d+$/.test(fields.pane) ||
|
|
216
|
+
!/^\d+$/.test(fields.panePid)) {
|
|
217
|
+
throw new CliError(code, `Stored tmux ${operation} identity is incomplete or invalid`);
|
|
218
|
+
}
|
|
219
|
+
return fields;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function tmuxIdentityCondition(fields) {
|
|
223
|
+
return `#{&&:#{==:#{session_name},${fields.session}},#{&&:#{==:#{session_created},${fields.sessionCreated}},#{&&:#{==:#{session_id},${fields.sessionId}},#{&&:#{==:#{pane_id},${fields.pane}},#{==:#{pane_pid},${fields.panePid}}}}}}`;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export function conditionalTmuxKill(runTmux, stored, options = {}) {
|
|
227
|
+
if (typeof runTmux !== 'function') throw new TypeError('runTmux must be a function');
|
|
228
|
+
const fields = conditionalFields(stored);
|
|
229
|
+
if (typeof options.beforeConditional === 'function') options.beforeConditional();
|
|
230
|
+
const identityCondition = tmuxIdentityCondition(fields);
|
|
231
|
+
const processCondition = options.requireDeadPane
|
|
232
|
+
? `#{&&:${identityCondition},#{==:#{pane_dead},1}}`
|
|
233
|
+
: identityCondition;
|
|
234
|
+
const condition = options.allowClients
|
|
235
|
+
? processCondition
|
|
236
|
+
: `#{&&:${processCondition},#{==:#{session_attached},0}}`;
|
|
237
|
+
const successCommand = `display-message -p ${KILL_OK} ; kill-session -t '${fields.sessionId}'`;
|
|
238
|
+
let output = '';
|
|
239
|
+
try {
|
|
240
|
+
output = runTmux([
|
|
241
|
+
'if-shell', '-F', '-t', fields.session,
|
|
242
|
+
condition,
|
|
243
|
+
successCommand,
|
|
244
|
+
`display-message -p ${KILL_MISMATCH}`
|
|
245
|
+
]);
|
|
246
|
+
} catch (error) {
|
|
247
|
+
if (error instanceof CliError) throw error;
|
|
248
|
+
throw new CliError('TMUX_ERROR',
|
|
249
|
+
`Conditional tmux kill command failed: ${error?.message || error}`);
|
|
250
|
+
}
|
|
251
|
+
if (!String(output).split('\n').includes(KILL_OK)) {
|
|
252
|
+
throw new CliError('TMUX_CONDITIONAL_KILL_MISMATCH', 'Conditional tmux kill target changed before execution');
|
|
253
|
+
}
|
|
254
|
+
return { killed: true };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export function conditionalTmuxRename(runTmux, stored, newName, options = {}) {
|
|
258
|
+
if (typeof runTmux !== 'function') throw new TypeError('runTmux must be a function');
|
|
259
|
+
const fields = conditionalFields(stored, {
|
|
260
|
+
code: 'TMUX_CONDITIONAL_RENAME_MISMATCH',
|
|
261
|
+
operation: 'conditional-rename'
|
|
262
|
+
});
|
|
263
|
+
const targetName = String(newName || '');
|
|
264
|
+
if (!/^[a-zA-Z0-9_.-]+$/.test(targetName) || targetName === fields.session) {
|
|
265
|
+
throw new CliError('TMUX_CONDITIONAL_RENAME_MISMATCH', 'Replacement tmux session name is incomplete or invalid');
|
|
266
|
+
}
|
|
267
|
+
if (typeof options.beforeConditional === 'function') options.beforeConditional();
|
|
268
|
+
const identityCondition = tmuxIdentityCondition(fields);
|
|
269
|
+
const condition = options.allowClients
|
|
270
|
+
? identityCondition
|
|
271
|
+
: `#{&&:${identityCondition},#{==:#{session_attached},0}}`;
|
|
272
|
+
const successCommand = `display-message -p ${RENAME_OK} ; rename-session -t '${fields.sessionId}' ${targetName}`;
|
|
273
|
+
let output = '';
|
|
274
|
+
try {
|
|
275
|
+
output = runTmux([
|
|
276
|
+
'if-shell', '-F', '-t', fields.session,
|
|
277
|
+
condition,
|
|
278
|
+
successCommand,
|
|
279
|
+
`display-message -p ${RENAME_MISMATCH}`
|
|
280
|
+
]);
|
|
281
|
+
} catch (error) {
|
|
282
|
+
if (error instanceof CliError) throw error;
|
|
283
|
+
throw new CliError('TMUX_ERROR',
|
|
284
|
+
`Conditional tmux rename command failed: ${error?.message || error}`);
|
|
285
|
+
}
|
|
286
|
+
if (!String(output).split('\n').includes(RENAME_OK)) {
|
|
287
|
+
throw new CliError('TMUX_CONDITIONAL_RENAME_MISMATCH', 'Conditional tmux rename target changed before execution');
|
|
288
|
+
}
|
|
289
|
+
return { renamed: true, session: targetName };
|
|
290
|
+
}
|
|
@@ -14,6 +14,7 @@ const LAUNCH_ENV_IGNORED_KEYS = new Set([
|
|
|
14
14
|
'HCC_NO_AUTO_INSTALL_TMUX',
|
|
15
15
|
'HCC_PEER',
|
|
16
16
|
'HCC_ROOT',
|
|
17
|
+
'HCC_RUNTIME_LOCAL_ONLY',
|
|
17
18
|
'HCC_SKIP_SHIM_INSTALL',
|
|
18
19
|
PROVIDER_STATE_ENV,
|
|
19
20
|
'HCC_SHIM_ENSURED',
|
|
@@ -30,14 +31,20 @@ const LAUNCH_ENV_IGNORED_KEYS = new Set([
|
|
|
30
31
|
'SHLVL',
|
|
31
32
|
'TERM',
|
|
32
33
|
'TERMCAP',
|
|
34
|
+
'TERM_SESSION_ID',
|
|
35
|
+
'TMPDIR',
|
|
33
36
|
'TMUX',
|
|
34
37
|
'TMUX_PANE',
|
|
35
|
-
'WINDOWID'
|
|
38
|
+
'WINDOWID',
|
|
39
|
+
'XPC_FLAGS',
|
|
40
|
+
'XPC_SERVICE_NAME',
|
|
41
|
+
'SSH_AUTH_SOCK'
|
|
36
42
|
]);
|
|
37
43
|
|
|
38
44
|
export function childSessionEnv(extra = {}, baseEnv = process.env) {
|
|
39
45
|
const env = { ...(baseEnv || {}), ...extra };
|
|
40
46
|
delete env[WEB_CHILD_ENV];
|
|
47
|
+
delete env.HCC_RUNTIME_LOCAL_ONLY;
|
|
41
48
|
return env;
|
|
42
49
|
}
|
|
43
50
|
|
|
@@ -77,6 +84,13 @@ export function isRelaunchableProviderSession(kind, command, binding = {}) {
|
|
|
77
84
|
return ['claude', 'codex'].includes(provider) && isProviderFallbackWrapper(command);
|
|
78
85
|
}
|
|
79
86
|
|
|
87
|
+
export function providerRestartReason({ providerState, ownerEvidence, paneCommand } = {}) {
|
|
88
|
+
if (ownerEvidence?.state !== 'dead') return null;
|
|
89
|
+
if (providerState === 'exited') return 'provider_exited';
|
|
90
|
+
if (!providerState && isLikelyShellCommand(paneCommand)) return 'provider_fallback_shell';
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
80
94
|
export function isolatedEnvCommandArgs(env) {
|
|
81
95
|
const envBin = fs.existsSync('/usr/bin/env') ? '/usr/bin/env' : 'env';
|
|
82
96
|
const args = [envBin, '-i'];
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// Database connection helpers extracted from bin/hcc.mjs.
|
|
2
|
+
// Factory pattern: callers inject the functions that remain in bin/hcc.mjs
|
|
3
|
+
// (now, dedupePeerBindings, redactedLogText) while everything else is imported
|
|
4
|
+
// directly from lib/ modules.
|
|
5
|
+
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
9
|
+
import { CliError } from '../shared/errors.mjs';
|
|
10
|
+
import {
|
|
11
|
+
DB_SCHEMA_VERSION,
|
|
12
|
+
execWithBusyRetry
|
|
13
|
+
} from './schema.mjs';
|
|
14
|
+
import { initSchemaWithBackup } from './migration-backup.mjs';
|
|
15
|
+
import { readProjectRegistry } from '../runtime/projects.mjs';
|
|
16
|
+
import { resolveProjectDatabase } from '../runtime/project-path.mjs';
|
|
17
|
+
|
|
18
|
+
const MIGRATION_FAILURE_COOLDOWN_MS = 5 * 60 * 1000;
|
|
19
|
+
|
|
20
|
+
export function createConnectionHelpers({ now, dedupePeerBindings, redactedLogText }) {
|
|
21
|
+
let projectMigrationFanoutDepth = 0;
|
|
22
|
+
const migratedRegisteredProjectDbs = new Set();
|
|
23
|
+
|
|
24
|
+
function connect(ctx, options = {}) {
|
|
25
|
+
if (options.create === false && !fs.existsSync(ctx.dbPath)) {
|
|
26
|
+
throw new CliError('NOT_FOUND', `Project database does not exist: ${ctx.dbPath}`);
|
|
27
|
+
}
|
|
28
|
+
if (options.create !== false) fs.mkdirSync(path.dirname(ctx.dbPath), { recursive: true });
|
|
29
|
+
const db = new DatabaseSync(ctx.dbPath, { timeout: 5000 });
|
|
30
|
+
db.exec('PRAGMA busy_timeout = 5000;');
|
|
31
|
+
execWithBusyRetry(db, 'PRAGMA journal_mode = WAL;', { ignoreBusy: true });
|
|
32
|
+
db.exec('PRAGMA synchronous = NORMAL;');
|
|
33
|
+
db.exec('PRAGMA wal_autocheckpoint = 1000;');
|
|
34
|
+
db.exec('PRAGMA foreign_keys = ON;');
|
|
35
|
+
initSchemaWithBackup(db, ctx.dbPath, {
|
|
36
|
+
beforePostMigrationIndexes: dedupePeerBindings
|
|
37
|
+
});
|
|
38
|
+
if (options.migrateRegistered !== false) migrateRegisteredProjectDbs(ctx);
|
|
39
|
+
return db;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function connectReadOnly(ctx) {
|
|
43
|
+
if (!fs.existsSync(ctx.dbPath) || !fs.statSync(ctx.dbPath).isFile()) {
|
|
44
|
+
throw new CliError('NOT_FOUND', `Project database does not exist: ${ctx.dbPath}`);
|
|
45
|
+
}
|
|
46
|
+
return new DatabaseSync(ctx.dbPath, { timeout: 5000, readOnly: true });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function migrateRegisteredProjectDbs(ctx) {
|
|
50
|
+
if (projectMigrationFanoutDepth > 0) return;
|
|
51
|
+
projectMigrationFanoutDepth += 1;
|
|
52
|
+
try {
|
|
53
|
+
const currentDb = path.resolve(ctx.dbPath);
|
|
54
|
+
const seen = new Set([currentDb]);
|
|
55
|
+
for (const project of readProjectRegistry()) {
|
|
56
|
+
let resolved;
|
|
57
|
+
try {
|
|
58
|
+
resolved = resolveProjectDatabase({
|
|
59
|
+
root: project.root,
|
|
60
|
+
db: project.db || path.join(project.root, '.hello-cc', 'mesh.db'),
|
|
61
|
+
createStateDir: false
|
|
62
|
+
});
|
|
63
|
+
} catch (err) {
|
|
64
|
+
console.error(redactedLogText(`[${new Date().toISOString()}] skipping registered project DB migration for ${project.db || project.root}: ${err?.message || err}`));
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
const root = resolved.root;
|
|
68
|
+
const dbPath = resolved.db;
|
|
69
|
+
if (seen.has(dbPath)) continue;
|
|
70
|
+
seen.add(dbPath);
|
|
71
|
+
const cacheKey = `${dbPath}:${DB_SCHEMA_VERSION}`;
|
|
72
|
+
if (migratedRegisteredProjectDbs.has(cacheKey)) continue;
|
|
73
|
+
if (!fs.existsSync(root) || !fs.existsSync(dbPath)) continue;
|
|
74
|
+
const failedMarker = `${dbPath}.migration-failed`;
|
|
75
|
+
try {
|
|
76
|
+
if (Date.now() - fs.statSync(failedMarker).mtimeMs < MIGRATION_FAILURE_COOLDOWN_MS) continue;
|
|
77
|
+
} catch {}
|
|
78
|
+
let db = null;
|
|
79
|
+
try {
|
|
80
|
+
db = new DatabaseSync(dbPath, { timeout: 5000 });
|
|
81
|
+
db.exec('PRAGMA busy_timeout = 5000;');
|
|
82
|
+
execWithBusyRetry(db, 'PRAGMA journal_mode = WAL;', { ignoreBusy: true });
|
|
83
|
+
db.exec('PRAGMA synchronous = NORMAL;');
|
|
84
|
+
db.exec('PRAGMA foreign_keys = ON;');
|
|
85
|
+
initSchemaWithBackup(db, dbPath, {
|
|
86
|
+
beforePostMigrationIndexes: dedupePeerBindings
|
|
87
|
+
});
|
|
88
|
+
migratedRegisteredProjectDbs.add(cacheKey);
|
|
89
|
+
try { fs.rmSync(failedMarker, { force: true }); } catch {}
|
|
90
|
+
} catch (err) {
|
|
91
|
+
console.error(redactedLogText(`[${new Date().toISOString()}] skipping registered project DB migration for ${dbPath}: ${err?.message || err}`));
|
|
92
|
+
try { fs.writeFileSync(failedMarker, String(now())); } catch {}
|
|
93
|
+
continue;
|
|
94
|
+
} finally {
|
|
95
|
+
try { db?.close(); } catch {}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
} finally {
|
|
99
|
+
projectMigrationFanoutDepth -= 1;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return { connect, connectReadOnly, migrateRegisteredProjectDbs };
|
|
104
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Shared DB event/audit helpers extracted from bin/hcc.mjs.
|
|
2
|
+
// These are pure functions with a single dependency (now()) that callers
|
|
3
|
+
// inject, so they can be reused by CLI commands, web routes, and tests.
|
|
4
|
+
|
|
5
|
+
function defaultNow() {
|
|
6
|
+
return Math.floor(Date.now() / 1000);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function createEventHelpers({ now = defaultNow } = {}) {
|
|
10
|
+
function addEvent(db, type, actor, taskId, payload) {
|
|
11
|
+
db.prepare(`
|
|
12
|
+
INSERT INTO events(type, actor, task_id, payload, created_at)
|
|
13
|
+
VALUES (?, ?, ?, ?, ?)
|
|
14
|
+
`).run(type, actor || null, taskId || null, JSON.stringify(payload || {}), now());
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function auditPayload({ actor = null, target = null, source = 'cli', admin = false, ...extra } = {}) {
|
|
18
|
+
const payload = { ...extra, source };
|
|
19
|
+
if (actor) payload.actor_peer = actor;
|
|
20
|
+
if (target) payload.target_peer = target;
|
|
21
|
+
if (admin) payload.admin = true;
|
|
22
|
+
return payload;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function requestActorPeer(input = {}, fallback = 'web') {
|
|
26
|
+
return String(input.auditActorPeer || fallback || 'web').trim() || 'web';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function requestSource(input = {}, fallback = 'web') {
|
|
30
|
+
return String(input.auditSource || fallback || 'web').trim() || fallback;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return { addEvent, auditPayload, requestActorPeer, requestSource };
|
|
34
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
4
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
5
|
+
import {
|
|
6
|
+
DB_SCHEMA_VERSION,
|
|
7
|
+
initSchema,
|
|
8
|
+
readSchemaVersion
|
|
9
|
+
} from './schema.mjs';
|
|
10
|
+
import { withFileLock } from '../shared/file-lock.mjs';
|
|
11
|
+
|
|
12
|
+
const MAX_BACKUP_COMPONENT_BYTES = 240;
|
|
13
|
+
// v1-02: keep at most this many migration backups per database basename.
|
|
14
|
+
const MAX_BACKUPS_PER_DB = 5;
|
|
15
|
+
|
|
16
|
+
function quoteSqlString(value) {
|
|
17
|
+
return `'${String(value).replaceAll("'", "''")}'`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function defaultTimestamp() {
|
|
21
|
+
return new Date().toISOString().replace(/[-:.]/g, '');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function defaultSuffix() {
|
|
25
|
+
return randomBytes(4).toString('hex');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function backupFilename(dbBasename, fromVersion, toVersion, timestamp, suffix) {
|
|
29
|
+
return `${dbBasename}.pre-v${fromVersion}-to-v${toVersion}.${timestamp}.${suffix}.bak`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function boundedDbBasename(dbPath, fromVersion, toVersion, timestamp) {
|
|
33
|
+
const dbBasename = path.basename(dbPath);
|
|
34
|
+
const finalName = backupFilename(dbBasename, fromVersion, toVersion, timestamp, 'ffffffff');
|
|
35
|
+
const stagingName = `.${dbBasename}.migration-backup-XXXXXX`;
|
|
36
|
+
if (Buffer.byteLength(finalName) <= MAX_BACKUP_COMPONENT_BYTES &&
|
|
37
|
+
Buffer.byteLength(stagingName) <= MAX_BACKUP_COMPONENT_BYTES) {
|
|
38
|
+
return dbBasename;
|
|
39
|
+
}
|
|
40
|
+
const hash = createHash('sha256').update(dbBasename).digest('hex').slice(0, 16);
|
|
41
|
+
return `db-${hash}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function syncPath(target) {
|
|
45
|
+
const fd = fs.openSync(target, 'r');
|
|
46
|
+
let syncError = null;
|
|
47
|
+
try {
|
|
48
|
+
fs.fsyncSync(fd);
|
|
49
|
+
} catch (err) {
|
|
50
|
+
syncError = err;
|
|
51
|
+
}
|
|
52
|
+
try {
|
|
53
|
+
fs.closeSync(fd);
|
|
54
|
+
} catch (closeError) {
|
|
55
|
+
if (syncError) {
|
|
56
|
+
throw new AggregateError([syncError, closeError], `Failed to sync and close ${target}`);
|
|
57
|
+
}
|
|
58
|
+
throw closeError;
|
|
59
|
+
}
|
|
60
|
+
if (syncError) throw syncError;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function removeStagingDirectory(tempDirectory) {
|
|
64
|
+
let lastError;
|
|
65
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
66
|
+
try {
|
|
67
|
+
fs.rmSync(tempDirectory, { recursive: true, force: true });
|
|
68
|
+
return;
|
|
69
|
+
} catch (err) {
|
|
70
|
+
lastError = err;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
throw new Error(`Failed to remove migration backup staging directory: ${tempDirectory}`, {
|
|
74
|
+
cause: lastError
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function verifyBackup(backupPath) {
|
|
79
|
+
const backup = new DatabaseSync(backupPath, { readOnly: true });
|
|
80
|
+
try {
|
|
81
|
+
const rows = backup.prepare('PRAGMA quick_check').all();
|
|
82
|
+
if (rows.length !== 1 || rows[0]?.quick_check !== 'ok') {
|
|
83
|
+
throw new Error(`Migration backup failed SQLite quick_check: ${backupPath}`);
|
|
84
|
+
}
|
|
85
|
+
} finally {
|
|
86
|
+
backup.close();
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function initSchemaWithBackup(db, dbPath, {
|
|
91
|
+
beforePostMigrationIndexes = null,
|
|
92
|
+
ensureBackup = ensureMigrationBackup,
|
|
93
|
+
withLock = withFileLock
|
|
94
|
+
} = {}) {
|
|
95
|
+
const initialize = (lockedDbPath = dbPath) => initSchema(db, {
|
|
96
|
+
beforeMigration: ({ fromVersion, toVersion }) =>
|
|
97
|
+
ensureBackup(db, lockedDbPath, fromVersion, toVersion),
|
|
98
|
+
beforePostMigrationIndexes
|
|
99
|
+
});
|
|
100
|
+
if (readSchemaVersion(db) >= DB_SCHEMA_VERSION) return initialize();
|
|
101
|
+
return withLock(dbPath, (lockedDbPath) => initialize(lockedDbPath));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function ensureMigrationBackup(db, dbPath, fromVersion, toVersion, {
|
|
105
|
+
timestamp = defaultTimestamp,
|
|
106
|
+
suffix = defaultSuffix,
|
|
107
|
+
verifySnapshot = verifyBackup
|
|
108
|
+
} = {}) {
|
|
109
|
+
if (!Number.isInteger(fromVersion) || fromVersion < 0 ||
|
|
110
|
+
!Number.isInteger(toVersion) || toVersion <= fromVersion) {
|
|
111
|
+
throw new TypeError(`Invalid migration version range: ${fromVersion} to ${toVersion}`);
|
|
112
|
+
}
|
|
113
|
+
// v1-03: under a racing migration, this connection may already observe the
|
|
114
|
+
// migrated version (another process migrated between our stale version read
|
|
115
|
+
// and this call). Backing it up would mislabel an already-migrated DB as
|
|
116
|
+
// pre-v<from>-to-v<to>; skip the backup entirely in that case.
|
|
117
|
+
try {
|
|
118
|
+
const pragma = db.prepare('PRAGMA user_version').get();
|
|
119
|
+
const actualVersion = Number(pragma?.user_version || 0);
|
|
120
|
+
if (Number.isInteger(actualVersion) && actualVersion >= toVersion) return null;
|
|
121
|
+
} catch {
|
|
122
|
+
// Cannot read the version — proceed conservatively with the backup.
|
|
123
|
+
}
|
|
124
|
+
const timestampValue = timestamp();
|
|
125
|
+
if (!/^\d{8}T\d{9}Z$/.test(timestampValue)) {
|
|
126
|
+
throw new TypeError(`Invalid migration backup timestamp: ${timestampValue}`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const directory = path.dirname(path.resolve(dbPath));
|
|
130
|
+
const dbBasename = boundedDbBasename(dbPath, fromVersion, toVersion, timestampValue);
|
|
131
|
+
let tempDirectory;
|
|
132
|
+
let backupPath;
|
|
133
|
+
let operationError = null;
|
|
134
|
+
try {
|
|
135
|
+
tempDirectory = fs.mkdtempSync(path.join(directory, `.${dbBasename}.migration-backup-`));
|
|
136
|
+
fs.chmodSync(tempDirectory, 0o700);
|
|
137
|
+
const tempPath = path.join(tempDirectory, 'snapshot.db');
|
|
138
|
+
db.exec(`VACUUM INTO ${quoteSqlString(tempPath)};`);
|
|
139
|
+
fs.chmodSync(tempPath, 0o600);
|
|
140
|
+
syncPath(tempPath);
|
|
141
|
+
verifySnapshot(tempPath);
|
|
142
|
+
|
|
143
|
+
for (let attempt = 0; attempt < 128; attempt += 1) {
|
|
144
|
+
const suffixValue = suffix();
|
|
145
|
+
if (!/^[a-f0-9]{8}$/.test(suffixValue)) {
|
|
146
|
+
throw new TypeError(`Invalid migration backup suffix: ${suffixValue}`);
|
|
147
|
+
}
|
|
148
|
+
const candidate = path.join(
|
|
149
|
+
directory,
|
|
150
|
+
backupFilename(dbBasename, fromVersion, toVersion, timestampValue, suffixValue)
|
|
151
|
+
);
|
|
152
|
+
try {
|
|
153
|
+
// Linking publishes the already-verified inode exclusively. EEXIST is
|
|
154
|
+
// safe for regular files and symlinks: neither is followed or replaced.
|
|
155
|
+
fs.linkSync(tempPath, candidate);
|
|
156
|
+
backupPath = candidate;
|
|
157
|
+
break;
|
|
158
|
+
} catch (err) {
|
|
159
|
+
if (err?.code === 'EEXIST') continue;
|
|
160
|
+
throw err;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
if (!backupPath) throw new Error('Unable to allocate a unique migration backup path');
|
|
164
|
+
} catch (err) {
|
|
165
|
+
operationError = err;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
let cleanupError = null;
|
|
169
|
+
if (tempDirectory) {
|
|
170
|
+
try {
|
|
171
|
+
removeStagingDirectory(tempDirectory);
|
|
172
|
+
} catch (err) {
|
|
173
|
+
cleanupError = err;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
if (operationError && cleanupError) {
|
|
177
|
+
throw new AggregateError(
|
|
178
|
+
[operationError, cleanupError],
|
|
179
|
+
'Migration backup failed and staging cleanup also failed'
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
if (operationError) throw operationError;
|
|
183
|
+
if (cleanupError) throw cleanupError;
|
|
184
|
+
|
|
185
|
+
syncPath(directory);
|
|
186
|
+
rotateBackups(directory, dbBasename, backupPath);
|
|
187
|
+
return backupPath;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// v1-02: prune old migration backups for this database, keeping the most
|
|
191
|
+
// recent MAX_BACKUPS_PER_DB. Backups are full DB copies; without rotation a
|
|
192
|
+
// long-lived project accumulates them on every failed/racing migration attempt.
|
|
193
|
+
function rotateBackups(directory, dbBasename, publishedPath) {
|
|
194
|
+
const escapedBasename = dbBasename.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
195
|
+
const backupPattern = new RegExp(
|
|
196
|
+
`^${escapedBasename}\\.pre-v(\\d+)-to-v(\\d+)\\.(\\d{8}T\\d{9}Z)\\.([a-f0-9]{8})\\.bak$`
|
|
197
|
+
);
|
|
198
|
+
const publishedName = path.basename(publishedPath);
|
|
199
|
+
let entries = [];
|
|
200
|
+
try {
|
|
201
|
+
entries = fs.readdirSync(directory);
|
|
202
|
+
} catch {
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
const backups = entries
|
|
206
|
+
.map((name) => {
|
|
207
|
+
const match = name.match(backupPattern);
|
|
208
|
+
if (!match) return null;
|
|
209
|
+
try {
|
|
210
|
+
if (!fs.lstatSync(path.join(directory, name)).isFile()) return null;
|
|
211
|
+
return { name, timestamp: match[3] };
|
|
212
|
+
} catch {
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
})
|
|
216
|
+
.filter(Boolean)
|
|
217
|
+
.sort((a, b) => {
|
|
218
|
+
if (a.name === publishedName) return b.name === publishedName ? 0 : -1;
|
|
219
|
+
if (b.name === publishedName) return 1;
|
|
220
|
+
return b.timestamp.localeCompare(a.timestamp) || b.name.localeCompare(a.name);
|
|
221
|
+
});
|
|
222
|
+
for (const old of backups.slice(MAX_BACKUPS_PER_DB)) {
|
|
223
|
+
try { fs.rmSync(path.join(directory, old.name), { force: true }); } catch {}
|
|
224
|
+
}
|
|
225
|
+
}
|