total-recall-brain 3.18.0 → 3.18.1
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/bin/total-recall.mjs +2 -0
- package/package.json +1 -1
- package/src/cli/update.mjs +115 -0
- package/src/core/crons.mjs +14 -0
- package/src/core/daemon-loop.mjs +14 -0
- package/src/core/package-auto-update.mjs +401 -0
- package/src/server/routes/update.mjs +87 -21
package/bin/total-recall.mjs
CHANGED
|
@@ -51,6 +51,7 @@ const COMMANDS = {
|
|
|
51
51
|
daemon: 'daemon.mjs',
|
|
52
52
|
restore: 'restore.mjs',
|
|
53
53
|
upgrade: 'upgrade.mjs',
|
|
54
|
+
update: 'update.mjs',
|
|
54
55
|
friction: 'friction.mjs',
|
|
55
56
|
chat: 'chat.mjs',
|
|
56
57
|
status: 'status.mjs',
|
|
@@ -109,6 +110,7 @@ function printHelp() {
|
|
|
109
110
|
status / doctor Health and diagnostics
|
|
110
111
|
|
|
111
112
|
Optional:
|
|
113
|
+
update [--apply] Auto-download total-recall-brain for registered repos
|
|
112
114
|
research, share, ingest, import, export, relay, setup, deploy,
|
|
113
115
|
start, backup, restore, config, map, generate-pat, lint, sync, …
|
|
114
116
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* total-recall update — check / install total-recall-brain on registered projects.
|
|
3
|
+
*
|
|
4
|
+
* Usage:
|
|
5
|
+
* npx total-recall update # check only
|
|
6
|
+
* npx total-recall update --apply # install latest where needed
|
|
7
|
+
* npx total-recall update --apply --force
|
|
8
|
+
* npx total-recall update --repo /path/to/app
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
runPackageAutoUpdate,
|
|
13
|
+
fetchLatestNpmVersion,
|
|
14
|
+
isPackageAutoUpdateEnabled,
|
|
15
|
+
PACKAGE_NAME,
|
|
16
|
+
} from '../core/package-auto-update.mjs';
|
|
17
|
+
import { brainDir } from '../core/config.mjs';
|
|
18
|
+
|
|
19
|
+
function parseArgs(args) {
|
|
20
|
+
const opts = {
|
|
21
|
+
apply: false,
|
|
22
|
+
force: false,
|
|
23
|
+
dryRun: false,
|
|
24
|
+
help: false,
|
|
25
|
+
repos: [],
|
|
26
|
+
};
|
|
27
|
+
for (let i = 0; i < args.length; i++) {
|
|
28
|
+
const a = args[i];
|
|
29
|
+
if (a === '--help' || a === '-h') opts.help = true;
|
|
30
|
+
else if (a === '--apply' || a === '--install') opts.apply = true;
|
|
31
|
+
else if (a === '--force') opts.force = true;
|
|
32
|
+
else if (a === '--dry-run') opts.dryRun = true;
|
|
33
|
+
else if (a === '--repo' && args[i + 1]) opts.repos.push(args[++i]);
|
|
34
|
+
}
|
|
35
|
+
return opts;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function printHelp() {
|
|
39
|
+
console.log(`
|
|
40
|
+
total-recall update — Auto-download ${PACKAGE_NAME} for registered projects
|
|
41
|
+
|
|
42
|
+
Roots: project-registry + TR_SYNC_REPOS + --repo (never hardcodes product paths)
|
|
43
|
+
|
|
44
|
+
Usage:
|
|
45
|
+
total-recall update Report status (dry)
|
|
46
|
+
total-recall update --apply npm install latest where behind
|
|
47
|
+
total-recall update --apply --force Ignore throttle / disabled flag
|
|
48
|
+
total-recall update --repo <path> Include extra root (repeatable)
|
|
49
|
+
|
|
50
|
+
Env:
|
|
51
|
+
TR_AUTO_UPDATE_PACKAGE=0 Disable daemon cron auto-update (default: on)
|
|
52
|
+
TR_SYNC_REPOS=/a:/b Extra project roots
|
|
53
|
+
|
|
54
|
+
Source monorepos named ${PACKAGE_NAME} are skipped (use git, not npm into self).
|
|
55
|
+
`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export default async function update(args = []) {
|
|
59
|
+
const opts = parseArgs(args);
|
|
60
|
+
if (opts.help) {
|
|
61
|
+
printHelp();
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const latest = fetchLatestNpmVersion();
|
|
66
|
+
console.log(`\n Package: ${PACKAGE_NAME}`);
|
|
67
|
+
console.log(` Latest on npm: ${latest || '(unavailable)'}`);
|
|
68
|
+
console.log(` Auto-update (daemon): ${isPackageAutoUpdateEnabled() ? 'enabled' : 'disabled'}`);
|
|
69
|
+
|
|
70
|
+
const dryRun = !opts.apply || opts.dryRun;
|
|
71
|
+
const summary = await runPackageAutoUpdate({
|
|
72
|
+
brainDir,
|
|
73
|
+
roots: opts.repos.length ? opts.repos : undefined,
|
|
74
|
+
dryRun,
|
|
75
|
+
force: opts.force || opts.apply,
|
|
76
|
+
skipThrottle: opts.apply || opts.force,
|
|
77
|
+
save: true,
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
if (summary.skipped) {
|
|
81
|
+
console.log(`\n Skipped: ${summary.reason}`);
|
|
82
|
+
if (summary.next_check_in_ms) {
|
|
83
|
+
console.log(` Next check in ~${Math.ceil(summary.next_check_in_ms / 60000)} min`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const results = summary.results || [];
|
|
88
|
+
if (!results.length) {
|
|
89
|
+
console.log('\n No project roots found. Track repos with:');
|
|
90
|
+
console.log(' npx total-recall skill track /path/to/repo');
|
|
91
|
+
console.log(' # or set TR_SYNC_REPOS\n');
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
console.log(`\n Projects (${results.length}):`);
|
|
96
|
+
for (const r of results) {
|
|
97
|
+
const inst = r.installed || '—';
|
|
98
|
+
const mark =
|
|
99
|
+
r.status === 'updated' || r.status === 'would_update'
|
|
100
|
+
? '↑'
|
|
101
|
+
: r.status === 'up_to_date'
|
|
102
|
+
? '✓'
|
|
103
|
+
: r.status === 'failed'
|
|
104
|
+
? '✗'
|
|
105
|
+
: '·';
|
|
106
|
+
console.log(` ${mark} ${r.name}: ${inst} → ${r.latest || latest} [${r.status}]`);
|
|
107
|
+
if (r.error) console.log(` error: ${r.error}`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (dryRun && results.some((r) => r.status === 'would_update')) {
|
|
111
|
+
console.log('\n Run with --apply to install updates.\n');
|
|
112
|
+
} else {
|
|
113
|
+
console.log('');
|
|
114
|
+
}
|
|
115
|
+
}
|
package/src/core/crons.mjs
CHANGED
|
@@ -2,6 +2,7 @@ import { logger } from './logger.mjs';
|
|
|
2
2
|
import { runGitHubSync, getGitHubSyncStatus } from './github-sync.mjs';
|
|
3
3
|
import { watchObsidianDirectory } from './obsidian-sync.mjs';
|
|
4
4
|
import { getSecret } from './secrets-store.mjs';
|
|
5
|
+
import { runPackageAutoUpdate } from './package-auto-update.mjs';
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* runCrons — scheduled background jobs.
|
|
@@ -27,6 +28,19 @@ export async function runCrons(options) {
|
|
|
27
28
|
} catch {
|
|
28
29
|
// Obsidian not configured
|
|
29
30
|
}
|
|
31
|
+
|
|
32
|
+
// 3. npm package auto-update for registered projects (total-recall-brain)
|
|
33
|
+
try {
|
|
34
|
+
const pkg = await runPackageAutoUpdate({ brainDir });
|
|
35
|
+
if (!pkg.skipped && (pkg.updated > 0 || pkg.failed > 0)) {
|
|
36
|
+
logger.info({
|
|
37
|
+
subsystem: 'cron',
|
|
38
|
+
message: `package-auto-update: latest=${pkg.latest} updated=${pkg.updated} failed=${pkg.failed}`,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
} catch (err) {
|
|
42
|
+
logger.warn({ subsystem: 'cron', message: `package-auto-update failed: ${err.message}` });
|
|
43
|
+
}
|
|
30
44
|
} catch (err) {
|
|
31
45
|
logger.error({ subsystem: 'cron', message: `Cron execution failed: ${err.message}` });
|
|
32
46
|
}
|
package/src/core/daemon-loop.mjs
CHANGED
|
@@ -239,6 +239,20 @@ async function main() {
|
|
|
239
239
|
logger.info({ subsystem: 'daemon-loop', message: `Mesh node patch failed: ${err.message}` });
|
|
240
240
|
}
|
|
241
241
|
|
|
242
|
+
// npm total-recall-brain auto-update for registered projects (throttled thereafter via crons)
|
|
243
|
+
try {
|
|
244
|
+
const { runPackageAutoUpdate } = await import('./package-auto-update.mjs');
|
|
245
|
+
const pkg = await runPackageAutoUpdate({ brainDir: BRAIN_DIR, skipThrottle: true });
|
|
246
|
+
if (!pkg.skipped) {
|
|
247
|
+
logger.info({
|
|
248
|
+
subsystem: 'daemon-loop',
|
|
249
|
+
message: `Boot package-auto-update: latest=${pkg.latest} updated=${pkg.updated || 0} failed=${pkg.failed || 0}`,
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
} catch (err) {
|
|
253
|
+
logger.info({ subsystem: 'daemon-loop', message: `Boot package-auto-update failed: ${err.message}` });
|
|
254
|
+
}
|
|
255
|
+
|
|
242
256
|
// Deterministic election (lowest mesh IP) — tryAcquire/renew are no-op shims
|
|
243
257
|
// that re-evaluate isLeader(); no node-local lease documents are written.
|
|
244
258
|
const { tryAcquireLease, isLeader, getLeaderInfo, renewLease } = await import('./leader-election.mjs');
|
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auto-download total-recall-brain updates into registered project roots.
|
|
3
|
+
*
|
|
4
|
+
* Roots come only from:
|
|
5
|
+
* - global/project project-registry.json
|
|
6
|
+
* - TR_SYNC_REPOS env (path separators : or ,)
|
|
7
|
+
* - optional explicit roots[] (CLI / API)
|
|
8
|
+
*
|
|
9
|
+
* Never hardcodes product repo paths. Opt-out: TR_AUTO_UPDATE_PACKAGE=0
|
|
10
|
+
* Source monorepos (package name total-recall-brain with src/server) are skipped
|
|
11
|
+
* for npm install — they track git, not the published tarball.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import os from 'node:os';
|
|
17
|
+
import { spawnSync } from 'node:child_process';
|
|
18
|
+
import { logger } from './logger.mjs';
|
|
19
|
+
|
|
20
|
+
export const PACKAGE_NAME = 'total-recall-brain';
|
|
21
|
+
export const DEFAULT_CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000; // 6 hours
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @returns {boolean}
|
|
25
|
+
*/
|
|
26
|
+
export function isPackageAutoUpdateEnabled() {
|
|
27
|
+
const v = process.env.TR_AUTO_UPDATE_PACKAGE ?? process.env.TR_AUTO_UPDATE;
|
|
28
|
+
if (v === '0' || v === 'false' || v === 'off' || v === 'no') return false;
|
|
29
|
+
// Default on so consumer installs stay current once the daemon runs.
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @param {string} [brainDir]
|
|
35
|
+
* @returns {string}
|
|
36
|
+
*/
|
|
37
|
+
export function packageUpdateStatePath(brainDir) {
|
|
38
|
+
const base = brainDir || path.join(os.homedir(), '.agent', 'skills', 'total-recall');
|
|
39
|
+
return path.join(base, 'memory-derived', 'package-auto-update.json');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* @param {string} statePath
|
|
44
|
+
* @returns {{ last_check_at?: string, last_latest?: string, last_results?: object[] }}
|
|
45
|
+
*/
|
|
46
|
+
export function loadUpdateState(statePath) {
|
|
47
|
+
try {
|
|
48
|
+
if (!fs.existsSync(statePath)) return {};
|
|
49
|
+
return JSON.parse(fs.readFileSync(statePath, 'utf8')) || {};
|
|
50
|
+
} catch {
|
|
51
|
+
return {};
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* @param {string} statePath
|
|
57
|
+
* @param {object} state
|
|
58
|
+
*/
|
|
59
|
+
export function saveUpdateState(statePath, state) {
|
|
60
|
+
fs.mkdirSync(path.dirname(statePath), { recursive: true });
|
|
61
|
+
fs.writeFileSync(statePath, JSON.stringify(state, null, 2), 'utf8');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Resolve registry JSON paths (global brain + optional brainDir config).
|
|
66
|
+
* @param {{ brainDir?: string }} [opts]
|
|
67
|
+
* @returns {string[]}
|
|
68
|
+
*/
|
|
69
|
+
export function resolveRegistryFiles(opts = {}) {
|
|
70
|
+
const files = [];
|
|
71
|
+
const globalReg = path.join(os.homedir(), '.agent', 'skills', 'total-recall', 'config', 'project-registry.json');
|
|
72
|
+
if (fs.existsSync(globalReg)) files.push(globalReg);
|
|
73
|
+
if (opts.brainDir) {
|
|
74
|
+
const local = path.join(opts.brainDir, 'config', 'project-registry.json');
|
|
75
|
+
if (fs.existsSync(local) && local !== globalReg) files.push(local);
|
|
76
|
+
}
|
|
77
|
+
return files;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* @param {string} registryPath
|
|
82
|
+
* @returns {Array<{ name?: string, path?: string }>}
|
|
83
|
+
*/
|
|
84
|
+
export function readRegistryEntries(registryPath) {
|
|
85
|
+
try {
|
|
86
|
+
const raw = JSON.parse(fs.readFileSync(registryPath, 'utf8'));
|
|
87
|
+
return Array.isArray(raw) ? raw : [];
|
|
88
|
+
} catch {
|
|
89
|
+
return [];
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* @returns {string[]}
|
|
95
|
+
*/
|
|
96
|
+
export function rootsFromEnv() {
|
|
97
|
+
const raw = process.env.TR_SYNC_REPOS || '';
|
|
98
|
+
if (!raw.trim()) return [];
|
|
99
|
+
return raw
|
|
100
|
+
.split(/[:|,]/)
|
|
101
|
+
.map((s) => s.trim())
|
|
102
|
+
.filter(Boolean)
|
|
103
|
+
.map((p) => path.resolve(p));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Unique absolute project roots from registry + env + explicit.
|
|
108
|
+
* @param {{ brainDir?: string, roots?: string[] }} [opts]
|
|
109
|
+
* @returns {Array<{ root: string, name: string, source: string }>}
|
|
110
|
+
*/
|
|
111
|
+
export function listUpdateRoots(opts = {}) {
|
|
112
|
+
const byRoot = new Map();
|
|
113
|
+
|
|
114
|
+
function add(root, name, source) {
|
|
115
|
+
if (!root || typeof root !== 'string') return;
|
|
116
|
+
const abs = path.resolve(root);
|
|
117
|
+
if (!fs.existsSync(abs) || !fs.statSync(abs).isDirectory()) return;
|
|
118
|
+
if (!byRoot.has(abs)) {
|
|
119
|
+
byRoot.set(abs, { root: abs, name: name || path.basename(abs), source });
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
for (const file of resolveRegistryFiles(opts)) {
|
|
124
|
+
for (const entry of readRegistryEntries(file)) {
|
|
125
|
+
add(entry.path, entry.name, 'project-registry');
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
for (const r of rootsFromEnv()) {
|
|
129
|
+
add(r, path.basename(r), 'TR_SYNC_REPOS');
|
|
130
|
+
}
|
|
131
|
+
for (const r of opts.roots || []) {
|
|
132
|
+
add(r, path.basename(path.resolve(r)), 'explicit');
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return [...byRoot.values()];
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* @param {string} projectRoot
|
|
140
|
+
* @returns {string|null}
|
|
141
|
+
*/
|
|
142
|
+
export function readPackageJsonVersionField(projectRoot, field = 'version') {
|
|
143
|
+
const pkgPath = path.join(projectRoot, 'package.json');
|
|
144
|
+
if (!fs.existsSync(pkgPath)) return null;
|
|
145
|
+
try {
|
|
146
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
|
147
|
+
return pkg?.[field] ?? null;
|
|
148
|
+
} catch {
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* @param {string} projectRoot
|
|
155
|
+
* @returns {{ declared: string|null, installed: string|null, isSourceTree: boolean, packageName: string|null }}
|
|
156
|
+
*/
|
|
157
|
+
export function inspectProjectPackage(projectRoot) {
|
|
158
|
+
const pkgPath = path.join(projectRoot, 'package.json');
|
|
159
|
+
if (!fs.existsSync(pkgPath)) {
|
|
160
|
+
return { declared: null, installed: null, isSourceTree: false, packageName: null };
|
|
161
|
+
}
|
|
162
|
+
let pkg = {};
|
|
163
|
+
try {
|
|
164
|
+
pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')) || {};
|
|
165
|
+
} catch {
|
|
166
|
+
return { declared: null, installed: null, isSourceTree: false, packageName: null };
|
|
167
|
+
}
|
|
168
|
+
const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
|
|
169
|
+
const declared = deps[PACKAGE_NAME] || null;
|
|
170
|
+
let installed = null;
|
|
171
|
+
const nm = path.join(projectRoot, 'node_modules', PACKAGE_NAME, 'package.json');
|
|
172
|
+
if (fs.existsSync(nm)) {
|
|
173
|
+
try {
|
|
174
|
+
installed = JSON.parse(fs.readFileSync(nm, 'utf8')).version || null;
|
|
175
|
+
} catch {
|
|
176
|
+
installed = null;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
const isSourceTree =
|
|
180
|
+
pkg.name === PACKAGE_NAME &&
|
|
181
|
+
fs.existsSync(path.join(projectRoot, 'src', 'server', 'index.mjs'));
|
|
182
|
+
return { declared, installed, isSourceTree, packageName: pkg.name || null };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* @param {{ timeoutMs?: number }} [opts]
|
|
187
|
+
* @returns {string|null}
|
|
188
|
+
*/
|
|
189
|
+
export function fetchLatestNpmVersion(opts = {}) {
|
|
190
|
+
const timeout = opts.timeoutMs ?? 20_000;
|
|
191
|
+
const result = spawnSync('npm', ['view', PACKAGE_NAME, 'version'], {
|
|
192
|
+
encoding: 'utf8',
|
|
193
|
+
timeout,
|
|
194
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
195
|
+
});
|
|
196
|
+
if (!result || result.status !== 0) return null;
|
|
197
|
+
const v = (result.stdout || '').trim();
|
|
198
|
+
return v || null;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Semver-ish compare: true if installed is missing or older than latest.
|
|
203
|
+
* Handles simple x.y.z only (npm publish tags).
|
|
204
|
+
* @param {string|null} installed
|
|
205
|
+
* @param {string} latest
|
|
206
|
+
*/
|
|
207
|
+
export function needsUpdate(installed, latest) {
|
|
208
|
+
if (!latest) return false;
|
|
209
|
+
if (!installed) return true;
|
|
210
|
+
if (installed === latest) return false;
|
|
211
|
+
const a = installed.split('.').map((n) => parseInt(n, 10) || 0);
|
|
212
|
+
const b = latest.split('.').map((n) => parseInt(n, 10) || 0);
|
|
213
|
+
for (let i = 0; i < Math.max(a.length, b.length); i++) {
|
|
214
|
+
const x = a[i] || 0;
|
|
215
|
+
const y = b[i] || 0;
|
|
216
|
+
if (x < y) return true;
|
|
217
|
+
if (x > y) return false;
|
|
218
|
+
}
|
|
219
|
+
return false;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* @param {string} projectRoot
|
|
224
|
+
* @param {string} latest
|
|
225
|
+
* @param {{ dryRun?: boolean, save?: boolean }} [opts]
|
|
226
|
+
* @returns {{ ok: boolean, action: string, error?: string, code?: number|null }}
|
|
227
|
+
*/
|
|
228
|
+
export function installPackageInProject(projectRoot, latest, opts = {}) {
|
|
229
|
+
const dryRun = Boolean(opts.dryRun);
|
|
230
|
+
const save = opts.save !== false;
|
|
231
|
+
const spec = `${PACKAGE_NAME}@${latest}`;
|
|
232
|
+
if (dryRun) {
|
|
233
|
+
return { ok: true, action: `dry-run: npm install ${spec}${save ? ' --save' : ' --no-save'}` };
|
|
234
|
+
}
|
|
235
|
+
const args = ['install', spec];
|
|
236
|
+
if (save) args.push('--save');
|
|
237
|
+
else args.push('--no-save');
|
|
238
|
+
// Prefer clean installs in CI-like envs
|
|
239
|
+
if (process.env.CI === 'true') args.push('--no-fund', '--no-audit');
|
|
240
|
+
const result = spawnSync('npm', args, {
|
|
241
|
+
cwd: projectRoot,
|
|
242
|
+
encoding: 'utf8',
|
|
243
|
+
timeout: 10 * 60 * 1000,
|
|
244
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
245
|
+
});
|
|
246
|
+
if (result.status !== 0) {
|
|
247
|
+
const err = (result.stderr || result.stdout || 'npm install failed').trim().slice(0, 500);
|
|
248
|
+
return { ok: false, action: `npm install ${spec}`, error: err, code: result.status };
|
|
249
|
+
}
|
|
250
|
+
return { ok: true, action: `installed ${spec}`, code: 0 };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Scan roots and optionally install updates.
|
|
255
|
+
*
|
|
256
|
+
* @param {{
|
|
257
|
+
* brainDir?: string,
|
|
258
|
+
* roots?: string[],
|
|
259
|
+
* dryRun?: boolean,
|
|
260
|
+
* force?: boolean,
|
|
261
|
+
* skipThrottle?: boolean,
|
|
262
|
+
* intervalMs?: number,
|
|
263
|
+
* save?: boolean,
|
|
264
|
+
* }} [opts]
|
|
265
|
+
* @returns {Promise<object>}
|
|
266
|
+
*/
|
|
267
|
+
export async function runPackageAutoUpdate(opts = {}) {
|
|
268
|
+
const enabled = opts.force || isPackageAutoUpdateEnabled();
|
|
269
|
+
if (!enabled) {
|
|
270
|
+
return { skipped: true, reason: 'disabled', results: [] };
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const brainDir = opts.brainDir || path.join(os.homedir(), '.agent', 'skills', 'total-recall');
|
|
274
|
+
const statePath = packageUpdateStatePath(brainDir);
|
|
275
|
+
const state = loadUpdateState(statePath);
|
|
276
|
+
const intervalMs = opts.intervalMs ?? DEFAULT_CHECK_INTERVAL_MS;
|
|
277
|
+
|
|
278
|
+
if (!opts.skipThrottle && !opts.force && state.last_check_at) {
|
|
279
|
+
const elapsed = Date.now() - new Date(state.last_check_at).getTime();
|
|
280
|
+
if (Number.isFinite(elapsed) && elapsed < intervalMs) {
|
|
281
|
+
return {
|
|
282
|
+
skipped: true,
|
|
283
|
+
reason: 'throttled',
|
|
284
|
+
next_check_in_ms: intervalMs - elapsed,
|
|
285
|
+
last_check_at: state.last_check_at,
|
|
286
|
+
last_latest: state.last_latest,
|
|
287
|
+
results: state.last_results || [],
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const latest = fetchLatestNpmVersion();
|
|
293
|
+
if (!latest) {
|
|
294
|
+
logger.warn('package-auto-update', `Could not resolve npm version for ${PACKAGE_NAME}`);
|
|
295
|
+
return { skipped: true, reason: 'npm-view-failed', results: [] };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const roots = listUpdateRoots({ brainDir, roots: opts.roots });
|
|
299
|
+
const results = [];
|
|
300
|
+
|
|
301
|
+
for (const { root, name, source } of roots) {
|
|
302
|
+
const info = inspectProjectPackage(root);
|
|
303
|
+
if (info.isSourceTree) {
|
|
304
|
+
results.push({
|
|
305
|
+
name,
|
|
306
|
+
root,
|
|
307
|
+
source,
|
|
308
|
+
status: 'skipped_source_tree',
|
|
309
|
+
declared: info.declared,
|
|
310
|
+
installed: info.installed || readPackageJsonVersionField(root),
|
|
311
|
+
latest,
|
|
312
|
+
message: 'Source monorepo — use git pull / release tags, not npm tarball into self',
|
|
313
|
+
});
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
if (!info.declared && !info.installed) {
|
|
317
|
+
// Only touch projects that already depend on or install the package
|
|
318
|
+
results.push({
|
|
319
|
+
name,
|
|
320
|
+
root,
|
|
321
|
+
source,
|
|
322
|
+
status: 'skipped_no_dependency',
|
|
323
|
+
declared: null,
|
|
324
|
+
installed: null,
|
|
325
|
+
latest,
|
|
326
|
+
});
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const installed = info.installed;
|
|
331
|
+
if (!needsUpdate(installed, latest)) {
|
|
332
|
+
results.push({
|
|
333
|
+
name,
|
|
334
|
+
root,
|
|
335
|
+
source,
|
|
336
|
+
status: 'up_to_date',
|
|
337
|
+
declared: info.declared,
|
|
338
|
+
installed,
|
|
339
|
+
latest,
|
|
340
|
+
});
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const install = installPackageInProject(root, latest, {
|
|
345
|
+
dryRun: opts.dryRun,
|
|
346
|
+
save: opts.save !== false,
|
|
347
|
+
});
|
|
348
|
+
results.push({
|
|
349
|
+
name,
|
|
350
|
+
root,
|
|
351
|
+
source,
|
|
352
|
+
status: install.ok ? (opts.dryRun ? 'would_update' : 'updated') : 'failed',
|
|
353
|
+
declared: info.declared,
|
|
354
|
+
installed: install.ok && !opts.dryRun ? latest : installed,
|
|
355
|
+
previous: installed,
|
|
356
|
+
latest,
|
|
357
|
+
action: install.action,
|
|
358
|
+
error: install.error || null,
|
|
359
|
+
});
|
|
360
|
+
if (install.ok && !opts.dryRun) {
|
|
361
|
+
logger.info(
|
|
362
|
+
'package-auto-update',
|
|
363
|
+
`Updated ${name} → ${PACKAGE_NAME}@${latest} (${root})`,
|
|
364
|
+
);
|
|
365
|
+
} else if (!install.ok) {
|
|
366
|
+
logger.warn(
|
|
367
|
+
'package-auto-update',
|
|
368
|
+
`Failed updating ${name}: ${install.error || 'unknown'}`,
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const summary = {
|
|
374
|
+
skipped: false,
|
|
375
|
+
package: PACKAGE_NAME,
|
|
376
|
+
latest,
|
|
377
|
+
checked_at: new Date().toISOString(),
|
|
378
|
+
root_count: roots.length,
|
|
379
|
+
updated: results.filter((r) => r.status === 'updated').length,
|
|
380
|
+
would_update: results.filter((r) => r.status === 'would_update').length,
|
|
381
|
+
failed: results.filter((r) => r.status === 'failed').length,
|
|
382
|
+
up_to_date: results.filter((r) => r.status === 'up_to_date').length,
|
|
383
|
+
results,
|
|
384
|
+
};
|
|
385
|
+
|
|
386
|
+
if (!opts.dryRun) {
|
|
387
|
+
saveUpdateState(statePath, {
|
|
388
|
+
last_check_at: summary.checked_at,
|
|
389
|
+
last_latest: latest,
|
|
390
|
+
last_results: results.map((r) => ({
|
|
391
|
+
name: r.name,
|
|
392
|
+
root: r.root,
|
|
393
|
+
status: r.status,
|
|
394
|
+
installed: r.installed,
|
|
395
|
+
latest: r.latest,
|
|
396
|
+
})),
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
return summary;
|
|
401
|
+
}
|
|
@@ -1,39 +1,105 @@
|
|
|
1
1
|
import { Router } from 'express';
|
|
2
|
-
import { requireAuth } from '../auth.mjs';
|
|
2
|
+
import { requireAuth, requireScope } from '../auth.mjs';
|
|
3
3
|
import { spawn } from 'node:child_process';
|
|
4
|
+
import path from 'node:path';
|
|
4
5
|
import { ROOT, serverError } from './_shared.mjs';
|
|
6
|
+
import { brainDir as configBrainDir } from '../../core/config.mjs';
|
|
7
|
+
import {
|
|
8
|
+
PACKAGE_NAME,
|
|
9
|
+
fetchLatestNpmVersion,
|
|
10
|
+
inspectProjectPackage,
|
|
11
|
+
listUpdateRoots,
|
|
12
|
+
runPackageAutoUpdate,
|
|
13
|
+
needsUpdate,
|
|
14
|
+
isPackageAutoUpdateEnabled,
|
|
15
|
+
} from '../../core/package-auto-update.mjs';
|
|
5
16
|
|
|
6
17
|
const router = Router();
|
|
7
18
|
|
|
8
|
-
router.get('/api/update/check', requireAuth, async (
|
|
19
|
+
router.get('/api/update/check', requireAuth, async (_req, res) => {
|
|
9
20
|
try {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
21
|
+
// Host package (this install)
|
|
22
|
+
let current = null;
|
|
23
|
+
try {
|
|
24
|
+
const pkg = await import('../../../package.json', { with: { type: 'json' } });
|
|
25
|
+
current = pkg.default.version;
|
|
26
|
+
} catch {
|
|
27
|
+
current = null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const latest = fetchLatestNpmVersion() || '';
|
|
31
|
+
const roots = listUpdateRoots({ brainDir: configBrainDir });
|
|
32
|
+
const projects = roots.map(({ root, name, source }) => {
|
|
33
|
+
const info = inspectProjectPackage(root);
|
|
34
|
+
return {
|
|
35
|
+
name,
|
|
36
|
+
root,
|
|
37
|
+
source,
|
|
38
|
+
declared: info.declared,
|
|
39
|
+
installed: info.installed,
|
|
40
|
+
is_source_tree: info.isSourceTree,
|
|
41
|
+
update_available: !info.isSourceTree && needsUpdate(info.installed, latest),
|
|
42
|
+
};
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const consumersBehind = projects.filter((p) => p.update_available).length;
|
|
46
|
+
|
|
47
|
+
res.json({
|
|
48
|
+
package: PACKAGE_NAME,
|
|
49
|
+
current,
|
|
50
|
+
latest,
|
|
51
|
+
update_available: Boolean(latest && current && current !== latest) || consumersBehind > 0,
|
|
52
|
+
auto_update_enabled: isPackageAutoUpdateEnabled(),
|
|
53
|
+
projects,
|
|
54
|
+
consumers_behind: consumersBehind,
|
|
22
55
|
});
|
|
23
56
|
} catch (err) {
|
|
24
57
|
serverError(res, err);
|
|
25
58
|
}
|
|
26
59
|
});
|
|
27
60
|
|
|
28
|
-
|
|
61
|
+
/**
|
|
62
|
+
* POST /api/update/run
|
|
63
|
+
* Body optional: { dryRun?: boolean, force?: boolean, roots?: string[] }
|
|
64
|
+
* Runs multi-repo npm install for total-recall-brain@latest via package-auto-update.
|
|
65
|
+
*/
|
|
66
|
+
router.post('/api/update/run', requireAuth, requireScope('config:write'), async (req, res) => {
|
|
29
67
|
try {
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
68
|
+
const dryRun = Boolean(req.body?.dryRun);
|
|
69
|
+
const force = req.body?.force !== false;
|
|
70
|
+
const roots = Array.isArray(req.body?.roots) ? req.body.roots : undefined;
|
|
71
|
+
|
|
72
|
+
// Fire multi-project updater (awaits — install can take minutes; client should timeout high)
|
|
73
|
+
const summary = await runPackageAutoUpdate({
|
|
74
|
+
brainDir: configBrainDir,
|
|
75
|
+
roots,
|
|
76
|
+
dryRun,
|
|
77
|
+
force,
|
|
78
|
+
skipThrottle: true,
|
|
79
|
+
save: true,
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// Also kick a local install in ROOT when this host is a consumer (non-source)
|
|
83
|
+
const hostInfo = inspectProjectPackage(ROOT);
|
|
84
|
+
if (!hostInfo.isSourceTree && (hostInfo.declared || hostInfo.installed) && !dryRun) {
|
|
85
|
+
const latest = summary.latest || fetchLatestNpmVersion();
|
|
86
|
+
if (latest && needsUpdate(hostInfo.installed, latest)) {
|
|
87
|
+
const proc = spawn('npm', ['install', `${PACKAGE_NAME}@${latest}`, '--save'], {
|
|
88
|
+
cwd: ROOT,
|
|
89
|
+
detached: true,
|
|
90
|
+
stdio: 'ignore',
|
|
91
|
+
});
|
|
92
|
+
proc.unref();
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
res.json({
|
|
97
|
+
updating: !dryRun,
|
|
98
|
+
message: dryRun
|
|
99
|
+
? 'Dry-run complete — see projects list'
|
|
100
|
+
: 'Package auto-update finished for registered projects',
|
|
101
|
+
summary,
|
|
34
102
|
});
|
|
35
|
-
proc.unref();
|
|
36
|
-
res.json({ updating: true, message: 'Update started in background. Daemon will restart automatically.' });
|
|
37
103
|
} catch (err) {
|
|
38
104
|
serverError(res, err);
|
|
39
105
|
}
|