pi-profile-switch 0.6.0 → 0.8.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 +52 -54
- package/README.zh-CN.md +52 -54
- package/bin/pi-profile.ts +10 -4
- package/bin/postinstall.js +26 -28
- package/examples/ask.json +13 -0
- package/examples/example.json +30 -35
- package/package.json +1 -1
- package/schemas/profiles.schema.json +50 -76
- package/src/launcher/runtime-cleanup.ts +210 -29
- package/src/profile-catalog-store.ts +51 -74
- package/src/profile-catalog.ts +81 -50
- package/src/project-trust.ts +1 -1
- package/src/switching/profile-crud.ts +3 -6
- package/src/workspace.ts +3 -16
- package/examples/profiles.json +0 -18
|
@@ -21,19 +21,21 @@
|
|
|
21
21
|
* PID reuse needs no /proc check: a wrong keep only delays cleanup and
|
|
22
22
|
* self-heals once the reused pid dies.
|
|
23
23
|
*
|
|
24
|
-
* A candidate is
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
24
|
+
* A candidate is reclaimed only when every entry in it is pi-profile's own
|
|
25
|
+
* or has been dispositioned away. Unrecognized entries (state an extension
|
|
26
|
+
* created at runtime) get one of three dispositions per entry (ADR-0012):
|
|
27
|
+
* content that references the instance dir's own path, exceeds the scan
|
|
28
|
+
* budget, or is not a regular file/dir is kept and reported; position-
|
|
29
|
+
* independent content is renamed into the real agent dir (adopted), or
|
|
30
|
+
* deleted when the real agent dir already holds that name (real wins — the
|
|
31
|
+
* conflict window means the instance copy is never the authority). The sweep
|
|
32
|
+
* never destroys data it cannot attribute.
|
|
31
33
|
*
|
|
32
34
|
* Everything is best-effort: sweep errors never block a launch.
|
|
33
35
|
*/
|
|
34
36
|
|
|
35
37
|
import type { Dirent } from "node:fs";
|
|
36
|
-
import { lstat, readdir, readFile, rm, stat } from "node:fs/promises";
|
|
38
|
+
import { lstat, readdir, readFile, rename, rm, stat } from "node:fs/promises";
|
|
37
39
|
import path from "node:path";
|
|
38
40
|
|
|
39
41
|
import { MANAGED_INSTANCE_FILES } from "../settings-generator.ts";
|
|
@@ -49,6 +51,29 @@ export const NO_PID_GRACE_MS = 10 * 60 * 1000;
|
|
|
49
51
|
* may write its own files inside them (e.g. an extension's own config). */
|
|
50
52
|
const MANAGED_DIRS_WITH_RUNTIME_CONTENT = new Set(["extensions"]);
|
|
51
53
|
|
|
54
|
+
/** Content-scan budget for deciding whether an unrecognized entry is safe to
|
|
55
|
+
* adopt (ADR-0012). Over budget → undecidable → keep and warn. The values
|
|
56
|
+
* are implementation constants: the contract only fixes "over budget means
|
|
57
|
+
* keep". Exported for the tests that pin the over-budget behavior. */
|
|
58
|
+
export const SCAN_MAX_TOTAL_BYTES = 8 * 1024 * 1024;
|
|
59
|
+
export const SCAN_MAX_FILES = 1024;
|
|
60
|
+
|
|
61
|
+
/** What the sweep does with one unrecognized entry in a candidate dir. */
|
|
62
|
+
type Disposition = "adopt" | "delete" | "keep-warn";
|
|
63
|
+
|
|
64
|
+
interface EntryDecision {
|
|
65
|
+
/** Entry name relative to the instance dir. */
|
|
66
|
+
name: string;
|
|
67
|
+
disposition: Disposition;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface SweepResult {
|
|
71
|
+
/** Warnings for directories intentionally kept. */
|
|
72
|
+
warnings: string[];
|
|
73
|
+
/** One-line notices for entries adopted into or deleted from a candidate. */
|
|
74
|
+
notices: string[];
|
|
75
|
+
}
|
|
76
|
+
|
|
52
77
|
function isProcessAlive(pid: number): boolean {
|
|
53
78
|
try {
|
|
54
79
|
process.kill(pid, 0);
|
|
@@ -59,10 +84,98 @@ function isProcessAlive(pid: number): boolean {
|
|
|
59
84
|
}
|
|
60
85
|
}
|
|
61
86
|
|
|
62
|
-
/**
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
87
|
+
/** Scan budget shared across one entry's recursive content scan. */
|
|
88
|
+
interface ScanBudget {
|
|
89
|
+
bytes: number;
|
|
90
|
+
files: number;
|
|
91
|
+
/** Set when a limit is hit or a member cannot be read: undecidable. */
|
|
92
|
+
exceeded: boolean;
|
|
93
|
+
/** Set when a member's bytes contain the instance dir's absolute path. */
|
|
94
|
+
found: boolean;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function scanContent(entryPath: string, instanceDir: string, budget: ScanBudget): Promise<void> {
|
|
98
|
+
if (budget.exceeded || budget.found) return;
|
|
99
|
+
let info;
|
|
100
|
+
try {
|
|
101
|
+
info = await lstat(entryPath);
|
|
102
|
+
} catch {
|
|
103
|
+
budget.exceeded = true; // Vanished or unreadable mid-scan: undecidable.
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
if (info.isDirectory()) {
|
|
107
|
+
let members: string[];
|
|
108
|
+
try {
|
|
109
|
+
members = await readdir(entryPath);
|
|
110
|
+
} catch {
|
|
111
|
+
budget.exceeded = true;
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
for (const member of members) {
|
|
115
|
+
await scanContent(path.join(entryPath, member), instanceDir, budget);
|
|
116
|
+
if (budget.exceeded || budget.found) return;
|
|
117
|
+
}
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if (!info.isFile()) {
|
|
121
|
+
// A socket/fifo/device member cannot be read: undecidable.
|
|
122
|
+
budget.exceeded = true;
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
budget.files += 1;
|
|
126
|
+
budget.bytes += info.size;
|
|
127
|
+
if (budget.files > SCAN_MAX_FILES || budget.bytes > SCAN_MAX_TOTAL_BYTES) {
|
|
128
|
+
budget.exceeded = true;
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
let content: Buffer;
|
|
132
|
+
try {
|
|
133
|
+
content = await readFile(entryPath);
|
|
134
|
+
} catch {
|
|
135
|
+
budget.exceeded = true;
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
if (content.includes(instanceDir)) budget.found = true;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Whether an unrecognized entry must be kept because its content scan says
|
|
142
|
+
* so: the content references the instance dir's own path (records like
|
|
143
|
+
* pi-subagents ledgers would break if moved), the scan could not finish
|
|
144
|
+
* within the budget, or the entry is not a regular file/dir. Only a clean
|
|
145
|
+
* negative scan makes the entry eligible for adoption (ADR-0012). */
|
|
146
|
+
export async function mustKeepByContentScan(entryPath: string, instanceDir: string): Promise<boolean> {
|
|
147
|
+
let info;
|
|
148
|
+
try {
|
|
149
|
+
info = await lstat(entryPath);
|
|
150
|
+
} catch {
|
|
151
|
+
return true; // Vanished or unreadable: nothing to adopt.
|
|
152
|
+
}
|
|
153
|
+
if (!info.isFile() && !info.isDirectory()) return true; // socket/fifo etc.
|
|
154
|
+
const budget: ScanBudget = { bytes: 0, files: 0, exceeded: false, found: false };
|
|
155
|
+
await scanContent(entryPath, instanceDir, budget);
|
|
156
|
+
// Keep unless the scan finished within budget and never saw the path.
|
|
157
|
+
return budget.exceeded || budget.found;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Decides one top-level unrecognized entry: keep when the content scan is
|
|
161
|
+
* not a clean negative; otherwise real-wins on a name conflict, adopt when
|
|
162
|
+
* the real agent dir is free. A name lookup that fails for reasons other
|
|
163
|
+
* than absence is undecidable → keep. */
|
|
164
|
+
async function decideEntry(name: string, entryPath: string, instanceDir: string, agentDir: string): Promise<EntryDecision> {
|
|
165
|
+
if (await mustKeepByContentScan(entryPath, instanceDir)) return { name, disposition: "keep-warn" };
|
|
166
|
+
try {
|
|
167
|
+
await lstat(path.join(agentDir, name));
|
|
168
|
+
return { name, disposition: "delete" };
|
|
169
|
+
} catch (error) {
|
|
170
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return { name, disposition: "adopt" };
|
|
171
|
+
return { name, disposition: "keep-warn" };
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Unrecognized entry names inside a managed runtime-content directory
|
|
176
|
+
* (`extensions/`): reported at pi-profile's boundary, never adopted or
|
|
177
|
+
* deleted. `undefined` means the directory could not be inspected. */
|
|
178
|
+
async function unrecognizedNames(dir: string): Promise<string[] | undefined> {
|
|
66
179
|
let entries: Dirent<string>[];
|
|
67
180
|
try {
|
|
68
181
|
entries = await readdir(dir, { withFileTypes: true });
|
|
@@ -71,6 +184,41 @@ async function unrecognizedEntries(dir: string): Promise<string[] | undefined> {
|
|
|
71
184
|
}
|
|
72
185
|
|
|
73
186
|
const found: string[] = [];
|
|
187
|
+
for (const entry of entries) {
|
|
188
|
+
const entryPath = path.join(dir, entry.name);
|
|
189
|
+
let linkStat;
|
|
190
|
+
try {
|
|
191
|
+
linkStat = await lstat(entryPath);
|
|
192
|
+
} catch {
|
|
193
|
+
continue; // Vanished under us: nothing to protect.
|
|
194
|
+
}
|
|
195
|
+
if (linkStat.isSymbolicLink()) continue;
|
|
196
|
+
if (MANAGED_DIRS_WITH_RUNTIME_CONTENT.has(entry.name) && linkStat.isDirectory()) {
|
|
197
|
+
const nested = await unrecognizedNames(entryPath);
|
|
198
|
+
if (nested === undefined) {
|
|
199
|
+
found.push(entry.name);
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
found.push(...nested.map((name) => path.join(entry.name, name)));
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
found.push(entry.name);
|
|
206
|
+
}
|
|
207
|
+
return found;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Decides every unrecognized entry in `dir`. `undefined` means the directory
|
|
211
|
+
* could not be inspected — the caller must treat that as unrecognized rather
|
|
212
|
+
* than as empty. */
|
|
213
|
+
async function classifyUnrecognized(dir: string, agentDir: string): Promise<EntryDecision[] | undefined> {
|
|
214
|
+
let entries: Dirent<string>[];
|
|
215
|
+
try {
|
|
216
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
217
|
+
} catch {
|
|
218
|
+
return undefined;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const decisions: EntryDecision[] = [];
|
|
74
222
|
for (const entry of entries) {
|
|
75
223
|
const entryPath = path.join(dir, entry.name);
|
|
76
224
|
let linkStat;
|
|
@@ -82,24 +230,26 @@ async function unrecognizedEntries(dir: string): Promise<string[] | undefined> {
|
|
|
82
230
|
// Symlinks only point at the real agent dir and hold no data of their own.
|
|
83
231
|
if (linkStat.isSymbolicLink()) continue;
|
|
84
232
|
if (!MANAGED_INSTANCE_FILES.has(entry.name)) {
|
|
85
|
-
|
|
233
|
+
decisions.push(await decideEntry(entry.name, entryPath, dir, agentDir));
|
|
86
234
|
continue;
|
|
87
235
|
}
|
|
88
236
|
if (MANAGED_DIRS_WITH_RUNTIME_CONTENT.has(entry.name) && linkStat.isDirectory()) {
|
|
89
|
-
const nested = await
|
|
237
|
+
const nested = await unrecognizedNames(entryPath);
|
|
90
238
|
if (nested === undefined) {
|
|
91
|
-
|
|
239
|
+
decisions.push({ name: entry.name, disposition: "keep-warn" });
|
|
92
240
|
continue;
|
|
93
241
|
}
|
|
94
|
-
|
|
242
|
+
for (const name of nested) decisions.push({ name: path.join(entry.name, name), disposition: "keep-warn" });
|
|
95
243
|
}
|
|
96
244
|
}
|
|
97
|
-
return
|
|
245
|
+
return decisions;
|
|
98
246
|
}
|
|
99
247
|
|
|
100
|
-
/** Reclaims `dir` if it is both dead and free of unrecognized entries
|
|
101
|
-
*
|
|
102
|
-
|
|
248
|
+
/** Reclaims `dir` if it is both dead and free of unrecognized entries, after
|
|
249
|
+
* dispositioning those entries (ADR-0012). Appends notices for adopted and
|
|
250
|
+
* deleted entries to `notices`; returns the warnings for the cases where the
|
|
251
|
+
* directory was intentionally kept. */
|
|
252
|
+
async function sweepEntry(dir: string, agentDir: string, notices: string[]): Promise<string[]> {
|
|
103
253
|
let pid: number | undefined;
|
|
104
254
|
try {
|
|
105
255
|
const raw = await readFile(path.join(dir, "pid"), "utf8");
|
|
@@ -116,15 +266,43 @@ async function sweepEntry(dir: string): Promise<string[]> {
|
|
|
116
266
|
if (Date.now() - info.mtimeMs <= NO_PID_GRACE_MS) return [];
|
|
117
267
|
}
|
|
118
268
|
|
|
119
|
-
const
|
|
120
|
-
if (
|
|
269
|
+
const decisions = await classifyUnrecognized(dir, agentDir);
|
|
270
|
+
if (decisions === undefined) {
|
|
121
271
|
return [
|
|
122
272
|
`${dir} was not reclaimed: it could not be inspected (permissions?). Check its contents and delete it manually.`,
|
|
123
273
|
];
|
|
124
274
|
}
|
|
125
|
-
|
|
275
|
+
|
|
276
|
+
const kept: string[] = [];
|
|
277
|
+
for (const decision of decisions) {
|
|
278
|
+
if (decision.disposition === "keep-warn") {
|
|
279
|
+
kept.push(decision.name);
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
const entryPath = path.join(dir, decision.name);
|
|
283
|
+
if (decision.disposition === "adopt") {
|
|
284
|
+
try {
|
|
285
|
+
await rename(entryPath, path.join(agentDir, decision.name));
|
|
286
|
+
notices.push(`adopted ${decision.name} from ${dir} into the real agent dir (${agentDir}); it is mirrored on the next launch`);
|
|
287
|
+
continue;
|
|
288
|
+
} catch {
|
|
289
|
+
kept.push(decision.name); // Best-effort: keep rather than destroy.
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
// delete: the real agent dir already holds this name (real wins, no
|
|
294
|
+
// content comparison — ADR-0012).
|
|
295
|
+
try {
|
|
296
|
+
await rm(entryPath, { recursive: true, force: true });
|
|
297
|
+
notices.push(`deleted ${decision.name} from ${dir}: the real agent dir already holds that name`);
|
|
298
|
+
} catch {
|
|
299
|
+
kept.push(decision.name);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (kept.length > 0) {
|
|
126
304
|
return [
|
|
127
|
-
`${dir} was not reclaimed: it holds state pi-profile did not generate (${
|
|
305
|
+
`${dir} was not reclaimed: it holds state pi-profile did not generate (${kept.join(", ")}). ` +
|
|
128
306
|
`Move that state into the real agent dir (it is mirrored on the next launch), or point the extension that ` +
|
|
129
307
|
`created it at a fixed path via that extension's own configuration, then delete ${dir}.`,
|
|
130
308
|
];
|
|
@@ -135,16 +313,19 @@ async function sweepEntry(dir: string): Promise<string[]> {
|
|
|
135
313
|
}
|
|
136
314
|
|
|
137
315
|
/** Deletes stale instance dirs under the instances root and returns the
|
|
138
|
-
* warnings for directories it deliberately kept
|
|
139
|
-
|
|
316
|
+
* warnings for directories it deliberately kept plus the notices for entries
|
|
317
|
+
* it adopted or deleted. `agentDir` is the real agent dir that adopted
|
|
318
|
+
* entries move into and name conflicts resolve against. Never throws. */
|
|
319
|
+
export async function sweepStaleInstances(agentDir: string): Promise<SweepResult> {
|
|
140
320
|
const warnings: string[] = [];
|
|
321
|
+
const notices: string[] = [];
|
|
141
322
|
const root = getInstancesRootDir();
|
|
142
323
|
|
|
143
324
|
let entries: string[];
|
|
144
325
|
try {
|
|
145
326
|
entries = await readdir(root);
|
|
146
327
|
} catch {
|
|
147
|
-
|
|
328
|
+
entries = []; // No instances root yet: nothing to sweep.
|
|
148
329
|
}
|
|
149
330
|
|
|
150
331
|
for (const entry of entries) {
|
|
@@ -155,11 +336,11 @@ export async function sweepStaleInstances(): Promise<string[]> {
|
|
|
155
336
|
const dir = path.join(root, entry);
|
|
156
337
|
try {
|
|
157
338
|
if (!(await stat(dir)).isDirectory()) continue;
|
|
158
|
-
warnings.push(...(await sweepEntry(dir)));
|
|
339
|
+
warnings.push(...(await sweepEntry(dir, agentDir, notices)));
|
|
159
340
|
} catch {
|
|
160
341
|
// Best-effort: one bad entry must not stop the sweep or the launch.
|
|
161
342
|
}
|
|
162
343
|
}
|
|
163
344
|
|
|
164
|
-
return warnings;
|
|
345
|
+
return { warnings, notices };
|
|
165
346
|
}
|
|
@@ -1,109 +1,86 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* ProfileCatalogStore: the WRITE side of
|
|
3
|
-
*
|
|
2
|
+
* ProfileCatalogStore: the WRITE side of profile catalog files,
|
|
3
|
+
* storing each profile in its own `<name>.json` file.
|
|
4
4
|
*
|
|
5
5
|
* Invariants:
|
|
6
|
-
* -
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* - Definitions are complete and self-contained: no inheritance fields
|
|
10
|
-
* (`extends`, merge, array append) exist or are accepted.
|
|
6
|
+
* - Single-file operations (pretty-printed JSON without schemaVersion envelope).
|
|
7
|
+
* - Writes land in `<dir>/<name>.json` via a temporary file + rename.
|
|
8
|
+
* - Names must match `PROFILE_NAME_PATTERN` and cannot be "default".
|
|
9
|
+
* - Definitions are complete and self-contained: no inheritance fields exist.
|
|
11
10
|
* - Definitions are re-parsed through the catalog's own
|
|
12
|
-
* `parseProfileDefinition`, so anything written is loadable
|
|
13
|
-
*
|
|
14
|
-
* - Writes land in exactly one scope file (global or project).
|
|
11
|
+
* `parseProfileDefinition`, so anything written is loadable.
|
|
12
|
+
* - Writes land in exactly one scope directory (global or project).
|
|
15
13
|
*/
|
|
16
14
|
|
|
17
|
-
import { mkdir, writeFile } from "node:fs/promises";
|
|
15
|
+
import { mkdir, rename, rm, unlink, writeFile } from "node:fs/promises";
|
|
18
16
|
import path from "node:path";
|
|
19
17
|
|
|
20
|
-
import { isRecord, readJsonFile } from "./json-file.ts";
|
|
21
18
|
import {
|
|
22
19
|
CatalogError,
|
|
23
20
|
DEFAULT_PROFILE_NAME,
|
|
21
|
+
loadCatalogDirectory,
|
|
24
22
|
parseProfileDefinition,
|
|
25
|
-
|
|
23
|
+
PROFILE_NAME_PATTERN,
|
|
26
24
|
type ProfileDefinition,
|
|
27
25
|
} from "./profile-catalog.ts";
|
|
28
26
|
|
|
29
27
|
export class ProfileCatalogStore {
|
|
30
|
-
readonly #
|
|
31
|
-
readonly #fallbackPath?: string;
|
|
28
|
+
readonly #dirPath: string;
|
|
32
29
|
|
|
33
|
-
constructor(
|
|
34
|
-
this.#
|
|
35
|
-
this.#fallbackPath = fallbackPath;
|
|
30
|
+
constructor(catalogDir: string) {
|
|
31
|
+
this.#dirPath = catalogDir;
|
|
36
32
|
}
|
|
37
33
|
|
|
38
|
-
/** Validated definitions: missing
|
|
34
|
+
/** Validated definitions: missing directory → empty; malformed → CatalogError
|
|
39
35
|
* (catalog errors never pass silently, even on the write path). */
|
|
40
36
|
async readDefinitions(): Promise<Map<string, ProfileDefinition>> {
|
|
41
|
-
|
|
42
|
-
if (!result.ok && result.reason === "missing" && this.#fallbackPath) {
|
|
43
|
-
result = await readJsonFile(this.#fallbackPath);
|
|
44
|
-
}
|
|
45
|
-
if (!result.ok) {
|
|
46
|
-
if (result.reason === "missing") return new Map();
|
|
47
|
-
throw new CatalogError(`invalid JSON in ${this.#filePath}`);
|
|
48
|
-
}
|
|
49
|
-
if (!isRecord(result.value)) {
|
|
50
|
-
throw new CatalogError(`${this.#filePath}: catalog must be an object`);
|
|
51
|
-
}
|
|
52
|
-
if (result.value.schemaVersion !== PROFILE_SCHEMA_VERSION) {
|
|
53
|
-
throw new CatalogError(
|
|
54
|
-
`${this.#filePath}: unsupported schemaVersion ${JSON.stringify(result.value.schemaVersion)} (expected ${PROFILE_SCHEMA_VERSION})`,
|
|
55
|
-
);
|
|
56
|
-
}
|
|
57
|
-
if (!isRecord(result.value.profiles)) {
|
|
58
|
-
throw new CatalogError(`${this.#filePath}: "profiles" must be an object mapping names to definitions`);
|
|
59
|
-
}
|
|
60
|
-
const definitions = new Map<string, ProfileDefinition>();
|
|
61
|
-
for (const [name, raw] of Object.entries(result.value.profiles)) {
|
|
62
|
-
if (name === DEFAULT_PROFILE_NAME) {
|
|
63
|
-
throw new CatalogError(
|
|
64
|
-
`${this.#filePath}: "${DEFAULT_PROFILE_NAME}" is built in and must not be defined in the catalog`,
|
|
65
|
-
);
|
|
66
|
-
}
|
|
67
|
-
definitions.set(name, parseProfileDefinition(name, raw));
|
|
68
|
-
}
|
|
69
|
-
return definitions;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/** Overwrites the file with the given definitions (last write wins). */
|
|
73
|
-
async writeDefinitions(definitions: ReadonlyMap<string, ProfileDefinition>): Promise<void> {
|
|
74
|
-
const profiles: Record<string, unknown> = {};
|
|
75
|
-
for (const [name, definition] of [...definitions.entries()].sort(([a], [b]) => a.localeCompare(b))) {
|
|
76
|
-
// Round-trip through the parser: only declared, validated fields
|
|
77
|
-
// are written back (self-contained; no unknown keys survive).
|
|
78
|
-
profiles[name] = parseProfileDefinition(name, definition);
|
|
79
|
-
}
|
|
80
|
-
await mkdir(path.dirname(this.#filePath), { recursive: true });
|
|
81
|
-
await writeFile(
|
|
82
|
-
this.#filePath,
|
|
83
|
-
`${JSON.stringify({ schemaVersion: PROFILE_SCHEMA_VERSION, profiles }, null, 2)}\n`,
|
|
84
|
-
);
|
|
37
|
+
return loadCatalogDirectory(this.#dirPath);
|
|
85
38
|
}
|
|
86
39
|
|
|
87
|
-
/** Inserts or replaces one complete definition. */
|
|
40
|
+
/** Inserts or replaces one complete definition (via atomic rename). */
|
|
88
41
|
async upsert(name: string, definition: ProfileDefinition): Promise<void> {
|
|
89
|
-
if (
|
|
90
|
-
throw new CatalogError(`profile name must
|
|
42
|
+
if (!PROFILE_NAME_PATTERN.test(name)) {
|
|
43
|
+
throw new CatalogError(`invalid profile name "${name}" (must match ${PROFILE_NAME_PATTERN})`);
|
|
91
44
|
}
|
|
92
45
|
if (name === DEFAULT_PROFILE_NAME) {
|
|
93
46
|
throw new CatalogError(`"${DEFAULT_PROFILE_NAME}" is built in and must not be defined in the catalog`);
|
|
94
47
|
}
|
|
95
|
-
|
|
96
|
-
// Validate before mutating: the
|
|
97
|
-
|
|
98
|
-
|
|
48
|
+
|
|
49
|
+
// Validate before mutating: the definition must parse.
|
|
50
|
+
const validated = parseProfileDefinition(name, definition);
|
|
51
|
+
|
|
52
|
+
await mkdir(this.#dirPath, { recursive: true });
|
|
53
|
+
const targetPath = path.join(this.#dirPath, `${name}.json`);
|
|
54
|
+
const tempPath = path.join(
|
|
55
|
+
this.#dirPath,
|
|
56
|
+
`.${name}.json.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`,
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
await writeFile(tempPath, `${JSON.stringify(validated, null, 2)}\n`);
|
|
61
|
+
await rename(tempPath, targetPath);
|
|
62
|
+
} catch (error) {
|
|
63
|
+
await rm(tempPath, { force: true });
|
|
64
|
+
throw error;
|
|
65
|
+
}
|
|
99
66
|
}
|
|
100
67
|
|
|
101
68
|
/** Removes one profile; unknown names are a loud error, not a no-op. */
|
|
102
69
|
async remove(name: string): Promise<void> {
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
70
|
+
if (!PROFILE_NAME_PATTERN.test(name)) {
|
|
71
|
+
throw new CatalogError(`invalid profile name "${name}" (must match ${PROFILE_NAME_PATTERN})`);
|
|
72
|
+
}
|
|
73
|
+
if (name === DEFAULT_PROFILE_NAME) {
|
|
74
|
+
throw new CatalogError(`"${DEFAULT_PROFILE_NAME}" is built in and cannot be deleted`);
|
|
75
|
+
}
|
|
76
|
+
const targetPath = path.join(this.#dirPath, `${name}.json`);
|
|
77
|
+
try {
|
|
78
|
+
await unlink(targetPath);
|
|
79
|
+
} catch (error) {
|
|
80
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
81
|
+
throw new CatalogError(`profile "${name}" not found in ${this.#dirPath}`);
|
|
82
|
+
}
|
|
83
|
+
throw error;
|
|
106
84
|
}
|
|
107
|
-
await this.writeDefinitions(definitions);
|
|
108
85
|
}
|
|
109
86
|
}
|