@kamilio/wire 0.1.2 → 0.1.4
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 +1 -1
- package/bin/wire-mcp.mjs +1 -1
- package/dist/adapters/root.d.ts +5 -2
- package/dist/adapters/root.d.ts.map +1 -1
- package/dist/adapters/root.js +58 -30
- package/dist/adapters/root.js.map +1 -1
- package/dist/auth.d.ts.map +1 -1
- package/dist/auth.js +9 -36
- package/dist/auth.js.map +1 -1
- package/dist/cli.js +2 -2
- package/dist/cli.js.map +1 -1
- package/dist/executable.d.ts +3 -1
- package/dist/executable.d.ts.map +1 -1
- package/dist/executable.js +38 -12
- package/dist/executable.js.map +1 -1
- package/dist/hooks.d.ts.map +1 -1
- package/dist/hooks.js +22 -7
- package/dist/hooks.js.map +1 -1
- package/node_modules/provider-asana/dist/asana-project.d.ts.map +1 -1
- package/node_modules/provider-asana/dist/asana-project.js +3 -0
- package/node_modules/provider-asana/dist/asana-project.js.map +1 -1
- package/node_modules/provider-asana/src/asana-project.ts +2 -0
- package/node_modules/provider-chatgpt/dist/chatgpt.d.ts.map +1 -1
- package/node_modules/provider-chatgpt/dist/chatgpt.js +24 -3
- package/node_modules/provider-chatgpt/dist/chatgpt.js.map +1 -1
- package/node_modules/provider-chatgpt/src/chatgpt.ts +21 -3
- package/node_modules/provider-gmail/dist/gmail.d.ts.map +1 -1
- package/node_modules/provider-gmail/dist/gmail.js +8 -2
- package/node_modules/provider-gmail/dist/gmail.js.map +1 -1
- package/node_modules/provider-gmail/src/gmail.ts +8 -2
- package/node_modules/provider-google-docs/dist/google-docs.d.ts.map +1 -1
- package/node_modules/provider-google-docs/dist/google-docs.js +373 -9
- package/node_modules/provider-google-docs/dist/google-docs.js.map +1 -1
- package/node_modules/provider-google-docs/dist/google-forms.d.ts +28 -0
- package/node_modules/provider-google-docs/dist/google-forms.d.ts.map +1 -0
- package/node_modules/provider-google-docs/dist/google-forms.js +191 -0
- package/node_modules/provider-google-docs/dist/google-forms.js.map +1 -0
- package/node_modules/provider-google-docs/dist/index.d.ts +26 -0
- package/node_modules/provider-google-docs/dist/index.d.ts.map +1 -1
- package/node_modules/provider-google-docs/dist/index.js +3 -1
- package/node_modules/provider-google-docs/dist/index.js.map +1 -1
- package/node_modules/provider-google-docs/src/google-docs.ts +342 -10
- package/node_modules/provider-google-docs/src/google-forms.ts +179 -0
- package/node_modules/provider-google-docs/src/index.ts +3 -1
- package/node_modules/provider-notion/dist/notion-sync.d.ts +2 -2
- package/node_modules/provider-notion/dist/notion-sync.d.ts.map +1 -1
- package/node_modules/provider-notion/dist/notion-sync.js +89 -38
- package/node_modules/provider-notion/dist/notion-sync.js.map +1 -1
- package/node_modules/provider-notion/dist/notion.js +1 -1
- package/node_modules/provider-notion/dist/notion.js.map +1 -1
- package/node_modules/provider-notion/src/notion-sync.ts +90 -35
- package/node_modules/provider-notion/src/notion.ts +1 -1
- package/node_modules/provider-slack/dist/slack.d.ts.map +1 -1
- package/node_modules/provider-slack/dist/slack.js +14 -7
- package/node_modules/provider-slack/dist/slack.js.map +1 -1
- package/node_modules/provider-slack/src/slack.ts +14 -5
- package/node_modules/provider-zoom/dist/zoom-hub.d.ts.map +1 -1
- package/node_modules/provider-zoom/dist/zoom-hub.js +42 -35
- package/node_modules/provider-zoom/dist/zoom-hub.js.map +1 -1
- package/node_modules/provider-zoom/src/zoom-hub.ts +40 -35
- package/node_modules/wire-core/dist/core/model.d.ts +1 -1
- package/node_modules/wire-core/dist/core/model.d.ts.map +1 -1
- package/node_modules/wire-core/dist/core/transform.d.ts +1 -0
- package/node_modules/wire-core/dist/core/transform.d.ts.map +1 -1
- package/node_modules/wire-core/dist/core/transform.js +4 -0
- package/node_modules/wire-core/dist/core/transform.js.map +1 -1
- package/node_modules/wire-core/dist/index.d.ts +3 -2
- package/node_modules/wire-core/dist/index.d.ts.map +1 -1
- package/node_modules/wire-core/dist/index.js +2 -2
- package/node_modules/wire-core/dist/index.js.map +1 -1
- package/node_modules/wire-core/dist/operations.d.ts +3 -0
- package/node_modules/wire-core/dist/operations.d.ts.map +1 -1
- package/node_modules/wire-core/dist/operations.js +50 -9
- package/node_modules/wire-core/dist/operations.js.map +1 -1
- package/node_modules/wire-core/dist/ports.d.ts +1 -0
- package/node_modules/wire-core/dist/ports.d.ts.map +1 -1
- package/node_modules/wire-core/dist/runtime/chrome.d.ts +1 -0
- package/node_modules/wire-core/dist/runtime/chrome.d.ts.map +1 -1
- package/node_modules/wire-core/dist/runtime/chrome.js +91 -83
- package/node_modules/wire-core/dist/runtime/chrome.js.map +1 -1
- package/node_modules/wire-core/dist/runtime/cookies.d.ts.map +1 -1
- package/node_modules/wire-core/dist/runtime/cookies.js +7 -2
- package/node_modules/wire-core/dist/runtime/cookies.js.map +1 -1
- package/node_modules/wire-core/dist/runtime/node.d.ts.map +1 -1
- package/node_modules/wire-core/dist/runtime/node.js +4 -0
- package/node_modules/wire-core/dist/runtime/node.js.map +1 -1
- package/node_modules/wire-core/dist/storage/registry.d.ts.map +1 -1
- package/node_modules/wire-core/dist/storage/registry.js +95 -208
- package/node_modules/wire-core/dist/storage/registry.js.map +1 -1
- package/node_modules/wire-core/dist/storage/workspace.d.ts +3 -0
- package/node_modules/wire-core/dist/storage/workspace.d.ts.map +1 -1
- package/node_modules/wire-core/dist/storage/workspace.js +10 -1
- package/node_modules/wire-core/dist/storage/workspace.js.map +1 -1
- package/node_modules/wire-core/package.json +4 -0
- package/node_modules/wire-core/src/core/model.ts +1 -1
- package/node_modules/wire-core/src/core/transform.ts +5 -1
- package/node_modules/wire-core/src/index.ts +3 -2
- package/node_modules/wire-core/src/operations.ts +56 -10
- package/node_modules/wire-core/src/ports.ts +1 -0
- package/node_modules/wire-core/src/runtime/chrome.ts +99 -80
- package/node_modules/wire-core/src/runtime/cookies.ts +4 -2
- package/node_modules/wire-core/src/runtime/node.ts +4 -0
- package/node_modules/wire-core/src/storage/registry.ts +48 -15
- package/node_modules/wire-core/src/storage/workspace.ts +10 -1
- package/package.json +1 -1
- package/src/adapters/root.ts +51 -31
- package/src/auth.ts +10 -29
- package/src/cli.ts +2 -2
- package/src/executable.ts +38 -12
- package/src/hooks.ts +21 -6
|
@@ -55,6 +55,10 @@ export type WireWatchSession = Readonly<{
|
|
|
55
55
|
close(): void;
|
|
56
56
|
}>;
|
|
57
57
|
|
|
58
|
+
export type WireWatchSyncCommand = "sync" | "download";
|
|
59
|
+
export type WireWatchSyncHook = (command: WireWatchSyncCommand, result: WireResult) => Promise<WireResult>;
|
|
60
|
+
export const wireWatchHooks = Symbol("wireWatchHooks");
|
|
61
|
+
|
|
58
62
|
export type Wire = Readonly<{
|
|
59
63
|
attach(url: string, path: string): Promise<WireResult>;
|
|
60
64
|
create(url: string, path: string): Promise<WireResult>;
|
|
@@ -73,6 +77,10 @@ export type Wire = Readonly<{
|
|
|
73
77
|
switchBackend(path: string): Promise<SwitchedWireBackend>;
|
|
74
78
|
}>;
|
|
75
79
|
|
|
80
|
+
type WireInternal = Wire & Readonly<{
|
|
81
|
+
[wireWatchHooks](value: string, path: string, hooks: readonly WireWatchSyncHook[]): Promise<WireWatchSession>;
|
|
82
|
+
}>;
|
|
83
|
+
|
|
76
84
|
async function wireRoot<FetchInput>(dependencies: WireDependencies<FetchInput>, path: string): Promise<string> {
|
|
77
85
|
const configured = await dependencies.workspace.configuredRoot(path, dependencies.home);
|
|
78
86
|
if (configured !== null) return configured;
|
|
@@ -147,6 +155,11 @@ function markdownSnapshot(snapshot: JsonValue): string | null {
|
|
|
147
155
|
return typeof markdown === "string" ? markdown : null;
|
|
148
156
|
}
|
|
149
157
|
|
|
158
|
+
function storedSnapshot(fetched: FetchedDocument): JsonValue {
|
|
159
|
+
if (!jsonObject(fetched.data)) return fetched.data;
|
|
160
|
+
return { ...fetched.data, markdown: fetched.markdown };
|
|
161
|
+
}
|
|
162
|
+
|
|
150
163
|
function pathLikeResourceValue(value: string): boolean {
|
|
151
164
|
return value === "." || value === ".." || value.startsWith("./") || value.startsWith("../") || value.startsWith("/") || value.includes("/") || value.includes("\\") || value.endsWith(".md");
|
|
152
165
|
}
|
|
@@ -155,6 +168,15 @@ function relativePathContains(parent: string, child: string): boolean {
|
|
|
155
168
|
return parent === "" || child === parent || child.startsWith(`${parent}/`);
|
|
156
169
|
}
|
|
157
170
|
|
|
171
|
+
function relativePathEscapes(path: string): boolean {
|
|
172
|
+
return path === ".." || path.startsWith("../");
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function serviceCanSynchronize<FetchInput>(url: string, catalog: ServiceCatalog<FetchInput>): boolean {
|
|
176
|
+
const parsed = new URL(url);
|
|
177
|
+
return catalog.find((service) => service.matches(parsed))!.synchronize !== undefined;
|
|
178
|
+
}
|
|
179
|
+
|
|
158
180
|
function errorMessage(error: unknown): string {
|
|
159
181
|
if (error instanceof Error) return error.message;
|
|
160
182
|
if (typeof error === "object" && error !== null && "message" in error && typeof error["message"] === "string") return error["message"];
|
|
@@ -177,6 +199,11 @@ export function composeWire<FetchInput>(dependencies: WireDependencies<FetchInpu
|
|
|
177
199
|
}
|
|
178
200
|
const relativePath = dependencies.workspace.relativePath(outputPath, root);
|
|
179
201
|
const previous = previousMarkdown === null ? await dependencies.filesystem.exists(outputPath) ? await dependencies.filesystem.readText(outputPath) : "" : previousMarkdown;
|
|
202
|
+
if (action === "attached" && current !== null) {
|
|
203
|
+
const snapshot = current.data.find((item) => item.namespace === source.service && item.key === "snapshot")!.value;
|
|
204
|
+
const baseMarkdown = markdownSnapshot(syncBase(source.service, snapshot, current.data));
|
|
205
|
+
if (baseMarkdown !== null && previous !== baseMarkdown) throw new Error(`Local edits exist for ${primaryLink(current).path}. Run \`wire sync ${primaryLink(current).path}\` before attaching this URL again.`);
|
|
206
|
+
}
|
|
180
207
|
await dependencies.filesystem.writeText(outputPath, fetched.markdown);
|
|
181
208
|
const id = resourceId(source);
|
|
182
209
|
const primary = current?.filesystem_links.find((link) => link.path === relativePath && link.role === "primary");
|
|
@@ -195,7 +222,7 @@ export function composeWire<FetchInput>(dependencies: WireDependencies<FetchInpu
|
|
|
195
222
|
...(current?.data.filter((item) => item.namespace !== "wire" && !(item.namespace === source.service && item.key === "snapshot")) ?? []),
|
|
196
223
|
{ namespace: "wire", key: "title", value: fetched.title },
|
|
197
224
|
{ namespace: "wire", key: "synced_at", value: dependencies.now().toISOString() },
|
|
198
|
-
{ namespace: source.service, key: "snapshot", value: fetched
|
|
225
|
+
{ namespace: source.service, key: "snapshot", value: storedSnapshot(fetched) },
|
|
199
226
|
],
|
|
200
227
|
relationships: extractRelationships(fetched.markdown, id, dependencies.catalog),
|
|
201
228
|
};
|
|
@@ -217,7 +244,11 @@ export function composeWire<FetchInput>(dependencies: WireDependencies<FetchInpu
|
|
|
217
244
|
const fetched = await fetchSource(dependencies.fetchInput, url, dependencies.catalog);
|
|
218
245
|
const source = parseSourceUrl(url, dependencies.catalog);
|
|
219
246
|
const cleanPath = join(resolve(path), markdownFilename(fetched.title));
|
|
220
|
-
|
|
247
|
+
let outputPath = cleanPath;
|
|
248
|
+
if (await dependencies.filesystem.exists(cleanPath)) {
|
|
249
|
+
const existingMarkdown = await dependencies.filesystem.readText(cleanPath);
|
|
250
|
+
if (existingMarkdown !== fetched.markdown) outputPath = join(resolve(path), collisionFilename(fetched.title, source.service, source.identifier));
|
|
251
|
+
}
|
|
221
252
|
const previous = await dependencies.filesystem.exists(outputPath) ? await dependencies.filesystem.readText(outputPath) : "";
|
|
222
253
|
await dependencies.filesystem.writeText(outputPath, fetched.markdown);
|
|
223
254
|
const id = resourceId(source);
|
|
@@ -230,7 +261,7 @@ export function composeWire<FetchInput>(dependencies: WireDependencies<FetchInpu
|
|
|
230
261
|
data: [
|
|
231
262
|
{ namespace: "wire", key: "title", value: fetched.title },
|
|
232
263
|
{ namespace: "wire", key: "synced_at", value: dependencies.now().toISOString() },
|
|
233
|
-
{ namespace: source.service, key: "snapshot", value: fetched
|
|
264
|
+
{ namespace: source.service, key: "snapshot", value: storedSnapshot(fetched) },
|
|
234
265
|
],
|
|
235
266
|
relationships: extractRelationships(fetched.markdown, id, dependencies.catalog),
|
|
236
267
|
};
|
|
@@ -273,10 +304,12 @@ export function composeWire<FetchInput>(dependencies: WireDependencies<FetchInpu
|
|
|
273
304
|
const outputDirectory = dirname(outputPath);
|
|
274
305
|
const source = parseSourceUrl(resource.urls[0]!, dependencies.catalog);
|
|
275
306
|
const snapshot = resource.data.find((item) => item.namespace === source.service && item.key === "snapshot")!.value;
|
|
276
|
-
|
|
307
|
+
if (!(await dependencies.filesystem.exists(outputPath))) throw new Error(`Linked file missing: ${primaryLink(resource).path} - restore it or run wire detach`);
|
|
308
|
+
const markdown = await dependencies.filesystem.readText(outputPath);
|
|
277
309
|
const base = syncBase(source.service, snapshot, resource.data);
|
|
278
310
|
const baseMarkdown = markdownSnapshot(base);
|
|
279
311
|
const localChanged = baseMarkdown !== null && markdown !== baseMarkdown;
|
|
312
|
+
if (localChanged && !serviceCanSynchronize(resource.urls[0]!, dependencies.catalog)) throw new Error(`${source.service} is download-only and the local file has edits. Run \`wire download ${primaryLink(resource).path}\` to discard them.`);
|
|
280
313
|
const fetched = await synchronizeSource(dependencies.fetchInput, resource.urls[0]!, dependencies.catalog, base, markdown, outputPath);
|
|
281
314
|
const action = localChanged ? fetched.markdown === baseMarkdown ? "synced" : "uploaded" : fetched.markdown === markdown ? "synced" : "downloaded";
|
|
282
315
|
return store(resource.urls[0]!, outputDirectory, fetched, markdown, action, undefined, action === "uploaded" && baseMarkdown !== null ? baseMarkdown : undefined, action === "uploaded" ? markdown : fetched.markdown);
|
|
@@ -298,27 +331,36 @@ export function composeWire<FetchInput>(dependencies: WireDependencies<FetchInpu
|
|
|
298
331
|
const root = await existingWireRoot(dependencies, await dependencies.filesystem.exists(candidatePath) ? candidatePath : path);
|
|
299
332
|
const registry = await dependencies.workspace.openRegistry(root, dependencies.home);
|
|
300
333
|
const resource = await resolveResource(registry, value, root, path);
|
|
301
|
-
const
|
|
334
|
+
const outputPath = join(dirname(root), primaryLink(resource).path);
|
|
335
|
+
const previous = await dependencies.filesystem.exists(outputPath) ? await dependencies.filesystem.readText(outputPath) : "";
|
|
336
|
+
const fetched = await fetchSource(dependencies.fetchInput, resource.urls[0]!, dependencies.catalog);
|
|
337
|
+
await dependencies.filesystem.writeText(outputPath, fetched.markdown);
|
|
302
338
|
await registry.delete(resource.id);
|
|
303
|
-
return {
|
|
339
|
+
return { resource, path: outputPath, markdown: fetched.markdown, summary: { action: "detached", ...changeSummary(previous, fetched.markdown), remote: resource.urls[0]!, local: outputPath } };
|
|
304
340
|
};
|
|
305
341
|
const unlink = detach;
|
|
306
342
|
|
|
307
|
-
const
|
|
343
|
+
const runWatchHooks = async (hooks: readonly WireWatchSyncHook[], command: WireWatchSyncCommand, result: WireResult): Promise<WireResult> => {
|
|
344
|
+
let current = result;
|
|
345
|
+
for (const hook of hooks) current = await hook(command, current);
|
|
346
|
+
return current;
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
const watchWithHooks = async (value: string, path: string, hooks: readonly WireWatchSyncHook[]): Promise<WireWatchSession> => {
|
|
308
350
|
const candidatePath = resolve(path, value);
|
|
309
351
|
const root = await existingWireRoot(dependencies, await dependencies.filesystem.exists(candidatePath) ? candidatePath : path);
|
|
310
352
|
const config = watchConfig(await dependencies.workspace.loadConfig!(root));
|
|
311
353
|
const registry = await dependencies.workspace.openRegistry(root, dependencies.home);
|
|
312
354
|
const resource = await resolveResource(registry, value, root, path);
|
|
313
355
|
const outputPath = join(dirname(root), primaryLink(resource).path);
|
|
314
|
-
const initial = await dependencies.filesystem.exists(outputPath) ? null : await download(value, path);
|
|
356
|
+
const initial = await dependencies.filesystem.exists(outputPath) ? null : await runWatchHooks(hooks, "download", await download(value, path));
|
|
315
357
|
let lastSyncedMarkdown = initial === null ? await dependencies.filesystem.readText(outputPath) : initial.markdown;
|
|
316
358
|
let debounce: ReturnType<typeof setTimeout> | undefined;
|
|
317
359
|
let resolveClosed!: () => void;
|
|
318
360
|
const closed = new Promise<void>((resolveClosedPromise) => { resolveClosed = resolveClosedPromise; });
|
|
319
361
|
const handles: WatchHandle[] = [];
|
|
320
362
|
const synchronize = async () => {
|
|
321
|
-
const result = config.mode === "download" ? await download(value, path) : await sync(value, path);
|
|
363
|
+
const result = config.mode === "download" ? await runWatchHooks(hooks, "download", await download(value, path)) : await runWatchHooks(hooks, "sync", await sync(value, path));
|
|
322
364
|
lastSyncedMarkdown = result.markdown;
|
|
323
365
|
};
|
|
324
366
|
const schedule = () => {
|
|
@@ -349,6 +391,8 @@ export function composeWire<FetchInput>(dependencies: WireDependencies<FetchInpu
|
|
|
349
391
|
});
|
|
350
392
|
};
|
|
351
393
|
|
|
394
|
+
const watch = (value: string, path: string): Promise<WireWatchSession> => watchWithHooks(value, path, []);
|
|
395
|
+
|
|
352
396
|
const openResource = async (value: string, path: string): Promise<Resource> => {
|
|
353
397
|
const candidatePath = resolve(path, value);
|
|
354
398
|
const root = await existingWireRoot(dependencies, await dependencies.filesystem.exists(candidatePath) ? candidatePath : path);
|
|
@@ -362,6 +406,7 @@ export function composeWire<FetchInput>(dependencies: WireDependencies<FetchInpu
|
|
|
362
406
|
const root = await existingWireRoot(dependencies, path);
|
|
363
407
|
const registry = await dependencies.workspace.openRegistry(root, dependencies.home);
|
|
364
408
|
const scope = dependencies.workspace.relativePath(path, root);
|
|
409
|
+
if (relativePathEscapes(scope)) throw new Error(`Sync scope is outside the Wire workspace: root ${dirname(root)}, path ${path}`);
|
|
365
410
|
const results: WireResult[] = [];
|
|
366
411
|
for (const resource of await registry.listResources()) {
|
|
367
412
|
const outputPath = join(dirname(root), primaryLink(resource).path);
|
|
@@ -404,5 +449,6 @@ export function composeWire<FetchInput>(dependencies: WireDependencies<FetchInpu
|
|
|
404
449
|
showResource,
|
|
405
450
|
init: dependencies.workspace.initialize,
|
|
406
451
|
switchBackend: (path: string) => dependencies.workspace.switchBackend!(path, dependencies.home),
|
|
407
|
-
|
|
452
|
+
[wireWatchHooks]: watchWithHooks,
|
|
453
|
+
}) as WireInternal;
|
|
408
454
|
}
|
|
@@ -23,10 +23,13 @@ export async function chromeLaunchArguments(environment: NodeEnvironment, startU
|
|
|
23
23
|
"--remote-debugging-port=0",
|
|
24
24
|
"--no-first-run",
|
|
25
25
|
"--no-default-browser-check",
|
|
26
|
+
"--restore-last-session",
|
|
26
27
|
startUrl,
|
|
27
28
|
]);
|
|
28
29
|
}
|
|
29
30
|
|
|
31
|
+
const chromeExecutable = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
|
|
32
|
+
|
|
30
33
|
interface ChromeCookie {
|
|
31
34
|
readonly domain: string;
|
|
32
35
|
readonly expires: number;
|
|
@@ -35,6 +38,10 @@ interface ChromeCookie {
|
|
|
35
38
|
readonly path: string;
|
|
36
39
|
readonly secure: boolean;
|
|
37
40
|
readonly value: string;
|
|
41
|
+
readonly sameSite?: string;
|
|
42
|
+
readonly sourceScheme?: string;
|
|
43
|
+
readonly sourcePort?: number;
|
|
44
|
+
readonly partitionKey?: Readonly<Record<string, unknown>>;
|
|
38
45
|
}
|
|
39
46
|
|
|
40
47
|
interface PendingRequest {
|
|
@@ -42,23 +49,39 @@ interface PendingRequest {
|
|
|
42
49
|
readonly reject: (reason: unknown) => void;
|
|
43
50
|
}
|
|
44
51
|
|
|
52
|
+
interface PendingEvent {
|
|
53
|
+
readonly resolve: (value: Record<string, unknown>) => void;
|
|
54
|
+
readonly reject: (reason: unknown) => void;
|
|
55
|
+
}
|
|
56
|
+
|
|
45
57
|
class ChromeConnection {
|
|
46
58
|
readonly socket: WebSocket;
|
|
47
59
|
readonly pending = new Map<number, PendingRequest>();
|
|
48
|
-
readonly events = new Map<string,
|
|
60
|
+
readonly events = new Map<string, PendingEvent[]>();
|
|
49
61
|
nextId = 1;
|
|
62
|
+
closed = false;
|
|
50
63
|
|
|
51
64
|
constructor(url: string) {
|
|
52
65
|
this.socket = new WebSocket(url);
|
|
66
|
+
const rejectPending = () => {
|
|
67
|
+
this.closed = true;
|
|
68
|
+
const error = new Error("Chrome window closed before login completed");
|
|
69
|
+
for (const request of this.pending.values()) request.reject(error);
|
|
70
|
+
this.pending.clear();
|
|
71
|
+
for (const events of this.events.values()) for (const event of events) event.reject(error);
|
|
72
|
+
this.events.clear();
|
|
73
|
+
};
|
|
74
|
+
this.socket.addEventListener("close", rejectPending, { once: true });
|
|
75
|
+
this.socket.addEventListener("error", rejectPending, { once: true });
|
|
53
76
|
this.socket.addEventListener("message", (event) => {
|
|
54
77
|
const message = JSON.parse(event.data.toString()) as { id?: number; method?: string; params?: Record<string, unknown>; result?: unknown; error?: unknown };
|
|
55
78
|
if (message.id === undefined) {
|
|
56
79
|
if (message.method !== undefined) {
|
|
57
80
|
const events = this.events.get(message.method);
|
|
58
81
|
if (events !== undefined) {
|
|
59
|
-
const
|
|
82
|
+
const pending = events.shift()!;
|
|
60
83
|
if (events.length === 0) this.events.delete(message.method);
|
|
61
|
-
resolve(message.params!);
|
|
84
|
+
pending.resolve(message.params!);
|
|
62
85
|
}
|
|
63
86
|
}
|
|
64
87
|
return;
|
|
@@ -81,15 +104,23 @@ class ChromeConnection {
|
|
|
81
104
|
const id = this.nextId;
|
|
82
105
|
this.nextId += 1;
|
|
83
106
|
return new Promise((resolve, reject) => {
|
|
107
|
+
if (this.closed) {
|
|
108
|
+
reject(new Error("Chrome window closed before login completed"));
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
84
111
|
this.pending.set(id, { resolve, reject });
|
|
85
112
|
this.socket.send(JSON.stringify({ id, method, params }));
|
|
86
113
|
});
|
|
87
114
|
}
|
|
88
115
|
|
|
89
116
|
event(method: string): Promise<Record<string, unknown>> {
|
|
90
|
-
return new Promise((resolve) => {
|
|
117
|
+
return new Promise((resolve, reject) => {
|
|
118
|
+
if (this.closed) {
|
|
119
|
+
reject(new Error("Chrome window closed before login completed"));
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
91
122
|
const events = this.events.get(method) ?? [];
|
|
92
|
-
events.push(resolve);
|
|
123
|
+
events.push({ resolve, reject });
|
|
93
124
|
this.events.set(method, events);
|
|
94
125
|
});
|
|
95
126
|
}
|
|
@@ -108,7 +139,7 @@ function devtoolsUrl(stderr: NodeJS.ReadableStream): Promise<string> {
|
|
|
108
139
|
const match = /DevTools listening on (ws:\/\/[^\s]+)/.exec(contents);
|
|
109
140
|
if (match !== null) resolve(match[1]!);
|
|
110
141
|
});
|
|
111
|
-
stderr.on("end", () => reject(new Error(contents)));
|
|
142
|
+
stderr.on("end", () => reject(new Error(contents === "" ? "Chrome exited before DevTools started. A Wire Chrome window is already open from a previous login; close it and retry." : contents)));
|
|
112
143
|
});
|
|
113
144
|
}
|
|
114
145
|
|
|
@@ -130,54 +161,6 @@ async function pageDevtoolsUrl(browserUrl: string, domains: readonly string[]):
|
|
|
130
161
|
return targets.find((target) => target.type === "page" && domains.some((domain) => new URL(target.url).hostname === domain || new URL(target.url).hostname.endsWith(`.${domain}`)))!.webSocketDebuggerUrl;
|
|
131
162
|
}
|
|
132
163
|
|
|
133
|
-
async function chatgptBrowserMetadata(pageConnection: ChromeConnection): Promise<Readonly<Record<string, string>> | null> {
|
|
134
|
-
const evaluation = await pageConnection.request("Runtime.evaluate", {
|
|
135
|
-
expression: `new Promise((resolve) => {
|
|
136
|
-
(async () => {
|
|
137
|
-
if (location.hostname !== "chatgpt.com") {
|
|
138
|
-
resolve({ ok: false });
|
|
139
|
-
return;
|
|
140
|
-
}
|
|
141
|
-
const response = await fetch("/api/auth/session", { cache: "no-store" });
|
|
142
|
-
const text = await response.text();
|
|
143
|
-
if (!text.startsWith("{")) {
|
|
144
|
-
resolve({ ok: false });
|
|
145
|
-
return;
|
|
146
|
-
}
|
|
147
|
-
const session = JSON.parse(text);
|
|
148
|
-
if (!("account" in session) || !("id" in session.account) || !("accessToken" in session) || "error" in session) {
|
|
149
|
-
resolve({ ok: false });
|
|
150
|
-
return;
|
|
151
|
-
}
|
|
152
|
-
const backend = await fetch("/backend-api/conversations?offset=0&limit=1&order=updated&is_archived=false&is_starred=false", {
|
|
153
|
-
cache: "no-store",
|
|
154
|
-
headers: {
|
|
155
|
-
authorization: \`Bearer \${session.accessToken}\`,
|
|
156
|
-
"chatgpt-account-id": session.account.id
|
|
157
|
-
}
|
|
158
|
-
});
|
|
159
|
-
resolve({ ok: backend.ok, account_id: session.account.id });
|
|
160
|
-
})().catch(() => resolve({ ok: false }));
|
|
161
|
-
})`,
|
|
162
|
-
awaitPromise: true,
|
|
163
|
-
returnByValue: true,
|
|
164
|
-
}) as { result: { value?: { ok?: boolean; account_id?: string } } };
|
|
165
|
-
const value = evaluation.result.value!;
|
|
166
|
-
if (value.ok !== true) return null;
|
|
167
|
-
return Object.freeze({ account_id: value.account_id! });
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
async function chatgptChallengeUrl(pageConnection: ChromeConnection): Promise<string | null> {
|
|
171
|
-
const evaluation = await pageConnection.request("Runtime.evaluate", {
|
|
172
|
-
expression: `(() => ({ href: location.href, title: document.title, text: document.body.innerText }))()`,
|
|
173
|
-
returnByValue: true,
|
|
174
|
-
}) as { result: { value?: { href: string; title: string; text: string } } };
|
|
175
|
-
const value = evaluation.result.value!;
|
|
176
|
-
const pageText = `${value.href}\n${value.title}\n${value.text}`;
|
|
177
|
-
if (/cdn-cgi\/challenge-platform|cf_chl|Just a moment|Verify you are human|Cloudflare/i.test(pageText)) return value.href;
|
|
178
|
-
return null;
|
|
179
|
-
}
|
|
180
|
-
|
|
181
164
|
function chromeCookies(values: readonly ChromeCookie[], domains: readonly string[]): readonly Cookie[] {
|
|
182
165
|
return Object.freeze(values.filter((cookie) => domains.some((domain) => cookie.domain === domain || cookie.domain.endsWith(`.${domain}`))).map((cookie) => Object.freeze({
|
|
183
166
|
domain: cookie.domain,
|
|
@@ -197,6 +180,7 @@ export interface ChromeCookieExtraction {
|
|
|
197
180
|
readonly domains: readonly string[];
|
|
198
181
|
readonly ready: (cookies: readonly Cookie[]) => boolean;
|
|
199
182
|
readonly metadataExpression?: string;
|
|
183
|
+
readonly freshSession?: true;
|
|
200
184
|
readonly verify: (cookies: readonly Cookie[], metadata: Readonly<Record<string, string>>) => Promise<boolean>;
|
|
201
185
|
}
|
|
202
186
|
|
|
@@ -217,10 +201,66 @@ function sleep(milliseconds: number): Promise<void> {
|
|
|
217
201
|
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
218
202
|
}
|
|
219
203
|
|
|
204
|
+
async function headlessChromeConnection(environment: NodeEnvironment): Promise<Readonly<{ chrome: ReturnType<typeof spawn>; connection: ChromeConnection }>> {
|
|
205
|
+
const chrome = spawn(chromeExecutable, [`--user-data-dir=${chromeUserDataDir(environment)}`, "--remote-debugging-port=0", "--headless=new", "--no-first-run", "--no-default-browser-check", "about:blank"], { detached: true, stdio: ["ignore", "ignore", "pipe"] });
|
|
206
|
+
const connection = new ChromeConnection(await devtoolsUrl(chrome.stderr!));
|
|
207
|
+
await connection.opened();
|
|
208
|
+
return Object.freeze({ chrome, connection });
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function expireServiceCookies(connection: ChromeConnection, domains: readonly string[]): Promise<void> {
|
|
212
|
+
const serviceDomain = (domain: string) => { const bare = domain.startsWith(".") ? domain.slice(1) : domain; return domains.some((value) => bare === value || bare.endsWith(`.${value}`)); };
|
|
213
|
+
const stored = await connection.request("Storage.getCookies") as { cookies: readonly ChromeCookie[] };
|
|
214
|
+
const expiredCopies = stored.cookies.filter((cookie) => serviceDomain(cookie.domain)).map((cookie) => ({
|
|
215
|
+
name: cookie.name,
|
|
216
|
+
value: "",
|
|
217
|
+
domain: cookie.domain,
|
|
218
|
+
path: cookie.path,
|
|
219
|
+
secure: cookie.secure,
|
|
220
|
+
httpOnly: cookie.httpOnly,
|
|
221
|
+
expires: 1,
|
|
222
|
+
...(cookie.sameSite === undefined ? {} : { sameSite: cookie.sameSite }),
|
|
223
|
+
...(cookie.sourceScheme === undefined ? {} : { sourceScheme: cookie.sourceScheme }),
|
|
224
|
+
...(cookie.sourcePort === undefined ? {} : { sourcePort: cookie.sourcePort }),
|
|
225
|
+
...(cookie.partitionKey === undefined ? {} : { partitionKey: cookie.partitionKey }),
|
|
226
|
+
}));
|
|
227
|
+
if (expiredCopies.length > 0) await connection.request("Storage.setCookies", { cookies: expiredCopies });
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async function extractDetachedChromeCookies(environment: NodeEnvironment, extraction: ChromeCookieExtraction): Promise<ChromeCookieResult> {
|
|
231
|
+
await mkdir(chromeUserDataDir(environment), { recursive: true });
|
|
232
|
+
const prep = await headlessChromeConnection(environment);
|
|
233
|
+
await expireServiceCookies(prep.connection, extraction.domains);
|
|
234
|
+
await closeChrome(prep.chrome, prep.connection);
|
|
235
|
+
const chrome = spawn(chromeExecutable, [`--user-data-dir=${chromeUserDataDir(environment)}`, "--no-first-run", "--no-default-browser-check", extraction.startUrl], { detached: true, stdio: ["ignore", "ignore", "ignore"] });
|
|
236
|
+
stderr.write(`\nLog in to ${extraction.service} in the opened Chrome window, then quit Chrome (Cmd+Q). Wire picks up the session after Chrome exits.\n`);
|
|
237
|
+
let interrupted = false;
|
|
238
|
+
const interrupt = () => {
|
|
239
|
+
interrupted = true;
|
|
240
|
+
process.kill(-chrome.pid!, "SIGTERM");
|
|
241
|
+
};
|
|
242
|
+
process.on("SIGINT", interrupt);
|
|
243
|
+
await new Promise((resolve) => chrome.once("exit", resolve));
|
|
244
|
+
process.off("SIGINT", interrupt);
|
|
245
|
+
await sleep(500);
|
|
246
|
+
const harvest = await headlessChromeConnection(environment);
|
|
247
|
+
const stored = await harvest.connection.request("Storage.getCookies") as { cookies: readonly ChromeCookie[] };
|
|
248
|
+
await closeChrome(harvest.chrome, harvest.connection);
|
|
249
|
+
const cookies = chromeCookies(stored.cookies, extraction.domains);
|
|
250
|
+
if (interrupted) {
|
|
251
|
+
const save = await confirmSaveLogin();
|
|
252
|
+
if (!save) throw new Error("Login not saved");
|
|
253
|
+
return Object.freeze({ cookies, metadata: Object.freeze({}), manual: true });
|
|
254
|
+
}
|
|
255
|
+
if (!extraction.ready(cookies)) throw new Error(`${extraction.service} login did not complete: the Chrome window was quit before login finished.`);
|
|
256
|
+
return Object.freeze({ cookies, metadata: Object.freeze({}) });
|
|
257
|
+
}
|
|
258
|
+
|
|
220
259
|
export async function extractChromeCookies(environment: NodeEnvironment, extraction: ChromeCookieExtraction): Promise<ChromeCookieResult> {
|
|
260
|
+
if (extraction.freshSession === true) return extractDetachedChromeCookies(environment, extraction);
|
|
221
261
|
const profile = chromeUserDataDir(environment);
|
|
222
262
|
await mkdir(profile, { recursive: true });
|
|
223
|
-
const chrome = spawn(
|
|
263
|
+
const chrome = spawn(chromeExecutable, [...await chromeLaunchArguments(environment, extraction.startUrl)], { detached: true, stdio: ["ignore", "ignore", "pipe"] });
|
|
224
264
|
let interrupted = false;
|
|
225
265
|
let interrupting = false;
|
|
226
266
|
let interruptResult: ChromeCookieResult | null | undefined;
|
|
@@ -243,7 +283,7 @@ export async function extractChromeCookies(environment: NodeEnvironment, extract
|
|
|
243
283
|
const browserUrl = await devtoolsUrl(chrome.stderr!);
|
|
244
284
|
const connection = new ChromeConnection(browserUrl);
|
|
245
285
|
await connection.opened();
|
|
246
|
-
const pageConnection = extraction.metadataExpression === undefined
|
|
286
|
+
const pageConnection = extraction.metadataExpression === undefined ? undefined : new ChromeConnection(await pageDevtoolsUrl(browserUrl, extraction.domains));
|
|
247
287
|
if (pageConnection !== undefined) await pageConnection.opened();
|
|
248
288
|
if (pageConnection !== undefined) await Promise.race([sleep(2000), interruptPromise]);
|
|
249
289
|
const currentResult = async (): Promise<ChromeCookieResult> => {
|
|
@@ -266,33 +306,12 @@ export async function extractChromeCookies(environment: NodeEnvironment, extract
|
|
|
266
306
|
return interruptResult;
|
|
267
307
|
};
|
|
268
308
|
for (;;) {
|
|
269
|
-
if (extraction.service === "chatgpt") {
|
|
270
|
-
const challengeUrl = await chatgptChallengeUrl(pageConnection!);
|
|
271
|
-
const manualResult = await interruptedResult();
|
|
272
|
-
if (manualResult !== undefined) return manualResult;
|
|
273
|
-
if (challengeUrl !== null) {
|
|
274
|
-
await closeChrome(chrome, connection, pageConnection);
|
|
275
|
-
process.off("SIGINT", interrupt);
|
|
276
|
-
throw new Error(`ChatGPT login is stuck on an HTML challenge: ${challengeUrl}`);
|
|
277
|
-
}
|
|
278
|
-
}
|
|
279
309
|
const result = await currentResult();
|
|
280
310
|
const manualResult = await interruptedResult();
|
|
281
311
|
if (manualResult !== undefined) return manualResult;
|
|
282
312
|
if (extraction.ready(result.cookies)) {
|
|
283
|
-
|
|
284
|
-
if (extraction.
|
|
285
|
-
const chatgptMetadata = await Promise.race([chatgptBrowserMetadata(pageConnection!), interruptPromise.then(() => undefined)]);
|
|
286
|
-
const interruptedChatgptResult = await interruptedResult();
|
|
287
|
-
if (interruptedChatgptResult !== undefined) return interruptedChatgptResult;
|
|
288
|
-
if (chatgptMetadata === undefined) continue;
|
|
289
|
-
if (chatgptMetadata === null) {
|
|
290
|
-
await closeChrome(chrome, connection, pageConnection);
|
|
291
|
-
process.off("SIGINT", interrupt);
|
|
292
|
-
throw new Error("ChatGPT login is blocked by an HTML challenge. Complete `wire chatgpt login` in the opened Chrome window, then retry.");
|
|
293
|
-
}
|
|
294
|
-
metadata = chatgptMetadata;
|
|
295
|
-
} else if (extraction.metadataExpression !== undefined) {
|
|
313
|
+
const metadata: Readonly<Record<string, string>> = result.metadata;
|
|
314
|
+
if (extraction.metadataExpression !== undefined) {
|
|
296
315
|
if (Object.keys(metadata).length === 0) {
|
|
297
316
|
await Promise.race([sleep(1000), interruptPromise]);
|
|
298
317
|
const interruptedMetadataResult = await interruptedResult();
|
|
@@ -300,7 +319,7 @@ export async function extractChromeCookies(environment: NodeEnvironment, extract
|
|
|
300
319
|
continue;
|
|
301
320
|
}
|
|
302
321
|
}
|
|
303
|
-
if (
|
|
322
|
+
if (!(await Promise.race([extraction.verify(result.cookies, metadata), interruptPromise.then(() => false)]))) {
|
|
304
323
|
const interruptedVerifyResult = await interruptedResult();
|
|
305
324
|
if (interruptedVerifyResult !== undefined) return interruptedVerifyResult;
|
|
306
325
|
await Promise.race([sleep(1000), interruptPromise]);
|
|
@@ -191,8 +191,10 @@ export function createCookiesCapability(filesystem: FilesystemCapability, home:
|
|
|
191
191
|
},
|
|
192
192
|
save: async (service: string, cookies: readonly Cookie[], metadata: Readonly<Record<string, string>>) => {
|
|
193
193
|
const candidatePaths = paths(service);
|
|
194
|
-
const
|
|
195
|
-
|
|
194
|
+
const contents = serializeNetscapeCookies(cookies, metadata);
|
|
195
|
+
const existingPaths = [];
|
|
196
|
+
for (const path of candidatePaths) if (await filesystem.exists(path)) existingPaths.push(path);
|
|
197
|
+
for (const path of existingPaths.length === 0 ? [candidatePaths[0]!] : existingPaths) await filesystem.writeText(path, contents);
|
|
196
198
|
},
|
|
197
199
|
delete: async (service: string) => {
|
|
198
200
|
for (const path of paths(service)) if (await filesystem.exists(path)) await filesystem.delete(path);
|
|
@@ -100,6 +100,10 @@ export function composeNodeRuntime(dependencies: NodeRuntimeDependencies): Runti
|
|
|
100
100
|
load: () => createGoogleTokensCapability(dependencies.filesystem, dependencies.http, dependencies.clock, configuration.get("GOOGLE_CREDENTIALS_FILE"), configuration.get("GOOGLE_TOKEN_FILE")).load(),
|
|
101
101
|
refresh: () => createGoogleTokensCapability(dependencies.filesystem, dependencies.http, dependencies.clock, configuration.get("GOOGLE_CREDENTIALS_FILE"), configuration.get("GOOGLE_TOKEN_FILE")).refresh(),
|
|
102
102
|
}),
|
|
103
|
+
googleFormsTokens: Object.freeze({
|
|
104
|
+
load: () => createGoogleTokensCapability(dependencies.filesystem, dependencies.http, dependencies.clock, configuration.get("GOOGLE_CREDENTIALS_FILE"), configuration.get("GOOGLE_FORMS_TOKEN_FILE")).load(),
|
|
105
|
+
refresh: () => createGoogleTokensCapability(dependencies.filesystem, dependencies.http, dependencies.clock, configuration.get("GOOGLE_CREDENTIALS_FILE"), configuration.get("GOOGLE_FORMS_TOKEN_FILE")).refresh(),
|
|
106
|
+
}),
|
|
103
107
|
});
|
|
104
108
|
}
|
|
105
109
|
|
|
@@ -19,7 +19,7 @@ type ResourceIdRow = Readonly<{ resource_id: string }>;
|
|
|
19
19
|
type IdRow = Readonly<{ id: string }>;
|
|
20
20
|
type SqliteModule = Readonly<{ DatabaseSync: new (path: string) => DatabaseSync }>;
|
|
21
21
|
|
|
22
|
-
const require = createRequire(
|
|
22
|
+
const require = createRequire("/wire-core/storage/registry.js");
|
|
23
23
|
|
|
24
24
|
function compareStrings(left: string, right: string): number {
|
|
25
25
|
const leftCodePoints = Array.from(left, (character) => character.codePointAt(0)!);
|
|
@@ -77,7 +77,7 @@ export class SqliteRegistry implements Registry {
|
|
|
77
77
|
|
|
78
78
|
constructor(path: string) {
|
|
79
79
|
this.path = realpathSyncParent(path);
|
|
80
|
-
|
|
80
|
+
const database = this.connect();
|
|
81
81
|
database.exec(`
|
|
82
82
|
PRAGMA journal_mode=DELETE;
|
|
83
83
|
PRAGMA foreign_keys=ON;
|
|
@@ -121,11 +121,27 @@ export class SqliteRegistry implements Registry {
|
|
|
121
121
|
);
|
|
122
122
|
CREATE INDEX IF NOT EXISTS resource_relationships_target ON resource_relationships(target_id);
|
|
123
123
|
`);
|
|
124
|
+
database.close();
|
|
124
125
|
}
|
|
125
126
|
|
|
126
127
|
async put(resource: Resource): Promise<Resource> {
|
|
127
128
|
const normalized = storageResource(resource);
|
|
128
|
-
|
|
129
|
+
assertUniqueFileResource(normalized);
|
|
130
|
+
const database = this.connect();
|
|
131
|
+
for (const identifier of normalized.identifiers) {
|
|
132
|
+
const row = database.prepare("SELECT resource_id FROM resource_identifiers WHERE service = ? AND identifier = ? AND resource_id != ?").get(identifier.service, identifier.identifier, normalized.id) as ResourceIdRow | undefined;
|
|
133
|
+
if (row !== undefined) {
|
|
134
|
+
database.close();
|
|
135
|
+
throw new Error(`${identifier.service}/${identifier.identifier}`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
for (const url of normalized.urls) {
|
|
139
|
+
const row = database.prepare("SELECT resource_id FROM resource_urls WHERE url = ? AND resource_id != ?").get(url, normalized.id) as ResourceIdRow | undefined;
|
|
140
|
+
if (row !== undefined) {
|
|
141
|
+
database.close();
|
|
142
|
+
throw new Error(url);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
129
145
|
database.exec("BEGIN");
|
|
130
146
|
database.prepare("INSERT INTO resources (id, type) VALUES (?, ?) ON CONFLICT(id) DO UPDATE SET type=excluded.type").run(normalized.id, normalized.type);
|
|
131
147
|
database.prepare("DELETE FROM resource_identifiers WHERE resource_id = ?").run(normalized.id);
|
|
@@ -144,58 +160,76 @@ export class SqliteRegistry implements Registry {
|
|
|
144
160
|
const insertRelationship = database.prepare("INSERT INTO resource_relationships (source_id, target_id, type, data_json) VALUES (?, ?, ?, ?)");
|
|
145
161
|
for (const relationship of normalized.relationships) insertRelationship.run(normalized.id, relationship.target_id, relationship.type, stableJsonCompact(relationship.data));
|
|
146
162
|
database.exec("COMMIT");
|
|
163
|
+
database.close();
|
|
147
164
|
return normalized;
|
|
148
165
|
}
|
|
149
166
|
|
|
150
167
|
async get(resourceId: string): Promise<Resource> {
|
|
151
|
-
|
|
168
|
+
const database = this.connect();
|
|
169
|
+
const row = database.prepare("SELECT id FROM resources WHERE id = ?").get(resourceId) as IdRow | undefined;
|
|
170
|
+
if (row === undefined) {
|
|
171
|
+
database.close();
|
|
172
|
+
throw missingResource(resourceId);
|
|
173
|
+
}
|
|
152
174
|
database.exec("BEGIN");
|
|
153
175
|
const resource = this.getResource(database, resourceId);
|
|
154
176
|
database.exec("COMMIT");
|
|
177
|
+
database.close();
|
|
155
178
|
return resource;
|
|
156
179
|
}
|
|
157
180
|
|
|
158
181
|
async findByIdentifier(service: string, identifier: string): Promise<Resource> {
|
|
159
|
-
|
|
182
|
+
const database = this.connect();
|
|
160
183
|
database.exec("BEGIN");
|
|
161
184
|
const row = database.prepare("SELECT resource_id FROM resource_identifiers WHERE service = ? AND identifier = ?").get(service, identifier) as ResourceIdRow | undefined;
|
|
162
|
-
if (row === undefined)
|
|
185
|
+
if (row === undefined) {
|
|
186
|
+
database.close();
|
|
187
|
+
throw missingIdentifier(service, identifier);
|
|
188
|
+
}
|
|
163
189
|
const resource = this.getResource(database, row.resource_id);
|
|
164
190
|
database.exec("COMMIT");
|
|
191
|
+
database.close();
|
|
165
192
|
return resource;
|
|
166
193
|
}
|
|
167
194
|
|
|
168
195
|
async findByUrl(url: string): Promise<Resource> {
|
|
169
|
-
|
|
196
|
+
const database = this.connect();
|
|
170
197
|
database.exec("BEGIN");
|
|
171
198
|
const row = database.prepare("SELECT resource_id FROM resource_urls WHERE url = ?").get(url) as ResourceIdRow | undefined;
|
|
172
|
-
if (row === undefined)
|
|
199
|
+
if (row === undefined) {
|
|
200
|
+
database.close();
|
|
201
|
+
throw missingUrl(url);
|
|
202
|
+
}
|
|
173
203
|
const resource = this.getResource(database, row.resource_id);
|
|
174
204
|
database.exec("COMMIT");
|
|
205
|
+
database.close();
|
|
175
206
|
return resource;
|
|
176
207
|
}
|
|
177
208
|
|
|
178
209
|
async findByPath(path: string): Promise<readonly Resource[]> {
|
|
179
|
-
|
|
210
|
+
const database = this.connect();
|
|
180
211
|
database.exec("BEGIN");
|
|
181
212
|
const rows = database.prepare("SELECT DISTINCT resource_id FROM filesystem_links WHERE path = ? ORDER BY resource_id").all(path.replaceAll(sep, "/")) as ResourceIdRow[];
|
|
182
213
|
const resources = rows.map((row) => this.getResource(database, row.resource_id));
|
|
183
214
|
database.exec("COMMIT");
|
|
215
|
+
database.close();
|
|
184
216
|
return resources;
|
|
185
217
|
}
|
|
186
218
|
|
|
187
219
|
async listResources(): Promise<readonly Resource[]> {
|
|
188
|
-
|
|
220
|
+
const database = this.connect();
|
|
189
221
|
database.exec("BEGIN");
|
|
190
222
|
const rows = database.prepare("SELECT id FROM resources ORDER BY id").all() as IdRow[];
|
|
191
223
|
const resources = rows.map((row) => this.getResource(database, row.id));
|
|
192
224
|
database.exec("COMMIT");
|
|
225
|
+
database.close();
|
|
193
226
|
return resources;
|
|
194
227
|
}
|
|
195
228
|
|
|
196
229
|
async delete(resourceId: string): Promise<void> {
|
|
197
|
-
|
|
230
|
+
const database = this.connect();
|
|
198
231
|
database.prepare("DELETE FROM resources WHERE id = ?").run(resourceId);
|
|
232
|
+
database.close();
|
|
199
233
|
}
|
|
200
234
|
|
|
201
235
|
private getResource(database: DatabaseSync, resourceId: string): Resource {
|
|
@@ -245,10 +279,9 @@ export class FileRegistry implements Registry {
|
|
|
245
279
|
if (existingUrls.has(url)) throw new Error(url);
|
|
246
280
|
}
|
|
247
281
|
const tempPath = join(this.path, randomUUID());
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
}
|
|
282
|
+
const handle = await open(tempPath, "wx", 0o600);
|
|
283
|
+
await handle.writeFile(`${stableJsonPretty(normalized)}\n`, "utf8");
|
|
284
|
+
await handle.close();
|
|
252
285
|
await rename(tempPath, this.resourcePath(normalized.id));
|
|
253
286
|
return normalized;
|
|
254
287
|
}
|