@tpsdev-ai/flair 0.51.2 → 0.52.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 +10 -5
- package/dist/build-info.json +3 -3
- package/dist/cli.js +575 -547
- package/dist/doctor-client.js +35 -0
- package/dist/hook-install.js +74 -0
- package/dist/install/global-bin-path.js +14 -0
- package/dist/lib/auth-resolve.js +15 -0
- package/dist/lib/doctor-run.js +28 -15
- package/dist/lib/upgrade-exec-path.js +257 -0
- package/dist/lib/upgrade-plain-tree.js +558 -0
- package/dist/rem/promote-policy.js +204 -0
- package/dist/rem/restore.js +55 -15
- package/dist/rem/runner.js +203 -20
- package/dist/resources/AdminMemory.js +2 -1
- package/dist/resources/AgentSeed.js +26 -10
- package/dist/resources/Asset.js +203 -0
- package/dist/resources/AutoPromoteCandidates.js +2 -4
- package/dist/resources/Credential.js +14 -0
- package/dist/resources/Federation.js +80 -0
- package/dist/resources/Integration.js +12 -0
- package/dist/resources/Memory.js +158 -60
- package/dist/resources/MemoryBootstrap.js +63 -20
- package/dist/resources/MemoryCandidate.js +12 -0
- package/dist/resources/MemoryConsolidate.js +2 -1
- package/dist/resources/MemoryDedupStats.js +17 -2
- package/dist/resources/MemoryFeed.js +30 -0
- package/dist/resources/MemoryGrant.js +14 -0
- package/dist/resources/MemoryReflect.js +75 -17
- package/dist/resources/Message.js +190 -0
- package/dist/resources/OrgEvent.js +12 -0
- package/dist/resources/PromoteMemoryCandidate.js +76 -0
- package/dist/resources/RecordUsage.js +1 -1
- package/dist/resources/Relationship.js +12 -0
- package/dist/resources/SemanticSearch.js +45 -13
- package/dist/resources/Soul.js +54 -18
- package/dist/resources/WorkspaceState.js +12 -0
- package/dist/resources/auth-middleware.js +17 -44
- package/dist/resources/authority-field-guard.js +37 -0
- package/dist/resources/bm25-index-service.js +1 -1
- package/dist/resources/bm25-index.js +50 -11
- package/dist/resources/embedding-space-guard.js +238 -0
- package/dist/resources/embeddings-provider.js +32 -5
- package/dist/resources/federation-classify.js +23 -1
- package/dist/resources/health.js +11 -2
- package/dist/resources/hit-tracking.js +244 -0
- package/dist/resources/mcp-tools.js +272 -7
- package/dist/resources/memory-reflect-lib.js +111 -0
- package/dist/resources/migrations/embedding-stamp.js +22 -4
- package/dist/resources/owner-field-guard.js +62 -0
- package/dist/resources/promotion-stamp.js +29 -0
- package/dist/resources/record-owner-guard.js +71 -5
- package/dist/resources/record-types.js +30 -7
- package/dist/resources/relay-lib.js +205 -0
- package/dist/resources/relay-ops.js +294 -0
- package/dist/resources/skill-write.js +120 -0
- package/dist/resources/soul-adk-guard.js +68 -0
- package/dist/resources/soul-write-policy.js +63 -0
- package/dist/resources/table-helpers.js +2 -0
- package/dist/resources/usage-recording.js +3 -3
- package/dist/src/rem/promote-policy.js +204 -0
- package/docs/api-reference.md +374 -0
- package/docs/auth.md +52 -0
- package/docs/federation.md +4 -0
- package/docs/integrations.md +6 -6
- package/docs/mcp-clients.md +16 -1
- package/docs/releasing.md +11 -8
- package/docs/rem.md +20 -2
- package/docs/upgrade.md +47 -2
- package/package.json +6 -5
- package/schemas/memory.graphql +51 -2
- package/schemas/message.graphql +74 -0
|
@@ -0,0 +1,558 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* upgrade-plain-tree.ts — flair#1109 (a)
|
|
3
|
+
*
|
|
4
|
+
* `flair upgrade` has a paved npm-global lane (`npm install -g`) and a Fabric
|
|
5
|
+
* `--target` lane. Production spokes that run a plain extracted package
|
|
6
|
+
* (npm pack + tar, `npm install --omit=dev`, systemd unit — no git checkout,
|
|
7
|
+
* no npm-global install) had neither. The serving tree was invisible; a stale
|
|
8
|
+
* npm-global relic was reported as "the" install.
|
|
9
|
+
*
|
|
10
|
+
* This module is the missing lane: detect that install shape, fetch the
|
|
11
|
+
* published tarball, swap the tree in place, keep operator launchers that
|
|
12
|
+
* are not in the pack, and restart the systemd unit that points at the tree.
|
|
13
|
+
*
|
|
14
|
+
* Detection and planning are pure / dependency-injected. The apply helpers
|
|
15
|
+
* take an `exec` hook so tests never hit the network or mutate a real prefix.
|
|
16
|
+
*
|
|
17
|
+
* (b) — the exec-path mismatch warning — already shipped in #1560
|
|
18
|
+
* (`upgrade-exec-path.ts`). This file does not change that wording.
|
|
19
|
+
*/
|
|
20
|
+
import { existsSync, mkdirSync, renameSync, rmSync, cpSync, readdirSync, readFileSync, statSync, lstatSync, } from "node:fs";
|
|
21
|
+
import { basename, dirname, join } from "node:path";
|
|
22
|
+
import { homedir } from "node:os";
|
|
23
|
+
import { execFileSync } from "node:child_process";
|
|
24
|
+
import { extract as tarExtract } from "tar";
|
|
25
|
+
import { canonicalPath, findFlairPackageDir, readFlairPackageAt, sameInstallPath, } from "./upgrade-exec-path.js";
|
|
26
|
+
export const FLAIR_PACKAGE = "@tpsdev-ai/flair";
|
|
27
|
+
/** Sibling suffix for the staged new tree (same filesystem as the live tree). */
|
|
28
|
+
export const UPGRADE_NEXT_SUFFIX = "upgrade-next";
|
|
29
|
+
/** Sibling suffix for the displaced live tree (rollback source). */
|
|
30
|
+
export const UPGRADE_PREV_SUFFIX = "upgrade-prev";
|
|
31
|
+
/** Sibling suffix for a failed new tree after rollback. */
|
|
32
|
+
export const UPGRADE_FAILED_SUFFIX = "upgrade-failed";
|
|
33
|
+
/**
|
|
34
|
+
* Root names shipped in the published `@tpsdev-ai/flair` tarball (package.json
|
|
35
|
+
* `files` plus `package.json` itself, plus `node_modules` which we reinstall).
|
|
36
|
+
* Anything else at the tree root is treated as an operator overlay — the
|
|
37
|
+
* launcher the issue asked us to preserve.
|
|
38
|
+
*/
|
|
39
|
+
export const PACKED_ROOT_NAMES = new Set([
|
|
40
|
+
"package.json",
|
|
41
|
+
"dist",
|
|
42
|
+
"schemas",
|
|
43
|
+
"templates",
|
|
44
|
+
"docs",
|
|
45
|
+
"config.yaml",
|
|
46
|
+
"LICENSE",
|
|
47
|
+
"README.md",
|
|
48
|
+
"SECURITY.md",
|
|
49
|
+
"node_modules",
|
|
50
|
+
]);
|
|
51
|
+
const DEFAULT_NPM_TIMEOUT_MS = 10 * 60 * 1000;
|
|
52
|
+
function hooksWithDefaults(h = {}) {
|
|
53
|
+
return {
|
|
54
|
+
exists: h.exists ?? existsSync,
|
|
55
|
+
read: h.read ?? ((p) => readFileSync(p, "utf-8")),
|
|
56
|
+
list: h.list ?? ((p) => readdirSync(p)),
|
|
57
|
+
isDir: h.isDir ?? ((p) => {
|
|
58
|
+
try {
|
|
59
|
+
return statSync(p).isDirectory();
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
}),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
/** Sibling of `treeDir` used for staging / previous / failed copies. */
|
|
68
|
+
export function treeSibling(treeDir, suffix) {
|
|
69
|
+
const canon = canonicalPath(treeDir);
|
|
70
|
+
return `${canon}.${suffix}`;
|
|
71
|
+
}
|
|
72
|
+
function pathExists(p, exists) {
|
|
73
|
+
try {
|
|
74
|
+
return exists(p);
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Classify a directory as a packed install, a git/source checkout, or not
|
|
82
|
+
* our package. Named checks, not hop counts:
|
|
83
|
+
*
|
|
84
|
+
* - `@tpsdev-ai/flair` package.json — otherwise this is not our tree
|
|
85
|
+
* - `.git` at the package root — a checkout; tarball-swap would overwrite it
|
|
86
|
+
* - `src/cli.ts` without `.git` — a copied source tree, same refusal
|
|
87
|
+
* - `dist/cli.js` and none of the above — the packed shape the issue named
|
|
88
|
+
*/
|
|
89
|
+
export function inspectFlairTree(dir, fs = {}) {
|
|
90
|
+
const h = hooksWithDefaults(fs);
|
|
91
|
+
const loc = readFlairPackageAt(dir);
|
|
92
|
+
const canon = loc?.dir ?? canonicalPath(dir);
|
|
93
|
+
if (!loc)
|
|
94
|
+
return { kind: "not-flair", dir: canon, version: null };
|
|
95
|
+
const version = loc.version;
|
|
96
|
+
if (pathExists(join(canon, ".git"), h.exists)) {
|
|
97
|
+
return { kind: "git-checkout", dir: canon, version };
|
|
98
|
+
}
|
|
99
|
+
if (pathExists(join(canon, "src", "cli.ts"), h.exists)) {
|
|
100
|
+
return { kind: "source-tree", dir: canon, version };
|
|
101
|
+
}
|
|
102
|
+
if (!pathExists(join(canon, "dist", "cli.js"), h.exists)) {
|
|
103
|
+
return { kind: "not-flair", dir: canon, version };
|
|
104
|
+
}
|
|
105
|
+
return { kind: "plain-tree", dir: canon, version };
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Decide whether `flair upgrade` should take the plain-tree lane.
|
|
109
|
+
*
|
|
110
|
+
* `--tree` is an explicit operator pin and refuses (never silently falls
|
|
111
|
+
* through) when the path is not a packed install. Without the flag, the
|
|
112
|
+
* serving instance's package dir wins, then this CLI's package dir when it
|
|
113
|
+
* is itself a packed tree and is not the npm-global install.
|
|
114
|
+
*/
|
|
115
|
+
export function resolvePlainTreeTarget(input) {
|
|
116
|
+
const inspect = input.inspect ?? ((d) => inspectFlairTree(d));
|
|
117
|
+
if (input.treeFlag && input.treeFlag.trim() !== "") {
|
|
118
|
+
const inspection = inspect(input.treeFlag.trim());
|
|
119
|
+
if (input.global && sameInstallPath(inspection.dir, input.global.dir)) {
|
|
120
|
+
return {
|
|
121
|
+
kind: "refuse",
|
|
122
|
+
message: `--tree ${inspection.dir} is the npm-global install. Omit --tree to use the npm-global lane.`,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
if (inspection.kind === "plain-tree")
|
|
126
|
+
return { kind: "use", inspection };
|
|
127
|
+
if (inspection.kind === "git-checkout") {
|
|
128
|
+
return {
|
|
129
|
+
kind: "refuse",
|
|
130
|
+
message: `--tree ${inspection.dir} is a git checkout. The tarball-swap lane would overwrite it.`,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
if (inspection.kind === "source-tree") {
|
|
134
|
+
return {
|
|
135
|
+
kind: "refuse",
|
|
136
|
+
message: `--tree ${inspection.dir} looks like a source tree (src/cli.ts is present). The tarball-swap lane is for npm-pack extracts only.`,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
return {
|
|
140
|
+
kind: "refuse",
|
|
141
|
+
message: `--tree ${input.treeFlag} is not a packed @tpsdev-ai/flair install (need package.json + dist/cli.js, no .git).`,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
const candidates = [];
|
|
145
|
+
if (input.serving)
|
|
146
|
+
candidates.push(input.serving);
|
|
147
|
+
if (input.cli && !candidates.some((c) => sameInstallPath(c.dir, input.cli.dir))) {
|
|
148
|
+
candidates.push(input.cli);
|
|
149
|
+
}
|
|
150
|
+
for (const loc of candidates) {
|
|
151
|
+
if (input.global && sameInstallPath(loc.dir, input.global.dir))
|
|
152
|
+
continue;
|
|
153
|
+
const inspection = inspect(loc.dir);
|
|
154
|
+
if (inspection.kind === "plain-tree")
|
|
155
|
+
return { kind: "use", inspection };
|
|
156
|
+
}
|
|
157
|
+
return { kind: "skip" };
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Root-level names in the live tree that are not part of the published
|
|
161
|
+
* pack. Those are the operator launcher / overlay the swap must copy onto
|
|
162
|
+
* the new tree.
|
|
163
|
+
*/
|
|
164
|
+
export function listPreservedLauncherNames(treeDir, fs = {}) {
|
|
165
|
+
const h = hooksWithDefaults(fs);
|
|
166
|
+
if (!h.isDir(treeDir))
|
|
167
|
+
return [];
|
|
168
|
+
let names;
|
|
169
|
+
try {
|
|
170
|
+
names = h.list(treeDir);
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
return [];
|
|
174
|
+
}
|
|
175
|
+
return names
|
|
176
|
+
.filter((n) => n !== "." && n !== "..")
|
|
177
|
+
.filter((n) => !PACKED_ROOT_NAMES.has(n))
|
|
178
|
+
.filter((n) => !n.startsWith(".upgrade-"))
|
|
179
|
+
.sort();
|
|
180
|
+
}
|
|
181
|
+
export function planPlainTreeUpgrade(input) {
|
|
182
|
+
const treeDir = canonicalPath(input.treeDir);
|
|
183
|
+
return {
|
|
184
|
+
treeDir,
|
|
185
|
+
fromVersion: input.fromVersion,
|
|
186
|
+
toVersion: input.toVersion,
|
|
187
|
+
stagingDir: treeSibling(treeDir, UPGRADE_NEXT_SUFFIX),
|
|
188
|
+
previousDir: treeSibling(treeDir, UPGRADE_PREV_SUFFIX),
|
|
189
|
+
preserve: input.preserve ?? listPreservedLauncherNames(treeDir),
|
|
190
|
+
systemdUnits: input.systemdUnits ?? [],
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
export function formatPlainTreeBanner(inspection) {
|
|
194
|
+
const ver = inspection.version ? ` (${inspection.version})` : "";
|
|
195
|
+
return [
|
|
196
|
+
`Plain-tree install: ${inspection.dir}${ver}`,
|
|
197
|
+
" `flair upgrade` will fetch the published tarball, swap this tree in place,",
|
|
198
|
+
" keep operator launchers that are not in the pack, and restart the unit.",
|
|
199
|
+
].join("\n");
|
|
200
|
+
}
|
|
201
|
+
export function formatPlainTreeScopeFooter(inspection) {
|
|
202
|
+
return (`Scope: plain-tree at ${inspection.dir} (fetch tarball, swap, preserve launcher, restart unit). ` +
|
|
203
|
+
"npm-global packages are not this instance's install.");
|
|
204
|
+
}
|
|
205
|
+
export function formatPlainTreePlan(plan) {
|
|
206
|
+
const from = plan.fromVersion ?? "unknown";
|
|
207
|
+
const lines = [
|
|
208
|
+
`Plain-tree plan: ${plan.treeDir}`,
|
|
209
|
+
` ${FLAIR_PACKAGE}: ${from} → ${plan.toVersion} (in-place tarball swap)`,
|
|
210
|
+
` staging: ${plan.stagingDir}`,
|
|
211
|
+
` previous: ${plan.previousDir} (rollback source until verify succeeds)`,
|
|
212
|
+
];
|
|
213
|
+
if (plan.preserve.length > 0) {
|
|
214
|
+
lines.push(` preserve launcher/overlay: ${plan.preserve.join(", ")}`);
|
|
215
|
+
}
|
|
216
|
+
else {
|
|
217
|
+
lines.push(" preserve launcher/overlay: (none — tree root matches the published pack)");
|
|
218
|
+
}
|
|
219
|
+
if (plan.systemdUnits.length > 0) {
|
|
220
|
+
lines.push(` restart unit: ${plan.systemdUnits.map(formatUnitRef).join(", ")}`);
|
|
221
|
+
}
|
|
222
|
+
else {
|
|
223
|
+
lines.push(" restart unit: no systemd unit found for this tree — will use `flair restart`");
|
|
224
|
+
}
|
|
225
|
+
return lines.join("\n");
|
|
226
|
+
}
|
|
227
|
+
function formatUnitRef(u) {
|
|
228
|
+
return `${u.name} (${u.scope}: ${u.path})`;
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* After a directory path, the next character must not continue the last
|
|
232
|
+
* path segment. `/opt/flair` matches `WorkingDirectory=/opt/flair`,
|
|
233
|
+
* `/opt/flair/`, and `/opt/flair/dist/cli.js` — not `/opt/flair-spoke`.
|
|
234
|
+
*
|
|
235
|
+
* String scan, not `new RegExp(variable)` — Semgrep
|
|
236
|
+
* `detect-non-literal-regexp` (same reason `readHarperConfig` parses YAML
|
|
237
|
+
* instead of interpolating a key into a regex).
|
|
238
|
+
*/
|
|
239
|
+
function isDirPathBoundary(next) {
|
|
240
|
+
if (next === undefined)
|
|
241
|
+
return true;
|
|
242
|
+
switch (next) {
|
|
243
|
+
case "/":
|
|
244
|
+
case "\\":
|
|
245
|
+
case "\"":
|
|
246
|
+
case "'":
|
|
247
|
+
case "=":
|
|
248
|
+
case ":":
|
|
249
|
+
case " ":
|
|
250
|
+
case "\t":
|
|
251
|
+
case "\n":
|
|
252
|
+
case "\r":
|
|
253
|
+
case "\f":
|
|
254
|
+
case "\v":
|
|
255
|
+
return true;
|
|
256
|
+
default:
|
|
257
|
+
return false;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
function textMentionsDir(text, dir) {
|
|
261
|
+
let from = 0;
|
|
262
|
+
while (from <= text.length) {
|
|
263
|
+
const i = text.indexOf(dir, from);
|
|
264
|
+
if (i < 0)
|
|
265
|
+
return false;
|
|
266
|
+
if (isDirPathBoundary(text[i + dir.length]))
|
|
267
|
+
return true;
|
|
268
|
+
from = i + 1;
|
|
269
|
+
}
|
|
270
|
+
return false;
|
|
271
|
+
}
|
|
272
|
+
export function unitTextMentionsTree(unitText, treeDir) {
|
|
273
|
+
const variants = new Set();
|
|
274
|
+
const add = (p) => {
|
|
275
|
+
const trimmed = p.replace(/\/+$/, "");
|
|
276
|
+
if (trimmed.length > 1)
|
|
277
|
+
variants.add(trimmed);
|
|
278
|
+
};
|
|
279
|
+
add(treeDir);
|
|
280
|
+
try {
|
|
281
|
+
add(canonicalPath(treeDir));
|
|
282
|
+
}
|
|
283
|
+
catch { /* lexical variants are enough */ }
|
|
284
|
+
return [...variants].some((v) => textMentionsDir(unitText, v));
|
|
285
|
+
}
|
|
286
|
+
const SYSTEM_UNIT_DIRS = ["/etc/systemd/system"];
|
|
287
|
+
export function userSystemdDir(home = homedir()) {
|
|
288
|
+
return join(home, ".config", "systemd", "user");
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* Find systemd unit files that name this tree in WorkingDirectory or
|
|
292
|
+
* ExecStart. Looks at `/etc/systemd/system` and the user unit dir — not
|
|
293
|
+
* `/lib/systemd/system` (distro packages).
|
|
294
|
+
*
|
|
295
|
+
* `FLAIR_SYSTEMD_UNIT` (comma-separated `name` or `scope:name`) adds
|
|
296
|
+
* explicit units even when the file does not mention the tree path.
|
|
297
|
+
*/
|
|
298
|
+
export function findSystemdUnitsForTree(treeDir, opts = {}) {
|
|
299
|
+
const h = hooksWithDefaults(opts.fs);
|
|
300
|
+
const found = [];
|
|
301
|
+
const seen = new Set();
|
|
302
|
+
const add = (u) => {
|
|
303
|
+
const key = `${u.scope}:${u.path}`;
|
|
304
|
+
if (seen.has(key))
|
|
305
|
+
return;
|
|
306
|
+
seen.add(key);
|
|
307
|
+
found.push(u);
|
|
308
|
+
};
|
|
309
|
+
const scan = (dir, scope) => {
|
|
310
|
+
if (!h.isDir(dir))
|
|
311
|
+
return;
|
|
312
|
+
let names;
|
|
313
|
+
try {
|
|
314
|
+
names = h.list(dir);
|
|
315
|
+
}
|
|
316
|
+
catch {
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
for (const name of names) {
|
|
320
|
+
if (!name.endsWith(".service"))
|
|
321
|
+
continue;
|
|
322
|
+
const path = join(dir, name);
|
|
323
|
+
let text;
|
|
324
|
+
try {
|
|
325
|
+
text = h.read(path);
|
|
326
|
+
}
|
|
327
|
+
catch {
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
if (unitTextMentionsTree(text, treeDir))
|
|
331
|
+
add({ name, path, scope });
|
|
332
|
+
}
|
|
333
|
+
};
|
|
334
|
+
for (const dir of opts.systemDirs ?? SYSTEM_UNIT_DIRS)
|
|
335
|
+
scan(dir, "system");
|
|
336
|
+
scan(opts.userDir ?? userSystemdDir(opts.home), "user");
|
|
337
|
+
const envRaw = opts.envUnit ?? process.env.FLAIR_SYSTEMD_UNIT ?? "";
|
|
338
|
+
for (const token of envRaw.split(",").map((s) => s.trim()).filter(Boolean)) {
|
|
339
|
+
const scoped = token.match(/^(system|user):(.+)$/);
|
|
340
|
+
const scope = (scoped?.[1] ?? null);
|
|
341
|
+
const name = (scoped?.[2] ?? token).replace(/\.service$/i, "") + ".service";
|
|
342
|
+
const candidates = [];
|
|
343
|
+
if (scope === "system" || scope === null) {
|
|
344
|
+
for (const dir of opts.systemDirs ?? SYSTEM_UNIT_DIRS)
|
|
345
|
+
candidates.push({ scope: "system", dir });
|
|
346
|
+
}
|
|
347
|
+
if (scope === "user" || scope === null) {
|
|
348
|
+
candidates.push({ scope: "user", dir: opts.userDir ?? userSystemdDir(opts.home) });
|
|
349
|
+
}
|
|
350
|
+
for (const c of candidates) {
|
|
351
|
+
const path = join(c.dir, name);
|
|
352
|
+
if (h.exists(path))
|
|
353
|
+
add({ name, path, scope: c.scope });
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
return found;
|
|
357
|
+
}
|
|
358
|
+
export function systemdRestartArgs(unit) {
|
|
359
|
+
return unit.scope === "user"
|
|
360
|
+
? ["--user", "restart", unit.name]
|
|
361
|
+
: ["restart", unit.name];
|
|
362
|
+
}
|
|
363
|
+
export function restartSystemdUnits(units, exec = execFileSync) {
|
|
364
|
+
for (const unit of units) {
|
|
365
|
+
exec("systemctl", systemdRestartArgs(unit), {
|
|
366
|
+
encoding: "utf-8",
|
|
367
|
+
timeout: 120_000,
|
|
368
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
function copyPreserved(fromTree, toTree, names) {
|
|
373
|
+
for (const name of names) {
|
|
374
|
+
const src = join(fromTree, name);
|
|
375
|
+
const dest = join(toTree, name);
|
|
376
|
+
try {
|
|
377
|
+
if (!existsSync(src))
|
|
378
|
+
continue;
|
|
379
|
+
cpSync(src, dest, { recursive: true, dereference: false, force: true });
|
|
380
|
+
}
|
|
381
|
+
catch (err) {
|
|
382
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
383
|
+
throw new Error(`failed to preserve launcher/overlay '${name}': ${msg}`);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
function npmPackFilename(stdout, spec) {
|
|
388
|
+
const lines = stdout.trim().split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
|
389
|
+
const tgz = [...lines].reverse().find((l) => l.endsWith(".tgz"));
|
|
390
|
+
if (tgz)
|
|
391
|
+
return basename(tgz);
|
|
392
|
+
throw new Error(`npm pack ${spec} did not print a .tgz filename`);
|
|
393
|
+
}
|
|
394
|
+
async function defaultExtract(tgz, dest) {
|
|
395
|
+
mkdirSync(dest, { recursive: true });
|
|
396
|
+
await tarExtract({ file: tgz, cwd: dest, strip: 1 });
|
|
397
|
+
}
|
|
398
|
+
/**
|
|
399
|
+
* Fetch `@tpsdev-ai/flair@toVersion` via `npm pack`, extract it next to the
|
|
400
|
+
* live tree, `npm install --omit=dev`, copy preserved launchers, then
|
|
401
|
+
* rename-swap (`tree → .upgrade-prev`, `staging → tree`).
|
|
402
|
+
*
|
|
403
|
+
* Staging as a sibling keeps the rename on one filesystem (rename fails
|
|
404
|
+
* across devices). Rollback is `restorePlainTreePrevious`.
|
|
405
|
+
*/
|
|
406
|
+
export async function applyPlainTreeUpgrade(plan, hooks = {}) {
|
|
407
|
+
const exec = hooks.exec ?? execFileSync;
|
|
408
|
+
const extract = hooks.extract ?? defaultExtract;
|
|
409
|
+
const packTimeout = hooks.packTimeoutMs ?? DEFAULT_NPM_TIMEOUT_MS;
|
|
410
|
+
const installTimeout = hooks.installTimeoutMs ?? DEFAULT_NPM_TIMEOUT_MS;
|
|
411
|
+
const parent = dirname(plan.stagingDir);
|
|
412
|
+
mkdirSync(parent, { recursive: true });
|
|
413
|
+
if (existsSync(plan.stagingDir)) {
|
|
414
|
+
rmSync(plan.stagingDir, { recursive: true, force: true });
|
|
415
|
+
}
|
|
416
|
+
mkdirSync(plan.stagingDir, { recursive: true });
|
|
417
|
+
const spec = `${FLAIR_PACKAGE}@${plan.toVersion}`;
|
|
418
|
+
const packOut = String(exec("npm", ["pack", spec, "--pack-destination", parent], {
|
|
419
|
+
encoding: "utf-8",
|
|
420
|
+
timeout: packTimeout,
|
|
421
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
422
|
+
}));
|
|
423
|
+
const tgzName = npmPackFilename(packOut, spec);
|
|
424
|
+
const tgzPath = join(parent, tgzName);
|
|
425
|
+
if (!existsSync(tgzPath)) {
|
|
426
|
+
throw new Error(`npm pack ${spec} reported ${tgzName} but ${tgzPath} is missing`);
|
|
427
|
+
}
|
|
428
|
+
try {
|
|
429
|
+
await extract(tgzPath, plan.stagingDir);
|
|
430
|
+
const staged = readFlairPackageAt(plan.stagingDir);
|
|
431
|
+
if (!staged) {
|
|
432
|
+
throw new Error(`extracted tarball at ${plan.stagingDir} is not ${FLAIR_PACKAGE}`);
|
|
433
|
+
}
|
|
434
|
+
if (staged.version && staged.version !== plan.toVersion) {
|
|
435
|
+
throw new Error(`extracted tarball at ${plan.stagingDir} is ${staged.version}, expected ${plan.toVersion}`);
|
|
436
|
+
}
|
|
437
|
+
exec("npm", ["install", "--omit=dev", "--no-audit", "--no-fund"], {
|
|
438
|
+
encoding: "utf-8",
|
|
439
|
+
timeout: installTimeout,
|
|
440
|
+
cwd: plan.stagingDir,
|
|
441
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
442
|
+
});
|
|
443
|
+
copyPreserved(plan.treeDir, plan.stagingDir, plan.preserve);
|
|
444
|
+
if (existsSync(plan.previousDir)) {
|
|
445
|
+
rmSync(plan.previousDir, { recursive: true, force: true });
|
|
446
|
+
}
|
|
447
|
+
try {
|
|
448
|
+
renameSync(plan.treeDir, plan.previousDir);
|
|
449
|
+
}
|
|
450
|
+
catch (err) {
|
|
451
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
452
|
+
throw new Error(`could not move live tree aside (${plan.treeDir} → ${plan.previousDir}): ${msg}`);
|
|
453
|
+
}
|
|
454
|
+
try {
|
|
455
|
+
renameSync(plan.stagingDir, plan.treeDir);
|
|
456
|
+
}
|
|
457
|
+
catch (err) {
|
|
458
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
459
|
+
try {
|
|
460
|
+
if (existsSync(plan.previousDir) && !existsSync(plan.treeDir)) {
|
|
461
|
+
renameSync(plan.previousDir, plan.treeDir);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
catch { /* restore-best-effort; surface the original error */ }
|
|
465
|
+
throw new Error(`could not move staged tree into place (${plan.stagingDir} → ${plan.treeDir}): ${msg}`);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
finally {
|
|
469
|
+
try {
|
|
470
|
+
if (existsSync(tgzPath))
|
|
471
|
+
rmSync(tgzPath, { force: true });
|
|
472
|
+
}
|
|
473
|
+
catch { /* leftover tgz is harmless */ }
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
/**
|
|
477
|
+
* Put `.upgrade-prev` back at the live tree path. Used by upgrade rollback
|
|
478
|
+
* when restart or verify fails after a swap.
|
|
479
|
+
*
|
|
480
|
+
* Returns whether a previous tree was restored. `false` means there was
|
|
481
|
+
* nothing to restore (swap never completed) — the caller must say so.
|
|
482
|
+
*/
|
|
483
|
+
export function restorePlainTreePrevious(plan) {
|
|
484
|
+
const treeDir = canonicalPath(plan.treeDir);
|
|
485
|
+
const previousDir = plan.previousDir;
|
|
486
|
+
if (!existsSync(previousDir))
|
|
487
|
+
return false;
|
|
488
|
+
const failedDir = treeSibling(treeDir, UPGRADE_FAILED_SUFFIX);
|
|
489
|
+
if (existsSync(treeDir)) {
|
|
490
|
+
if (existsSync(failedDir))
|
|
491
|
+
rmSync(failedDir, { recursive: true, force: true });
|
|
492
|
+
renameSync(treeDir, failedDir);
|
|
493
|
+
}
|
|
494
|
+
renameSync(previousDir, treeDir);
|
|
495
|
+
return true;
|
|
496
|
+
}
|
|
497
|
+
/** Drop the displaced previous tree after a successful verify (saves a second copy). */
|
|
498
|
+
export function discardPlainTreePrevious(previousDir) {
|
|
499
|
+
if (existsSync(previousDir)) {
|
|
500
|
+
rmSync(previousDir, { recursive: true, force: true });
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
/**
|
|
504
|
+
* Re-export for callers that already have a path and want the package loc
|
|
505
|
+
* without taking a dependency on the (b) module at the call site.
|
|
506
|
+
*/
|
|
507
|
+
export function locateFlairTree(startPath) {
|
|
508
|
+
return findFlairPackageDir(startPath);
|
|
509
|
+
}
|
|
510
|
+
/** True when `dir` is the npm-global package (so the tarball lane must not claim it). */
|
|
511
|
+
export function isNpmGlobalTree(dir, global) {
|
|
512
|
+
if (!global)
|
|
513
|
+
return false;
|
|
514
|
+
return sameInstallPath(dir, global.dir);
|
|
515
|
+
}
|
|
516
|
+
/** lstat helper kept for tests that want to assert a preserve copy kept a symlink. */
|
|
517
|
+
export function isSymlink(p) {
|
|
518
|
+
try {
|
|
519
|
+
return lstatSync(p).isSymbolicLink();
|
|
520
|
+
}
|
|
521
|
+
catch {
|
|
522
|
+
return false;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
/**
|
|
526
|
+
* The "→ version" for `@tpsdev-ai/flair` on the plain-tree lane.
|
|
527
|
+
*
|
|
528
|
+
* Always consulted against registry `/latest` when that fetch succeeded.
|
|
529
|
+
* `--flair-version` is the swap target (an operator pin), and it still
|
|
530
|
+
* applies when `/latest` timed out or was non-OK — otherwise a requested
|
|
531
|
+
* tarball swap is silently skipped. No pin and no latest → cannot list.
|
|
532
|
+
*/
|
|
533
|
+
export function resolvePlainTreeListingTarget(input) {
|
|
534
|
+
const pin = typeof input.pin === "string" && input.pin.trim() !== "" ? input.pin.trim() : null;
|
|
535
|
+
const latest = typeof input.registryLatest === "string"
|
|
536
|
+
&& input.registryLatest.trim() !== ""
|
|
537
|
+
&& input.registryLatest !== "unknown"
|
|
538
|
+
? input.registryLatest.trim()
|
|
539
|
+
: null;
|
|
540
|
+
if (pin)
|
|
541
|
+
return { version: pin, pinned: true };
|
|
542
|
+
if (latest)
|
|
543
|
+
return { version: latest, pinned: false };
|
|
544
|
+
return null;
|
|
545
|
+
}
|
|
546
|
+
/**
|
|
547
|
+
* Rollback must not assume `.upgrade-prev` exists. A plain-tree run that
|
|
548
|
+
* did not swap @tpsdev-ai/flair (already current; openclaw-only) has no
|
|
549
|
+
* previous tree — skip restore rather than aborting as a hard failure.
|
|
550
|
+
*/
|
|
551
|
+
export function decidePlainTreeRollback(previousDirExists) {
|
|
552
|
+
if (previousDirExists)
|
|
553
|
+
return { kind: "restore" };
|
|
554
|
+
return {
|
|
555
|
+
kind: "skip",
|
|
556
|
+
reason: "no previous tree to restore (the live tree was not swapped)",
|
|
557
|
+
};
|
|
558
|
+
}
|