@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.
Files changed (110) hide show
  1. package/README.md +1 -1
  2. package/bin/wire-mcp.mjs +1 -1
  3. package/dist/adapters/root.d.ts +5 -2
  4. package/dist/adapters/root.d.ts.map +1 -1
  5. package/dist/adapters/root.js +58 -30
  6. package/dist/adapters/root.js.map +1 -1
  7. package/dist/auth.d.ts.map +1 -1
  8. package/dist/auth.js +9 -36
  9. package/dist/auth.js.map +1 -1
  10. package/dist/cli.js +2 -2
  11. package/dist/cli.js.map +1 -1
  12. package/dist/executable.d.ts +3 -1
  13. package/dist/executable.d.ts.map +1 -1
  14. package/dist/executable.js +38 -12
  15. package/dist/executable.js.map +1 -1
  16. package/dist/hooks.d.ts.map +1 -1
  17. package/dist/hooks.js +22 -7
  18. package/dist/hooks.js.map +1 -1
  19. package/node_modules/provider-asana/dist/asana-project.d.ts.map +1 -1
  20. package/node_modules/provider-asana/dist/asana-project.js +3 -0
  21. package/node_modules/provider-asana/dist/asana-project.js.map +1 -1
  22. package/node_modules/provider-asana/src/asana-project.ts +2 -0
  23. package/node_modules/provider-chatgpt/dist/chatgpt.d.ts.map +1 -1
  24. package/node_modules/provider-chatgpt/dist/chatgpt.js +24 -3
  25. package/node_modules/provider-chatgpt/dist/chatgpt.js.map +1 -1
  26. package/node_modules/provider-chatgpt/src/chatgpt.ts +21 -3
  27. package/node_modules/provider-gmail/dist/gmail.d.ts.map +1 -1
  28. package/node_modules/provider-gmail/dist/gmail.js +8 -2
  29. package/node_modules/provider-gmail/dist/gmail.js.map +1 -1
  30. package/node_modules/provider-gmail/src/gmail.ts +8 -2
  31. package/node_modules/provider-google-docs/dist/google-docs.d.ts.map +1 -1
  32. package/node_modules/provider-google-docs/dist/google-docs.js +373 -9
  33. package/node_modules/provider-google-docs/dist/google-docs.js.map +1 -1
  34. package/node_modules/provider-google-docs/dist/google-forms.d.ts +28 -0
  35. package/node_modules/provider-google-docs/dist/google-forms.d.ts.map +1 -0
  36. package/node_modules/provider-google-docs/dist/google-forms.js +191 -0
  37. package/node_modules/provider-google-docs/dist/google-forms.js.map +1 -0
  38. package/node_modules/provider-google-docs/dist/index.d.ts +26 -0
  39. package/node_modules/provider-google-docs/dist/index.d.ts.map +1 -1
  40. package/node_modules/provider-google-docs/dist/index.js +3 -1
  41. package/node_modules/provider-google-docs/dist/index.js.map +1 -1
  42. package/node_modules/provider-google-docs/src/google-docs.ts +342 -10
  43. package/node_modules/provider-google-docs/src/google-forms.ts +179 -0
  44. package/node_modules/provider-google-docs/src/index.ts +3 -1
  45. package/node_modules/provider-notion/dist/notion-sync.d.ts +2 -2
  46. package/node_modules/provider-notion/dist/notion-sync.d.ts.map +1 -1
  47. package/node_modules/provider-notion/dist/notion-sync.js +89 -38
  48. package/node_modules/provider-notion/dist/notion-sync.js.map +1 -1
  49. package/node_modules/provider-notion/dist/notion.js +1 -1
  50. package/node_modules/provider-notion/dist/notion.js.map +1 -1
  51. package/node_modules/provider-notion/src/notion-sync.ts +90 -35
  52. package/node_modules/provider-notion/src/notion.ts +1 -1
  53. package/node_modules/provider-slack/dist/slack.d.ts.map +1 -1
  54. package/node_modules/provider-slack/dist/slack.js +14 -7
  55. package/node_modules/provider-slack/dist/slack.js.map +1 -1
  56. package/node_modules/provider-slack/src/slack.ts +14 -5
  57. package/node_modules/provider-zoom/dist/zoom-hub.d.ts.map +1 -1
  58. package/node_modules/provider-zoom/dist/zoom-hub.js +42 -35
  59. package/node_modules/provider-zoom/dist/zoom-hub.js.map +1 -1
  60. package/node_modules/provider-zoom/src/zoom-hub.ts +40 -35
  61. package/node_modules/wire-core/dist/core/model.d.ts +1 -1
  62. package/node_modules/wire-core/dist/core/model.d.ts.map +1 -1
  63. package/node_modules/wire-core/dist/core/transform.d.ts +1 -0
  64. package/node_modules/wire-core/dist/core/transform.d.ts.map +1 -1
  65. package/node_modules/wire-core/dist/core/transform.js +4 -0
  66. package/node_modules/wire-core/dist/core/transform.js.map +1 -1
  67. package/node_modules/wire-core/dist/index.d.ts +3 -2
  68. package/node_modules/wire-core/dist/index.d.ts.map +1 -1
  69. package/node_modules/wire-core/dist/index.js +2 -2
  70. package/node_modules/wire-core/dist/index.js.map +1 -1
  71. package/node_modules/wire-core/dist/operations.d.ts +3 -0
  72. package/node_modules/wire-core/dist/operations.d.ts.map +1 -1
  73. package/node_modules/wire-core/dist/operations.js +50 -9
  74. package/node_modules/wire-core/dist/operations.js.map +1 -1
  75. package/node_modules/wire-core/dist/ports.d.ts +1 -0
  76. package/node_modules/wire-core/dist/ports.d.ts.map +1 -1
  77. package/node_modules/wire-core/dist/runtime/chrome.d.ts +1 -0
  78. package/node_modules/wire-core/dist/runtime/chrome.d.ts.map +1 -1
  79. package/node_modules/wire-core/dist/runtime/chrome.js +91 -83
  80. package/node_modules/wire-core/dist/runtime/chrome.js.map +1 -1
  81. package/node_modules/wire-core/dist/runtime/cookies.d.ts.map +1 -1
  82. package/node_modules/wire-core/dist/runtime/cookies.js +7 -2
  83. package/node_modules/wire-core/dist/runtime/cookies.js.map +1 -1
  84. package/node_modules/wire-core/dist/runtime/node.d.ts.map +1 -1
  85. package/node_modules/wire-core/dist/runtime/node.js +4 -0
  86. package/node_modules/wire-core/dist/runtime/node.js.map +1 -1
  87. package/node_modules/wire-core/dist/storage/registry.d.ts.map +1 -1
  88. package/node_modules/wire-core/dist/storage/registry.js +95 -208
  89. package/node_modules/wire-core/dist/storage/registry.js.map +1 -1
  90. package/node_modules/wire-core/dist/storage/workspace.d.ts +3 -0
  91. package/node_modules/wire-core/dist/storage/workspace.d.ts.map +1 -1
  92. package/node_modules/wire-core/dist/storage/workspace.js +10 -1
  93. package/node_modules/wire-core/dist/storage/workspace.js.map +1 -1
  94. package/node_modules/wire-core/package.json +4 -0
  95. package/node_modules/wire-core/src/core/model.ts +1 -1
  96. package/node_modules/wire-core/src/core/transform.ts +5 -1
  97. package/node_modules/wire-core/src/index.ts +3 -2
  98. package/node_modules/wire-core/src/operations.ts +56 -10
  99. package/node_modules/wire-core/src/ports.ts +1 -0
  100. package/node_modules/wire-core/src/runtime/chrome.ts +99 -80
  101. package/node_modules/wire-core/src/runtime/cookies.ts +4 -2
  102. package/node_modules/wire-core/src/runtime/node.ts +4 -0
  103. package/node_modules/wire-core/src/storage/registry.ts +48 -15
  104. package/node_modules/wire-core/src/storage/workspace.ts +10 -1
  105. package/package.json +1 -1
  106. package/src/adapters/root.ts +51 -31
  107. package/src/auth.ts +10 -29
  108. package/src/cli.ts +2 -2
  109. package/src/executable.ts +38 -12
  110. 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.data },
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
- const outputPath = await dependencies.filesystem.exists(cleanPath) ? join(resolve(path), collisionFilename(fetched.title, source.service, source.identifier)) : cleanPath;
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.data },
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
- const markdown = await dependencies.filesystem.exists(outputPath) ? await dependencies.filesystem.readText(outputPath) : "";
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 result = await download(value, path);
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 { ...result, summary: { ...result.summary, action: "detached" } };
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 watch = async (value: string, path: string): Promise<WireWatchSession> => {
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
  }
@@ -134,4 +134,5 @@ export interface RuntimeCapabilities {
134
134
  readonly secrets: SecretsCapability;
135
135
  readonly cookies: CookiesCapability;
136
136
  readonly gmailTokens: GoogleTokensCapability;
137
+ readonly googleFormsTokens: GoogleTokensCapability;
137
138
  }
@@ -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, Array<(value: Record<string, unknown>) => void>>();
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 resolve = events.shift()!;
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("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", [...await chromeLaunchArguments(environment, extraction.startUrl)], { detached: true, stdio: ["ignore", "ignore", "pipe"] });
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 && extraction.service !== "chatgpt" ? undefined : new ChromeConnection(await pageDevtoolsUrl(browserUrl, extraction.domains));
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
- let metadata: Readonly<Record<string, string>> = result.metadata;
284
- if (extraction.service === "chatgpt") {
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 (extraction.service !== "chatgpt" && !(await Promise.race([extraction.verify(result.cookies, metadata), interruptPromise.then(() => false)]))) {
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 path = await existingCookiesFile(filesystem, candidatePaths) ?? candidatePaths[0]!;
195
- await filesystem.writeText(path, serializeNetscapeCookies(cookies, metadata));
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(import.meta.url);
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
- using database = this.connect();
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
- using database = this.connect();
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
- using database = this.connect();
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
- using database = this.connect();
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) throw missingIdentifier(service, identifier);
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
- using database = this.connect();
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) throw missingUrl(url);
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
- using database = this.connect();
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
- using database = this.connect();
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
- using database = this.connect();
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
- await using handle = await open(tempPath, "wx", 0o600);
250
- await handle.writeFile(`${stableJsonPretty(normalized)}\n`, "utf8");
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
  }