rootrework 1.0.2 → 1.0.3

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/src/client.mjs ADDED
@@ -0,0 +1,408 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+ import http from 'node:http';
5
+ import { randomUUID, randomBytes, createHash } from 'node:crypto';
6
+ import { spawn } from 'node:child_process';
7
+ import { fileURLToPath } from 'node:url';
8
+
9
+ export const packageRoot = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
10
+ export const defaults = { issuer: 'https://rootrework.com', resource: 'https://rootrework.com/mcp', scope: 'mcp' };
11
+ export const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
12
+ export function statePath(env = process.env, platform = process.platform, home = os.homedir()) {
13
+ if (env.ROOTREWORK_STATE_DIR) return path.resolve(env.ROOTREWORK_STATE_DIR);
14
+ const base = platform === 'win32' ? env.LOCALAPPDATA || path.join(home, 'AppData', 'Local') : platform === 'darwin' ? path.join(home, 'Library', 'Application Support') : env.XDG_STATE_HOME || path.join(home, '.local', 'state');
15
+ return path.join(base, 'rootRework');
16
+ }
17
+ export async function readJson(file, fallback = null) {
18
+ try { return JSON.parse(await fs.readFile(file, 'utf8')); } catch (error) { if (error.code === 'ENOENT') return fallback; throw error; }
19
+ }
20
+ export async function writeJson(file, value) {
21
+ await fs.mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
22
+ const temporary = `${file}.${process.pid}.${randomUUID()}.tmp`;
23
+ const handle = await fs.open(temporary, 'wx', 0o600);
24
+ try { await handle.writeFile(JSON.stringify(value, null, 2) + '\n'); await handle.sync(); } finally { await handle.close(); }
25
+ try { await fs.rename(temporary, file); } catch (error) { await fs.rm(temporary, { force: true }); throw error; }
26
+ }
27
+ export async function withLock(file, callback) {
28
+ await fs.mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
29
+ let handle;
30
+ for (let attempt = 0; attempt < 150; attempt++) {
31
+ try { handle = await fs.open(file, 'wx', 0o600); await handle.writeFile(JSON.stringify({ pid: process.pid, at: Date.now() })); break; }
32
+ catch (error) {
33
+ if (error.code !== 'EEXIST') throw error;
34
+ const lock = await readJson(file).catch(() => null);
35
+ if (lock && !alive(lock.pid)) { await fs.rm(file, { force: true }); continue; }
36
+ await sleep(100);
37
+ }
38
+ }
39
+ if (!handle) throw new Error('Another installation or session update is still running.');
40
+ try { return await callback(); } finally { await handle.close(); await fs.rm(file, { force: true }); }
41
+ }
42
+ export function alive(pid) {
43
+ if (!Number.isInteger(pid) || pid <= 0) return false;
44
+ try { process.kill(pid, 0); return true; } catch (error) { return error.code === 'EPERM'; }
45
+ }
46
+ export async function requestJson(url, options = {}) {
47
+ const response = await fetch(url, { ...options, signal: options.signal || AbortSignal.timeout(40000) });
48
+ const data = await response.json().catch(() => ({}));
49
+ if (!response.ok) throw Object.assign(new Error(data.error_description || data.message || data.error || `HTTP ${response.status}`), { code: data.error, status: response.status, data });
50
+ return data;
51
+ }
52
+ export async function discover(issuer = defaults.issuer) {
53
+ const selected = new URL(issuer);
54
+ if (selected.protocol !== 'https:' && selected.hostname !== '127.0.0.1') throw new Error('The issuer must use HTTPS.');
55
+ const response = await fetch(new URL('/.well-known/oauth-authorization-server', selected), { signal: AbortSignal.timeout(30000), redirect: 'follow' });
56
+ if (!response.ok) throw new Error(`Discovery failed: HTTP ${response.status}`);
57
+ const metadata = await response.json();
58
+ const effectiveOrigin = new URL(response.url).origin;
59
+ if (metadata.issuer !== effectiveOrigin) throw new Error('Authorization-server issuer mismatch.');
60
+ if (!metadata.registration_endpoint || !metadata.code_challenge_methods_supported?.includes('S256')) throw new Error('The server does not provide DCR with S256.');
61
+ for (const key of ['authorization_endpoint', 'token_endpoint', 'registration_endpoint']) if (new URL(metadata[key]).origin !== metadata.issuer) throw new Error(`Unexpected ${key} origin.`);
62
+ return metadata;
63
+ }
64
+ export async function openBrowser(url) {
65
+ let command, args;
66
+ if (process.platform === 'win32') { command = 'rundll32.exe'; args = ['url.dll,FileProtocolHandler', url]; }
67
+ else if (process.platform === 'darwin') { command = 'open'; args = [url]; }
68
+ else { command = 'xdg-open'; args = [url]; }
69
+ return await new Promise(resolve => {
70
+ const child = spawn(command, args, { stdio: 'ignore', detached: true, windowsHide: true });
71
+ child.once('error', () => resolve(false)); child.once('spawn', () => { child.unref(); resolve(true); });
72
+ });
73
+ }
74
+ export async function login(stateDir, { issuer = defaults.issuer, replaceRegistration = false, onAuthorize = async url => { process.stdout.write(JSON.stringify({ state: 'authorization_required', url }) + '\n'); await openBrowser(url); } } = {}) {
75
+ await fs.mkdir(stateDir, { recursive: true, mode: 0o700 });
76
+ const metadata = await discover(issuer);
77
+ const canonicalRegistration = path.join(stateDir, 'registration.json');
78
+ const persistedRegistration = await readJson(canonicalRegistration);
79
+ if (persistedRegistration && persistedRegistration.issuer !== metadata.issuer) throw new Error('Stored registration belongs to a different issuer.');
80
+ const registrationFile = path.join(stateDir, replaceRegistration ? 'registration-replacement.json' : 'registration.json');
81
+ let registration = await readJson(registrationFile);
82
+ if (registration && registration.issuer !== metadata.issuer) throw new Error('Stored registration belongs to a different issuer.');
83
+ const interrupted = await readJson(path.join(stateDir, 'registration-pending.json'));
84
+ if (!registration && interrupted && interrupted.issuer !== metadata.issuer) throw new Error('Interrupted registration belongs to a different issuer.');
85
+ const existingCallback = registration?.redirect_uris?.[0] || (interrupted?.issuer === metadata.issuer ? interrupted.redirect : null);
86
+ const server = http.createServer();
87
+ const requestedPort = existingCallback ? Number(new URL(existingCallback).port) : 0;
88
+ await new Promise((resolve, reject) => { server.once('error', reject); server.listen(requestedPort, '127.0.0.1', resolve); });
89
+ const redirect = `http://127.0.0.1:${server.address().port}/callback`;
90
+ let timer;
91
+ try {
92
+ if (!registration) {
93
+ let pending = await readJson(path.join(stateDir, 'registration-pending.json'));
94
+ if (pending && pending.issuer === metadata.issuer && pending.redirect !== redirect) {
95
+ throw new Error('An interrupted registration is bound to a different callback. Retry repair before creating a new registration.');
96
+ }
97
+ pending ||= { issuer: metadata.issuer, redirect, key: randomUUID() };
98
+ await writeJson(path.join(stateDir, 'registration-pending.json'), pending);
99
+ const created = await requestJson(metadata.registration_endpoint, { method: 'POST', headers: { 'content-type': 'application/json', 'idempotency-key': pending.key }, body: JSON.stringify({ client_name: 'rootRework', application_type: 'native', redirect_uris: [redirect], token_endpoint_auth_method: 'none', grant_types: ['authorization_code', 'refresh_token'], response_types: ['code'] }) });
100
+ if (created.token_endpoint_auth_method !== 'none' || !created.redirect_uris?.includes(redirect) || created.client_secret) throw new Error('The effective public-client registration is incompatible.');
101
+ registration = { ...created, issuer: metadata.issuer };
102
+ await writeJson(registrationFile, registration);
103
+ await fs.rm(path.join(stateDir, 'registration-pending.json'), { force: true });
104
+ }
105
+ const verifier = randomBytes(32).toString('base64url');
106
+ const state = randomBytes(32).toString('base64url');
107
+ const authorization = new URL(metadata.authorization_endpoint);
108
+ for (const [key, value] of Object.entries({ response_type: 'code', client_id: registration.client_id, redirect_uri: redirect, scope: 'mcp', resource: `${metadata.issuer}/mcp`, state, code_challenge: createHash('sha256').update(verifier).digest('base64url'), code_challenge_method: 'S256' })) authorization.searchParams.set(key, value);
109
+ const completion = new Promise((resolve, reject) => {
110
+ timer = setTimeout(() => reject(new Error('Authorization did not complete. The registration is retained for retry.')), 10 * 60 * 1000);
111
+ server.on('request', (request, response) => {
112
+ const callback = new URL(request.url, redirect);
113
+ if (request.method !== 'GET' || callback.pathname !== '/callback') { response.writeHead(404).end(); return; }
114
+ if (callback.searchParams.get('state') !== state || callback.searchParams.get('iss') !== metadata.issuer) { response.writeHead(400).end('Invalid callback.'); return; }
115
+ clearTimeout(timer);
116
+ response.writeHead(200, { 'content-type': 'text/plain; charset=utf-8' }).end(callback.searchParams.has('code') ? 'Connected. You may close this page.' : 'Authorization failed.');
117
+ if (callback.searchParams.has('error')) reject(new Error(callback.searchParams.get('error')));
118
+ else resolve(callback.searchParams.get('code'));
119
+ });
120
+ });
121
+ const [code] = await Promise.all([completion, Promise.resolve().then(() => onAuthorize(authorization.href))]);
122
+ const token = await requestJson(metadata.token_endpoint, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'authorization_code', client_id: registration.client_id, code, redirect_uri: redirect, code_verifier: verifier, resource: `${metadata.issuer}/mcp` }) });
123
+ const identity = await requestJson(`${metadata.issuer}/agent/identity`, { headers: { authorization: `Bearer ${token.access_token}` } });
124
+ const previous = await readJson(path.join(stateDir, 'session.json'));
125
+ const session = { schema: 1, issuer: metadata.issuer, resource: `${metadata.issuer}/mcp`, client: registration, token: { ...token, expires_at: token.expires_at || Date.now() + token.expires_in * 1000 }, accountId: identity.accountId, deviceId: previous?.accountId === identity.accountId ? previous.deviceId : randomUUID(), deviceName: previous?.accountId === identity.accountId ? previous.deviceName : os.hostname() };
126
+ if (replaceRegistration) await writeJson(canonicalRegistration, registration);
127
+ await writeJson(path.join(stateDir, 'session.json'), session);
128
+ if (replaceRegistration) await fs.rm(registrationFile, { force: true });
129
+ await fs.rm(path.join(stateDir, 'session-refresh.json'), { force: true });
130
+ return session;
131
+ } finally { clearTimeout(timer); server.close(); }
132
+ }
133
+ export async function accessSession(stateDir, { refresh = false } = {}) {
134
+ return await withLock(path.join(stateDir, 'session.lock'), () => readAccessSession(stateDir, { refresh }));
135
+ }
136
+ async function readAccessSession(stateDir, { refresh = false } = {}) {
137
+ const session = await readJson(path.join(stateDir, 'session.json'));
138
+ if (!session) throw Object.assign(new Error('Sign in to connect this computer.'), { code: 'login_required' });
139
+ if (session.ownerInput !== undefined && (session.ownerInput !== '0' || !session.accountId || !session.deviceId)) throw new Error('The private owner initialization record is invalid.');
140
+ let expiry = session.token.expires_at;
141
+ if (expiry && expiry < 1e12) expiry *= 1000;
142
+ if (!refresh && expiry > Date.now() + 60000) return session;
143
+ const metadata = await discover(session.issuer);
144
+ const pendingFile = path.join(stateDir, 'session-refresh.json');
145
+ let pending = await readJson(pendingFile);
146
+ if (pending && (pending.refreshToken !== session.token.refresh_token || pending.clientId !== session.client.client_id)) { await fs.rm(pendingFile, { force: true }); pending = null; }
147
+ pending ||= { key: randomUUID(), clientId: session.client.client_id, refreshToken: session.token.refresh_token };
148
+ await writeJson(pendingFile, pending);
149
+ const body = new URLSearchParams({ grant_type: 'refresh_token', refresh_token: session.token.refresh_token, client_id: session.client.client_id, resource: session.resource });
150
+ const token = await requestJson(metadata.token_endpoint, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded', 'idempotency-key': pending.key }, body });
151
+ session.token = { ...token, expires_at: token.expires_at || Date.now() + token.expires_in * 1000 };
152
+ await writeJson(path.join(stateDir, 'session.json'), session);
153
+ await fs.rm(pendingFile, { force: true });
154
+ return session;
155
+ }
156
+ export async function initializeOwner(stateDir, file) {
157
+ if (!file || file.startsWith('--')) throw new Error('A private provisioned installation file is required.');
158
+ const input = await readJson(path.resolve(file));
159
+ if (!input || input.ownerInput !== '0' || !input.accountId || !input.deviceId || input.issuer !== defaults.issuer || input.resource !== defaults.resource) throw new Error('Invalid private owner initialization.');
160
+ return await withLock(path.join(stateDir, 'session.lock'), async () => {
161
+ const previous = await readJson(path.join(stateDir, 'session.json'));
162
+ if (previous && ['accountId', 'deviceId', 'issuer', 'resource'].some(key => previous[key] !== input[key])) throw new Error('This state directory belongs to another account or device.');
163
+ let session = previous ? await readAccessSession(stateDir) : input;
164
+ const inspect = () => requestJson(`${session.issuer}/agent/identity`, { headers: { authorization: `Bearer ${session.token.access_token}` } });
165
+ let identity;
166
+ try { identity = await inspect(); } catch (error) {
167
+ if (!previous || error.status !== 401) throw error;
168
+ session = await readAccessSession(stateDir, { refresh: true });
169
+ identity = await inspect();
170
+ }
171
+ if (!identity.owner || identity.accountId !== input.accountId || identity.deviceId !== input.deviceId || identity.clientId !== session.client?.client_id) throw new Error('The owner association does not match the issued installation session.');
172
+ await writeJson(path.join(stateDir, 'session.json'), { ...session, schema: 1, deviceName: session.deviceName || 'root', ownerInput: input.ownerInput, ownerInputAcceptedAt: session.ownerInputAcceptedAt || Date.now() });
173
+ await writeJson(path.join(stateDir, 'registration.json'), { ...session.client, issuer: session.issuer });
174
+ return { state: 'configured', account_id: identity.accountId, device_id: identity.deviceId, owner_input_accepted: true, zero_only: /^0+$/.test(input.ownerInput) };
175
+ });
176
+ }
177
+ export async function api(stateDir, endpoint, body, options = {}) {
178
+ let session = await accessSession(stateDir);
179
+ const invoke = () => requestJson(new URL(endpoint, session.issuer), { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${session.token.access_token}` }, body: JSON.stringify(body), ...options });
180
+ try { return await invoke(); } catch (error) {
181
+ if (error.status !== 401) throw error;
182
+ session = await accessSession(stateDir, { refresh: true });
183
+ return await invoke();
184
+ }
185
+ }
186
+
187
+ async function locateDependency(name, from) {
188
+ for (let current = from; ; current = path.dirname(current)) {
189
+ const candidate = path.join(current, 'node_modules', ...name.split('/'));
190
+ if (await readJson(path.join(candidate, 'package.json'))) return candidate;
191
+ if (path.dirname(current) === current) return null;
192
+ }
193
+ }
194
+ async function collectFiles(source, destination, selected, excludeDependencies = false) {
195
+ const info = await fs.stat(source);
196
+ if (info.isDirectory()) {
197
+ for (const name of await fs.readdir(source)) if (!excludeDependencies || !['node_modules', '.git'].includes(name)) await collectFiles(path.join(source, name), path.join(destination, name), selected, excludeDependencies);
198
+ } else if (info.isFile()) selected.add(destination);
199
+ }
200
+ async function copyDependencies(source, destination, ancestors = new Map(), selected = null, copy = true) {
201
+ const manifest = await readJson(path.join(source, 'package.json'));
202
+ const dependencies = { ...manifest.dependencies, ...manifest.optionalDependencies };
203
+ for (const name of Object.keys(dependencies)) {
204
+ const resolved = await locateDependency(name, source);
205
+ if (!resolved) {
206
+ if (manifest.optionalDependencies?.[name]) continue;
207
+ throw new Error(`Missing installed dependency: ${name}`);
208
+ }
209
+ const real = await fs.realpath(resolved);
210
+ if (ancestors.get(name) === real) continue;
211
+ const target = path.join(destination, 'node_modules', ...name.split('/'));
212
+ if (selected) await collectFiles(real, target, selected, true);
213
+ if (copy) { await fs.mkdir(path.dirname(target), { recursive: true }); await fs.cp(real, target, { recursive: true, dereference: true, filter: item => path.basename(item) !== 'node_modules' && path.basename(item) !== '.git' }); }
214
+ await copyDependencies(real, target, new Map([...ancestors, [name, real]]), selected, copy);
215
+ }
216
+ }
217
+ async function sameFile(source, destination) {
218
+ const a = await fs.readFile(source);
219
+ try { return a.equals(await fs.readFile(destination)); } catch (error) { if (error.code === 'ENOENT') return false; throw error; }
220
+ }
221
+ async function repairTree(source, destination) {
222
+ if (path.resolve(source) === path.resolve(destination)) return;
223
+ const info = await fs.stat(source);
224
+ if (info.isDirectory()) {
225
+ await fs.mkdir(destination, { recursive: true, mode: info.mode & 0o777 });
226
+ for (const name of await fs.readdir(source)) await repairTree(path.join(source, name), path.join(destination, name));
227
+ } else if (info.isFile() && !await sameFile(source, destination)) {
228
+ await fs.mkdir(path.dirname(destination), { recursive: true });
229
+ const temporary = `${destination}.${randomUUID()}.tmp`;
230
+ await fs.copyFile(source, temporary); await fs.chmod(temporary, info.mode & 0o777);
231
+ await fs.rename(temporary, destination);
232
+ if (!await sameFile(source, destination)) throw new Error('Installation readback differs from its selected release.');
233
+ }
234
+ }
235
+ async function copyPayload(source, destination, repair = false) {
236
+ const manifest = await readJson(path.join(source, 'package.json'));
237
+ const entries = [...new Set([...(manifest.files || ['bin', 'src', 'config']), 'package.json', 'LICENSE'])];
238
+ for (const entry of entries) {
239
+ if (path.isAbsolute(entry) || entry.split(/[\\/]/).includes('..')) throw new Error('Invalid package payload path.');
240
+ const from = path.join(source, entry), to = path.join(destination, entry);
241
+ if (repair) await repairTree(from, to);
242
+ else { await fs.mkdir(path.dirname(to), { recursive: true }); await fs.cp(from, to, { recursive: true, dereference: true }); }
243
+ }
244
+ for (const excluded of ['auth.mjs', 'worker.mjs', 'public.mjs']) if (!entries.includes(`src/${excluded}`) && !entries.includes('src')) await fs.rm(path.join(destination, 'src', excluded), { force: true });
245
+ }
246
+ async function ownedFiles(source, destination) {
247
+ const manifest = await readJson(path.join(source, 'package.json'));
248
+ const selected = new Set();
249
+ for (const entry of [...new Set([...(manifest.files || ['bin', 'src', 'config']), 'package.json', 'LICENSE'])]) {
250
+ if (path.isAbsolute(entry) || entry.split(/[\\/]/).includes('..')) throw new Error('Invalid package payload path.');
251
+ await collectFiles(path.join(source, entry), path.join(destination, entry), selected);
252
+ }
253
+ await copyDependencies(source, destination, new Map(), selected, false);
254
+ return [...selected].map(file => path.relative(destination, file)).sort();
255
+ }
256
+ async function removeFiles(base, files) {
257
+ const parents = new Set([base]);
258
+ for (const relative of files) {
259
+ if (typeof relative !== 'string' || path.isAbsolute(relative) || relative.split(/[\\/]/).includes('..')) throw new Error('Invalid owned-file record.');
260
+ const file = path.join(base, relative), info = await fs.lstat(file).catch(error => { if (error.code === 'ENOENT') return null; throw error; });
261
+ if (info && !info.isDirectory()) await fs.unlink(file);
262
+ for (let current = path.dirname(file); current !== base && current.startsWith(base + path.sep); current = path.dirname(current)) parents.add(current);
263
+ }
264
+ for (const directory of [...parents].sort((a, b) => b.length - a.length)) try { await fs.rmdir(directory); } catch (error) { if (!['ENOENT', 'ENOTEMPTY', 'EEXIST'].includes(error.code)) throw error; }
265
+ }
266
+ async function payloadMatches(source, destination) {
267
+ const manifest = await readJson(path.join(source, 'package.json'));
268
+ async function sameTree(from, to, excludeDependencies = false) {
269
+ let a, b; try { a = await fs.stat(from); b = await fs.stat(to); } catch { return false; }
270
+ if (a.isDirectory()) {
271
+ if (!b.isDirectory()) return false;
272
+ for (const name of await fs.readdir(from)) {
273
+ if (excludeDependencies && ['node_modules', '.git'].includes(name)) continue;
274
+ if (!await sameTree(path.join(from, name), path.join(to, name), excludeDependencies)) return false;
275
+ }
276
+ return true;
277
+ }
278
+ return a.isFile() && b.isFile() && a.size === b.size && (a.mode & 0o111) === (b.mode & 0o111) && await sameFile(from, to);
279
+ }
280
+ for (const entry of [...new Set([...(manifest.files || ['bin', 'src', 'config']), 'package.json', 'LICENSE'])]) {
281
+ if (path.isAbsolute(entry) || entry.split(/[\\/]/).includes('..')) throw new Error('Invalid package payload path.');
282
+ if (!await sameTree(path.join(source, entry), path.join(destination, entry))) return false;
283
+ }
284
+ async function dependenciesMatch(fromRoot, toRoot, ancestors = new Map()) {
285
+ const descriptor = await readJson(path.join(fromRoot, 'package.json'));
286
+ for (const name of Object.keys({ ...descriptor.dependencies, ...descriptor.optionalDependencies })) {
287
+ const from = await locateDependency(name, fromRoot), to = path.join(toRoot, 'node_modules', ...name.split('/'));
288
+ if (!from) { if (descriptor.optionalDependencies?.[name]) continue; return false; }
289
+ const real = await fs.realpath(from);
290
+ if (ancestors.get(name) === real) continue;
291
+ if (!await sameTree(real, to, true) || !await dependenciesMatch(real, to, new Map([...ancestors, [name, real]]))) return false;
292
+ }
293
+ return true;
294
+ }
295
+ return dependenciesMatch(source, destination);
296
+ }
297
+ export async function activateInstallation(stateDir, prepared) {
298
+ const previous = await readJson(path.join(stateDir, 'installation.json'));
299
+ const value = { schema: 1, version: prepared.version, release: prepared.release, previousRelease: previous?.release !== prepared.release ? previous?.release : previous?.previousRelease, source: prepared.source || prepared.release };
300
+ await writeJson(path.join(stateDir, 'installation.json'), value);
301
+ return value;
302
+ }
303
+ export async function materialize(stateDir, sourceRoot = packageRoot, { activate = true, projectionRoot = sourceRoot } = {}) {
304
+ return await withLock(path.join(stateDir, 'install.lock'), async () => {
305
+ const manifest = await readJson(path.join(sourceRoot, 'package.json'));
306
+ if (!manifest || typeof manifest.version !== 'string' || !/^[0-9]+\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.-]+)?$/.test(manifest.version)) throw new Error('Invalid release manifest.');
307
+ let target = path.join(stateDir, 'releases', manifest.version);
308
+ const current = await readJson(path.join(target, 'package.json'));
309
+ const active = await readJson(path.join(stateDir, 'installation.json'));
310
+ if (active?.version === manifest.version && await payloadMatches(sourceRoot, active.release)) target = active.release;
311
+ else if (!current || !await payloadMatches(sourceRoot, target)) {
312
+ if (current) target += '-' + randomUUID();
313
+ const staging = `${target}.${randomUUID()}.tmp`;
314
+ try {
315
+ await fs.mkdir(staging, { recursive: true, mode: 0o700 });
316
+ await copyPayload(sourceRoot, staging); await copyDependencies(sourceRoot, staging);
317
+ if (!await payloadMatches(sourceRoot, staging)) throw new Error('Staged release readback differs from its selected source.');
318
+ await fs.rename(staging, target);
319
+ } catch (error) { await fs.rm(staging, { recursive: true, force: true }); throw error; }
320
+ }
321
+ const prepared = { release: target, version: manifest.version, source: sourceRoot };
322
+ const releases = await readJson(path.join(stateDir, 'releases.json'), {});
323
+ if (active?.release && !releases[active.release] && await readJson(path.join(active.release, 'package.json'))) releases[active.release] = await ownedFiles(active.release, active.release);
324
+ releases[target] = await ownedFiles(sourceRoot, target);
325
+ await writeJson(path.join(stateDir, 'releases.json'), releases);
326
+ if (activate) await activateInstallation(stateDir, prepared);
327
+ const projection = await materializeProjection(target, stateDir, manifest.version, projectionPath(projectionRoot), target);
328
+ return { ...prepared, projection };
329
+ });
330
+ }
331
+ export function projectionPath(sourceRoot) {
332
+ let cursor = sourceRoot;
333
+ while (path.dirname(cursor) !== cursor && path.basename(cursor) !== 'node_modules') cursor = path.dirname(cursor);
334
+ if (path.basename(cursor) !== 'node_modules') cursor = path.join(sourceRoot, 'node_modules');
335
+ return path.join(cursor, 'rootEnterprise', 'rootRework', 'node_modules', 'user');
336
+ }
337
+ export async function materializeProjection(sourceRoot, stateDir, version, explicitPath, release = sourceRoot) {
338
+ const destination = explicitPath || projectionPath(sourceRoot);
339
+ const marker = path.join(destination, 'data', 'installation.json');
340
+ const directories = ['data', 'sources'];
341
+ for (const directory of [destination, ...directories.map(name => path.join(destination, name))]) {
342
+ const info = await fs.lstat(directory).catch(error => { if (error.code === 'ENOENT') return null; throw error; });
343
+ if (info && !info.isDirectory()) throw new Error('The user-product destination contains unowned files.');
344
+ }
345
+ const markerInfo = await fs.lstat(marker).catch(error => { if (error.code === 'ENOENT') return null; throw error; });
346
+ if (markerInfo && !markerInfo.isFile()) throw new Error('The user-product destination contains unowned files.');
347
+ const prior = await readJson(marker);
348
+ if (prior && (prior.schema !== 1 || prior.product !== 'rootRework' || typeof prior.stateDir !== 'string' || path.resolve(prior.stateDir) !== path.resolve(stateDir))) throw new Error('The user-product projection belongs to another installation.');
349
+ if (!prior) {
350
+ const contents = await fs.readdir(destination).catch(error => { if (error.code === 'ENOENT') return []; throw error; });
351
+ for (const name of contents) if (!directories.includes(name) || (await fs.readdir(path.join(destination, name))).length) throw new Error('The user-product destination contains unowned files.');
352
+ }
353
+ await fs.mkdir(path.join(destination, 'data'), { recursive: true, mode: 0o700 });
354
+ await fs.mkdir(path.join(destination, 'sources'), { recursive: true });
355
+ const session = await readJson(path.join(stateDir, 'session.json'));
356
+ const files = (await ownedFiles(sourceRoot, path.join(destination, 'sources'))).map(name => path.join('sources', name));
357
+ files.push(path.join('data', 'client.json'), path.join('data', 'installation.json'));
358
+ if (session) files.push(path.join('data', 'session.json'));
359
+ const tracked = await readJson(path.join(stateDir, 'projections.json'), []);
360
+ if (!tracked.includes(destination)) { tracked.push(destination); await writeJson(path.join(stateDir, 'projections.json'), tracked); }
361
+ await writeJson(marker, { schema: 1, product: 'rootRework', version, stateDir, release, owned_files: [...new Set([...(prior?.owned_files || []), ...files])] });
362
+ await fs.copyFile(path.join(sourceRoot, 'config', 'client.json'), path.join(destination, 'data', 'client.json'));
363
+ if (session) await writeJson(path.join(destination, 'data', 'session.json'), session);
364
+ await copyPayload(sourceRoot, path.join(destination, 'sources'), true);
365
+ await copyDependencies(sourceRoot, path.join(destination, 'sources'));
366
+ await removeFiles(destination, (prior?.owned_files || []).filter(file => !files.includes(file)));
367
+ await writeJson(marker, { schema: 1, product: 'rootRework', version, stateDir, release, owned_files: files });
368
+ return destination;
369
+ }
370
+ export async function removeOwnedCode(stateDir, { purge = false } = {}) {
371
+ const removed = [], retained = [], cleaned = [];
372
+ async function removalState(directory) { cleaned.push(directory); if (await fs.lstat(directory).catch(() => null)) retained.push(directory); else removed.push(directory); }
373
+ const activeTasks = [];
374
+ for (const name of await fs.readdir(path.join(stateDir, 'tasks')).catch(() => [])) {
375
+ const record = await readJson(path.join(stateDir, 'tasks', name, 'state.json')).catch(() => null);
376
+ if (record && !['completed_success', 'completed_failure', 'cancelled', 'timed_out', 'unknown_outcome'].includes(record.state) && alive(record.supervisor_pid || record.worker_pid)) activeTasks.push(name);
377
+ }
378
+ if (purge && activeTasks.length) throw new Error('Active task supervisors still use this installation. Finish or cancel those tasks before purging its data.');
379
+ for (const destination of await readJson(path.join(stateDir, 'projections.json'), [])) {
380
+ const marker = await readJson(path.join(destination, 'data', 'installation.json'));
381
+ if (!marker) continue;
382
+ if (path.resolve(marker.stateDir || '') !== path.resolve(stateDir) || marker.product !== 'rootRework') { retained.push(destination); continue; }
383
+ if (path.resolve(destination) === path.resolve(stateDir) || path.resolve(stateDir).startsWith(path.resolve(destination) + path.sep)) throw new Error('An invalid projection cannot be removed.');
384
+ if (!marker.owned_files) { retained.push(destination); continue; }
385
+ await removeFiles(destination, marker.owned_files);
386
+ await removalState(destination);
387
+ const nodeModules = path.dirname(destination), product = path.dirname(nodeModules), scope = path.dirname(product);
388
+ if (path.basename(nodeModules) === 'node_modules' && path.basename(product) === 'rootRework' && path.basename(scope) === 'rootEnterprise') for (const directory of [nodeModules, product, scope]) try { await fs.rmdir(directory); } catch (error) { if (!['ENOENT', 'ENOTEMPTY', 'EEXIST'].includes(error.code)) throw error; }
389
+ }
390
+ if (!activeTasks.length) {
391
+ for (const [directory, files] of Object.entries(await readJson(path.join(stateDir, 'releases.json'), {}))) {
392
+ if (!path.resolve(directory).startsWith(path.resolve(stateDir, 'releases') + path.sep)) throw new Error('Invalid release ownership record.');
393
+ await removeFiles(directory, files); await removalState(directory);
394
+ }
395
+ await fs.rm(path.join(stateDir, 'releases.json'), { force: true });
396
+ await fs.rmdir(path.join(stateDir, 'releases')).catch(error => { if (!['ENOENT', 'ENOTEMPTY', 'EEXIST'].includes(error.code)) throw error; });
397
+ }
398
+ for (const directory of await readJson(path.join(stateDir, 'candidates.json'), [])) {
399
+ if (!path.resolve(directory).startsWith(path.resolve(stateDir, 'candidates') + path.sep)) throw new Error('Invalid candidate ownership record.');
400
+ await fs.rm(directory, { recursive: true, force: true }); removed.push(directory);
401
+ }
402
+ await fs.rmdir(path.join(stateDir, 'candidates')).catch(error => { if (!['ENOENT', 'ENOTEMPTY', 'EEXIST'].includes(error.code)) throw error; });
403
+ for (const name of ['installation.json', 'projections.json', 'candidates.json', 'transition.json', 'service.json', 'agent-stop.json']) await fs.rm(path.join(stateDir, name), { force: true });
404
+ if (purge) {
405
+ for (const name of ['session.json', 'session-refresh.json', 'registration.json', 'registration-pending.json', 'registration-replacement.json', 'status.json', 'agent.json', 'agent.log', 'scheduler-status.json', 'operations', 'tasks', 'requests', 'cursors', 'artifacts', 'schedules', 'extension-pages', 'extension-requests', 'retention']) await fs.rm(path.join(stateDir, name), { recursive: true, force: true });
406
+ }
407
+ return { removed, cleaned_code: cleaned, retained_unowned: retained, retained_running_tasks: activeTasks, retained_data: !purge, remote_data_deleted: false };
408
+ }