From c8962b56252a4c6278abaf3f4d2f13900eebefdc Mon Sep 17 00:00:00 2001 From: Tim Nunamaker Date: Fri, 17 Jul 2026 16:44:23 -0500 Subject: [PATCH 1/6] chore: add Apache-2.0 license, neutralize vana->pdp-connect, extract Vana-Web capabilities Three concerns landed together per the LFDT PDP-Connect transfer prep: 1. Licensing: add LICENSE (Apache-2.0), NOTICE (Copyright (c) 2026 Vana Foundation), and package.json "license" field. This repo previously had neither. 2. Neutralize vana -> pdp-connect (per-line, not a blind sweep): - State path ~/.vana/desktop -> ~/.pdp-connect/desktop everywhere - skills/vana-connect/ -> skills/pdp-connect/ via git mv; fixed the root run-connector.cjs symlink and every path reference (README, CREATE.md, RECIPES.md, SKILL.md, SETUP.md, CLAUDE_CONNECTOR_PLAN.md) - IndexedDB checkpoint keys vana_*_ckpt -> pdpconnect_*_ckpt (hyphens are illegal in IndexedDB names) - Hardcoded github.com/vana-com/data-connectors and raw.githubusercontent.com/vana-com/data-connectors URLs -> the PDP-Connect org, in generator DEFAULTS only (scripts/generate-*.mjs, packages/connector-installer-core/index.mjs, registry.json baseUrl, schema $id/const/pattern values, wholefoods definitionUrl); regenerated connector-index.json, fixture-index.json, scope-catalog.json, SCOPES.md to match, and updated generator test assertions - Removed the external `vana` CLI install stanzas (brew tap, curl install, npx @opendatalabs/connect fallback) from SETUP.md and SKILL.md -- that CLI lives in a different repo. The skill's "how to run a connector" flow now documents run-connector.cjs (the in-repo runner) as the sole entrypoint, since removing the CLI references would otherwise have left the skill with no documented way to run a connector - Removed Tim's hardcoded personal path (/home/tnunamak/code/vana-connect/dist/cli/bin.js) from SETUP.md and SKILL.md Two connectors (chatgpt-playwright, claude-export-playwright) and one (wholefoods-playwright) had their source/schemas change as part of this pass; each already had a published artifact under the old checksum, so each got a patch version bump (chatgpt 3.0.0 -> 3.0.1, claude-export 2.0.0 -> 2.0.1, wholefoods 1.0.0 -> 1.0.1) with matching checksums and regenerated release artifacts, to keep the artifact-immutability contract (connector-artifact-contract.test.mjs) honest. 3. Extract the Vana-Web capability column: SCOPES.md is generated. Edited scripts/generate-scope-catalog.mjs to drop the web-capabilities read/validate and the "Vana Web (hosted)" column/legend/limit emission; git rm scopes/web-capabilities.json and schemas/web-scope-capabilities.schema.json; updated schemas/scope-catalog.schema.json to drop the now-unused `web` fulfillment shape and `webCapabilities` field; regenerated scope-catalog.json, SCOPES.md, and the README connectors section (single local-connector-collection path); updated generator tests. The removed scopes/web-capabilities.json content is preserved at local/extracted-web-capabilities.json (not part of this PR) for a follow-up PR to vana-com/docs, since that data is Vana-Web/Data Pipe-specific and belongs in a Vana-owned home, not the neutral LFDT repo. Intentional `vana` survivors (grep-verified, everything else is gone): - .github/workflows/consumer-update-prs.yml:45,96 -- external Vana-owned consumer repos (vana-com/context-gateway, vana-com/data-connect) this workflow checks out; not this repo's own name - README.md (6 lines) -- links to vana-com/unity-surfaces and vana-com/context-gateway, genuinely separate Vana-owned repos this repo depends on / is consumed by - docs/specs/2026-05-29-scope-coverage-catalog-design.md (4 lines) -- historical internal design doc: a Linear workspace slug, prose describing the (now-extracted) Vana Web product, and the data-pipe.vana.org domain it calls -- archival, not this repo's branding - docs/superpowers/specs/2026-03-31-connector-smoke-test-design.md:46 -- citation to a historical GitHub issue number in this repo's own tracker - packages/connector-installer-core/index.mjs:22 -- DEFAULT_SIGSTORE_CERTIFICATE_IDENTITY, left as-is per owner request (Ry Jones); will be re-pinned post-transfer - scripts/generate-scope-catalog.test.mjs:105 -- a negative test assertion proving "Vana" no longer appears in generated markdown - skills/pdp-connect/scripts/setup.sh:28 -- vana-com/data-connect.git, the look-alike external repo (no "-ors"), distinct from this repo - NOTICE -- the intentional "Copyright (c) 2026 Vana Foundation" line Verification: npm ci clean; connector-index:check, fixture-index:check, scope-catalog:check all pass; node --test on connector-artifact-contract.test.mjs and generate-scope-catalog.test.mjs (14/14 passing); full contract-guardrails CI sequence (validate-manifests, normalize-manifests --check, validate-scope-schemas, fixture validate-only, check-page-api-additive, check-source-id-stability, check-additive-schemas) run locally against origin/main, all pass; root run-connector.cjs symlink resolves; Tim's /home/tnunamak path is gone. Not part of this PR: squash-transfer to PDP-Connect/data-connectors (a later owner step after review), npm scope migration (@opendatalabs/... stays unchanged), and the vana-com/docs PR to receive the extracted Web-capability data. Signed-off-by: Tim Nunamaker --- .claude/skills/data-connector/SKILL.md | 4 +- .github/workflows/consumer-update-prs.yml | 4 +- LICENSE | 202 +++++++++++++++++ NOTICE | 2 + README.md | 37 ++- SCOPES.md | 122 +++++----- .../chatgpt-playwright-3.0.0.tgz | Bin 18366 -> 0 bytes .../chatgpt-playwright-3.0.1.tgz | Bin 0 -> 18393 bytes .../claude-export-playwright-2.0.0.tgz | Bin 9586 -> 0 bytes .../claude-export-playwright-2.0.1.tgz | Bin 0 -> 9801 bytes .../wholefoods-playwright-1.0.0.tgz | Bin 49030 -> 0 bytes .../wholefoods-playwright-1.0.1.tgz | Bin 0 -> 49136 bytes connector-index.json | 102 ++++++++- .../_conformance/conformance-playwright.json | 2 +- connectors/anthropic/CLAUDE_CONNECTOR_PLAN.md | 22 +- .../anthropic/claude-export-playwright.js | 2 +- .../anthropic/claude-export-playwright.json | 2 +- .../__tests__/chatgpt-resume.browser.test.cjs | 2 +- connectors/openai/chatgpt-playwright.js | 2 +- connectors/openai/chatgpt-playwright.json | 2 +- .../schemas/wholefoods.nutrition.json | 2 +- .../wholefoods/schemas/wholefoods.orders.json | 2 +- .../schemas/wholefoods.profile.json | 2 +- .../wholefoods/wholefoods-playwright.json | 2 +- fixture-index.json | 26 +-- package.json | 1 + packages/connector-installer-core/index.mjs | 4 +- registry.json | 18 +- run-connector.cjs | 2 +- schemas/connector-index.schema.json | 2 +- schemas/fixture-index.schema.json | 2 +- schemas/manifest.schema.json | 2 +- schemas/scope-catalog.schema.json | 62 +---- schemas/web-scope-capabilities.schema.json | 92 -------- scope-catalog.json | 211 +----------------- scopes/web-capabilities.json | 83 ------- .../skills/auto-create-connector/SKILL.md | 6 +- scripts/CLAUDE.md | 2 +- scripts/capture-session.cjs | 8 +- scripts/connector-artifact-contract.test.mjs | 2 +- scripts/create-connector.sh | 2 +- scripts/generate-connector-index.mjs | 4 +- scripts/generate-fixture-index.mjs | 4 +- scripts/generate-scope-catalog.mjs | 90 +------- scripts/generate-scope-catalog.test.mjs | 86 +------ .../{vana-connect => pdp-connect}/CREATE.md | 10 +- .../{vana-connect => pdp-connect}/RECIPES.md | 10 +- skills/pdp-connect/SETUP.md | 35 +++ skills/{vana-connect => pdp-connect}/SKILL.md | 152 +++---------- .../reference/PAGE-API.md | 0 .../reference/PATTERNS.md | 0 .../scripts/fetch-connector.cjs | 8 +- .../scripts/generate-schemas.cjs | 0 .../scripts/register.cjs | 0 .../scripts/run-connector.cjs | 10 +- .../scripts/scaffold.cjs | 4 +- .../scripts/setup.sh | 24 +- .../scripts/validate.cjs | 8 +- .../templates/connector-metadata.json | 0 .../templates/connector-script.js | 0 .../templates/schema.json | 0 skills/vana-connect/SETUP.md | 95 -------- test-connector.cjs | 2 +- 63 files changed, 567 insertions(+), 1015 deletions(-) create mode 100644 LICENSE create mode 100644 NOTICE delete mode 100644 artifacts/chatgpt-playwright/chatgpt-playwright-3.0.0.tgz create mode 100644 artifacts/chatgpt-playwright/chatgpt-playwright-3.0.1.tgz delete mode 100644 artifacts/claude-export-playwright/claude-export-playwright-2.0.0.tgz create mode 100644 artifacts/claude-export-playwright/claude-export-playwright-2.0.1.tgz delete mode 100644 artifacts/wholefoods-playwright/wholefoods-playwright-1.0.0.tgz create mode 100644 artifacts/wholefoods-playwright/wholefoods-playwright-1.0.1.tgz delete mode 100644 schemas/web-scope-capabilities.schema.json delete mode 100644 scopes/web-capabilities.json rename skills/{vana-connect => pdp-connect}/CREATE.md (94%) rename skills/{vana-connect => pdp-connect}/RECIPES.md (91%) create mode 100644 skills/pdp-connect/SETUP.md rename skills/{vana-connect => pdp-connect}/SKILL.md (50%) rename skills/{vana-connect => pdp-connect}/reference/PAGE-API.md (100%) rename skills/{vana-connect => pdp-connect}/reference/PATTERNS.md (100%) rename skills/{vana-connect => pdp-connect}/scripts/fetch-connector.cjs (91%) rename skills/{vana-connect => pdp-connect}/scripts/generate-schemas.cjs (100%) rename skills/{vana-connect => pdp-connect}/scripts/register.cjs (100%) rename skills/{vana-connect => pdp-connect}/scripts/run-connector.cjs (96%) rename skills/{vana-connect => pdp-connect}/scripts/scaffold.cjs (94%) rename skills/{vana-connect => pdp-connect}/scripts/setup.sh (59%) rename skills/{vana-connect => pdp-connect}/scripts/validate.cjs (98%) rename skills/{vana-connect => pdp-connect}/templates/connector-metadata.json (100%) rename skills/{vana-connect => pdp-connect}/templates/connector-script.js (100%) rename skills/{vana-connect => pdp-connect}/templates/schema.json (100%) delete mode 100644 skills/vana-connect/SETUP.md diff --git a/.claude/skills/data-connector/SKILL.md b/.claude/skills/data-connector/SKILL.md index 79f9abd..4112e79 100644 --- a/.claude/skills/data-connector/SKILL.md +++ b/.claude/skills/data-connector/SKILL.md @@ -1,7 +1,7 @@ --- name: data-connector description: > - Build new Vana DataConnect data connectors that export user data from web platforms. + Build new data connectors that export user data from web platforms. Use when asked to "create a connector", "add a connector", "build a connector", "new data connector", "write a connector for X", or when working on the data-connectors repository and the task involves adding or modifying a Playwright-based connector. @@ -9,7 +9,7 @@ description: > # Data Connector Builder -Build Playwright-based data connectors for the Vana DataConnect ecosystem. Connectors export +Build Playwright-based data connectors for the data-connectors ecosystem. Connectors export a user's personal data from web platforms (LinkedIn, ChatGPT, Spotify, etc.) using browser automation. Credentials never leave the device. diff --git a/.github/workflows/consumer-update-prs.yml b/.github/workflows/consumer-update-prs.yml index 04cdb57..563b6da 100644 --- a/.github/workflows/consumer-update-prs.yml +++ b/.github/workflows/consumer-update-prs.yml @@ -76,7 +76,7 @@ jobs: commit-message: "chore: refresh connector snapshot" title: "chore: refresh connector snapshot" body: | - Automated update from `vana-com/data-connectors`. + Automated update from `PDP-Connect/data-connectors`. Changes: - refreshed `config/data-connectors-snapshot/` @@ -130,7 +130,7 @@ jobs: commit-message: "chore: refresh bundled connectors" title: "chore: refresh bundled connectors" body: | - Automated update from `vana-com/data-connectors`. + Automated update from `PDP-Connect/data-connectors`. Changes: - refreshed bundled connectors under `connectors/` diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..24e3113 --- /dev/null +++ b/NOTICE @@ -0,0 +1,2 @@ +Copyright © 2026 Vana Foundation +Licensed under the Apache License, Version 2.0. diff --git a/README.md b/README.md index 4ff9929..2491ce3 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Data Connectors -Playwright-based data connectors for Vana Desktop in [Unity Surfaces](https://github.com/vana-com/unity-surfaces). Each connector exports your data from a web platform using browser automation. Credentials never leave your device. +Playwright-based data connectors for local desktop collection in [Unity Surfaces](https://github.com/vana-com/unity-surfaces). Each connector exports your data from a web platform using browser automation. Credentials never leave your device. ## Connector status @@ -14,7 +14,7 @@ Each connector has a status indicating its maturity level: This table is an overview, not the public contract. [`scope-catalog.json`](scope-catalog.json) is the authoritative machine-readable contract for sources and scopes. [`SCOPES.md`](SCOPES.md) is its generated human-readable view. -The catalog separates two collection paths. **Vana Desktop (local)** runs registered Playwright connectors on your device. **Vana Web (hosted)** requests data from Open Data Labs' hosted collection service, called Data Pipe. A website cannot run the local Node.js/Playwright connector or read your signed-in session on another site. Vana Web could ask an installed Vana Desktop app to collect the data, but that is still Desktop collection. +The catalog describes the local desktop collection path: registered Playwright connectors run on your device and use your browser session. @@ -63,7 +63,7 @@ node run-connector.cjs ./connectors/github/github-playwright.js --pretty # node run-connector.cjs ./connectors/github/github-playwright.js --inputs '{"username":"x","password":"y"}' ``` -See [`skills/vana-connect/`](skills/vana-connect/) for the agent skill: setup, running, creating new connectors, and data recipes. +See [`skills/pdp-connect/`](skills/pdp-connect/) for the agent skill: setup, running, creating new connectors, and data recipes. ## Repository structure @@ -71,7 +71,7 @@ See [`skills/vana-connect/`](skills/vana-connect/) for the agent skill: setup, r |--------|--------------|----------| | **`connectors/`** | All platform connectors (`/-playwright.js` + `.json` + connector-local `schemas/` + local assets like `icons/`) | Everyone | | **`scripts/`** | Developer tooling: scaffold, test, validate, session capture | Human developers | -| **`skills/`** | AI agent skill for creating/running connectors (`vana-connect/`) | AI agents (Claude, etc.) | +| **`skills/`** | AI agent skill for creating/running connectors (`pdp-connect/`) | AI agents (Claude, etc.) | | **`schemas/`** | Shared meta-schemas such as `manifest.schema.json` | Validation | | **`types/`** | TypeScript type definitions (`connector.d.ts`) | TypeScript consumers | | **`fixture-index.json`** | Public index of schema-valid connector fixtures with main-branch raw GitHub URLs and checksums | Builders and AI agents | @@ -94,7 +94,7 @@ scripts/ # Developer tooling (human-driven) ├── validate-connector.cjs # Structure + output validator └── reference/ # Templates and API docs │ -skills/vana-connect/ # Agent skill (AI-agent-driven) +skills/pdp-connect/ # Agent skill (AI-agent-driven) ├── SKILL.md # Skill entry point (setup, connect, recipes) ├── CREATE.md # Full walkthrough for building connectors └── scripts/ # Agent-facing scripts (runner, validator, etc.) @@ -105,8 +105,7 @@ schemas/ # Shared meta-schemas registry.json # Central registry (checksums, versions, OTA) fixture-index.json # Public fixture catalog for builders and agents scope-catalog.json # Generated public source/scope contract -scopes/web-capabilities.json # Hand-authored Vana Web hosted-collection truth and limits -run-connector.cjs # Symlink → skills/vana-connect/scripts/run-connector.cjs +run-connector.cjs # Symlink → skills/pdp-connect/scripts/run-connector.cjs test-connector.cjs # Standalone test runner create-connector.sh # Quick autonomous scaffold script ``` @@ -118,11 +117,11 @@ create-connector.sh # Quick autonomous scaffold script - `connector-index.json` is the authoritative release index. - `scope-catalog.json` is the generated, schema-validated public contract for - source IDs, scope IDs, payload schema paths, Vana Desktop/Vana Web fulfillment, material + source IDs, scope IDs, payload schema paths, desktop fulfillment, material limits, and maturity. `SCOPES.md` is generated from the same model. - Pin `scope-catalog.json` from an immutable GitHub release named `connectors-`, for example - `https://github.com/vana-com/data-connectors/releases/download/connectors-/scope-catalog.json`. + `https://github.com/PDP-Connect/data-connectors/releases/download/connectors-/scope-catalog.json`. Release catalogs embed the full source commit and an immutable `schema.url` for every payload schema. Checked-in catalogs retain `schema.path` for local repository use. A raw `main` URL is not a version pin. @@ -196,11 +195,11 @@ npm run fixture-index:check The repo has two interfaces for building and running connectors. They serve different audiences but share the same connector format and output: -| | `scripts/` | `skills/vana-connect/` | +| | `scripts/` | `skills/pdp-connect/` | |---|---|---| | **Audience** | Human developers at a terminal | AI agents (Claude, etc.) | -| **Entry point** | `scripts/create-connector.sh` | `skills/vana-connect/SKILL.md` | -| **Login** | Manual browser login via `capture-session.cjs` | CLI-driven (`vana connect`) | +| **Entry point** | `scripts/create-connector.sh` | `skills/pdp-connect/SKILL.md` | +| **Login** | Manual browser login via `capture-session.cjs` | Runner-driven (`run-connector.cjs`) | | **Testing** | `scripts/test-connector.cjs` | `run-connector.cjs` | | **When to use** | Local development, debugging, manual QA | Automated connector creation and data export | @@ -210,7 +209,7 @@ Both produce the same connector files (`connectors//-playwright.j ## How connectors work -Connectors run in a sandboxed Playwright browser managed by Vana Desktop in Unity Surfaces. The runner provides a `page` API object (not raw Playwright). The browser starts headless; connectors call `page.showBrowser()` when login is needed and `page.goHeadless()` after. +Connectors run in a sandboxed Playwright browser managed by the local desktop runner in Unity Surfaces. The runner provides a `page` API object (not raw Playwright). The browser starts headless; connectors call `page.showBrowser()` when login is needed and `page.goHeadless()` after. ### Two-phase architecture @@ -354,14 +353,14 @@ The runner classifies each run into one of: ## Building a new connector -See [`skills/vana-connect/CREATE.md`](skills/vana-connect/CREATE.md) for the full walkthrough. Summary: +See [`skills/pdp-connect/CREATE.md`](skills/pdp-connect/CREATE.md) for the full walkthrough. Summary: -1. **Scaffold:** `node skills/vana-connect/scripts/scaffold.cjs [company]` -- generates script, metadata, and connector-local stub schema +1. **Scaffold:** `node skills/pdp-connect/scripts/scaffold.cjs [company]` -- generates script, metadata, and connector-local stub schema 2. **Implement:** Write login + data collection logic (see CREATE.md for auth patterns, extraction strategies, and reference connectors) 3. **Validate structure:** `node scripts/validate-connector.cjs connectors//-playwright.js` 4. **Test:** `node run-connector.cjs connectors//-playwright.js --inputs '{"username":"x","password":"y"}'` -5. **Validate output:** `node scripts/validate-connector.cjs connectors//-playwright.js --check-result ~/.vana/desktop/last-result.json` -6. **Register:** `node skills/vana-connect/scripts/register.cjs connectors//-playwright.js` -- adds entry + checksums to `registry.json` +5. **Validate output:** `node scripts/validate-connector.cjs connectors//-playwright.js --check-result ~/.pdp-connect/desktop/last-result.json` +6. **Register:** `node skills/pdp-connect/scripts/register.cjs connectors//-playwright.js` -- adds entry + checksums to `registry.json` --- @@ -436,11 +435,11 @@ await page.setProgress({ ## Testing locally -Vana Desktop development belongs to [Unity Surfaces](https://github.com/vana-com/unity-surfaces/tree/main/apps/desktop). Follow that repo's Desktop instructions for app setup, connector syncing, and runtime debugging. +Desktop app development belongs to [Unity Surfaces](https://github.com/vana-com/unity-surfaces/tree/main/apps/desktop). Follow that repo's Desktop instructions for app setup, connector syncing, and runtime debugging. ### Standalone test runner -Test connectors without the full Vana Desktop app. The runner spawns `playwright-runner` as a child process and outputs JSON protocol messages. +Test connectors without the full desktop app. The runner spawns `playwright-runner` as a child process and outputs JSON protocol messages. **Prerequisites:** Clone [Unity Surfaces](https://github.com/vana-com/unity-surfaces) alongside this repo, then point `PLAYWRIGHT_RUNNER_DIR` at its Desktop runner. diff --git a/SCOPES.md b/SCOPES.md index 4f65ae5..0655207 100644 --- a/SCOPES.md +++ b/SCOPES.md @@ -3,12 +3,7 @@ The public human-readable view of [`scope-catalog.json`](scope-catalog.json). -This catalog shows two collection paths. When both paths support a scope, they use the same scope ID and payload schema: - -- **Vana Desktop (local):** A registered Playwright connector runs on your device and uses your browser session. Connector manifests plus `registry.json` own this column. -- **Vana Web (hosted):** Vana Web requests data from Open Data Labs' hosted collection service, called Data Pipe. [`scopes/web-capabilities.json`](scopes/web-capabilities.json) owns this column and its limits. - -A website cannot run the local Node.js/Playwright connector or read your signed-in session on another site. Vana Web could ask an installed Vana Desktop app to collect the data, but that is still Desktop collection. The table uses **Yes**, **No**, or **Blocked** for each path. **Blocked** names the evidence required before making a claim. +This catalog describes the local connector collection path: a registered Playwright connector runs on your device and uses your browser session. Connector manifests plus `registry.json` own this data. ## Versioned artifact contract @@ -16,68 +11,59 @@ Every immutable `connectors-` GitHub release includes `scope-catalog.j ## Coverage -| Source | Scope | Description | Schema | Vana Web (hosted) | Vana Desktop (local) | Material limits | Desktop connector(s) / maturity | -|---|---|---|---|:--:|:--:|---|---| -| amazon | `amazon.orders` | Order history including items, prices, dates, and delivery status | [JSON Schema](connectors/amazon/schemas/amazon.orders.json) | No | Yes | None | amazon-playwright (beta) | -| amazon | `amazon.profile` | Account name and Prime membership status | [JSON Schema](connectors/amazon/schemas/amazon.profile.json) | No | Yes | None | amazon-playwright (beta) | -| chatgpt | `chatgpt.conversations` | All conversation history including messages with ChatGPT | [JSON Schema](connectors/openai/schemas/chatgpt.conversations.json) | No | Yes | None | chatgpt-playwright (stable) | -| chatgpt | `chatgpt.memories` | Saved memories that ChatGPT uses to personalize responses | [JSON Schema](connectors/openai/schemas/chatgpt.memories.json) | No | Yes | None | chatgpt-playwright (stable) | -| claude | `claude.conversations` | All Claude conversations with full message threads, from Anthropic's official data export. | [JSON Schema](connectors/anthropic/schemas/claude.conversations.json) | No | Yes | None | claude-export-playwright (experimental) | -| claude | `claude.projects` | Claude projects and their detail, from Anthropic's official data export. | [JSON Schema](connectors/anthropic/schemas/claude.projects.json) | No | Yes | None | claude-export-playwright (experimental) | -| doordash | `doordash.orders` | Complete order history with restaurant names, items ordered, dates, totals, and delivery status | [JSON Schema](connectors/doordash/schemas/doordash.orders.json) | No | Yes | None | doordash-playwright (beta) | -| github | `github.contributions` | Daily contribution counts for the last 4 years (per-year scrape, not just rolling 12 months), monthly totals, per-year totals, and your top day | [JSON Schema](connectors/github/schemas/github.contributions.json) | No | Yes | Vana Desktop (local; github-playwright): The Desktop connector collects the four most recent calendar years. | github-playwright (stable) | -| github | `github.events` | Recent public activity (pushes, pull requests, issues, comments, branches, releases) across every repository you have touched, including organization-owned repos | [JSON Schema](connectors/github/schemas/github.events.json) | No | Yes | Vana Desktop (local; github-playwright): The Desktop connector returns at most 300 public events.; Vana Desktop (local; github-playwright): The GitHub public-events API covers at most the previous 90 days. | github-playwright (stable) | -| github | `github.history` | Pull requests and issues you have authored across public repositories via the GitHub Search API, including title, body, dates, repository, labels, and state. | [JSON Schema](connectors/github/schemas/github.history.json) | No | Yes | Vana Desktop (local; github-playwright): The GitHub Search API returns at most 1,000 pull requests and 1,000 issues per query. | github-playwright (stable) | -| github | `github.profile` | Account profile information such as name, bio, follower counts, pinned repositories, organization memberships, and achievement badges | [JSON Schema](connectors/github/schemas/github.profile.json) | Yes | Yes | None | github-playwright (stable) | -| github | `github.repositories` | Your repository list with metadata like language, stars, and update time | [JSON Schema](connectors/github/schemas/github.repositories.json) | No | Yes | None | github-playwright (stable) | -| github | `github.starred` | Repositories you have starred on GitHub | [JSON Schema](connectors/github/schemas/github.starred.json) | No | Yes | None | github-playwright (stable) | -| heb | `heb.nutrition` | Nutrition facts (calories, macros, sodium, fiber, vitamins) for each product ordered. Aligned to Schema.org NutritionInformation. | [JSON Schema](connectors/heb/schemas/heb.nutrition.json) | No | Yes | None | heb-playwright (experimental) | -| heb | `heb.orders` | Curbside and delivery orders with item names, quantities, prices, and order dates | [JSON Schema](connectors/heb/schemas/heb.orders.json) | No | Yes | None | heb-playwright (experimental) | -| heb | `heb.profile` | Your name, email, phone number, and saved delivery addresses | [JSON Schema](connectors/heb/schemas/heb.profile.json) | No | Yes | None | heb-playwright (experimental) | -| icloud_notes | `icloud_notes.folders` | Folder structure for organizing notes | [JSON Schema](connectors/apple/schemas/icloud_notes.folders.json) | No | Yes | None | icloud-notes-playwright (experimental) | -| icloud_notes | `icloud_notes.notes` | Note content, titles, created/modified timestamps, and folder assignment | [JSON Schema](connectors/apple/schemas/icloud_notes.notes.json) | No | Yes | None | icloud-notes-playwright (experimental) | -| instagram | `instagram.ads` | Advertisers the user has seen ads from and ad topic interests based on their Instagram activity | [JSON Schema](connectors/meta/schemas/instagram.ads.json) | No | Yes | None | instagram-playwright (stable); instagram-ads-playwright (beta) | -| instagram | `instagram.following` | The list of Instagram accounts you follow | [JSON Schema](connectors/meta/schemas/instagram.following.json) | No | Yes | None | instagram-playwright (stable) | -| instagram | `instagram.posts` | Published posts from your feed | [JSON Schema](connectors/meta/schemas/instagram.posts.json) | Yes | Yes | Vana Web (hosted): The Web path returns at most the 150 most recent posts. | instagram-playwright (stable) | -| instagram | `instagram.profile` | Profile information including bio and follower counts | [JSON Schema](connectors/meta/schemas/instagram.profile.json) | Yes | Yes | None | instagram-playwright (stable) | -| linkedin | `linkedin.connections` | Your LinkedIn connections with names, headlines, profile URLs, and date connected | [JSON Schema](connectors/linkedin/schemas/linkedin.connections.json) | Blocked (linkedin-live-output) | Yes | None | linkedin-playwright (stable) | -| linkedin | `linkedin.education` | Your education history including schools, degrees, years, and grades | [JSON Schema](connectors/linkedin/schemas/linkedin.education.json) | Blocked (linkedin-live-output) | Yes | None | linkedin-playwright (stable) | -| linkedin | `linkedin.experience` | Your work experience including job titles, companies, dates, locations, and descriptions | [JSON Schema](connectors/linkedin/schemas/linkedin.experience.json) | Blocked (linkedin-live-output) | Yes | None | linkedin-playwright (stable) | -| linkedin | `linkedin.languages` | Your languages and proficiency levels | [JSON Schema](connectors/linkedin/schemas/linkedin.languages.json) | Blocked (linkedin-live-output) | Yes | None | linkedin-playwright (stable) | -| linkedin | `linkedin.profile` | Your LinkedIn profile including name, headline, location, about, and profile picture | [JSON Schema](connectors/linkedin/schemas/linkedin.profile.json) | Blocked (linkedin-live-output) | Yes | None | linkedin-playwright (stable) | -| linkedin | `linkedin.skills` | Your skills and endorsement counts | [JSON Schema](connectors/linkedin/schemas/linkedin.skills.json) | Blocked (linkedin-live-output) | Yes | None | linkedin-playwright (stable) | -| oura | `oura.activity` | Daily activity scores, step counts, calorie burn, and activity level breakdowns | [JSON Schema](connectors/oura/schemas/oura.activity.json) | No | Yes | None | oura-playwright (stable) | -| oura | `oura.readiness` | Daily readiness scores and contributing factors like HRV balance, resting heart rate, recovery, and body temperature | [JSON Schema](connectors/oura/schemas/oura.readiness.json) | No | Yes | None | oura-playwright (stable) | -| oura | `oura.sleep` | Daily sleep scores with contributor breakdowns and detailed sleep period data including duration, stages, heart rate, and HRV | [JSON Schema](connectors/oura/schemas/oura.sleep.json) | No | Yes | None | oura-playwright (stable) | -| shop | `shop.orders` | Complete order history from the Shop app including merchants, items, totals, and delivery status | [JSON Schema](connectors/shopify/schemas/shop.orders.json) | No | Yes | None | shop-playwright (beta) | -| spotify | `spotify.playlists` | Playlists and their tracks | [JSON Schema](connectors/spotify/schemas/spotify.playlists.json) | No | Yes | None | spotify-playwright (stable) | -| spotify | `spotify.profile` | Profile information | [JSON Schema](connectors/spotify/schemas/spotify.profile.json) | Yes | Yes | None | spotify-playwright (stable) | -| spotify | `spotify.savedTracks` | Saved/liked tracks in your library | [JSON Schema](connectors/spotify/schemas/spotify.savedTracks.json) | No | Yes | None | spotify-playwright (stable) | -| steam | `steam.friends` | Friend list with persona names, avatars, and friendship dates | [JSON Schema](connectors/valve/schemas/steam.friends.json) | No | Yes | None | steam-playwright (experimental) | -| steam | `steam.games` | Owned games with playtime statistics, plus recently played games | [JSON Schema](connectors/valve/schemas/steam.games.json) | No | Yes | None | steam-playwright (experimental) | -| steam | `steam.profile` | Steam profile including persona name, avatar, level, country, and account age | [JSON Schema](connectors/valve/schemas/steam.profile.json) | No | Yes | None | steam-playwright (experimental) | -| uber | `uber.receipts` | Fare breakdowns and receipt details for each trip | [JSON Schema](connectors/uber/schemas/uber.receipts.json) | No | Yes | None | uber-playwright (beta) | -| uber | `uber.trips` | Complete history of your Uber rides including pickup/dropoff locations, dates, and driver info | [JSON Schema](connectors/uber/schemas/uber.trips.json) | No | Yes | None | uber-playwright (beta) | -| wholefoods | `wholefoods.nutrition` | Nutrition facts (calories, macros, sodium, fiber, vitamins) for each product ordered. Aligned to Schema.org NutritionInformation. | [JSON Schema](connectors/wholefoods/schemas/wholefoods.nutrition.json) | No | Yes | None | wholefoods-playwright (experimental) | -| wholefoods | `wholefoods.orders` | Whole Foods delivery and pickup orders with item names, quantities, prices, and order dates | [JSON Schema](connectors/wholefoods/schemas/wholefoods.orders.json) | No | Yes | None | wholefoods-playwright (experimental) | -| wholefoods | `wholefoods.profile` | Your Amazon account name and email associated with Whole Foods orders | [JSON Schema](connectors/wholefoods/schemas/wholefoods.profile.json) | No | Yes | None | wholefoods-playwright (experimental) | -| youtube | `youtube.history` | Raw watch history entries for the top 50 most recent items | [JSON Schema](connectors/google/schemas/youtube.history.json) | No | Yes | Vana Desktop (local; youtube-playwright): The Desktop connector returns at most the 50 most recent watch history items. | youtube-playwright (beta) | -| youtube | `youtube.likes` | Videos you have liked | [JSON Schema](connectors/google/schemas/youtube.likes.json) | No | Yes | None | youtube-playwright (beta) | -| youtube | `youtube.playlistItems` | Videos inside your playlists, including titles and URLs | [JSON Schema](connectors/google/schemas/youtube.playlistItems.json) | No | Yes | None | youtube-playwright (beta) | -| youtube | `youtube.playlists` | Playlists visible in your YouTube Library | [JSON Schema](connectors/google/schemas/youtube.playlists.json) | No | Yes | None | youtube-playwright (beta) | -| youtube | `youtube.profile` | Email, channel URL, handle, and joined date (plus optional channel fields like title, avatar, and stats) | [JSON Schema](connectors/google/schemas/youtube.profile.json) | Yes | Yes | None | youtube-playwright (beta) | -| youtube | `youtube.subscriptions` | Channels you are subscribed to | [JSON Schema](connectors/google/schemas/youtube.subscriptions.json) | No | Yes | None | youtube-playwright (beta) | -| youtube | `youtube.watchLater` | Videos saved to Watch Later | [JSON Schema](connectors/google/schemas/youtube.watchLater.json) | No | Yes | None | youtube-playwright (beta) | - -## Blocked evidence - -- `linkedin.connections`: LinkedIn Web scope capability is not classified without live end-to-end evidence. Required: A captured successful LinkedIn Data Pipe result showing the exact returned scope-key set. The resulting Personal Server scope inventory for the same run and user after sync. -- `linkedin.education`: LinkedIn Web scope capability is not classified without live end-to-end evidence. Required: A captured successful LinkedIn Data Pipe result showing the exact returned scope-key set. The resulting Personal Server scope inventory for the same run and user after sync. -- `linkedin.experience`: LinkedIn Web scope capability is not classified without live end-to-end evidence. Required: A captured successful LinkedIn Data Pipe result showing the exact returned scope-key set. The resulting Personal Server scope inventory for the same run and user after sync. -- `linkedin.languages`: LinkedIn Web scope capability is not classified without live end-to-end evidence. Required: A captured successful LinkedIn Data Pipe result showing the exact returned scope-key set. The resulting Personal Server scope inventory for the same run and user after sync. -- `linkedin.profile`: LinkedIn Web scope capability is not classified without live end-to-end evidence. Required: A captured successful LinkedIn Data Pipe result showing the exact returned scope-key set. The resulting Personal Server scope inventory for the same run and user after sync. -- `linkedin.skills`: LinkedIn Web scope capability is not classified without live end-to-end evidence. Required: A captured successful LinkedIn Data Pipe result showing the exact returned scope-key set. The resulting Personal Server scope inventory for the same run and user after sync. +| Source | Scope | Description | Schema | Material limits | Desktop connector(s) / maturity | +|---|---|---|---|---|---| +| amazon | `amazon.orders` | Order history including items, prices, dates, and delivery status | [JSON Schema](connectors/amazon/schemas/amazon.orders.json) | None | amazon-playwright (beta) | +| amazon | `amazon.profile` | Account name and Prime membership status | [JSON Schema](connectors/amazon/schemas/amazon.profile.json) | None | amazon-playwright (beta) | +| chatgpt | `chatgpt.conversations` | All conversation history including messages with ChatGPT | [JSON Schema](connectors/openai/schemas/chatgpt.conversations.json) | None | chatgpt-playwright (stable) | +| chatgpt | `chatgpt.memories` | Saved memories that ChatGPT uses to personalize responses | [JSON Schema](connectors/openai/schemas/chatgpt.memories.json) | None | chatgpt-playwright (stable) | +| claude | `claude.conversations` | All Claude conversations with full message threads, from Anthropic's official data export. | [JSON Schema](connectors/anthropic/schemas/claude.conversations.json) | None | claude-export-playwright (experimental) | +| claude | `claude.projects` | Claude projects and their detail, from Anthropic's official data export. | [JSON Schema](connectors/anthropic/schemas/claude.projects.json) | None | claude-export-playwright (experimental) | +| doordash | `doordash.orders` | Complete order history with restaurant names, items ordered, dates, totals, and delivery status | [JSON Schema](connectors/doordash/schemas/doordash.orders.json) | None | doordash-playwright (beta) | +| github | `github.contributions` | Daily contribution counts for the last 4 years (per-year scrape, not just rolling 12 months), monthly totals, per-year totals, and your top day | [JSON Schema](connectors/github/schemas/github.contributions.json) | Desktop (local; github-playwright): The Desktop connector collects the four most recent calendar years. | github-playwright (stable) | +| github | `github.events` | Recent public activity (pushes, pull requests, issues, comments, branches, releases) across every repository you have touched, including organization-owned repos | [JSON Schema](connectors/github/schemas/github.events.json) | Desktop (local; github-playwright): The Desktop connector returns at most 300 public events.; Desktop (local; github-playwright): The GitHub public-events API covers at most the previous 90 days. | github-playwright (stable) | +| github | `github.history` | Pull requests and issues you have authored across public repositories via the GitHub Search API, including title, body, dates, repository, labels, and state. | [JSON Schema](connectors/github/schemas/github.history.json) | Desktop (local; github-playwright): The GitHub Search API returns at most 1,000 pull requests and 1,000 issues per query. | github-playwright (stable) | +| github | `github.profile` | Account profile information such as name, bio, follower counts, pinned repositories, organization memberships, and achievement badges | [JSON Schema](connectors/github/schemas/github.profile.json) | None | github-playwright (stable) | +| github | `github.repositories` | Your repository list with metadata like language, stars, and update time | [JSON Schema](connectors/github/schemas/github.repositories.json) | None | github-playwright (stable) | +| github | `github.starred` | Repositories you have starred on GitHub | [JSON Schema](connectors/github/schemas/github.starred.json) | None | github-playwright (stable) | +| heb | `heb.nutrition` | Nutrition facts (calories, macros, sodium, fiber, vitamins) for each product ordered. Aligned to Schema.org NutritionInformation. | [JSON Schema](connectors/heb/schemas/heb.nutrition.json) | None | heb-playwright (experimental) | +| heb | `heb.orders` | Curbside and delivery orders with item names, quantities, prices, and order dates | [JSON Schema](connectors/heb/schemas/heb.orders.json) | None | heb-playwright (experimental) | +| heb | `heb.profile` | Your name, email, phone number, and saved delivery addresses | [JSON Schema](connectors/heb/schemas/heb.profile.json) | None | heb-playwright (experimental) | +| icloud_notes | `icloud_notes.folders` | Folder structure for organizing notes | [JSON Schema](connectors/apple/schemas/icloud_notes.folders.json) | None | icloud-notes-playwright (experimental) | +| icloud_notes | `icloud_notes.notes` | Note content, titles, created/modified timestamps, and folder assignment | [JSON Schema](connectors/apple/schemas/icloud_notes.notes.json) | None | icloud-notes-playwright (experimental) | +| instagram | `instagram.ads` | Advertisers the user has seen ads from and ad topic interests based on their Instagram activity | [JSON Schema](connectors/meta/schemas/instagram.ads.json) | None | instagram-playwright (stable); instagram-ads-playwright (beta) | +| instagram | `instagram.following` | The list of Instagram accounts you follow | [JSON Schema](connectors/meta/schemas/instagram.following.json) | None | instagram-playwright (stable) | +| instagram | `instagram.posts` | Published posts from your feed | [JSON Schema](connectors/meta/schemas/instagram.posts.json) | None | instagram-playwright (stable) | +| instagram | `instagram.profile` | Profile information including bio and follower counts | [JSON Schema](connectors/meta/schemas/instagram.profile.json) | None | instagram-playwright (stable) | +| linkedin | `linkedin.connections` | Your LinkedIn connections with names, headlines, profile URLs, and date connected | [JSON Schema](connectors/linkedin/schemas/linkedin.connections.json) | None | linkedin-playwright (stable) | +| linkedin | `linkedin.education` | Your education history including schools, degrees, years, and grades | [JSON Schema](connectors/linkedin/schemas/linkedin.education.json) | None | linkedin-playwright (stable) | +| linkedin | `linkedin.experience` | Your work experience including job titles, companies, dates, locations, and descriptions | [JSON Schema](connectors/linkedin/schemas/linkedin.experience.json) | None | linkedin-playwright (stable) | +| linkedin | `linkedin.languages` | Your languages and proficiency levels | [JSON Schema](connectors/linkedin/schemas/linkedin.languages.json) | None | linkedin-playwright (stable) | +| linkedin | `linkedin.profile` | Your LinkedIn profile including name, headline, location, about, and profile picture | [JSON Schema](connectors/linkedin/schemas/linkedin.profile.json) | None | linkedin-playwright (stable) | +| linkedin | `linkedin.skills` | Your skills and endorsement counts | [JSON Schema](connectors/linkedin/schemas/linkedin.skills.json) | None | linkedin-playwright (stable) | +| oura | `oura.activity` | Daily activity scores, step counts, calorie burn, and activity level breakdowns | [JSON Schema](connectors/oura/schemas/oura.activity.json) | None | oura-playwright (stable) | +| oura | `oura.readiness` | Daily readiness scores and contributing factors like HRV balance, resting heart rate, recovery, and body temperature | [JSON Schema](connectors/oura/schemas/oura.readiness.json) | None | oura-playwright (stable) | +| oura | `oura.sleep` | Daily sleep scores with contributor breakdowns and detailed sleep period data including duration, stages, heart rate, and HRV | [JSON Schema](connectors/oura/schemas/oura.sleep.json) | None | oura-playwright (stable) | +| shop | `shop.orders` | Complete order history from the Shop app including merchants, items, totals, and delivery status | [JSON Schema](connectors/shopify/schemas/shop.orders.json) | None | shop-playwright (beta) | +| spotify | `spotify.playlists` | Playlists and their tracks | [JSON Schema](connectors/spotify/schemas/spotify.playlists.json) | None | spotify-playwright (stable) | +| spotify | `spotify.profile` | Profile information | [JSON Schema](connectors/spotify/schemas/spotify.profile.json) | None | spotify-playwright (stable) | +| spotify | `spotify.savedTracks` | Saved/liked tracks in your library | [JSON Schema](connectors/spotify/schemas/spotify.savedTracks.json) | None | spotify-playwright (stable) | +| steam | `steam.friends` | Friend list with persona names, avatars, and friendship dates | [JSON Schema](connectors/valve/schemas/steam.friends.json) | None | steam-playwright (experimental) | +| steam | `steam.games` | Owned games with playtime statistics, plus recently played games | [JSON Schema](connectors/valve/schemas/steam.games.json) | None | steam-playwright (experimental) | +| steam | `steam.profile` | Steam profile including persona name, avatar, level, country, and account age | [JSON Schema](connectors/valve/schemas/steam.profile.json) | None | steam-playwright (experimental) | +| uber | `uber.receipts` | Fare breakdowns and receipt details for each trip | [JSON Schema](connectors/uber/schemas/uber.receipts.json) | None | uber-playwright (beta) | +| uber | `uber.trips` | Complete history of your Uber rides including pickup/dropoff locations, dates, and driver info | [JSON Schema](connectors/uber/schemas/uber.trips.json) | None | uber-playwright (beta) | +| wholefoods | `wholefoods.nutrition` | Nutrition facts (calories, macros, sodium, fiber, vitamins) for each product ordered. Aligned to Schema.org NutritionInformation. | [JSON Schema](connectors/wholefoods/schemas/wholefoods.nutrition.json) | None | wholefoods-playwright (experimental) | +| wholefoods | `wholefoods.orders` | Whole Foods delivery and pickup orders with item names, quantities, prices, and order dates | [JSON Schema](connectors/wholefoods/schemas/wholefoods.orders.json) | None | wholefoods-playwright (experimental) | +| wholefoods | `wholefoods.profile` | Your Amazon account name and email associated with Whole Foods orders | [JSON Schema](connectors/wholefoods/schemas/wholefoods.profile.json) | None | wholefoods-playwright (experimental) | +| youtube | `youtube.history` | Raw watch history entries for the top 50 most recent items | [JSON Schema](connectors/google/schemas/youtube.history.json) | Desktop (local; youtube-playwright): The Desktop connector returns at most the 50 most recent watch history items. | youtube-playwright (beta) | +| youtube | `youtube.likes` | Videos you have liked | [JSON Schema](connectors/google/schemas/youtube.likes.json) | None | youtube-playwright (beta) | +| youtube | `youtube.playlistItems` | Videos inside your playlists, including titles and URLs | [JSON Schema](connectors/google/schemas/youtube.playlistItems.json) | None | youtube-playwright (beta) | +| youtube | `youtube.playlists` | Playlists visible in your YouTube Library | [JSON Schema](connectors/google/schemas/youtube.playlists.json) | None | youtube-playwright (beta) | +| youtube | `youtube.profile` | Email, channel URL, handle, and joined date (plus optional channel fields like title, avatar, and stats) | [JSON Schema](connectors/google/schemas/youtube.profile.json) | None | youtube-playwright (beta) | +| youtube | `youtube.subscriptions` | Channels you are subscribed to | [JSON Schema](connectors/google/schemas/youtube.subscriptions.json) | None | youtube-playwright (beta) | +| youtube | `youtube.watchLater` | Videos saved to Watch Later | [JSON Schema](connectors/google/schemas/youtube.watchLater.json) | None | youtube-playwright (beta) | ## Maintenance -Do not edit this file. Update connector manifests/`registry.json` for Vana Desktop truth or `scopes/web-capabilities.json` for Vana Web truth, then run `node scripts/generate-scope-catalog.mjs`. +Do not edit this file. Update connector manifests/`registry.json`, then run `node scripts/generate-scope-catalog.mjs`. diff --git a/artifacts/chatgpt-playwright/chatgpt-playwright-3.0.0.tgz b/artifacts/chatgpt-playwright/chatgpt-playwright-3.0.0.tgz deleted file mode 100644 index c00e130f9aa4a536591610eedd5994ea4d4f37c8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 18366 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 Z_4oDn_4oDn_4j$d{|CUiM)&|20RVpU>s0^% diff --git a/artifacts/chatgpt-playwright/chatgpt-playwright-3.0.1.tgz b/artifacts/chatgpt-playwright/chatgpt-playwright-3.0.1.tgz new file mode 100644 index 0000000000000000000000000000000000000000..96795703c5a4def937de9941d245ca06890a20ae GIT binary patch literal 18393 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 AsQ>@~ literal 0 HcmV?d00001 diff --git a/artifacts/claude-export-playwright/claude-export-playwright-2.0.0.tgz b/artifacts/claude-export-playwright/claude-export-playwright-2.0.0.tgz deleted file mode 100644 index 9ff578e7f03d3169a2c5fcd6012a4b6458df001c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 9586 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 cyq4GUT3*X*c`dKy^ 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 literal 0 HcmV?d00001 diff --git a/artifacts/wholefoods-playwright/wholefoods-playwright-1.0.0.tgz b/artifacts/wholefoods-playwright/wholefoods-playwright-1.0.0.tgz deleted file mode 100644 index f8e10c5e20feb70c794ed98b6d25271d68c65f61..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 49030 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 F{SW-(k4^vp diff --git a/artifacts/wholefoods-playwright/wholefoods-playwright-1.0.1.tgz b/artifacts/wholefoods-playwright/wholefoods-playwright-1.0.1.tgz new file mode 100644 index 0000000000000000000000000000000000000000..4a8459153910c9faa7d3eb26b841d1b390d74488 GIT binary patch literal 49136 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 Q{2zS&7YL=acmTKo0I7lbHvj+t literal 0 HcmV?d00001 diff --git a/connector-index.json b/connector-index.json index b7b0af4..9d37de5 100644 --- a/connector-index.json +++ b/connector-index.json @@ -1,6 +1,6 @@ { "indexVersion": "2.0", - "sourceRepo": "https://github.com/vana-com/data-connectors", + "sourceRepo": "https://github.com/PDP-Connect/data-connectors", "generatedAt": "2026-07-13T00:00:00Z", "signature": null, "connectors": { @@ -110,6 +110,42 @@ "iconKey": "chatgpt", "defaultScope": "chatgpt.conversations" } + }, + { + "connectorId": "chatgpt-playwright", + "company": "openai", + "version": "3.0.1", + "name": "ChatGPT", + "status": "stable", + "description": "Exports your email, memories, and all conversations from ChatGPT.", + "sourceFiles": { + "script": "openai/chatgpt-playwright.js", + "metadata": "openai/chatgpt-playwright.json" + }, + "publishedAt": "2026-07-13T00:00:00Z", + "sourceTag": "chore/apache-license-and-neutralize", + "sourceCommit": "37b45c33d159281f718bf2f1da325520ce0f50c4", + "releaseId": "connectors-37b45c33d159", + "pageApiVersion": 1, + "manifestSha256": "sha256:6b3df1f5ce03e588c23f1e26e35494526166baee99e69c384b72b31f7b306371", + "scriptSha256": "sha256:f2f33ef300e8b89ecbc13fcfa80ee88b0ec0d7849f2018a7a16623b5e1146527", + "artifactSha256": "sha256:e99ba585710615ae3a4afcd1babe21097bdd52acc06bfa79837e32334e5ee03e", + "artifactPath": "artifacts/chatgpt-playwright/chatgpt-playwright-3.0.1.tgz", + "artifactUrl": "https://raw.githubusercontent.com/PDP-Connect/data-connectors/37b45c33d159281f718bf2f1da325520ce0f50c4/artifacts/chatgpt-playwright/chatgpt-playwright-3.0.1.tgz", + "scopes": [ + "chatgpt.memories", + "chatgpt.conversations" + ], + "consumerMetadata": { + "sourceId": "chatgpt", + "displayName": "ChatGPT", + "brandDomain": "chatgpt.com", + "aliases": [ + "openai" + ], + "iconKey": "chatgpt", + "defaultScope": "chatgpt.conversations" + } } ], "claude-export-playwright": [ @@ -139,6 +175,33 @@ "claude.projects" ], "consumerMetadata": null + }, + { + "connectorId": "claude-export-playwright", + "company": "Anthropic", + "version": "2.0.1", + "name": "Claude", + "status": "experimental", + "description": "Exports your Claude conversations and projects via Anthropic's official data export (one complete archive, no per-conversation rate limiting).", + "sourceFiles": { + "script": "anthropic/claude-export-playwright.js", + "metadata": "anthropic/claude-export-playwright.json" + }, + "publishedAt": "2026-07-13T00:00:00Z", + "sourceTag": "chore/apache-license-and-neutralize", + "sourceCommit": "37b45c33d159281f718bf2f1da325520ce0f50c4", + "releaseId": "connectors-37b45c33d159", + "pageApiVersion": 1, + "manifestSha256": "sha256:7213ef42d1bcbde91db4972ae22a8f0b445f2ff47d85c95548b34ce20054c315", + "scriptSha256": "sha256:12a068a5186aececdd13e999b84f14a281d8e3d256c1eec70b812afd17142152", + "artifactSha256": "sha256:23c8b63a91e3ea3bafce80aa1a6efd5afb79625cdc65719776480fa1b2f8e320", + "artifactPath": "artifacts/claude-export-playwright/claude-export-playwright-2.0.1.tgz", + "artifactUrl": "https://raw.githubusercontent.com/PDP-Connect/data-connectors/37b45c33d159281f718bf2f1da325520ce0f50c4/artifacts/claude-export-playwright/claude-export-playwright-2.0.1.tgz", + "scopes": [ + "claude.conversations", + "claude.projects" + ], + "consumerMetadata": null } ], "doordash-playwright": [ @@ -911,6 +974,43 @@ "iconKey": "wholefoods", "defaultScope": "wholefoods.orders" } + }, + { + "connectorId": "wholefoods-playwright", + "company": "wholefoods", + "version": "1.0.1", + "name": "Whole Foods Market", + "status": "experimental", + "description": "Exports your Whole Foods Market order history (via Amazon) and product nutrition data.", + "sourceFiles": { + "script": "wholefoods/wholefoods-playwright.js", + "metadata": "wholefoods/wholefoods-playwright.json" + }, + "publishedAt": "2026-07-13T00:00:00Z", + "sourceTag": "chore/apache-license-and-neutralize", + "sourceCommit": "37b45c33d159281f718bf2f1da325520ce0f50c4", + "releaseId": "connectors-37b45c33d159", + "pageApiVersion": 1, + "manifestSha256": "sha256:b374a547bacfe49c0fcce7cea41db0630e8335c9f655e644a43a11b2eae9e0e1", + "scriptSha256": "sha256:2ebb4426258b87a8e3699b9609c36ff69b03f050e747ed530c54ee1e7f84c59b", + "artifactSha256": "sha256:5f1262b008e96360385af589b2cee71e8512fde6505382ed6e4c39087181ddd0", + "artifactPath": "artifacts/wholefoods-playwright/wholefoods-playwright-1.0.1.tgz", + "artifactUrl": "https://raw.githubusercontent.com/PDP-Connect/data-connectors/37b45c33d159281f718bf2f1da325520ce0f50c4/artifacts/wholefoods-playwright/wholefoods-playwright-1.0.1.tgz", + "scopes": [ + "wholefoods.profile", + "wholefoods.orders", + "wholefoods.nutrition" + ], + "consumerMetadata": { + "sourceId": "wholefoods", + "displayName": "Whole Foods Market", + "brandDomain": "wholefoodsmarket.com", + "aliases": [ + "whole foods" + ], + "iconKey": "wholefoods", + "defaultScope": "wholefoods.orders" + } } ], "youtube-playwright": [ diff --git a/connectors/_conformance/conformance-playwright.json b/connectors/_conformance/conformance-playwright.json index fa675b5..c39dbb8 100644 --- a/connectors/_conformance/conformance-playwright.json +++ b/connectors/_conformance/conformance-playwright.json @@ -4,7 +4,7 @@ "source_id": "conformance", "version": "1.0.0", "name": "Page API Conformance", - "company": "Vana", + "company": "Conformance", "description": "Synthetic connector that exercises every method in the canonical page API minimum surface. Used by CI to detect runtime drift between shells (Context Gateway, DataConnect, future runners).", "runtime": "playwright", "page_api_version": 1, diff --git a/connectors/anthropic/CLAUDE_CONNECTOR_PLAN.md b/connectors/anthropic/CLAUDE_CONNECTOR_PLAN.md index 30824e1..d7b2181 100644 --- a/connectors/anthropic/CLAUDE_CONNECTOR_PLAN.md +++ b/connectors/anthropic/CLAUDE_CONNECTOR_PLAN.md @@ -56,10 +56,10 @@ Initial target: Source docs: -- `skills/vana-connect/SKILL.md` -- `skills/vana-connect/CREATE.md` -- `skills/vana-connect/reference/PATTERNS.md` -- `skills/vana-connect/reference/PAGE-API.md` +- `skills/pdp-connect/SKILL.md` +- `skills/pdp-connect/CREATE.md` +- `skills/pdp-connect/reference/PATTERNS.md` +- `skills/pdp-connect/reference/PAGE-API.md` Important repo constraints: @@ -249,7 +249,7 @@ Once files exist, the working loop is: What is working now: -- connector is scaffolded, registered, and runnable through `vana` +- connector is scaffolded, registered, and runnable through `run-connector.cjs` - manual-login-first Claude session detection works - full thread/message export works for conversations via: - `GET /api/organizations/:orgId/chat_conversations_v2?...` @@ -318,15 +318,15 @@ Structural validation: Register updated connector locally: -- `node skills/vana-connect/scripts/register.cjs connectors/anthropic/claude-playwright.js` +- `node skills/pdp-connect/scripts/register.cjs connectors/anthropic/claude-playwright.js` Run the real connector: -- `vana connect claude --json --no-input` +- `node run-connector.cjs connectors/anthropic/claude-playwright.js --inputs '{...}'` Validate the exported result: -- `node scripts/validate-connector.cjs connectors/anthropic/claude-playwright.js --check-result ~/.vana/results/claude.json` +- `node scripts/validate-connector.cjs connectors/anthropic/claude-playwright.js --check-result ~/.pdp-connect/desktop/last-result.json` ## Next Steps @@ -386,15 +386,15 @@ Reason: ## Claude-Specific Learnings -- the local repo can be exercised directly through `vana` when run from the repo root; this is the right execution path for this project +- the local repo can be exercised directly through `run-connector.cjs` when run from the repo root; this is the right execution path for this project - Claude session state is reusable through the existing local browser profile, which makes manual-login-first iteration practical - the active organization id is recoverable from `lastActiveOrg` in cookie or storage, and that is enough to unlock the main authenticated API paths - the conversation-detail API returns message trees under `chat_messages` - flattening message text requires handling Claude content blocks rather than assuming a single plain-text field - sidebar DOM scraping is still useful as a fallback, but API collection is the primary path now - fetching every conversation serially is too slow; batching detail requests is necessary for practical export times -- after changing the connector, re-run the repo registration step so `vana` picks up the new script hash -- prefer validating with a real `vana connect claude --json --no-input` run, not only the structural validator +- after changing the connector, re-run the repo registration step so the registry picks up the new script hash +- prefer validating with a real `run-connector.cjs` run, not only the structural validator - if a field may legitimately be `null`, avoid marking it as required in the schema because the current validator can report it as missing - manual login is an acceptable and common connector tradeoff when auth is brittle or includes anti-bot checks, CAPTCHA, SSO, or third-party identity flows - for Claude specifically, email/password automation is a reasonable fast-follow candidate; Google automation should be treated as later work unless there is a very strong product reason to take it on diff --git a/connectors/anthropic/claude-export-playwright.js b/connectors/anthropic/claude-export-playwright.js index 121e18d..ef3b198 100644 --- a/connectors/anthropic/claude-export-playwright.js +++ b/connectors/anthropic/claude-export-playwright.js @@ -23,7 +23,7 @@ const CLAUDE_HOME_URL = 'https://claude.ai/new'; const CLAUDE_LOGIN_URL = 'https://claude.ai/login'; -const CKPT_DB = 'vana_claude_export_ckpt'; +const CKPT_DB = 'pdpconnect_claude_export_ckpt'; // The export is an async job; poll within the run so it completes in one click. const POLL_ATTEMPT_TIMEOUT_MS = 25000; // per attempt: navigate + wait for the download to fire const POLL_INTERVAL_MS = 8000; // pause between attempts while the job is still preparing diff --git a/connectors/anthropic/claude-export-playwright.json b/connectors/anthropic/claude-export-playwright.json index 2321ece..d2c7f80 100644 --- a/connectors/anthropic/claude-export-playwright.json +++ b/connectors/anthropic/claude-export-playwright.json @@ -2,7 +2,7 @@ "manifest_version": "1.0", "connector_id": "claude-export-playwright", "source_id": "claude", - "version": "2.0.0", + "version": "2.0.1", "name": "Claude", "company": "Anthropic", "description": "Exports your Claude conversations and projects via Anthropic's official data export (one complete archive, no per-conversation rate limiting).", diff --git a/connectors/openai/__tests__/chatgpt-resume.browser.test.cjs b/connectors/openai/__tests__/chatgpt-resume.browser.test.cjs index 09c3c43..1556230 100644 --- a/connectors/openai/__tests__/chatgpt-resume.browser.test.cjs +++ b/connectors/openai/__tests__/chatgpt-resume.browser.test.cjs @@ -176,7 +176,7 @@ async function runConnectorOnce(userDataDir, plan) { const returned = await connectorFn.call(null, api); // Read the on-disk checkpoint back the same way the connector does. const ckpt = await realPage.evaluate(`(async () => { - const db = await new Promise((res, rej) => { const r = indexedDB.open('vana_chatgpt_ckpt', 1); r.onsuccess = () => res(r.result); r.onerror = () => rej(r.error); }); + const db = await new Promise((res, rej) => { const r = indexedDB.open('pdpconnect_chatgpt_ckpt', 1); r.onsuccess = () => res(r.result); r.onerror = () => rej(r.error); }); const ids = await new Promise((res) => { const out = []; const cur = db.transaction('conversations').objectStore('conversations').openCursor(); cur.onsuccess = (e) => { const c = e.target.result; if (!c) return res(out); out.push(c.value.id); c.continue(); }; }); const meta = await new Promise((res) => { const g = db.transaction('meta').objectStore('meta').get('state'); g.onsuccess = () => res(g.result && g.result.v ? g.result.v : {}); }); return { ids: ids.sort(), meta }; diff --git a/connectors/openai/chatgpt-playwright.js b/connectors/openai/chatgpt-playwright.js index 31c6971..963e690 100644 --- a/connectors/openai/chatgpt-playwright.js +++ b/connectors/openai/chatgpt-playwright.js @@ -38,7 +38,7 @@ */ // ─── Tunables ──────────────────────────────────────────────────────── -const CKPT_DB = 'vana_chatgpt_ckpt'; +const CKPT_DB = 'pdpconnect_chatgpt_ckpt'; const CKPT_FORMAT = 1; // "Concurrency" now sizes the /conversations/batch request (ids per POST). The diff --git a/connectors/openai/chatgpt-playwright.json b/connectors/openai/chatgpt-playwright.json index cd23b38..6406fde 100644 --- a/connectors/openai/chatgpt-playwright.json +++ b/connectors/openai/chatgpt-playwright.json @@ -2,7 +2,7 @@ "manifest_version": "1.0", "connector_id": "chatgpt-playwright", "source_id": "chatgpt", - "version": "3.0.0", + "version": "3.0.1", "name": "ChatGPT", "company": "OpenAI", "description": "Exports your email, memories, and all conversations from ChatGPT.", diff --git a/connectors/wholefoods/schemas/wholefoods.nutrition.json b/connectors/wholefoods/schemas/wholefoods.nutrition.json index 7614ed6..9cf4743 100644 --- a/connectors/wholefoods/schemas/wholefoods.nutrition.json +++ b/connectors/wholefoods/schemas/wholefoods.nutrition.json @@ -3,7 +3,7 @@ "version": "1.0.0", "scope": "wholefoods.nutrition", "dialect": "json", - "definitionUrl": "https://raw.githubusercontent.com/vana-com/data-connectors/main/connectors/wholefoods/schemas/wholefoods.nutrition.json", + "definitionUrl": "https://raw.githubusercontent.com/PDP-Connect/data-connectors/main/connectors/wholefoods/schemas/wholefoods.nutrition.json", "description": "Nutrition facts per unique product ordered from Whole Foods Market, aligned to Schema.org NutritionInformation", "schema": { "type": "object", diff --git a/connectors/wholefoods/schemas/wholefoods.orders.json b/connectors/wholefoods/schemas/wholefoods.orders.json index 26bbd8f..20b4b63 100644 --- a/connectors/wholefoods/schemas/wholefoods.orders.json +++ b/connectors/wholefoods/schemas/wholefoods.orders.json @@ -3,7 +3,7 @@ "version": "1.0.0", "scope": "wholefoods.orders", "dialect": "json", - "definitionUrl": "https://raw.githubusercontent.com/vana-com/data-connectors/main/connectors/wholefoods/schemas/wholefoods.orders.json", + "definitionUrl": "https://raw.githubusercontent.com/PDP-Connect/data-connectors/main/connectors/wholefoods/schemas/wholefoods.orders.json", "description": "Whole Foods Market delivery and pickup order history with item details", "schema": { "type": "object", diff --git a/connectors/wholefoods/schemas/wholefoods.profile.json b/connectors/wholefoods/schemas/wholefoods.profile.json index 9db460c..8e76af8 100644 --- a/connectors/wholefoods/schemas/wholefoods.profile.json +++ b/connectors/wholefoods/schemas/wholefoods.profile.json @@ -3,7 +3,7 @@ "version": "1.0.0", "scope": "wholefoods.profile", "dialect": "json", - "definitionUrl": "https://raw.githubusercontent.com/vana-com/data-connectors/main/connectors/wholefoods/schemas/wholefoods.profile.json", + "definitionUrl": "https://raw.githubusercontent.com/PDP-Connect/data-connectors/main/connectors/wholefoods/schemas/wholefoods.profile.json", "description": "Amazon account information associated with Whole Foods Market orders", "schema": { "type": "object", diff --git a/connectors/wholefoods/wholefoods-playwright.json b/connectors/wholefoods/wholefoods-playwright.json index 87f1923..f391fd6 100644 --- a/connectors/wholefoods/wholefoods-playwright.json +++ b/connectors/wholefoods/wholefoods-playwright.json @@ -2,7 +2,7 @@ "manifest_version": "1.0", "connector_id": "wholefoods-playwright", "source_id": "wholefoods", - "version": "1.0.0", + "version": "1.0.1", "name": "Whole Foods Market", "company": "wholefoods", "description": "Exports your Whole Foods Market order history (via Amazon) and product nutrition data.", diff --git a/fixture-index.json b/fixture-index.json index 56f33f5..34baea8 100644 --- a/fixture-index.json +++ b/fixture-index.json @@ -1,7 +1,7 @@ { "$schema": "./schemas/fixture-index.schema.json", "indexVersion": "1.0", - "sourceRepo": "https://github.com/vana-com/data-connectors", + "sourceRepo": "https://github.com/PDP-Connect/data-connectors", "generatedAt": "2026-07-13T00:00:00Z", "fixtures": [ { @@ -12,7 +12,7 @@ "scenario": "empty", "recordCount": 0, "path": "connectors/meta/fixtures/instagram.profile.empty.json", - "rawUrl": "https://raw.githubusercontent.com/vana-com/data-connectors/main/connectors/meta/fixtures/instagram.profile.empty.json", + "rawUrl": "https://raw.githubusercontent.com/PDP-Connect/data-connectors/main/connectors/meta/fixtures/instagram.profile.empty.json", "schemaPath": "connectors/meta/schemas/instagram.profile.json", "sizeBytes": 271, "sha256": "sha256:f294a6ca5ff524edf68110ef8b564c1df15284beccc1dc28bf8af7ff16a7e5f3" @@ -25,7 +25,7 @@ "scenario": "small", "recordCount": 0, "path": "connectors/meta/fixtures/instagram.profile.small.json", - "rawUrl": "https://raw.githubusercontent.com/vana-com/data-connectors/main/connectors/meta/fixtures/instagram.profile.small.json", + "rawUrl": "https://raw.githubusercontent.com/PDP-Connect/data-connectors/main/connectors/meta/fixtures/instagram.profile.small.json", "schemaPath": "connectors/meta/schemas/instagram.profile.json", "sizeBytes": 274, "sha256": "sha256:1840789166c70c1468b7296b8842f0ff2b937da2a75f2d26d5a84a6d7c192efc" @@ -38,7 +38,7 @@ "scenario": "large", "recordCount": 0, "path": "connectors/meta/fixtures/instagram.profile.large.json", - "rawUrl": "https://raw.githubusercontent.com/vana-com/data-connectors/main/connectors/meta/fixtures/instagram.profile.large.json", + "rawUrl": "https://raw.githubusercontent.com/PDP-Connect/data-connectors/main/connectors/meta/fixtures/instagram.profile.large.json", "schemaPath": "connectors/meta/schemas/instagram.profile.json", "sizeBytes": 484, "sha256": "sha256:9b304fea3be9c2e3937060d8374c1d1ba77cc66cfa578868950c2a7a7d246f9c" @@ -51,7 +51,7 @@ "scenario": "empty", "recordCount": 0, "path": "connectors/spotify/fixtures/spotify.savedTracks.empty.json", - "rawUrl": "https://raw.githubusercontent.com/vana-com/data-connectors/main/connectors/spotify/fixtures/spotify.savedTracks.empty.json", + "rawUrl": "https://raw.githubusercontent.com/PDP-Connect/data-connectors/main/connectors/spotify/fixtures/spotify.savedTracks.empty.json", "schemaPath": "connectors/spotify/schemas/spotify.savedTracks.json", "sizeBytes": 38, "sha256": "sha256:1083809a5e7b2adb1c94cf74a067c80182da1b96b7fa90d4b648487e7bda7fef" @@ -64,7 +64,7 @@ "scenario": "small", "recordCount": 3, "path": "connectors/spotify/fixtures/spotify.savedTracks.small.json", - "rawUrl": "https://raw.githubusercontent.com/vana-com/data-connectors/main/connectors/spotify/fixtures/spotify.savedTracks.small.json", + "rawUrl": "https://raw.githubusercontent.com/PDP-Connect/data-connectors/main/connectors/spotify/fixtures/spotify.savedTracks.small.json", "schemaPath": "connectors/spotify/schemas/spotify.savedTracks.json", "sizeBytes": 1355, "sha256": "sha256:0f577bfb2877e93b7b6e0b2c89b855906faaca373d2865c5a11d8ef47aa7063f" @@ -77,7 +77,7 @@ "scenario": "large", "recordCount": 15, "path": "connectors/spotify/fixtures/spotify.savedTracks.large.json", - "rawUrl": "https://raw.githubusercontent.com/vana-com/data-connectors/main/connectors/spotify/fixtures/spotify.savedTracks.large.json", + "rawUrl": "https://raw.githubusercontent.com/PDP-Connect/data-connectors/main/connectors/spotify/fixtures/spotify.savedTracks.large.json", "schemaPath": "connectors/spotify/schemas/spotify.savedTracks.json", "sizeBytes": 6358, "sha256": "sha256:b1fe3c82003fef4f222161eed65c5a3e76c7cc13cc793b39940c3589c394c840" @@ -90,7 +90,7 @@ "scenario": "empty", "recordCount": 0, "path": "connectors/google/fixtures/youtube.history.empty.json", - "rawUrl": "https://raw.githubusercontent.com/vana-com/data-connectors/main/connectors/google/fixtures/youtube.history.empty.json", + "rawUrl": "https://raw.githubusercontent.com/PDP-Connect/data-connectors/main/connectors/google/fixtures/youtube.history.empty.json", "schemaPath": "connectors/google/schemas/youtube.history.json", "sizeBytes": 64, "sha256": "sha256:e1920da9025417bba991f2edc74e91aa4444d86a13e402ad5625fbfc5e12e1aa" @@ -103,7 +103,7 @@ "scenario": "small", "recordCount": 4, "path": "connectors/google/fixtures/youtube.history.small.json", - "rawUrl": "https://raw.githubusercontent.com/vana-com/data-connectors/main/connectors/google/fixtures/youtube.history.small.json", + "rawUrl": "https://raw.githubusercontent.com/PDP-Connect/data-connectors/main/connectors/google/fixtures/youtube.history.small.json", "schemaPath": "connectors/google/schemas/youtube.history.json", "sizeBytes": 1431, "sha256": "sha256:4d3fb2096b2a46741c42894095fdd12ffea69b3fbe905b1b507167236a43197f" @@ -116,7 +116,7 @@ "scenario": "large", "recordCount": 30, "path": "connectors/google/fixtures/youtube.history.large.json", - "rawUrl": "https://raw.githubusercontent.com/vana-com/data-connectors/main/connectors/google/fixtures/youtube.history.large.json", + "rawUrl": "https://raw.githubusercontent.com/PDP-Connect/data-connectors/main/connectors/google/fixtures/youtube.history.large.json", "schemaPath": "connectors/google/schemas/youtube.history.json", "sizeBytes": 10107, "sha256": "sha256:fd202ae77b4f21f7bfe26f9b3b2f50a6f7af5cd76ab933f8ebe9ed762fc98eb6" @@ -129,7 +129,7 @@ "scenario": "empty", "recordCount": 0, "path": "connectors/google/fixtures/youtube.likes.empty.json", - "rawUrl": "https://raw.githubusercontent.com/vana-com/data-connectors/main/connectors/google/fixtures/youtube.likes.empty.json", + "rawUrl": "https://raw.githubusercontent.com/PDP-Connect/data-connectors/main/connectors/google/fixtures/youtube.likes.empty.json", "schemaPath": "connectors/google/schemas/youtube.likes.json", "sizeBytes": 24, "sha256": "sha256:b142328617d8c8ecc8d81c3cee885930154515b95b131e9c53214e78d807434d" @@ -142,7 +142,7 @@ "scenario": "small", "recordCount": 4, "path": "connectors/google/fixtures/youtube.likes.small.json", - "rawUrl": "https://raw.githubusercontent.com/vana-com/data-connectors/main/connectors/google/fixtures/youtube.likes.small.json", + "rawUrl": "https://raw.githubusercontent.com/PDP-Connect/data-connectors/main/connectors/google/fixtures/youtube.likes.small.json", "schemaPath": "connectors/google/schemas/youtube.likes.json", "sizeBytes": 1531, "sha256": "sha256:a134c8f9097f8cdbcce2acb3e18383595ea09a9cc729131ae9c3f7bda665c5a5" @@ -155,7 +155,7 @@ "scenario": "large", "recordCount": 30, "path": "connectors/google/fixtures/youtube.likes.large.json", - "rawUrl": "https://raw.githubusercontent.com/vana-com/data-connectors/main/connectors/google/fixtures/youtube.likes.large.json", + "rawUrl": "https://raw.githubusercontent.com/PDP-Connect/data-connectors/main/connectors/google/fixtures/youtube.likes.large.json", "schemaPath": "connectors/google/schemas/youtube.likes.json", "sizeBytes": 11318, "sha256": "sha256:2d02bcef10dbb34b7a84e6a875b5b555fc0fe4fbb7712ce5067af6d9e9fb3f35" diff --git a/package.json b/package.json index a4cf082..8e031d0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,7 @@ { "name": "@opendatalabs/data-connectors-tools", "version": "0.1.0", + "license": "Apache-2.0", "private": true, "type": "module", "files": [ diff --git a/packages/connector-installer-core/index.mjs b/packages/connector-installer-core/index.mjs index 6506341..3298568 100644 --- a/packages/connector-installer-core/index.mjs +++ b/packages/connector-installer-core/index.mjs @@ -15,7 +15,7 @@ import { dirname, join, normalize, resolve as resolvePath } from "node:path"; import { verify as verifySigstoreBundle } from "sigstore"; export const DEFAULT_CONNECTOR_INDEX_URL = - "https://github.com/vana-com/data-connectors/releases/download/connectors-latest/connector-index.json"; + "https://github.com/PDP-Connect/data-connectors/releases/download/connectors-latest/connector-index.json"; export const DEFAULT_SIGSTORE_CERTIFICATE_ISSUER = "https://token.actions.githubusercontent.com"; export const DEFAULT_SIGSTORE_CERTIFICATE_IDENTITY = @@ -738,7 +738,7 @@ export async function generateLock({ sourceRepo: source.doc.sourceRepo ?? dependencies.source_repo ?? - "https://github.com/vana-com/data-connectors", + "https://github.com/PDP-Connect/data-connectors", sourceTag: sourceMeta.sourceTag, sourceCommit: sourceMeta.sourceCommit, index: { diff --git a/registry.json b/registry.json index 33d289e..480efc9 100644 --- a/registry.json +++ b/registry.json @@ -1,12 +1,12 @@ { "version": "2.0.0", "lastUpdated": "2026-07-13T00:00:00Z", - "baseUrl": "https://raw.githubusercontent.com/vana-com/data-connectors/main/connectors", + "baseUrl": "https://raw.githubusercontent.com/PDP-Connect/data-connectors/main/connectors", "connectors": [ { "id": "chatgpt-playwright", "company": "openai", - "version": "3.0.0", + "version": "3.0.1", "name": "ChatGPT", "status": "stable", "description": "Exports your email, memories, and all conversations from ChatGPT.", @@ -25,8 +25,8 @@ "metadata": "openai/chatgpt-playwright.json" }, "checksums": { - "script": "sha256:f0bebc7055a0b49c87cece29bdcd078e447d88a575e643d02f30f5b6daa2e95b", - "metadata": "sha256:b4ced371d0a87c5d3d202ff0835b361caa0124732da179fb913688a6b29fc914" + "script": "sha256:f2f33ef300e8b89ecbc13fcfa80ee88b0ec0d7849f2018a7a16623b5e1146527", + "metadata": "sha256:6b3df1f5ce03e588c23f1e26e35494526166baee99e69c384b72b31f7b306371" } }, { @@ -225,7 +225,7 @@ { "id": "wholefoods-playwright", "company": "wholefoods", - "version": "1.0.0", + "version": "1.0.1", "name": "Whole Foods Market", "status": "experimental", "description": "Exports your Whole Foods Market order history (via Amazon) and product nutrition data.", @@ -245,7 +245,7 @@ }, "checksums": { "script": "sha256:2ebb4426258b87a8e3699b9609c36ff69b03f050e747ed530c54ee1e7f84c59b", - "metadata": "sha256:bbeb1748b6964a1b564788744eed1ddbbf5ea9c7d0a423639dfa5313c653fbe9" + "metadata": "sha256:b374a547bacfe49c0fcce7cea41db0630e8335c9f655e644a43a11b2eae9e0e1" } }, { @@ -338,7 +338,7 @@ { "id": "claude-export-playwright", "company": "Anthropic", - "version": "2.0.0", + "version": "2.0.1", "name": "Claude", "status": "experimental", "description": "Exports your Claude conversations and projects via Anthropic's official data export (one complete archive, no per-conversation rate limiting).", @@ -347,8 +347,8 @@ "metadata": "anthropic/claude-export-playwright.json" }, "checksums": { - "script": "sha256:1b8b4caf80b5e976822fa201b8232edd0d562858ce71f976dd1ebe449da909c1", - "metadata": "sha256:083af470ca0a590fff2b4929716c10332db230ab8ed7d83aa974cf5f183e2f53" + "script": "sha256:12a068a5186aececdd13e999b84f14a281d8e3d256c1eec70b812afd17142152", + "metadata": "sha256:7213ef42d1bcbde91db4972ae22a8f0b445f2ff47d85c95548b34ce20054c315" } }, { diff --git a/run-connector.cjs b/run-connector.cjs index f44adcf..9501f34 120000 --- a/run-connector.cjs +++ b/run-connector.cjs @@ -1 +1 @@ -skills/vana-connect/scripts/run-connector.cjs \ No newline at end of file +skills/pdp-connect/scripts/run-connector.cjs \ No newline at end of file diff --git a/schemas/connector-index.schema.json b/schemas/connector-index.schema.json index 75fc630..a802b2b 100644 --- a/schemas/connector-index.schema.json +++ b/schemas/connector-index.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/vana-com/data-connectors/schemas/connector-index.schema.json", + "$id": "https://github.com/PDP-Connect/data-connectors/schemas/connector-index.schema.json", "title": "Connector Index v2", "type": "object", "required": [ diff --git a/schemas/fixture-index.schema.json b/schemas/fixture-index.schema.json index f6fc2c3..c750a76 100644 --- a/schemas/fixture-index.schema.json +++ b/schemas/fixture-index.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/vana-com/data-connectors/schemas/fixture-index.schema.json", + "$id": "https://github.com/PDP-Connect/data-connectors/schemas/fixture-index.schema.json", "title": "Connector Fixture Index v1", "type": "object", "required": [ diff --git a/schemas/manifest.schema.json b/schemas/manifest.schema.json index c89bf24..617fd42 100644 --- a/schemas/manifest.schema.json +++ b/schemas/manifest.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://raw.githubusercontent.com/vana-com/data-connectors/main/schemas/manifest.schema.json", + "$id": "https://raw.githubusercontent.com/PDP-Connect/data-connectors/main/schemas/manifest.schema.json", "title": "Connector Manifest", "description": "Canonical manifest contract for data-connectors. Every *-playwright.json file must validate against this schema.", "type": "object", diff --git a/schemas/scope-catalog.schema.json b/schemas/scope-catalog.schema.json index fbb282b..5a5c536 100644 --- a/schemas/scope-catalog.schema.json +++ b/schemas/scope-catalog.schema.json @@ -1,7 +1,7 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "urn:vana:data-connectors:scope-catalog:1.0.0", - "title": "Vana public source and scope catalog", + "$id": "urn:pdp-connect:data-connectors:scope-catalog:1.0.0", + "title": "PDP-Connect public source and scope catalog", "type": "object", "required": ["catalogSchema", "distribution", "catalogVersion", "generatedBy", "generatedFrom", "scopes"], "properties": { @@ -19,7 +19,7 @@ "type": "object", "required": ["repository"], "properties": { - "repository": { "const": "https://github.com/vana-com/data-connectors" }, + "repository": { "const": "https://github.com/PDP-Connect/data-connectors" }, "sourceCommit": { "type": "string", "pattern": "^[0-9a-f]{40}$" }, "releaseTag": { "type": "string", "pattern": "^connectors-[0-9a-f]{12}$" } }, @@ -32,7 +32,7 @@ "generatedBy": { "const": "scripts/generate-scope-catalog.mjs" }, "generatedFrom": { "type": "object", - "required": ["publishability", "manifests", "webCapabilities"], + "required": ["publishability", "manifests"], "properties": { "publishability": { "type": "object", @@ -47,8 +47,7 @@ "type": "array", "items": { "type": "string", "pattern": "^connectors/.+-playwright\\.json$" }, "uniqueItems": true - }, - "webCapabilities": { "const": "scopes/web-capabilities.json" } + } }, "additionalProperties": false }, @@ -83,20 +82,6 @@ }, "additionalProperties": false }, - "blocker": { - "type": "object", - "required": ["id", "description", "requiredCapture"], - "properties": { - "id": { "type": "string", "minLength": 1 }, - "description": { "type": "string", "minLength": 1 }, - "requiredCapture": { - "type": "array", - "minItems": 1, - "items": { "type": "string", "minLength": 1 } - } - }, - "additionalProperties": false - }, "scope": { "type": "object", "required": ["sourceId", "scopeId", "description", "schema", "maturity", "fulfillment"], @@ -109,14 +94,14 @@ "required": ["path"], "properties": { "path": { "type": "string", "pattern": "^connectors/.+/schemas/.+\\.json$" }, - "url": { "type": "string", "pattern": "^https://raw\\.githubusercontent\\.com/vana-com/data-connectors/[0-9a-f]{40}/connectors/.+/schemas/.+\\.json$" } + "url": { "type": "string", "pattern": "^https://raw\\.githubusercontent\\.com/PDP-Connect/data-connectors/[0-9a-f]{40}/connectors/.+/schemas/.+\\.json$" } }, "additionalProperties": false }, "maturity": { "type": "string", "enum": ["stable", "beta", "experimental"] }, "fulfillment": { "type": "object", - "required": ["desktop", "web"], + "required": ["desktop"], "properties": { "desktop": { "type": "object", @@ -131,39 +116,6 @@ } }, "additionalProperties": false - }, - "web": { - "type": "object", - "required": ["status"], - "properties": { - "status": { "type": "string", "enum": ["supported", "unsupported", "blocked"] }, - "limits": { - "type": "array", - "minItems": 1, - "items": { "$ref": "#/$defs/limit" } - }, - "blocker": { "$ref": "#/$defs/blocker" } - }, - "allOf": [ - { - "if": { "properties": { "status": { "const": "blocked" } }, "required": ["status"] }, - "then": { - "properties": { "blocker": { "$ref": "#/$defs/blocker" } }, - "required": ["blocker"] - }, - "else": { "not": { "required": ["blocker"] } } - }, - { - "if": { "properties": { "status": { "const": "supported" } }, "required": ["status"] }, - "else": { - "not": { - "properties": { "limits": { "type": "array" } }, - "required": ["limits"] - } - } - } - ], - "additionalProperties": false } }, "additionalProperties": false diff --git a/schemas/web-scope-capabilities.schema.json b/schemas/web-scope-capabilities.schema.json deleted file mode 100644 index f032bce..0000000 --- a/schemas/web-scope-capabilities.schema.json +++ /dev/null @@ -1,92 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "urn:vana:data-connectors:web-scope-capabilities:1.0.0", - "title": "Web scope capabilities input", - "type": "object", - "required": ["$schema", "schemaVersion", "contract", "provenance", "blockers", "scopes"], - "properties": { - "$schema": { "const": "../schemas/web-scope-capabilities.schema.json" }, - "schemaVersion": { "const": "1.0.0" }, - "contract": { "type": "string", "minLength": 1 }, - "provenance": { - "type": "object", - "required": ["issue", "baselineCommit", "note"], - "properties": { - "issue": { "type": "string", "minLength": 1 }, - "baselineCommit": { "type": "string", "pattern": "^[0-9a-f]{40}$" }, - "note": { "type": "string", "minLength": 1 } - }, - "additionalProperties": false - }, - "blockers": { - "type": "array", - "items": { "$ref": "#/$defs/blocker" } - }, - "scopes": { - "type": "array", - "items": { "$ref": "#/$defs/webScope" } - } - }, - "$defs": { - "limit": { - "type": "object", - "required": ["type", "value", "unit", "description"], - "properties": { - "type": { "type": "string", "enum": ["maxItems", "timeWindow"] }, - "value": { "type": "integer", "minimum": 1 }, - "unit": { "type": "string", "minLength": 1 }, - "description": { "type": "string", "minLength": 1 } - }, - "additionalProperties": false - }, - "blocker": { - "type": "object", - "required": ["id", "description", "requiredCapture"], - "properties": { - "id": { "type": "string", "pattern": "^[a-z0-9-]+$" }, - "description": { "type": "string", "minLength": 1 }, - "requiredCapture": { - "type": "array", - "minItems": 1, - "items": { "type": "string", "minLength": 1 } - } - }, - "additionalProperties": false - }, - "webScope": { - "type": "object", - "required": ["scopeId", "status"], - "properties": { - "scopeId": { "type": "string", "pattern": "^[a-z0-9_-]+\\.[A-Za-z0-9_.-]+$" }, - "status": { "type": "string", "enum": ["supported", "unsupported", "blocked"] }, - "limits": { - "type": "array", - "minItems": 1, - "items": { "$ref": "#/$defs/limit" } - }, - "blockerId": { "type": "string", "pattern": "^[a-z0-9-]+$" } - }, - "allOf": [ - { - "if": { "properties": { "status": { "const": "blocked" } }, "required": ["status"] }, - "then": { - "properties": { "blockerId": { "type": "string", "pattern": "^[a-z0-9-]+$" } }, - "required": ["blockerId"] - }, - "else": { "not": { "required": ["blockerId"] } } - }, - { - "if": { "properties": { "status": { "const": "supported" } }, "required": ["status"] }, - "else": { - "not": { - "properties": { "limits": { "type": "array" } }, - "required": ["limits"] - } - } - } - ], - "additionalProperties": false - } - }, - "additionalProperties": false -} diff --git a/scope-catalog.json b/scope-catalog.json index 49b5657..96e31c2 100644 --- a/scope-catalog.json +++ b/scope-catalog.json @@ -4,7 +4,7 @@ "releaseAsset": "scope-catalog.schema.json" }, "distribution": { - "repository": "https://github.com/vana-com/data-connectors" + "repository": "https://github.com/PDP-Connect/data-connectors" }, "catalogVersion": "1.0.0", "generatedBy": "scripts/generate-scope-catalog.mjs", @@ -31,8 +31,7 @@ "connectors/uber/uber-playwright.json", "connectors/valve/steam-playwright.json", "connectors/wholefoods/wholefoods-playwright.json" - ], - "webCapabilities": "scopes/web-capabilities.json" + ] }, "scopes": [ { @@ -52,9 +51,6 @@ "status": "beta" } ] - }, - "web": { - "status": "unsupported" } } }, @@ -75,9 +71,6 @@ "status": "beta" } ] - }, - "web": { - "status": "unsupported" } } }, @@ -98,9 +91,6 @@ "status": "stable" } ] - }, - "web": { - "status": "unsupported" } } }, @@ -121,9 +111,6 @@ "status": "stable" } ] - }, - "web": { - "status": "unsupported" } } }, @@ -144,9 +131,6 @@ "status": "experimental" } ] - }, - "web": { - "status": "unsupported" } } }, @@ -167,9 +151,6 @@ "status": "experimental" } ] - }, - "web": { - "status": "unsupported" } } }, @@ -190,9 +171,6 @@ "status": "beta" } ] - }, - "web": { - "status": "unsupported" } } }, @@ -221,9 +199,6 @@ ] } ] - }, - "web": { - "status": "unsupported" } } }, @@ -258,9 +233,6 @@ ] } ] - }, - "web": { - "status": "unsupported" } } }, @@ -289,9 +261,6 @@ ] } ] - }, - "web": { - "status": "unsupported" } } }, @@ -312,9 +281,6 @@ "status": "stable" } ] - }, - "web": { - "status": "supported" } } }, @@ -335,9 +301,6 @@ "status": "stable" } ] - }, - "web": { - "status": "unsupported" } } }, @@ -358,9 +321,6 @@ "status": "stable" } ] - }, - "web": { - "status": "unsupported" } } }, @@ -381,9 +341,6 @@ "status": "experimental" } ] - }, - "web": { - "status": "unsupported" } } }, @@ -404,9 +361,6 @@ "status": "experimental" } ] - }, - "web": { - "status": "unsupported" } } }, @@ -427,9 +381,6 @@ "status": "experimental" } ] - }, - "web": { - "status": "unsupported" } } }, @@ -450,9 +401,6 @@ "status": "experimental" } ] - }, - "web": { - "status": "unsupported" } } }, @@ -473,9 +421,6 @@ "status": "experimental" } ] - }, - "web": { - "status": "unsupported" } } }, @@ -500,9 +445,6 @@ "status": "beta" } ] - }, - "web": { - "status": "unsupported" } } }, @@ -523,9 +465,6 @@ "status": "stable" } ] - }, - "web": { - "status": "unsupported" } } }, @@ -546,17 +485,6 @@ "status": "stable" } ] - }, - "web": { - "status": "supported", - "limits": [ - { - "type": "maxItems", - "value": 150, - "unit": "posts", - "description": "The Web path returns at most the 150 most recent posts." - } - ] } } }, @@ -577,9 +505,6 @@ "status": "stable" } ] - }, - "web": { - "status": "supported" } } }, @@ -600,17 +525,6 @@ "status": "stable" } ] - }, - "web": { - "status": "blocked", - "blocker": { - "id": "linkedin-live-output", - "description": "LinkedIn Web scope capability is not classified without live end-to-end evidence.", - "requiredCapture": [ - "A captured successful LinkedIn Data Pipe result showing the exact returned scope-key set.", - "The resulting Personal Server scope inventory for the same run and user after sync." - ] - } } } }, @@ -631,17 +545,6 @@ "status": "stable" } ] - }, - "web": { - "status": "blocked", - "blocker": { - "id": "linkedin-live-output", - "description": "LinkedIn Web scope capability is not classified without live end-to-end evidence.", - "requiredCapture": [ - "A captured successful LinkedIn Data Pipe result showing the exact returned scope-key set.", - "The resulting Personal Server scope inventory for the same run and user after sync." - ] - } } } }, @@ -662,17 +565,6 @@ "status": "stable" } ] - }, - "web": { - "status": "blocked", - "blocker": { - "id": "linkedin-live-output", - "description": "LinkedIn Web scope capability is not classified without live end-to-end evidence.", - "requiredCapture": [ - "A captured successful LinkedIn Data Pipe result showing the exact returned scope-key set.", - "The resulting Personal Server scope inventory for the same run and user after sync." - ] - } } } }, @@ -693,17 +585,6 @@ "status": "stable" } ] - }, - "web": { - "status": "blocked", - "blocker": { - "id": "linkedin-live-output", - "description": "LinkedIn Web scope capability is not classified without live end-to-end evidence.", - "requiredCapture": [ - "A captured successful LinkedIn Data Pipe result showing the exact returned scope-key set.", - "The resulting Personal Server scope inventory for the same run and user after sync." - ] - } } } }, @@ -724,17 +605,6 @@ "status": "stable" } ] - }, - "web": { - "status": "blocked", - "blocker": { - "id": "linkedin-live-output", - "description": "LinkedIn Web scope capability is not classified without live end-to-end evidence.", - "requiredCapture": [ - "A captured successful LinkedIn Data Pipe result showing the exact returned scope-key set.", - "The resulting Personal Server scope inventory for the same run and user after sync." - ] - } } } }, @@ -755,17 +625,6 @@ "status": "stable" } ] - }, - "web": { - "status": "blocked", - "blocker": { - "id": "linkedin-live-output", - "description": "LinkedIn Web scope capability is not classified without live end-to-end evidence.", - "requiredCapture": [ - "A captured successful LinkedIn Data Pipe result showing the exact returned scope-key set.", - "The resulting Personal Server scope inventory for the same run and user after sync." - ] - } } } }, @@ -786,9 +645,6 @@ "status": "stable" } ] - }, - "web": { - "status": "unsupported" } } }, @@ -809,9 +665,6 @@ "status": "stable" } ] - }, - "web": { - "status": "unsupported" } } }, @@ -832,9 +685,6 @@ "status": "stable" } ] - }, - "web": { - "status": "unsupported" } } }, @@ -855,9 +705,6 @@ "status": "beta" } ] - }, - "web": { - "status": "unsupported" } } }, @@ -878,9 +725,6 @@ "status": "stable" } ] - }, - "web": { - "status": "unsupported" } } }, @@ -901,9 +745,6 @@ "status": "stable" } ] - }, - "web": { - "status": "supported" } } }, @@ -924,9 +765,6 @@ "status": "stable" } ] - }, - "web": { - "status": "unsupported" } } }, @@ -947,9 +785,6 @@ "status": "experimental" } ] - }, - "web": { - "status": "unsupported" } } }, @@ -970,9 +805,6 @@ "status": "experimental" } ] - }, - "web": { - "status": "unsupported" } } }, @@ -993,9 +825,6 @@ "status": "experimental" } ] - }, - "web": { - "status": "unsupported" } } }, @@ -1016,9 +845,6 @@ "status": "beta" } ] - }, - "web": { - "status": "unsupported" } } }, @@ -1039,9 +865,6 @@ "status": "beta" } ] - }, - "web": { - "status": "unsupported" } } }, @@ -1062,9 +885,6 @@ "status": "experimental" } ] - }, - "web": { - "status": "unsupported" } } }, @@ -1085,9 +905,6 @@ "status": "experimental" } ] - }, - "web": { - "status": "unsupported" } } }, @@ -1108,9 +925,6 @@ "status": "experimental" } ] - }, - "web": { - "status": "unsupported" } } }, @@ -1139,9 +953,6 @@ ] } ] - }, - "web": { - "status": "unsupported" } } }, @@ -1162,9 +973,6 @@ "status": "beta" } ] - }, - "web": { - "status": "unsupported" } } }, @@ -1185,9 +993,6 @@ "status": "beta" } ] - }, - "web": { - "status": "unsupported" } } }, @@ -1208,9 +1013,6 @@ "status": "beta" } ] - }, - "web": { - "status": "unsupported" } } }, @@ -1231,9 +1033,6 @@ "status": "beta" } ] - }, - "web": { - "status": "supported" } } }, @@ -1254,9 +1053,6 @@ "status": "beta" } ] - }, - "web": { - "status": "unsupported" } } }, @@ -1277,9 +1073,6 @@ "status": "beta" } ] - }, - "web": { - "status": "unsupported" } } } diff --git a/scopes/web-capabilities.json b/scopes/web-capabilities.json deleted file mode 100644 index 6d3e826..0000000 --- a/scopes/web-capabilities.json +++ /dev/null @@ -1,83 +0,0 @@ -{ - "$schema": "../schemas/web-scope-capabilities.schema.json", - "schemaVersion": "1.0.0", - "contract": "ODL Data Pipe API", - "provenance": { - "issue": "BUI-705", - "baselineCommit": "d0a537a7d2fc7606f8a8492a22d9c2cd6d9dc6e8", - "note": "Non-LinkedIn values preserve the Web capability assertions in SCOPES.md at the baseline commit. LinkedIn is blocked pending live Data Pipe and Personal Server evidence." - }, - "blockers": [ - { - "id": "linkedin-live-output", - "description": "LinkedIn Web scope capability is not classified without live end-to-end evidence.", - "requiredCapture": [ - "A captured successful LinkedIn Data Pipe result showing the exact returned scope-key set.", - "The resulting Personal Server scope inventory for the same run and user after sync." - ] - } - ], - "scopes": [ - { "scopeId": "amazon.orders", "status": "unsupported" }, - { "scopeId": "amazon.profile", "status": "unsupported" }, - { "scopeId": "chatgpt.conversations", "status": "unsupported" }, - { "scopeId": "chatgpt.memories", "status": "unsupported" }, - { "scopeId": "claude.conversations", "status": "unsupported" }, - { "scopeId": "claude.projects", "status": "unsupported" }, - { "scopeId": "doordash.orders", "status": "unsupported" }, - { "scopeId": "github.contributions", "status": "unsupported" }, - { "scopeId": "github.events", "status": "unsupported" }, - { "scopeId": "github.history", "status": "unsupported" }, - { "scopeId": "github.profile", "status": "supported" }, - { "scopeId": "github.repositories", "status": "unsupported" }, - { "scopeId": "github.starred", "status": "unsupported" }, - { "scopeId": "heb.nutrition", "status": "unsupported" }, - { "scopeId": "heb.orders", "status": "unsupported" }, - { "scopeId": "heb.profile", "status": "unsupported" }, - { "scopeId": "icloud_notes.folders", "status": "unsupported" }, - { "scopeId": "icloud_notes.notes", "status": "unsupported" }, - { "scopeId": "instagram.ads", "status": "unsupported" }, - { "scopeId": "instagram.following", "status": "unsupported" }, - { - "scopeId": "instagram.posts", - "status": "supported", - "limits": [ - { - "type": "maxItems", - "value": 150, - "unit": "posts", - "description": "The Web path returns at most the 150 most recent posts." - } - ] - }, - { "scopeId": "instagram.profile", "status": "supported" }, - { "scopeId": "linkedin.connections", "status": "blocked", "blockerId": "linkedin-live-output" }, - { "scopeId": "linkedin.education", "status": "blocked", "blockerId": "linkedin-live-output" }, - { "scopeId": "linkedin.experience", "status": "blocked", "blockerId": "linkedin-live-output" }, - { "scopeId": "linkedin.languages", "status": "blocked", "blockerId": "linkedin-live-output" }, - { "scopeId": "linkedin.profile", "status": "blocked", "blockerId": "linkedin-live-output" }, - { "scopeId": "linkedin.skills", "status": "blocked", "blockerId": "linkedin-live-output" }, - { "scopeId": "oura.activity", "status": "unsupported" }, - { "scopeId": "oura.readiness", "status": "unsupported" }, - { "scopeId": "oura.sleep", "status": "unsupported" }, - { "scopeId": "shop.orders", "status": "unsupported" }, - { "scopeId": "spotify.playlists", "status": "unsupported" }, - { "scopeId": "spotify.profile", "status": "supported" }, - { "scopeId": "spotify.savedTracks", "status": "unsupported" }, - { "scopeId": "steam.friends", "status": "unsupported" }, - { "scopeId": "steam.games", "status": "unsupported" }, - { "scopeId": "steam.profile", "status": "unsupported" }, - { "scopeId": "uber.receipts", "status": "unsupported" }, - { "scopeId": "uber.trips", "status": "unsupported" }, - { "scopeId": "wholefoods.nutrition", "status": "unsupported" }, - { "scopeId": "wholefoods.orders", "status": "unsupported" }, - { "scopeId": "wholefoods.profile", "status": "unsupported" }, - { "scopeId": "youtube.history", "status": "unsupported" }, - { "scopeId": "youtube.likes", "status": "unsupported" }, - { "scopeId": "youtube.playlistItems", "status": "unsupported" }, - { "scopeId": "youtube.playlists", "status": "unsupported" }, - { "scopeId": "youtube.profile", "status": "supported" }, - { "scopeId": "youtube.subscriptions", "status": "unsupported" }, - { "scopeId": "youtube.watchLater", "status": "unsupported" } - ] -} diff --git a/scripts/.claude/skills/auto-create-connector/SKILL.md b/scripts/.claude/skills/auto-create-connector/SKILL.md index 48e810a..057a98f 100644 --- a/scripts/.claude/skills/auto-create-connector/SKILL.md +++ b/scripts/.claude/skills/auto-create-connector/SKILL.md @@ -49,7 +49,7 @@ node capture-session.cjs ``` This opens a headed browser. The user logs in manually (handles CAPTCHAs, 2FA, OAuth). -The session persists in `~/.vana/desktop/browser-profiles/-playwright/` and is +The session persists in `~/.pdp-connect/desktop/browser-profiles/-playwright/` and is automatically reused by the connector during testing. ## Workflow @@ -117,7 +117,7 @@ If web search doesn't reveal clear APIs, use Chrome browser automation tools to: 2. Check if the browser profile exists: ```bash - ls ~/.vana/desktop/browser-profiles/-playwright/ 2>/dev/null && echo "Profile exists" || echo "No profile" + ls ~/.pdp-connect/desktop/browser-profiles/-playwright/ 2>/dev/null && echo "Profile exists" || echo "No profile" ``` 3. If no session/profile, tell the user and run capture: @@ -266,7 +266,7 @@ Re-run the validator after each fix until the report shows `"valid": true`. **Check if session or credentials are available:** ```bash # Check for captured session (from Step 1.5) -ls ~/.vana/desktop/browser-profiles/-playwright/ 2>/dev/null && echo "Session profile exists" || echo "No session profile" +ls ~/.pdp-connect/desktop/browser-profiles/-playwright/ 2>/dev/null && echo "Session profile exists" || echo "No session profile" # Check for .env credentials grep -q "USER_LOGIN_" .env 2>/dev/null && echo "Credentials found" || echo "No credentials" diff --git a/scripts/CLAUDE.md b/scripts/CLAUDE.md index 645118b..5982a94 100644 --- a/scripts/CLAUDE.md +++ b/scripts/CLAUDE.md @@ -67,7 +67,7 @@ For platforms where automated login fails (CAPTCHAs, Cloudflare, 2FA, OAuth): 1. Run `node capture-session.cjs ` — opens a headed browser 2. Log in manually in the browser window (handles any auth complexity) 3. Login is detected automatically via URL change + cookie increase -4. Session persists in `~/.vana/desktop/browser-profiles/-playwright/` +4. Session persists in `~/.pdp-connect/desktop/browser-profiles/-playwright/` 5. Subsequent connector tests automatically reuse the session (shared profile) The browser profile is shared by filename: both `sessions/-playwright.js` (capture) diff --git a/scripts/capture-session.cjs b/scripts/capture-session.cjs index df04686..948bb37 100755 --- a/scripts/capture-session.cjs +++ b/scripts/capture-session.cjs @@ -126,7 +126,7 @@ ${c.cyan}How it works:${c.reset} 4. Session is persisted in browser profile for connector reuse ${c.cyan}Profile sharing:${c.reset} - The browser profile is stored at ~/.vana/desktop/browser-profiles/-playwright/ + The browser profile is stored at ~/.pdp-connect/desktop/browser-profiles/-playwright/ Any connector named -playwright.js will automatically reuse this session. `); } @@ -266,7 +266,7 @@ function generateLoginConnector(platform, loginUrl, timeout, checkUrl) { lines.push(' sessionStorageKeys: Object.keys(sessionStorageData || {}).length'); lines.push(' },'); lines.push(' note: \'Full session (including HttpOnly cookies) is persisted in the browser profile. This file contains browser-accessible data only.\','); - lines.push(' profileDir: \'~/.vana/desktop/browser-profiles/' + platform + '-playwright/\''); + lines.push(' profileDir: \'~/.pdp-connect/desktop/browser-profiles/' + platform + '-playwright/\''); lines.push(' };'); lines.push(''); lines.push(' await page.setData(\'result\', result);'); @@ -318,7 +318,7 @@ async function main() { console.log(` ${c.cyan}Platform:${c.reset} ${args.platform}`); console.log(` ${c.cyan}Login URL:${c.reset} ${args.loginUrl}`); console.log(` ${c.cyan}Timeout:${c.reset} ${args.timeout}s`); - console.log(` ${c.cyan}Profile:${c.reset} ~/.vana/desktop/browser-profiles/${connectorName}/`); + console.log(` ${c.cyan}Profile:${c.reset} ~/.pdp-connect/desktop/browser-profiles/${connectorName}/`); console.log('─'.repeat(50)); console.log(''); console.log(`${c.yellow}A browser window will open. Please log in manually.${c.reset}`); @@ -411,7 +411,7 @@ async function main() { const sessionFile = path.join(sessionsDir, `${args.platform}.json`); fs.writeFileSync(sessionFile, JSON.stringify(resultRef.data, null, 2)); print(c.green, '[saved]', `Session data: ${sessionFile}`); - print(c.green, '[saved]', `Browser profile: ~/.vana/desktop/browser-profiles/${connectorName}/`); + print(c.green, '[saved]', `Browser profile: ~/.pdp-connect/desktop/browser-profiles/${connectorName}/`); const stats = resultRef.data.stats || {}; console.log(''); diff --git a/scripts/connector-artifact-contract.test.mjs b/scripts/connector-artifact-contract.test.mjs index 94e7f05..ce131e0 100644 --- a/scripts/connector-artifact-contract.test.mjs +++ b/scripts/connector-artifact-contract.test.mjs @@ -61,7 +61,7 @@ test("a fully signed index passes the publish signature gate", () => { type: "sigstoreBundle", bundlePath: "alpha-playwright-1.0.0.tgz.sigstore.json", bundleUrl: - "https://github.com/vana-com/data-connectors/releases/download/connectors-test/alpha-playwright-1.0.0.tgz.sigstore.json", + "https://github.com/PDP-Connect/data-connectors/releases/download/connectors-test/alpha-playwright-1.0.0.tgz.sigstore.json", }, }, { diff --git a/scripts/create-connector.sh b/scripts/create-connector.sh index 7ea0686..1e03a84 100755 --- a/scripts/create-connector.sh +++ b/scripts/create-connector.sh @@ -115,7 +115,7 @@ if [ -n "${!LOGIN_VAR}" ] && [ -n "${!PASSWORD_VAR}" ]; then HAS_CREDENTIALS=true fi -PROFILE_DIR="$HOME/.vana/desktop/browser-profiles/${PLATFORM}-playwright" +PROFILE_DIR="$HOME/.pdp-connect/desktop/browser-profiles/${PLATFORM}-playwright" if [ -d "$PROFILE_DIR" ]; then HAS_SESSION=true fi diff --git a/scripts/generate-connector-index.mjs b/scripts/generate-connector-index.mjs index 015e069..43e852e 100644 --- a/scripts/generate-connector-index.mjs +++ b/scripts/generate-connector-index.mjs @@ -65,7 +65,7 @@ function resolveReleaseMetadata(sourceCommit) { `connectors-${sourceCommit.slice(0, 12)}`; const releaseId = process.env.CONNECTOR_RELEASE_ID?.trim() || releaseTag; const repo = - process.env.GITHUB_REPOSITORY?.trim() || "vana-com/data-connectors"; + process.env.GITHUB_REPOSITORY?.trim() || "PDP-Connect/data-connectors"; return { releaseTag, @@ -455,7 +455,7 @@ async function main() { const releaseMetadata = resolveReleaseMetadata(sourceCommit); const nextIndex = { indexVersion: "2.0", - sourceRepo: "https://github.com/vana-com/data-connectors", + sourceRepo: "https://github.com/PDP-Connect/data-connectors", generatedAt: registry.lastUpdated ?? new Date().toISOString(), signature: buildSigstoreBundleMetadata( "connector-index.json.sigstore.json", diff --git a/scripts/generate-fixture-index.mjs b/scripts/generate-fixture-index.mjs index 1f89c15..e5b495d 100644 --- a/scripts/generate-fixture-index.mjs +++ b/scripts/generate-fixture-index.mjs @@ -18,7 +18,7 @@ const indexPath = join(repoRoot, "fixture-index.json"); const registryPath = join(repoRoot, "registry.json"); const rawBaseUrl = ( process.env.FIXTURE_RAW_BASE_URL || - "https://raw.githubusercontent.com/vana-com/data-connectors/main" + "https://raw.githubusercontent.com/PDP-Connect/data-connectors/main" ).replace(/\/$/, ""); const CONNECTOR_ID_PATTERN = /^[A-Za-z0-9_-]+$/; const SCOPE_PATTERN = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/; @@ -491,7 +491,7 @@ function buildFixtureIndex() { return { $schema: "./schemas/fixture-index.schema.json", indexVersion: "1.0", - sourceRepo: "https://github.com/vana-com/data-connectors", + sourceRepo: "https://github.com/PDP-Connect/data-connectors", generatedAt: generatedAt(), fixtures, }; diff --git a/scripts/generate-scope-catalog.mjs b/scripts/generate-scope-catalog.mjs index b167dbc..ebd00d0 100644 --- a/scripts/generate-scope-catalog.mjs +++ b/scripts/generate-scope-catalog.mjs @@ -8,8 +8,8 @@ import Ajv2020 from "ajv/dist/2020.js"; const scriptDir = dirname(fileURLToPath(import.meta.url)); const defaultRepoRoot = join(scriptDir, ".."); -const repositoryUrl = "https://github.com/vana-com/data-connectors"; -const rawRepositoryUrl = "https://raw.githubusercontent.com/vana-com/data-connectors"; +const repositoryUrl = "https://github.com/PDP-Connect/data-connectors"; +const rawRepositoryUrl = "https://raw.githubusercontent.com/PDP-Connect/data-connectors"; const readmeOverviewStart = ""; const readmeOverviewEnd = ""; const maturityRank = new Map([ @@ -147,25 +147,6 @@ function loadPublishedScopes(repoRoot) { }; } -function validateExactWebSet(publishedScopes, webInput) { - const webIds = webInput.scopes.map(({ scopeId }) => scopeId); - const duplicateIds = webIds.filter((scopeId, index) => webIds.indexOf(scopeId) !== index); - if (duplicateIds.length > 0) { - throw new Error(`Duplicate Web capability entries: ${[...new Set(duplicateIds)].sort().join(", ")}`); - } - - const publishedIds = new Set(publishedScopes.keys()); - const webIdSet = new Set(webIds); - const missing = [...publishedIds].filter((scopeId) => !webIdSet.has(scopeId)).sort(); - const extra = [...webIdSet].filter((scopeId) => !publishedIds.has(scopeId)).sort(); - if (missing.length > 0) { - throw new Error(`Missing Web capability entries: ${missing.join(", ")}`); - } - if (extra.length > 0) { - throw new Error(`Extra Web capability entries: ${extra.join(", ")}`); - } -} - function buildCatalog(repoRoot, { sourceCommit, releaseTag } = {}) { if ((sourceCommit && !releaseTag) || (!sourceCommit && releaseTag)) { throw new Error("sourceCommit and releaseTag must be provided together"); @@ -176,24 +157,7 @@ function buildCatalog(repoRoot, { sourceCommit, releaseTag } = {}) { if (sourceCommit && releaseTag !== `connectors-${sourceCommit.slice(0, 12)}`) { throw new Error("releaseTag must match connectors-"); } - const webInputPath = join(repoRoot, "scopes", "web-capabilities.json"); - const webInput = readJson(webInputPath); - const webInputSchema = readJson( - join(repoRoot, "schemas", "web-scope-capabilities.schema.json"), - ); - validateAgainstSchema(webInput, webInputSchema, "web-capabilities.json"); - const { groupedScopes: publishedScopes, manifestPaths } = loadPublishedScopes(repoRoot); - validateExactWebSet(publishedScopes, webInput); - - const blockerById = new Map(); - for (const blocker of webInput.blockers) { - if (blockerById.has(blocker.id)) { - throw new Error(`Duplicate Web blocker id: ${blocker.id}`); - } - blockerById.set(blocker.id, blocker); - } - const webByScope = new Map(webInput.scopes.map((entry) => [entry.scopeId, entry])); const scopes = [...publishedScopes.entries()] .sort(([left], [right]) => left.localeCompare(right)) @@ -218,21 +182,6 @@ function buildCatalog(repoRoot, { sourceCommit, releaseTag } = {}) { ); } - const webInputEntry = webByScope.get(scopeId); - const web = { status: webInputEntry.status }; - if (webInputEntry.limits) { - web.limits = webInputEntry.limits; - } - if (webInputEntry.blockerId) { - const blocker = blockerById.get(webInputEntry.blockerId); - if (!blocker) { - throw new Error( - `Web capability ${scopeId} references unknown blocker ${webInputEntry.blockerId}`, - ); - } - web.blocker = blocker; - } - return { sourceId: primary.sourceId, scopeId, @@ -249,7 +198,6 @@ function buildCatalog(repoRoot, { sourceCommit, releaseTag } = {}) { status: "supported", connectors: candidates.map(({ connector }) => connector), }, - web, }, }; }); @@ -271,7 +219,6 @@ function buildCatalog(repoRoot, { sourceCommit, releaseTag } = {}) { manifestSelector: "connectors[].files.metadata", }, manifests: manifestPaths, - webCapabilities: "scopes/web-capabilities.json", }, scopes, }; @@ -280,20 +227,11 @@ function buildCatalog(repoRoot, { sourceCommit, releaseTag } = {}) { return catalog; } -function renderWebStatus(web) { - if (web.status === "supported") return "Yes"; - if (web.status === "blocked") return `Blocked (${web.blocker.id})`; - return "No"; -} - function renderLimits(scope) { const limits = []; - for (const limit of scope.fulfillment.web.limits ?? []) { - limits.push(`Vana Web (hosted): ${limit.description}`); - } for (const connector of scope.fulfillment.desktop.connectors) { for (const limit of connector.limits ?? []) { - limits.push(`Vana Desktop (local; ${connector.id}): ${limit.description}`); + limits.push(`Desktop (local; ${connector.id}): ${limit.description}`); } } return limits.join("; ") || "None"; @@ -351,7 +289,7 @@ function renderScopesMarkdown(catalog) { const connectors = scope.fulfillment.desktop.connectors .map(({ id, status }) => `${id} (${status})`) .join("; "); - return `| ${escapeCell(scope.sourceId)} | \`${scope.scopeId}\` | ${escapeCell(scope.description)} | [JSON Schema](${scope.schema.path}) | ${renderWebStatus(scope.fulfillment.web)} | Yes | ${escapeCell(renderLimits(scope))} | ${escapeCell(connectors)} |`; + return `| ${escapeCell(scope.sourceId)} | \`${scope.scopeId}\` | ${escapeCell(scope.description)} | [JSON Schema](${scope.schema.path}) | ${escapeCell(renderLimits(scope))} | ${escapeCell(connectors)} |`; }); return ` @@ -359,12 +297,7 @@ function renderScopesMarkdown(catalog) { The public human-readable view of [\`scope-catalog.json\`](scope-catalog.json). -This catalog shows two collection paths. When both paths support a scope, they use the same scope ID and payload schema: - -- **Vana Desktop (local):** A registered Playwright connector runs on your device and uses your browser session. Connector manifests plus \`registry.json\` own this column. -- **Vana Web (hosted):** Vana Web requests data from Open Data Labs' hosted collection service, called Data Pipe. [\`scopes/web-capabilities.json\`](scopes/web-capabilities.json) owns this column and its limits. - -A website cannot run the local Node.js/Playwright connector or read your signed-in session on another site. Vana Web could ask an installed Vana Desktop app to collect the data, but that is still Desktop collection. The table uses **Yes**, **No**, or **Blocked** for each path. **Blocked** names the evidence required before making a claim. +This catalog describes the local connector collection path: a registered Playwright connector runs on your device and uses your browser session. Connector manifests plus \`registry.json\` own this data. ## Versioned artifact contract @@ -372,20 +305,13 @@ Every immutable \`connectors-\` GitHub release includes \`scope-catalo ## Coverage -| Source | Scope | Description | Schema | Vana Web (hosted) | Vana Desktop (local) | Material limits | Desktop connector(s) / maturity | -|---|---|---|---|:--:|:--:|---|---| +| Source | Scope | Description | Schema | Material limits | Desktop connector(s) / maturity | +|---|---|---|---|---|---| ${rows.join("\n")} -## Blocked evidence - -${catalog.scopes - .filter(({ fulfillment }) => fulfillment.web.status === "blocked") - .map(({ scopeId, fulfillment }) => `- \`${scopeId}\`: ${fulfillment.web.blocker.description} Required: ${fulfillment.web.blocker.requiredCapture.join(" ")}`) - .join("\n") || "None."} - ## Maintenance -Do not edit this file. Update connector manifests/\`registry.json\` for Vana Desktop truth or \`scopes/web-capabilities.json\` for Vana Web truth, then run \`node scripts/generate-scope-catalog.mjs\`. +Do not edit this file. Update connector manifests/\`registry.json\`, then run \`node scripts/generate-scope-catalog.mjs\`. `; } diff --git a/scripts/generate-scope-catalog.test.mjs b/scripts/generate-scope-catalog.test.mjs index 61a7e57..f4d8082 100644 --- a/scripts/generate-scope-catalog.test.mjs +++ b/scripts/generate-scope-catalog.test.mjs @@ -19,10 +19,6 @@ function writeJson(path, value) { function makeFixture() { const root = mkdtempSync(join(tmpdir(), "scope-catalog-test-")); mkdirSync(join(root, "schemas"), { recursive: true }); - cpSync( - join(repoRoot, "schemas", "web-scope-capabilities.schema.json"), - join(root, "schemas", "web-scope-capabilities.schema.json"), - ); cpSync( join(repoRoot, "schemas", "scope-catalog.schema.json"), join(root, "schemas", "scope-catalog.schema.json"), @@ -68,18 +64,6 @@ function makeFixture() { source_id: "ignored", scopes: [{ scope: "ignored.profile", description: "Not published." }], }); - writeJson(join(root, "scopes", "web-capabilities.json"), { - $schema: "../schemas/web-scope-capabilities.schema.json", - schemaVersion: "1.0.0", - contract: "Test Web API", - provenance: { - issue: "TEST-1", - baselineCommit: "a".repeat(40), - note: "Test evidence.", - }, - blockers: [], - scopes: [{ scopeId: "alpha.profile", status: "unsupported" }], - }); writeFileSync( join(root, "README.md"), `# Fixture @@ -116,12 +100,9 @@ test("generated files are clean and exclude unregistered manifests", () => { manifestSelector: "connectors[].files.metadata", }, manifests: ["connectors/alpha/alpha-playwright.json"], - webCapabilities: "scopes/web-capabilities.json", }); - assert.match(markdown, /Vana Web \(hosted\)/); - assert.match(markdown, /Vana Desktop \(local\)/); - assert.match(markdown, /A website cannot run the local Node\.js\/Playwright connector/); - assert.match(markdown, /\| No \| Yes \|/); + assert.match(markdown, /local connector collection path/); + assert.doesNotMatch(markdown, /Vana/); assert.doesNotMatch(markdown, /✅|—/); assert.match( readme, @@ -137,65 +118,6 @@ test("generated files are clean and exclude unregistered manifests", () => { ); }); -test("missing Web capability entries fail exact-set validation", () => { - const root = makeFixture(); - const inputPath = join(root, "scopes", "web-capabilities.json"); - const input = JSON.parse(readFileSync(inputPath)); - input.scopes = []; - writeJson(inputPath, input); - - assert.throws( - () => generateScopeCatalog({ repoRoot: root }), - /Missing Web capability entries: alpha\.profile/, - ); -}); - -test("extra Web capability entries fail exact-set validation", () => { - const root = makeFixture(); - const inputPath = join(root, "scopes", "web-capabilities.json"); - const input = JSON.parse(readFileSync(inputPath)); - input.scopes.push({ scopeId: "extra.profile", status: "unsupported" }); - writeJson(inputPath, input); - - assert.throws( - () => generateScopeCatalog({ repoRoot: root }), - /Extra Web capability entries: extra\.profile/, - ); -}); - -test("malformed Web capability entries fail schema validation", () => { - const root = makeFixture(); - const inputPath = join(root, "scopes", "web-capabilities.json"); - const input = JSON.parse(readFileSync(inputPath)); - input.scopes[0].status = "maybe"; - writeJson(inputPath, input); - - assert.throws( - () => generateScopeCatalog({ repoRoot: root }), - /web-capabilities\.json failed schema validation/, - ); -}); - -test("unsupported Web capability entries cannot declare limits", () => { - const root = makeFixture(); - const inputPath = join(root, "scopes", "web-capabilities.json"); - const input = JSON.parse(readFileSync(inputPath)); - input.scopes[0].limits = [ - { - type: "maxItems", - value: 1, - unit: "item", - description: "Contradictory unsupported limit.", - }, - ]; - writeJson(inputPath, input); - - assert.throws( - () => generateScopeCatalog({ repoRoot: root }), - /web-capabilities\.json failed schema validation/, - ); -}); - test("conflicting registered schema evidence fails", () => { const root = makeFixture(); const registryPath = join(root, "registry.json"); @@ -275,13 +197,13 @@ test("release generation embeds immutable payload-schema URLs", () => { const catalog = JSON.parse(readFileSync(join(root, "scope-catalog.json"))); assert.deepEqual(catalog.distribution, { - repository: "https://github.com/vana-com/data-connectors", + repository: "https://github.com/PDP-Connect/data-connectors", sourceCommit, releaseTag, }); assert.equal( catalog.scopes[0].schema.url, - `https://raw.githubusercontent.com/vana-com/data-connectors/${sourceCommit}/connectors/alpha/schemas/alpha.profile.json`, + `https://raw.githubusercontent.com/PDP-Connect/data-connectors/${sourceCommit}/connectors/alpha/schemas/alpha.profile.json`, ); }); diff --git a/skills/vana-connect/CREATE.md b/skills/pdp-connect/CREATE.md similarity index 94% rename from skills/vana-connect/CREATE.md rename to skills/pdp-connect/CREATE.md index f1ceb74..2588323 100644 --- a/skills/vana-connect/CREATE.md +++ b/skills/pdp-connect/CREATE.md @@ -7,7 +7,7 @@ Build a data connector for a platform that isn't in the registry yet. - `reference/PAGE-API.md` -- full `page` object API - `reference/PATTERNS.md` -- data extraction approaches and code examples -All `node scripts/...` commands refer to `skills/vana-connect/scripts/` in the data-connectors repo. `run-connector.cjs` is at `~/.vana/desktop/run-connector.cjs` (installed by SETUP.md). +All `node scripts/...` commands refer to `skills/pdp-connect/scripts/` in the data-connectors repo. `run-connector.cjs` is at `~/.pdp-connect/desktop/run-connector.cjs` (installed by SETUP.md). ## Connector Format @@ -31,7 +31,7 @@ Scripts are plain JavaScript (CJS), no imports, no require. The runner injects a | GitHub | DOM extraction | 3 | Server-rendered, no client API | | Spotify | In-page fetch | 1 | Well-documented public API | -Look at existing connectors in `~/.vana/desktop/connectors/` for working examples. +Look at existing connectors in `~/.pdp-connect/desktop/connectors/` for working examples. --- @@ -167,8 +167,8 @@ Run the connector and validate in one step: ```bash node scripts/validate.cjs connectors//-playwright.js && \ - node ~/.vana/desktop/run-connector.cjs connectors//-playwright.js [start-url] && \ - node scripts/validate.cjs connectors//-playwright.js --check-result ~/.vana/desktop/last-result.json + node ~/.pdp-connect/desktop/run-connector.cjs connectors//-playwright.js [start-url] && \ + node scripts/validate.cjs connectors//-playwright.js --check-result ~/.pdp-connect/desktop/last-result.json ``` The validator checks structure, output quality, debug code, data cleanliness, schema descriptions, and login method diversity. Fix all reported issues and re-run. @@ -184,7 +184,7 @@ Schemas are an API contract — app developers build against them. ### Generate the skeleton ```bash -node scripts/generate-schemas.cjs ~/.vana/desktop/last-result.json [output-dir] +node scripts/generate-schemas.cjs ~/.pdp-connect/desktop/last-result.json [output-dir] ``` ### Enrich from what you know diff --git a/skills/vana-connect/RECIPES.md b/skills/pdp-connect/RECIPES.md similarity index 91% rename from skills/vana-connect/RECIPES.md rename to skills/pdp-connect/RECIPES.md index 55ff8a8..405e306 100644 --- a/skills/vana-connect/RECIPES.md +++ b/skills/pdp-connect/RECIPES.md @@ -1,6 +1,6 @@ # Recipes -What to do with collected data. Each recipe starts from `~/.vana/desktop/last-result.json`. +What to do with collected data. Each recipe starts from `~/.pdp-connect/desktop/last-result.json`. --- @@ -56,7 +56,7 @@ Show the profile before saving. Ask what to change. Where to save: - **Claude Code:** User memory or CLAUDE.md - **OpenClaw/Kimi:** `USER.md` in the agent's workspace -- **Generic:** `~/.vana/desktop/user-profile.md` +- **Generic:** `~/.pdp-connect/desktop/user-profile.md` When adding a new platform to an existing profile, merge. Don't overwrite. @@ -109,7 +109,7 @@ Export personal data to portable formats. The result file is already JSON. Copy it: ```bash -cp ~/.vana/desktop/last-result.json ~/backups/github-export-2026-03-10.json +cp ~/.pdp-connect/desktop/last-result.json ~/backups/github-export-2026-03-10.json ``` ### CSV (for tabular data) @@ -131,7 +131,7 @@ Run the connector on a schedule (cron, agent heartbeat, etc.) and timestamp each ```bash node run-connector.cjs -cp ~/.vana/desktop/last-result.json ~/backups/-$(date +%Y-%m-%d).json +cp ~/.pdp-connect/desktop/last-result.json ~/backups/-$(date +%Y-%m-%d).json ``` --- @@ -143,7 +143,7 @@ Combine data from multiple platforms. ### Approach 1. Connect platforms one at a time. Each run produces a separate result file. -2. Before each run, copy the previous result: `cp ~/.vana/desktop/last-result.json ~/.vana/desktop/-result.json` +2. Before each run, copy the previous result: `cp ~/.pdp-connect/desktop/last-result.json ~/.pdp-connect/desktop/-result.json` 3. After all platforms are connected, read all result files and synthesize. ### What cross-referencing reveals diff --git a/skills/pdp-connect/SETUP.md b/skills/pdp-connect/SETUP.md new file mode 100644 index 0000000..60a4b5c --- /dev/null +++ b/skills/pdp-connect/SETUP.md @@ -0,0 +1,35 @@ +# Connect -- Setup + +This setup provisions the local runtime the in-repo scripts use to run connectors headlessly. + +## Prerequisites + +- Node.js +- `git` and (optionally) the `gh` CLI for contributing new connectors + +## Install the runtime + +```bash +bash skills/pdp-connect/scripts/setup.sh +``` + +This downloads the `playwright-runner` and a Chromium build into `~/.pdp-connect/desktop/`. This is a one-time step. + +## Verify + +```bash +ls ~/.pdp-connect/desktop/playwright-runner/index.cjs +ls ~/.pdp-connect/desktop/run-connector.cjs +``` + +If either is missing, re-run `setup.sh` and check its output for the step that failed. + +## File Locations + +| Path | Purpose | +|------|---------| +| `run-connector.cjs` (repo root, symlinked to `skills/pdp-connect/scripts/run-connector.cjs`) | Runs a connector headlessly | +| `~/.pdp-connect/desktop/connectors/` | Connector scripts | +| `~/.pdp-connect/desktop/browser-profiles/` | Persistent sessions (cookies) | +| `~/.pdp-connect/desktop/last-result.json` | Most recent result | +| `~/.pdp-connect/desktop/logs/` | Setup, fetch, and run logs | diff --git a/skills/vana-connect/SKILL.md b/skills/pdp-connect/SKILL.md similarity index 50% rename from skills/vana-connect/SKILL.md rename to skills/pdp-connect/SKILL.md index 83efcec..ef06200 100644 --- a/skills/vana-connect/SKILL.md +++ b/skills/pdp-connect/SKILL.md @@ -1,5 +1,5 @@ --- -name: vana-connect +name: pdp-connect description: > Connect personal data from any web platform using browser automation. Use when: (1) user wants to connect a data source like ChatGPT, Instagram, @@ -10,87 +10,31 @@ description: > # Connect -Connect personal data from web platforms using the `vana` CLI and local browser automation. +Connect personal data from web platforms using the in-repo scripts under `skills/pdp-connect/scripts/` and local browser automation. ## Setup -Prefer an installed `vana` CLI on `PATH`: +Check whether the runtime is already installed: ```bash -command -v vana +ls ~/.pdp-connect/desktop/playwright-runner/index.cjs ``` -If that succeeds, use: +If it's missing, follow `SETUP.md` in this folder before continuing. Tell the user: "I need to do a one-time setup first. This downloads a browser engine and some dependencies into `~/.pdp-connect/desktop/` and usually takes about a minute." -```bash -vana -``` - -If `vana` is unavailable, install the current published canary. Prefer: - -macOS with Homebrew: - -```bash -brew tap vana-com/vana -brew install vana -``` - -macOS and Linux: - -```bash -curl -fsSL https://raw.githubusercontent.com/vana-com/vana-connect/feat/connect-cli-v1/install/install.sh | sh -s -- --version canary-feat-connect-cli-v1 -``` - -Only if the installed CLI path is unavailable or blocked, fall back to: - -```bash -npx -y @opendatalabs/connect@canary -``` - -If the user is explicitly testing local changes, fall back to: - -```bash -node /home/tnunamak/code/vana-connect/dist/cli/bin.js -``` - -If neither path is available, follow `SETUP.md` in this folder. - -Before connecting a source, check runtime state with: - -```bash -vana status --json -``` - -If the user needs install, path, or upgrade diagnostics, use: - -```bash -vana doctor -``` - -If the user needs recent setup, fetch, or run logs, use: - -```bash -vana logs -vana logs -``` - -If the runtime is missing, tell the user: "I need to do a one-time setup first. This downloads a browser engine and some dependencies into `~/.vana/desktop/` and usually takes about a minute." Then run: - -```bash -vana setup --yes -``` +If setup, fetch, or run output is truncated, check `~/.pdp-connect/desktop/logs/` directly. ## Flow ### 1. Explore available sources ```bash -vana sources --json +node skills/pdp-connect/scripts/fetch-connector.cjs ``` -This is the source of truth for what the CLI can currently connect. Prefer it over inspecting repo files manually. +or inspect `registry.json` / the `connectors/` directory directly for the source of truth on what can currently be connected. -If the requested platform is present, use the CLI flow below. +If the requested platform is present, use the run flow below. **If no connector exists for the platform,** tell the user you'll build one — this involves researching the platform's data APIs, writing the extraction code, and testing it. Let them know it'll take a bit and they're welcome to do something else while you work. Then read `CREATE.md` and follow it. @@ -98,7 +42,7 @@ If the user is building or testing an app and needs sample data, prefer the public fixture flow over pasting large JSON into the agent: ```bash -curl -fsSL https://raw.githubusercontent.com/vana-com/data-connectors/main/fixture-index.json +curl -fsSL https://raw.githubusercontent.com/PDP-Connect/data-connectors/main/fixture-index.json ``` Find the matching `sourceId`, `scope`, and `scenario`, then download the entry's @@ -107,72 +51,41 @@ payloads that conform to the connector schema. The raw URL points at latest `main`; compare the downloaded file against the entry's `sha256` if exact bytes matter. -### 2. Connect with the CLI +### 2. Run the connector -Start with the agent-safe probe: +Start with the agent-safe, non-interactive probe: ```bash -vana connect --json --no-input +node run-connector.cjs connectors//-playwright.js [start-url] ``` This will: -- ensure the runtime is installed -- resolve and cache the connector -- try a saved session if one exists -- return structured events and a final outcome such as `needs_input`, `legacy_auth`, `connected_local_only`, or `connected_and_ingested` +- resolve the playwright-runner (auto-detected, or pass `--runner-dir`) +- try a saved browser-profile session if one exists +- stream structured line-delimited JSON events on stdout: `need-input`, `legacy-auth`, `log`, `result`, `error` -If the outcome is `needs_input`, rerun interactively: +If the connector emits `need-input`, either supply the value up front with `--inputs '{"key":"val"}'` or rerun with `--pretty` for a human-readable interactive session so the user can respond. -```bash -vana connect -``` - -If the user specifically wants to inspect current state before rerunning, use: - -```bash -vana status -``` +If the connector emits `legacy-auth`, it still depends on an older `showBrowser` / `promptUser` pattern and may need a headed/manual session path — flag this to the user rather than retrying blindly. ### 3. Handle outcomes -The CLI emits structured JSON events in `--json` mode. - -Key outcomes: - -- `needs_input` - The connector needs a live login or another manual step. Explain that you'll rerun interactively. -- `legacy_auth` - The connector still depends on `showBrowser` / `promptUser`. Explain that this source still needs a headed/manual session path and may not work in fully headless batch mode yet. -- `connected_local_only` - Data was collected locally but no Personal Server target was available. -- `connected_and_ingested` - Data was collected and synced to the Personal Server. - -If setup, fetch, or run output is truncated, use: - -```bash -vana logs -vana logs -``` +- `need-input` — the connector needs a live login or another manual step. Explain that you'll rerun interactively or with `--inputs`. +- `legacy-auth` — this source still needs a headed/manual session and may not work in fully headless batch mode yet. +- `result` — data was collected and written to the output path (default `~/.pdp-connect/desktop/last-result.json`, override with `--output`). +- `error` — the run failed; inspect the emitted message and `~/.pdp-connect/desktop/logs/` if present. -Prefer that over manually hunting through `~/.vana/desktop/logs/` or rerunning blindly. +If output is truncated or unclear, check `~/.pdp-connect/desktop/logs/` directly rather than rerunning blindly. -After a successful connect, prefer the CLI data surfaces over raw file inspection when possible: - -```bash -vana data list -vana data show -vana data path -vana logs -``` +After a successful run, read the result file at the output path to inspect the collected data. ### 4. Validate, present results, and offer to contribute If you built or modified a connector, immediately run validation — before presenting results to the user: ```bash -node scripts/validate.cjs connectors//-playwright.js --check-result ~/.vana/desktop/last-result.json +node scripts/validate.cjs connectors//-playwright.js --check-result ~/.pdp-connect/desktop/last-result.json ``` Fix any issues the validator reports. The validator checks debug code, login method diversity, schema descriptions, data cleanliness, and more — it is the quality gate. Iterate until validation passes. @@ -201,8 +114,8 @@ The user can't see what you're doing behind the scenes. Keep them informed at ke 3. **After collection**, summarize results in human terms — not file paths: - Good: "Connected! I collected 249 issues, 63 projects, 9 teams, and your profile from Linear." - - Bad: "Data saved to ~/.vana/desktop/last-result.json" - - Prefer the CLI outcome plus the result file. Build the summary from `exportSummary` and the scoped keys. + - Bad: "Data saved to ~/.pdp-connect/desktop/last-result.json" + - Build the summary from the result's `exportSummary` and the scoped keys. 4. **On failure**, explain what went wrong and what the user can do: - Auth failed → "Login didn't work. Can you double-check your credentials?" @@ -215,13 +128,4 @@ The user can't see what you're doing behind the scenes. Keep them informed at ke 3. **One platform at a time** 4. **Check session first** -- try without credentials if a browser profile exists 5. **Read connectors before running them** -6. **Use the CLI as the primary interface** -- only drop to raw scripts when debugging or updating connector internals - -## CLI fallback order - -Use this order when choosing the CLI entrypoint: - -1. installed `vana` -2. official installer path for the current canary -3. `npx -y @opendatalabs/connect@canary` -4. `node /home/tnunamak/code/vana-connect/dist/cli/bin.js` only for local development or debugging +6. **Run connectors via `run-connector.cjs`** -- it is the sole documented entrypoint for executing a connector; see `SETUP.md` for the one-time runtime install diff --git a/skills/vana-connect/reference/PAGE-API.md b/skills/pdp-connect/reference/PAGE-API.md similarity index 100% rename from skills/vana-connect/reference/PAGE-API.md rename to skills/pdp-connect/reference/PAGE-API.md diff --git a/skills/vana-connect/reference/PATTERNS.md b/skills/pdp-connect/reference/PATTERNS.md similarity index 100% rename from skills/vana-connect/reference/PATTERNS.md rename to skills/pdp-connect/reference/PATTERNS.md diff --git a/skills/vana-connect/scripts/fetch-connector.cjs b/skills/pdp-connect/scripts/fetch-connector.cjs similarity index 91% rename from skills/vana-connect/scripts/fetch-connector.cjs rename to skills/pdp-connect/scripts/fetch-connector.cjs index a621251..c572975 100644 --- a/skills/vana-connect/scripts/fetch-connector.cjs +++ b/skills/pdp-connect/scripts/fetch-connector.cjs @@ -5,7 +5,7 @@ * Usage: node scripts/fetch-connector.cjs * * Searches the registry for the platform, downloads the connector script, - * metadata, and connector-local schemas to ~/.vana/desktop/connectors/. + * metadata, and connector-local schemas to ~/.pdp-connect/desktop/connectors/. * Prints the local path on success. * * Exit codes: 0 = found and downloaded, 1 = not found or error. @@ -22,9 +22,9 @@ if (!platform) { process.exit(1); } -const REGISTRY_URL = 'https://raw.githubusercontent.com/vana-com/data-connectors/main/registry.json'; -const BASE_URL = 'https://raw.githubusercontent.com/vana-com/data-connectors/main'; -const CONNECTORS_DIR = path.join(os.homedir(), '.vana', 'desktop', 'connectors'); +const REGISTRY_URL = 'https://raw.githubusercontent.com/PDP-Connect/data-connectors/main/registry.json'; +const BASE_URL = 'https://raw.githubusercontent.com/PDP-Connect/data-connectors/main'; +const CONNECTORS_DIR = path.join(os.homedir(), '.pdp-connect', 'desktop', 'connectors'); function fetch(url) { return new Promise((resolve, reject) => { diff --git a/skills/vana-connect/scripts/generate-schemas.cjs b/skills/pdp-connect/scripts/generate-schemas.cjs similarity index 100% rename from skills/vana-connect/scripts/generate-schemas.cjs rename to skills/pdp-connect/scripts/generate-schemas.cjs diff --git a/skills/vana-connect/scripts/register.cjs b/skills/pdp-connect/scripts/register.cjs similarity index 100% rename from skills/vana-connect/scripts/register.cjs rename to skills/pdp-connect/scripts/register.cjs diff --git a/skills/vana-connect/scripts/run-connector.cjs b/skills/pdp-connect/scripts/run-connector.cjs similarity index 96% rename from skills/vana-connect/scripts/run-connector.cjs rename to skills/pdp-connect/scripts/run-connector.cjs index a864726..6f77119 100644 --- a/skills/vana-connect/scripts/run-connector.cjs +++ b/skills/pdp-connect/scripts/run-connector.cjs @@ -6,7 +6,7 @@ * * Options: * --inputs '{"key":"val"}' Pre-supply credentials/2FA - * --output Result file path (default: ~/.vana/desktop/last-result.json) + * --output Result file path (default: ~/.pdp-connect/desktop/last-result.json) * --pretty Human-readable colored output instead of JSON * --runner-dir Path to playwright-runner (auto-detected if not set) * @@ -37,7 +37,7 @@ const rawArgs = process.argv.slice(2); const positional = []; let preSuppliedInputs = {}; let pretty = false; -let outputPath = path.join(homedir, '.vana', 'desktop', 'last-result.json'); +let outputPath = path.join(homedir, '.pdp-connect', 'desktop', 'last-result.json'); let runnerDir = null; for (let i = 0; i < rawArgs.length; i++) { @@ -66,8 +66,8 @@ if (!connectorPath) { // Scope IPC files by connector name + timestamp so multiple runs never collide. const connectorSlug = path.basename(connectorPath, path.extname(connectorPath)); const runId = `${connectorSlug}-${Date.now()}`; -const PENDING_INPUT_PATH = path.join(homedir, '.vana', 'desktop', `pending-input-${runId}.json`); -const INPUT_RESPONSE_PATH = path.join(homedir, '.vana', 'desktop', `input-response-${runId}.json`); +const PENDING_INPUT_PATH = path.join(homedir, '.pdp-connect', 'desktop', `pending-input-${runId}.json`); +const INPUT_RESPONSE_PATH = path.join(homedir, '.pdp-connect', 'desktop', `input-response-${runId}.json`); // ─── Pretty output helpers ─────────────────────────────────── @@ -122,7 +122,7 @@ function resolveRunnerDir() { } const candidates = [ - path.join(homedir, '.vana', 'desktop', 'playwright-runner'), + path.join(homedir, '.pdp-connect', 'desktop', 'playwright-runner'), process.env.PLAYWRIGHT_RUNNER_DIR, path.resolve(__dirname, '..', '..', '..', 'data-dt-app', 'playwright-runner'), ].filter(Boolean); diff --git a/skills/vana-connect/scripts/scaffold.cjs b/skills/pdp-connect/scripts/scaffold.cjs similarity index 94% rename from skills/vana-connect/scripts/scaffold.cjs rename to skills/pdp-connect/scripts/scaffold.cjs index 0a8198c..4e17a01 100644 --- a/skills/vana-connect/scripts/scaffold.cjs +++ b/skills/pdp-connect/scripts/scaffold.cjs @@ -4,7 +4,7 @@ * * Usage: node scaffold.cjs [company] [output-dir] * - * Defaults: company = platform, output-dir = ~/.vana/desktop/connectors + * Defaults: company = platform, output-dir = ~/.pdp-connect/desktop/connectors */ const fs = require('fs'); @@ -12,7 +12,7 @@ const path = require('path'); const platform = process.argv[2]; const company = process.argv[3] || platform; -const outputDir = process.argv[4] || path.join(require('os').homedir(), '.vana', 'desktop', 'connectors'); +const outputDir = process.argv[4] || path.join(require('os').homedir(), '.pdp-connect', 'desktop', 'connectors'); if (!platform) { console.error('Usage: node scaffold.cjs [company] [output-dir]'); diff --git a/skills/vana-connect/scripts/setup.sh b/skills/pdp-connect/scripts/setup.sh similarity index 59% rename from skills/vana-connect/scripts/setup.sh rename to skills/pdp-connect/scripts/setup.sh index f114e18..669a78f 100755 --- a/skills/vana-connect/scripts/setup.sh +++ b/skills/pdp-connect/scripts/setup.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# setup.sh — One-shot setup for the vana-connect skill. +# setup.sh — One-shot setup for the pdp-connect skill. # Installs playwright-runner, Chromium, and run-connector.cjs. # # Usage: bash scripts/setup.sh @@ -8,21 +8,21 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -VANA_DIR="$HOME/.vana/desktop" +PDP_CONNECT_DIR="$HOME/.pdp-connect/desktop" # Skip if already set up -if [[ -f "$VANA_DIR/playwright-runner/index.cjs" && -f "$VANA_DIR/run-connector.cjs" ]]; then - echo "Already set up. To reinstall, remove ~/.vana/desktop/playwright-runner/ first." +if [[ -f "$PDP_CONNECT_DIR/playwright-runner/index.cjs" && -f "$PDP_CONNECT_DIR/run-connector.cjs" ]]; then + echo "Already set up. To reinstall, remove ~/.pdp-connect/desktop/playwright-runner/ first." exit 0 fi -echo "Setting up vana-connect..." +echo "Setting up pdp-connect..." -mkdir -p "$VANA_DIR/connectors" +mkdir -p "$PDP_CONNECT_DIR/connectors" # 1. Clone playwright-runner (sparse checkout, minimal download) echo "Downloading playwright-runner..." -cd "$VANA_DIR" +cd "$PDP_CONNECT_DIR" rm -rf _data-connect git clone --depth 1 --filter=blob:none --sparse --branch main \ https://github.com/vana-com/data-connect.git _data-connect 2>&1 @@ -32,7 +32,7 @@ cd .. && rm -rf _data-connect # 2. Install dependencies echo "Installing dependencies..." -cd "$VANA_DIR/playwright-runner" && npm install 2>&1 +cd "$PDP_CONNECT_DIR/playwright-runner" && npm install 2>&1 # 3. Install Chromium echo "Installing Chromium (this may take a minute)..." @@ -43,17 +43,17 @@ npx playwright install chromium 2>&1 || { # 4. Copy run-connector.cjs if [[ -f "$SCRIPT_DIR/run-connector.cjs" ]]; then - cp "$SCRIPT_DIR/run-connector.cjs" "$VANA_DIR/run-connector.cjs" + cp "$SCRIPT_DIR/run-connector.cjs" "$PDP_CONNECT_DIR/run-connector.cjs" else echo "Warning: run-connector.cjs not found at $SCRIPT_DIR/run-connector.cjs" - echo "Copy it manually: cp skills/vana-connect/scripts/run-connector.cjs ~/.vana/desktop/run-connector.cjs" + echo "Copy it manually: cp skills/pdp-connect/scripts/run-connector.cjs ~/.pdp-connect/desktop/run-connector.cjs" fi # Verify echo "" -if [[ -f "$VANA_DIR/playwright-runner/index.cjs" && -f "$VANA_DIR/run-connector.cjs" ]]; then +if [[ -f "$PDP_CONNECT_DIR/playwright-runner/index.cjs" && -f "$PDP_CONNECT_DIR/run-connector.cjs" ]]; then echo "Setup complete!" else - echo "Setup may be incomplete. Check ~/.vana/desktop/ for missing files." + echo "Setup may be incomplete. Check ~/.pdp-connect/desktop/ for missing files." exit 1 fi diff --git a/skills/vana-connect/scripts/validate.cjs b/skills/pdp-connect/scripts/validate.cjs similarity index 98% rename from skills/vana-connect/scripts/validate.cjs rename to skills/pdp-connect/scripts/validate.cjs index d79cf18..a7e9254 100644 --- a/skills/vana-connect/scripts/validate.cjs +++ b/skills/pdp-connect/scripts/validate.cjs @@ -637,7 +637,7 @@ function contribute(connectorPath, connectorDir, metadataPath) { if (!useGh) { try { - execSync('git ls-remote https://github.com/vana-com/data-connectors.git HEAD', { stdio: 'pipe' }); + execSync('git ls-remote https://github.com/PDP-Connect/data-connectors.git HEAD', { stdio: 'pipe' }); } catch { console.error('Cannot authenticate with GitHub. Install and auth `gh` CLI, or configure git credentials.'); process.exit(1); @@ -672,7 +672,7 @@ function contribute(connectorPath, connectorDir, metadataPath) { try { // Fork if needed if (useGh) { - try { execSync('gh repo fork vana-com/data-connectors --clone=false', { stdio: 'pipe', cwd: repoRoot }); } catch {} + try { execSync('gh repo fork PDP-Connect/data-connectors --clone=false', { stdio: 'pipe', cwd: repoRoot }); } catch {} } execSync(`git checkout -b ${branchName}`, { stdio: 'pipe', cwd: repoRoot }); @@ -715,7 +715,7 @@ function contribute(connectorPath, connectorDir, metadataPath) { reportSection, ``, `---`, - `Generated by the [vana-connect skill](https://github.com/vana-com/data-connectors/tree/main/skills/vana-connect).`, + `Generated by the [pdp-connect skill](https://github.com/PDP-Connect/data-connectors/tree/main/skills/pdp-connect).`, ].join('\n'); const prBodyFile = path.join(repoRoot, '.pr-body.tmp'); @@ -732,7 +732,7 @@ function contribute(connectorPath, connectorDir, metadataPath) { } else { execSync(`git push -u origin ${branchName}`, { stdio: 'pipe', cwd: repoRoot }); console.log(`\nBranch pushed: ${branchName}`); - console.log(`Open a PR at: https://github.com/vana-com/data-connectors/compare/${branchName}`); + console.log(`Open a PR at: https://github.com/PDP-Connect/data-connectors/compare/${branchName}`); } } catch (e) { console.error('Contribution failed:', e.message); diff --git a/skills/vana-connect/templates/connector-metadata.json b/skills/pdp-connect/templates/connector-metadata.json similarity index 100% rename from skills/vana-connect/templates/connector-metadata.json rename to skills/pdp-connect/templates/connector-metadata.json diff --git a/skills/vana-connect/templates/connector-script.js b/skills/pdp-connect/templates/connector-script.js similarity index 100% rename from skills/vana-connect/templates/connector-script.js rename to skills/pdp-connect/templates/connector-script.js diff --git a/skills/vana-connect/templates/schema.json b/skills/pdp-connect/templates/schema.json similarity index 100% rename from skills/vana-connect/templates/schema.json rename to skills/pdp-connect/templates/schema.json diff --git a/skills/vana-connect/SETUP.md b/skills/vana-connect/SETUP.md deleted file mode 100644 index 2b35b6f..0000000 --- a/skills/vana-connect/SETUP.md +++ /dev/null @@ -1,95 +0,0 @@ -# Connect -- Setup - -This setup exists to let the skill use an installed `vana` CLI. - -```bash -command -v vana -``` - -Skip runtime setup if `vana status --json` reports `"runtime":"installed"` or `"runtime":{"installed":true,...}`. - -## Prerequisites - -For the normal installed CLI path: - -- Homebrew on macOS, or -- `curl` and `sh` on macOS/Linux - -Only local development and `npx` fallback require Node.js. - -## Install the published CLI - -Preferred on macOS: - -```bash -brew tap vana-com/vana -brew install vana -``` - -macOS and Linux: - -```bash -curl -fsSL https://raw.githubusercontent.com/vana-com/vana-connect/feat/connect-cli-v1/install/install.sh | sh -s -- --version canary-feat-connect-cli-v1 -``` - -Fallback if installer paths are blocked: - -```bash -npx -y @opendatalabs/connect@canary --help -``` - -## Local development fallback - -From `/home/tnunamak/code/vana-connect`: - -```bash -pnpm install -pnpm build -``` - -Verify: - -```bash -ls /home/tnunamak/code/vana-connect/dist/cli/bin.js -``` - -## Install the runtime - -Use the published CLI to provision the runtime: - -```bash -vana setup --yes -``` - -Before running, tell the user this downloads a browser engine and some dependencies into `~/.vana/desktop/`. This is a one-time step. - -## Verify - -```bash -vana status -vana doctor -vana logs -``` - -You should see `Runtime: installed`. If not, inspect the log path surfaced by the CLI or use `vana logs`, and only fall back to the older script-level flow if the CLI setup path is blocked. - -## Legacy fallback - -Only use this if the CLI setup path is broken and you are debugging the underlying runtime: - -```bash -bash skills/vana-connect/scripts/setup.sh -``` - -## File Locations - -| Path | Purpose | -|------|---------| -| `vana` | Preferred installed CLI entrypoint | -| `npx -y @opendatalabs/connect@canary` | Published canary CLI fallback | -| `/home/tnunamak/code/vana-connect/dist/cli/bin.js` | Local development fallback | -| `~/.local/share/vana/` | Hosted-installer release payloads | -| `~/.vana/desktop/connectors/` | Connector scripts | -| `~/.vana/desktop/browser-profiles/` | Persistent sessions (cookies) | -| `~/.vana/desktop/last-result.json` | Most recent result | -| `~/.vana/desktop/logs/` | Setup, fetch, and run logs | diff --git a/test-connector.cjs b/test-connector.cjs index c431efb..06d830e 100644 --- a/test-connector.cjs +++ b/test-connector.cjs @@ -1,7 +1,7 @@ #!/usr/bin/env node // TODO: Migrate .claude/skills/ and scripts/ to use run-connector.cjs -// (skills/vana-connect/scripts/run-connector.cjs), then delete this file. +// (skills/pdp-connect/scripts/run-connector.cjs), then delete this file. /** * Standalone Connector Test Runner From 57ed3508dfcadf4e4a6931c67f6f7425955e0d04 Mon Sep 17 00:00:00 2001 From: Tim Nunamaker Date: Fri, 17 Jul 2026 17:55:47 -0500 Subject: [PATCH 2/6] chore: remove stale internal design docs These docs reference a private Linear workspace and are marked superseded/historical. They do not belong in a public repo. Signed-off-by: Tim Nunamaker --- docs/260410-contract-freeze.md | 204 ---- ...026-05-29-scope-coverage-catalog-design.md | 78 -- .../plans/2026-03-31-connector-smoke-test.md | 878 ------------------ .../2026-03-31-connector-smoke-test-design.md | 118 --- 4 files changed, 1278 deletions(-) delete mode 100644 docs/260410-contract-freeze.md delete mode 100644 docs/specs/2026-05-29-scope-coverage-catalog-design.md delete mode 100644 docs/superpowers/plans/2026-03-31-connector-smoke-test.md delete mode 100644 docs/superpowers/specs/2026-03-31-connector-smoke-test-design.md diff --git a/docs/260410-contract-freeze.md b/docs/260410-contract-freeze.md deleted file mode 100644 index c53199d..0000000 --- a/docs/260410-contract-freeze.md +++ /dev/null @@ -1,204 +0,0 @@ -# Connector Contract Freeze — Phase 0 Decisions - -Date: 2026-04-10 -Supersedes: ad-hoc manifest and page API conventions. -Related eval file: `context-gateway/docs/evals/260410-connector-contract-unification.evals.yaml` - -This document records the contract decisions that Phase 0 of the connector -unification plan locked in. Subsequent phases consume these as given. - -## 1. Canonical manifest shape - -The canonical contract is `schemas/manifest.schema.json` in this repository. -Every `*-playwright.json` manifest must validate against it. - -Required fields (additive from the pre-migration shape): - -| Field | Example | Notes | -| --- | --- | --- | -| `manifest_version` | `"1.0"` | Version of the manifest contract itself. | -| `connector_id` | `"instagram-playwright"` | Globally unique artifact id. | -| `source_id` | `"instagram"` | Stable product-facing source id. Must not change across releases without a versioned migration path. | -| `version` | `"1.0.0"` | Connector version (semver). Independent of the other versions. | -| `name` | `"Instagram"` | Display name. | -| `company` | `"Meta"` | Company / organization. | -| `description` | `"..."` | Human description. | -| `runtime` | `"playwright"` | Enum: `playwright`, `vanilla`, `network-capture`. | -| `page_api_version` | `1` | Major version of the page API this connector targets. Independent of `version`. | -| `connect_url` | `"https://..."` | Login / start URL. | -| `connect_selector` | `"..."` | Logged-in-state detection selector. | -| `scopes` | `[{scope, label, description}, ...]` | Canonical public data units. | - -Legacy aliases (`id`, `connectURL`, `connectSelector`, `iconURL`, -`exportFrequency`, `vectorize_config`) are kept in place during migration but -are no longer the source of truth. `scripts/normalize-manifests.mjs` keeps the -canonical and legacy fields in sync. - -### `connector_id` vs `source_id` - -- `connector_id` is the build/runtime artifact id. It includes runtime - qualifiers (e.g. `-playwright`) and secondary suffixes (e.g. `-ads`). -- `source_id` is the product-facing source id as users see it (e.g. the CG - source dropdown). Multiple connectors MAY emit into the same `source_id` if - they produce scopes under the same platform namespace. - -Explicit override: `instagram-ads-playwright` has `source_id: "instagram"` -because it is a second artifact producing `instagram.*` scopes, not a distinct -source. - -### Scope id format - -Every scope id must match `^[a-z0-9_-]+\.[A-Za-z0-9_.-]+$` and must begin with -the manifest's `source_id` followed by a dot. Historical camelCase scope tails -(`youtube.playlistItems`, `spotify.savedTracks`) are preserved during the -migration. - -## 2. Page API minimum surface - -`types/connector.d.ts` exposes the canonical page API. Phase 0 decision: the -following methods become canonical (they were missing or de facto only in -Context Gateway's CG proxy, not in the typed contract): - -- `click(selector, options?)` -- `fill(selector, value, options?)` -- `press(selector, key, options?)` -- `waitForSelector(selector, options?)` -- `url()` - -Rationale: CG connector scripts already rely on these methods, and DataConnect -can implement them as thin pass-throughs to the underlying Playwright page -object. Adding them to the canonical surface is easier than rewriting CG -scripts to the narrower upstream style, and it costs DC only a handful of -one-line method wrappers. - -`requestInput` is canonical. `getInput` is a shell-runtime adapter concern -only. - -Versioning: `types/page-api-version.ts` exports `PAGE_API_VERSION = 1`. Within -a major version the contract is additive-only. Breaking changes require both a -constant bump and a matching `page_api_version` bump in every manifest. CI -enforces this via `scripts/check-page-api-additive.mjs`. - -## 3. Instagram contract decision - -CG has historically exposed Instagram via a local script that scraped extra -fields (`following_accounts`, ad `targeting_categories`) and did NOT emit -posts. The canonical `data-connectors` Instagram connector emits profile, -posts, and ads but did not previously emit following. - -### `instagram.following` — upstreamed into the canonical script - -- `scrapeFollowingAccounts` from CG's `public/automations/instagram-headless.js` - has been ported into the canonical `connectors/meta/instagram-playwright.js` - (see `state.followingAccounts` + the `scrapeFollowingAccounts` helper near - the top of the file). -- The canonical script now emits `instagram.following` alongside profile, - posts, and ads. -- Schema: `schemas/instagram.following.json`. -- Rationale: keeping CG-only fields in a CG-only fork would perpetuate the - drift that this whole plan exists to eliminate. The port was mechanical — - the function only uses `page.evaluate` — so there was no reason to defer. - -### Ad targeting categories — open contract gap - -- `schemas/instagram.ads.json` declares an optional `categories` field, but - neither the canonical Instagram script nor the current CG script actually - emits it under that name. CG collects a similar concept as - `targeting_categories`, but that is not the canonical field name and is - not currently exported by either script in this cycle. -- Phase 3 deliberately does NOT attempt to rename or populate `categories`. - If product wants ad targeting categories on the public contract, that is - a follow-up task: implement canonical collection and then either populate - the existing optional `categories` field or declare a new scope. -- The earlier version of this doc incorrectly claimed `categories` was - "already present" on `instagram.ads`. It is present in the schema as an - optional field, but no emitter populates it. - -### Canonical Instagram script is now activated in CG - -Phase 3 replaces CG's `public/automations/instagram-headless.js` with the -canonical `connectors/meta/instagram-playwright.js` (byte-for-byte via the -generator's snapshot emission). CG users gain `instagram.posts` (which they -never had before), retain `instagram.profile`, `instagram.following`, and -`instagram.ads`. The runtime method gap is closed by new `setProgress`, -`showBrowser`, `goHeadless` shims on CG's `PlaywrightPageProxy`. - -### GitHub is also activated - -Phase 3 replaces `public/automations/github.js` with the canonical -`connectors/github/github-playwright.js`. Same mechanism. The CG proxy shims -also cover GitHub's method surface. - -### iCloud Notes is upstreamed but NOT yet runtime-canonical - -- The iCloud Notes manifest, schemas, and script are committed to - `data-connectors`, satisfying HC-PHASE3-ICLOUD-UPSTREAM-001. -- HOWEVER: the script depends on CG-runtime-only page methods that are - intentionally NOT part of the canonical typed Page API: - - `page.getInput(inputSchema)` — CG-specific credential prompt primitive - - `page.frame_click`, `page.frame_fill`, `page.frame_evaluate`, - `page.frame_waitForSelector` — CG-specific iframe helpers (used for - Apple's auth widget iframe) - - `page.keyboard_press`, `page.keyboard_type` — CG-specific keyboard - primitives (used for 2FA OTP entry) -- These methods are NOT in `types/connector.d.ts`, they are NOT implemented - in `data-connect/playwright-runner/index.cjs`, and the upstream script - will NOT run under the canonical DataConnect runtime without further - convergence. -- The manifest advertises this explicitly via the - `capabilities: ["cg-legacy-page-api"]` flag. Any runner that checks - capabilities before activating a connector should reject iCloud Notes - if it does not implement the CG-legacy surface. -- This is an honest "metadata converged, script runtime pending" state. - Full canonical convergence requires either (a) rewriting the iCloud Notes - login flow to use `requestInput` and eliminate the iframe helpers, or - (b) promoting `frame_*` and `keyboard_*` into the canonical Page API. - Neither is in scope for this PR set. - -### Oura is NOT activated - -- The canonical Oura script uses email + password login. -- The existing CG Oura script uses email + OTP-only login. -- Some Oura accounts are OTP-only (no password set); activating the canonical - script would block them from connecting. -- Phase 3 keeps `public/automations/oura.js` as the existing CG script with - an exception comment. The canonical script is still pinned in the snapshot - at `config/data-connectors-snapshot/scripts/oura-playwright.js` so that - future convergence has a stable baseline to diff against. -- The CG overlay flags this with `activate_canonical_script: false`. -- Runtime convergence follow-up: either add OTP as an option in the canonical - script, or have CG detect OTP accounts and fall through to a CG-only - adapter. Not in this cycle. - -## 4. Shell overlay scope - -Per HC-SHELL-OVERLAY-001, shell overlays (Context Gateway, DataConnect display -registry) own ONLY: - -- Availability / whitelist -- Canonical source or connector references (pointers, not definitions) -- Color / theme treatment -- Privacy note copy -- Route config -- Embed copy -- Optional icon override - -They do NOT own: - -- Source descriptions (live in manifests) -- Public scope or stream definitions (live in manifests) -- Public schema definitions (live in `schemas/`) - -## 5. Versioning axes - -Per HC-VERSIONING-INDEPENDENT-001, four version dimensions are tracked -independently: - -| Axis | Location | Bumps when | -| --- | --- | --- | -| Manifest schema | `manifest_version` field | The manifest contract itself changes. | -| Connector | `version` field | A connector's behavior changes. | -| Page API | `page_api_version` field + `PAGE_API_VERSION` constant | The page API contract changes. | -| Scope schema | `version` field inside each `schemas/*.json` | A public scope schema changes. | - -No two of these are allowed to be collapsed into a single field. diff --git a/docs/specs/2026-05-29-scope-coverage-catalog-design.md b/docs/specs/2026-05-29-scope-coverage-catalog-design.md deleted file mode 100644 index 5efe2a4..0000000 --- a/docs/specs/2026-05-29-scope-coverage-catalog-design.md +++ /dev/null @@ -1,78 +0,0 @@ -# Scope Coverage Registry — Design Spec - -**Date:** 2026-05-29 -**Author:** Maciej (brainstormed 2026-05-29) -**Status:** Accepted -**Linear:** [BUI-395 — Instagram scope mismatch found in CG lab E2E](https://linear.app/vana-team/issue/BUI-395/instagram-scope-mismatch-found-in-cg-lab-e2e) -**Superseded by BUI-705:** `SCOPES.md` is now generated from the public -machine-readable catalog. This document records the earlier BUI-395 decision -and is not the current maintenance contract. - -**Historical artifact:** `SCOPES.md` (repo root) - ---- - -## 1. Summary - -A single, **hand-maintained** reference file — `SCOPES.md` — in the -`data-connectors` repo. Open it and you see every data scope, whether the **web** -(ODL Data Pipe API) and/or **desktop** (Playwright connectors) flow can fulfill -it, and the exact connector(s) behind each scope. - -It is documentation, not machinery: no generator, no codegen, no automated sync. - -## 2. Background — the bug - -During a CG → Vana Web → Personal Server lab E2E, the lab requested `read:ads` -(→ `instagram.ads`). CG forwarded it correctly, but the **web (light)** flow can -only produce `instagram.profile`, so the Personal Server had no `instagram.ads` -data and the read returned a valid 404. - -The architecture is two-tier: -- **Desktop / heavy** — Playwright connectors in `data-connectors`. Already canonical. -- **Web / light** — the ODL-run Data Pipe API (`data-pipe.vana.org`) used by the - Vana Web app. Produces only `*.profile` today, for five wired sources. - -"Which tier can fulfill which scope" was undocumented; the only place the web set -existed was a hardcoded list in `unity-surfaces` -(`web-writable-scopes.ts`). The open question on BUI-395 was *where* this should -be documented. This spec answers: one file in data-connectors. - -## 3. Decision — manual file, deliberately not generated - -We considered generating the catalog from the connector manifests + a declared -web-capabilities input, and generating `unity-surfaces`' -`web-writable-scopes.ts` from it. **Rejected for now**: the web app's set of -web-fulfillable scopes changes infrequently, so the cost/complexity of a -generator, a vendored snapshot, and a sync step is not worth it. A single file a -human reads and edits is enough. - -Consequences: -- No scripts are added to any repo. -- `unity-surfaces` `web-writable-scopes.ts` is unchanged and stays - hand-maintained. Keeping it consistent with `SCOPES.md`'s Web column is a - manual responsibility. If it drifts, we fix it by hand. - -## 4. The file - -`SCOPES.md` at the `data-connectors` repo root. One table: - -| Column | Meaning | -|---|---| -| **Source** | source id (e.g. `instagram`) | -| **Scope** | canonical scope id (e.g. `instagram.ads`) | -| **Web** | ✅ if the ODL Data Pipe API can produce it (today: `*.profile` for instagram, spotify, youtube, linkedin, github) | -| **Desktop** | ✅ if a Playwright connector produces it | -| **Connector(s)** | the exact connector id(s) + status that fulfill the scope | - -Plus a header explaining how to read it and a footer explaining how to maintain -it (update Desktop rows when a connector changes scopes; flip Web cells when the -Data Pipe API gains a scope; keep `web-writable-scopes.ts` consistent by hand). - -## 5. Out of scope - -- Any generator, sync script, or codegen. -- Changes to `unity-surfaces` (`web-writable-scopes.ts` stays as-is). -- A Data Pipe API capabilities endpoint. -- CG surfacing the tier at request time (e.g. warning "ads needs Desktop" in the - lab) — a separate follow-on if desired. diff --git a/docs/superpowers/plans/2026-03-31-connector-smoke-test.md b/docs/superpowers/plans/2026-03-31-connector-smoke-test.md deleted file mode 100644 index 1ffe516..0000000 --- a/docs/superpowers/plans/2026-03-31-connector-smoke-test.md +++ /dev/null @@ -1,878 +0,0 @@ -# Connector Smoke Test Harness — Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Build a batch test harness that runs stable connectors headlessly, validates output against declared scopes, and reports per-connector health as a colored terminal table + JSON report. - -**Architecture:** A single CJS script (`scripts/test-connectors.cjs`) that reads `registry.json` and connector metadata, loops over connectors, spawns `run-connector.cjs` for each with a unique `--output` path, classifies outcomes from exit codes + stdout, validates result files against metadata scopes, and prints a summary table + writes a JSON report. - -**Tech Stack:** Node.js (CJS), child_process.spawn, fs, path. No external dependencies. - ---- - -## File Structure - -| File | Responsibility | -|---|---| -| `scripts/test-connectors.cjs` (create) | CLI entrypoint: arg parsing, connector resolution from registry, orchestration loop, result validation, terminal + JSON reporting | -| `.gitignore` (modify) | Add `test-results/` | - -All logic lives in one file. The harness is ~300 lines with no external dependencies. - ---- - -### Task 1: Gitignore and test-results directory - -**Files:** -- Modify: `.gitignore` - -- [ ] **Step 1: Add test-results/ to .gitignore** - -Add this line to `.gitignore` after the existing `connector-result.json` entry: - -``` -test-results/ -``` - -The full `# Test runner output` section becomes: - -```gitignore -# Test runner output -connector-result.json -test-results/ -``` - -- [ ] **Step 2: Commit** - -```bash -git add .gitignore -git commit -m "chore: gitignore test-results directory" -``` - ---- - -### Task 2: Arg parsing and connector resolution - -**Files:** -- Create: `scripts/test-connectors.cjs` - -- [ ] **Step 1: Write the test** - -Create `scripts/test-connectors.test.cjs` (we'll run it with `node --test`): - -```js -const { describe, it } = require('node:test'); -const assert = require('node:assert'); - -// We'll extract pure functions from the main script for testing. -// For now, test the module by requiring it with a test flag. -// The main script exports { parseArgs, resolveConnectors, classifyOutcome, validateResult } -// when require.main !== module. - -const { - parseArgs, - resolveConnectors, - classifyOutcome, - validateResult, -} = require('./test-connectors.cjs'); - -describe('parseArgs', () => { - it('returns stable defaults with no args', () => { - const result = parseArgs([]); - assert.strictEqual(result.includeBeta, false); - assert.strictEqual(result.validateSchemas, false); - assert.deepStrictEqual(result.connectors, null); - }); - - it('parses --connectors flag', () => { - const result = parseArgs(['--connectors', 'instagram-playwright,spotify-playwright']); - assert.deepStrictEqual(result.connectors, ['instagram-playwright', 'spotify-playwright']); - }); - - it('parses --include-beta flag', () => { - const result = parseArgs(['--include-beta']); - assert.strictEqual(result.includeBeta, true); - }); - - it('parses --validate-schemas flag', () => { - const result = parseArgs(['--validate-schemas']); - assert.strictEqual(result.validateSchemas, true); - }); -}); - -describe('resolveConnectors', () => { - const registry = { - connectors: [ - { id: 'a-playwright', status: 'stable', files: { script: 'a/a.js', metadata: 'a/a.json' } }, - { id: 'b-playwright', status: 'beta', files: { script: 'b/b.js', metadata: 'b/b.json' } }, - { id: 'c-playwright', status: 'experimental', files: { script: 'c/c.js', metadata: 'c/c.json' } }, - ], - }; - - it('returns only stable by default', () => { - const result = resolveConnectors(registry, { connectors: null, includeBeta: false }); - assert.deepStrictEqual(result.map(c => c.id), ['a-playwright']); - }); - - it('includes beta when flag set', () => { - const result = resolveConnectors(registry, { connectors: null, includeBeta: true }); - assert.deepStrictEqual(result.map(c => c.id), ['a-playwright', 'b-playwright']); - }); - - it('filters to specific connectors', () => { - const result = resolveConnectors(registry, { connectors: ['b-playwright'], includeBeta: false }); - assert.deepStrictEqual(result.map(c => c.id), ['b-playwright']); - }); - - it('throws on unknown connector id', () => { - assert.throws( - () => resolveConnectors(registry, { connectors: ['nonexistent'], includeBeta: false }), - /not found in registry: nonexistent/ - ); - }); -}); -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `node --test scripts/test-connectors.test.cjs` -Expected: FAIL — `Cannot find module './test-connectors.cjs'` - -- [ ] **Step 3: Write arg parsing and connector resolution** - -Create `scripts/test-connectors.cjs`: - -```js -#!/usr/bin/env node -/** - * test-connectors.cjs — Batch smoke test for data connectors. - * - * Wraps run-connector.cjs to run each connector headlessly, validates output - * against declared scopes, and reports per-connector health. - * - * Usage: - * node scripts/test-connectors.cjs [options] - * - * Options: - * --connectors , Override default connector set (comma-separated IDs) - * --include-beta Include beta connectors (default: stable only) - * --validate-schemas Validate result data against schemas/*.json - * - * Exit codes: 0 all pass/warn, 1 any fail/auth/timeout - */ - -const { spawn } = require('child_process'); -const fs = require('fs'); -const path = require('path'); - -const ROOT = path.resolve(__dirname, '..'); -const REGISTRY_PATH = path.join(ROOT, 'registry.json'); -const CONNECTORS_DIR = path.join(ROOT, 'connectors'); -const RESULTS_DIR = path.join(ROOT, 'test-results'); -const RUN_CONNECTOR = path.join(ROOT, 'run-connector.cjs'); - -// ─── ANSI Colors ──────────────────────────────────────────── - -const c = { - reset: '\x1b[0m', bold: '\x1b[1m', dim: '\x1b[2m', - red: '\x1b[31m', green: '\x1b[32m', yellow: '\x1b[33m', - blue: '\x1b[34m', magenta: '\x1b[35m', cyan: '\x1b[36m', gray: '\x1b[90m', -}; - -// ─── Arg Parsing ──────────────────────────────────────────── - -function parseArgs(argv) { - const opts = { connectors: null, includeBeta: false, validateSchemas: false }; - for (let i = 0; i < argv.length; i++) { - if (argv[i] === '--connectors' && argv[i + 1]) { - opts.connectors = argv[++i].split(',').map(s => s.trim()).filter(Boolean); - } else if (argv[i] === '--include-beta') { - opts.includeBeta = true; - } else if (argv[i] === '--validate-schemas') { - opts.validateSchemas = true; - } - } - return opts; -} - -// ─── Connector Resolution ─────────────────────────────────── - -function resolveConnectors(registry, opts) { - if (opts.connectors) { - // Explicit list — find each by ID, regardless of status - return opts.connectors.map(id => { - const entry = registry.connectors.find(c => c.id === id); - if (!entry) throw new Error(`Connector not found in registry: ${id}`); - return entry; - }); - } - // Default: stable, or stable + beta - const allowed = opts.includeBeta ? ['stable', 'beta'] : ['stable']; - return registry.connectors.filter(c => allowed.includes(c.status)); -} - -// ─── Exports for testing ──────────────────────────────────── - -if (require.main !== module) { - module.exports = { parseArgs, resolveConnectors }; -} -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `node --test scripts/test-connectors.test.cjs` -Expected: All `parseArgs` and `resolveConnectors` tests PASS - -- [ ] **Step 5: Commit** - -```bash -git add scripts/test-connectors.cjs scripts/test-connectors.test.cjs -git commit -m "feat(test-connectors): arg parsing and connector resolution" -``` - ---- - -### Task 3: Outcome classification and result validation - -**Files:** -- Modify: `scripts/test-connectors.test.cjs` -- Modify: `scripts/test-connectors.cjs` - -- [ ] **Step 1: Write the tests** - -Append to `scripts/test-connectors.test.cjs`: - -```js -describe('classifyOutcome', () => { - it('returns AUTH for exit code 2', () => { - const result = classifyOutcome(2, []); - assert.strictEqual(result.status, 'auth'); - assert.match(result.error, /need-input/); - }); - - it('returns AUTH for exit code 3', () => { - const result = classifyOutcome(3, []); - assert.strictEqual(result.status, 'auth'); - assert.match(result.error, /legacy-auth/); - }); - - it('returns TIMEOUT for exit 1 with timeout message', () => { - const stdout = [ - JSON.stringify({ type: 'error', message: 'Timeout after 5 minutes' }), - ]; - const result = classifyOutcome(1, stdout); - assert.strictEqual(result.status, 'timeout'); - }); - - it('returns FAIL for exit 1 without timeout', () => { - const stdout = [ - JSON.stringify({ type: 'error', message: 'Some other error' }), - ]; - const result = classifyOutcome(1, stdout); - assert.strictEqual(result.status, 'fail'); - }); - - it('returns needs-validation for exit 0', () => { - const result = classifyOutcome(0, []); - assert.strictEqual(result.status, 'needs-validation'); - }); -}); - -describe('validateResult', () => { - const metadata = { - scopes: [ - { scope: 'test.profile' }, - { scope: 'test.posts' }, - { scope: 'test.likes' }, - ], - }; - - it('returns PASS when all scopes present and non-empty', () => { - const data = { - 'test.profile': { name: 'Alice' }, - 'test.posts': [{ id: 1 }], - 'test.likes': [{ id: 2 }], - exportSummary: { count: 2 }, - }; - const result = validateResult(data, metadata); - assert.strictEqual(result.status, 'pass'); - assert.strictEqual(result.scopesFound.length, 3); - assert.strictEqual(result.scopesMissing.length, 0); - }); - - it('returns WARN when scope has empty array', () => { - const data = { - 'test.profile': { name: 'Alice' }, - 'test.posts': [], - 'test.likes': [{ id: 2 }], - }; - const result = validateResult(data, metadata); - assert.strictEqual(result.status, 'warn'); - assert.deepStrictEqual(result.warnings, ['test.posts: empty array']); - }); - - it('returns WARN when scope has empty object', () => { - const data = { - 'test.profile': {}, - 'test.posts': [{ id: 1 }], - 'test.likes': [{ id: 2 }], - }; - const result = validateResult(data, metadata); - assert.strictEqual(result.status, 'warn'); - assert.deepStrictEqual(result.warnings, ['test.profile: empty object']); - }); - - it('returns FAIL when scope is missing', () => { - const data = { - 'test.profile': { name: 'Alice' }, - 'test.posts': [{ id: 1 }], - }; - const result = validateResult(data, metadata); - assert.strictEqual(result.status, 'fail'); - assert.deepStrictEqual(result.scopesMissing, ['test.likes']); - }); - - it('returns FAIL when scope value is null', () => { - const data = { - 'test.profile': { name: 'Alice' }, - 'test.posts': [{ id: 1 }], - 'test.likes': null, - }; - const result = validateResult(data, metadata); - assert.strictEqual(result.status, 'fail'); - assert.deepStrictEqual(result.scopesMissing, ['test.likes']); - }); - - it('normalizes bare keys for github-style connectors', () => { - const githubMeta = { - scopes: [ - { scope: 'github.profile' }, - { scope: 'github.repositories' }, - ], - }; - const data = { - profile: { name: 'Alice' }, - repositories: [{ name: 'repo1' }], - }; - const result = validateResult(data, githubMeta); - assert.strictEqual(result.status, 'pass'); - assert.strictEqual(result.scopesFound.length, 2); - }); -}); -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `node --test scripts/test-connectors.test.cjs` -Expected: FAIL — `classifyOutcome is not a function`, `validateResult is not a function` - -- [ ] **Step 3: Implement classifyOutcome and validateResult** - -Add to `scripts/test-connectors.cjs`, before the `if (require.main !== module)` block: - -```js -// ─── Outcome Classification ───────────────────────────────── - -function classifyOutcome(exitCode, stdoutLines) { - if (exitCode === 2) return { status: 'auth', error: 'need-input: connector requires credentials' }; - if (exitCode === 3) return { status: 'auth', error: 'legacy-auth: connector uses headed login not supported in batch mode' }; - if (exitCode === 1) { - const isTimeout = stdoutLines.some(line => { - try { - const msg = JSON.parse(line); - return msg.type === 'error' && msg.message && msg.message.includes('Timeout after 5 minutes'); - } catch { return false; } - }); - return isTimeout - ? { status: 'timeout', error: 'Timed out after 5 minutes' } - : { status: 'fail', error: 'Connector exited with error' }; - } - if (exitCode === 0) return { status: 'needs-validation' }; - return { status: 'fail', error: `Unknown exit code: ${exitCode}` }; -} - -// ─── Result Validation ────────────────────────────────────── - -function validateResult(data, metadata) { - const expectedScopes = (metadata.scopes || []).map(s => s.scope); - const scopesFound = []; - const scopesMissing = []; - const warnings = []; - - for (const scope of expectedScopes) { - // Try exact scoped key first, then bare suffix (for github-style connectors) - const bareSuffix = scope.includes('.') ? scope.split('.').slice(1).join('.') : null; - const value = scope in data ? data[scope] : (bareSuffix && bareSuffix in data ? data[bareSuffix] : undefined); - - if (value === undefined || value === null) { - scopesMissing.push(scope); - continue; - } - - scopesFound.push(scope); - - if (Array.isArray(value) && value.length === 0) { - warnings.push(`${scope}: empty array`); - } else if (typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length === 0) { - warnings.push(`${scope}: empty object`); - } - } - - if (scopesMissing.length > 0) { - return { status: 'fail', scopesFound, scopesMissing, warnings }; - } - if (warnings.length > 0) { - return { status: 'warn', scopesFound, scopesMissing, warnings }; - } - return { status: 'pass', scopesFound, scopesMissing, warnings }; -} -``` - -Update the exports block: - -```js -if (require.main !== module) { - module.exports = { parseArgs, resolveConnectors, classifyOutcome, validateResult }; -} -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `node --test scripts/test-connectors.test.cjs` -Expected: All tests PASS - -- [ ] **Step 5: Commit** - -```bash -git add scripts/test-connectors.cjs scripts/test-connectors.test.cjs -git commit -m "feat(test-connectors): outcome classification and result validation" -``` - ---- - -### Task 4: Orchestration loop and reporting - -**Files:** -- Modify: `scripts/test-connectors.cjs` - -- [ ] **Step 1: Implement the main function and connector runner** - -Add to `scripts/test-connectors.cjs`, replacing the `if (require.main !== module)` block with: - -```js -// ─── Run a single connector via run-connector.cjs ─────────── - -function runConnector(connectorEntry, opts) { - return new Promise((resolve) => { - const scriptPath = path.join(CONNECTORS_DIR, '..', connectorEntry.files.script); - const metadataPath = scriptPath.replace(/\.js$/, '.json'); - const outputPath = path.join(RESULTS_DIR, `${connectorEntry.id}.json`); - - // Load metadata for connectURL and scope info - let metadata = {}; - try { metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf-8')); } catch {} - const startUrl = metadata.connectURL || 'about:blank'; - - // Delete stale result file - try { fs.unlinkSync(outputPath); } catch {} - - const startTime = Date.now(); - const stdoutLines = []; - - const child = spawn(process.execPath, [ - RUN_CONNECTOR, scriptPath, startUrl, '--output', outputPath, - ], { stdio: ['pipe', 'pipe', 'pipe'] }); - - child.stdout.on('data', (chunk) => { - for (const line of chunk.toString().split('\n')) { - if (line.trim()) stdoutLines.push(line.trim()); - } - }); - - // Suppress stderr (runner debug output) - child.stderr.on('data', () => {}); - - child.on('close', (exitCode) => { - const duration = Date.now() - startTime; - const outcome = classifyOutcome(exitCode || 0, stdoutLines); - - if (outcome.status === 'needs-validation') { - // Read and validate result file - let data = null; - try { data = JSON.parse(fs.readFileSync(outputPath, 'utf-8')); } catch {} - - if (!data) { - resolve({ connector: connectorEntry.id, status: 'fail', exitCode: 0, duration, error: 'No result file produced' }); - return; - } - - const validation = validateResult(data, metadata); - resolve({ - connector: connectorEntry.id, - status: validation.status, - exitCode: 0, - duration, - scopesExpected: (metadata.scopes || []).map(s => s.scope), - scopesFound: validation.scopesFound, - scopesMissing: validation.scopesMissing, - warnings: validation.warnings.length > 0 ? validation.warnings : undefined, - }); - } else { - resolve({ - connector: connectorEntry.id, - status: outcome.status, - exitCode: exitCode || 0, - duration, - error: outcome.error, - }); - } - }); - }); -} - -// ─── Reporting ────────────────────────────────────────────── - -function printResults(results) { - const timestamp = new Date().toISOString(); - console.log(`\n${c.bold}Connector Smoke Test${c.reset} ${c.dim}— ${timestamp}${c.reset}\n`); - - for (const r of results) { - const dur = (r.duration / 1000).toFixed(1) + 's'; - const scopeCount = r.scopesExpected - ? `${r.scopesFound.length}/${r.scopesExpected.length} scopes` - : '—'; - - let detail = ''; - if (r.warnings && r.warnings.length > 0) detail = ` (${r.warnings.join(', ')})`; - if (r.scopesMissing && r.scopesMissing.length > 0) detail = ` (missing: ${r.scopesMissing.join(', ')})`; - if (r.error) detail = ` (${r.error})`; - - let statusStr; - switch (r.status) { - case 'pass': statusStr = `${c.green}PASS${c.reset} `; break; - case 'warn': statusStr = `${c.yellow}WARN${c.reset} `; break; - case 'auth': statusStr = `${c.magenta}AUTH${c.reset} `; break; - case 'fail': statusStr = `${c.red}FAIL${c.reset} `; break; - case 'timeout': statusStr = `${c.red}TIMEOUT${c.reset}`; break; - default: statusStr = `${c.gray}???${c.reset} `; - } - - const id = r.connector.padEnd(28); - const sc = scopeCount.padEnd(14); - console.log(` ${statusStr} ${id} ${sc} ${dur}${c.dim}${detail}${c.reset}`); - } - - const counts = { pass: 0, warn: 0, auth: 0, fail: 0, timeout: 0 }; - let totalDuration = 0; - for (const r of results) { - counts[r.status] = (counts[r.status] || 0) + 1; - totalDuration += r.duration; - } - - const parts = []; - if (counts.pass) parts.push(`${c.green}${counts.pass} pass${c.reset}`); - if (counts.warn) parts.push(`${c.yellow}${counts.warn} warn${c.reset}`); - if (counts.auth) parts.push(`${c.magenta}${counts.auth} auth${c.reset}`); - if (counts.fail) parts.push(`${c.red}${counts.fail} fail${c.reset}`); - if (counts.timeout) parts.push(`${c.red}${counts.timeout} timeout${c.reset}`); - - console.log(`\n${parts.join(' · ')} — ${(totalDuration / 1000).toFixed(1)}s total\n`); - - return counts; -} - -function writeJsonReport(results) { - const timestamp = new Date().toISOString(); - const counts = { pass: 0, warn: 0, auth: 0, fail: 0, timeout: 0 }; - for (const r of results) counts[r.status] = (counts[r.status] || 0) + 1; - - const report = { - timestamp, - summary: { ...counts, total: results.length }, - results, - }; - - const reportPath = path.join(RESULTS_DIR, `connector-smoke-${timestamp.replace(/[:.]/g, '-')}.json`); - fs.writeFileSync(reportPath, JSON.stringify(report, null, 2)); - console.log(`${c.dim}Report: ${reportPath}${c.reset}\n`); - return reportPath; -} - -// ─── Main ─────────────────────────────────────────────────── - -async function main() { - const opts = parseArgs(process.argv.slice(2)); - - // Load registry - if (!fs.existsSync(REGISTRY_PATH)) { - console.error(`${c.red}Registry not found: ${REGISTRY_PATH}${c.reset}`); - process.exit(1); - } - const registry = JSON.parse(fs.readFileSync(REGISTRY_PATH, 'utf-8')); - - // Resolve connectors - const connectors = resolveConnectors(registry, opts); - if (connectors.length === 0) { - console.error(`${c.red}No connectors matched.${c.reset}`); - process.exit(1); - } - - // Ensure results directory - fs.mkdirSync(RESULTS_DIR, { recursive: true }); - - // Verify run-connector.cjs exists - if (!fs.existsSync(RUN_CONNECTOR)) { - console.error(`${c.red}run-connector.cjs not found: ${RUN_CONNECTOR}${c.reset}`); - process.exit(1); - } - - // Run connectors sequentially (each needs its own browser) - const results = []; - for (const entry of connectors) { - const label = `${c.cyan}${entry.name}${c.reset} ${c.dim}(${entry.id})${c.reset}`; - process.stdout.write(` Running ${label}...`); - const result = await runConnector(entry, opts); - // Clear the "Running..." line - process.stdout.write('\r\x1b[K'); - results.push(result); - } - - // Report - const counts = printResults(results); - writeJsonReport(results); - - // Exit 1 if any fail/auth/timeout - const hasFailure = counts.fail > 0 || counts.auth > 0 || counts.timeout > 0; - process.exit(hasFailure ? 1 : 0); -} - -// ─── Exports for testing ──────────────────────────────────── - -if (require.main !== module) { - module.exports = { parseArgs, resolveConnectors, classifyOutcome, validateResult }; -} else { - main().catch((err) => { - console.error(`${c.red}Fatal: ${err.message}${c.reset}`); - process.exit(1); - }); -} -``` - -- [ ] **Step 2: Run existing tests to verify nothing broke** - -Run: `node --test scripts/test-connectors.test.cjs` -Expected: All previous tests still PASS - -- [ ] **Step 3: Manual smoke test** - -Run the harness against a single connector you have cookies for: - -```bash -node scripts/test-connectors.cjs --connectors github-playwright -``` - -Expected: A single-row result table showing PASS, WARN, AUTH, or FAIL with timing. - -- [ ] **Step 4: Commit** - -```bash -git add scripts/test-connectors.cjs -git commit -m "feat(test-connectors): orchestration loop and reporting" -``` - ---- - -### Task 5: Schema validation (opt-in) - -**Files:** -- Modify: `scripts/test-connectors.test.cjs` -- Modify: `scripts/test-connectors.cjs` - -- [ ] **Step 1: Write the test** - -Append to `scripts/test-connectors.test.cjs`: - -```js -describe('validateSchema', () => { - it('returns pass for data matching schema', () => { - const schema = { - type: 'object', - properties: { - username: { type: 'string' }, - followers: { type: 'number' }, - }, - required: ['username'], - }; - const data = { username: 'alice', followers: 42 }; - const errors = validateSchema(data, schema); - assert.deepStrictEqual(errors, []); - }); - - it('returns error for missing required field', () => { - const schema = { - type: 'object', - properties: { - username: { type: 'string' }, - }, - required: ['username'], - }; - const data = { followers: 42 }; - const errors = validateSchema(data, schema); - assert.strictEqual(errors.length, 1); - assert.match(errors[0], /required.*username/i); - }); - - it('returns error for wrong type', () => { - const schema = { - type: 'object', - properties: { - username: { type: 'string' }, - }, - required: ['username'], - }; - const data = { username: 123 }; - const errors = validateSchema(data, schema); - assert.strictEqual(errors.length, 1); - assert.match(errors[0], /username.*string/i); - }); -}); -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `node --test scripts/test-connectors.test.cjs` -Expected: FAIL — `validateSchema is not a function` - -- [ ] **Step 3: Implement lightweight schema validator** - -Add to `scripts/test-connectors.cjs`, before the `classifyOutcome` function: - -```js -// ─── Lightweight JSON Schema Validator ────────────────────── -// Validates required fields and basic types. Not a full JSON Schema -// implementation — just enough for smoke testing connector output. - -function validateSchema(data, schema) { - const errors = []; - if (!schema || schema.type !== 'object' || !schema.properties) return errors; - - // Check required fields - for (const field of (schema.required || [])) { - if (!(field in data) || data[field] === undefined || data[field] === null) { - errors.push(`Required field missing: ${field}`); - } - } - - // Check types of present fields - for (const [field, spec] of Object.entries(schema.properties)) { - if (!(field in data) || data[field] === null || data[field] === undefined) continue; - const value = data[field]; - const expectedType = spec.type; - if (!expectedType) continue; - - let actual; - if (Array.isArray(value)) actual = 'array'; - else actual = typeof value; - - if (expectedType === 'array' && actual !== 'array') { - errors.push(`${field}: expected array, got ${actual}`); - } else if (expectedType !== 'array' && actual !== expectedType) { - errors.push(`${field}: expected ${expectedType}, got ${actual}`); - } - } - - return errors; -} -``` - -Update the exports: - -```js -if (require.main !== module) { - module.exports = { parseArgs, resolveConnectors, classifyOutcome, validateResult, validateSchema }; -} -``` - -- [ ] **Step 4: Wire schema validation into runConnector** - -In the `runConnector` function's `needs-validation` branch, after the `const validation = validateResult(data, metadata);` line, add: - -```js - // Optional schema validation - let schemaErrors = []; - if (opts.validateSchemas) { - const schemasDir = path.join(ROOT, 'schemas'); - for (const scope of validation.scopesFound) { - const schemaPath = path.join(schemasDir, `${scope}.json`); - if (!fs.existsSync(schemaPath)) continue; - try { - const schemaFile = JSON.parse(fs.readFileSync(schemaPath, 'utf-8')); - const scopeKey = scope in data ? scope : scope.split('.').slice(1).join('.'); - const errs = validateSchema(data[scopeKey], schemaFile.schema); - schemaErrors.push(...errs.map(e => `${scope}: ${e}`)); - } catch {} - } - } -``` - -Add `schemaErrors` to the resolved result object: - -```js - schemaErrors: schemaErrors.length > 0 ? schemaErrors : undefined, -``` - -- [ ] **Step 5: Run tests to verify all pass** - -Run: `node --test scripts/test-connectors.test.cjs` -Expected: All tests PASS - -- [ ] **Step 6: Commit** - -```bash -git add scripts/test-connectors.cjs scripts/test-connectors.test.cjs -git commit -m "feat(test-connectors): opt-in schema validation" -``` - ---- - -### Task 6: Final integration test and cleanup - -**Files:** -- Modify: `scripts/test-connectors.cjs` (if any issues found) - -- [ ] **Step 1: Run full harness with --connectors flag** - -Pick one connector you have valid cookies for: - -```bash -node scripts/test-connectors.cjs --connectors github-playwright -``` - -Verify: -- Terminal output shows a single-row result table -- `test-results/github-playwright.json` exists and contains valid connector output -- `test-results/connector-smoke-*.json` exists with the summary report -- Exit code is 0 for pass/warn, 1 for fail/auth/timeout - -- [ ] **Step 2: Run harness with no args to verify default set** - -```bash -node scripts/test-connectors.cjs -``` - -Verify it attempts all 6 stable connectors. (Some will AUTH if you don't have sessions — that's expected.) - -- [ ] **Step 3: Run harness with --validate-schemas** - -```bash -node scripts/test-connectors.cjs --connectors github-playwright --validate-schemas -``` - -Verify schema validation output appears in the report. - -- [ ] **Step 4: Final commit** - -```bash -git add scripts/test-connectors.cjs scripts/test-connectors.test.cjs -git commit -m "feat(test-connectors): batch connector smoke test harness" -``` diff --git a/docs/superpowers/specs/2026-03-31-connector-smoke-test-design.md b/docs/superpowers/specs/2026-03-31-connector-smoke-test-design.md deleted file mode 100644 index a6993a8..0000000 --- a/docs/superpowers/specs/2026-03-31-connector-smoke-test-design.md +++ /dev/null @@ -1,118 +0,0 @@ -# Connector Smoke Test Harness — Design Spec - -## Purpose - -A batch orchestrator that runs all stable connectors headlessly using pre-existing browser sessions, validates output against declared scopes, and reports per-connector health. Designed for local use with a path to CI. - -## Architecture - -The harness wraps `run-connector.cjs` — the maintained standalone runner that already handles runner resolution, auth classification, result capture, and process lifecycle. The test harness is a thin loop: resolve connectors from `registry.json`, spawn `run-connector.cjs` once per connector with a unique `--output` path, classify outcomes from exit codes + stdout, validate the result file against metadata scopes, and report. - -## Connector Resolution - -- Source: root `registry.json` (15 connectors: 6 stable, 5 beta, 4 experimental) -- Default policy: **stable connectors only** (chatgpt, github, instagram, linkedin, spotify, oura) -- `--connectors ,` overrides the default set, filtering by connector ID -- `--include-beta` expands default to stable + beta - -## Execution Per Connector - -1. Delete any previous `test-results/.json` (remove stale results) -2. Resolve connector script path from registry entry's `files.script` -3. Resolve start URL from connector metadata's `connectURL` -4. Spawn: `node run-connector.cjs --output test-results/.json` -5. Capture stdout (JSON lines) for timeout detection and logging -6. Wait for exit - -## Outcome Classification - -| Status | Condition | -|-----------|------------------------------------------------------------------| -| **PASS** | Exit 0 + all metadata scopes present in result with non-empty data | -| **WARN** | Exit 0 + all scopes present but some have empty arrays/objects | -| **AUTH** | Exit 2 (need-input) or exit 3 (legacy-auth) | -| **FAIL** | Exit 0 but missing scopes, or exit 1 without timeout message | -| **TIMEOUT** | Exit 1 + stdout contains `"Timeout after 5 minutes"` error event | - -## Validation Rules - -- For each scope in the connector's `.json` metadata, check that the result object contains a key matching the full scope (e.g., `github.profile`) -- Non-empty means the value is not `null`, not `undefined` -- Empty arrays (`[]`) or empty objects (`{}`) are **warnings**, not failures — a user could legitimately have zero starred repos -- Only validate result files from exit-0 runs - -### Scope Normalization (temporary) - -`github-playwright` currently returns bare keys (`profile`, `repositories`, `starred`) instead of scoped keys (`github.profile`, etc.). Until vana-com/data-connectors#52 lands, the harness checks both `` and the bare suffix (everything after the first dot) when matching result keys. This normalization applies only during validation, not to the result file itself. - -## Schema Validation (opt-in) - -When `--validate-schemas` is passed, the harness additionally validates each scope's data against the corresponding JSON Schema file in `schemas/.json`. This is not the default because schemas are strict and may flag acceptable variations in live data. - -## Output - -### Terminal (colored table) - -``` -Connector Smoke Test — 2026-03-31T12:00:00Z - - PASS chatgpt-playwright 2/2 scopes 18.2s - WARN instagram-playwright 3/3 scopes 12.1s (instagram.posts: empty array) - AUTH spotify-playwright — 2.3s (need-input: session expired) - FAIL github-playwright 2/3 scopes 6.4s (missing: github.starred) - PASS linkedin-playwright 6/6 scopes 22.1s - PASS oura-playwright 3/3 scopes 14.7s - -3 pass · 1 warn · 1 auth · 1 fail · 0 timeout — 75.8s total -``` - -### JSON report - -Written to `test-results/connector-smoke-.json`: - -```json -{ - "timestamp": "2026-03-31T12:00:00Z", - "summary": { "pass": 3, "warn": 1, "auth": 1, "fail": 1, "timeout": 0, "total": 6 }, - "results": [ - { - "connector": "spotify-playwright", - "status": "auth", - "exitCode": 2, - "duration": 2300, - "error": "need-input: session expired" - }, - { - "connector": "github-playwright", - "status": "fail", - "exitCode": 0, - "duration": 6400, - "scopesExpected": ["github.profile", "github.repositories", "github.starred"], - "scopesFound": ["github.profile", "github.repositories"], - "scopesMissing": ["github.starred"] - } - ] -} -``` - -## Invocation - -```bash -# Default: stable connectors only -node scripts/test-connectors.cjs - -# Specific connectors -node scripts/test-connectors.cjs --connectors instagram-playwright,spotify-playwright - -# Include beta -node scripts/test-connectors.cjs --include-beta - -# With schema validation -node scripts/test-connectors.cjs --validate-schemas -``` - -## Files - -- **New:** `scripts/test-connectors.cjs` (the harness) -- **New:** `test-results/` directory (gitignored) -- **Modified:** `.gitignore` (add `test-results/`) From 3ce3e1f95cf93381c6aeba4b845c91ddff6d97ae Mon Sep 17 00:00:00 2001 From: Tim Nunamaker Date: Fri, 17 Jul 2026 17:55:49 -0500 Subject: [PATCH 3/6] chore: remove Vana-internal consumer-update workflow This workflow reached into Vana-internal repos (context-gateway, data-connect) to open automated update PRs; it does not belong in a neutral connectors repo. Signed-off-by: Tim Nunamaker --- .github/workflows/consumer-update-prs.yml | 137 ---------------------- 1 file changed, 137 deletions(-) delete mode 100644 .github/workflows/consumer-update-prs.yml diff --git a/.github/workflows/consumer-update-prs.yml b/.github/workflows/consumer-update-prs.yml deleted file mode 100644 index 563b6da..0000000 --- a/.github/workflows/consumer-update-prs.yml +++ /dev/null @@ -1,137 +0,0 @@ -name: Consumer Update PRs - -on: - workflow_dispatch: - push: - branches: [main] - paths: - - "connector-index.json" - - "artifacts/**" - - "packages/connector-installer-core/**" - - "packages/connector-installer-cli/**" - - "schemas/connector-index.schema.json" - - "package.json" - -permissions: - contents: read - -jobs: - preflight: - runs-on: ubuntu-latest - outputs: - has_token: ${{ steps.check.outputs.has_token }} - steps: - - id: check - env: - CONSUMER_PR_TOKEN: ${{ secrets.CONSUMER_PR_TOKEN }} - run: | - if [ -n "$CONSUMER_PR_TOKEN" ]; then - echo "has_token=true" >> "$GITHUB_OUTPUT" - else - echo "has_token=false" >> "$GITHUB_OUTPUT" - fi - - update-context-gateway: - needs: preflight - if: needs.preflight.outputs.has_token == 'true' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - path: data-connectors - - - uses: actions/checkout@v4 - with: - repository: vana-com/context-gateway - token: ${{ secrets.CONSUMER_PR_TOKEN }} - ref: staging - path: context-gateway - - - uses: actions/setup-node@v4 - with: - node-version: "20" - - - name: Install context-gateway deps - working-directory: context-gateway - run: npm install - - - name: Install local shared installer package - working-directory: context-gateway - run: npm install --no-save ../data-connectors - - - name: Resolve connectors and regenerate catalog - working-directory: context-gateway - run: | - node scripts/resolve-connectors.mjs --from-local ../data-connectors - node scripts/generate-connector-catalog.mjs - node scripts/validate-connector-overlay.mjs - - - name: Create pull request - uses: peter-evans/create-pull-request@v7 - with: - token: ${{ secrets.CONSUMER_PR_TOKEN }} - path: context-gateway - branch: automation/update-data-connectors - delete-branch: true - commit-message: "chore: refresh connector snapshot" - title: "chore: refresh connector snapshot" - body: | - Automated update from `PDP-Connect/data-connectors`. - - Changes: - - refreshed `config/data-connectors-snapshot/` - - refreshed generated connector catalog - - update-data-connect: - needs: preflight - if: needs.preflight.outputs.has_token == 'true' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - path: data-connectors - - - uses: actions/checkout@v4 - with: - repository: vana-com/data-connect - token: ${{ secrets.CONSUMER_PR_TOKEN }} - ref: main - path: data-connect - - - uses: actions/setup-node@v4 - with: - node-version: "20" - - - name: Install data-connect deps - working-directory: data-connect - run: npm install - - - name: Install local shared installer package - working-directory: data-connect - run: npm install --no-save ../data-connectors - - - name: Resolve connectors and regenerate platform registry - working-directory: data-connect - run: | - node scripts/resolve-connectors.js --from-local ../data-connectors - node scripts/generate-platform-registry.js - - - name: Run tests - working-directory: data-connect - run: npm test - - - name: Create pull request - uses: peter-evans/create-pull-request@v7 - with: - token: ${{ secrets.CONSUMER_PR_TOKEN }} - path: data-connect - branch: automation/update-data-connectors - delete-branch: true - commit-message: "chore: refresh bundled connectors" - title: "chore: refresh bundled connectors" - body: | - Automated update from `PDP-Connect/data-connectors`. - - Changes: - - refreshed bundled connectors under `connectors/` - - refreshed generated platform registry From 9d761ae7cf948b396d729aab50f4019984e44cff Mon Sep 17 00:00:00 2001 From: Tim Nunamaker Date: Fri, 17 Jul 2026 18:10:57 -0500 Subject: [PATCH 4/6] Use 'The PDP-Connect Contributors' as the copyright holder Signed-off-by: Tim Nunamaker --- NOTICE | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/NOTICE b/NOTICE index 24e3113..3ef460a 100644 --- a/NOTICE +++ b/NOTICE @@ -1,2 +1,2 @@ -Copyright © 2026 Vana Foundation +Copyright © 2026 The PDP-Connect Contributors Licensed under the Apache License, Version 2.0. From 9cc4f7e140c8e5b82eed0ce71e41bfee42c17123 Mon Sep 17 00:00:00 2001 From: Tim Nunamaker Date: Fri, 17 Jul 2026 18:32:00 -0500 Subject: [PATCH 5/6] feat: vendor standalone playwright-runner so connectors run without an external repo Copies data-connect's standalone playwright-runner into this repo (playwright-runner/) and neutralizes its remaining vana/dataconnect naming to pdp-connect equivalents. run-connector.cjs, test-connector.cjs, and capture-session.cjs now resolve the bundled runner first, before any external-repo fallback. Also fixes a protocol gap the vendored runner's stricter contract exposed: run-connector.cjs and both test-connector.cjs copies never sent requestedScopes on the `run` command, which the runner now requires. Both now derive it from the connector's sibling metadata JSON. setup.sh no longer clones vana-com/data-connect; it installs the bundled runner's own dependencies (just playwright) and Chromium in place. README, SETUP.md, and SKILL.md are updated to describe the bundled-runner flow as primary, with PLAYWRIGHT_RUNNER_DIR/--runner-dir now an optional override rather than a required step. Verified: `node run-connector.cjs connectors/github/github-playwright.js` boots the runner, launches real Chromium, navigates to github.com/login, and executes the connector's real page-API calls end to end (fails on auth as expected with no live session). The vendored runner's own tests pass (3/3, excluding one conformance test whose corpus fixture wasn't part of the standalone runner extraction). All repo-wide checks (connector-index, fixture-index, scope-catalog) and existing test suites pass unchanged. playwright-runner/node_modules/ is gitignored; contributors run `npm install` inside playwright-runner/ (or skills/pdp-connect/scripts/setup.sh) once. Signed-off-by: Tim Nunamaker --- .gitignore | 3 + README.md | 23 +- playwright-runner/index.cjs | 1251 +++++++++++++++++ playwright-runner/package.json | 24 + playwright-runner/result-classifier.cjs | 294 ++++ .../result-classifier.conformance.test.cjs | 51 + playwright-runner/result-classifier.test.cjs | 116 ++ playwright-runner/runner-compat.cjs | 217 +++ playwright-runner/runner-compat.test.cjs | 143 ++ playwright-runner/zip-reader.cjs | 54 + playwright-runner/zip-reader.test.cjs | 126 ++ scripts/capture-session.cjs | 2 + scripts/test-connector.cjs | 13 +- skills/pdp-connect/SETUP.md | 15 +- skills/pdp-connect/SKILL.md | 6 +- skills/pdp-connect/scripts/run-connector.cjs | 33 +- skills/pdp-connect/scripts/setup.sh | 55 +- test-connector.cjs | 13 +- 18 files changed, 2381 insertions(+), 58 deletions(-) create mode 100644 playwright-runner/index.cjs create mode 100644 playwright-runner/package.json create mode 100644 playwright-runner/result-classifier.cjs create mode 100644 playwright-runner/result-classifier.conformance.test.cjs create mode 100644 playwright-runner/result-classifier.test.cjs create mode 100644 playwright-runner/runner-compat.cjs create mode 100644 playwright-runner/runner-compat.test.cjs create mode 100644 playwright-runner/zip-reader.cjs create mode 100644 playwright-runner/zip-reader.test.cjs diff --git a/.gitignore b/.gitignore index 4ca591d..2fb7318 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,6 @@ test-results/ # Non-connector projects (stray checkouts) clearcut/ proxy-test/ + +# Vendored playwright-runner's own install +playwright-runner/node_modules/ diff --git a/README.md b/README.md index 2491ce3..336bffc 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # Data Connectors -Playwright-based data connectors for local desktop collection in [Unity Surfaces](https://github.com/vana-com/unity-surfaces). Each connector exports your data from a web platform using browser automation. Credentials never leave your device. +Playwright-based data connectors for local desktop collection. Each connector exports your data from a web platform using browser automation. Credentials never leave your device. + +This repo bundles its own standalone runner (`playwright-runner/`) -- no other repo is required to run a connector. See [Running a connector](#running-a-connector) below. ## Connector status @@ -58,12 +60,15 @@ See the [Context Gateway docs](https://dev.opendatalabs.com/docs/data-sources) f ## Running a connector ```bash +# One-time setup: install the bundled runner's dependencies + Chromium +bash skills/pdp-connect/scripts/setup.sh + node run-connector.cjs ./connectors/github/github-playwright.js # JSON output (for agents) node run-connector.cjs ./connectors/github/github-playwright.js --pretty # colored output (for humans) node run-connector.cjs ./connectors/github/github-playwright.js --inputs '{"username":"x","password":"y"}' ``` -See [`skills/pdp-connect/`](skills/pdp-connect/) for the agent skill: setup, running, creating new connectors, and data recipes. +`run-connector.cjs` resolves the bundled `playwright-runner/` in this repo automatically -- no other repo needs to be cloned. See [`skills/pdp-connect/SETUP.md`](skills/pdp-connect/SETUP.md) for details, and [`skills/pdp-connect/`](skills/pdp-connect/) for the agent skill: setup, running, creating new connectors, and data recipes. ## Repository structure @@ -209,7 +214,7 @@ Both produce the same connector files (`connectors//-playwright.j ## How connectors work -Connectors run in a sandboxed Playwright browser managed by the local desktop runner in Unity Surfaces. The runner provides a `page` API object (not raw Playwright). The browser starts headless; connectors call `page.showBrowser()` when login is needed and `page.goHeadless()` after. +Connectors run in a sandboxed Playwright browser managed by the bundled runner (`playwright-runner/`) or, in the desktop app, an equivalent driver. The runner provides a `page` API object (not raw Playwright). The browser starts headless; connectors call `page.showBrowser()` when login is needed and `page.goHeadless()` after. ### Two-phase architecture @@ -366,7 +371,7 @@ See [`skills/pdp-connect/CREATE.md`](skills/pdp-connect/CREATE.md) for the full ## Page API reference -The `page` object is available as a global in connector scripts. The runner implementation lives in [Unity Surfaces](https://github.com/vana-com/unity-surfaces/tree/main/apps/desktop/playwright-runner). +The `page` object is available as a global in connector scripts. The runner implementation is bundled in this repo at [`playwright-runner/`](playwright-runner/). | Method | Description | |--------|-------------| @@ -414,7 +419,7 @@ const { email, password } = await page.requestInput({ }); ``` -The runner relays the request to the driver (Tauri app, agent, CLI) and resolves with the response. The `schema` field uses JSON Schema — the same format used by OpenAI, Anthropic, and Google for LLM tool definitions. See the [headless-first runner spec](https://github.com/vana-com/unity-surfaces/blob/main/apps/desktop/docs/260310-headless-first-runner-spec.md) for the full protocol design. +The runner relays the request to the driver (Tauri app, agent, CLI) and resolves with the response. The `schema` field uses JSON Schema — the same format used by OpenAI, Anthropic, and Google for LLM tool definitions. ### Progress reporting @@ -435,17 +440,11 @@ await page.setProgress({ ## Testing locally -Desktop app development belongs to [Unity Surfaces](https://github.com/vana-com/unity-surfaces/tree/main/apps/desktop). Follow that repo's Desktop instructions for app setup, connector syncing, and runtime debugging. - ### Standalone test runner -Test connectors without the full desktop app. The runner spawns `playwright-runner` as a child process and outputs JSON protocol messages. - -**Prerequisites:** Clone [Unity Surfaces](https://github.com/vana-com/unity-surfaces) alongside this repo, then point `PLAYWRIGHT_RUNNER_DIR` at its Desktop runner. +Test connectors without any other app or repo. `run-connector.cjs` spawns the bundled `playwright-runner/` (see [Running a connector](#running-a-connector) for one-time setup) as a child process and outputs JSON protocol messages. ```bash -export PLAYWRIGHT_RUNNER_DIR=../unity-surfaces/apps/desktop/playwright-runner - # Run a connector (headed by default, browser visible) node run-connector.cjs ./connectors/linkedin/linkedin-playwright.js diff --git a/playwright-runner/index.cjs b/playwright-runner/index.cjs new file mode 100644 index 0000000..c7a88b4 --- /dev/null +++ b/playwright-runner/index.cjs @@ -0,0 +1,1251 @@ +/** + * Playwright Runner for PDP-Connect + * + * Runs as a sidecar process, receives commands via stdin, sends results via stdout. + * + * Commands: + * - { type: "run", runId, connectorPath, url, headless, allowHeaded, requestedScopes } + * - { type: "stop", runId } + * - { type: "evaluate", runId, script } + * - { type: "input-response", runId, requestId, data?, error? } + * - { type: "screenshot", runId } + * - { type: "quit" } + * + * Supports two-phase connectors: + * - Phase 1 (Browser): Login detection + credential extraction + * - Phase 2 (Background): Direct HTTP fetch without browser + */ + +const { chromium } = require('playwright'); +const fs = require('fs'); +const os = require('os'); +const readline = require('readline'); +const path = require('path'); +const { execSync } = require('child_process'); +const { readZipJsonEntries } = require('./zip-reader.cjs'); +const { classifyConnectorResult } = require('./result-classifier.cjs'); +const { + normalizeConnectorResult, + resolveHeadlessResumeUrl, +} = require('./runner-compat.cjs'); + +// System Chrome paths by platform +const CHROME_PATHS = { + darwin: '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + win32: 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe', + linux: '/usr/bin/google-chrome' +}; + +// Get browser cache directory - checks multiple candidate paths +function getBrowserCacheDir() { + if (process.env.PLAYWRIGHT_BROWSERS_PATH) { + log(`Using PLAYWRIGHT_BROWSERS_PATH: ${process.env.PLAYWRIGHT_BROWSERS_PATH}`); + return process.env.PLAYWRIGHT_BROWSERS_PATH; + } + const home = process.env.HOME || process.env.USERPROFILE || ''; + const candidates = [ + path.join(home, '.pdp-connect', 'browsers'), + path.join(home, '.pdp-connect', 'playwright-runner', 'node_modules', 'playwright-core', '.local-browsers'), + ]; + for (const dir of candidates) { + if (fs.existsSync(dir)) { + log(`Found browser cache: ${dir}`); + return dir; + } + } + return candidates[0]; +} + +// Check if system Chrome exists +function getSystemChromePath() { + const chromePath = CHROME_PATHS[process.platform]; + log(`Checking system Chrome at: ${chromePath}`); + if (chromePath && fs.existsSync(chromePath)) { + log(`Found system Chrome: ${chromePath}`); + return chromePath; + } + log(`System Chrome not found at default path`); + // Try alternative Windows paths + if (process.platform === 'win32') { + const altPaths = [ + 'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe', + path.join(process.env.LOCALAPPDATA || '', 'Google\\Chrome\\Application\\chrome.exe') + ]; + for (const p of altPaths) { + if (fs.existsSync(p)) return p; + } + } + // Try Edge on Windows + if (process.platform === 'win32') { + const edgePath = 'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe'; + if (fs.existsSync(edgePath)) return edgePath; + } + return null; +} + +// Check if Playwright Chromium is already downloaded (or bundled via PLAYWRIGHT_BROWSERS_PATH) +function getDownloadedChromiumPath() { + const cacheDir = getBrowserCacheDir(); + log(`Checking for Chromium in: ${cacheDir}`); + if (!fs.existsSync(cacheDir)) { + log(`Browser cache dir does not exist: ${cacheDir}`); + return null; + } + + // Look for chromium directory + const entries = fs.readdirSync(cacheDir); + const chromiumDir = entries.find(e => e.startsWith('chromium-') && !e.includes('headless')); + if (!chromiumDir) return null; + + const chromiumPath = path.join(cacheDir, chromiumDir); + + // Platform-specific executable paths (Playwright's "Chrome for Testing" structure) + if (process.platform === 'darwin') { + // Try arm64 first, then x64 + const paths = [ + path.join(chromiumPath, 'chrome-mac-arm64', 'Google Chrome for Testing.app', 'Contents', 'MacOS', 'Google Chrome for Testing'), + path.join(chromiumPath, 'chrome-mac', 'Google Chrome for Testing.app', 'Contents', 'MacOS', 'Google Chrome for Testing'), + // Legacy paths + path.join(chromiumPath, 'chrome-mac-arm64', 'Chromium.app', 'Contents', 'MacOS', 'Chromium'), + path.join(chromiumPath, 'chrome-mac', 'Chromium.app', 'Contents', 'MacOS', 'Chromium'), + ]; + for (const p of paths) { + if (fs.existsSync(p)) return p; + } + } else if (process.platform === 'win32') { + const paths = [ + path.join(chromiumPath, 'chrome-win', 'chrome.exe'), + path.join(chromiumPath, 'chrome-win64', 'chrome.exe'), + ]; + for (const p of paths) { + if (fs.existsSync(p)) return p; + } + } else { + const paths = [ + path.join(chromiumPath, 'chrome-linux', 'chrome'), + path.join(chromiumPath, 'chrome-linux64', 'chrome'), + ]; + for (const p of paths) { + if (fs.existsSync(p)) return p; + } + } + + return null; +} + +// Default Chrome user-data directories by platform +const CHROME_PROFILE_DIRS = { + darwin: path.join(process.env.HOME || '', 'Library', 'Application Support', 'Google', 'Chrome'), + win32: path.join(process.env.LOCALAPPDATA || '', 'Google', 'Chrome', 'User Data'), + linux: path.join(process.env.HOME || '', '.config', 'google-chrome'), +}; + +// Check whether a browser path points to system Chrome (not Playwright Chromium). +function isSystemChrome(browserPath) { + if (!browserPath) return false; + const lower = browserPath.toLowerCase(); + if (lower.includes('.databridge') || lower.includes('chromium') || lower.includes('chrome for testing')) { + return false; + } + return true; +} + +// Get the Chrome last-used profile directory path. +function getChromeProfileDir(chromeRoot) { + const localStatePath = path.join(chromeRoot, 'Local State'); + if (fs.existsSync(localStatePath)) { + try { + const localState = JSON.parse(fs.readFileSync(localStatePath, 'utf-8')); + const lastUsed = localState?.profile?.last_used; + if (lastUsed) { + const profileDir = path.join(chromeRoot, lastUsed); + if (fs.existsSync(profileDir)) { + log(`Chrome last-used profile: "${lastUsed}"`); + return profileDir; + } + } + } catch (e) { + log(`Warning: could not read Chrome Local State: ${e.message}`); + } + } + + const defaultDir = path.join(chromeRoot, 'Default'); + if (fs.existsSync(defaultDir)) return defaultDir; + return null; +} + +// Import cookies from the user's Chrome profile into a running Playwright +// browser context's Cookies database. This is done AFTER Chrome creates its +// own fresh profile, so we INSERT into Chrome's own db rather than replacing it +// (which Chrome would wipe on startup). +// +// The encrypted_value blobs use the same Keychain key (v10 format), so Chrome +// can decrypt them transparently — no Keychain popup needed since Chrome itself +// is the one reading them. +function importChromecookies(userDataDir, browserPath) { + if (!isSystemChrome(browserPath)) return; + + // Only import once + const markerFile = path.join(userDataDir, '.cookies-imported'); + if (fs.existsSync(markerFile)) { + log('Skipping cookie import — already done'); + return; + } + + const chromeRoot = CHROME_PROFILE_DIRS[process.platform]; + if (!chromeRoot || !fs.existsSync(chromeRoot)) return; + + const sourceProfileDir = getChromeProfileDir(chromeRoot); + if (!sourceProfileDir) return; + + const sourceCookies = path.join(sourceProfileDir, 'Cookies'); + if (!fs.existsSync(sourceCookies)) return; + + // Find the target Cookies db — Chrome creates it inside "Default/" by default + const targetCookies = path.join(userDataDir, 'Default', 'Cookies'); + if (!fs.existsSync(targetCookies)) { + log('Skipping cookie import — target Cookies db not found yet'); + return; + } + + try { + // Use sqlite3 to INSERT cookies from source into the target db. + // ATTACH the source db, then INSERT OR IGNORE to avoid duplicates. + const sql = ` + ATTACH DATABASE '${sourceCookies.replace(/'/g, "''")}' AS src; + INSERT OR REPLACE INTO cookies + SELECT * FROM src.cookies; + DETACH DATABASE src; + `; + execSync(`sqlite3 "${targetCookies}" "${sql}"`, { + encoding: 'utf-8', + timeout: 10000, + }); + + // Verify + const count = execSync( + `sqlite3 "${targetCookies}" "SELECT COUNT(*) FROM cookies;"`, + { encoding: 'utf-8' } + ).trim(); + log(`Imported cookies into profile — total cookies now: ${count}`); + + fs.writeFileSync(markerFile, new Date().toISOString()); + } catch (e) { + log(`Warning: could not import Chrome cookies: ${e.message}`); + } +} + +// Download Chromium using Playwright +async function downloadChromium(sendStatus) { + const cacheDir = getBrowserCacheDir(); + + // Create cache directory + if (!fs.existsSync(cacheDir)) { + fs.mkdirSync(cacheDir, { recursive: true }); + } + + log('Downloading Chromium browser (one-time setup)...'); + if (sendStatus) { + sendStatus('DOWNLOADING_BROWSER'); + } + + // Set environment for Playwright to use our cache dir + process.env.PLAYWRIGHT_BROWSERS_PATH = cacheDir; + + try { + // Use Playwright's CLI to download Chromium + execSync('npx playwright install chromium', { + stdio: 'inherit', + env: { ...process.env, PLAYWRIGHT_BROWSERS_PATH: cacheDir } + }); + log('Chromium download complete'); + return getDownloadedChromiumPath(); + } catch (error) { + log('Failed to download Chromium:', error.message); + throw new Error('Failed to download browser. Please install Google Chrome or try again.'); + } +} + +// Active browser contexts by runId +const activeRuns = new Map(); + +// Send message to parent process +function send(msg) { + process.stdout.write(JSON.stringify(msg) + '\n'); +} + +function drainStdout() { + return new Promise(resolve => { + if (process.stdout.writableNeedDrain) { + process.stdout.once('drain', resolve); + } else { + process.stdout.write('', resolve); + } + }); +} + +// Log to stderr (doesn't interfere with JSON protocol) +function log(...args) { + console.error('[PlaywrightRunner]', ...args); +} + +function isCanonicalScopeId(scope) { + return ( + typeof scope === 'string' && + scope.length > 0 && + scope.includes('.') && + !scope.startsWith('.') && + !scope.endsWith('.') + ); +} + +function normalizeRequestedScopes(requestedScopes) { + if (!Array.isArray(requestedScopes) || requestedScopes.length === 0) { + throw new Error('Resolved requestedScopes must be a non-empty array'); + } + + const deduped = []; + const seen = new Set(); + for (const scope of requestedScopes) { + if (!isCanonicalScopeId(scope)) { + throw new Error(`Resolved requestedScopes contains a non-canonical scope id: ${String(scope)}`); + } + if (!seen.has(scope)) { + seen.add(scope); + deduped.push(scope); + } + } + + if (deduped.length === 0) { + throw new Error('Resolved requestedScopes must contain at least one canonical scope id'); + } + + return deduped; +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function shouldRetryNavigationError(error) { + const message = String(error && error.message ? error.message : error || '').toLowerCase(); + return ( + message.includes('err_name_not_resolved') || + message.includes('err_internet_disconnected') || + message.includes('err_network_changed') || + message.includes('err_connection_reset') || + message.includes('err_connection_closed') || + message.includes('timeout') + ); +} + +async function gotoWithRetries(page, url, options = {}) { + const { + attempts = 3, + delayMs = 1500, + context = 'navigation', + waitUntil = 'domcontentloaded', + timeout, + } = options; + + const gotoOptions = { waitUntil }; + if (timeout != null) { + gotoOptions.timeout = timeout; + } + + let lastError = null; + for (let attempt = 1; attempt <= attempts; attempt += 1) { + try { + await page.goto(url, gotoOptions); + return; + } catch (error) { + lastError = error; + if (attempt === attempts || !shouldRetryNavigationError(error)) { + throw error; + } + log( + `${context} attempt ${attempt}/${attempts} failed for ${url}: ${error.message}. Retrying...`, + ); + await sleep(delayMs * attempt); + } + } + + throw lastError; +} + +function storeConnectorResult(runState, result) { + const normalizedResult = normalizeConnectorResult(result, { + requestedScopes: runState.requestedScopes, + }); + const classification = classifyConnectorResult(normalizedResult, { + expectedRequestedScopes: runState.requestedScopes, + }); + + if (classification.outcome === 'failure') { + log( + `Connector result classified as failure: ${classification.errorClass}` + + (classification.debug ? ` (${classification.debug})` : ''), + ); + } + + runState.resultEnvelope = { + classification, + rawResult: normalizedResult, + }; + + return runState.resultEnvelope; +} + +function emitStoredConnectorResult(runId, runState) { + if (!runState.resultEnvelope) { + return null; + } + + const { classification, rawResult } = runState.resultEnvelope; + const message = { + type: 'result', + runId, + classification, + }; + + if (classification.outcome === 'success' || classification.outcome === 'partial') { + message.data = rawResult; + } + + send(message); + return classification; +} + +// Resolve browser executable path +function resolveBrowserPath() { + let browserPath = null; + + if (!process.env.PDP_CONNECT_SIMULATE_NO_CHROME) { + browserPath = getSystemChromePath(); + } else { + log('PDP_CONNECT_SIMULATE_NO_CHROME is set, skipping system Chrome detection'); + } + + if (!browserPath) { + browserPath = getDownloadedChromiumPath(); + } + + if (!browserPath) { + throw new Error('No browser available. The Rust backend should have downloaded Chromium before starting the connector.'); + } + + return browserPath; +} + +// Launch a persistent browser context +async function launchPersistentContext(userDataDir, headless, browserPath) { + // Ensure profile directory exists + fs.mkdirSync(userDataDir, { recursive: true }); + + const launchOptions = { + headless, + args: [ + '--disable-blink-features=AutomationControlled', + '--disable-features=MediaRouter,DialMediaRouteProvider', + ], + viewport: { width: 1280, height: 800 }, + userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36', + }; + + if (browserPath) { + launchOptions.executablePath = browserPath; + } + + // When using system Chrome, disable Playwright's mock keychain so Chrome + // uses the real macOS Keychain. This lets it decrypt cookies imported from + // the user's real Chrome profile (both use the same "Chrome Safe Storage" + // Keychain entry). No popup — Chrome itself is already authorized. + if (isSystemChrome(browserPath)) { + launchOptions.ignoreDefaultArgs = ['--use-mock-keychain']; + } + + log(`Launching ${headless ? 'headless' : 'headed'} browser with profile: ${userDataDir}`); + const context = await chromium.launchPersistentContext(userDataDir, launchOptions); + log('Browser launched successfully'); + return context; +} + +// Create the page API that connectors use +function createPageApi(runState, runId) { + const networkCaptures = new Map(); + const capturedResponses = new Map(); + + // Helper to get current page, throw if browser is closed + function requirePage() { + if (runState.browserClosed || !runState.page) { + throw new Error('Browser is closed. Use page.httpFetch() for HTTP requests.'); + } + return runState.page; + } + + // Set up network interception on current page + function setupNetworkCapture(page) { + page.on('response', async (response) => { + const url = response.url(); + + for (const [key, config] of networkCaptures.entries()) { + if (config.urlPattern && !url.includes(config.urlPattern)) continue; + + try { + const request = response.request(); + const postData = request.postData() || ''; + + if (config.bodyPattern) { + const patterns = config.bodyPattern.split('|'); + if (!patterns.some(p => postData.includes(p))) continue; + } + + const body = await response.json().catch(() => null); + if (body) { + capturedResponses.set(key, { url, data: body, timestamp: Date.now() }); + send({ type: 'network-captured', runId, key, url }); + } + } catch (e) { + // Ignore errors for non-JSON responses + } + } + }); + } + + // Set up network capture on initial page + if (runState.page) { + setupNetworkCapture(runState.page); + } + + return { + // ── Canonical page API minimum surface ───────────────────────────── + // These are thin pass-throughs to the underlying Playwright page object. + // See types/connector.d.ts in this repo for the canonical contract. + + requestedScopes: () => { + return [...runState.requestedScopes]; + }, + + url: async () => { + const page = requirePage(); + return page.url(); + }, + + click: async (selector, options = {}) => { + const page = requirePage(); + await page.click(selector, options); + }, + + fill: async (selector, value, options = {}) => { + const page = requirePage(); + await page.fill(selector, value, options); + }, + + press: async (selector, key, options = {}) => { + const page = requirePage(); + await page.press(selector, key, options); + }, + + waitForSelector: async (selector, options = {}) => { + const page = requirePage(); + await page.waitForSelector(selector, options); + }, + + goto: async (url, options = {}) => { + const page = requirePage(); + log(`pageApi.goto called with: ${url}`); + send({ type: 'log', runId, message: `Navigating to: ${url}` }); + const { waitUntil = 'domcontentloaded', timeout } = options; + const gotoOpts = { waitUntil }; + if (timeout != null) gotoOpts.timeout = timeout; + try { + await page.goto(url, gotoOpts); + log('pageApi.goto completed successfully'); + } catch (err) { + log(`pageApi.goto error: ${err.message}`); + throw err; + } + }, + + evaluate: async (script) => { + const page = requirePage(); + return await page.evaluate(script); + }, + + screenshot: async () => { + const page = requirePage(); + const buffer = await page.screenshot({ type: 'jpeg', quality: 70, timeout: 5000 }); + return buffer.toString('base64'); + }, + + sleep: (ms) => new Promise(resolve => setTimeout(resolve, ms)), + + setData: async (key, value) => { + if (key === 'status') { + send({ type: 'log', runId, message: value }); + log(`[status] ${value}`); + } else if (key === 'error') { + log(`[error] ${value}`); + } else if (key === 'result') { + storeConnectorResult(runState, value); + } + send({ type: 'data', runId, key, value }); + }, + + // Structured progress update — drives the frontend progress UI + setProgress: async ({ phase, message, count }) => { + send({ type: 'status', runId, status: { type: 'COLLECTING', message, phase, count } }); + if (message) log(`[progress] ${message}`); + }, + + promptUser: async (message, checkFn, interval = 2000) => { + send({ type: 'log', runId, message }); + send({ type: 'status', runId, status: 'WAITING_FOR_USER' }); + + // Poll until condition is met + while (true) { + await new Promise(resolve => setTimeout(resolve, interval)); + try { + const result = await checkFn(); + if (result) { + send({ type: 'log', runId, message: 'User action completed' }); + return; + } + } catch (e) { + // Keep waiting + } + } + }, + + captureNetwork: async (config) => { + networkCaptures.set(config.key, { + urlPattern: config.urlPattern || '', + bodyPattern: config.bodyPattern || '' + }); + log(`Registered network capture: ${config.key}`); + }, + + getCapturedResponse: async (key) => { + const captured = capturedResponses.get(key); + return captured ? captured : null; + }, + + clearNetworkCaptures: async () => { + networkCaptures.clear(); + capturedResponses.clear(); + }, + + hasCapturedResponse: (key) => { + return capturedResponses.has(key); + }, + + // Close the browser but keep the Node.js process alive for background HTTP work. + // Cookies/session persist in the profile directory for next run. + closeBrowser: async () => { + if (runState.browserClosed) { + log('Browser already closed'); + return; + } + + log('Closing browser (connector requested closeBrowser)'); + + // Extract cookies before closing so httpFetch can use them + if (runState.context) { + try { + runState.cookies = await runState.context.cookies(); + log(`Extracted ${runState.cookies.length} cookies for background HTTP requests`); + } catch (e) { + log('Warning: could not extract cookies:', e.message); + runState.cookies = []; + } + } + + runState.browserClosed = true; + runState.browserClosedByConnector = true; + + if (runState.context) { + try { + await runState.context.close(); + } catch (e) { + log('Error closing context:', e.message); + } + runState.context = null; + runState.page = null; + } + + send({ type: 'log', runId, message: 'Browser closed, continuing in background...' }); + log('Browser closed, process stays alive for background work'); + }, + + // Escalate to headed mode for live human interaction (e.g., interactive CAPTCHAs). + // Gated by allowHeaded capability — if the driver doesn't support headed mode, + // navigates in the existing headless browser and returns { headed: false }. + showBrowser: async (url) => { + log('showBrowser requested'); + + if (runState.browserClosed) { + log('showBrowser called but browser is already closed'); + return { headed: false }; + } + + if (!runState.allowHeaded) { + log('Headed mode not available — navigating headless'); + if (url && runState.page) { + try { + await runState.page.goto(url, { waitUntil: 'domcontentloaded' }); + } catch (e) { + log(`showBrowser headless navigation failed: ${e.message}`); + } + } + send({ type: 'log', runId, message: 'Headed interaction unavailable — staying headless' }); + return { headed: false }; + } + + // Close existing browser if open + if (runState.context && !runState.browserClosed) { + log('Closing existing browser before reopening headed'); + runState.browserClosedByConnector = true; + try { + await runState.context.close(); + } catch (e) { + log('Error closing existing context:', e.message); + } + runState.context = null; + runState.page = null; + } + + // Launch new headed browser with persistent context + runState.browserClosed = false; + runState.browserClosedByConnector = false; + runState.headless = false; + const context = await launchPersistentContext(runState.userDataDir, false, runState.browserPath); + const page = context.pages()[0] || await context.newPage(); + + // Set up disconnect handler + context.browser().on('disconnected', () => { + if (!runState.connectorCompleted && !runState.browserClosedByConnector) { + log(`Browser disconnected for run ${runId} (user closed window)`); + runState.browserClosed = true; + runState.context = null; + runState.page = null; + activeRuns.delete(runId); + send({ type: 'status', runId, status: 'STOPPED' }); + drainStdout().then(() => process.exit(0)); + } + }); + + // Update state + runState.context = context; + runState.page = page; + + // Re-setup network capture on new page + setupNetworkCapture(page); + + // Navigate to URL + if (url) { + await page.goto(url, { waitUntil: 'domcontentloaded' }); + } + + send({ type: 'log', runId, message: 'Browser opened for user interaction' }); + log('Headed browser opened'); + return { headed: true }; + }, + + // Switch to headless mode — browser becomes invisible but stays running. + // Use this after credentials are captured so the user doesn't see the browser + // during data collection, while preserving the TLS fingerprint for Cloudflare. + goHeadless: async (options = {}) => { + if (runState.headless && !runState.browserClosed) { + log('Already in headless mode'); + return; + } + + log('Switching to headless mode'); + const currentUrl = runState.page ? runState.page.url() : null; + const resumeUrl = resolveHeadlessResumeUrl({ + resumeUrl: options.resumeUrl, + currentUrl, + }); + + // Close existing headed browser + if (runState.context && !runState.browserClosed) { + runState.browserClosedByConnector = true; + try { + await runState.context.close(); + } catch (e) { + log('Error closing headed context:', e.message); + } + runState.context = null; + runState.page = null; + } + + // Reopen headless browser with persistent context + runState.browserClosed = false; + runState.browserClosedByConnector = false; + runState.headless = true; + const context = await launchPersistentContext(runState.userDataDir, true, runState.browserPath); + const page = context.pages()[0] || await context.newPage(); + + // Set up disconnect handler + context.browser().on('disconnected', () => { + if (!runState.connectorCompleted && !runState.browserClosedByConnector) { + log(`Browser disconnected for run ${runId}`); + runState.browserClosed = true; + runState.context = null; + runState.page = null; + activeRuns.delete(runId); + send({ type: 'status', runId, status: 'STOPPED' }); + drainStdout().then(() => process.exit(0)); + } + }); + + // Update state + runState.context = context; + runState.page = page; + + // Re-setup network capture on new page + setupNetworkCapture(page); + + // Restore the current browser location when possible so connectors that + // authenticate in headed mode can resume collection without re-implementing + // their own login handoff logic. + await gotoWithRetries(page, resumeUrl, { + context: 'goHeadless resume navigation', + }); + + send({ type: 'log', runId, message: 'Switched to headless mode for background data collection' }); + log(`Switched to headless mode (resumeUrl: ${resumeUrl})`); + }, + + // Direct HTTP fetch from Node.js — no browser needed. + // Works after closeBrowser() for background data collection. + // Automatically includes cookies extracted from the browser session. + httpFetch: async (url, options = {}) => { + const { timeout = 30000, ...fetchOptions } = options; + + // Auto-include cookies from the closed browser session + if (runState.cookies && runState.cookies.length > 0) { + try { + const urlObj = new URL(url); + const relevantCookies = runState.cookies + .filter(c => { + const cookieDomain = c.domain.startsWith('.') ? c.domain.slice(1) : c.domain; + return urlObj.hostname === cookieDomain || urlObj.hostname.endsWith('.' + cookieDomain); + }) + .map(c => `${c.name}=${c.value}`) + .join('; '); + if (relevantCookies) { + fetchOptions.headers = { ...fetchOptions.headers, cookie: relevantCookies }; + } + } catch (e) { + // Ignore cookie injection errors + } + } + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeout); + try { + const response = await fetch(url, { + ...fetchOptions, + signal: controller.signal, + }); + clearTimeout(timeoutId); + const text = await response.text(); + let json = null; + try { json = JSON.parse(text); } catch {} + if (!response.ok) { + log(`[httpFetch] ${response.status} ${response.statusText} for ${url.substring(0, 100)}`); + log(`[httpFetch] Response body (first 200 chars): ${text.substring(0, 200)}`); + } + return { + ok: response.ok, + status: response.status, + headers: Object.fromEntries(response.headers.entries()), + text, + json, + error: null, + }; + } catch (err) { + clearTimeout(timeoutId); + return { + ok: false, + status: 0, + headers: {}, + text: '', + json: null, + error: err.message, + }; + } + }, + + // Trigger a browser download by navigating to `url` and capture it to a temp + // file. Works for both direct attachment URLs (navigation aborts, download + // fires) and SPA-mediated downloads (the page hydrates and JS starts the + // download). Returns { ok, ready, path, name, size }. ready:false means no + // download fired within the timeout (e.g. an async export still preparing). + captureDownload: async (url, options = {}) => { + const page = requirePage(); + const timeout = options.timeout || 60000; + const downloadPromise = page.waitForEvent('download', { timeout }).catch(() => null); + try { + await page.goto(url, { timeout, waitUntil: 'commit' }); + } catch (e) { + // ERR_ABORTED is expected when the URL serves a direct attachment. + } + const download = await downloadPromise; + if (!download) return { ok: false, ready: false, error: 'no download within timeout' }; + try { + // Persist captured downloads to a durable, known location rather than a + // temp dir, so they survive as a local archive (e.g. the raw Claude + // export ZIP a connector keeps out of the Personal Server). Caller can + // override with options.persistDir. + const dir = options.persistDir || path.join(os.homedir(), '.pdp-connect', 'raw-exports'); + fs.mkdirSync(dir, { recursive: true }); + const name = download.suggestedFilename() || 'download.bin'; + const dest = path.join(dir, name); + await download.saveAs(dest); + const size = fs.statSync(dest).size; + log(`[captureDownload] saved ${name} (${size} bytes) to ${dest}`); + return { ok: true, ready: true, path: dest, name, size }; + } catch (err) { + return { ok: false, ready: true, error: err.message }; + } + }, + + // Read JSON entries out of a ZIP on disk (e.g. a captured export archive). + // `options.include` is an optional list of name substrings to select; when + // omitted, all .json entries are parsed. Returns { ok, names, json }. + extractZipEntries: async (zipPath, options = {}) => { + try { + const buffer = fs.readFileSync(zipPath); + const { names, json } = readZipJsonEntries(buffer, options.include || null); + log(`[extractZipEntries] ${names.length} entries, ${Object.keys(json).length} json parsed from ${path.basename(zipPath)}`); + return { ok: true, names, json }; + } catch (err) { + return { ok: false, error: err.message }; + } + }, + }; +} + +// Run a connector +async function runConnector(runId, connectorPath, url, headless = true, allowHeaded = true, requestedScopes) { + log(`Starting run ${runId} with connector ${connectorPath} (headless: ${headless}, allowHeaded: ${allowHeaded})`); + + // Derive connector ID for persistent browser profile + const connectorFileName = path.basename(connectorPath, path.extname(connectorPath)); + const home = process.env.HOME || process.env.USERPROFILE || ''; + const userDataDir = path.join(home, '.pdp-connect', 'browser-profiles', connectorFileName); + + // Mutable state shared with pageApi + const runState = { + context: null, + page: null, + browserClosed: false, + browserClosedByConnector: false, + connectorCompleted: false, + headless, + allowHeaded, + userDataDir, + browserPath: null, + requestCounter: 0, + pendingInputs: new Map(), + requestedScopes: normalizeRequestedScopes(requestedScopes), + resultEnvelope: null, + }; + + try { + // Read connector script + const connectorCode = fs.readFileSync(connectorPath, 'utf-8'); + + // Resolve browser executable + runState.browserPath = resolveBrowserPath(); + log(`Using browser: ${runState.browserPath}`); + + // On first run, we need to: + // 1. Launch Chrome briefly so it creates its profile/Cookies db + // 2. Close it + // 3. INSERT cookies from the user's Chrome profile into the db + // 4. Relaunch — now Chrome loads the imported cookies from disk + const markerFile = path.join(userDataDir, '.cookies-imported'); + if (isSystemChrome(runState.browserPath) && !fs.existsSync(markerFile)) { + log('First run: launching browser to initialize profile...'); + const tempCtx = await launchPersistentContext(userDataDir, true, runState.browserPath); + await tempCtx.close(); + log('Profile initialized, importing cookies...'); + importChromecookies(userDataDir, runState.browserPath); + } + + // Launch browser with persistent context (cookies already in db on first run) + const context = await launchPersistentContext(userDataDir, headless, runState.browserPath); + const page = context.pages()[0] || await context.newPage(); + + runState.context = context; + runState.page = page; + + // Handle browser disconnect (user closed browser window) + context.browser().on('disconnected', () => { + if (!runState.connectorCompleted && !runState.browserClosedByConnector && activeRuns.has(runId)) { + log(`Browser disconnected for run ${runId} (user closed window)`); + runState.browserClosed = true; + runState.context = null; + runState.page = null; + activeRuns.delete(runId); + send({ type: 'status', runId, status: 'STOPPED' }); + drainStdout().then(() => process.exit(0)); + } + }); + + // Store for cleanup + activeRuns.set(runId, { + runState, + setCompleted: () => { runState.connectorCompleted = true; }, + }); + + // Create page API + const pageApi = createPageApi(runState, runId); + + // Navigate to starting URL + log(`Navigating to initial URL: ${url}`); + await gotoWithRetries(page, url, { + context: 'initial navigation', + }); + log('Initial navigation complete'); + send({ type: 'status', runId, status: 'RUNNING' }); + + // Build the connector execution wrapper + // The connector has an IIFE at the end - we need to return its Promise + // Find the LAST IIFE and add 'return' before it (there may be inner IIFEs in helpers) + let modifiedCode = connectorCode; + + // Match both a leading-newline IIFE and one that starts on line 1 + // (e.g. steam-playwright.js). An earlier version required `\n` as a + // leading anchor, which silently false-succeeded any script whose only + // top-level IIFE was on line 1 — the AsyncFunction would resolve to + // undefined instantly while the IIFE ran fire-and-forget. + const iifePattern = /(?:^|\n)\(async\s*\(\)\s*=>\s*\{/g; + const matches = [...modifiedCode.matchAll(iifePattern)]; + + if (matches.length > 0) { + const lastMatch = matches[matches.length - 1]; + const matchedText = lastMatch[0]; + const leadingNewline = matchedText.startsWith('\n'); + const insertPos = lastMatch.index; + const replacement = leadingNewline + ? '\nreturn (async () => {' + : 'return (async () => {'; + modifiedCode = modifiedCode.substring(0, insertPos) + + replacement + + modifiedCode.substring(insertPos + matchedText.length); + log(`Added return before IIFE (match ${matches.length} of ${matches.length})`); + } else { + log('WARNING: Could not find IIFE pattern in connector code'); + } + + // Execute connector with page API in scope using AsyncFunction + log('Starting connector execution...'); + const AsyncFunction = Object.getPrototypeOf(async function(){}).constructor; + const runConnectorFn = new AsyncFunction('page', modifiedCode); + + log('Calling connector function...'); + const result = await runConnectorFn.call(null, pageApi); + log('Connector function completed with result:', result != null ? 'has result' : 'undefined'); + + if (result !== undefined) { + storeConnectorResult(runState, result); + } else if (runState.resultEnvelope === null) { + storeConnectorResult(runState, result); + } + const classification = emitStoredConnectorResult(runId, runState); + + // Mark as completed to prevent disconnect handler from sending STOPPED + runState.connectorCompleted = true; + + // Close browser if still open + if (!runState.browserClosed && runState.context) { + await new Promise(resolve => setTimeout(resolve, 2000)); + try { + await runState.context.close(); + } catch (e) { + // Browser may already be closed + } + } + + activeRuns.delete(runId); + + // Exit process after successful completion + log( + 'Connector completed, classified outcome:', + classification ? classification.outcome : 'unknown', + ); + await drainStdout(); + process.exit(classification && classification.outcome === 'failure' ? 1 : 0); + + } catch (error) { + log(`Error in run ${runId}:`, error.message); + send({ type: 'error', runId, message: error.message }); + send({ type: 'status', runId, status: 'ERROR' }); + + // Cleanup on error + if (runState.context && !runState.browserClosed) { + try { + await runState.context.close(); + } catch (e) {} + } + activeRuns.delete(runId); + + // Exit process after error + log('Connector failed, exiting'); + await drainStdout(); + process.exit(1); + } +} + +// Stop a run +async function stopRun(runId) { + const run = activeRuns.get(runId); + if (run) { + log(`Stopping run ${runId}`); + // Reject any pending requestInput promises so the connector doesn't hang + for (const [, pending] of run.runState.pendingInputs) { + pending.reject(new Error('Run stopped')); + } + run.runState.pendingInputs.clear(); + if (run.runState && run.runState.context && !run.runState.browserClosed) { + await run.runState.context.close().catch(() => {}); + } + activeRuns.delete(runId); + send({ type: 'status', runId, status: 'STOPPED' }); + } +} + +// Main loop - read commands from stdin +async function main() { + log('Playwright runner started'); + send({ type: 'ready' }); + + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + terminal: false + }); + + for await (const line of rl) { + try { + const cmd = JSON.parse(line); + + switch (cmd.type) { + case 'run': + runConnector( + cmd.runId, + cmd.connectorPath, + cmd.url, + cmd.headless !== false, + cmd.allowHeaded !== false, + cmd.requestedScopes, + ); + break; + + case 'stop': + await stopRun(cmd.runId); + break; + + case 'quit': + log('Quitting...'); + for (const [runId, run] of activeRuns) { + if (run.runState && run.runState.context && !run.runState.browserClosed) { + await run.runState.context.close().catch(() => {}); + } + } + process.exit(0); + break; + + case 'test': + // Simple test to prove Node.js is working + const os = require('os'); + send({ + type: 'test-result', + data: { + nodejs: process.version, + platform: process.platform, + arch: process.arch, + hostname: os.hostname(), + cpus: os.cpus().length, + memory: Math.round(os.totalmem() / 1024 / 1024 / 1024) + ' GB', + uptime: Math.round(os.uptime() / 60) + ' minutes' + } + }); + break; + + case 'evaluate': { + const evalRun = activeRuns.get(cmd.runId); + if (!evalRun) { + send({ type: 'evaluate-result', runId: cmd.runId, error: `No active run: ${cmd.runId}` }); + break; + } + const { runState: evalState } = evalRun; + if (evalState.browserClosed || !evalState.page) { + send({ type: 'evaluate-result', runId: cmd.runId, error: 'Browser is closed' }); + break; + } + // Non-blocking: don't await so stdin loop keeps processing other commands. + // Wrapped in try so synchronous throws (e.g. page torn down mid-call) + // always produce an evaluate-result instead of hanging the driver. + try { + evalState.page.evaluate(cmd.script) + .then(result => send({ type: 'evaluate-result', runId: cmd.runId, result })) + .catch(e => send({ type: 'evaluate-result', runId: cmd.runId, error: e.stack || e.message })); + } catch (e) { + send({ type: 'evaluate-result', runId: cmd.runId, error: e.stack || e.message }); + } + break; + } + + case 'input-response': { + const inputRun = activeRuns.get(cmd.runId); + if (!inputRun) break; + const pending = inputRun.runState.pendingInputs.get(cmd.requestId); + if (!pending) break; + inputRun.runState.pendingInputs.delete(cmd.requestId); + if (cmd.error) { + pending.reject(new Error(typeof cmd.error === 'string' ? cmd.error : JSON.stringify(cmd.error))); + } else { + pending.resolve(cmd.data); + } + break; + } + + case 'screenshot': { + const ssRun = activeRuns.get(cmd.runId); + if (!ssRun) { + send({ type: 'screenshot-result', runId: cmd.runId, error: `No active run: ${cmd.runId}` }); + break; + } + const { runState: ssState } = ssRun; + if (ssState.browserClosed || !ssState.page) { + send({ type: 'screenshot-result', runId: cmd.runId, error: 'Browser is closed' }); + break; + } + try { + ssState.page.screenshot({ type: 'jpeg', quality: 70, timeout: 5000 }) + .then(buffer => send({ type: 'screenshot-result', runId: cmd.runId, data: buffer.toString('base64') })) + .catch(e => send({ type: 'screenshot-result', runId: cmd.runId, error: e.stack || e.message })); + } catch (e) { + send({ type: 'screenshot-result', runId: cmd.runId, error: e.stack || e.message }); + } + break; + } + + default: + log(`Unknown command: ${cmd.type}`); + } + } catch (error) { + log(`Error parsing command: ${error.message}`); + } + } +} + +main().catch(err => { + log('Fatal error:', err); + process.exit(1); +}); diff --git a/playwright-runner/package.json b/playwright-runner/package.json new file mode 100644 index 0000000..cb2f7c5 --- /dev/null +++ b/playwright-runner/package.json @@ -0,0 +1,24 @@ +{ + "name": "pdp-connect-playwright-runner", + "version": "1.0.0", + "description": "Playwright sidecar for PDP-Connect connectors", + "main": "index.cjs", + "type": "module", + "scripts": { + "start": "node index.cjs", + "build": "node scripts/build.js", + "build:lean": "node scripts/build.js --lean", + "postinstall": "playwright install chromium" + }, + "pkg": { + "assets": [], + "targets": ["node20-macos-arm64", "node20-macos-x64", "node20-win-x64", "node20-linux-x64"], + "outputPath": "dist" + }, + "dependencies": { + "playwright": "^1.40.0" + }, + "devDependencies": { + "@yao-pkg/pkg": "^5.12.0" + } +} diff --git a/playwright-runner/result-classifier.cjs b/playwright-runner/result-classifier.cjs new file mode 100644 index 0000000..6786725 --- /dev/null +++ b/playwright-runner/result-classifier.cjs @@ -0,0 +1,294 @@ +const CONNECTOR_RESULT_RESERVED_KEYS = new Set([ + 'requestedScopes', + 'timestamp', + 'version', + 'platform', + 'exportSummary', + 'errors', +]); + +const TELEMETRY_ERROR_CLASS_PRIORITY = [ + 'protocol_violation', + 'auth_failed', + 'rate_limited', + 'upstream_error', + 'navigation_error', + 'network_error', + 'timeout', + 'selector_error', + 'runtime_error', + 'personal_server_unavailable', + 'unknown', +]; + +function isPlainRecord(value) { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function protocolFailure(message, scopeSummary) { + return { + outcome: 'failure', + errorClass: 'protocol_violation', + scopeSummary: scopeSummary || { + requested: 0, + produced: 0, + degraded: 0, + omitted: 0, + }, + debug: `Protocol violation: ${message}`, + }; +} + +function dominantErrorClass(errors) { + for (const errorClass of TELEMETRY_ERROR_CLASS_PRIORITY) { + if (errors.some((error) => error.errorClass === errorClass)) { + return errorClass; + } + } + return 'unknown'; +} + +function hasSameMembers(a, b) { + if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) { + return false; + } + + const left = new Set(a); + const right = new Set(b); + if (left.size !== a.length || right.size !== b.length) { + return false; + } + + for (const value of left) { + if (!right.has(value)) { + return false; + } + } + return true; +} + +function validateConnectorErrors(rawErrors, requestedScopes) { + if (!Array.isArray(rawErrors)) { + return { protocolMessage: 'errors must be an array' }; + } + + const errors = []; + for (const rawError of rawErrors) { + if (!isPlainRecord(rawError)) { + return { protocolMessage: 'errors entries must be objects' }; + } + + const { errorClass, reason, disposition, scope, phase } = rawError; + if ( + typeof errorClass !== 'string' || + !TELEMETRY_ERROR_CLASS_PRIORITY.includes(errorClass) + ) { + return { protocolMessage: 'errors entries must use a known errorClass' }; + } + if (typeof reason !== 'string' || reason.trim().length === 0) { + return { protocolMessage: 'errors entries must include a non-empty reason' }; + } + if (disposition !== 'omitted' && disposition !== 'degraded' && disposition !== 'fatal') { + return { protocolMessage: 'errors entries must use omitted, degraded, or fatal disposition' }; + } + if ((disposition === 'omitted' || disposition === 'degraded') && typeof scope !== 'string') { + return { protocolMessage: `${disposition} errors must include a scope` }; + } + if (typeof scope === 'string' && scope.length > 0 && !requestedScopes.has(scope)) { + return { protocolMessage: `errors scope '${scope}' is outside requestedScopes` }; + } + if (phase !== undefined && typeof phase !== 'string') { + return { protocolMessage: 'errors phase must be a string when present' }; + } + + errors.push({ + errorClass, + reason, + disposition, + ...(typeof scope === 'string' ? { scope } : {}), + ...(typeof phase === 'string' ? { phase } : {}), + }); + } + + return { errors }; +} + +function isCanonicalScopeId(scope) { + return ( + typeof scope === 'string' && + scope.length > 0 && + scope.includes('.') && + !scope.startsWith('.') && + !scope.endsWith('.') + ); +} + +function classifyConnectorResult(result, options) { + if (!isPlainRecord(result)) { + return protocolFailure('connector result must be an object'); + } + + if ( + typeof result.success === 'boolean' && + (Object.prototype.hasOwnProperty.call(result, 'data') || + Object.prototype.hasOwnProperty.call(result, 'error')) + ) { + return protocolFailure('legacy { success, data } wrapper is not supported'); + } + + const requestedScopesValue = result.requestedScopes; + if (!Array.isArray(requestedScopesValue) || requestedScopesValue.length === 0) { + return protocolFailure('requestedScopes must be a non-empty array'); + } + + if (requestedScopesValue.some((scope) => !isCanonicalScopeId(scope))) { + return protocolFailure('requestedScopes must contain canonical scope ids'); + } + + const requestedScopes = requestedScopesValue.slice(); + const requestedScopeSet = new Set(requestedScopes); + if (requestedScopeSet.size !== requestedScopes.length) { + return protocolFailure('requestedScopes must be deduplicated'); + } + + if ( + options && + Array.isArray(options.expectedRequestedScopes) && + !hasSameMembers(requestedScopes, options.expectedRequestedScopes) + ) { + return protocolFailure('requestedScopes does not match the resolved execution scope set'); + } + + if (typeof result.timestamp !== 'string' || result.timestamp.length === 0) { + return protocolFailure('timestamp is required'); + } + if (typeof result.version !== 'string' || result.version.length === 0) { + return protocolFailure('version is required'); + } + if (typeof result.platform !== 'string' || result.platform.length === 0) { + return protocolFailure('platform is required'); + } + + if (!isPlainRecord(result.exportSummary)) { + return protocolFailure('exportSummary is required'); + } + + const exportSummary = result.exportSummary; + if ( + typeof exportSummary.count !== 'number' || + !Number.isFinite(exportSummary.count) || + exportSummary.count < 0 + ) { + return protocolFailure('exportSummary.count must be a non-negative number'); + } + if (typeof exportSummary.label !== 'string' || exportSummary.label.length === 0) { + return protocolFailure('exportSummary.label must be a string'); + } + if ( + exportSummary.details !== undefined && + !isPlainRecord(exportSummary.details) + ) { + return protocolFailure('exportSummary.details must be an object when present'); + } + + const { errors, protocolMessage } = validateConnectorErrors(result.errors, requestedScopeSet); + if (!errors) { + return protocolFailure(protocolMessage || 'errors is malformed'); + } + + const producedScopes = Object.keys(result).filter( + (key) => !CONNECTOR_RESULT_RESERVED_KEYS.has(key), + ); + for (const producedScope of producedScopes) { + if (!isCanonicalScopeId(producedScope)) { + return protocolFailure(`produced scope key '${producedScope}' is not canonical`); + } + if (!requestedScopeSet.has(producedScope)) { + return protocolFailure(`produced scope key '${producedScope}' is outside requestedScopes`); + } + } + + const producedScopeSet = new Set(producedScopes); + const degradedScopes = new Set( + errors + .filter((error) => error.disposition === 'degraded' && error.scope) + .map((error) => error.scope), + ); + const omittedScopes = new Set( + errors + .filter((error) => error.disposition === 'omitted' && error.scope) + .map((error) => error.scope), + ); + const fatalErrors = errors.filter((error) => error.disposition === 'fatal'); + + for (const scope of degradedScopes) { + if (!producedScopeSet.has(scope)) { + return protocolFailure(`degraded scope '${scope}' must also be present in the result`); + } + if (omittedScopes.has(scope)) { + return protocolFailure(`scope '${scope}' cannot be both degraded and omitted`); + } + } + + for (const scope of omittedScopes) { + if (producedScopeSet.has(scope)) { + return protocolFailure(`omitted scope '${scope}' must not be present in the result`); + } + } + + const scopeSummary = { + requested: requestedScopeSet.size, + produced: producedScopeSet.size, + degraded: degradedScopes.size, + omitted: omittedScopes.size, + }; + + if (fatalErrors.length > 0) { + return { + outcome: 'failure', + errorClass: dominantErrorClass(fatalErrors), + recordCount: exportSummary.count, + scopeSummary, + debug: fatalErrors[0] ? fatalErrors[0].reason : undefined, + }; + } + + for (const scope of requestedScopeSet) { + if (!producedScopeSet.has(scope) && !omittedScopes.has(scope)) { + return protocolFailure( + `requested scope '${scope}' must be produced or marked omitted`, + scopeSummary, + ); + } + } + + if (producedScopeSet.size === 0) { + return { + outcome: 'failure', + errorClass: dominantErrorClass(errors), + recordCount: exportSummary.count, + scopeSummary, + debug: errors[0] ? errors[0].reason : undefined, + }; + } + + if (errors.length === 0) { + return { + outcome: 'success', + recordCount: exportSummary.count, + scopeSummary, + }; + } + + return { + outcome: 'partial', + errorClass: dominantErrorClass(errors), + recordCount: exportSummary.count, + scopeSummary, + debug: errors[0] ? errors[0].reason : undefined, + }; +} + +module.exports = { + classifyConnectorResult, +}; diff --git a/playwright-runner/result-classifier.conformance.test.cjs b/playwright-runner/result-classifier.conformance.test.cjs new file mode 100644 index 0000000..725f600 --- /dev/null +++ b/playwright-runner/result-classifier.conformance.test.cjs @@ -0,0 +1,51 @@ +const assert = require('node:assert/strict'); +const { readFileSync } = require('node:fs'); +const { join } = require('node:path'); + +const { classifyConnectorResult } = require('./result-classifier.cjs'); + +const corpusDir = join(__dirname, 'conformance', 'honest-telemetry'); +const corpus = JSON.parse(readFileSync(join(corpusDir, 'corpus.json'), 'utf8')); + +for (const testCase of corpus.cases) { + const fixture = JSON.parse( + readFileSync(join(corpusDir, testCase.fixture), 'utf8'), + ); + const classification = classifyConnectorResult(fixture, { + expectedRequestedScopes: fixture.requestedScopes, + }); + + assert.equal( + classification.outcome, + testCase.expected.classification.outcome, + `${testCase.id}: outcome mismatch`, + ); + + if (testCase.expected.classification.errorClass) { + assert.equal( + classification.errorClass, + testCase.expected.classification.errorClass, + `${testCase.id}: errorClass mismatch`, + ); + } + + if (testCase.expected.classification.scopeSummary) { + assert.deepEqual( + classification.scopeSummary, + testCase.expected.classification.scopeSummary, + `${testCase.id}: scopeSummary mismatch`, + ); + } + + if (testCase.expected.validity === 'protocol_violation') { + assert.equal( + classification.errorClass, + 'protocol_violation', + `${testCase.id}: expected protocol violation`, + ); + } +} + +console.log( + `honest telemetry conformance passed (${corpus.cases.length} cases)`, +); diff --git a/playwright-runner/result-classifier.test.cjs b/playwright-runner/result-classifier.test.cjs new file mode 100644 index 0000000..4230ebc --- /dev/null +++ b/playwright-runner/result-classifier.test.cjs @@ -0,0 +1,116 @@ +const assert = require('node:assert/strict'); + +const { classifyConnectorResult } = require('./result-classifier.cjs'); + +function makeResult(overrides = {}) { + return { + requestedScopes: ['chatgpt.conversations'], + timestamp: '2026-04-14T12:00:00.000Z', + version: '1.2.3', + platform: 'chatgpt', + exportSummary: { + count: 1, + label: '1 conversation', + }, + errors: [], + 'chatgpt.conversations': [{ id: 'conv-1' }], + ...overrides, + }; +} + +function run() { + const success = classifyConnectorResult(makeResult(), { + expectedRequestedScopes: ['chatgpt.conversations'], + }); + assert.equal(success.outcome, 'success'); + assert.deepEqual(success.scopeSummary, { + requested: 1, + produced: 1, + degraded: 0, + omitted: 0, + }); + + const partial = classifyConnectorResult( + makeResult({ + requestedScopes: ['chatgpt.conversations', 'chatgpt.memories'], + errors: [ + { + errorClass: 'selector_error', + reason: 'Memories selector drifted', + disposition: 'omitted', + scope: 'chatgpt.memories', + }, + ], + }), + { + expectedRequestedScopes: ['chatgpt.conversations', 'chatgpt.memories'], + }, + ); + assert.equal(partial.outcome, 'partial'); + assert.equal(partial.errorClass, 'selector_error'); + assert.deepEqual(partial.scopeSummary, { + requested: 2, + produced: 1, + degraded: 0, + omitted: 1, + }); + + const fatalFailure = classifyConnectorResult( + makeResult({ + errors: [ + { + errorClass: 'auth_failed', + reason: 'Auth expired', + disposition: 'fatal', + }, + ], + }), + { + expectedRequestedScopes: ['chatgpt.conversations'], + }, + ); + assert.equal(fatalFailure.outcome, 'failure'); + assert.equal(fatalFailure.errorClass, 'auth_failed'); + + const legacyWrapper = classifyConnectorResult({ + success: true, + data: makeResult(), + }); + assert.equal(legacyWrapper.outcome, 'failure'); + assert.equal(legacyWrapper.errorClass, 'protocol_violation'); + assert.match(legacyWrapper.debug || '', /legacy \{ success, data \} wrapper/i); + + const overProduced = classifyConnectorResult( + makeResult({ + 'chatgpt.memories': [{ id: 'memory-1' }], + }), + { + expectedRequestedScopes: ['chatgpt.conversations'], + }, + ); + assert.equal(overProduced.outcome, 'failure'); + assert.equal(overProduced.errorClass, 'protocol_violation'); + assert.match(overProduced.debug || '', /outside requestedScopes/i); + + const malformedErrors = classifyConnectorResult( + makeResult({ + errors: [{ errorClass: 'selector_error', reason: 'bad', disposition: 'omitted' }], + }), + { + expectedRequestedScopes: ['chatgpt.conversations'], + }, + ); + assert.equal(malformedErrors.outcome, 'failure'); + assert.equal(malformedErrors.errorClass, 'protocol_violation'); + assert.match(malformedErrors.debug || '', /must include a scope/i); + + const scopeMismatch = classifyConnectorResult(makeResult(), { + expectedRequestedScopes: ['chatgpt.memories'], + }); + assert.equal(scopeMismatch.outcome, 'failure'); + assert.equal(scopeMismatch.errorClass, 'protocol_violation'); + assert.match(scopeMismatch.debug || '', /resolved execution scope set/i); +} + +run(); +console.log('result-classifier tests passed'); diff --git a/playwright-runner/runner-compat.cjs b/playwright-runner/runner-compat.cjs new file mode 100644 index 0000000..47818c9 --- /dev/null +++ b/playwright-runner/runner-compat.cjs @@ -0,0 +1,217 @@ +function isPlainRecord(value) { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function inferPlatformFromScopes(requestedScopes) { + if (!Array.isArray(requestedScopes)) { + return null; + } + + for (const scope of requestedScopes) { + if (typeof scope !== 'string') { + continue; + } + + const [platform] = scope.split('.', 1); + if (platform) { + return platform; + } + } + + return null; +} + +function isMeaningfulResumeUrl(url) { + if (typeof url !== 'string' || url.trim().length === 0) { + return false; + } + + try { + const parsed = new URL(url); + return parsed.protocol === 'http:' || parsed.protocol === 'https:'; + } catch { + return false; + } +} + +function resolveHeadlessResumeUrl(options = {}) { + const { resumeUrl, currentUrl } = options; + + if (isMeaningfulResumeUrl(resumeUrl)) { + return resumeUrl; + } + + if (isMeaningfulResumeUrl(currentUrl)) { + return currentUrl; + } + + return 'about:blank'; +} + +function inferLegacyErrorClass(reason) { + const normalizedReason = String(reason || '').toLowerCase(); + + if (/(login|sign in|sign-in|authenticate|authentication|auth|2fa|password|captcha|verification)/.test(normalizedReason)) { + return 'auth_failed'; + } + if (/(rate limit|too many requests|429)/.test(normalizedReason)) { + return 'rate_limited'; + } + if (/(timeout|timed out)/.test(normalizedReason)) { + return 'timeout'; + } + if (/(network|fetch failed|econn|socket|dns)/.test(normalizedReason)) { + return 'network_error'; + } + if (/(selector|locator)/.test(normalizedReason)) { + return 'selector_error'; + } + if (/(navigation|redirect|page crashed|about:blank)/.test(normalizedReason)) { + return 'navigation_error'; + } + + return 'runtime_error'; +} + +function normalizeProtocolDefaults(result, options) { + if (!isPlainRecord(result)) { + return result; + } + + const { requestedScopes = [], inferredPlatform } = options; + let changed = false; + const normalized = { ...result }; + + if (!Array.isArray(normalized.requestedScopes) || normalized.requestedScopes.length === 0) { + normalized.requestedScopes = [...requestedScopes]; + changed = true; + } + + if (!Array.isArray(normalized.errors)) { + normalized.errors = []; + changed = true; + } + + if ( + (typeof normalized.platform !== 'string' || normalized.platform.length === 0) && + inferredPlatform + ) { + normalized.platform = inferredPlatform; + changed = true; + } + + if (isPlainRecord(normalized.exportSummary) && normalized.exportSummary.details !== undefined) { + const details = normalized.exportSummary.details; + if (!isPlainRecord(details)) { + normalized.exportSummary = { ...normalized.exportSummary }; + if (typeof details === 'string') { + normalized.exportSummary.details = { text: details }; + } else if (Array.isArray(details)) { + normalized.exportSummary.details = { items: details }; + } else { + normalized.exportSummary.details = { value: details }; + } + changed = true; + } + } + + return changed ? normalized : result; +} + +function normalizeFailureReason(errorValue) { + if (typeof errorValue === 'string' && errorValue.trim().length > 0) { + return errorValue.trim(); + } + + if ( + isPlainRecord(errorValue) && + typeof errorValue.message === 'string' && + errorValue.message.trim().length > 0 + ) { + return errorValue.message.trim(); + } + + return 'Connector reported failure'; +} + +function synthesizeLegacyFailureResult(result, options) { + const { requestedScopes = [], inferredPlatform } = options; + const reason = normalizeFailureReason(result.error); + + return { + requestedScopes: [...requestedScopes], + timestamp: new Date().toISOString(), + version: + typeof result.version === 'string' && result.version.length > 0 + ? result.version + : 'legacy-compat', + platform: + typeof result.platform === 'string' && result.platform.length > 0 + ? result.platform + : inferredPlatform || 'unknown', + exportSummary: { + count: 0, + label: 'items', + }, + errors: [ + { + errorClass: inferLegacyErrorClass(reason), + reason, + disposition: 'fatal', + }, + ], + }; +} + +function normalizeConnectorResult(result, options = {}) { + const requestedScopes = Array.isArray(options.requestedScopes) + ? options.requestedScopes.filter((scope) => typeof scope === 'string' && scope.length > 0) + : []; + const inferredPlatform = inferPlatformFromScopes(requestedScopes); + + if (!isPlainRecord(result)) { + return result; + } + + const hasLegacyWrapper = + typeof result.success === 'boolean' && + (Object.prototype.hasOwnProperty.call(result, 'data') || + Object.prototype.hasOwnProperty.call(result, 'error')); + + if (!hasLegacyWrapper) { + return normalizeProtocolDefaults(result, { + requestedScopes, + inferredPlatform, + }); + } + + if (result.success) { + if (!isPlainRecord(result.data)) { + return synthesizeLegacyFailureResult( + { + ...result, + error: 'Legacy success wrapper must contain an object data payload', + }, + { + requestedScopes, + inferredPlatform, + }, + ); + } + + return normalizeProtocolDefaults(result.data, { + requestedScopes, + inferredPlatform, + }); + } + + return synthesizeLegacyFailureResult(result, { + requestedScopes, + inferredPlatform, + }); +} + +module.exports = { + normalizeConnectorResult, + resolveHeadlessResumeUrl, +}; diff --git a/playwright-runner/runner-compat.test.cjs b/playwright-runner/runner-compat.test.cjs new file mode 100644 index 0000000..4ccf3a3 --- /dev/null +++ b/playwright-runner/runner-compat.test.cjs @@ -0,0 +1,143 @@ +const assert = require('node:assert/strict'); + +const { classifyConnectorResult } = require('./result-classifier.cjs'); +const { + normalizeConnectorResult, + resolveHeadlessResumeUrl, +} = require('./runner-compat.cjs'); + +function makeLegacyPayload(overrides = {}) { + return { + timestamp: '2026-04-21T18:00:00.000Z', + version: '1.0.0-playwright', + platform: 'linkedin', + exportSummary: { + count: 1, + label: 'items', + }, + 'linkedin.profile': { + headline: 'Builder', + }, + ...overrides, + }; +} + +function run() { + assert.equal( + resolveHeadlessResumeUrl({ + resumeUrl: 'https://github.com/settings/profile', + currentUrl: 'https://github.com/', + }), + 'https://github.com/settings/profile', + ); + + assert.equal( + resolveHeadlessResumeUrl({ + resumeUrl: 'about:blank', + currentUrl: 'https://www.instagram.com/example/', + }), + 'https://www.instagram.com/example/', + ); + + assert.equal( + resolveHeadlessResumeUrl({ + currentUrl: 'chrome://settings', + }), + 'about:blank', + ); + + const normalizedWrappedSuccess = normalizeConnectorResult( + { + success: true, + data: makeLegacyPayload(), + }, + { + requestedScopes: ['linkedin.profile'], + }, + ); + assert.deepEqual(normalizedWrappedSuccess.requestedScopes, ['linkedin.profile']); + assert.deepEqual(normalizedWrappedSuccess.errors, []); + assert.equal( + classifyConnectorResult(normalizedWrappedSuccess, { + expectedRequestedScopes: ['linkedin.profile'], + }).outcome, + 'success', + ); + + const normalizedStringDetails = normalizeConnectorResult( + makeLegacyPayload({ + exportSummary: { + count: 1, + label: 'items', + details: 'x', + }, + }), + { + requestedScopes: ['linkedin.profile'], + }, + ); + assert.deepEqual(normalizedStringDetails.requestedScopes, ['linkedin.profile']); + assert.deepEqual(normalizedStringDetails.errors, []); + assert.equal( + classifyConnectorResult(normalizedStringDetails, { + expectedRequestedScopes: ['linkedin.profile'], + }).outcome, + 'success', + ); + assert.deepEqual(normalizedStringDetails.exportSummary.details, { text: 'x' }); + + const normalizedArrayDetails = normalizeConnectorResult( + makeLegacyPayload({ + exportSummary: { + count: 2, + label: 'items', + details: ['2 orders', '1 refund'], + }, + }), + { + requestedScopes: ['linkedin.profile'], + }, + ); + assert.deepEqual(normalizedArrayDetails.exportSummary.details, { + items: ['2 orders', '1 refund'], + }); + assert.equal( + classifyConnectorResult(normalizedArrayDetails, { + expectedRequestedScopes: ['linkedin.profile'], + }).outcome, + 'success', + ); + + const normalizedFailure = normalizeConnectorResult( + { + success: false, + error: 'Login requires a headed browser or requestInput support.', + }, + { + requestedScopes: ['instagram.profile'], + }, + ); + const failureClassification = classifyConnectorResult(normalizedFailure, { + expectedRequestedScopes: ['instagram.profile'], + }); + assert.equal(failureClassification.outcome, 'failure'); + assert.equal(failureClassification.errorClass, 'auth_failed'); + + const malformedLegacySuccess = normalizeConnectorResult( + { + success: true, + data: null, + }, + { + requestedScopes: ['github.profile'], + }, + ); + const malformedClassification = classifyConnectorResult(malformedLegacySuccess, { + expectedRequestedScopes: ['github.profile'], + }); + assert.equal(malformedClassification.outcome, 'failure'); + assert.equal(malformedClassification.errorClass, 'runtime_error'); +} + +run(); +console.log('runner-compat tests passed'); diff --git a/playwright-runner/zip-reader.cjs b/playwright-runner/zip-reader.cjs new file mode 100644 index 0000000..5c31965 --- /dev/null +++ b/playwright-runner/zip-reader.cjs @@ -0,0 +1,54 @@ +'use strict'; + +const zlib = require('zlib'); + +// Minimal, dependency-free ZIP reader (stored + deflate entries). +// Parses the central directory so it works on real archives without shelling +// out to an `unzip` binary (cross-platform). Returns { names, json } where +// `json` holds parsed JSON entries matching `include` (substring list, or all +// .json when null). +function readZipJsonEntries(buffer, include) { + const EOCD_SIG = 0x06054b50; + const CEN_SIG = 0x02014b50; + // Locate End Of Central Directory (scan backwards; comment is usually empty). + let eocd = -1; + for (let i = buffer.length - 22; i >= 0 && i >= buffer.length - 22 - 0x10000; i--) { + if (buffer.readUInt32LE(i) === EOCD_SIG) { eocd = i; break; } + } + if (eocd < 0) throw new Error('not a zip (no EOCD)'); + const cdCount = buffer.readUInt16LE(eocd + 10); + let off = buffer.readUInt32LE(eocd + 16); + const names = []; + const json = {}; + for (let n = 0; n < cdCount; n++) { + if (buffer.readUInt32LE(off) !== CEN_SIG) break; + const method = buffer.readUInt16LE(off + 10); + const compSize = buffer.readUInt32LE(off + 20); + const nameLen = buffer.readUInt16LE(off + 28); + const extraLen = buffer.readUInt16LE(off + 30); + const commentLen = buffer.readUInt16LE(off + 32); + const localOff = buffer.readUInt32LE(off + 42); + const name = buffer.toString('utf8', off + 46, off + 46 + nameLen); + off += 46 + nameLen + extraLen + commentLen; + + if (name.endsWith('/')) { names.push(name); continue; } // directory entry + names.push(name); + const wanted = name.endsWith('.json') && (!include || include.some((s) => name.includes(s))); + if (!wanted) continue; + + // Resolve the data offset from the local file header (its name/extra lengths + // can differ from the central record). + const lNameLen = buffer.readUInt16LE(localOff + 26); + const lExtraLen = buffer.readUInt16LE(localOff + 28); + const dataStart = localOff + 30 + lNameLen + lExtraLen; + const comp = buffer.subarray(dataStart, dataStart + compSize); + let raw; + if (method === 0) raw = comp; // stored + else if (method === 8) raw = zlib.inflateRawSync(comp); // deflate + else continue; // unsupported method — skip rather than crash the run + try { json[name] = JSON.parse(raw.toString('utf8')); } catch (e) { /* skip unparseable */ } + } + return { names, json }; +} + +module.exports = { readZipJsonEntries }; diff --git a/playwright-runner/zip-reader.test.cjs b/playwright-runner/zip-reader.test.cjs new file mode 100644 index 0000000..e214531 --- /dev/null +++ b/playwright-runner/zip-reader.test.cjs @@ -0,0 +1,126 @@ +#!/usr/bin/env node +/** + * Unit test for the runner's dependency-free ZIP reader (readZipJsonEntries), + * which backs page.extractZipEntries(). Builds real ZIP archives in-memory + * (stored + deflate entries) and asserts JSON entries are parsed and filtered. + */ + +const assert = require('assert'); +const zlib = require('zlib'); +const { readZipJsonEntries } = require('./zip-reader.cjs'); + +// Build a minimal but spec-valid ZIP from { name: Buffer } entries. +// Uses deflate (method 8) when it helps, stored (method 0) otherwise. +function buildZip(entries) { + const crcTable = (() => { + const t = []; + for (let n = 0; n < 256; n++) { + let c = n; + for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + t[n] = c >>> 0; + } + return t; + })(); + const crc32 = (buf) => { + let c = 0xffffffff; + for (let i = 0; i < buf.length; i++) c = crcTable[(c ^ buf[i]) & 0xff] ^ (c >>> 8); + return (c ^ 0xffffffff) >>> 0; + }; + + const locals = []; + const centrals = []; + let offset = 0; + for (const [name, content] of Object.entries(entries)) { + const nameBuf = Buffer.from(name, 'utf8'); + const deflated = zlib.deflateRawSync(content); + const useDeflate = deflated.length < content.length; + const method = useDeflate ? 8 : 0; + const data = useDeflate ? deflated : content; + const crc = crc32(content); + + const local = Buffer.alloc(30); + local.writeUInt32LE(0x04034b50, 0); + local.writeUInt16LE(20, 4); + local.writeUInt16LE(0, 6); + local.writeUInt16LE(method, 8); + local.writeUInt16LE(0, 10); + local.writeUInt16LE(0, 12); + local.writeUInt32LE(crc, 14); + local.writeUInt32LE(data.length, 18); + local.writeUInt32LE(content.length, 22); + local.writeUInt16LE(nameBuf.length, 26); + local.writeUInt16LE(0, 28); + locals.push(local, nameBuf, data); + + const central = Buffer.alloc(46); + central.writeUInt32LE(0x02014b50, 0); + central.writeUInt16LE(20, 4); + central.writeUInt16LE(20, 6); + central.writeUInt16LE(0, 8); + central.writeUInt16LE(method, 10); + central.writeUInt32LE(crc, 16); + central.writeUInt32LE(data.length, 20); + central.writeUInt32LE(content.length, 24); + central.writeUInt16LE(nameBuf.length, 28); + central.writeUInt32LE(offset, 42); + centrals.push({ header: central, nameBuf }); + + offset += local.length + nameBuf.length + data.length; + } + + const cdStart = offset; + const cdParts = []; + for (const { header, nameBuf } of centrals) { + cdParts.push(header, nameBuf); + } + const cd = Buffer.concat(cdParts); + + const eocd = Buffer.alloc(22); + eocd.writeUInt32LE(0x06054b50, 0); + eocd.writeUInt16LE(centrals.length, 8); + eocd.writeUInt16LE(centrals.length, 10); + eocd.writeUInt32LE(cd.length, 12); + eocd.writeUInt32LE(cdStart, 16); + + return Buffer.concat([...locals, cd, eocd]); +} + +const cases = []; +function test(name, fn) { + try { fn(); cases.push({ name, ok: true }); } + catch (err) { cases.push({ name, ok: false, err: err.message }); } +} + +test('parses stored + deflate JSON entries', () => { + // A large, compressible payload forces the deflate path; a tiny one stays stored. + const big = JSON.stringify(Array.from({ length: 500 }, (_, i) => ({ id: i, text: 'hello world' }))); + const zip = buildZip({ + 'conversations.json': Buffer.from(big), + 'users.json': Buffer.from('[{"full_name":"Volod I"}]'), + 'notes.txt': Buffer.from('ignore me'), + }); + const { names, json } = readZipJsonEntries(zip, null); + assert.ok(names.includes('conversations.json') && names.includes('notes.txt')); + assert.strictEqual(json['conversations.json'].length, 500); + assert.strictEqual(json['users.json'][0].full_name, 'Volod I'); + assert.ok(!('notes.txt' in json), 'non-json entries are not parsed'); +}); + +test('include filter selects only matching entries', () => { + const zip = buildZip({ + 'conversations.json': Buffer.from('[1,2,3]'), + 'projects/p1.json': Buffer.from('{"uuid":"p1"}'), + 'users.json': Buffer.from('[{}]'), + }); + const { json } = readZipJsonEntries(zip, ['projects/']); + assert.deepStrictEqual(Object.keys(json), ['projects/p1.json']); +}); + +test('throws on a non-zip buffer', () => { + assert.throws(() => readZipJsonEntries(Buffer.from('not a zip'), null)); +}); + +const failed = cases.filter((c) => !c.ok); +for (const c of cases) console.log(`${c.ok ? 'ok ' : 'FAIL'} ${c.name}${c.ok ? '' : ' — ' + c.err}`); +console.log(`\n${cases.length - failed.length}/${cases.length} passed.`); +process.exit(failed.length === 0 ? 0 : 1); diff --git a/scripts/capture-session.cjs b/scripts/capture-session.cjs index 948bb37..15255c9 100755 --- a/scripts/capture-session.cjs +++ b/scripts/capture-session.cjs @@ -55,6 +55,8 @@ function resolveRunnerDir() { } const candidates = [ + // Bundled runner in this repo + path.resolve(__dirname, '..', 'playwright-runner'), path.resolve(__dirname, '..', 'data-dt-app', 'playwright-runner'), path.join(os.homedir(), 'Documents', 'GitHub', 'data-dt-app', 'playwright-runner'), path.join(os.homedir(), 'Documents', 'Github', 'data-dt-app', 'playwright-runner'), diff --git a/scripts/test-connector.cjs b/scripts/test-connector.cjs index be17362..243aaf3 100755 --- a/scripts/test-connector.cjs +++ b/scripts/test-connector.cjs @@ -55,6 +55,8 @@ function resolveRunnerDir() { // 2. Common locations relative to this script const candidates = [ + // Bundled runner in this repo + path.resolve(__dirname, '..', 'playwright-runner'), // Sibling repo (same parent directory) path.resolve(__dirname, '..', 'data-dt-app', 'playwright-runner'), // Home directory common paths @@ -250,9 +252,17 @@ async function main() { const runnerDir = resolveRunnerDir(); const metadata = loadMetadata(connectorPath); - const connectUrl = args.url || (metadata && metadata.connectURL) || 'about:blank'; + const connectUrl = args.url || (metadata && (metadata.connectURL || metadata.connect_url)) || 'about:blank'; const connectorName = metadata?.name || path.basename(connectorPath, '.js'); const connectorVersion = metadata?.version || 'unknown'; + const requestedScopes = Array.isArray(metadata?.scopes) + ? metadata.scopes.map((s) => s.scope).filter((s) => typeof s === 'string' && s.length > 0) + : []; + + if (requestedScopes.length === 0) { + console.error(`${c.red}Could not resolve requestedScopes for ${connectorPath} — no sibling metadata JSON with a non-empty "scopes" array found.${c.reset}`); + process.exit(1); + } console.log(''); console.log(`${c.bold}Connector Test Runner${c.reset}`); @@ -290,6 +300,7 @@ async function main() { url: connectUrl, headless: args.headless, forceHeaded: !args.headless, + requestedScopes, }); child.stdin.write(runCmd + '\n'); print(c.green, '[runner]', 'Connected, starting connector...'); diff --git a/skills/pdp-connect/SETUP.md b/skills/pdp-connect/SETUP.md index 60a4b5c..fc0c57c 100644 --- a/skills/pdp-connect/SETUP.md +++ b/skills/pdp-connect/SETUP.md @@ -1,6 +1,6 @@ # Connect -- Setup -This setup provisions the local runtime the in-repo scripts use to run connectors headlessly. +This repo bundles its own Playwright runner (`playwright-runner/`), so connectors run standalone -- no other repo required. ## Prerequisites @@ -13,13 +13,13 @@ This setup provisions the local runtime the in-repo scripts use to run connector bash skills/pdp-connect/scripts/setup.sh ``` -This downloads the `playwright-runner` and a Chromium build into `~/.pdp-connect/desktop/`. This is a one-time step. +This installs the bundled `playwright-runner/`'s dependencies (just Playwright) and downloads a Chromium build. This is a one-time step. Equivalent to running `npm install` inside `playwright-runner/` yourself. ## Verify ```bash -ls ~/.pdp-connect/desktop/playwright-runner/index.cjs -ls ~/.pdp-connect/desktop/run-connector.cjs +ls playwright-runner/index.cjs +ls playwright-runner/node_modules/playwright/package.json ``` If either is missing, re-run `setup.sh` and check its output for the step that failed. @@ -29,7 +29,12 @@ If either is missing, re-run `setup.sh` and check its output for the step that f | Path | Purpose | |------|---------| | `run-connector.cjs` (repo root, symlinked to `skills/pdp-connect/scripts/run-connector.cjs`) | Runs a connector headlessly | -| `~/.pdp-connect/desktop/connectors/` | Connector scripts | +| `playwright-runner/` (repo root) | Bundled runner the above resolves by default | +| `~/.pdp-connect/desktop/connectors/` | Connector scripts (when using the desktop app layout) | | `~/.pdp-connect/desktop/browser-profiles/` | Persistent sessions (cookies) | | `~/.pdp-connect/desktop/last-result.json` | Most recent result | | `~/.pdp-connect/desktop/logs/` | Setup, fetch, and run logs | + +## Optional: override the runner location + +If you want to point at a different runner build (e.g. while developing the runner itself), pass `--runner-dir ` to `run-connector.cjs` or set `PLAYWRIGHT_RUNNER_DIR`. Neither is required for normal use. diff --git a/skills/pdp-connect/SKILL.md b/skills/pdp-connect/SKILL.md index ef06200..2c16886 100644 --- a/skills/pdp-connect/SKILL.md +++ b/skills/pdp-connect/SKILL.md @@ -14,13 +14,13 @@ Connect personal data from web platforms using the in-repo scripts under `skills ## Setup -Check whether the runtime is already installed: +Check whether the bundled runner's dependencies are already installed: ```bash -ls ~/.pdp-connect/desktop/playwright-runner/index.cjs +ls playwright-runner/node_modules/playwright/package.json ``` -If it's missing, follow `SETUP.md` in this folder before continuing. Tell the user: "I need to do a one-time setup first. This downloads a browser engine and some dependencies into `~/.pdp-connect/desktop/` and usually takes about a minute." +If it's missing, follow `SETUP.md` in this folder before continuing. Tell the user: "I need to do a one-time setup first. This installs a browser engine and some dependencies and usually takes about a minute." If setup, fetch, or run output is truncated, check `~/.pdp-connect/desktop/logs/` directly. diff --git a/skills/pdp-connect/scripts/run-connector.cjs b/skills/pdp-connect/scripts/run-connector.cjs index 6f77119..8750553 100644 --- a/skills/pdp-connect/scripts/run-connector.cjs +++ b/skills/pdp-connect/scripts/run-connector.cjs @@ -56,13 +56,40 @@ for (let i = 0; i < rawArgs.length; i++) { } const connectorPath = positional[0]; -const startUrl = positional[1] || 'about:blank'; if (!connectorPath) { console.error('Usage: node run-connector.cjs [start-url] [--inputs \'{"key":"val"}\'] [--pretty]'); process.exit(1); } +// ─── Metadata resolution ───────────────────────────────────── +// Connector metadata lives in a sibling .json next to the .js script. +// It supplies the canonical requestedScopes the runner requires, and (when +// --url isn't passed) the starting connectURL. +function loadMetadata(resolvedConnectorPath) { + const metadataPath = resolvedConnectorPath.replace(/\.js$/, '.json'); + if (!fs.existsSync(metadataPath)) return null; + try { + return JSON.parse(fs.readFileSync(metadataPath, 'utf-8')); + } catch (e) { + console.error(`Warning: could not parse metadata at ${metadataPath}: ${e.message}`); + return null; + } +} + +const resolvedConnectorPath = path.resolve(connectorPath); +const metadata = loadMetadata(resolvedConnectorPath); +const requestedScopes = Array.isArray(metadata?.scopes) + ? metadata.scopes.map((s) => s.scope).filter((s) => typeof s === 'string' && s.length > 0) + : []; + +if (requestedScopes.length === 0) { + console.error(`Could not resolve requestedScopes for ${connectorPath} — no sibling metadata JSON with a non-empty "scopes" array found.`); + process.exit(1); +} + +const startUrl = positional[1] || metadata?.connectURL || metadata?.connect_url || 'about:blank'; + // Scope IPC files by connector name + timestamp so multiple runs never collide. const connectorSlug = path.basename(connectorPath, path.extname(connectorPath)); const runId = `${connectorSlug}-${Date.now()}`; @@ -122,6 +149,7 @@ function resolveRunnerDir() { } const candidates = [ + path.resolve(__dirname, '..', '..', '..', 'playwright-runner'), path.join(homedir, '.pdp-connect', 'desktop', 'playwright-runner'), process.env.PLAYWRIGHT_RUNNER_DIR, path.resolve(__dirname, '..', '..', '..', 'data-dt-app', 'playwright-runner'), @@ -187,8 +215,9 @@ function handleMessage(msg) { case 'ready': runner.stdin.write(JSON.stringify({ type: 'run', runId, - connectorPath: path.resolve(connectorPath), + connectorPath: resolvedConnectorPath, url: startUrl, headless: true, allowHeaded: false, + requestedScopes, }) + '\n'); if (pretty) prettyPrint(c.green, '[ready]', 'Connected, starting connector...'); break; diff --git a/skills/pdp-connect/scripts/setup.sh b/skills/pdp-connect/scripts/setup.sh index 669a78f..38fa18f 100755 --- a/skills/pdp-connect/scripts/setup.sh +++ b/skills/pdp-connect/scripts/setup.sh @@ -1,59 +1,46 @@ #!/usr/bin/env bash # setup.sh — One-shot setup for the pdp-connect skill. -# Installs playwright-runner, Chromium, and run-connector.cjs. +# Installs the bundled playwright-runner's dependencies and Chromium. # -# Usage: bash scripts/setup.sh -# Run from the data-connectors repo root (where skills/ lives). +# Usage: bash skills/pdp-connect/scripts/setup.sh +# Run from the data-connectors repo root (where playwright-runner/ lives). set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PDP_CONNECT_DIR="$HOME/.pdp-connect/desktop" +REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" +RUNNER_DIR="$REPO_ROOT/playwright-runner" + +if [[ ! -f "$RUNNER_DIR/index.cjs" ]]; then + echo "Could not find playwright-runner/ at $RUNNER_DIR — is this the data-connectors repo root?" + exit 1 +fi # Skip if already set up -if [[ -f "$PDP_CONNECT_DIR/playwright-runner/index.cjs" && -f "$PDP_CONNECT_DIR/run-connector.cjs" ]]; then - echo "Already set up. To reinstall, remove ~/.pdp-connect/desktop/playwright-runner/ first." +if [[ -f "$RUNNER_DIR/node_modules/playwright/package.json" ]]; then + echo "Already set up. To reinstall, remove $RUNNER_DIR/node_modules/ first." exit 0 fi -echo "Setting up pdp-connect..." - -mkdir -p "$PDP_CONNECT_DIR/connectors" +echo "Setting up the bundled playwright-runner..." -# 1. Clone playwright-runner (sparse checkout, minimal download) -echo "Downloading playwright-runner..." -cd "$PDP_CONNECT_DIR" -rm -rf _data-connect -git clone --depth 1 --filter=blob:none --sparse --branch main \ - https://github.com/vana-com/data-connect.git _data-connect 2>&1 -cd _data-connect && git sparse-checkout set playwright-runner 2>&1 -cp -r playwright-runner ../playwright-runner -cd .. && rm -rf _data-connect - -# 2. Install dependencies +# 1. Install runner dependencies (just playwright) echo "Installing dependencies..." -cd "$PDP_CONNECT_DIR/playwright-runner" && npm install 2>&1 +cd "$RUNNER_DIR" && npm install 2>&1 -# 3. Install Chromium +# 2. Install Chromium (also runs automatically via the runner's postinstall hook) echo "Installing Chromium (this may take a minute)..." npx playwright install chromium 2>&1 || { - echo "Note: 'playwright install --with-deps' may need root. Trying without system deps..." + echo "Note: Chromium install may need extra system deps. Trying 'playwright install-deps'..." + npx playwright install-deps chromium 2>&1 || true npx playwright install chromium 2>&1 || true } -# 4. Copy run-connector.cjs -if [[ -f "$SCRIPT_DIR/run-connector.cjs" ]]; then - cp "$SCRIPT_DIR/run-connector.cjs" "$PDP_CONNECT_DIR/run-connector.cjs" -else - echo "Warning: run-connector.cjs not found at $SCRIPT_DIR/run-connector.cjs" - echo "Copy it manually: cp skills/pdp-connect/scripts/run-connector.cjs ~/.pdp-connect/desktop/run-connector.cjs" -fi - # Verify echo "" -if [[ -f "$PDP_CONNECT_DIR/playwright-runner/index.cjs" && -f "$PDP_CONNECT_DIR/run-connector.cjs" ]]; then - echo "Setup complete!" +if [[ -d "$RUNNER_DIR/node_modules/playwright" ]]; then + echo "Setup complete! The bundled runner at $RUNNER_DIR is ready." else - echo "Setup may be incomplete. Check ~/.pdp-connect/desktop/ for missing files." + echo "Setup may be incomplete. Check $RUNNER_DIR for missing files." exit 1 fi diff --git a/test-connector.cjs b/test-connector.cjs index 06d830e..e9fd9ed 100644 --- a/test-connector.cjs +++ b/test-connector.cjs @@ -58,6 +58,8 @@ function resolveRunnerDir() { // 2. Common locations relative to this script const candidates = [ + // Bundled runner in this repo + path.resolve(__dirname, 'playwright-runner'), // Sibling repo (same parent directory) path.resolve(__dirname, '..', 'data-dt-app', 'playwright-runner'), path.resolve(__dirname, '..', 'data-connect', 'playwright-runner'), @@ -301,9 +303,17 @@ async function main() { const runnerDir = resolveRunnerDir(); const metadata = loadMetadata(connectorPath); - const connectUrl = args.url || (metadata && metadata.connectURL) || 'about:blank'; + const connectUrl = args.url || (metadata && (metadata.connectURL || metadata.connect_url)) || 'about:blank'; const connectorName = metadata?.name || path.basename(connectorPath, '.js'); const connectorVersion = metadata?.version || 'unknown'; + const requestedScopes = Array.isArray(metadata?.scopes) + ? metadata.scopes.map((s) => s.scope).filter((s) => typeof s === 'string' && s.length > 0) + : []; + + if (requestedScopes.length === 0) { + console.error(`${c.red}Could not resolve requestedScopes for ${connectorPath} — no sibling metadata JSON with a non-empty "scopes" array found.${c.reset}`); + process.exit(1); + } console.log(''); console.log(`${c.bold}Connector Test Runner${c.reset}`); @@ -341,6 +351,7 @@ async function main() { url: connectUrl, headless: args.headless, forceHeaded: !args.headless, + requestedScopes, }); child.stdin.write(runCmd + '\n'); print(c.green, '[runner]', 'Connected, starting connector...'); From 5a9a3093e79a283556926f9581cb4b09a4ab7d64 Mon Sep 17 00:00:00 2001 From: Tim Nunamaker Date: Fri, 17 Jul 2026 18:42:44 -0500 Subject: [PATCH 6/6] docs: neutralize consumer framing + fix runner path in CREATE.md README described this repo as existing to serve Vana's Context Gateway and Unity Surfaces as "the" distribution source/consumers. Reframe both sections neutrally: this is an open connector collection any consumer can pin as a versioned dependency, with Vana's products as examples rather than the repo's purpose. CREATE.md still pointed at the old desktop-install runner location (~/.pdp-connect/desktop/run-connector.cjs). The runner is now bundled in-repo at the root (symlinked to skills/pdp-connect/scripts/run-connector.cjs, per SETUP.md and SKILL.md). Update CREATE.md's setup note and test command to match. Signed-off-by: Tim Nunamaker --- README.md | 21 ++++++++++++--------- skills/pdp-connect/CREATE.md | 4 ++-- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 336bffc..668eb29 100644 --- a/README.md +++ b/README.md @@ -117,8 +117,9 @@ create-connector.sh # Quick autonomous scaffold script ## Distribution contract -`data-connectors` is the canonical connector distribution source for both -`unity-surfaces` and `context-gateway`. +`data-connectors` is an open collection of data connectors that any consumer +can pin and resolve as a versioned dependency. Vana's Context Gateway and +Unity Surfaces are two example consumers; nothing here is specific to them. - `connector-index.json` is the authoritative release index. - `scope-catalog.json` is the generated, schema-validated public contract for @@ -515,19 +516,21 @@ shasum -a 256 connectors//-playwright.json | awk '{print "sha256: ## How consumers get connectors -Both [Context Gateway](https://github.com/vana-com/context-gateway) and [Unity Surfaces](https://github.com/vana-com/unity-surfaces) consume connectors from this repo as pinned dependencies. - -Each consumer declares version constraints in a `connector-dependencies.json` file and resolves them with: +Any project can consume connectors from this repo as pinned dependencies. A +consumer declares version constraints in a `connector-dependencies.json` file +and resolves them with a `connectors:resolve` script (naming and invocation +are up to the consumer's own tooling), for example: ```bash -# Context Gateway +# Example: a consumer's package.json script npm run connectors:resolve - -# Unity Surfaces -pnpm --dir apps/desktop connectors:resolve ``` This fetches matching versions from the signed `connectors-latest` `connector-index.json`, verifies the detached Sigstore bundles plus the artifact/manifest/script checksums, and writes the scripts + manifests to a local snapshot directory. It's analogous to `npm install`. + +Vana's [Context Gateway](https://github.com/vana-com/context-gateway) and +[Unity Surfaces](https://github.com/vana-com/unity-surfaces) are two examples +of consumers that resolve connectors this way. diff --git a/skills/pdp-connect/CREATE.md b/skills/pdp-connect/CREATE.md index 2588323..b099aa3 100644 --- a/skills/pdp-connect/CREATE.md +++ b/skills/pdp-connect/CREATE.md @@ -7,7 +7,7 @@ Build a data connector for a platform that isn't in the registry yet. - `reference/PAGE-API.md` -- full `page` object API - `reference/PATTERNS.md` -- data extraction approaches and code examples -All `node scripts/...` commands refer to `skills/pdp-connect/scripts/` in the data-connectors repo. `run-connector.cjs` is at `~/.pdp-connect/desktop/run-connector.cjs` (installed by SETUP.md). +All `node scripts/...` commands refer to `skills/pdp-connect/scripts/` in the data-connectors repo. `run-connector.cjs` is the bundled runner at the repo root (a symlink to `skills/pdp-connect/scripts/run-connector.cjs`); see `SETUP.md` for the one-time install. ## Connector Format @@ -167,7 +167,7 @@ Run the connector and validate in one step: ```bash node scripts/validate.cjs connectors//-playwright.js && \ - node ~/.pdp-connect/desktop/run-connector.cjs connectors//-playwright.js [start-url] && \ + node run-connector.cjs connectors//-playwright.js [start-url] && \ node scripts/validate.cjs connectors//-playwright.js --check-result ~/.pdp-connect/desktop/last-result.json ```