safetyvibe-connect 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (5) hide show
  1. package/AGENTS.md +167 -114
  2. package/README.md +167 -114
  3. package/cli.mjs +64 -25
  4. package/lib.mjs +150 -45
  5. package/package.json +5 -1
package/AGENTS.md CHANGED
@@ -1,114 +1,167 @@
1
- # SafetyVibe Connect instructions
2
-
3
- SafetyVibe Connect reads package.json, the lockfile, and client-exposed Supabase config (client-prefixed env vars and the client Supabase file). It reports your package list so we can warn you about vulnerable dependencies. The JSON body sends exactly: v, optional name, url, platform and environment, lockfile, versions_source, source, packages, optional truncated, and supabase (null, or project_ref and host with key_kind and key_fingerprint, or secret_key_exposed_client_side). It never sends Supabase keys, secrets or file contents. Requires Node >=18; no dependencies or package install scripts.
4
-
5
- Full source: https://safetyvibe.eu/connect/source/0.3.0
6
-
7
- ## Run without installing
8
-
9
- Get a claim token from the dashboard, then run:
10
-
11
- ```sh
12
- npx -y safetyvibe-connect@0.3.0 setup --no-hook --claim-token <TOKEN>
13
- # pnpm
14
- pnpm dlx safetyvibe-connect@0.3.0 setup --no-hook --claim-token <TOKEN>
15
- # bun
16
- bunx safetyvibe-connect@0.3.0 setup --no-hook --claim-token <TOKEN>
17
- # yarn
18
- yarn dlx safetyvibe-connect@0.3.0 setup --no-hook --claim-token <TOKEN>
19
- ```
20
-
21
- Add `--url <published URL>` if known. Without a token, setup prints a claim link.
22
- On Replit/pnpm projects, `npm install` can fail with "Cannot read properties of null (reading 'isDescendantOf')".
23
- Use `pnpm add -D safetyvibe-connect`, or run without installing:
24
- `npx -y safetyvibe-connect@0.3.0 setup --no-hook --claim-token <TOKEN>`.
25
- Optional installation commands: `npm install -D safetyvibe-connect`, `pnpm add -D safetyvibe-connect`, `bun add -d safetyvibe-connect`, `yarn add -D safetyvibe-connect`.
26
-
27
- ## Exactly what is sent
28
-
29
- Preview the report before sending it:
30
-
31
- ```sh
32
- npx -y safetyvibe-connect@0.3.0 setup --dry-run --url https://notes.example.com
33
- ```
34
-
35
- By default dry-run stdout is exactly the pretty JSON body plus a newline. Its stderr carries five summary lines: Platform, Lockfile (package and dev-only counts), Supabase (fingerprint or exposed-secret flag only), Keys sent: 0, and Dry run: nothing sent. The summary is under 400 characters. It is followed by a plain explanation, destination and header names with values hidden, then one-line warnings (including any "Dropped <field>: it looked like a key." notices). Dry-run makes no requests. With --dry-run --out report.json, the body is written to that file without sending; otherwise JSON goes to stdout and the summary to stderr.
36
-
37
- Real runs print the summary on stdout, ending with Connected: and the dashboard or validated claim link (printed once). --quiet suppresses success chatter, but safety warnings always appear on stderr, including during dry runs. Accepted reports show the Connected line even if saving local config or installing the hook fails; warnings and errors follow the summary. Rejected or unreachable reports have no Connected line.
38
-
39
- --summary prints only the summary on stdout in dry and real runs, with warnings on stderr and no JSON or follow-up chatter. --out <file.json> (also --out=file.json) writes the exact JSON body plus newline instead of printing JSON; in dry runs stdout stays empty and the summary stays on stderr unless --summary is also used. It works for real runs too. The file must be new, end in .json, and resolve inside the project root with an existing parent directory; parent symlink escapes are refused. It uses exclusive creation and never overwrites files. Connector config filenames and package.json are reserved so later setup writes cannot replace the exported body. Invalid output paths exit 2 for setup/dry run; scan always exits 0 with an error on stderr.
40
- Example (optional `truncated` appears only when the inventory exceeds 5000):
41
-
42
- ```json
43
- {
44
- "v": 1,
45
- "name": "notes.example.com",
46
- "url": "https://notes.example.com",
47
- "environment": "production",
48
- "lockfile": "package-lock.json",
49
- "versions_source": "lockfile",
50
- "source": "connector",
51
- "packages": [{ "ecosystem": "npm", "name": "vite", "version": "6.0.0", "dev": true }],
52
- "supabase": { "project_ref": "abcdefghijklmnopqrst", "host": "abcdefghijklmnopqrst.supabase.co", "key_kind": "anon", "key_fingerprint": "12ab34cd" }
53
- }
54
- ```
55
-
56
- - `v`: report format version.
57
- - `name`: explicit or rc name, then URL host (first label for platform hosts), then package name, skipping template and key-like names; limited to 80 letters, digits, spaces, @ / . _ -; omitted when unknown.
58
- - `url`: HTTP(S) origin and path only, without credentials, query or fragment; paths are kept only with up to eight safe ASCII segments of at most 40 characters, excluding sb_, svk_ and eyJ markers; key-like hostnames are rejected; omitted when unknown.
59
- - `platform`: detected project builder/hosting label; omitted when unknown.
60
- - `environment`: `SAFETYVIBE_ENV`, or production when `NODE_ENV=production`; limited to 20 letters, digits, underscores or hyphens; omitted when unknown.
61
- - `lockfile`: selected lockfile filename, or `none` when no readable lockfile exists. Parent locks are used only at workspace roots when the project matches a workspace glob in package.json workspaces (array or workspaces.packages) or the pnpm-workspace.yaml packages list. Literal paths, * and ** are supported; negated globs exclude matching paths from membership. Search stops at .git or six parent levels.
62
- - `packages`: registry package names and versions, npm ecosystem, and optional `dev: true` for dev-only dependencies when recorded in the lockfile. Workspace entries are excluded; incompatible optional npm binaries are excluded. Names that a lockfile declares with a git/tarball/file/link spec are excluded, including hoisted entries. Recognized empty or fully excluded locks remain authoritative. Supported formats are npm v1/v2/v3, Yarn v1/Berry, pnpm v5.4/v6/v9 and text bun.lock; installed node_modules folders are never read. Without a readable lockfile (none, only binary bun.lockb, or an unparseable one) no packages are sent, `lockfile` is `none` (the dashboard keeps the last known list) and the CLI prints: "No readable lockfile found (package-lock.json, pnpm-lock.yaml, yarn.lock or bun.lock); dependency check unavailable." For bun.lockb run `bun install --save-text-lockfile` to create bun.lock.
63
- - `truncated`: optional boolean; only the first 5000 packages are sent.
64
- - `versions_source`: always `lockfile`; `source`: always `connector`.
65
- - `supabase`: null unless a validated URL and public key are found, or a client-exposed secret is detected. Public keys yield only `project_ref`, `host`, `key_kind` (`anon` or `publishable`) and `key_fingerprint` (first eight lowercase hex characters of SHA-256 of the UTF-8 key). `host` is the URL host only; `project_ref` is the lowercase 20-character alphanumeric ref of a .supabase.co host, otherwise null. An exposed secret always wins: only `{ "project_ref": null, "host": null, "secret_key_exposed_client_side": true }` is sent, with project_ref/host filled when a validated URL is known. No key kind, fingerprint or length is sent for secrets.
66
-
67
- All report strings are screened before sending or printing: key-like values (including encoded and Unicode-disguised keys) are dropped, with a local warning naming only the field. A key-like URL is dropped entirely.
68
-
69
- Never sent: source code, file paths, full lockfiles, .env files, Supabase keys (including anon/publishable), secrets, URL credentials or query strings.
70
- The request headers x-safetyvibe-site, x-safetyvibe-key and x-safetyvibe-claim-token carry the validated UUID site ID, validated report key and validated one-shot claim token when available; these are not in the JSON body. The server validates public keys too for older clients; this connector sends no Supabase keys.
71
- Public keys are read only from client-exposed variables (prefixes VITE_, NEXT_PUBLIC_, EXPO_PUBLIC_, PUBLIC_, REACT_APP_, NUXT_PUBLIC_; from the environment or .env files) or a client-side Supabase source file (src/integrations/supabase/client.*, src/lib/supabase.*, lib/supabase.ts, utils/supabase.ts). Files that import or require server-only, start with "use server", are named *.server.*, or sit under server/, api/, app/api/, pages/api/, functions/, supabase/functions/, netlify/functions/ or are middleware.* are skipped. Server-only names such as SUPABASE_URL or SUPABASE_ANON_KEY are not read. Every client-prefixed variable in process env and .env/.env.local/.env.production/.env.production.local is checked for exposed secrets, as is every quoted string in the scanned client files. Server-only names remain unflagged. Local warnings name only the variable or relative client file, never the value, and tell you to rotate exposed secrets in Supabase.
72
-
73
- Review the entire implementation in `cli.mjs` and `lib.mjs` (approximately 633 lines total). There are no dependencies and no install scripts in this package itself.
74
- By default reports go only to `https://dashboard.safetyvibe.eu/connect/api/v1/report`. The committed rc cannot change the destination.
75
- An explicit `SAFETYVIBE_API` environment override supports HTTPS, or HTTP on localhost/127.0.0.1/[::1] for local testing. Redirects are refused.
76
-
77
- ## Changes
78
-
79
- 0.3.0 never sends Supabase keys: it reports public-key fingerprints or exposed-secret flags, adds a five-line summary, --summary and safe --out JSON export, and ships MIT licensing and security contact details. 0.2.3 prints a one-line summary first (dry run: package count, Supabase yes/no, destination; setup/scan: result and the dashboard link, printed once) so terminals that cut off long output still show it; in a dry run the full JSON follows. 0.2.2 improves response deadlines, registry-only inventory, project selection and app labels, restores flag-only setup, and rejects ambiguous JWT payloads. It reads pnpm v5.4 locks and no longer reads installed node_modules folders: without a readable lockfile no packages are sent. 0.2.1 fixed a bug where a key on a commented .env line could be included; upgrade from 0.2.0.
80
-
81
- [Documentation](https://safetyvibe.eu/connect) and [changelog / what is sent](https://safetyvibe.eu/connect/changelog).
82
-
83
- ## Files written and removal
84
-
85
- Before a setup/scan request, legacy credentials are migrated best-effort into the local file, the committed api field is dropped and its URL normalized. Dry-run writes only an explicitly requested --out file; status and help never write. After a successful report:
86
-
87
- - `.safetyviberc.json`: app ID, normalized public URL, optional name and `hook: false`.
88
- - `.safetyviberc.local.json`: report key and optional fallback claim token; never commit this file. The token is removed once used or rejected as expired/used. Only stored credentials require the local file and ignore entry. The ignore entry is attempted first, then this file is written with mode 0600. An ignore failure warns and fails setup; scan still exits 0. Empty local credentials are removed.
89
- - `.gitignore`: adds the local config filename.
90
- - `package.json`: setup normally adds a postinstall report hook. `--no-hook` leaves package.json untouched and persists this preference. Existing unsafe shell scripts are left untouched with a manual instruction.
91
-
92
- The project directory is cwd. INIT_CWD is used only when its own package.json lists the connector or its postinstall hook and it is not a workspace root. Scan at an npm/Yarn workspace root (package.json workspaces) or pnpm workspace root (pnpm-workspace.yaml with a packages list) that has no app ID in .safetyviberc.json is skipped without network or writes; run setup in the app directory. A root that was linked by running setup there is treated like any app: it reports and updates its rc files. Dry-run still previews the selected directory. An unlinked scan without a key or claim token skips projects without the connector dependency. Requests have one deadline of at most 10 seconds, including the response body, capped at 64 KiB. Failed responses keep the token unless the server reports it expired or already used, and do not install a hook.
93
-
94
- The postinstall hook runs `safetyvibe-connect scan --quiet || exit 0`: it re-sends the current inventory and public Supabase details using the local report key, suppresses success chatter but keeps safety warnings on stderr, and never fails installation. Without a local key it skips reporting for a linked app unless SAFETYVIBE_KEY or a claim token is supplied.
95
-
96
- Without terminal access, first add `.safetyviberc.local.json` to `.gitignore`, then write `{"claimToken": "<TOKEN>"}` into `.safetyviberc.local.json` and configure the dependency and postinstall hook. Never put the token in the committed `.safetyviberc.json`.
97
-
98
- For manual reporting use `npx safetyvibe-connect scan` (pnpm: `pnpm dlx`, bun: `bunx`, yarn: `yarn dlx`).
99
- Remove the connector dependency if installed, its postinstall command, both rc files, and the local-config .gitignore entry. Keep other postinstall commands.
100
-
101
- ## Commands
102
-
103
- | Command | Effect |
104
- |---|---|
105
- | `setup [--claim-token T] [--url URL] [--name NAME] [--no-hook]` | Send report, save config, optionally add hook |
106
- | `setup --dry-run` or `--dry-run` | Print exact report without network; only --out writes a file |
107
- | `scan [--quiet]` | Re-report; always exits 0 for install hooks |
108
- | `status` | Show local link state |
109
- | `help`, `--help`, `-h`, no arguments | Usage only |
110
- | `--version`, `-v` | Package version only |
111
-
112
- Flags without a command imply setup. Unknown name, URL, platform and environment fields are omitted, never empty strings.
113
-
114
- Value flags accept `--flag=value` or `--flag value`, including values starting with a dash. In the space form, a known flag (also with `=...`) is treated as a missing value. Unknown commands/flags and missing values exit 2, except scan exits 0.
1
+ # SafetyVibe Connect instructions
2
+
3
+ SafetyVibe Connect reads package.json, the lockfile, and client-exposed Supabase config (client-prefixed env vars and the client Supabase file). It reports your package list so we can warn you about vulnerable dependencies. The JSON body sends exactly: v, optional name, url, platform and environment, lockfile, versions_source, source, packages, optional truncated, and supabase (null, or project_ref and host with key_kind and key_fingerprint, or secret_key_exposed_client_side). It never sends Supabase keys, secrets or file contents. Requires Node >=18; no dependencies or package install scripts.
4
+
5
+ Full source: https://safetyvibe.eu/connect/source/0.3.2
6
+
7
+ ## 0.3.2: review an inventory locally
8
+
9
+ For a manual no-install inventory, ask: "Show me a safe inventory summary and JSON from package.json and a supported lockfile; I will review it locally." Request no installation, execution, file changes, keys or environment values. Unknown facts stay unknown. Review the output locally; dashboard paste acceptance and save/refresh compatibility require separate verification, including exposed-secret flags and declaration whitespace.
10
+
11
+ With Node and a terminal, run without adding the connector to your project (npx fetches and executes the package):
12
+
13
+ ```sh
14
+ npx -y safetyvibe-connect@0.3.2 inventory --json --compact
15
+ npx -y safetyvibe-connect@0.3.2 inventory --project frontend --json --compact
16
+ npx -y safetyvibe-connect@0.3.2 setup --dry-run --json
17
+ ```
18
+
19
+ `inventory` always makes no network call and writes nothing unless you pass `--out`. It does not install the connector, add hooks or update link settings. Warnings and guidance stay on stderr when stdout contains JSON. Compact JSON is one line and retains every inventory row (up to the existing 5000-row cap). This prepares a reviewable inventory; it does not establish dashboard acceptance.
20
+
21
+ Common subprojects (frontend, client, web, apps/*) and declared npm/pnpm workspace members are suggested. Discovery is bounded to six levels and 500 entries, skips symlink directories, and never merges projects or changes the selected/linked app. Use `--project DIR` to explicitly select one existing package.json directory inside the current root, or change directories yourself. Exports/config writes apply to that selected directory.
22
+
23
+ Flag precedence is independent of order: `--summary` or `--json=summary` wins over JSON stdout; `--out` still exports the full body and suppresses JSON stdout; `--json` opts into full JSON. `--compact` only changes JSON formatting and does not enable JSON by itself. An explicit summary plus --out prints a summary and exports the body. Other --out modes keep stdout empty. Real --json reports print the safe request body on stdout and connection/guidance text on stderr.
24
+
25
+ Counts are local summary metadata, not new JSON fields. Direct means safe registry declarations in the selected package.json. Total means unique name+resolved-version rows in the selected lockfile before truncation; declared means manifest rows when no usable lock exists. The summary separately shows direct dev declarations and confirmed dev-only inventory rows. Yarn v1 resolves direct dev selectors and suppresses the flag for runtime-reachable shared entries; unclassified transitive packages are not labelled dev-only. Shared workspace Yarn locks retain unknown dev-only status because other apps may use those packages at runtime. A shared workspace lock may cover more than the selected app, so its total is explicitly a lockfile total.
26
+
27
+ When no usable lock exists, safe package.json ranges are preserved verbatim with `versions_source:"declared"` and `lockfile:"none"`. No resolved versions or successful vulnerability checks are invented. Recognized empty or fully excluded locks remain authoritative. Declared bodies (including empty declared inventories) are kept local until dashboard range handling and save/refresh behavior are verified. Real `setup` refuses them with exit 1 before any network call, config migration, link-setting write or hook change; real `scan` emits the same warning and exits 0 for install hooks. This guard also applies with `--json`: a refused real run prints no JSON on stdout. `inventory` and `setup --dry-run` still preview them. An explicit `--out` export is processed before the guard and can write the requested local JSON even when the real report is refused (invalid output paths exit 2). Supply a supported lockfile to send a resolved inventory.
28
+
29
+ ## 0.3.2: connect without adding a dependency
30
+
31
+ Get a claim token from the dashboard, then run:
32
+
33
+ ```sh
34
+ npx -y safetyvibe-connect@0.3.2 setup --no-hook --claim-token <TOKEN>
35
+ # pnpm
36
+ pnpm dlx safetyvibe-connect@0.3.2 setup --no-hook --claim-token <TOKEN>
37
+ # bun
38
+ bunx safetyvibe-connect@0.3.2 setup --no-hook --claim-token <TOKEN>
39
+ # yarn
40
+ yarn dlx safetyvibe-connect@0.3.2 setup --no-hook --claim-token <TOKEN>
41
+ ```
42
+
43
+ Add `--url <published URL>` if known. Without a token, setup prints a claim link.
44
+ On Replit/pnpm projects, `npm install` can fail with "Cannot read properties of null (reading 'isDescendantOf')".
45
+ Use `pnpm add -D safetyvibe-connect@0.3.2`, or run without installing:
46
+ `npx -y safetyvibe-connect@0.3.2 setup --no-hook --claim-token <TOKEN>`.
47
+ Optional installation commands: `npm install -D safetyvibe-connect@0.3.2`, `pnpm add -D safetyvibe-connect@0.3.2`, `bun add -d safetyvibe-connect@0.3.2`, `yarn add -D safetyvibe-connect@0.3.2`.
48
+
49
+ ## For AI agents
50
+
51
+ `setup --dry-run` makes no network call and writes nothing unless you pass `--out`. Check cli.mjs line 188: `return 0;` inside `if (flags['dry-run'])`. This exits before the only `fetch(` call. This describes the connector itself; fetching the package with npx is separate.
52
+
53
+ It reads package.json and one supported lockfile (package-lock.json, npm-shrinkwrap.json, bun.lock, pnpm-lock.yaml or yarn.lock). It may inspect candidate locks and ancestor package.json / pnpm-workspace.yaml files to select a workspace lock, stopping at .git or six parent levels. It checks whether bun.lockb exists, but does not parse it. It reads .safetyviberc.json and .safetyviberc.local.json for local link settings.
54
+
55
+ It reads and parses .env, .env.local, .env.production and .env.production.local locally, not just their key names. Service-role variable names produce local warnings. Client-prefixed values are checked for secret shapes; SUPABASE_URL, SUPABASE_PUBLISHABLE_KEY and SUPABASE_ANON_KEY with VITE_, NEXT_PUBLIC_, EXPO_PUBLIC_, PUBLIC_, REACT_APP_ or NUXT_PUBLIC_ prefixes supply the URL and public key used locally for a fingerprint. Process env is inspected the same way. Server-only Supabase values are not selected as connection details. For platform detection it also examines URL values in .env, .env.local and .env.production in the root and frontend/; those values are not sent.
56
+
57
+ Client files scanned for the Supabase URL and key shape, exactly:
58
+
59
+ - src/integrations/supabase/client.ts
60
+ - src/integrations/supabase/client.js
61
+ - src/lib/supabase.ts
62
+ - src/lib/supabase.js
63
+ - lib/supabase.ts
64
+ - utils/supabase.ts
65
+
66
+ Files marked server-only or use server are excluded. Bare prefixes and incomplete JWTs are ignored for secret detection. Full identifiable secrets are flagged even when embedded in larger values, comparisons, validation calls or comments in client files. Source scanning uses a forward token pass with bounded JWT candidates. Only a full sb_secret_ shape (at least 20 body characters), a three-segment service_role JWT, or a JWT with any role other than anon under a publishable/anon key name sets the exposure flag. Unknown keys are never accepted as public.
67
+
68
+ Platform detection also reads metadata.json and vite.config.ts/js/mjs/mts, package names/dependencies, and checks .lovable, .replit, .bolt, .emergent, .softgen, .leap, .rocket and .v0 markers. The CLI uses cwd / INIT_CWD, package manager and agent/CI environment hints, including BASE44_* and CURSOR_*, plus SAFETYVIBE_API, SAFETYVIBE_URL, SAFETYVIBE_ENV, NODE_ENV, SAFETYVIBE_KEY, SAFETYVIBE_CLAIM_TOKEN and SAFETYVIBE_TIMEOUT_MS. No source files or raw Supabase keys are sent. The complete JSON field list and authentication headers are documented below under "Exactly what is sent".
69
+
70
+ Source: https://safetyvibe.eu/connect/source/0.3.2
71
+
72
+ ## Exactly what is sent
73
+
74
+ Preview the report before sending it:
75
+
76
+ ```sh
77
+ npx -y safetyvibe-connect@0.3.2 setup --dry-run --json --url https://notes.example.com
78
+ ```
79
+
80
+ By default non-TTY and agent/CI runs print five summary lines on stdout plus one Next guidance line: Platform, Lockfile (direct / total or declared counts, direct dev and confirmed dev-only counts), Supabase (fingerprint or exposed-secret flag only), Keys sent: 0, and Dry run: nothing sent. Warnings go to stderr. Use --json for the full body on stdout, with summary, guidance, explanation and destination/header names (values hidden) on stderr. A regular TTY keeps that JSON behaviour by default. --summary or --json=summary wins over --json. Dry-run makes no requests. --out preserves the export behaviour described below.
81
+
82
+ Real runs print the summary on stdout, ending with Connected: and the dashboard or validated claim link (printed once), followed by one Next guidance line for manual reporting or hook status. Explicit --json puts the safe body on stdout and all summary/guidance on stderr. --quiet suppresses success chatter, but safety warnings always appear on stderr, including during dry runs. Accepted reports show the Connected line even if saving local config or installing the hook fails; warnings and errors follow the summary. Rejected or unreachable reports have no Connected line.
83
+
84
+ --summary (also --json=summary) prints only the summary plus one guidance line on stdout in dry and real runs, with warnings on stderr and no JSON. --out <file.json> (also --out=file.json) writes the exact JSON body plus newline instead of printing JSON; in dry runs stdout stays empty and the summary stays on stderr unless --summary is also used. It works for real runs too. The file must be new, end in .json, and resolve inside the project root with an existing parent directory; parent symlink escapes are refused. It uses exclusive creation and never overwrites files. Connector config filenames and package.json are reserved so later setup writes cannot replace the exported body. Invalid output paths exit 2, including scan, with an error on stderr.
85
+ Example (optional `truncated` appears only when the inventory exceeds 5000):
86
+
87
+ ```json
88
+ {
89
+ "v": 1,
90
+ "name": "notes.example.com",
91
+ "url": "https://notes.example.com",
92
+ "environment": "production",
93
+ "lockfile": "package-lock.json",
94
+ "versions_source": "lockfile",
95
+ "source": "connector",
96
+ "packages": [{ "ecosystem": "npm", "name": "vite", "version": "6.0.0", "dev": true }],
97
+ "supabase": { "project_ref": "abcdefghijklmnopqrst", "host": "abcdefghijklmnopqrst.supabase.co", "key_kind": "anon", "key_fingerprint": "12ab34cd" }
98
+ }
99
+ ```
100
+
101
+ - `v`: report format version.
102
+ - `name`: explicit or rc name, then URL host (first label for platform hosts), then package name, skipping template and key-like names; limited to 80 letters, digits, spaces, @ / . _ -; omitted when unknown.
103
+ - `url`: HTTP(S) origin and path only, without credentials, query or fragment; paths are kept only with up to eight safe ASCII segments of at most 40 characters, excluding sb_, svk_ and eyJ markers; key-like hostnames are rejected; omitted when unknown.
104
+ - `platform`: detected project builder/hosting label; omitted when unknown.
105
+ - `environment`: `SAFETYVIBE_ENV`, or production when `NODE_ENV=production`; limited to 20 letters, digits, underscores or hyphens; omitted when unknown.
106
+ - `lockfile`: selected lockfile filename, or `none` when no readable lockfile exists. Parent locks are used only at workspace roots when the project matches a workspace glob in package.json workspaces (array or workspaces.packages) or the pnpm-workspace.yaml packages list. Literal paths, * and ** are supported; negated globs exclude matching paths from membership. Search stops at .git or six parent levels.
107
+ - `packages`: registry package names and versions, npm ecosystem, and optional `dev: true` for dev-only dependencies when recorded in the lockfile. Workspace entries are excluded; incompatible optional npm binaries are excluded. Names that a lockfile declares with a git/tarball/file/link spec are excluded, including hoisted entries. Recognized empty or fully excluded locks remain authoritative. Supported formats are npm v1/v2/v3, Yarn v1/Berry, pnpm v5.4/v6/v9 and text bun.lock; installed node_modules folders are never read. Without a readable lockfile (none, only binary bun.lockb, or an unparseable one), safe package.json registry ranges are used locally with `lockfile` set to `none` and `versions_source` set to `declared`. The CLI prints: "No readable lockfile found (package-lock.json, pnpm-lock.yaml, yarn.lock or bun.lock); using safe package.json declarations, not resolved versions." Network reports of declared inventories are blocked pending separately verified receiver support. For bun.lockb run `bun install --save-text-lockfile` to create bun.lock.
108
+ - `truncated`: optional boolean; only the first 5000 packages are sent.
109
+ - `versions_source`: `lockfile` for resolved inventory; `declared` for safe manifest ranges in local inventory/dry-run output.
110
+ - `source`: always `connector`.
111
+ - `supabase`: null unless a validated URL and public key are found, or a client-exposed secret is detected. Public keys yield only `project_ref`, `host`, `key_kind` (`anon` or `publishable`) and `key_fingerprint` (first eight lowercase hex characters of SHA-256 of the UTF-8 key). `host` is the URL host only; `project_ref` is the lowercase 20-character alphanumeric ref of a .supabase.co host, otherwise null. An exposed secret always wins: only `{ "project_ref": null, "host": null, "secret_key_exposed_client_side": true }` is sent, with project_ref/host filled when a validated URL is known. No key kind, fingerprint or length is sent for secrets.
112
+
113
+ Nested fields: `packages[].ecosystem` is `npm`; `packages[].name` is the registry name; `packages[].version` is the exact lockfile version, or the unchanged safe declared registry range when versions_source is declared; optional `packages[].dev` is true for dev-only packages. `supabase.project_ref` identifies the project (or null); `supabase.host` is the validated host (or null); `supabase.key_kind` is anon or publishable; `supabase.key_fingerprint` is the eight-character SHA-256 prefix; `supabase.secret_key_exposed_client_side` is true only for a detected client-exposed secret and replaces kind/fingerprint.
114
+
115
+ All report strings are screened before sending or printing: key-like values (including encoded and Unicode-disguised keys) are dropped, with a local warning naming only the field. A key-like URL is dropped entirely.
116
+
117
+ Never sent: source code, file paths, full lockfiles, .env files, Supabase keys (including anon/publishable), secrets, URL credentials or query strings.
118
+ The request headers x-safetyvibe-site, x-safetyvibe-key and x-safetyvibe-claim-token carry the validated UUID site ID, validated report key and validated one-shot claim token when available; these are not in the JSON body. The server validates public keys too for older clients; this connector sends no Supabase keys.
119
+ Public keys are read only from client-exposed variables (prefixes VITE_, NEXT_PUBLIC_, EXPO_PUBLIC_, PUBLIC_, REACT_APP_, NUXT_PUBLIC_; from the environment or .env files) or a client-side Supabase source file (src/integrations/supabase/client.*, src/lib/supabase.*, lib/supabase.ts, utils/supabase.ts). Files that contain the quoted text server-only or use server anywhere (even in a comment), are named *.server.*, or sit under server/, api/, app/api/, pages/api/, functions/, supabase/functions/, netlify/functions/ or are middleware.* are skipped. Server-only names such as SUPABASE_URL or SUPABASE_ANON_KEY are not selected as connection details. Every client-prefixed variable in process env and .env/.env.local/.env.production/.env.production.local is checked for exposed secrets, as are full secret-shaped runs in the scanned client files, independent of quote pairing (a stray apostrophe in a comment cannot hide a full secret), including full-secret comparisons and validation calls, while ignoring bare prefixes. Full secret shapes embedded in larger client values also set the flag. A key split across concatenated strings is not detected. Any service-role variable name in a .env* file, including SUPABASE_SERVICE_ROLE_KEY and SUPABASE_SERVICE_KEY, produces a separate local warning; its presence alone never sets an exposure flag or adds anything to the body. Local warnings name only the variable or relative client file, never the value, and tell you to rotate exposed secrets in Supabase.
120
+
121
+ Review the entire implementation in `cli.mjs` and `lib.mjs`. There are no dependencies and no install scripts in this package itself.
122
+ By default reports go only to `https://dashboard.safetyvibe.eu/connect/api/v1/report`. The committed rc cannot change the destination.
123
+ An explicit `SAFETYVIBE_API` environment override supports HTTPS, or HTTP on localhost/127.0.0.1/[::1] for local testing. Redirects are refused.
124
+
125
+ ## Changes
126
+
127
+ 0.3.2 (2026-10-19) adds the local `inventory` command, `--project` selection and bounded hints for common subprojects and npm/pnpm workspaces. Summaries distinguish direct declarations from total lockfile rows and show direct dev and confirmed dev-only counts; Yarn v1 resolves direct dev selectors without labelling runtime-shared or unknown transitive rows dev-only. `--json --compact` emits one-line full JSON; `--json=summary` selects the summary with explicit flag precedence. Without a usable lockfile, safe manifest ranges are preserved as `versions_source:"declared"` for local review; real declared reports remain blocked pending dashboard verification. Client-source secret scanning now uses a bounded forward pass and detects full secrets embedded in larger values, comparisons, validation calls and comments while ignoring bare prefixes. BASE44_* and CURSOR_* agent hints select summary output, which includes a Next guidance line.
128
+
129
+ 0.3.1 fixes false secret alarms from Supabase prefix checks while keeping real secret warnings and public fingerprints. Non-TTY and agent/CI runs default to a five-line summary; --json opts into the body. It checks config writability before sending, stops re-sending env claim tokens for linked projects, warns locally about service-role variable names, and exits 2 for bad --out paths even on scan. It adds AI-agent source guidance.
130
+
131
+ 0.3.0 never sends Supabase keys: it reports public-key fingerprints or exposed-secret flags, puts a five-line summary first, adds --summary and safe --out JSON export, and ships MIT licensing and security contact details. 0.2.2 improves response deadlines, registry-only inventory, project selection and app labels, restores flag-only setup, and rejects ambiguous JWT payloads. It reads pnpm v5.4 locks and no longer reads installed node_modules folders: without a readable lockfile no packages are sent. 0.2.1 fixed a bug where a key on a commented .env line could be included; upgrade from 0.2.0.
132
+
133
+ [Documentation](https://safetyvibe.eu/connect) and [changelog / what is sent](https://safetyvibe.eu/connect/changelog).
134
+
135
+ ## Files written and removal
136
+
137
+ Before a setup/scan request, the connector probes directory writability and checks existing config files; a failure sends nothing and exits 1 for setup (0 for scan). Then legacy credentials are migrated best-effort into the local file, the committed api field is dropped and its URL normalized. Dry-run writes only an explicitly requested --out file; status and help never write. After a successful report:
138
+
139
+ - `.safetyviberc.json`: app ID, normalized public URL, optional name and `hook: false`.
140
+ - `.safetyviberc.local.json`: report key and optional fallback claim token; never commit this file. The token is removed once used or rejected as expired/used. Once a valid site ID and report key are present, SAFETYVIBE_CLAIM_TOKEN is not re-sent. Only stored credentials require the local file and ignore entry. The ignore entry is attempted first, then this file is written with mode 0600. An ignore failure warns and fails setup; scan still exits 0. Empty local credentials are removed.
141
+ - `.gitignore`: adds the local config filename.
142
+ - `package.json`: setup normally adds a postinstall report hook. `--no-hook` leaves package.json untouched and persists this preference. Existing unsafe shell scripts are left untouched with a manual instruction.
143
+
144
+ The project directory is cwd. INIT_CWD is used only when its own package.json lists the connector or its postinstall hook and it is not a workspace root. Scan at an npm/Yarn workspace root (package.json workspaces) or pnpm workspace root (pnpm-workspace.yaml with a packages list) that has no app ID in .safetyviberc.json is skipped without network or writes; run setup in the app directory. A root that was linked by running setup there is treated like any app: it reports and updates its rc files. Dry-run still previews the selected directory. An unlinked scan without a key or claim token skips projects without the connector dependency. Requests have one deadline of at most 10 seconds, including the response body, capped at 64 KiB. Failed responses keep the token unless the server reports it expired or already used, and do not install a hook.
145
+
146
+ The postinstall hook runs `safetyvibe-connect scan --quiet || exit 0`: it re-sends the current inventory and public Supabase details using the local report key, suppresses success chatter but keeps safety warnings on stderr, and never fails installation. Without a local key it skips reporting for a linked app unless SAFETYVIBE_KEY or a claim token is supplied.
147
+
148
+ Without terminal access, first add `.safetyviberc.local.json` to `.gitignore`, then write `{"claimToken": "<TOKEN>"}` into `.safetyviberc.local.json` and configure the dependency and postinstall hook. Never put the token in the committed `.safetyviberc.json`.
149
+
150
+ For manual reporting use `npx -y safetyvibe-connect@0.3.2 scan` (pnpm: `pnpm dlx`, bun: `bunx`, yarn: `yarn dlx`).
151
+ Remove the connector dependency if installed, its postinstall command, both rc files, and the local-config .gitignore entry. Keep other postinstall commands.
152
+
153
+ ## Commands
154
+
155
+ | Command | Effect |
156
+ |---|---|
157
+ | `setup [--claim-token T] [--url URL] [--name NAME] [--no-hook]` | Send report, save config, optionally add hook |
158
+ | `setup --dry-run` or `--dry-run` | Preview summary (--json for the body) without network; only --out writes a file |
159
+ | `inventory [--project DIR] [--json] [--compact]` | Local review body/summary; no network or config writes, only explicit --out writes |
160
+ | `scan [--quiet]` | Re-report; exits 0 for install hooks, except bad --out exits 2 |
161
+ | `status` | Show local link state |
162
+ | `help`, `--help`, `-h`, no arguments | Usage only |
163
+ | `--version`, `-v` | Package version only |
164
+
165
+ Flags without a command imply setup. Unknown name, URL, platform and environment fields are omitted, never empty strings.
166
+
167
+ Value flags accept `--flag=value` or `--flag value`, including values starting with a dash. In the space form, a known flag (also with `=...`) is treated as a missing value. Unknown commands/flags and missing values exit 2, except scan exits 0 (a missing --out value still exits 2).
package/README.md CHANGED
@@ -1,114 +1,167 @@
1
- # safetyvibe-connect
2
-
3
- SafetyVibe Connect reads package.json, the lockfile, and client-exposed Supabase config (client-prefixed env vars and the client Supabase file). It reports your package list so we can warn you about vulnerable dependencies. The JSON body sends exactly: v, optional name, url, platform and environment, lockfile, versions_source, source, packages, optional truncated, and supabase (null, or project_ref and host with key_kind and key_fingerprint, or secret_key_exposed_client_side). It never sends Supabase keys, secrets or file contents. Requires Node >=18; no dependencies or package install scripts.
4
-
5
- Full source: https://safetyvibe.eu/connect/source/0.3.0
6
-
7
- ## Run without installing
8
-
9
- Get a claim token from the dashboard, then run:
10
-
11
- ```sh
12
- npx -y safetyvibe-connect@0.3.0 setup --no-hook --claim-token <TOKEN>
13
- # pnpm
14
- pnpm dlx safetyvibe-connect@0.3.0 setup --no-hook --claim-token <TOKEN>
15
- # bun
16
- bunx safetyvibe-connect@0.3.0 setup --no-hook --claim-token <TOKEN>
17
- # yarn
18
- yarn dlx safetyvibe-connect@0.3.0 setup --no-hook --claim-token <TOKEN>
19
- ```
20
-
21
- Add `--url <published URL>` if known. Without a token, setup prints a claim link.
22
- On Replit/pnpm projects, `npm install` can fail with "Cannot read properties of null (reading 'isDescendantOf')".
23
- Use `pnpm add -D safetyvibe-connect`, or run without installing:
24
- `npx -y safetyvibe-connect@0.3.0 setup --no-hook --claim-token <TOKEN>`.
25
- Optional installation commands: `npm install -D safetyvibe-connect`, `pnpm add -D safetyvibe-connect`, `bun add -d safetyvibe-connect`, `yarn add -D safetyvibe-connect`.
26
-
27
- ## Exactly what is sent
28
-
29
- Preview the report before sending it:
30
-
31
- ```sh
32
- npx -y safetyvibe-connect@0.3.0 setup --dry-run --url https://notes.example.com
33
- ```
34
-
35
- By default dry-run stdout is exactly the pretty JSON body plus a newline. Its stderr carries five summary lines: Platform, Lockfile (package and dev-only counts), Supabase (fingerprint or exposed-secret flag only), Keys sent: 0, and Dry run: nothing sent. The summary is under 400 characters. It is followed by a plain explanation, destination and header names with values hidden, then one-line warnings (including any "Dropped <field>: it looked like a key." notices). Dry-run makes no requests. With --dry-run --out report.json, the body is written to that file without sending; otherwise JSON goes to stdout and the summary to stderr.
36
-
37
- Real runs print the summary on stdout, ending with Connected: and the dashboard or validated claim link (printed once). --quiet suppresses success chatter, but safety warnings always appear on stderr, including during dry runs. Accepted reports show the Connected line even if saving local config or installing the hook fails; warnings and errors follow the summary. Rejected or unreachable reports have no Connected line.
38
-
39
- --summary prints only the summary on stdout in dry and real runs, with warnings on stderr and no JSON or follow-up chatter. --out <file.json> (also --out=file.json) writes the exact JSON body plus newline instead of printing JSON; in dry runs stdout stays empty and the summary stays on stderr unless --summary is also used. It works for real runs too. The file must be new, end in .json, and resolve inside the project root with an existing parent directory; parent symlink escapes are refused. It uses exclusive creation and never overwrites files. Connector config filenames and package.json are reserved so later setup writes cannot replace the exported body. Invalid output paths exit 2 for setup/dry run; scan always exits 0 with an error on stderr.
40
- Example (optional `truncated` appears only when the inventory exceeds 5000):
41
-
42
- ```json
43
- {
44
- "v": 1,
45
- "name": "notes.example.com",
46
- "url": "https://notes.example.com",
47
- "environment": "production",
48
- "lockfile": "package-lock.json",
49
- "versions_source": "lockfile",
50
- "source": "connector",
51
- "packages": [{ "ecosystem": "npm", "name": "vite", "version": "6.0.0", "dev": true }],
52
- "supabase": { "project_ref": "abcdefghijklmnopqrst", "host": "abcdefghijklmnopqrst.supabase.co", "key_kind": "anon", "key_fingerprint": "12ab34cd" }
53
- }
54
- ```
55
-
56
- - `v`: report format version.
57
- - `name`: explicit or rc name, then URL host (first label for platform hosts), then package name, skipping template and key-like names; limited to 80 letters, digits, spaces, @ / . _ -; omitted when unknown.
58
- - `url`: HTTP(S) origin and path only, without credentials, query or fragment; paths are kept only with up to eight safe ASCII segments of at most 40 characters, excluding sb_, svk_ and eyJ markers; key-like hostnames are rejected; omitted when unknown.
59
- - `platform`: detected project builder/hosting label; omitted when unknown.
60
- - `environment`: `SAFETYVIBE_ENV`, or production when `NODE_ENV=production`; limited to 20 letters, digits, underscores or hyphens; omitted when unknown.
61
- - `lockfile`: selected lockfile filename, or `none` when no readable lockfile exists. Parent locks are used only at workspace roots when the project matches a workspace glob in package.json workspaces (array or workspaces.packages) or the pnpm-workspace.yaml packages list. Literal paths, * and ** are supported; negated globs exclude matching paths from membership. Search stops at .git or six parent levels.
62
- - `packages`: registry package names and versions, npm ecosystem, and optional `dev: true` for dev-only dependencies when recorded in the lockfile. Workspace entries are excluded; incompatible optional npm binaries are excluded. Names that a lockfile declares with a git/tarball/file/link spec are excluded, including hoisted entries. Recognized empty or fully excluded locks remain authoritative. Supported formats are npm v1/v2/v3, Yarn v1/Berry, pnpm v5.4/v6/v9 and text bun.lock; installed node_modules folders are never read. Without a readable lockfile (none, only binary bun.lockb, or an unparseable one) no packages are sent, `lockfile` is `none` (the dashboard keeps the last known list) and the CLI prints: "No readable lockfile found (package-lock.json, pnpm-lock.yaml, yarn.lock or bun.lock); dependency check unavailable." For bun.lockb run `bun install --save-text-lockfile` to create bun.lock.
63
- - `truncated`: optional boolean; only the first 5000 packages are sent.
64
- - `versions_source`: always `lockfile`; `source`: always `connector`.
65
- - `supabase`: null unless a validated URL and public key are found, or a client-exposed secret is detected. Public keys yield only `project_ref`, `host`, `key_kind` (`anon` or `publishable`) and `key_fingerprint` (first eight lowercase hex characters of SHA-256 of the UTF-8 key). `host` is the URL host only; `project_ref` is the lowercase 20-character alphanumeric ref of a .supabase.co host, otherwise null. An exposed secret always wins: only `{ "project_ref": null, "host": null, "secret_key_exposed_client_side": true }` is sent, with project_ref/host filled when a validated URL is known. No key kind, fingerprint or length is sent for secrets.
66
-
67
- All report strings are screened before sending or printing: key-like values (including encoded and Unicode-disguised keys) are dropped, with a local warning naming only the field. A key-like URL is dropped entirely.
68
-
69
- Never sent: source code, file paths, full lockfiles, .env files, Supabase keys (including anon/publishable), secrets, URL credentials or query strings.
70
- The request headers x-safetyvibe-site, x-safetyvibe-key and x-safetyvibe-claim-token carry the validated UUID site ID, validated report key and validated one-shot claim token when available; these are not in the JSON body. The server validates public keys too for older clients; this connector sends no Supabase keys.
71
- Public keys are read only from client-exposed variables (prefixes VITE_, NEXT_PUBLIC_, EXPO_PUBLIC_, PUBLIC_, REACT_APP_, NUXT_PUBLIC_; from the environment or .env files) or a client-side Supabase source file (src/integrations/supabase/client.*, src/lib/supabase.*, lib/supabase.ts, utils/supabase.ts). Files that import or require server-only, start with "use server", are named *.server.*, or sit under server/, api/, app/api/, pages/api/, functions/, supabase/functions/, netlify/functions/ or are middleware.* are skipped. Server-only names such as SUPABASE_URL or SUPABASE_ANON_KEY are not read. Every client-prefixed variable in process env and .env/.env.local/.env.production/.env.production.local is checked for exposed secrets, as is every quoted string in the scanned client files. Server-only names remain unflagged. Local warnings name only the variable or relative client file, never the value, and tell you to rotate exposed secrets in Supabase.
72
-
73
- Review the entire implementation in `cli.mjs` and `lib.mjs` (approximately 633 lines total). There are no dependencies and no install scripts in this package itself.
74
- By default reports go only to `https://dashboard.safetyvibe.eu/connect/api/v1/report`. The committed rc cannot change the destination.
75
- An explicit `SAFETYVIBE_API` environment override supports HTTPS, or HTTP on localhost/127.0.0.1/[::1] for local testing. Redirects are refused.
76
-
77
- ## Changes
78
-
79
- 0.3.0 never sends Supabase keys: it reports public-key fingerprints or exposed-secret flags, adds a five-line summary, --summary and safe --out JSON export, and ships MIT licensing and security contact details. 0.2.3 prints a one-line summary first (dry run: package count, Supabase yes/no, destination; setup/scan: result and the dashboard link, printed once) so terminals that cut off long output still show it; in a dry run the full JSON follows. 0.2.2 improves response deadlines, registry-only inventory, project selection and app labels, restores flag-only setup, and rejects ambiguous JWT payloads. It reads pnpm v5.4 locks and no longer reads installed node_modules folders: without a readable lockfile no packages are sent. 0.2.1 fixed a bug where a key on a commented .env line could be included; upgrade from 0.2.0.
80
-
81
- [Documentation](https://safetyvibe.eu/connect) and [changelog / what is sent](https://safetyvibe.eu/connect/changelog).
82
-
83
- ## Files written and removal
84
-
85
- Before a setup/scan request, legacy credentials are migrated best-effort into the local file, the committed api field is dropped and its URL normalized. Dry-run writes only an explicitly requested --out file; status and help never write. After a successful report:
86
-
87
- - `.safetyviberc.json`: app ID, normalized public URL, optional name and `hook: false`.
88
- - `.safetyviberc.local.json`: report key and optional fallback claim token; never commit this file. The token is removed once used or rejected as expired/used. Only stored credentials require the local file and ignore entry. The ignore entry is attempted first, then this file is written with mode 0600. An ignore failure warns and fails setup; scan still exits 0. Empty local credentials are removed.
89
- - `.gitignore`: adds the local config filename.
90
- - `package.json`: setup normally adds a postinstall report hook. `--no-hook` leaves package.json untouched and persists this preference. Existing unsafe shell scripts are left untouched with a manual instruction.
91
-
92
- The project directory is cwd. INIT_CWD is used only when its own package.json lists the connector or its postinstall hook and it is not a workspace root. Scan at an npm/Yarn workspace root (package.json workspaces) or pnpm workspace root (pnpm-workspace.yaml with a packages list) that has no app ID in .safetyviberc.json is skipped without network or writes; run setup in the app directory. A root that was linked by running setup there is treated like any app: it reports and updates its rc files. Dry-run still previews the selected directory. An unlinked scan without a key or claim token skips projects without the connector dependency. Requests have one deadline of at most 10 seconds, including the response body, capped at 64 KiB. Failed responses keep the token unless the server reports it expired or already used, and do not install a hook.
93
-
94
- The postinstall hook runs `safetyvibe-connect scan --quiet || exit 0`: it re-sends the current inventory and public Supabase details using the local report key, suppresses success chatter but keeps safety warnings on stderr, and never fails installation. Without a local key it skips reporting for a linked app unless SAFETYVIBE_KEY or a claim token is supplied.
95
-
96
- Without terminal access, first add `.safetyviberc.local.json` to `.gitignore`, then write `{"claimToken": "<TOKEN>"}` into `.safetyviberc.local.json` and configure the dependency and postinstall hook. Never put the token in the committed `.safetyviberc.json`.
97
-
98
- For manual reporting use `npx safetyvibe-connect scan` (pnpm: `pnpm dlx`, bun: `bunx`, yarn: `yarn dlx`).
99
- Remove the connector dependency if installed, its postinstall command, both rc files, and the local-config .gitignore entry. Keep other postinstall commands.
100
-
101
- ## Commands
102
-
103
- | Command | Effect |
104
- |---|---|
105
- | `setup [--claim-token T] [--url URL] [--name NAME] [--no-hook]` | Send report, save config, optionally add hook |
106
- | `setup --dry-run` or `--dry-run` | Print exact report without network; only --out writes a file |
107
- | `scan [--quiet]` | Re-report; always exits 0 for install hooks |
108
- | `status` | Show local link state |
109
- | `help`, `--help`, `-h`, no arguments | Usage only |
110
- | `--version`, `-v` | Package version only |
111
-
112
- Flags without a command imply setup. Unknown name, URL, platform and environment fields are omitted, never empty strings.
113
-
114
- Value flags accept `--flag=value` or `--flag value`, including values starting with a dash. In the space form, a known flag (also with `=...`) is treated as a missing value. Unknown commands/flags and missing values exit 2, except scan exits 0.
1
+ # safetyvibe-connect
2
+
3
+ SafetyVibe Connect reads package.json, the lockfile, and client-exposed Supabase config (client-prefixed env vars and the client Supabase file). It reports your package list so we can warn you about vulnerable dependencies. The JSON body sends exactly: v, optional name, url, platform and environment, lockfile, versions_source, source, packages, optional truncated, and supabase (null, or project_ref and host with key_kind and key_fingerprint, or secret_key_exposed_client_side). It never sends Supabase keys, secrets or file contents. Requires Node >=18; no dependencies or package install scripts.
4
+
5
+ Full source: https://safetyvibe.eu/connect/source/0.3.2
6
+
7
+ ## 0.3.2: review an inventory locally
8
+
9
+ For a manual no-install inventory, ask: "Show me a safe inventory summary and JSON from package.json and a supported lockfile; I will review it locally." Request no installation, execution, file changes, keys or environment values. Unknown facts stay unknown. Review the output locally; dashboard paste acceptance and save/refresh compatibility require separate verification, including exposed-secret flags and declaration whitespace.
10
+
11
+ With Node and a terminal, run without adding the connector to your project (npx fetches and executes the package):
12
+
13
+ ```sh
14
+ npx -y safetyvibe-connect@0.3.2 inventory --json --compact
15
+ npx -y safetyvibe-connect@0.3.2 inventory --project frontend --json --compact
16
+ npx -y safetyvibe-connect@0.3.2 setup --dry-run --json
17
+ ```
18
+
19
+ `inventory` always makes no network call and writes nothing unless you pass `--out`. It does not install the connector, add hooks or update link settings. Warnings and guidance stay on stderr when stdout contains JSON. Compact JSON is one line and retains every inventory row (up to the existing 5000-row cap). This prepares a reviewable inventory; it does not establish dashboard acceptance.
20
+
21
+ Common subprojects (frontend, client, web, apps/*) and declared npm/pnpm workspace members are suggested. Discovery is bounded to six levels and 500 entries, skips symlink directories, and never merges projects or changes the selected/linked app. Use `--project DIR` to explicitly select one existing package.json directory inside the current root, or change directories yourself. Exports/config writes apply to that selected directory.
22
+
23
+ Flag precedence is independent of order: `--summary` or `--json=summary` wins over JSON stdout; `--out` still exports the full body and suppresses JSON stdout; `--json` opts into full JSON. `--compact` only changes JSON formatting and does not enable JSON by itself. An explicit summary plus --out prints a summary and exports the body. Other --out modes keep stdout empty. Real --json reports print the safe request body on stdout and connection/guidance text on stderr.
24
+
25
+ Counts are local summary metadata, not new JSON fields. Direct means safe registry declarations in the selected package.json. Total means unique name+resolved-version rows in the selected lockfile before truncation; declared means manifest rows when no usable lock exists. The summary separately shows direct dev declarations and confirmed dev-only inventory rows. Yarn v1 resolves direct dev selectors and suppresses the flag for runtime-reachable shared entries; unclassified transitive packages are not labelled dev-only. Shared workspace Yarn locks retain unknown dev-only status because other apps may use those packages at runtime. A shared workspace lock may cover more than the selected app, so its total is explicitly a lockfile total.
26
+
27
+ When no usable lock exists, safe package.json ranges are preserved verbatim with `versions_source:"declared"` and `lockfile:"none"`. No resolved versions or successful vulnerability checks are invented. Recognized empty or fully excluded locks remain authoritative. Declared bodies (including empty declared inventories) are kept local until dashboard range handling and save/refresh behavior are verified. Real `setup` refuses them with exit 1 before any network call, config migration, link-setting write or hook change; real `scan` emits the same warning and exits 0 for install hooks. This guard also applies with `--json`: a refused real run prints no JSON on stdout. `inventory` and `setup --dry-run` still preview them. An explicit `--out` export is processed before the guard and can write the requested local JSON even when the real report is refused (invalid output paths exit 2). Supply a supported lockfile to send a resolved inventory.
28
+
29
+ ## 0.3.2: connect without adding a dependency
30
+
31
+ Get a claim token from the dashboard, then run:
32
+
33
+ ```sh
34
+ npx -y safetyvibe-connect@0.3.2 setup --no-hook --claim-token <TOKEN>
35
+ # pnpm
36
+ pnpm dlx safetyvibe-connect@0.3.2 setup --no-hook --claim-token <TOKEN>
37
+ # bun
38
+ bunx safetyvibe-connect@0.3.2 setup --no-hook --claim-token <TOKEN>
39
+ # yarn
40
+ yarn dlx safetyvibe-connect@0.3.2 setup --no-hook --claim-token <TOKEN>
41
+ ```
42
+
43
+ Add `--url <published URL>` if known. Without a token, setup prints a claim link.
44
+ On Replit/pnpm projects, `npm install` can fail with "Cannot read properties of null (reading 'isDescendantOf')".
45
+ Use `pnpm add -D safetyvibe-connect@0.3.2`, or run without installing:
46
+ `npx -y safetyvibe-connect@0.3.2 setup --no-hook --claim-token <TOKEN>`.
47
+ Optional installation commands: `npm install -D safetyvibe-connect@0.3.2`, `pnpm add -D safetyvibe-connect@0.3.2`, `bun add -d safetyvibe-connect@0.3.2`, `yarn add -D safetyvibe-connect@0.3.2`.
48
+
49
+ ## For AI agents
50
+
51
+ `setup --dry-run` makes no network call and writes nothing unless you pass `--out`. Check cli.mjs line 188: `return 0;` inside `if (flags['dry-run'])`. This exits before the only `fetch(` call. This describes the connector itself; fetching the package with npx is separate.
52
+
53
+ It reads package.json and one supported lockfile (package-lock.json, npm-shrinkwrap.json, bun.lock, pnpm-lock.yaml or yarn.lock). It may inspect candidate locks and ancestor package.json / pnpm-workspace.yaml files to select a workspace lock, stopping at .git or six parent levels. It checks whether bun.lockb exists, but does not parse it. It reads .safetyviberc.json and .safetyviberc.local.json for local link settings.
54
+
55
+ It reads and parses .env, .env.local, .env.production and .env.production.local locally, not just their key names. Service-role variable names produce local warnings. Client-prefixed values are checked for secret shapes; SUPABASE_URL, SUPABASE_PUBLISHABLE_KEY and SUPABASE_ANON_KEY with VITE_, NEXT_PUBLIC_, EXPO_PUBLIC_, PUBLIC_, REACT_APP_ or NUXT_PUBLIC_ prefixes supply the URL and public key used locally for a fingerprint. Process env is inspected the same way. Server-only Supabase values are not selected as connection details. For platform detection it also examines URL values in .env, .env.local and .env.production in the root and frontend/; those values are not sent.
56
+
57
+ Client files scanned for the Supabase URL and key shape, exactly:
58
+
59
+ - src/integrations/supabase/client.ts
60
+ - src/integrations/supabase/client.js
61
+ - src/lib/supabase.ts
62
+ - src/lib/supabase.js
63
+ - lib/supabase.ts
64
+ - utils/supabase.ts
65
+
66
+ Files marked server-only or use server are excluded. Bare prefixes and incomplete JWTs are ignored for secret detection. Full identifiable secrets are flagged even when embedded in larger values, comparisons, validation calls or comments in client files. Source scanning uses a forward token pass with bounded JWT candidates. Only a full sb_secret_ shape (at least 20 body characters), a three-segment service_role JWT, or a JWT with any role other than anon under a publishable/anon key name sets the exposure flag. Unknown keys are never accepted as public.
67
+
68
+ Platform detection also reads metadata.json and vite.config.ts/js/mjs/mts, package names/dependencies, and checks .lovable, .replit, .bolt, .emergent, .softgen, .leap, .rocket and .v0 markers. The CLI uses cwd / INIT_CWD, package manager and agent/CI environment hints, including BASE44_* and CURSOR_*, plus SAFETYVIBE_API, SAFETYVIBE_URL, SAFETYVIBE_ENV, NODE_ENV, SAFETYVIBE_KEY, SAFETYVIBE_CLAIM_TOKEN and SAFETYVIBE_TIMEOUT_MS. No source files or raw Supabase keys are sent. The complete JSON field list and authentication headers are documented below under "Exactly what is sent".
69
+
70
+ Source: https://safetyvibe.eu/connect/source/0.3.2
71
+
72
+ ## Exactly what is sent
73
+
74
+ Preview the report before sending it:
75
+
76
+ ```sh
77
+ npx -y safetyvibe-connect@0.3.2 setup --dry-run --json --url https://notes.example.com
78
+ ```
79
+
80
+ By default non-TTY and agent/CI runs print five summary lines on stdout plus one Next guidance line: Platform, Lockfile (direct / total or declared counts, direct dev and confirmed dev-only counts), Supabase (fingerprint or exposed-secret flag only), Keys sent: 0, and Dry run: nothing sent. Warnings go to stderr. Use --json for the full body on stdout, with summary, guidance, explanation and destination/header names (values hidden) on stderr. A regular TTY keeps that JSON behaviour by default. --summary or --json=summary wins over --json. Dry-run makes no requests. --out preserves the export behaviour described below.
81
+
82
+ Real runs print the summary on stdout, ending with Connected: and the dashboard or validated claim link (printed once), followed by one Next guidance line for manual reporting or hook status. Explicit --json puts the safe body on stdout and all summary/guidance on stderr. --quiet suppresses success chatter, but safety warnings always appear on stderr, including during dry runs. Accepted reports show the Connected line even if saving local config or installing the hook fails; warnings and errors follow the summary. Rejected or unreachable reports have no Connected line.
83
+
84
+ --summary (also --json=summary) prints only the summary plus one guidance line on stdout in dry and real runs, with warnings on stderr and no JSON. --out <file.json> (also --out=file.json) writes the exact JSON body plus newline instead of printing JSON; in dry runs stdout stays empty and the summary stays on stderr unless --summary is also used. It works for real runs too. The file must be new, end in .json, and resolve inside the project root with an existing parent directory; parent symlink escapes are refused. It uses exclusive creation and never overwrites files. Connector config filenames and package.json are reserved so later setup writes cannot replace the exported body. Invalid output paths exit 2, including scan, with an error on stderr.
85
+ Example (optional `truncated` appears only when the inventory exceeds 5000):
86
+
87
+ ```json
88
+ {
89
+ "v": 1,
90
+ "name": "notes.example.com",
91
+ "url": "https://notes.example.com",
92
+ "environment": "production",
93
+ "lockfile": "package-lock.json",
94
+ "versions_source": "lockfile",
95
+ "source": "connector",
96
+ "packages": [{ "ecosystem": "npm", "name": "vite", "version": "6.0.0", "dev": true }],
97
+ "supabase": { "project_ref": "abcdefghijklmnopqrst", "host": "abcdefghijklmnopqrst.supabase.co", "key_kind": "anon", "key_fingerprint": "12ab34cd" }
98
+ }
99
+ ```
100
+
101
+ - `v`: report format version.
102
+ - `name`: explicit or rc name, then URL host (first label for platform hosts), then package name, skipping template and key-like names; limited to 80 letters, digits, spaces, @ / . _ -; omitted when unknown.
103
+ - `url`: HTTP(S) origin and path only, without credentials, query or fragment; paths are kept only with up to eight safe ASCII segments of at most 40 characters, excluding sb_, svk_ and eyJ markers; key-like hostnames are rejected; omitted when unknown.
104
+ - `platform`: detected project builder/hosting label; omitted when unknown.
105
+ - `environment`: `SAFETYVIBE_ENV`, or production when `NODE_ENV=production`; limited to 20 letters, digits, underscores or hyphens; omitted when unknown.
106
+ - `lockfile`: selected lockfile filename, or `none` when no readable lockfile exists. Parent locks are used only at workspace roots when the project matches a workspace glob in package.json workspaces (array or workspaces.packages) or the pnpm-workspace.yaml packages list. Literal paths, * and ** are supported; negated globs exclude matching paths from membership. Search stops at .git or six parent levels.
107
+ - `packages`: registry package names and versions, npm ecosystem, and optional `dev: true` for dev-only dependencies when recorded in the lockfile. Workspace entries are excluded; incompatible optional npm binaries are excluded. Names that a lockfile declares with a git/tarball/file/link spec are excluded, including hoisted entries. Recognized empty or fully excluded locks remain authoritative. Supported formats are npm v1/v2/v3, Yarn v1/Berry, pnpm v5.4/v6/v9 and text bun.lock; installed node_modules folders are never read. Without a readable lockfile (none, only binary bun.lockb, or an unparseable one), safe package.json registry ranges are used locally with `lockfile` set to `none` and `versions_source` set to `declared`. The CLI prints: "No readable lockfile found (package-lock.json, pnpm-lock.yaml, yarn.lock or bun.lock); using safe package.json declarations, not resolved versions." Network reports of declared inventories are blocked pending separately verified receiver support. For bun.lockb run `bun install --save-text-lockfile` to create bun.lock.
108
+ - `truncated`: optional boolean; only the first 5000 packages are sent.
109
+ - `versions_source`: `lockfile` for resolved inventory; `declared` for safe manifest ranges in local inventory/dry-run output.
110
+ - `source`: always `connector`.
111
+ - `supabase`: null unless a validated URL and public key are found, or a client-exposed secret is detected. Public keys yield only `project_ref`, `host`, `key_kind` (`anon` or `publishable`) and `key_fingerprint` (first eight lowercase hex characters of SHA-256 of the UTF-8 key). `host` is the URL host only; `project_ref` is the lowercase 20-character alphanumeric ref of a .supabase.co host, otherwise null. An exposed secret always wins: only `{ "project_ref": null, "host": null, "secret_key_exposed_client_side": true }` is sent, with project_ref/host filled when a validated URL is known. No key kind, fingerprint or length is sent for secrets.
112
+
113
+ Nested fields: `packages[].ecosystem` is `npm`; `packages[].name` is the registry name; `packages[].version` is the exact lockfile version, or the unchanged safe declared registry range when versions_source is declared; optional `packages[].dev` is true for dev-only packages. `supabase.project_ref` identifies the project (or null); `supabase.host` is the validated host (or null); `supabase.key_kind` is anon or publishable; `supabase.key_fingerprint` is the eight-character SHA-256 prefix; `supabase.secret_key_exposed_client_side` is true only for a detected client-exposed secret and replaces kind/fingerprint.
114
+
115
+ All report strings are screened before sending or printing: key-like values (including encoded and Unicode-disguised keys) are dropped, with a local warning naming only the field. A key-like URL is dropped entirely.
116
+
117
+ Never sent: source code, file paths, full lockfiles, .env files, Supabase keys (including anon/publishable), secrets, URL credentials or query strings.
118
+ The request headers x-safetyvibe-site, x-safetyvibe-key and x-safetyvibe-claim-token carry the validated UUID site ID, validated report key and validated one-shot claim token when available; these are not in the JSON body. The server validates public keys too for older clients; this connector sends no Supabase keys.
119
+ Public keys are read only from client-exposed variables (prefixes VITE_, NEXT_PUBLIC_, EXPO_PUBLIC_, PUBLIC_, REACT_APP_, NUXT_PUBLIC_; from the environment or .env files) or a client-side Supabase source file (src/integrations/supabase/client.*, src/lib/supabase.*, lib/supabase.ts, utils/supabase.ts). Files that contain the quoted text server-only or use server anywhere (even in a comment), are named *.server.*, or sit under server/, api/, app/api/, pages/api/, functions/, supabase/functions/, netlify/functions/ or are middleware.* are skipped. Server-only names such as SUPABASE_URL or SUPABASE_ANON_KEY are not selected as connection details. Every client-prefixed variable in process env and .env/.env.local/.env.production/.env.production.local is checked for exposed secrets, as are full secret-shaped runs in the scanned client files, independent of quote pairing (a stray apostrophe in a comment cannot hide a full secret), including full-secret comparisons and validation calls, while ignoring bare prefixes. Full secret shapes embedded in larger client values also set the flag. A key split across concatenated strings is not detected. Any service-role variable name in a .env* file, including SUPABASE_SERVICE_ROLE_KEY and SUPABASE_SERVICE_KEY, produces a separate local warning; its presence alone never sets an exposure flag or adds anything to the body. Local warnings name only the variable or relative client file, never the value, and tell you to rotate exposed secrets in Supabase.
120
+
121
+ Review the entire implementation in `cli.mjs` and `lib.mjs`. There are no dependencies and no install scripts in this package itself.
122
+ By default reports go only to `https://dashboard.safetyvibe.eu/connect/api/v1/report`. The committed rc cannot change the destination.
123
+ An explicit `SAFETYVIBE_API` environment override supports HTTPS, or HTTP on localhost/127.0.0.1/[::1] for local testing. Redirects are refused.
124
+
125
+ ## Changes
126
+
127
+ 0.3.2 (2026-10-19) adds the local `inventory` command, `--project` selection and bounded hints for common subprojects and npm/pnpm workspaces. Summaries distinguish direct declarations from total lockfile rows and show direct dev and confirmed dev-only counts; Yarn v1 resolves direct dev selectors without labelling runtime-shared or unknown transitive rows dev-only. `--json --compact` emits one-line full JSON; `--json=summary` selects the summary with explicit flag precedence. Without a usable lockfile, safe manifest ranges are preserved as `versions_source:"declared"` for local review; real declared reports remain blocked pending dashboard verification. Client-source secret scanning now uses a bounded forward pass and detects full secrets embedded in larger values, comparisons, validation calls and comments while ignoring bare prefixes. BASE44_* and CURSOR_* agent hints select summary output, which includes a Next guidance line.
128
+
129
+ 0.3.1 fixes false secret alarms from Supabase prefix checks while keeping real secret warnings and public fingerprints. Non-TTY and agent/CI runs default to a five-line summary; --json opts into the body. It checks config writability before sending, stops re-sending env claim tokens for linked projects, warns locally about service-role variable names, and exits 2 for bad --out paths even on scan. It adds AI-agent source guidance.
130
+
131
+ 0.3.0 never sends Supabase keys: it reports public-key fingerprints or exposed-secret flags, puts a five-line summary first, adds --summary and safe --out JSON export, and ships MIT licensing and security contact details. 0.2.2 improves response deadlines, registry-only inventory, project selection and app labels, restores flag-only setup, and rejects ambiguous JWT payloads. It reads pnpm v5.4 locks and no longer reads installed node_modules folders: without a readable lockfile no packages are sent. 0.2.1 fixed a bug where a key on a commented .env line could be included; upgrade from 0.2.0.
132
+
133
+ [Documentation](https://safetyvibe.eu/connect) and [changelog / what is sent](https://safetyvibe.eu/connect/changelog).
134
+
135
+ ## Files written and removal
136
+
137
+ Before a setup/scan request, the connector probes directory writability and checks existing config files; a failure sends nothing and exits 1 for setup (0 for scan). Then legacy credentials are migrated best-effort into the local file, the committed api field is dropped and its URL normalized. Dry-run writes only an explicitly requested --out file; status and help never write. After a successful report:
138
+
139
+ - `.safetyviberc.json`: app ID, normalized public URL, optional name and `hook: false`.
140
+ - `.safetyviberc.local.json`: report key and optional fallback claim token; never commit this file. The token is removed once used or rejected as expired/used. Once a valid site ID and report key are present, SAFETYVIBE_CLAIM_TOKEN is not re-sent. Only stored credentials require the local file and ignore entry. The ignore entry is attempted first, then this file is written with mode 0600. An ignore failure warns and fails setup; scan still exits 0. Empty local credentials are removed.
141
+ - `.gitignore`: adds the local config filename.
142
+ - `package.json`: setup normally adds a postinstall report hook. `--no-hook` leaves package.json untouched and persists this preference. Existing unsafe shell scripts are left untouched with a manual instruction.
143
+
144
+ The project directory is cwd. INIT_CWD is used only when its own package.json lists the connector or its postinstall hook and it is not a workspace root. Scan at an npm/Yarn workspace root (package.json workspaces) or pnpm workspace root (pnpm-workspace.yaml with a packages list) that has no app ID in .safetyviberc.json is skipped without network or writes; run setup in the app directory. A root that was linked by running setup there is treated like any app: it reports and updates its rc files. Dry-run still previews the selected directory. An unlinked scan without a key or claim token skips projects without the connector dependency. Requests have one deadline of at most 10 seconds, including the response body, capped at 64 KiB. Failed responses keep the token unless the server reports it expired or already used, and do not install a hook.
145
+
146
+ The postinstall hook runs `safetyvibe-connect scan --quiet || exit 0`: it re-sends the current inventory and public Supabase details using the local report key, suppresses success chatter but keeps safety warnings on stderr, and never fails installation. Without a local key it skips reporting for a linked app unless SAFETYVIBE_KEY or a claim token is supplied.
147
+
148
+ Without terminal access, first add `.safetyviberc.local.json` to `.gitignore`, then write `{"claimToken": "<TOKEN>"}` into `.safetyviberc.local.json` and configure the dependency and postinstall hook. Never put the token in the committed `.safetyviberc.json`.
149
+
150
+ For manual reporting use `npx -y safetyvibe-connect@0.3.2 scan` (pnpm: `pnpm dlx`, bun: `bunx`, yarn: `yarn dlx`).
151
+ Remove the connector dependency if installed, its postinstall command, both rc files, and the local-config .gitignore entry. Keep other postinstall commands.
152
+
153
+ ## Commands
154
+
155
+ | Command | Effect |
156
+ |---|---|
157
+ | `setup [--claim-token T] [--url URL] [--name NAME] [--no-hook]` | Send report, save config, optionally add hook |
158
+ | `setup --dry-run` or `--dry-run` | Preview summary (--json for the body) without network; only --out writes a file |
159
+ | `inventory [--project DIR] [--json] [--compact]` | Local review body/summary; no network or config writes, only explicit --out writes |
160
+ | `scan [--quiet]` | Re-report; exits 0 for install hooks, except bad --out exits 2 |
161
+ | `status` | Show local link state |
162
+ | `help`, `--help`, `-h`, no arguments | Usage only |
163
+ | `--version`, `-v` | Package version only |
164
+
165
+ Flags without a command imply setup. Unknown name, URL, platform and environment fields are omitted, never empty strings.
166
+
167
+ Value flags accept `--flag=value` or `--flag value`, including values starting with a dash. In the space form, a known flag (also with `=...`) is treated as a missing value. Unknown commands/flags and missing values exit 2, except scan exits 0 (a missing --out value still exits 2).
package/cli.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
- import { readFileSync, writeFileSync, chmodSync, unlinkSync, realpathSync } from 'node:fs';
2
+ import { readFileSync, writeFileSync, chmodSync, unlinkSync, realpathSync, accessSync, statSync, constants } from 'node:fs';
3
3
  import { join, resolve, relative, dirname, isAbsolute, sep } from 'node:path';
4
- import { buildReport, findSupabase, normPublicUrl, urlNeedsCleaning, withHook, HOOK, packageManager, readPackages, screenString, secretLike, takeScreenWarnings } from './lib.mjs';
4
+ import { buildReport, findSupabase, normPublicUrl, urlNeedsCleaning, withHook, HOOK, packageManager, readPackages, screenString, secretLike, takeScreenWarnings, inventoryCounts, discoverProjects, selectProject } from './lib.mjs';
5
5
 
6
6
  const RC = '.safetyviberc.json';
7
7
  const LOCAL = '.safetyviberc.local.json';
@@ -9,7 +9,7 @@ const DEFAULT_API = 'https://dashboard.safetyvibe.eu/connect/api/v1';
9
9
  const REPORT_KEY = /^svk_[A-Za-z0-9_-]{32}$/;
10
10
  const CLAIM_TOKEN = /^[A-Za-z0-9_-]{16,64}$/;
11
11
  const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
12
- const USAGE = 'Usage: safetyvibe-connect setup|scan|status|help [--claim-token TOKEN] [--url URL] [--name NAME] [--dry-run] [--summary] [--out FILE.json] [--quiet] [--no-hook] [--help/-h] [--version/-v]\n--dry-run: summary on stderr, JSON on stdout; --out FILE.json writes the body without sending.';
12
+ const USAGE = 'Usage: safetyvibe-connect setup|scan|inventory|status|help [--claim-token TOKEN] [--url URL] [--name NAME] [--project DIR] [--dry-run] [--summary|--json=summary] [--json] [--compact] [--out FILE.json] [--quiet] [--no-hook] [--help/-h] [--version/-v]\n--dry-run and inventory: no network; non-TTY/agent default is summary. --json: full body on stdout, summary on stderr. --compact: one-line JSON. Precedence: --summary/--json=summary > --out > --json; --out still exports the body. --dry-run --out FILE.json writes the body without sending. inventory --json --compact is the local inventory route; paste receiver compatibility requires separate verification.';
13
13
  const argv = process.argv.slice(2);
14
14
  const flags = {};
15
15
  let cmd = ''; let error = '';
@@ -17,25 +17,27 @@ let cmd = ''; let error = '';
17
17
  for (let i = 0; i < argv.length; i++) {
18
18
  const arg = argv[i];
19
19
  if (!arg.startsWith('-')) {
20
- if (cmd || !['setup', 'scan', 'status', 'help'].includes(arg)) { error = 'Unknown command or argument.'; break; }
20
+ if (cmd || !['setup', 'scan', 'inventory', 'status', 'help'].includes(arg)) { error = 'Unknown command or argument.'; break; }
21
21
  cmd = arg; continue;
22
22
  }
23
23
  const [raw, ...rest] = arg.split('=');
24
24
  const name = ({ '-h': 'help', '-v': 'version' })[raw] || raw.replace(/^--/, '');
25
- if (['claim-token', 'url', 'name', 'out'].includes(name)) {
25
+ if (['claim-token', 'url', 'name', 'out', 'project'].includes(name)) {
26
26
  const value = rest.length ? rest.join('=') : argv[++i];
27
- if (!value || (!rest.length && /^(?:--(?:dry-run|quiet|no-hook|help|version|claim-token|url|name|summary|out)|-h|-v)(?:=|$)/.test(value))) { error = 'Missing value for --' + name + '.'; break; }
27
+ if (!value || (!rest.length && /^(?:--(?:dry-run|quiet|no-hook|help|version|claim-token|url|name|summary|json|compact|out|project)|-h|-v)(?:=|$)/.test(value))) { error = 'Missing value for --' + name + '.'; break; }
28
28
  flags[name] = value;
29
- } else if (['dry-run', 'quiet', 'no-hook', 'help', 'version', 'summary'].includes(name) && !rest.length) flags[name] = true;
29
+ } else if (name === 'json' && rest.join('=') === 'summary') flags.summary = true;
30
+ else if (['dry-run', 'quiet', 'no-hook', 'help', 'version', 'summary', 'json', 'compact'].includes(name) && !rest.length) flags[name] = true;
30
31
  else { error = 'Unknown flag.'; break; }
31
32
  }
32
33
  if (error) {
33
34
  const scan = cmd === 'scan' || argv[0] === 'scan';
34
35
  if (!(scan && argv.includes('--quiet'))) console.error(USAGE + '\n' + error);
35
- process.exit(scan ? 0 : 2);
36
+ process.exit(scan && !error.startsWith('Missing value for --out') ? 0 : 2);
36
37
  }
37
38
  if (!argv.length || cmd === 'help' || flags.help) { console.log(USAGE); process.exit(0); }
38
39
  if (!cmd) cmd = 'setup';
40
+ if (cmd === 'inventory') flags['dry-run'] = true;
39
41
  const VERSION = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8')).version;
40
42
  if (flags.version) { console.log(VERSION); process.exit(0); }
41
43
  function installed(dir, hook = false) {
@@ -46,8 +48,11 @@ function workspaceRoot(dir) {
46
48
  // pnpm 10 also writes pnpm-workspace.yaml for build settings in single apps: only a packages list counts.
47
49
  try { return /^packages\s*:/m.test(readFileSync(join(dir, 'pnpm-workspace.yaml'), 'utf8')); } catch { return false; }
48
50
  }
49
- const root = process.env.INIT_CWD && process.env.INIT_CWD !== process.cwd() && !workspaceRoot(process.env.INIT_CWD) && installed(process.env.INIT_CWD, true) ? process.env.INIT_CWD : process.cwd();
50
- const say = (...args) => { if (!flags.quiet && !flags['dry-run'] && !flags.summary) console.log(...args); };
51
+ let root = process.env.INIT_CWD && process.env.INIT_CWD !== process.cwd() && !workspaceRoot(process.env.INIT_CWD) && installed(process.env.INIT_CWD, true) ? process.env.INIT_CWD : process.cwd();
52
+ try { root = selectProject(root, flags.project); } catch { console.error('Invalid --project: choose an existing project directory inside the current project root.'); process.exit(cmd === 'scan' ? 0 : 2); }
53
+ const summaryOnly = flags.summary || (!flags.json && !flags.out && (!process.stdout.isTTY || process.env.TERM === 'dumb' || (process.env.CI && !/^(false|0)$/i.test(process.env.CI)) || Object.keys(process.env).some(k => /^(CLAUDECODE|REPLIT.*|REPL_ID|LOVABLE.*|BOLT.*|CODEX.*|EMERGENT.*|BASE44_.*|CURSOR_.*|GITHUB_ACTIONS|GITLAB_CI|BUILD_ID|CONTINUOUS_INTEGRATION)$/.test(k))));
54
+ const machineJson = flags.json && !summaryOnly && !flags.out;
55
+ const say = (...args) => { if (!flags.quiet && !flags['dry-run'] && !flags.summary && (cmd === 'status' || !summaryOnly)) (machineJson ? console.error : console.log)(...args); };
51
56
  const warnings = [];
52
57
  const alert = message => warnings.push(message);
53
58
  const flushWarnings = () => { for (const message of [...takeScreenWarnings(), ...warnings.splice(0)]) console.error(message); };
@@ -80,6 +85,20 @@ function writeRc(rc) {
80
85
  return ignored ? 'saved' : 'unignored';
81
86
  }
82
87
 
88
+ function writableConfig() {
89
+ const probe = join(root, '.safetyvibe-write-' + process.pid); let created = false;
90
+ try {
91
+ for (const file of [RC, LOCAL]) {
92
+ try { if (!statSync(join(root, file)).isFile()) return false; accessSync(join(root, file), constants.W_OK); }
93
+ catch (e) { if (e.code !== 'ENOENT') return false; }
94
+ }
95
+ writeFileSync(probe, '', { flag: 'wx', mode: 0o600 }); created = true;
96
+ unlinkSync(probe); created = false;
97
+ return true;
98
+ } catch { return false; }
99
+ finally { if (created) try { unlinkSync(probe); } catch {} }
100
+ }
101
+
83
102
  function addHook() {
84
103
  let pj; let original;
85
104
  try { original = readFileSync(join(root, 'package.json'), 'utf8'); pj = JSON.parse(original); } catch { return 'skipped (no readable package.json)'; }
@@ -98,16 +117,20 @@ function apiEndpoint() {
98
117
  if (u.username || u.password || u.search || u.hash || !(u.protocol === 'https:' || (u.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(u.hostname)))) throw new Error('Invalid SAFETYVIBE_API');
99
118
  return u.href.replace(/\/+$/, '') + '/report';
100
119
  }
101
- function summary(body, last) {
120
+ function guidance(message) {
121
+ if (!flags.quiet) ((flags['dry-run'] || flags.json || flags.out) && !summaryOnly ? console.error : console.log)('Next: ' + message);
122
+ }
123
+ function summary(body, last, inventory) {
102
124
  const label = value => safeText(value).slice(0, 32);
125
+ const counts = inventoryCounts(root, inventory);
103
126
  const lines = [
104
127
  'Platform: ' + label(body.platform || 'unknown'),
105
- `Lockfile: ${label(body.lockfile || 'none')}, ${body.packages.length} package(s) (${body.packages.filter(p => p.dev).length} dev-only)`,
128
+ `Lockfile: ${label(body.lockfile || 'none')}, ${counts.direct} direct / ${counts.total} ${body.versions_source === 'declared' ? 'declared' : 'total'} (${counts.directDev} direct dev; ${counts.confirmedDev} confirmed dev-only)`,
106
129
  'Supabase: ' + (body.supabase?.secret_key_exposed_client_side ? 'SECRET key exposed client-side (flag only)' : body.supabase ? body.supabase.key_kind + ' key (fingerprint only)' : 'none'),
107
130
  'Keys sent: 0', last,
108
131
  ];
109
132
  if (lines.join('\n').length >= 400) lines[4] = 'Connected: open your SafetyVibe dashboard';
110
- if (flags['dry-run'] || !flags.quiet) (flags['dry-run'] && !flags.summary ? console.error : console.log)(lines.join('\n'));
133
+ if (flags['dry-run'] || !flags.quiet) ((flags['dry-run'] || flags.json || flags.out) && !summaryOnly ? console.error : console.log)(lines.join('\n'));
111
134
  flushWarnings();
112
135
  }
113
136
  function writeBody(json) {
@@ -123,9 +146,10 @@ async function report() {
123
146
  const setup = cmd === 'setup';
124
147
  if (!setup && !flags['dry-run'] && workspaceRoot(root) && !readRc().site) { say('SafetyVibe: workspace root; run setup in an app directory.'); return 0; }
125
148
  let destination;
126
- try { destination = apiEndpoint(); } catch { alert('Invalid SAFETYVIBE_API: use HTTPS or local HTTP.'); return 1; }
149
+ try { destination = cmd === 'inventory' ? 'Local inventory (no destination)' : apiEndpoint(); } catch { alert('Invalid SAFETYVIBE_API: use HTTPS or local HTTP.'); return 1; }
127
150
  const rc = readRc();
128
- const rawToken = flags['claim-token'] || rc.claimToken || process.env.SAFETYVIBE_CLAIM_TOKEN || '';
151
+ const linked = typeof rc.site === 'string' && UUID.test(rc.site) && REPORT_KEY.test(rc.key || process.env.SAFETYVIBE_KEY || '');
152
+ const rawToken = flags['claim-token'] || rc.claimToken || (!linked && process.env.SAFETYVIBE_CLAIM_TOKEN) || '';
129
153
  const token = typeof rawToken === 'string' && CLAIM_TOKEN.test(rawToken) ? rawToken : '';
130
154
  if (rawToken && !token) alert('Invalid claim token in --claim-token, SAFETYVIBE_CLAIM_TOKEN or .safetyviberc.local.json/.safetyviberc.json.');
131
155
  const givenUrl = flags.url || process.env.SAFETYVIBE_URL || rc.url || '';
@@ -139,27 +163,41 @@ async function report() {
139
163
  if (typeof rc.site === 'string' && UUID.test(rc.site)) headers['x-safetyvibe-site'] = rc.site;
140
164
  if (rc.site && key) headers['x-safetyvibe-key'] = key;
141
165
  if (token) headers['x-safetyvibe-claim-token'] = token;
142
- for (const finding of findSupabase(root).warnings) alert(`WARNING: ${safeName(finding)} holds a Supabase secret/service_role key in client-exposed config; every visitor can read it. Rotate it in Supabase now. Only a flag is sent, never the key.`);
166
+ const supabase = findSupabase(root);
167
+ for (const name of supabase.serviceVariables) alert(`found a service-role key variable (${safeName(name)}); make sure it is never in client code`);
168
+ for (const finding of supabase.warnings) alert(`WARNING: ${safeName(finding)} holds a Supabase secret/service_role key in client-exposed config; every visitor can read it. Rotate it in Supabase now. Only a flag is sent, never the key.`);
143
169
  const lockState = readPackages(root);
144
- if (lockState.noLockfile && !flags.quiet) alert('No readable lockfile found (package-lock.json, pnpm-lock.yaml, yarn.lock or bun.lock); dependency check unavailable.'
170
+ if (lockState.noLockfile && !flags.quiet) alert('No readable lockfile found (package-lock.json, pnpm-lock.yaml, yarn.lock or bun.lock); using safe package.json declarations, not resolved versions.'
145
171
  + (lockState.binaryBun ? ' Run `bun install --save-text-lockfile` to create bun.lock.' : ''));
146
- const json = JSON.stringify(body, null, 2) + '\n';
172
+ const projects = discoverProjects(root);
173
+ if (projects.length) alert('Projects available (not combined): ' + projects.map(p => './' + p).join(', ') + '. Choose --project DIR or run in that directory.'
174
+ + (body.packages.length === 0 ? ' No packages found here.' : ''));
175
+ const json = JSON.stringify(body, null, flags.compact ? undefined : 2) + '\n';
147
176
  try { writeBody(json); } catch { alert('Could not write --out: use a new .json file inside the project root with an existing parent directory.'); return 2; }
148
177
  if (flags['dry-run']) {
149
178
  // Keep the five summary lines first; warnings follow the destination details.
150
179
  const pending = [...takeScreenWarnings(), ...warnings.splice(0)];
151
- summary(body, 'Dry run: nothing sent');
152
- if (!flags.summary) {
180
+ summary(body, 'Dry run: nothing sent', lockState);
181
+ guidance('Review locally with inventory --json --compact; verify receiver support before pasting.');
182
+ if (!summaryOnly) {
153
183
  console.error(body.supabase?.secret_key_exposed_client_side ? 'A secret key is exposed in client code; only that fact is reported, never the key.' : 'No keys or secrets are sent. The anon key is public by design. Only a fingerprint is sent.');
154
184
  console.error(safeText(destination) + ' headers: ' + Object.keys(headers).join(', ') + ' (values hidden)');
155
185
  }
156
186
  warnings.push(...pending); flushWarnings();
157
- if (!flags.summary && !flags.out) process.stdout.write(json);
187
+ if (!summaryOnly && !flags.out) process.stdout.write(json);
158
188
  return 0;
159
189
  }
190
+ // Receiver 5406454 accepts ordinary ranges, but whitespace and persistence
191
+ // compatibility still need a separate receiver fix and integration tests.
192
+ // Keep declared bodies local until those are verified (see inventory contract).
193
+ if (body.versions_source === 'declared') {
194
+ alert('Declared-range receiver support is not verified; nothing sent. Use inventory --json --compact to review locally, or supply a supported lockfile.'); return 1;
195
+ }
160
196
  if (!setup && !rc.site && !key && !token && !installed(root)) {
161
197
  say('SafetyVibe: no project connection or connector dependency; skipping report.'); return 0;
162
198
  }
199
+ if (machineJson) process.stdout.write(json);
200
+ if (!writableConfig()) { alert('SafetyVibe: project config is not writable (.safetyviberc.json / .safetyviberc.local.json); nothing sent. Run setup in a writable checkout.'); return 1; }
163
201
  let saveFailed = false;
164
202
  const legacy = readJson(RC);
165
203
  if (['key', 'claimToken', 'api'].some(k => Object.hasOwn(legacy, k)) || (legacy.url && legacy.url !== normPublicUrl(legacy.url))) {
@@ -170,7 +208,7 @@ async function report() {
170
208
  const manager = packageManager(root);
171
209
  const runner = { npm: 'npx', pnpm: 'pnpm dlx', yarn: 'yarn dlx', bun: 'bunx' }[manager];
172
210
  if (rc.site && !key && !token) {
173
- say('SafetyVibe: report key is not on this machine. Set SAFETYVIBE_KEY or get a new prompt and run ' + runner + ' safetyvibe-connect setup --claim-token <TOKEN>.');
211
+ alert('SafetyVibe: report key is not on this machine. Set SAFETYVIBE_KEY or get a new prompt and run ' + runner + ' safetyvibe-connect setup --claim-token <TOKEN>.');
174
212
  return setup ? 1 : 0;
175
213
  }
176
214
  let response; let b;
@@ -218,7 +256,7 @@ async function report() {
218
256
  if (u.protocol === 'https:' && !u.username && !u.password && u.origin === new URL(destination).origin) claim = u.href; // same-origin, validated; a random claim code must not be dropped by the key screen
219
257
  }
220
258
  } catch {}
221
- summary(body, 'Connected: ' + (b.claimed ? 'https://dashboard.safetyvibe.eu' : claim || 'open your SafetyVibe dashboard'));
259
+ summary(body, 'Connected: ' + (b.claimed ? 'https://dashboard.safetyvibe.eu' : claim || 'open your SafetyVibe dashboard'), lockState);
222
260
  const next = { ...rc, site: b.site, url, ...(flags.name ? { name: body.name } : {}), ...(flags['no-hook'] ? { hook: false } : {}) };
223
261
  if (typeof b.key === 'string' && REPORT_KEY.test(b.key)) next.key = b.key;
224
262
  else if (b.key) alert('Invalid report key in server response.');
@@ -237,11 +275,12 @@ async function report() {
237
275
  followup = 'SafetyVibe: postinstall hook ' + hook + '.';
238
276
  } else if (setup) followup = 'Re-report manually: ' + runner + ' safetyvibe-connect scan';
239
277
  if (saveFailed || saved === 'unignored') return 1;
240
- if (followup) say(followup);
278
+ // A changed package.json is always reported, even in summary mode.
279
+ guidance(followup || 'Re-report manually: ' + runner + ' safetyvibe-connect scan');
241
280
  return 0;
242
281
  }
243
282
  async function main() {
244
283
  if (cmd === 'status') { const rc = readRc(); say(rc.site ? 'Linked to SafetyVibe app ' + safeText(rc.site) + (normPublicUrl(rc.url) ? ' (' + normPublicUrl(rc.url) + ')' : '') + '.' : 'Not linked.'); return 0; }
245
284
  return report();
246
285
  }
247
- main().then(code => { flushWarnings(); process.exit(cmd === 'scan' ? 0 : code); }, () => { alert('SafetyVibe: could not complete report.'); flushWarnings(); process.exit(cmd === 'scan' ? 0 : 1); });
286
+ main().then(code => { flushWarnings(); process.exit(cmd === 'scan' && code !== 2 ? 0 : code); }, () => { alert('SafetyVibe: could not complete report.'); flushWarnings(); process.exit(cmd === 'scan' ? 0 : 1); });
package/lib.mjs CHANGED
@@ -6,8 +6,8 @@
6
6
  // x-safetyvibe-claim-token, only to the default API or SAFETYVIBE_API
7
7
  // (https, or http on localhost); redirects refused. Never source code or any Supabase keys.
8
8
  import { createHash } from 'node:crypto';
9
- import { readFileSync, existsSync, statSync } from 'node:fs';
10
- import { join, dirname, sep, relative } from 'node:path';
9
+ import { readFileSync, existsSync, statSync, readdirSync, realpathSync } from 'node:fs';
10
+ import { join, dirname, sep, relative, resolve, isAbsolute } from 'node:path';
11
11
 
12
12
  const MAX_PACKAGES = 5000;
13
13
  const read = (f) => { try { return readFileSync(f, 'utf8'); } catch { return null; } };
@@ -48,6 +48,7 @@ export function fromPackageLock(json, { platform = process.platform, arch = proc
48
48
  export function fromBunLock(text) {
49
49
  const clean = String(text).replace(/^\s*\/\/.*$/gm, '').replace(/,(\s*[}\]])/g, '$1');
50
50
  const json = JSON.parse(clean);
51
+ if (!json || !json.packages || typeof json.packages !== 'object' || Array.isArray(json.packages)) throw new Error('Unrecognized bun lock');
51
52
  const out = [];
52
53
  for (const v of Object.values((json && json.packages) || {})) {
53
54
  const spec = Array.isArray(v) ? v[0] : null;
@@ -61,8 +62,9 @@ export function fromBunLock(text) {
61
62
  }
62
63
 
63
64
  // yarn.lock v1 ("name@range":\n version "x") and berry ("name@npm:range":\n version: x)
64
- export function fromYarnLock(text) {
65
- const out = []; const seen = new Set(); const berry = /^__metadata:/m.test(text);
65
+ export function fromYarnLock(text, manifest = {}) {
66
+ const out = []; const seen = new Map(); const berry = /^__metadata:/m.test(text);
67
+ const records = []; const selectors = new Map();
66
68
  for (const block of String(text).split(/(?=^\S)/m)) {
67
69
  const lines = block.split(/\r?\n/); const head = lines.shift();
68
70
  if (!head?.endsWith(':') || head.startsWith('#') || head.startsWith('__metadata')) continue;
@@ -71,16 +73,106 @@ export function fromYarnLock(text) {
71
73
  const resolved = /^\s+resolved\s+["']?([^"'\s]+)/m.exec(block)?.[1];
72
74
  const resolution = /^\s+resolution:\s+"?([^"\s]+)/m.exec(block)?.[1];
73
75
  if (!semver(version) || (berry ? !/^(?:@[^/]+\/)?[^@:/]+@npm:/.test(resolution || '') : resolved != null && !registryTarball(resolved))) continue;
76
+ const record = { specs, rows: [], dependencies: [] };
77
+ // Yarn v1 records the exact requested selector for each dependency edge.
78
+ for (const [, name, range] of block.matchAll(/^ {4}["']?([^\s"']+)["']?\s+["']?([^\s"']+)["']?\s*$/gm)) record.dependencies.push(name + '@' + range);
74
79
  for (const spec of new Set(specs)) {
75
80
  const at = spec.indexOf('@', spec.startsWith('@') ? 1 : 0);
76
81
  if (at < 1 || !(berry ? spec.slice(at + 1).startsWith('npm:') : registrySpec(spec.slice(at + 1)))) continue;
77
82
  const name = spec.slice(0, at); const id = name + '@' + version;
78
- if (!seen.has(id)) { seen.add(id); out.push({ name, version }); }
83
+ if (!seen.has(id)) { const row = { name, version }; seen.set(id, row); out.push(row); }
84
+ record.rows.push(seen.get(id)); selectors.set(spec, record);
79
85
  }
86
+ records.push(record);
87
+ }
88
+ if (!berry) {
89
+ const production = new Set();
90
+ const queue = Object.entries({ ...manifest.dependencies, ...manifest.optionalDependencies }).map(([n, r]) => n + '@' + r);
91
+ for (let i = 0; i < queue.length; i++) {
92
+ const record = selectors.get(queue[i]);
93
+ if (!record || production.has(record)) continue;
94
+ production.add(record); queue.push(...record.dependencies);
95
+ }
96
+ const productionRows = new Set([...production].flatMap(r => r.rows));
97
+ for (const [name, range] of Object.entries(manifest.devDependencies || {})) {
98
+ const record = selectors.get(name + '@' + range);
99
+ if (record) for (const row of record.rows) if (!productionRows.has(row)) row.dev = true;
100
+ }
101
+ // Transitive dev status is unknown unless the lock format records it; never
102
+ // infer that every transitive row is dev-only from an all-dev root manifest.
80
103
  }
81
104
  return out;
82
105
  }
83
106
 
107
+ export function declaredPackages(root) {
108
+ const manifest = readJson(join(root, 'package.json')) || {};
109
+ const runtime = { ...manifest.dependencies, ...manifest.optionalDependencies };
110
+ return Object.entries({ ...manifest.devDependencies, ...runtime }).flatMap(([name, version]) => {
111
+ if (!/^(@[\w.-]+\/)?[\w.-]+$/.test(name) || !screenString(name, 'package name', undefined, true)
112
+ || typeof version !== 'string' || version.length > 200 || !screenString(version, 'package version')
113
+ || !registrySpec(version) || version.includes(':')) return [];
114
+ return [{ ecosystem: 'npm', name, version, ...(!Object.hasOwn(runtime, name) ? { dev: true } : {}) }];
115
+ });
116
+ }
117
+
118
+ export function inventoryCounts(root, inventory = readPackages(root)) {
119
+ const direct = declaredPackages(root);
120
+ return { direct: direct.length, directDev: direct.filter(p => p.dev).length,
121
+ total: inventory.total ?? inventory.packages.length, confirmedDev: inventory.packages.filter(p => p.dev).length };
122
+ }
123
+
124
+ function workspaceGlobs(root) {
125
+ const ws = readJson(join(root, 'package.json'))?.workspaces;
126
+ const block = /^packages:\s*\r?\n((?:[ \t]+.*\r?\n?)*)/m.exec(read(join(root, 'pnpm-workspace.yaml')) || '')?.[1] || '';
127
+ return [...(Array.isArray(ws) ? ws : Array.isArray(ws?.packages) ? ws.packages : []),
128
+ ...[...block.matchAll(/^\s+-\s+['"]?([^'"\r\n]+?)['"]?\s*$/gm)].map(m => m[1])].filter(g => typeof g === 'string');
129
+ }
130
+ function matchesWorkspace(path, globs) {
131
+ const segments = path.split('/');
132
+ const matches = g => {
133
+ const parts = g.replace(/^\.\//, '').replace(/\/$/, '').split('/');
134
+ const segmentMatches = (pattern, value) => new RegExp('^' + pattern.split('*').map(s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('[^/]*') + '$').test(value);
135
+ const match = (i, j) => i === parts.length ? j === segments.length
136
+ : parts[i] === '**' ? match(i + 1, j) || (j < segments.length && match(i, j + 1))
137
+ : j < segments.length && segmentMatches(parts[i], segments[j]) && match(i + 1, j + 1);
138
+ return match(0, 0);
139
+ };
140
+ return globs.some(g => !g.startsWith('!') && matches(g)) && !globs.some(g => g.startsWith('!') && matches(g.slice(1)));
141
+ }
142
+
143
+ // Discovery is advice only. Never merge subprojects or silently change a linked app.
144
+ export function discoverProjects(root) {
145
+ const globs = workspaceGlobs(root); const out = []; const queue = [{ dir: root, path: '', depth: 0 }];
146
+ let visited = 0;
147
+ for (let i = 0; i < queue.length && visited < 500; i++) {
148
+ const parent = queue[i]; let entries = [];
149
+ try { entries = readdirSync(parent.dir, { withFileTypes: true }); } catch { continue; }
150
+ for (const entry of entries) {
151
+ if (++visited > 500) break;
152
+ if (!entry.isDirectory() || entry.isSymbolicLink() || entry.name.startsWith('.') || entry.name === 'node_modules') continue;
153
+ const path = parent.path ? parent.path + '/' + entry.name : entry.name;
154
+ const dir = join(parent.dir, entry.name);
155
+ const common = /^(frontend|client|web)$|^apps\/[^/]+$/.test(path);
156
+ const excluded = globs.some(g => g.startsWith('!') && matchesWorkspace(path, [g.slice(1)]));
157
+ if (!excluded && (common || matchesWorkspace(path, globs)) && readJson(join(dir, 'package.json'))) {
158
+ // Never print paths carrying key-shaped folder names.
159
+ if (/^[A-Za-z0-9@/._ -]{1,200}$/.test(path) && screenString(path, 'project hint')) out.push(path);
160
+ }
161
+ if (parent.depth < 5 && (globs.length || path === 'apps')) queue.push({ dir, path, depth: parent.depth + 1 });
162
+ }
163
+ }
164
+ return out.sort();
165
+ }
166
+
167
+ export function selectProject(root, hint) {
168
+ if (!hint) return root;
169
+ const base = realpathSync(root); const target = realpathSync(resolve(root, hint));
170
+ const path = relative(base, target);
171
+ if (isAbsolute(hint) || !path || path === '..' || path.startsWith('..' + sep) || isAbsolute(path)
172
+ || !statSync(target).isDirectory() || !readJson(join(target, 'package.json'))) throw new Error('project');
173
+ return target;
174
+ }
175
+
84
176
  export function fromPnpmLock(text) {
85
177
  const out = []; const section = /^packages:\s*\r?\n([\s\S]*?)(?=^\S|(?![\s\S]))/m.exec(text)?.[1] || '';
86
178
  for (const block of section.split(/(?=^ {2}\S)/m)) {
@@ -94,32 +186,24 @@ export function fromPnpmLock(text) {
94
186
 
95
187
  export function readPackages(root) {
96
188
  let list = []; let source = 'none';
189
+ const npmLock = t => {
190
+ const json = JSON.parse(t);
191
+ if (!json || !['packages', 'dependencies'].some(k => json[k] && typeof json[k] === 'object' && !Array.isArray(json[k]))) throw new Error('Unrecognized npm lock');
192
+ return fromPackageLock(json);
193
+ };
97
194
  const tries = [
98
- ['package-lock.json', (t) => fromPackageLock(JSON.parse(t))],
99
- ['npm-shrinkwrap.json', (t) => fromPackageLock(JSON.parse(t))],
195
+ ['package-lock.json', npmLock],
196
+ ['npm-shrinkwrap.json', npmLock],
100
197
  ['bun.lock', fromBunLock],
101
198
  ['pnpm-lock.yaml', fromPnpmLock],
102
- ['yarn.lock', fromYarnLock],
199
+ // A shared workspace lock can contain runtime users outside this manifest.
200
+ // Without a complete cross-workspace graph its dev-only status is unknown.
201
+ ['yarn.lock', t => fromYarnLock(t, lockRoot === root && !workspaceGlobs(lockRoot).length ? readJson(join(root, 'package.json')) || {} : {})],
103
202
  ];
104
203
  let lockRoot = root;
105
204
  let current = root;
106
205
  for (let depth = 0; depth <= 6; depth++) {
107
- const ws = readJson(join(current, 'package.json'))?.workspaces;
108
- const yaml = read(join(current, 'pnpm-workspace.yaml')) || '';
109
- const block = /^packages:\s*\r?\n((?:[ \t]+.*\r?\n?)*)/m.exec(yaml)?.[1] || '';
110
- const globs = [...(Array.isArray(ws) ? ws : Array.isArray(ws?.packages) ? ws.packages : []),
111
- ...[...block.matchAll(/^\s+-\s+['"]?([^'"\r\n]+?)['"]?\s*$/gm)].map(m => m[1])];
112
- const segments = relative(current, root).split(sep);
113
- const matchesGlob = g => {
114
- const parts = g.replace(/^\.\//, '').replace(/\/$/, '').split('/');
115
- const segmentMatches = (pattern, value) => new RegExp('^' + pattern.split('*').map(s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('[^/]*') + '$').test(value);
116
- const match = (i, j) => i === parts.length ? j === segments.length
117
- : parts[i] === '**' ? match(i + 1, j) || (j < segments.length && match(i, j + 1))
118
- : j < segments.length && segmentMatches(parts[i], segments[j]) && match(i + 1, j + 1);
119
- return match(0, 0);
120
- };
121
- const workspace = globs.some(g => typeof g === 'string' && !g.startsWith('!') && matchesGlob(g))
122
- && !globs.some(g => typeof g === 'string' && g.startsWith('!') && matchesGlob(g.slice(1)));
206
+ const workspace = matchesWorkspace(relative(current, root).split(sep).join('/'), workspaceGlobs(current));
123
207
  if (current === root || workspace) {
124
208
  lockRoot = current;
125
209
  if (workspace || tries.some(([f]) => existsSync(join(current, f))) || existsSync(join(current, 'bun.lockb'))) break;
@@ -137,11 +221,10 @@ export function readPackages(root) {
137
221
  if (list.length) { source = file; break; }
138
222
  // Recognized locks remain authoritative when every entry was excluded; falling back
139
223
  // to installed packages could reintroduce names from non-registry sources.
140
- if (ok && (/\.json$|^bun\.lock$/.test(file) || (file === 'pnpm-lock.yaml' && /^packages:/m.test(t)) || (file === 'yarn.lock' && /^(?:__metadata:|# yarn lockfile v1|\S.*:\s*$)/m.test(t)))) { source = file; break; }
224
+ if (ok && (/\.json$|^bun\.lock$/.test(file) || (file === 'pnpm-lock.yaml' && (/^packages:/m.test(t) || /^lockfileVersion:/m.test(t) && /^importers:/m.test(t))) || (file === 'yarn.lock' && /^(?:__metadata:|# yarn lockfile v1|\S.*:\s*$)/m.test(t)))) { source = file; break; }
141
225
  }
142
- // No node_modules fallback: installed folders do not say where a package came from, so a git/tarball/
143
- // file dependency could be reported under its name. Without a readable lockfile no packages are sent and
144
- // lockfile stays 'none', so the dashboard keeps the last known inventory.
226
+ // No node_modules fallback. Only safe registry ranges declared in this project's
227
+ // manifest may supplement an absent/unusable lock, never a recognized empty lock.
145
228
  const missing = source === 'none' ? { noLockfile: true, ...(existsSync(join(lockRoot, 'bun.lockb')) || existsSync(join(root, 'bun.lockb')) ? { binaryBun: true } : {}) } : {};
146
229
  const seen = new Map(); const pkgs = [];
147
230
  for (const p of list) {
@@ -156,7 +239,8 @@ export function readPackages(root) {
156
239
  seen.set(k, row); pkgs.push(row);
157
240
 
158
241
  }
159
- return { packages: pkgs.slice(0, MAX_PACKAGES), source, ...missing, ...(pkgs.length > MAX_PACKAGES ? { total: pkgs.length, truncated: true } : {}) };
242
+ const selected = source === 'none' ? declaredPackages(root) : pkgs;
243
+ return { packages: selected.slice(0, MAX_PACKAGES), source, ...missing, ...(selected.length > MAX_PACKAGES ? { total: selected.length, truncated: true } : {}) };
160
244
  }
161
245
 
162
246
  // ---- Supabase (project URL + PUBLIC key only) ------------------------------
@@ -189,17 +273,23 @@ export function keyKind(value) {
189
273
  }
190
274
  export function parseEnv(text) {
191
275
  const env = {};
192
- for (const line of String(text || '').split(/\r?\n/)) {
276
+ const lines = String(text || '').split(/\r?\n/);
277
+ for (let i = 0; i < lines.length; i++) {
278
+ const line = lines[i];
193
279
  const m = line.match(/^\s*(?:export\s+)?([\w.-]+)\s*=\s*(.*)$/);
194
280
  if (!m) continue;
195
- const value = m[2]; const quote = value[0];
281
+ let value = m[2]; const quote = value[0];
196
282
  if (["'", '"', '`'].includes(quote)) {
197
283
  let end = 1;
198
- for (; end < value.length; end++) {
284
+ for (; ; end++) {
285
+ if (end >= value.length) {
286
+ if (i + 1 === lines.length) break;
287
+ value += '\n' + lines[++i];
288
+ }
199
289
  if (quote === '"' && value[end] === '\\') { end++; continue; }
200
290
  if (value[end] === quote) break;
201
291
  }
202
- if (end === value.length) continue;
292
+ if (end >= value.length) continue;
203
293
  env[m[1]] = value.slice(1, end);
204
294
  } else env[m[1]] = value.split('#')[0].trim();
205
295
  }
@@ -211,7 +301,21 @@ export function parseEnv(text) {
211
301
  const CLIENT_PREFIXES = ['VITE_', 'NEXT_PUBLIC_', 'EXPO_PUBLIC_', 'PUBLIC_', 'REACT_APP_', 'NUXT_PUBLIC_'];
212
302
  const URL_VARS = CLIENT_PREFIXES.map(p => p + 'SUPABASE_URL');
213
303
  const KEY_VARS = CLIENT_PREFIXES.flatMap(p => [p + 'SUPABASE_PUBLISHABLE_KEY', p + 'SUPABASE_ANON_KEY']);
214
- const exposedSupabaseSecret = value => typeof value === 'string' && (value.startsWith('sb_secret_') || jwtPayload(value)?.role === 'service_role');
304
+ // Full shapes only: a bare prefix is validation code, but a full key in a comparison,
305
+ // comment or larger literal is still shipped to visitors. Bounded JWT candidates keep
306
+ // scanning linear even for large minified files with malformed/unterminated literals.
307
+ const secretTokens = /sb_secret_[A-Za-z0-9_-]{20,}|(?<![A-Za-z0-9_-])[A-Za-z0-9_-]{1,2000}\.[A-Za-z0-9_-]{1,2000}\.[A-Za-z0-9_-]{1,2000}/g;
308
+ function exposedSupabaseSecret(value, keySlot = false) {
309
+ if (typeof value !== 'string') return false;
310
+ for (const [token] of value.matchAll(secretTokens)) {
311
+ if (token.startsWith('sb_secret_')) return true;
312
+ const role = jwtPayload(token)?.role;
313
+ if (role === 'service_role' || (keySlot && typeof role === 'string' && role !== 'anon')) return true;
314
+ }
315
+ return false;
316
+ }
317
+ // A value assigned to a publishable/anon key name must be public: any other JWT role there is a secret.
318
+ const secretInKeySlot = value => exposedSupabaseSecret(value, true);
215
319
 
216
320
  // Only clearly client-side source files: what the browser bundle already ships to every visitor.
217
321
  const SERVER_PATH = /(^|\/)(server|api|functions)\/|(^|\/)app\/api\/|(^|\/)pages\/api\/|(^|\/)supabase\/functions\/|(^|\/)netlify\/functions\/|(^|\/)middleware\.[cm]?[jt]sx?$|\.server\.[cm]?[jt]sx?$/i;
@@ -222,16 +326,18 @@ export function clientSource(path, text) {
222
326
  return !/['"`]server-only['"`]/.test(text) && !/['"`]use server['"`]/.test(text);
223
327
  }
224
328
  export function findSupabase(root, procEnv = process.env) {
225
- const env = {}; const warnings = new Set();
226
- const collect = values => {
329
+ const env = {}; const warnings = new Set(); const serviceVariables = new Set();
330
+ const collect = (values, file = false) => {
227
331
  for (const k of Object.keys(values)) {
332
+ // Server-side env vars are normal; only names written into .env* files get the local reminder.
333
+ if (file && /SERVICE_ROLE|(?:^|_)SUPABASE_SERVICE_KEY$/i.test(k)) serviceVariables.add(k);
228
334
  if (!CLIENT_PREFIXES.some(p => k.startsWith(p))) continue;
229
335
  const value = values[k];
230
- if (KEY_VARS.includes(k) ? keyKind(value) === 'secret' : exposedSupabaseSecret(value)) warnings.add(k);
336
+ if (KEY_VARS.includes(k) ? secretInKeySlot(value) : exposedSupabaseSecret(value)) warnings.add(k);
231
337
  if (value) env[k] = value;
232
338
  }
233
339
  };
234
- for (const f of ['.env', '.env.local', '.env.production', '.env.production.local']) collect(parseEnv(read(join(root, f))));
340
+ for (const f of ['.env', '.env.local', '.env.production', '.env.production.local']) collect(parseEnv(read(join(root, f))), true);
235
341
  collect(procEnv);
236
342
  let url = URL_VARS.map(k => env[k]).find(value => /^https:\/\/[a-z0-9-]+\.supabase\.(co|in)\/?$/i.test(value || '')) || '';
237
343
  let key = KEY_VARS.map(k => env[k]).find(value => publicKeyKind(value)) || '';
@@ -239,11 +345,10 @@ export function findSupabase(root, procEnv = process.env) {
239
345
  for (const f of ['src/integrations/supabase/client.ts', 'src/integrations/supabase/client.js', 'src/lib/supabase.ts', 'src/lib/supabase.js', 'lib/supabase.ts', 'utils/supabase.ts']) {
240
346
  const t = read(join(root, f)); if (!t || !clientSource(f, t)) continue;
241
347
  url = url || ((t.match(/["'`](https:\/\/[a-z0-9-]+\.supabase\.co)["'`]/i) || [])[1] || '');
242
- for (const match of t.matchAll(/(["'`])((?:\\.|(?!\1)[^\\])*?)\1/g)) {
243
- if (exposedSupabaseSecret(match[2])) warnings.add(f);
244
- }
245
- for (const match of t.matchAll(/([\w]*(?:PUBLISHABLE|ANON)_KEY)\s*=\s*["'`]([^"'`]+)["'`]/gi)) {
246
- if (keyKind(match[2]) === 'secret') warnings.add(f);
348
+ // One forward token pass; no growing prefix/suffix slices and no quote-pair drift.
349
+ if (exposedSupabaseSecret(t)) warnings.add(f);
350
+ for (const match of t.matchAll(/\b([\w]*(?:PUBLISHABLE|ANON)_KEY)\s*=\s*["'`]([^"'`]+)["'`]/gi)) {
351
+ if (secretInKeySlot(match[2])) warnings.add(f);
247
352
  if (!key && publicKeyKind(match[2])) key = match[2];
248
353
  }
249
354
  }
@@ -251,7 +356,7 @@ export function findSupabase(root, procEnv = process.env) {
251
356
  const kind = key ? keyKind(key) : 'none';
252
357
  // Never send anything that isn't provably a public key.
253
358
  if (!publicKeyKind(key)) key = '';
254
- return { url: url.replace(/\/$/, ''), key, kind, warnings: [...warnings] };
359
+ return { url: url.replace(/\/$/, ''), key, kind, warnings: [...warnings], serviceVariables: [...serviceVariables] };
255
360
  }
256
361
 
257
362
  // ---- app identity ------------------------------------------------------------
@@ -377,7 +482,7 @@ export function buildReport(root, opts = {}, procEnv = process.env) {
377
482
  platform: screenString(detectPlatform(root, procEnv, url), 'platform'),
378
483
  environment: screenString(rawEnvironment, 'environment') && screenString(environment, 'environment') ? environment.slice(0, 20) : '',
379
484
  lockfile: source,
380
- versions_source: "lockfile",
485
+ versions_source: source === 'none' ? 'declared' : 'lockfile',
381
486
  source: "connector",
382
487
  packages,
383
488
  ...(truncated ? { truncated: true } : {}),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "safetyvibe-connect",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "Checks your app's dependencies for known vulnerabilities and its public configuration for common mistakes. Sends the package list and public settings only; preview with --dry-run.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -31,5 +31,9 @@
31
31
  "bugs": {
32
32
  "email": "safetyvibe@advertis.ee",
33
33
  "url": "https://safetyvibe.eu/connect"
34
+ },
35
+ "repository": {
36
+ "type": "git",
37
+ "url": "git+https://github.com/kare22/safetyvibe-connect.git"
34
38
  }
35
39
  }