@stage-labs/metro 0.1.0-beta.302 → 0.1.0-beta.304
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/package.json +1 -1
- package/runtime/marketplace/plugin/.claude-plugin/plugin.json +2 -2
- package/runtime/marketplace/plugin/METRO.md +2 -0
- package/runtime/marketplace/plugin/bin/guard.mjs +2 -1
- package/runtime/marketplace/plugin/bin/metro-plugin.mjs +20 -2
- package/runtime/node_modules/@metro-labs/daemon/src/agent-user/view.ts +4 -1
- package/runtime/node_modules/@metro-labs/daemon/src/agents/bundle.ts +3 -11
- package/runtime/node_modules/@metro-labs/daemon/src/approvals/pending.ts +10 -3
- package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +16 -8
- package/runtime/node_modules/@metro-labs/daemon/src/channels/inbound.ts +6 -3
- package/runtime/node_modules/@metro-labs/daemon/src/channels/notice.ts +22 -0
- package/runtime/node_modules/@metro-labs/daemon/src/claude/session.ts +3 -1
- package/runtime/node_modules/@metro-labs/daemon/src/claude/setup.ts +1 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/gates.ts +1 -11
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-adopt.ts +57 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-runtime.ts +39 -95
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync-control.ts +27 -45
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync-watch.ts +2 -17
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/plugin-sync.ts +12 -37
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/publication.ts +8 -29
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay-session.ts +0 -1
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay.ts +33 -138
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/server-keys.ts +22 -9
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/index.ts +2 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/permission-prompt.ts +27 -8
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/permission-relay.ts +6 -4
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/server-approvals.ts +109 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp-server/config.ts +29 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp-server/link.ts +186 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp-server/token.ts +50 -0
- package/runtime/node_modules/@metro-labs/daemon/src/routes/local-mode.ts +4 -58
- package/runtime/node_modules/@metro-labs/daemon/src/routes/session-apis.ts +0 -12
- package/runtime/node_modules/@metro-labs/daemon/src/stations/materialize.ts +0 -7
- package/runtime/node_modules/@metro-labs/http/package.json +2 -1
- package/runtime/node_modules/@metro-labs/http/src/connector-tools.ts +2 -1
- package/runtime/node_modules/@metro-labs/http/src/mcp-server-protocol.ts +81 -0
- package/runtime/runtime.json +1 -1
- package/runtime/sdk-runner/runner.json +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/api.ts +0 -316
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/config.ts +0 -105
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/health.ts +0 -18
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-api.ts +0 -60
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-control.ts +0 -366
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-guard.ts +0 -95
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-operations.ts +0 -226
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-preview.ts +0 -235
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-source.ts +0 -42
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-state.ts +0 -142
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-url.ts +0 -9
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/oauth-routes.ts +0 -178
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/oauth.ts +0 -171
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay-passive.ts +0 -96
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/store.ts +0 -393
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/transfer-api.ts +0 -116
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/transfer-crypto.ts +0 -44
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/transfer-store.ts +0 -46
|
@@ -1,226 +0,0 @@
|
|
|
1
|
-
import { createHash, randomBytes } from 'node:crypto';
|
|
2
|
-
import { closeSync, existsSync, fsyncSync, openSync, readFileSync, writeFileSync } from 'node:fs';
|
|
3
|
-
import { join, resolve } from 'node:path';
|
|
4
|
-
import { isRecord } from '@metro-labs/core/is-record';
|
|
5
|
-
import { parseId } from '@metro-labs/core/ids';
|
|
6
|
-
import { ensureSecureDir, writeSecure } from '@metro-labs/core/secure-fs';
|
|
7
|
-
import { ApiError } from '@metro-labs/http/api-error';
|
|
8
|
-
import { agentsDir } from '../agents/files.js';
|
|
9
|
-
|
|
10
|
-
interface RequestOperation {
|
|
11
|
-
id: string;
|
|
12
|
-
source: string;
|
|
13
|
-
dir: string;
|
|
14
|
-
lease: string;
|
|
15
|
-
durable: boolean;
|
|
16
|
-
abandoned: boolean;
|
|
17
|
-
refresh: boolean;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
interface PassiveListener {
|
|
21
|
-
idle: () => boolean;
|
|
22
|
-
stop: () => void;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export interface MigrationOperation {
|
|
26
|
-
passiveListener: (listener: PassiveListener) => void;
|
|
27
|
-
dispatch: (send: () => Promise<Response>, options?: { protectedSource?: boolean; effectful?: boolean; refresh?: boolean }) => Promise<Response>;
|
|
28
|
-
close: () => void;
|
|
29
|
-
persistedRefresh: () => void;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
const leases = new Map<string, Set<string>>();
|
|
33
|
-
const listeners = new Map<string, PassiveListener>();
|
|
34
|
-
const requests = new Map<string, RequestOperation>();
|
|
35
|
-
const held = new Map<string, RequestOperation>();
|
|
36
|
-
const keyOf = (source: string, dir: string): string => JSON.stringify([resolve(dir), source]);
|
|
37
|
-
const unavailable = (): ApiError => new ApiError('Protected connector request records cannot be read. Restore their files before using this connector.', 503);
|
|
38
|
-
const uncertain = (): ApiError => new ApiError('A previous connector request has an unknown outcome. Inspect the vendor and acknowledge it before continuing. Do not retry the tool.', 409);
|
|
39
|
-
const CONTROL = new Set(['initialize', 'ping', 'tools/list', 'notifications/initialized', 'notifications/cancelled']);
|
|
40
|
-
|
|
41
|
-
export function migrationRequestEffectful(method: string | undefined, body: unknown): boolean {
|
|
42
|
-
const verb = (method ?? 'GET').toUpperCase();
|
|
43
|
-
if (['GET', 'HEAD', 'DELETE'].includes(verb)) return false;
|
|
44
|
-
if (verb !== 'POST') return true;
|
|
45
|
-
const text = typeof body === 'string' ? body : body instanceof Uint8Array ? new TextDecoder().decode(body) : null;
|
|
46
|
-
if (text === null) return true;
|
|
47
|
-
try {
|
|
48
|
-
const value: unknown = JSON.parse(text);
|
|
49
|
-
const batch: unknown[] = Array.isArray(value) ? value : [value];
|
|
50
|
-
return batch.length === 0 || batch.some((item) => !isRecord(item) || item.jsonrpc !== '2.0' || typeof item.method !== 'string' || !CONTROL.has(item.method));
|
|
51
|
-
} catch { return true; }
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
function paths(source: string, dir: string): { data: string; marker: string } {
|
|
55
|
-
if (parseId(source) === null) throw unavailable();
|
|
56
|
-
const data = join(dir, `connector-migration-operations-${source}.json`);
|
|
57
|
-
return { data, marker: `${data}.initialized` };
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
function flush(path: string): void {
|
|
61
|
-
const fd = openSync(path, 'r');
|
|
62
|
-
try { fsyncSync(fd); } finally { closeSync(fd); }
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
function pendingOf(text: string): string[] {
|
|
66
|
-
const value: unknown = JSON.parse(text);
|
|
67
|
-
if (!isRecord(value) || value.version !== 1 || !Array.isArray(value.pending) || value.pending.length > 10_000) throw unavailable();
|
|
68
|
-
const ids: unknown[] = value.pending;
|
|
69
|
-
if (!ids.every((id): id is string => typeof id === 'string' && /^r?[a-f0-9]{32}$/.test(id))) throw unavailable();
|
|
70
|
-
if (new Set(ids).size !== ids.length) throw unavailable();
|
|
71
|
-
return ids;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
function readPending(source: string, dir: string, initialize = false): string[] {
|
|
75
|
-
const { data, marker } = paths(source, dir);
|
|
76
|
-
try {
|
|
77
|
-
if (initialize && !existsSync(data) && !existsSync(marker)) return [];
|
|
78
|
-
if (readFileSync(marker, 'utf8') !== '1\n') throw unavailable();
|
|
79
|
-
return pendingOf(readFileSync(data, 'utf8'));
|
|
80
|
-
} catch { throw unavailable(); }
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
function writePending(source: string, dir: string, pending: string[]): void {
|
|
84
|
-
const { data, marker } = paths(source, dir);
|
|
85
|
-
ensureSecureDir(dir);
|
|
86
|
-
if (!existsSync(marker)) {
|
|
87
|
-
writeFileSync(marker, '1\n', { flag: 'wx', mode: 0o600 });
|
|
88
|
-
flush(marker);
|
|
89
|
-
flush(dir);
|
|
90
|
-
}
|
|
91
|
-
writeSecure(data, `${JSON.stringify({ version: 1, pending })}\n`);
|
|
92
|
-
flush(data);
|
|
93
|
-
flush(dir);
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
export function captureMigrationOperations(source: string, dir: string, initialize: boolean): void {
|
|
97
|
-
const pending = new Set(readPending(source, dir, initialize));
|
|
98
|
-
const active = [...held.values()].filter((request) => keyOf(request.source, request.dir) === keyOf(source, dir));
|
|
99
|
-
for (const request of active) pending.add(request.id);
|
|
100
|
-
writePending(source, dir, [...pending]);
|
|
101
|
-
for (const request of active) request.durable = true;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
export function migrationUncertaintyReceipt(source: string, dir = agentsDir()): string | null {
|
|
105
|
-
const unknown = readPending(source, dir).filter((id) => !requests.has(id)).sort();
|
|
106
|
-
return unknown.length === 0 ? null : createHash('sha256').update(JSON.stringify([1, source, unknown])).digest('hex');
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
export function assertMigrationOutcomeKnown(source: string, dir = agentsDir()): void {
|
|
110
|
-
if (migrationUncertaintyReceipt(source, dir) !== null) throw uncertain();
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
export function drainMigrationListeners(source: string, dir: string): void {
|
|
114
|
-
const active = [...(leases.get(keyOf(source, dir)) ?? [])];
|
|
115
|
-
if (active.length === 0 || active.some((lease) => listeners.get(lease)?.idle() !== true)) return;
|
|
116
|
-
if (readPending(source, dir).some((id) => requests.has(id))) return;
|
|
117
|
-
for (const lease of active) {
|
|
118
|
-
const listener = listeners.get(lease);
|
|
119
|
-
listeners.delete(lease);
|
|
120
|
-
listener?.stop();
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
export function migrationOperationsIdle(source: string, dir: string, protectedSource: boolean): boolean {
|
|
125
|
-
if (leases.has(keyOf(source, dir))) return false;
|
|
126
|
-
return !protectedSource || readPending(source, dir).length === 0;
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
export function resolveMigrationUncertainty(source: string, expectedReceipt: string, dir = agentsDir()): boolean {
|
|
130
|
-
if (leases.has(keyOf(source, dir))) return false;
|
|
131
|
-
const receipt = migrationUncertaintyReceipt(source, dir);
|
|
132
|
-
if (receipt === null || receipt !== expectedReceipt) return false;
|
|
133
|
-
writePending(source, dir, []);
|
|
134
|
-
return true;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
function finish(request: RequestOperation, completed: boolean, persisted = false): void {
|
|
138
|
-
if (request.refresh && completed && !persisted) return;
|
|
139
|
-
if (request.abandoned) return;
|
|
140
|
-
requests.delete(request.id);
|
|
141
|
-
if (completed) held.delete(request.id);
|
|
142
|
-
if (completed && request.durable) writePending(request.source, request.dir, readPending(request.source, request.dir).filter((id) => id !== request.id));
|
|
143
|
-
request.abandoned = true;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
function observed(response: Response, request: RequestOperation): Response {
|
|
147
|
-
if (response.status === 401 || response.status === 403) {
|
|
148
|
-
finish(request, true);
|
|
149
|
-
return response;
|
|
150
|
-
}
|
|
151
|
-
if (response.body === null) {
|
|
152
|
-
finish(request, response.ok);
|
|
153
|
-
return response;
|
|
154
|
-
}
|
|
155
|
-
const reader = response.body.getReader();
|
|
156
|
-
const body = new ReadableStream<Uint8Array>({
|
|
157
|
-
async pull(controller) {
|
|
158
|
-
try {
|
|
159
|
-
const result = await reader.read();
|
|
160
|
-
if (result.done) {
|
|
161
|
-
finish(request, response.ok);
|
|
162
|
-
controller.close();
|
|
163
|
-
} else controller.enqueue(result.value);
|
|
164
|
-
} catch (error) {
|
|
165
|
-
finish(request, false);
|
|
166
|
-
controller.error(error);
|
|
167
|
-
}
|
|
168
|
-
},
|
|
169
|
-
async cancel(reason: unknown) {
|
|
170
|
-
finish(request, false);
|
|
171
|
-
await reader.cancel(reason);
|
|
172
|
-
},
|
|
173
|
-
});
|
|
174
|
-
return new Response(body, { status: response.status, statusText: response.statusText, headers: response.headers });
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
async function dispatch(source: string, dir: string, lease: string, protectedSource: boolean, effectful: boolean, refresh: boolean, send: () => Promise<Response>): Promise<Response> {
|
|
178
|
-
if (protectedSource) assertMigrationOutcomeKnown(source, dir);
|
|
179
|
-
if (!effectful) return send();
|
|
180
|
-
const request: RequestOperation = { id: `${refresh ? 'r' : ''}${randomBytes(16).toString('hex')}`, source, dir, lease, durable: false, abandoned: false, refresh };
|
|
181
|
-
if (protectedSource) {
|
|
182
|
-
const pending = readPending(source, dir);
|
|
183
|
-
if (pending.length >= 10_000) throw unavailable();
|
|
184
|
-
writePending(source, dir, [...pending, request.id]);
|
|
185
|
-
request.durable = true;
|
|
186
|
-
}
|
|
187
|
-
requests.set(request.id, request);
|
|
188
|
-
held.set(request.id, request);
|
|
189
|
-
try { return observed(await send(), request); }
|
|
190
|
-
catch (error) { finish(request, false); throw error; }
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
export function beginMigrationOperation(source: string, dir: string, protectedSource: boolean): MigrationOperation {
|
|
194
|
-
if (protectedSource) assertMigrationOutcomeKnown(source, dir);
|
|
195
|
-
const key = keyOf(source, dir);
|
|
196
|
-
const lease = randomBytes(16).toString('hex');
|
|
197
|
-
const active = leases.get(key) ?? new Set<string>();
|
|
198
|
-
active.add(lease);
|
|
199
|
-
leases.set(key, active);
|
|
200
|
-
let closed = false;
|
|
201
|
-
return {
|
|
202
|
-
passiveListener: (listener) => { if (!closed) listeners.set(lease, listener); },
|
|
203
|
-
dispatch: (send, options = {}) => {
|
|
204
|
-
if (closed) return Promise.reject(uncertain());
|
|
205
|
-
return dispatch(source, dir, lease, options.protectedSource ?? protectedSource, options.effectful ?? true, options.refresh ?? false, send);
|
|
206
|
-
},
|
|
207
|
-
persistedRefresh: () => {
|
|
208
|
-
for (const request of held.values()) if (request.lease === lease && request.refresh) finish(request, true, true);
|
|
209
|
-
},
|
|
210
|
-
close: () => {
|
|
211
|
-
if (closed) return;
|
|
212
|
-
closed = true;
|
|
213
|
-
listeners.delete(lease);
|
|
214
|
-
for (const request of held.values()) if (request.lease === lease) {
|
|
215
|
-
finish(request, false);
|
|
216
|
-
held.delete(request.id);
|
|
217
|
-
}
|
|
218
|
-
active.delete(lease);
|
|
219
|
-
if (active.size === 0) leases.delete(key);
|
|
220
|
-
},
|
|
221
|
-
};
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
export function migrationRefreshUncertain(source: string, dir = agentsDir()): boolean {
|
|
225
|
-
return readPending(source, dir).some((id) => id.startsWith('r') && !requests.has(id));
|
|
226
|
-
}
|
|
@@ -1,235 +0,0 @@
|
|
|
1
|
-
import { readFileSync } from 'node:fs';
|
|
2
|
-
import { join } from 'node:path';
|
|
3
|
-
import { parseId } from '@metro-labs/core/ids';
|
|
4
|
-
import { isRecord } from '@metro-labs/core/is-record';
|
|
5
|
-
import { ApiError } from '@metro-labs/http/api-error';
|
|
6
|
-
import { bodyField } from '@metro-labs/http/api-http';
|
|
7
|
-
import { connectorAuth, connectorClient, connectorName } from '@metro-labs/http/connector-input';
|
|
8
|
-
import { portableGrant } from '@metro-labs/http/connector-grant';
|
|
9
|
-
import { connectorPolicyOf } from '@metro-labs/http/connector-delivery';
|
|
10
|
-
import { readConfig } from './config.js';
|
|
11
|
-
import type { LocalConnectorRow } from './store.js';
|
|
12
|
-
import { agentsDir } from '../agents/files.js';
|
|
13
|
-
import { normalizePolicy, type ToolPolicy } from '../policy/policy.js';
|
|
14
|
-
import { connectorSyncScope, syncOptedIn, syncSetting } from './org-sync-setting.js';
|
|
15
|
-
import { readAgentKey } from './agent-key.js';
|
|
16
|
-
import { MigrationBlocked } from './migration-source.js';
|
|
17
|
-
import { migrationOrigin } from './migration-url.js';
|
|
18
|
-
|
|
19
|
-
export type MigrationIssue = 'invalid_config' | 'unsupported_url' | 'custom_policy' | 'oauth_sign_in' | 'header_reentry' | 'unused_oauth_client' | 'oauth_unsupported';
|
|
20
|
-
|
|
21
|
-
export interface MigrationConnector {
|
|
22
|
-
id: string;
|
|
23
|
-
name: string;
|
|
24
|
-
auth: 'none' | 'header' | 'oauth' | 'invalid';
|
|
25
|
-
origin: string | null;
|
|
26
|
-
policy: ToolPolicy;
|
|
27
|
-
policySupported: boolean;
|
|
28
|
-
issues: MigrationIssue[];
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
export interface ConnectorMigrationPreview {
|
|
32
|
-
migrationSupported: true;
|
|
33
|
-
oauthResourceSupported: true;
|
|
34
|
-
moveAllSupported: true;
|
|
35
|
-
enabled: boolean;
|
|
36
|
-
enrolled: boolean;
|
|
37
|
-
agentId: string | null;
|
|
38
|
-
owner: string | null;
|
|
39
|
-
liveEnabled: boolean;
|
|
40
|
-
connectors: MigrationConnector[];
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
const invalid = (): never => { throw new Error('Invalid local connector configuration.'); };
|
|
44
|
-
const onlyKeys = (raw: Record<string, unknown>, keys: readonly string[]): boolean => Object.keys(raw).every((key) => keys.includes(key));
|
|
45
|
-
const nonempty = (value: unknown): value is string => typeof value === 'string' && value.trim() !== '';
|
|
46
|
-
|
|
47
|
-
function optionalStrings(raw: Record<string, unknown>, keys: readonly string[]): boolean {
|
|
48
|
-
return keys.every((key) => raw[key] === undefined || typeof raw[key] === 'string');
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
function validClient(raw: unknown): boolean {
|
|
52
|
-
if (raw === undefined || raw === null) return true;
|
|
53
|
-
if (!isRecord(raw) || !onlyKeys(raw, ['clientId', 'clientSecret'])) return false;
|
|
54
|
-
if (!nonempty(raw.clientId) || !optionalStrings(raw, ['clientSecret'])) return false;
|
|
55
|
-
const client = connectorClient(raw.clientId, raw.clientSecret);
|
|
56
|
-
return client !== null && client.clientId === raw.clientId && client.clientSecret === raw.clientSecret;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
function oauthAuth(raw: Record<string, unknown>): 'oauth' {
|
|
60
|
-
if (!onlyKeys(raw, ['kind', 'accessToken', 'refreshToken', 'clientId', 'clientSecret', 'tokenEndpoint', 'issuer', 'scope', 'expiresAt', 'resource', 'tokenEndpointAuthMethod'])) invalid();
|
|
61
|
-
return 'oauth';
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
function headerAuth(raw: Record<string, unknown>): 'header' {
|
|
65
|
-
if (raw.kind !== 'header' || !onlyKeys(raw, ['kind', 'name', 'value'])) return invalid();
|
|
66
|
-
if (!nonempty(raw.name) || !nonempty(raw.value)) return invalid();
|
|
67
|
-
const auth = connectorAuth(raw.name, raw.value);
|
|
68
|
-
return auth.kind === 'header' && auth.name === raw.name && auth.value === raw.value ? 'header' : invalid();
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
function authKind(raw: unknown): Exclude<MigrationConnector['auth'], 'invalid'> {
|
|
72
|
-
if (!isRecord(raw)) return invalid();
|
|
73
|
-
if (raw.kind === 'oauth') return oauthAuth(raw);
|
|
74
|
-
if (raw.kind === 'none' && onlyKeys(raw, ['kind'])) return 'none';
|
|
75
|
-
return headerAuth(raw);
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
function metadataValid(config: Record<string, unknown>): boolean {
|
|
79
|
-
if (!optionalStrings(config, ['createdAt'])) return false;
|
|
80
|
-
const verified = config.verified;
|
|
81
|
-
if (verified !== undefined && (!isRecord(verified) || !optionalStrings(verified, ['at', 'server']))) return false;
|
|
82
|
-
if (config.oauth !== undefined && typeof config.oauth !== 'boolean') return false;
|
|
83
|
-
const groups = config.toolGroups;
|
|
84
|
-
if (groups === undefined) return true;
|
|
85
|
-
if (!isRecord(groups) || Object.keys(groups).length > 1000) return false;
|
|
86
|
-
return Object.entries(groups).every(([name, group]) => name.length > 0 && name.length <= 128 && (group === 'read' || group === 'write'));
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
function configAuth(config: unknown): MigrationConnector['auth'] {
|
|
90
|
-
try {
|
|
91
|
-
if (!isRecord(config) || !onlyKeys(config, ['auth', 'oauth', 'client', 'createdAt', 'verified', 'policy', 'toolGroups'])) return 'invalid';
|
|
92
|
-
if (!metadataValid(config) || !validClient(config.client)) return 'invalid';
|
|
93
|
-
const kind = authKind(config.auth);
|
|
94
|
-
if (kind === 'header' && config.oauth === true) return 'invalid';
|
|
95
|
-
return kind === 'none' && config.oauth === true ? 'oauth' : kind;
|
|
96
|
-
} catch {
|
|
97
|
-
return 'invalid';
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
function policyShape(raw: Record<string, unknown>): boolean {
|
|
102
|
-
if (!onlyKeys(raw, ['read', 'write', 'tools'])) return false;
|
|
103
|
-
for (const key of ['read', 'write']) if (Object.hasOwn(raw, key) && typeof raw[key] !== 'string') return false;
|
|
104
|
-
return raw.tools === undefined || (isRecord(raw.tools) && Object.values(raw.tools).every((value) => typeof value === 'string'));
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
function strictPolicy(raw: unknown): ToolPolicy {
|
|
108
|
-
if (raw === undefined) return {};
|
|
109
|
-
if (!isRecord(raw) || !policyShape(raw)) return invalid();
|
|
110
|
-
const policy = normalizePolicy(raw);
|
|
111
|
-
if (isRecord(raw.tools)) {
|
|
112
|
-
policy.tools = Object.fromEntries(Object.entries(raw.tools).map(([name, value]) => {
|
|
113
|
-
const { read } = normalizePolicy({ read: value });
|
|
114
|
-
if (read === undefined) return invalid();
|
|
115
|
-
return [name, read];
|
|
116
|
-
}));
|
|
117
|
-
}
|
|
118
|
-
return policy;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
function policyPreview(raw: unknown): Pick<MigrationConnector, 'policy' | 'policySupported'> & { valid: boolean } {
|
|
122
|
-
try {
|
|
123
|
-
const policy = strictPolicy(raw);
|
|
124
|
-
let policySupported = false;
|
|
125
|
-
try {
|
|
126
|
-
connectorPolicyOf(policy);
|
|
127
|
-
policySupported = true;
|
|
128
|
-
} catch {
|
|
129
|
-
policySupported = false;
|
|
130
|
-
}
|
|
131
|
-
return { policy, policySupported, valid: true };
|
|
132
|
-
} catch {
|
|
133
|
-
return { policy: {}, policySupported: false, valid: false };
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
function nameOf(raw: unknown): string | null {
|
|
138
|
-
try {
|
|
139
|
-
return connectorName(raw);
|
|
140
|
-
} catch {
|
|
141
|
-
return null;
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
function rowIdentity(row: Record<string, unknown>, index: number): { id: string; name: string; valid: boolean } {
|
|
146
|
-
const id = parseId(row.id);
|
|
147
|
-
const name = nameOf(row.name);
|
|
148
|
-
return { id: id ?? `invalid-${String(index + 1)}`, name: name ?? `Connector ${String(index + 1)}`, valid: id !== null && name !== null };
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
function grantIssues(row: Record<string, unknown>): MigrationIssue[] {
|
|
152
|
-
const grant = bodyField(row.config, 'auth');
|
|
153
|
-
if (!isRecord(grant) || grant.kind !== 'oauth') return ['oauth_sign_in'];
|
|
154
|
-
try {
|
|
155
|
-
const portable = portableGrant(grant, String(row.url));
|
|
156
|
-
return portable.expiresAt !== undefined && portable.expiresAt <= Date.now() && portable.refreshToken === undefined ? ['oauth_sign_in'] : [];
|
|
157
|
-
} catch { return ['oauth_unsupported']; }
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
function authIssues(row: Record<string, unknown>, auth: MigrationConnector['auth']): MigrationIssue[] {
|
|
161
|
-
if (auth === 'oauth') return grantIssues(row);
|
|
162
|
-
const client = bodyField(row.config, 'client');
|
|
163
|
-
return (auth === 'none' || auth === 'header') && client !== undefined && client !== null ? ['unused_oauth_client'] : [];
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
function previewRow(raw: unknown, index: number): MigrationConnector {
|
|
167
|
-
const row = isRecord(raw) ? raw : {};
|
|
168
|
-
const identity = rowIdentity(row, index);
|
|
169
|
-
const auth = configAuth(row.config);
|
|
170
|
-
const { policy, policySupported, valid } = policyPreview(bodyField(row.config, 'policy'));
|
|
171
|
-
const origin = migrationOrigin(row.url);
|
|
172
|
-
const issues: MigrationIssue[] = [];
|
|
173
|
-
if (!identity.valid || auth === 'invalid' || !valid) issues.push('invalid_config');
|
|
174
|
-
if (origin === null) issues.push('unsupported_url');
|
|
175
|
-
if (valid && !policySupported) issues.push('custom_policy');
|
|
176
|
-
issues.push(...authIssues(row, auth));
|
|
177
|
-
return { id: identity.id, name: identity.name, auth, origin, policy, policySupported, issues };
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
const unavailable = (): ApiError => new ApiError('Local connector configuration cannot be previewed. Restore its file or permissions first.', 503);
|
|
181
|
-
|
|
182
|
-
function readRows(dir: string): unknown[] {
|
|
183
|
-
let text: string;
|
|
184
|
-
try {
|
|
185
|
-
text = readFileSync(join(dir, 'connectors.json'), 'utf8');
|
|
186
|
-
} catch (err) {
|
|
187
|
-
if (isRecord(err) && err.code === 'ENOENT') return [];
|
|
188
|
-
throw unavailable();
|
|
189
|
-
}
|
|
190
|
-
let raw: unknown;
|
|
191
|
-
try {
|
|
192
|
-
raw = JSON.parse(text);
|
|
193
|
-
} catch {
|
|
194
|
-
throw unavailable();
|
|
195
|
-
}
|
|
196
|
-
if (!isRecord(raw) || raw.version !== 1 || !Array.isArray(raw.connectors)) throw unavailable();
|
|
197
|
-
return raw.connectors;
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
function previewRows(dir: string): MigrationConnector[] {
|
|
201
|
-
const rows = readRows(dir).map(previewRow);
|
|
202
|
-
const seen = new Map<string, MigrationConnector>();
|
|
203
|
-
for (const row of rows) {
|
|
204
|
-
const prior = seen.get(row.id);
|
|
205
|
-
if (prior !== undefined) {
|
|
206
|
-
for (const duplicate of [prior, row]) if (!duplicate.issues.includes('invalid_config')) duplicate.issues.push('invalid_config');
|
|
207
|
-
}
|
|
208
|
-
seen.set(row.id, row);
|
|
209
|
-
}
|
|
210
|
-
return rows;
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
function assertTransferable(source: MigrationConnector): void {
|
|
214
|
-
if (source.issues.includes('oauth_sign_in')) throw new MigrationBlocked('sign_in', 'No usable local OAuth grant is saved. Sign in locally before moving it.');
|
|
215
|
-
if (source.issues.includes('oauth_unsupported')) throw new MigrationBlocked('unsupported_grant', 'This OAuth grant has unsupported client authentication or provider binding. Keep it local or sign in separately.');
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
export function migrationSource(id: string, dir = agentsDir(), transfer = false): LocalConnectorRow {
|
|
219
|
-
const source = previewRows(dir).find((row) => row.id === id);
|
|
220
|
-
if (source === undefined) throw new ApiError('The local connector is missing.', 404);
|
|
221
|
-
if (source.issues.some((issue) => issue !== 'oauth_sign_in' && issue !== 'oauth_unsupported')) throw new ApiError('This connector cannot be migrated without changing its settings or permissions. Resolve the preview blockers first.', 409);
|
|
222
|
-
if (transfer) assertTransferable(source);
|
|
223
|
-
const raw = readRows(dir).find((row) => isRecord(row) && row.id === id);
|
|
224
|
-
if (!isRecord(raw) || typeof raw.url !== 'string') throw unavailable();
|
|
225
|
-
return { id, name: source.name, url: raw.url, config: { ...readConfig(raw.config), oauth: source.auth === 'oauth', policy: source.policy } };
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
export function connectorMigrationPreview(owner: string | null, dir = agentsDir()): ConnectorMigrationPreview {
|
|
229
|
-
const enabled = syncOptedIn(dir) && syncSetting(dir, connectorSyncScope(owner, readAgentKey(dir))).enabled;
|
|
230
|
-
const liveEnabled = process.env.METRO_CONNECTORS_LIVE_ENABLED === 'true';
|
|
231
|
-
if (!enabled) return { migrationSupported: true, oauthResourceSupported: true, moveAllSupported: true, enabled, enrolled: false, agentId: null, owner, liveEnabled, connectors: [] };
|
|
232
|
-
const enrollment = owner === null ? null : readAgentKey(dir)?.enrollment;
|
|
233
|
-
const agentId = enrollment?.organization === owner ? enrollment?.server ?? null : null;
|
|
234
|
-
return { migrationSupported: true, oauthResourceSupported: true, moveAllSupported: true, enabled, enrolled: agentId !== null, agentId, owner, liveEnabled, connectors: previewRows(dir) };
|
|
235
|
-
}
|
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
import { createHash } from 'node:crypto';
|
|
2
|
-
import type { MigrationPayload } from '@metro-labs/http/connector-migration';
|
|
3
|
-
import { connectorPolicyOf } from '@metro-labs/http/connector-delivery';
|
|
4
|
-
import { ApiError } from '@metro-labs/http/api-error';
|
|
5
|
-
import { portableGrant } from '@metro-labs/http/connector-grant';
|
|
6
|
-
import { boundOAuthResource } from '@metro-labs/http/connector-public-url';
|
|
7
|
-
import type { LocalConnectorRow } from './store.js';
|
|
8
|
-
|
|
9
|
-
export class MigrationBlocked extends ApiError {
|
|
10
|
-
constructor(readonly reason: string, message: string) { super(message, 409); }
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
export function migrationPayload(row: LocalConnectorRow, oauthResource?: string, transfer = false): MigrationPayload {
|
|
14
|
-
const oauth = row.config.oauth || row.config.auth.kind === 'oauth';
|
|
15
|
-
const auth = sourceAuth(row, oauth, transfer);
|
|
16
|
-
if (!oauth && oauthResource !== undefined) throw new ApiError('Only an OAuth connector accepts a public OAuth resource.', 400);
|
|
17
|
-
return stamped({
|
|
18
|
-
name: row.name, url: row.url, auth, client: row.config.client, policy: connectorPolicyOf(row.config.policy ?? {}),
|
|
19
|
-
...(oauthResource === undefined ? {} : { oauthResource: boundOAuthResource(row.url, oauthResource) }),
|
|
20
|
-
});
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
function sourceAuth(row: LocalConnectorRow, oauth: boolean, transfer: boolean): MigrationPayload['auth'] {
|
|
24
|
-
if (!oauth) return row.config.auth;
|
|
25
|
-
return transfer ? { kind: 'oauth', grant: transferableGrant(row) } : { kind: 'oauth' };
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
function stamped(value: Omit<MigrationPayload, 'sourceStamp'>): MigrationPayload {
|
|
29
|
-
const policy = value.policy;
|
|
30
|
-
const canonical = {
|
|
31
|
-
...value,
|
|
32
|
-
policy: { read: policy.read ?? 'allow', write: policy.write ?? 'allow', tools: Object.entries(policy.tools ?? {}).sort(([a], [b]) => a.localeCompare(b)) },
|
|
33
|
-
};
|
|
34
|
-
const sourceStamp = createHash('sha256').update(JSON.stringify(canonical)).digest('base64url');
|
|
35
|
-
return { ...value, sourceStamp };
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
function transferableGrant(row: LocalConnectorRow): ReturnType<typeof portableGrant> {
|
|
39
|
-
if (row.config.auth.kind !== 'oauth') throw new MigrationBlocked('sign_in', 'This connector has no saved OAuth grant. Sign in locally before moving it.');
|
|
40
|
-
try { return portableGrant(row.config.auth, row.url); }
|
|
41
|
-
catch { throw new MigrationBlocked('unsupported_grant', 'This OAuth grant has unsupported client authentication or provider binding. Keep it local or sign in separately.'); }
|
|
42
|
-
}
|
|
@@ -1,142 +0,0 @@
|
|
|
1
|
-
import { randomBytes } from 'node:crypto';
|
|
2
|
-
import { closeSync, existsSync, fsyncSync, openSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
-
import { join } from 'node:path';
|
|
4
|
-
import { isRecord } from '@metro-labs/core/is-record';
|
|
5
|
-
import { parseId } from '@metro-labs/core/ids';
|
|
6
|
-
import { ensureSecureDir } from '@metro-labs/core/secure-fs';
|
|
7
|
-
import { ApiError } from '@metro-labs/http/api-error';
|
|
8
|
-
import { connectorPolicyOf, type ConnectorPolicy } from '@metro-labs/http/connector-delivery';
|
|
9
|
-
import { publicOAuthResource } from '@metro-labs/http/connector-public-url';
|
|
10
|
-
import { agentsDir } from '../agents/files.js';
|
|
11
|
-
import { captureMigrationOperations } from './migration-operations.js';
|
|
12
|
-
|
|
13
|
-
export type MigrationPhase = 'recovering' | 'freezing' | 'preparing' | 'prepared' | 'cutover' | 'active' | 'rollback' | 'rolled_back';
|
|
14
|
-
export type MigrationAuth = 'none' | 'header' | 'oauth';
|
|
15
|
-
|
|
16
|
-
export interface MigrationRecord {
|
|
17
|
-
id: string;
|
|
18
|
-
owner: string;
|
|
19
|
-
agentId: string;
|
|
20
|
-
keyId: string;
|
|
21
|
-
sourceId: string;
|
|
22
|
-
name: string;
|
|
23
|
-
destinationId: string | null;
|
|
24
|
-
phase: MigrationPhase;
|
|
25
|
-
auth: MigrationAuth;
|
|
26
|
-
oauthResource?: string;
|
|
27
|
-
move?: boolean;
|
|
28
|
-
grantImported?: boolean;
|
|
29
|
-
captured?: boolean;
|
|
30
|
-
recoveryReceipt?: string;
|
|
31
|
-
sourceStamp: string;
|
|
32
|
-
policy: ConnectorPolicy;
|
|
33
|
-
reason: string | null;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
const phases: readonly MigrationPhase[] = ['recovering', 'freezing', 'preparing', 'prepared', 'cutover', 'active', 'rollback', 'rolled_back'];
|
|
37
|
-
const auths: readonly MigrationAuth[] = ['none', 'header', 'oauth'];
|
|
38
|
-
const fields = ['id', 'owner', 'agentId', 'keyId', 'sourceId', 'name', 'destinationId', 'phase', 'auth', 'oauthResource', 'sourceStamp', 'policy', 'reason', 'move', 'grantImported', 'captured', 'recoveryReceipt'];
|
|
39
|
-
const unavailable = (): ApiError => new ApiError('Protected connector migration records cannot be read. Restore their file or permissions before using connectors.', 503);
|
|
40
|
-
const DIGEST = /^[A-Za-z0-9_-]{43}$/;
|
|
41
|
-
const pathOf = (dir: string): string => join(dir, 'connector-migrations.json');
|
|
42
|
-
const markerOf = (dir: string): string => join(dir, 'connector-migrations.initialized');
|
|
43
|
-
|
|
44
|
-
function requiredId(value: unknown): string {
|
|
45
|
-
const id = parseId(value);
|
|
46
|
-
if (id === null) throw unavailable();
|
|
47
|
-
return id;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
function field(value: unknown, pattern: RegExp): string {
|
|
51
|
-
if (typeof value !== 'string' || !pattern.test(value)) throw unavailable();
|
|
52
|
-
return value;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
function movementOf(value: Record<string, unknown>): Pick<MigrationRecord, 'move' | 'grantImported' | 'captured' | 'recoveryReceipt'> {
|
|
56
|
-
const result: Pick<MigrationRecord, 'move' | 'grantImported' | 'captured' | 'recoveryReceipt'> = {};
|
|
57
|
-
for (const key of ['move', 'grantImported', 'captured'] as const) {
|
|
58
|
-
const flag = value[key];
|
|
59
|
-
if (flag === undefined) continue;
|
|
60
|
-
if (typeof flag !== 'boolean') throw unavailable();
|
|
61
|
-
result[key] = flag;
|
|
62
|
-
}
|
|
63
|
-
if (value.recoveryReceipt !== undefined) result.recoveryReceipt = field(value.recoveryReceipt, /^[a-f0-9]{64}$/);
|
|
64
|
-
return result;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
function recordOf(value: unknown): MigrationRecord {
|
|
68
|
-
if (!isRecord(value) || Object.keys(value).some((key) => !fields.includes(key))) throw unavailable();
|
|
69
|
-
const phase = phases.find((item) => item === value.phase);
|
|
70
|
-
const auth = auths.find((item) => item === value.auth);
|
|
71
|
-
if (phase === undefined || auth === undefined || (value.oauthResource !== undefined && auth !== 'oauth')) throw unavailable();
|
|
72
|
-
return {
|
|
73
|
-
...movementOf(value),
|
|
74
|
-
...(value.oauthResource === undefined ? {} : { oauthResource: publicOAuthResource(value.oauthResource) }),
|
|
75
|
-
id: requiredId(value.id), sourceId: requiredId(value.sourceId), agentId: requiredId(value.agentId),
|
|
76
|
-
owner: field(value.owner, /^org_[A-Za-z0-9]{1,128}$/), keyId: field(value.keyId, DIGEST), sourceStamp: field(value.sourceStamp, DIGEST),
|
|
77
|
-
name: field(value.name, /^.{1,200}$/), destinationId: value.destinationId === null ? null : requiredId(value.destinationId),
|
|
78
|
-
phase, auth, policy: connectorPolicyOf(value.policy), reason: value.reason === null ? null : field(value.reason, /^[a-z_]{1,64}$/),
|
|
79
|
-
};
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
function recordsOf(text: string): MigrationRecord[] {
|
|
83
|
-
const value: unknown = JSON.parse(text);
|
|
84
|
-
if (!isRecord(value) || value.version !== 1 || !Array.isArray(value.records) || value.records.length > 500) throw unavailable();
|
|
85
|
-
const records = value.records.map(recordOf);
|
|
86
|
-
if (new Set(records.map((record) => record.sourceId)).size !== records.length) throw unavailable();
|
|
87
|
-
return records;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
export function migrationRecords(dir = agentsDir()): MigrationRecord[] {
|
|
91
|
-
try {
|
|
92
|
-
const marked = existsSync(markerOf(dir));
|
|
93
|
-
if (marked && readFileSync(markerOf(dir), 'utf8') !== '1\n') throw unavailable();
|
|
94
|
-
if (!existsSync(pathOf(dir))) {
|
|
95
|
-
if (marked) throw unavailable();
|
|
96
|
-
return [];
|
|
97
|
-
}
|
|
98
|
-
if (!marked) throw unavailable();
|
|
99
|
-
return recordsOf(readFileSync(pathOf(dir), 'utf8'));
|
|
100
|
-
} catch {
|
|
101
|
-
throw unavailable();
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
export function migrationRecord(id: string, dir = agentsDir()): MigrationRecord | null {
|
|
106
|
-
return migrationRecords(dir).find((record) => record.sourceId === id) ?? null;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
function flush(path: string): void {
|
|
110
|
-
const fd = openSync(path, 'r');
|
|
111
|
-
try { fsyncSync(fd); } finally { closeSync(fd); }
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
function markInitialized(dir: string): void {
|
|
115
|
-
const path = markerOf(dir);
|
|
116
|
-
if (existsSync(path)) return;
|
|
117
|
-
writeFileSync(path, '1\n', { flag: 'wx', mode: 0o600 });
|
|
118
|
-
flush(path);
|
|
119
|
-
flush(dir);
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
export function saveMigration(record: MigrationRecord, dir = agentsDir()): void {
|
|
123
|
-
const valid = recordOf(record);
|
|
124
|
-
const previous = migrationRecords(dir);
|
|
125
|
-
const rows = previous.filter((row) => row.sourceId !== valid.sourceId);
|
|
126
|
-
if (rows.length >= 500) throw new ApiError('There are too many connector migrations on this agent.', 409);
|
|
127
|
-
rows.push(valid);
|
|
128
|
-
ensureSecureDir(dir);
|
|
129
|
-
markInitialized(dir);
|
|
130
|
-
captureMigrationOperations(valid.sourceId, dir, !previous.some((row) => row.sourceId === valid.sourceId));
|
|
131
|
-
const path = pathOf(dir);
|
|
132
|
-
const temporary = `${path}.${randomBytes(12).toString('hex')}.tmp`;
|
|
133
|
-
const fd = openSync(temporary, 'wx', 0o600);
|
|
134
|
-
try {
|
|
135
|
-
try {
|
|
136
|
-
writeFileSync(fd, `${JSON.stringify({ version: 1, records: rows })}\n`);
|
|
137
|
-
fsyncSync(fd);
|
|
138
|
-
} finally { closeSync(fd); }
|
|
139
|
-
renameSync(temporary, path);
|
|
140
|
-
flush(dir);
|
|
141
|
-
} finally { rmSync(temporary, { force: true }); }
|
|
142
|
-
}
|