safetyvibe-connect 0.2.0 → 0.2.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 +101 -6
  2. package/README.md +86 -29
  3. package/cli.mjs +195 -104
  4. package/lib.mjs +222 -104
  5. package/package.json +29 -31
package/AGENTS.md CHANGED
@@ -1,8 +1,103 @@
1
- # For AI coding agents
1
+ # SafetyVibe Connect instructions
2
2
 
3
- This project uses `safetyvibe-connect` for security monitoring.
3
+ SafetyVibe Connect reports your package list so we can warn you about vulnerable dependencies and, if the app uses Supabase, the public project URL and public anon/publishable key already shipped to every visitor, so we can check the public configuration. Requires Node >=18; no dependencies or package install scripts.
4
4
 
5
- - To link or relink it: `bunx safetyvibe-connect setup` (`npx` on npm projects). Pass `--claim-token <TOKEN>` if the user gave you one, and `--url <published URL>` if you know it.
6
- - Keep the postinstall hook: `"postinstall": "safetyvibe-connect scan --quiet || exit 0"`, or after an existing step: `"<existing> && (safetyvibe-connect scan --quiet || exit 0)"`. It re-reports dependencies after each install and never fails a build.
7
- - Don't edit `.safetyviberc.json` by hand, except to add `{"claimToken": "..."}` when you can't run commands. Never commit `.safetyviberc.local.json`; it holds the report key.
8
- - It sends only dependency names and versions, the app URL, and the public Supabase URL and anon key. Never put secrets into its config.
5
+ ## Run without installing
6
+
7
+ Get a claim token from the dashboard, then run:
8
+
9
+ ```sh
10
+ npx -y safetyvibe-connect@0.2.2 setup --no-hook --claim-token <TOKEN>
11
+ # pnpm
12
+ pnpm dlx safetyvibe-connect@0.2.2 setup --no-hook --claim-token <TOKEN>
13
+ # bun
14
+ bunx safetyvibe-connect@0.2.2 setup --no-hook --claim-token <TOKEN>
15
+ # yarn
16
+ yarn dlx safetyvibe-connect@0.2.2 setup --no-hook --claim-token <TOKEN>
17
+ ```
18
+
19
+ Add `--url <published URL>` if known. Without a token, setup prints a claim link.
20
+ On Replit/pnpm projects, `npm install` can fail with "Cannot read properties of null (reading 'isDescendantOf')".
21
+ Use `pnpm add -D safetyvibe-connect`, or run without installing:
22
+ `npx -y safetyvibe-connect@0.2.2 setup --no-hook --claim-token <TOKEN>`.
23
+ Optional installation commands: `npm install -D safetyvibe-connect`, `pnpm add -D safetyvibe-connect`, `bun add -d safetyvibe-connect`, `yarn add -D safetyvibe-connect`.
24
+
25
+ ## Exactly what is sent
26
+
27
+ Preview the report before sending it:
28
+
29
+ ```sh
30
+ npx -y safetyvibe-connect@0.2.2 setup --dry-run --url https://notes.example.com
31
+ ```
32
+
33
+ stdout is exactly the JSON body; stderr lists destination and header names with values hidden, plus safety warnings (also under --quiet). Dry-run makes no requests and writes no files.
34
+ Example (optional `truncated` appears only when the inventory exceeds 5000):
35
+
36
+ ```json
37
+ {
38
+ "v": 1,
39
+ "name": "notes.example.com",
40
+ "url": "https://notes.example.com",
41
+ "environment": "production",
42
+ "lockfile": "package-lock.json",
43
+ "packages": [{ "ecosystem": "npm", "name": "vite", "version": "6.0.0", "dev": true }],
44
+ "supabase": { "url": "https://example.supabase.co", "key": "sb_publishable_abc123def456", "kind": "publishable" }
45
+ }
46
+ ```
47
+
48
+ - `v`: report format version.
49
+ - `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.
50
+ - `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.
51
+ - `platform`: detected project builder/hosting label; omitted when unknown.
52
+ - `environment`: `SAFETYVIBE_ENV`, or production when `NODE_ENV=production`; limited to 20 letters, digits, underscores or hyphens; omitted when unknown.
53
+ - `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.
54
+ - `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.
55
+ - `truncated`: optional boolean; only the first 5000 packages are sent.
56
+ - `supabase`: public project URL, strictly validated public key and kind (`publishable` or `anon`); otherwise null.
57
+
58
+ Never sent: source code, file paths, full lockfiles, .env files, Supabase secret/service keys, URL credentials or query strings.
59
+ 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.
60
+ 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. Local key warnings name only the variable.
61
+
62
+ Review the entire implementation in `cli.mjs` and `lib.mjs` (approximately 543 lines total). There are no dependencies and no install scripts in this package itself.
63
+ By default reports go only to `https://dashboard.safetyvibe.eu/connect/api/v1/report`. The committed rc cannot change the destination.
64
+ An explicit `SAFETYVIBE_API` environment override supports HTTPS, or HTTP on localhost/127.0.0.1/[::1] for local testing. Redirects are refused.
65
+
66
+ ## Changes
67
+
68
+ 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.
69
+
70
+ [Documentation](https://safetyvibe.eu/connect) and [changelog / what is sent](https://safetyvibe.eu/connect/changelog).
71
+
72
+ ## Files written and removal
73
+
74
+ 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, status and help never write. After a successful report:
75
+
76
+ - `.safetyviberc.json`: app ID, normalized public URL, optional name and `hook: false`.
77
+ - `.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.
78
+ - `.gitignore`: adds the local config filename.
79
+ - `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.
80
+
81
+ 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.
82
+
83
+ 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.
84
+
85
+ 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`.
86
+
87
+ For manual reporting use `npx safetyvibe-connect scan` (pnpm: `pnpm dlx`, bun: `bunx`, yarn: `yarn dlx`).
88
+ Remove the connector dependency if installed, its postinstall command, both rc files, and the local-config .gitignore entry. Keep other postinstall commands.
89
+
90
+ ## Commands
91
+
92
+ | Command | Effect |
93
+ |---|---|
94
+ | `setup [--claim-token T] [--url URL] [--name NAME] [--no-hook]` | Send report, save config, optionally add hook |
95
+ | `setup --dry-run` or `--dry-run` | Print exact report without network or writes |
96
+ | `scan [--quiet]` | Re-report; always exits 0 for install hooks |
97
+ | `status` | Show local link state |
98
+ | `help`, `--help`, `-h`, no arguments | Usage only |
99
+ | `--version`, `-v` | Package version only |
100
+
101
+ Flags without a command imply setup. Unknown name, URL, platform and environment fields are omitted, never empty strings.
102
+
103
+ 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.
package/README.md CHANGED
@@ -1,46 +1,103 @@
1
1
  # safetyvibe-connect
2
2
 
3
- Links an app to [SafetyVibe](https://dashboard.safetyvibe.eu) security monitoring.
4
- Made for AI builders (Lovable, Replit, Bolt, Claude Code…): paste one prompt, the builder's
5
- agent installs this package, and the app shows up on your SafetyVibe dashboard.
3
+ SafetyVibe Connect reports your package list so we can warn you about vulnerable dependencies and, if the app uses Supabase, the public project URL and public anon/publishable key already shipped to every visitor, so we can check the public configuration. Requires Node >=18; no dependencies or package install scripts.
6
4
 
7
- ## Install
5
+ ## Run without installing
6
+
7
+ Get a claim token from the dashboard, then run:
8
8
 
9
9
  ```sh
10
- bun add safetyvibe-connect && bunx safetyvibe-connect setup --claim-token <TOKEN>
11
- # npm
12
- npm install --save safetyvibe-connect && npx safetyvibe-connect setup --claim-token <TOKEN>
10
+ npx -y safetyvibe-connect@0.2.2 setup --no-hook --claim-token <TOKEN>
11
+ # pnpm
12
+ pnpm dlx safetyvibe-connect@0.2.2 setup --no-hook --claim-token <TOKEN>
13
+ # bun
14
+ bunx safetyvibe-connect@0.2.2 setup --no-hook --claim-token <TOKEN>
15
+ # yarn
16
+ yarn dlx safetyvibe-connect@0.2.2 setup --no-hook --claim-token <TOKEN>
17
+ ```
18
+
19
+ Add `--url <published URL>` if known. Without a token, setup prints a claim link.
20
+ On Replit/pnpm projects, `npm install` can fail with "Cannot read properties of null (reading 'isDescendantOf')".
21
+ Use `pnpm add -D safetyvibe-connect`, or run without installing:
22
+ `npx -y safetyvibe-connect@0.2.2 setup --no-hook --claim-token <TOKEN>`.
23
+ Optional installation commands: `npm install -D safetyvibe-connect`, `pnpm add -D safetyvibe-connect`, `bun add -d safetyvibe-connect`, `yarn add -D safetyvibe-connect`.
24
+
25
+ ## Exactly what is sent
26
+
27
+ Preview the report before sending it:
28
+
29
+ ```sh
30
+ npx -y safetyvibe-connect@0.2.2 setup --dry-run --url https://notes.example.com
31
+ ```
32
+
33
+ stdout is exactly the JSON body; stderr lists destination and header names with values hidden, plus safety warnings (also under --quiet). Dry-run makes no requests and writes no files.
34
+ Example (optional `truncated` appears only when the inventory exceeds 5000):
35
+
36
+ ```json
37
+ {
38
+ "v": 1,
39
+ "name": "notes.example.com",
40
+ "url": "https://notes.example.com",
41
+ "environment": "production",
42
+ "lockfile": "package-lock.json",
43
+ "packages": [{ "ecosystem": "npm", "name": "vite", "version": "6.0.0", "dev": true }],
44
+ "supabase": { "url": "https://example.supabase.co", "key": "sb_publishable_abc123def456", "kind": "publishable" }
45
+ }
13
46
  ```
14
47
 
15
- Get `<TOKEN>` from the dashboard (**Add app → Connect with one prompt**). Without a token,
16
- `setup` prints a link you open to add the app to your account.
48
+ - `v`: report format version.
49
+ - `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.
50
+ - `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.
51
+ - `platform`: detected project builder/hosting label; omitted when unknown.
52
+ - `environment`: `SAFETYVIBE_ENV`, or production when `NODE_ENV=production`; limited to 20 letters, digits, underscores or hyphens; omitted when unknown.
53
+ - `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.
54
+ - `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.
55
+ - `truncated`: optional boolean; only the first 5000 packages are sent.
56
+ - `supabase`: public project URL, strictly validated public key and kind (`publishable` or `anon`); otherwise null.
57
+
58
+ Never sent: source code, file paths, full lockfiles, .env files, Supabase secret/service keys, URL credentials or query strings.
59
+ 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.
60
+ 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. Local key warnings name only the variable.
61
+
62
+ Review the entire implementation in `cli.mjs` and `lib.mjs` (approximately 543 lines total). There are no dependencies and no install scripts in this package itself.
63
+ By default reports go only to `https://dashboard.safetyvibe.eu/connect/api/v1/report`. The committed rc cannot change the destination.
64
+ An explicit `SAFETYVIBE_API` environment override supports HTTPS, or HTTP on localhost/127.0.0.1/[::1] for local testing. Redirects are refused.
65
+
66
+ ## Changes
67
+
68
+ 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.
17
69
 
18
- ## What it sends — and what it never sends
70
+ [Documentation](https://safetyvibe.eu/connect) and [changelog / what is sent](https://safetyvibe.eu/connect/changelog).
19
71
 
20
- Sent (see it yourself with `safetyvibe-connect setup --dry-run`):
21
- - package names and versions from your lockfile (to check them against known vulnerabilities)
22
- - the app name, its public URL (if you pass `--url` or it is set) and a platform label
23
- - your Supabase project URL and its **public** anon/publishable key — the same values your
24
- website already ships to every visitor. A `service_role` / `sb_secret_` key is refused, never sent.
72
+ ## Files written and removal
25
73
 
26
- Never sent: source code, file paths, `.env` secrets, or any other key.
74
+ 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, status and help never write. After a successful report:
27
75
 
28
- ## What it changes in your project
76
+ - `.safetyviberc.json`: app ID, normalized public URL, optional name and `hook: false`.
77
+ - `.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.
78
+ - `.gitignore`: adds the local config filename.
79
+ - `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.
29
80
 
30
- - `.safetyviberc.json` (safe to commit) — this app's SafetyVibe id and public URL. No secrets.
31
- - `.safetyviberc.local.json` (added to `.gitignore`) — this app's report key. It can update this one
32
- app's package list, link and Supabase details on SafetyVibe; it cannot read anything. Don't commit it.
33
- - `package.json` → `scripts.postinstall` gets `safetyvibe-connect scan --quiet || exit 0`, so the package
34
- list is re-reported after each install. It never fails an install or build, even after you remove the
35
- connector.
36
- - The one-prompt claim token links one app within 24 hours and is then useless; the CLI removes it.
81
+ 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.
37
82
 
38
- Remove it any time: delete the postinstall entry, `.safetyviberc.json`, `.safetyviberc.local.json`, and the dependency.
83
+ 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.
84
+
85
+ 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`.
86
+
87
+ For manual reporting use `npx safetyvibe-connect scan` (pnpm: `pnpm dlx`, bun: `bunx`, yarn: `yarn dlx`).
88
+ Remove the connector dependency if installed, its postinstall command, both rc files, and the local-config .gitignore entry. Keep other postinstall commands.
39
89
 
40
90
  ## Commands
41
91
 
42
- | command | does |
92
+ | Command | Effect |
43
93
  |---|---|
44
- | `setup [--claim-token T] [--url URL] [--dry-run]` | report + write `.safetyviberc.json` + add the postinstall hook |
45
- | `scan [--quiet]` | report again (used by the hook; always exits 0) |
46
- | `status` | show what is linked |
94
+ | `setup [--claim-token T] [--url URL] [--name NAME] [--no-hook]` | Send report, save config, optionally add hook |
95
+ | `setup --dry-run` or `--dry-run` | Print exact report without network or writes |
96
+ | `scan [--quiet]` | Re-report; always exits 0 for install hooks |
97
+ | `status` | Show local link state |
98
+ | `help`, `--help`, `-h`, no arguments | Usage only |
99
+ | `--version`, `-v` | Package version only |
100
+
101
+ Flags without a command imply setup. Unknown name, URL, platform and environment fields are omitted, never empty strings.
102
+
103
+ 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.
package/cli.mjs CHANGED
@@ -1,123 +1,214 @@
1
1
  #!/usr/bin/env node
2
- // safetyvibe-connect — link this app to SafetyVibe security monitoring.
3
- // setup [--claim-token T] [--url https://my-app.lovable.app] [--dry-run]
4
- // scan [--quiet] re-report (runs from the postinstall hook; never fails a build)
5
- // status show what is linked
6
- // Sends ONLY: package names+versions, app name, public URL, platform label, and the
7
- // Supabase project URL + its public anon/publishable key. Run with --dry-run to see it.
8
- import { readFileSync, writeFileSync, existsSync } from 'node:fs';
2
+ import { readFileSync, writeFileSync, chmodSync, unlinkSync } from 'node:fs';
9
3
  import { join } from 'node:path';
10
- import { buildReport } from './lib.mjs';
4
+ import { buildReport, findSupabase, normPublicUrl, urlNeedsCleaning, withHook, HOOK, packageManager, readPackages } from './lib.mjs';
11
5
 
12
- const RC = '.safetyviberc.json'; // committed: app id + URL (no secrets)
13
- const RC_LOCAL = '.safetyviberc.local.json'; // git-ignored: the app's report key
14
- const HOOK = 'safetyvibe-connect scan --quiet || exit 0'; // works in sh and cmd.exe; never fails an install
6
+ const RC = '.safetyviberc.json';
7
+ const LOCAL = '.safetyviberc.local.json';
15
8
  const DEFAULT_API = 'https://dashboard.safetyvibe.eu/connect/api/v1';
16
-
9
+ const REPORT_KEY = /^svk_[A-Za-z0-9_-]{32}$/;
10
+ const CLAIM_TOKEN = /^[A-Za-z0-9_-]{16,64}$/;
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] [--quiet] [--no-hook] [--help/-h] [--version/-v]';
17
13
  const argv = process.argv.slice(2);
18
- const cmd = argv[0] && !argv[0].startsWith('--') ? argv[0] : 'setup';
19
- const flag = (name) => { const i = argv.indexOf('--' + name); return i !== -1 ? (argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[i + 1] : true) : undefined; };
20
- const quiet = !!flag('quiet');
21
- const root = process.env.INIT_CWD || process.cwd();
22
- const say = (...a) => { if (!quiet) console.log(...a); };
23
-
24
- const readJsonFile = (f) => { try { return JSON.parse(readFileSync(join(root, f), 'utf8')) || {}; } catch { return {}; } };
25
- function readRc() { return { ...readJsonFile(RC), ...readJsonFile(RC_LOCAL) }; }
14
+ const flags = {};
15
+ let cmd = ''; let error = '';
16
+ // Complete argument validation before reading even package.json.
17
+ for (let i = 0; i < argv.length; i++) {
18
+ const arg = argv[i];
19
+ if (!arg.startsWith('-')) {
20
+ if (cmd || !['setup', 'scan', 'status', 'help'].includes(arg)) { error = 'Unknown command or argument.'; break; }
21
+ cmd = arg; continue;
22
+ }
23
+ const [raw, ...rest] = arg.split('=');
24
+ const name = ({ '-h': 'help', '-v': 'version' })[raw] || raw.replace(/^--/, '');
25
+ if (['claim-token', 'url', 'name'].includes(name)) {
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)|-h|-v)(?:=|$)/.test(value))) { error = 'Missing value for --' + name + '.'; break; }
28
+ flags[name] = value;
29
+ } else if (['dry-run', 'quiet', 'no-hook', 'help', 'version'].includes(name) && !rest.length) flags[name] = true;
30
+ else { error = 'Unknown flag.'; break; }
31
+ }
32
+ if (error) {
33
+ const scan = cmd === 'scan' || argv[0] === 'scan';
34
+ if (!(scan && argv.includes('--quiet'))) console.error(USAGE + '\n' + error);
35
+ process.exit(scan ? 0 : 2);
36
+ }
37
+ if (!argv.length || cmd === 'help' || flags.help) { console.log(USAGE); process.exit(0); }
38
+ if (!cmd) cmd = 'setup';
39
+ const VERSION = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8')).version;
40
+ if (flags.version) { console.log(VERSION); process.exit(0); }
41
+ function installed(dir, hook = false) {
42
+ try { const p = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')); return ['dependencies', 'devDependencies', 'optionalDependencies'].some(k => Object.hasOwn(p[k] || {}, 'safetyvibe-connect')) || (hook && /(?:^|[\s(])safetyvibe-connect\s+scan\b/.test(p.scripts?.postinstall || '')); } catch { return false; }
43
+ }
44
+ function workspaceRoot(dir) {
45
+ try { const p = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')); if (p.workspaces) return true; } catch {}
46
+ // pnpm 10 also writes pnpm-workspace.yaml for build settings in single apps: only a packages list counts.
47
+ try { return /^packages\s*:/m.test(readFileSync(join(dir, 'pnpm-workspace.yaml'), 'utf8')); } catch { return false; }
48
+ }
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']) console.log(...args); };
51
+ const alert = (message) => console.error(message);
52
+ const safeText = value => String(value).replace(/[\x00-\x1f\x7f-\x9f]/g, '').slice(0, 300);
53
+ const readJson = (file) => { try { return JSON.parse(readFileSync(join(root, file), 'utf8')) || {}; } catch { return {}; } };
54
+ function readRc() {
55
+ const pub = readJson(RC); const local = readJson(LOCAL);
56
+ return { ...pub, ...Object.fromEntries(['key', 'claimToken'].filter(k => Object.hasOwn(local, k)).map(k => [k, local[k]])) };
57
+ }
26
58
  function writeRc(rc) {
27
- const { key, ...pub } = rc;
59
+ const { key, claimToken, api, ...pub } = rc;
60
+ pub.url = normPublicUrl(pub.url || '');
61
+ let ignored = true;
62
+ if (key || claimToken) try {
63
+ let ignore = ''; try { ignore = readFileSync(join(root, '.gitignore'), 'utf8'); } catch (e) { if (e.code !== 'ENOENT') throw e; }
64
+ if (!ignore.split(/\r?\n/).some(l => l.trim() === LOCAL)) writeFileSync(join(root, '.gitignore'), ignore + (ignore && !ignore.endsWith('\n') ? '\n' : '') + LOCAL + '\n');
65
+ } catch { ignored = false; }
28
66
  try {
67
+ if (key || claimToken) {
68
+ writeFileSync(join(root, LOCAL), JSON.stringify({ ...(key ? { key } : {}), ...(claimToken ? { claimToken } : {}) }, null, 2) + '\n', { mode: 0o600 });
69
+ chmodSync(join(root, LOCAL), 0o600);
70
+ } else {
71
+ try { unlinkSync(join(root, LOCAL)); } catch (e) { if (e.code !== 'ENOENT') throw e; }
72
+ }
29
73
  writeFileSync(join(root, RC), JSON.stringify(pub, null, 2) + '\n');
30
- if (key) { writeFileSync(join(root, RC_LOCAL), JSON.stringify({ key }, null, 2) + '\n'); ignoreLocal(); }
31
- return true;
32
- } catch { return false; } // read-only build sandboxes: nothing to persist
33
- }
34
- function ignoreLocal() {
35
- const f = join(root, '.gitignore');
36
- let t = ''; try { t = readFileSync(f, 'utf8'); } catch {}
37
- if (t.split(/\r?\n/).some((l) => l.trim() === RC_LOCAL)) return;
38
- try { writeFileSync(f, t + (t && !t.endsWith('\n') ? '\n' : '') + RC_LOCAL + '\n'); } catch {}
74
+ } catch { return false; }
75
+ if (!ignored) alert('saved the report key but could not add it to .gitignore - add .safetyviberc.local.json to .gitignore');
76
+ return ignored ? 'saved' : 'unignored';
39
77
  }
40
78
 
41
79
  function addHook() {
42
- const f = join(root, 'package.json');
43
- let pj; try { pj = JSON.parse(readFileSync(f, 'utf8')); } catch { return 'no package.json'; }
44
- pj.scripts = pj.scripts || {};
45
- const cur = String(pj.scripts.postinstall || '');
46
- // Only the connector's own failure is ignored: the user's existing postinstall keeps its exit code.
47
- const ours = (s) => s.replace(/\s*&&\s*\(?\s*safetyvibe-connect scan[^&()]*\)?\s*$/, '').replace(/^\(?\s*safetyvibe-connect scan[^&()]*\)?\s*$/, '');
48
- const base = ours(cur).trim();
49
- const want = base ? base + ' && (' + HOOK + ')' : HOOK;
50
- if (cur === want) return 'already there';
51
- pj.scripts.postinstall = want;
52
- try { writeFileSync(f, JSON.stringify(pj, null, 2) + '\n'); return 'added'; } catch { return 'could not write package.json'; }
80
+ let pj; let original;
81
+ try { original = readFileSync(join(root, 'package.json'), 'utf8'); pj = JSON.parse(original); } catch { return 'skipped (no readable package.json)'; }
82
+ if (!pj || typeof pj !== 'object' || Array.isArray(pj)) return 'skipped (invalid package.json)';
83
+ if (Object.hasOwn(pj, 'scripts') && (!pj.scripts || typeof pj.scripts !== 'object' || Array.isArray(pj.scripts))) return 'skipped (invalid scripts)';
84
+ const result = pj.scripts && Object.hasOwn(pj.scripts, 'postinstall') && typeof pj.scripts.postinstall !== 'string'
85
+ ? { status: 'unsafe' } : withHook(pj.scripts?.postinstall || '');
86
+ if (result.status === 'unsafe') { alert('Could not safely chain postinstall. Add this line by hand: "postinstall": "' + HOOK + '"'); return 'skipped'; }
87
+ if (result.status === 'already there') return result.status;
88
+ pj.scripts = { ...pj.scripts, postinstall: result.script };
89
+ try { writeFileSync(join(root, 'package.json'), JSON.stringify(pj, null, /\n(\t| {4}| {2})\S/.exec(original)?.[1] || 2) + (/\r?\n$/.exec(original)?.[0] || '')); return 'added'; }
90
+ catch { return 'could not write package.json'; }
53
91
  }
54
-
55
- async function post(api, body, headers) {
56
- const ctl = new AbortController(); const t = setTimeout(() => ctl.abort(), 10000);
57
- try {
58
- const r = await fetch(api.replace(/\/+$/, '') + '/report', {
59
- method: 'POST', signal: ctl.signal,
60
- headers: { 'content-type': 'application/json', 'user-agent': 'safetyvibe-connect/0.2.0', ...headers },
61
- body: JSON.stringify(body),
62
- });
63
- let j = {}; try { j = await r.json(); } catch {}
64
- return { ok: r.ok, status: r.status, body: j };
65
- } finally { clearTimeout(t); }
92
+ function apiEndpoint() {
93
+ const u = new URL(process.env.SAFETYVIBE_API || DEFAULT_API);
94
+ 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');
95
+ return u.href.replace(/\/+$/, '') + '/report';
66
96
  }
67
-
68
- async function report({ setup }) {
97
+ async function report() {
98
+ const setup = cmd === 'setup';
99
+ if (!setup && !flags['dry-run'] && workspaceRoot(root) && !readRc().site) { say('SafetyVibe: workspace root; run setup in an app directory.'); return 0; }
100
+ let destination;
101
+ try { destination = apiEndpoint(); } catch { alert('Invalid SAFETYVIBE_API: use HTTPS or local HTTP.'); return 1; }
69
102
  const rc = readRc();
70
- const claimToken = typeof flag('claim-token') === 'string' ? flag('claim-token') : (rc.claimToken || process.env.SAFETYVIBE_CLAIM_TOKEN || '');
71
- const url = typeof flag('url') === 'string' ? flag('url') : (rc.url || '');
72
- const api = process.env.SAFETYVIBE_API || rc.api || DEFAULT_API;
73
- const body = buildReport(root, { url });
74
-
75
- if (flag('dry-run')) { console.log(JSON.stringify(body, null, 2)); return 0; }
76
-
77
- const key = rc.key || process.env.SAFETYVIBE_KEY || ''; // CI: provide the report key as a secret env var
78
- // Linked app, but this machine has no key (fresh clone / CI): never create a stray new app.
79
- if (rc.site && !key && !claimToken) {
80
- say('SafetyVibe: this app is linked (' + rc.site + '), but its report key is not on this machine. ' +
81
- 'Set SAFETYVIBE_KEY, or run "safetyvibe-connect setup --claim-token <new prompt token>" to relink.');
103
+ const rawToken = flags['claim-token'] || rc.claimToken || process.env.SAFETYVIBE_CLAIM_TOKEN || '';
104
+ const token = typeof rawToken === 'string' && CLAIM_TOKEN.test(rawToken) ? rawToken : '';
105
+ if (rawToken && !token) alert('Invalid claim token in --claim-token, SAFETYVIBE_CLAIM_TOKEN or .safetyviberc.local.json/.safetyviberc.json.');
106
+ const givenUrl = flags.url || process.env.SAFETYVIBE_URL || rc.url || '';
107
+ const url = normPublicUrl(givenUrl);
108
+ if (urlNeedsCleaning(givenUrl)) alert('Removed credentials/query/fragment or unsafe path from URL; only the public URL is stored and sent');
109
+ const body = buildReport(root, { url, name: flags.name || rc.name });
110
+ const headers = { 'content-type': 'application/json', 'user-agent': 'safetyvibe-connect/' + VERSION };
111
+ const rawKey = rc.key || process.env.SAFETYVIBE_KEY || '';
112
+ const key = typeof rawKey === 'string' && REPORT_KEY.test(rawKey) ? rawKey : '';
113
+ if (rawKey && !key) alert('Invalid report key in SAFETYVIBE_KEY or .safetyviberc.local.json/.safetyviberc.json.');
114
+ if (typeof rc.site === 'string' && UUID.test(rc.site)) headers['x-safetyvibe-site'] = rc.site;
115
+ if (rc.site && key) headers['x-safetyvibe-key'] = key;
116
+ if (token) headers['x-safetyvibe-claim-token'] = token;
117
+ for (const variable of findSupabase(root).warnings) alert(`Warning: ${variable} holds a secret key. Variables starting with VITE_/NEXT_PUBLIC_/EXPO_PUBLIC_/PUBLIC_/REACT_APP_/NUXT_PUBLIC_ are shipped to every visitor; rotate that key in Supabase.`);
118
+ const lockState = readPackages(root);
119
+ if (lockState.noLockfile && !flags.quiet) console.error('No readable lockfile found (package-lock.json, pnpm-lock.yaml, yarn.lock or bun.lock); dependency check unavailable.'
120
+ + (lockState.binaryBun ? ' Run `bun install --save-text-lockfile` to create bun.lock.' : ''));
121
+ if (flags['dry-run']) {
122
+ console.log(JSON.stringify(body, null, 2));
123
+ console.error(destination + ' headers: ' + Object.keys(headers).join(', ') + ' (values hidden)');
82
124
  return 0;
83
125
  }
84
- const headers = {};
85
- if (rc.site) headers['x-safetyvibe-site'] = rc.site;
86
- if (rc.site && key) headers['x-safetyvibe-key'] = key;
87
- if (claimToken) headers['x-safetyvibe-claim-token'] = claimToken; // with x-safetyvibe-site: relink that app
88
-
89
- let res;
90
- try { res = await post(api, body, headers); }
91
- catch { say('SafetyVibe: could not reach ' + api + ' (will retry on the next install).'); return setup ? 1 : 0; }
92
- if (!res.ok) { say('SafetyVibe: ' + (res.body.error || 'report rejected (HTTP ' + res.status + ')')); return setup ? 1 : 0; }
93
-
94
- const b = res.body;
95
- const next = { ...rc, site: b.site || rc.site, url: url || rc.url || undefined };
96
- if (b.key) next.key = b.key;
97
- delete next.claimToken; // one-shot: never keep a used claim token around
98
- // Also rewrite when a key still sits in the committed file (older setups): move it to the local file.
99
- const keyInCommitted = !!readJsonFile(RC).key;
100
- if (keyInCommitted || JSON.stringify(next) !== JSON.stringify(rc)) writeRc(next);
101
-
102
- const hook = setup ? addHook() : null;
103
- const sb = body.supabase ? 'Supabase project ' + new URL(body.supabase.url).hostname : 'no Supabase project found';
104
- say(`SafetyVibe: reported ${body.packages.length} packages (${body.lockfile}), ${sb}${body.url ? ', ' + body.url : ''}.`);
105
- if (hook) say('SafetyVibe: postinstall hook ' + hook + ' (re-reports after every install).');
106
- if (b.claimed) say('SafetyVibe: linked to your dashboard: ' + (b.appUrl || 'https://dashboard.safetyvibe.eu'));
107
- else if (b.claimUrl) say('SafetyVibe: open this link to add the app to your dashboard: ' + b.claimUrl);
108
- return 0;
126
+ if (!setup && !rc.site && !key && !token && !installed(root)) {
127
+ say('SafetyVibe: no project connection or connector dependency; skipping report.'); return 0;
128
+ }
129
+ let saveFailed = false;
130
+ const legacy = readJson(RC);
131
+ if (['key', 'claimToken', 'api'].some(k => Object.hasOwn(legacy, k)) || (legacy.url && legacy.url !== normPublicUrl(legacy.url))) {
132
+ const saved = writeRc(rc);
133
+ if (!saved) alert('Could not save .safetyviberc.json migration; remove credentials manually.');
134
+ saveFailed = saved !== 'saved';
135
+ }
136
+ const manager = packageManager(root);
137
+ const runner = { npm: 'npx', pnpm: 'pnpm dlx', yarn: 'yarn dlx', bun: 'bunx' }[manager];
138
+ if (rc.site && !key && !token) {
139
+ 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>.');
140
+ return setup ? 1 : 0;
141
+ }
142
+ let response; let b;
143
+ const ctl = new AbortController(); let reader;
144
+ const requested = Number(process.env.SAFETYVIBE_TIMEOUT_MS);
145
+ const timeout = Number.isFinite(requested) && requested > 0 ? Math.min(requested, 10000) : 10000;
146
+ const cancel = () => { ctl.abort(); if (reader) void reader.cancel().catch(() => {}); };
147
+ let timer;
148
+ const deadline = new Promise((_, reject) => { timer = setTimeout(() => { cancel(); reject(new Error('deadline')); }, timeout); });
149
+ try {
150
+ const work = (async () => {
151
+ response = await fetch(destination, { method: 'POST', headers, body: JSON.stringify(body), signal: ctl.signal, redirect: 'error' });
152
+ reader = response.body?.getReader();
153
+ const chunks = []; let size = 0;
154
+ if (reader) while (true) {
155
+ const { done, value } = await reader.read(); if (done) break;
156
+ size += value.byteLength; if (size > 65536) { cancel(); throw new Error('response too large'); }
157
+ chunks.push(Buffer.from(value));
158
+ }
159
+ try { b = JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch {}
160
+ })();
161
+ work.catch(() => {}); // a late rejection after the deadline must not become an unhandled rejection
162
+ await Promise.race([deadline, work]);
163
+ } catch { alert('SafetyVibe: could not reach ' + new URL(destination).host + '.'); return 1; }
164
+ finally { clearTimeout(timer); }
165
+ if (typeof b?.warning === 'string') alert('SafetyVibe: ' + safeText(b.warning));
166
+ if (!response.ok) {
167
+ if (['claim_expired', 'claim_used'].includes(b?.code)) {
168
+ if (rc.claimToken && token === rc.claimToken) {
169
+ delete rc.claimToken;
170
+ if (!writeRc(rc)) alert('Could not remove claimToken from .safetyviberc.local.json/.safetyviberc.json; remove it manually before retrying.');
171
+ }
172
+ alert('SafetyVibe: claim token expired or already used. Get a new prompt from your dashboard.');
173
+ }
174
+ alert('SafetyVibe: report rejected: ' + (typeof b?.error === 'string' ? safeText(b.error) : 'HTTP ' + response.status));
175
+ return 1;
176
+ }
177
+ if (!b || typeof b !== 'object' || Array.isArray(b) || typeof b.site !== 'string' || !UUID.test(b.site)) {
178
+ alert('SafetyVibe: unexpected response from ' + new URL(destination).host + ' (HTTP ' + response.status + ')'); return 1;
179
+ }
180
+ const next = { ...rc, site: b.site, url, ...(flags.name ? { name: body.name } : {}), ...(flags['no-hook'] ? { hook: false } : {}) };
181
+ if (typeof b.key === 'string' && REPORT_KEY.test(b.key)) next.key = b.key;
182
+ else if (b.key) alert('Invalid report key in server response.');
183
+ delete next.claimToken;
184
+ const saved = writeRc(next);
185
+ if (!saved) {
186
+ alert('SafetyVibe: report sent; app id ' + b.site + '; could not save .safetyviberc.json here, so future reports from this folder cannot reach the same app - set SAFETYVIBE_KEY or run setup in a writable checkout');
187
+ return 1;
188
+ }
189
+ const inventory = lockState;
190
+ say(`SafetyVibe: reported ${body.packages.length} packages (${body.packages.filter(p => p.dev).length} dev-only).`);
191
+ if (body.truncated) alert(`Only the first 5000 of ${inventory.total} packages were reported.`);
192
+ if (setup && next.hook !== false) {
193
+ const hook = addHook();
194
+ if (hook === 'could not write package.json') { alert('SafetyVibe: report sent; app id ' + b.site + '; could not write package.json.'); return 1; }
195
+ say('SafetyVibe: postinstall hook ' + hook + '.');
196
+ } else if (setup) say('Re-report manually: ' + runner + ' safetyvibe-connect scan');
197
+ if (b.claimed) say('SafetyVibe: linked to your dashboard: https://dashboard.safetyvibe.eu');
198
+ else {
199
+ let claim = '';
200
+ try {
201
+ if (typeof b.claimUrl === 'string' && !/[\x00-\x1f\x7f-\x9f]/.test(b.claimUrl)) {
202
+ const u = new URL(b.claimUrl);
203
+ if (u.protocol === 'https:' && !u.username && !u.password && u.origin === new URL(destination).origin) claim = u.href;
204
+ }
205
+ } catch {}
206
+ say(claim ? 'SafetyVibe: open this link to add the app to your dashboard: ' + claim : 'SafetyVibe: open your SafetyVibe dashboard');
207
+ }
208
+ return saveFailed || saved === 'unignored' ? 1 : 0;
109
209
  }
110
-
111
210
  async function main() {
112
- if (cmd === 'status') {
113
- const rc = readRc();
114
- console.log(rc.site ? `Linked to SafetyVibe app ${rc.site}${rc.url ? ' (' + rc.url + ')' : ''}.` : (rc.claimToken ? 'Not reported yet (claim token present).' : 'Not linked. Run: safetyvibe-connect setup'));
115
- return 0;
116
- }
117
- if (cmd === 'scan') { try { await report({ setup: false }); } catch {} return 0; } // never fail an install/build
118
- if (cmd === 'setup') return report({ setup: true });
119
- console.log('Usage: safetyvibe-connect setup [--claim-token T] [--url URL] [--dry-run] | scan [--quiet] | status');
120
- return 1;
211
+ if (cmd === 'status') { const rc = readRc(); say(rc.site ? 'Linked to SafetyVibe app ' + rc.site + (normPublicUrl(rc.url) ? ' (' + normPublicUrl(rc.url) + ')' : '') + '.' : 'Not linked.'); return 0; }
212
+ return report();
121
213
  }
122
-
123
- main().then((code) => process.exit(code), () => process.exit(cmd === 'scan' ? 0 : 1));
214
+ main().then(code => process.exit(cmd === 'scan' ? 0 : code), () => { alert('SafetyVibe: could not complete report.'); process.exit(cmd === 'scan' ? 0 : 1); });
package/lib.mjs CHANGED
@@ -1,37 +1,49 @@
1
- // safetyvibe-connect — pure helpers (no network, no writes). Tested in connect.test.mjs.
2
- // What leaves the machine is exactly buildReport()'s return value: package names +
3
- // versions, app name, public URL, platform/environment labels, and the Supabase project
4
- // URL + its PUBLIC (anon/publishable) key. Never source code, file paths or secrets.
5
- import { readFileSync, existsSync, readdirSync } from 'node:fs';
6
- import { join, basename } from 'node:path';
1
+ // safetyvibe-connect - pure helpers (no network, no writes). Tested in connect.test.mjs.
2
+ // Egress = buildReport() body + headers x-safetyvibe-site / x-safetyvibe-key /
3
+ // x-safetyvibe-claim-token, only to the default API or SAFETYVIBE_API
4
+ // (https, or http on localhost); redirects refused. Never source code or secret Supabase keys.
5
+ import { readFileSync, existsSync, statSync } from 'node:fs';
6
+ import { join, dirname, sep, relative } from 'node:path';
7
7
 
8
8
  const MAX_PACKAGES = 5000;
9
9
  const read = (f) => { try { return readFileSync(f, 'utf8'); } catch { return null; } };
10
10
  const readJson = (f) => { const t = read(f); if (t == null) return null; try { return JSON.parse(t); } catch { return null; } };
11
+ const semver = v => typeof v === 'string' && /^\d+\.\d+\.\d+(?:-[\w.-]+)?(?:\+[\w.-]+)?$/.test(v);
12
+ function registryTarball(value) {
13
+ try { const u = new URL(value); return /^https?:$/.test(u.protocol) && /\/(?:@[^/]+\/)?[^/]+\/-\/[^/]+-\d+\.\d+\.\d+(?:-[\w.-]+)?(?:\+[\w.-]+)?\.tgz$/.test(decodeURIComponent(u.pathname)); } catch { return false; }
14
+ }
15
+ const registrySpec = v => typeof v === 'string' && (/^npm:(?:@[^/]+\/)?[^@]+@/.test(v) ? registrySpec(v.slice(v.lastIndexOf('@') + 1)) : /^(?:[~^<>=]*\s*v?(?:\d+|[xX*])(?:\.(?:\d+|[xX*])){0,2}(?:-[\w.-]+)?(?:\+[\w.-]+)?)(?:(?:\s+(?:-\s+)?|\s*\|\|\s*)[~^<>=]*\s*v?(?:\d+|[xX*])(?:\.(?:\d+|[xX*])){0,2}(?:-[\w.-]+)?(?:\+[\w.-]+)?)*$/.test(v.trim()));
16
+ const dependencySpecs = p => ({ ...p?.dependencies, ...p?.devDependencies, ...p?.optionalDependencies, ...p?.requires });
17
+ const nonRegistryNames = p => Object.entries(dependencySpecs(p)).filter(([, spec]) => typeof spec === 'string' && !registrySpec(spec)).map(([name]) => name);
18
+ const registryEntry = v => v && !v.link && semver(v.version) && (v.resolved == null || registryTarball(v.resolved));
11
19
 
12
20
  // ---- lockfiles -------------------------------------------------------------
13
21
  // Each parser returns [{ name, version }] for every installed package (deduped later).
14
22
 
15
- export function fromPackageLock(json) {
16
- const out = [];
23
+ export function fromPackageLock(json, { platform = process.platform, arch = process.arch } = {}) {
24
+ const out = []; const excluded = new Set();
25
+ const remember = p => { for (const name of nonRegistryNames(p)) excluded.add(name); };
17
26
  if (json && json.packages && typeof json.packages === 'object') {
27
+ for (const v of Object.values(json.packages)) remember(v);
18
28
  for (const [path, v] of Object.entries(json.packages)) {
19
- if (!path || !v || typeof v.version !== 'string') continue; // "" = the root project
29
+ if (!path || !registryEntry(v)) continue;
30
+ const matches = (list, value) => !Array.isArray(list) || (!list.includes('!' + value) && (!list.some(x => !x.startsWith('!')) || list.includes(value) || list.includes('any')));
31
+ if (v.optional && (!matches(v.os, platform) || !matches(v.cpu, arch))) continue;
20
32
  const i = path.lastIndexOf('node_modules/');
21
- if (i === -1) continue;
22
- out.push({ name: v.name || path.slice(i + 'node_modules/'.length), version: v.version });
33
+ if (i === -1 || excluded.has(path.slice(i + 'node_modules/'.length))) continue;
34
+ out.push({ name: v.name || path.slice(i + 'node_modules/'.length), version: v.version, ...(v.dev === true ? { dev: true } : {}) });
23
35
  }
24
36
  } else if (json && json.dependencies) { // lockfileVersion 1
25
- const walk = (deps) => { for (const [n, v] of Object.entries(deps || {})) { if (v && v.version) out.push({ name: n, version: v.version }); walk(v && v.dependencies); } };
37
+ const walk = (deps) => { for (const [n, v] of Object.entries(deps || {})) { remember(v); if (registryEntry(v)) out.push({ name: n, version: v.version, ...(v.dev === true ? { dev: true } : {}) }); walk(v && v.dependencies); } };
26
38
  walk(json.dependencies);
27
39
  }
28
- return out;
40
+ return out.filter(p => !excluded.has(p.name));
29
41
  }
30
42
 
31
43
  // bun.lock (text, JSONC): "packages": { "<key>": ["name@version", registry, {...}, integrity], ... }
32
44
  export function fromBunLock(text) {
33
45
  const clean = String(text).replace(/^\s*\/\/.*$/gm, '').replace(/,(\s*[}\]])/g, '$1');
34
- let json; try { json = JSON.parse(clean); } catch { return []; }
46
+ const json = JSON.parse(clean);
35
47
  const out = [];
36
48
  for (const v of Object.values((json && json.packages) || {})) {
37
49
  const spec = Array.isArray(v) ? v[0] : null;
@@ -39,48 +51,39 @@ export function fromBunLock(text) {
39
51
  const at = spec.lastIndexOf('@');
40
52
  if (at <= 0) continue;
41
53
  const version = spec.slice(at + 1);
42
- if (/^\d/.test(version)) out.push({ name: spec.slice(0, at), version }); // skip workspace:/git:/file:
54
+ if (semver(version) && (v[1] === '' || (typeof v[1] === 'string' && /^https?:\/\//.test(v[1]) && !/\.tgz|\.git|github/i.test(v[1])))) out.push({ name: spec.slice(0, at), version });
43
55
  }
44
56
  return out;
45
57
  }
46
58
 
47
59
  // yarn.lock v1 ("name@range":\n version "x") and berry ("name@npm:range":\n version: x)
48
60
  export function fromYarnLock(text) {
49
- const out = []; let names = null;
50
- for (const line of String(text).split(/\r?\n/)) {
51
- if (/^\S.*:\s*$/.test(line) && !line.startsWith('#') && !line.startsWith('__metadata')) {
52
- names = line.replace(/:\s*$/, '').split(',').map((s) => s.trim().replace(/^"|"$/g, ''))
53
- .map((s) => s.slice(0, s.lastIndexOf('@') > 0 ? s.lastIndexOf('@') : s.length));
54
- continue;
61
+ const out = []; const seen = new Set(); const berry = /^__metadata:/m.test(text);
62
+ for (const block of String(text).split(/(?=^\S)/m)) {
63
+ const lines = block.split(/\r?\n/); const head = lines.shift();
64
+ if (!head?.endsWith(':') || head.startsWith('#') || head.startsWith('__metadata')) continue;
65
+ const specs = head.slice(0, -1).split(/,\s*/).map(s => s.replace(/^"|"$/g, ''));
66
+ const version = /^\s+version:?\s+"?([^"\s]+)/m.exec(block)?.[1];
67
+ const resolved = /^\s+resolved\s+["']?([^"'\s]+)/m.exec(block)?.[1];
68
+ const resolution = /^\s+resolution:\s+"?([^"\s]+)/m.exec(block)?.[1];
69
+ if (!semver(version) || (berry ? !/^(?:@[^/]+\/)?[^@:/]+@npm:/.test(resolution || '') : resolved != null && !registryTarball(resolved))) continue;
70
+ for (const spec of new Set(specs)) {
71
+ const at = spec.indexOf('@', spec.startsWith('@') ? 1 : 0);
72
+ if (at < 1 || !(berry ? spec.slice(at + 1).startsWith('npm:') : registrySpec(spec.slice(at + 1)))) continue;
73
+ const name = spec.slice(0, at); const id = name + '@' + version;
74
+ if (!seen.has(id)) { seen.add(id); out.push({ name, version }); }
55
75
  }
56
- const m = line.match(/^\s+version:?\s+"?([^"\s]+)"?\s*$/);
57
- if (m && names) { for (const n of new Set(names)) if (n) out.push({ name: n, version: m[1] }); names = null; }
58
76
  }
59
77
  return out;
60
78
  }
61
79
 
62
- // pnpm-lock.yaml: package keys like "/name@1.2.3", "name@1.2.3", "/@scope/name@1.2.3(peer@x)"
63
80
  export function fromPnpmLock(text) {
64
- const out = []; let inPackages = false;
65
- for (const line of String(text).split(/\r?\n/)) {
66
- if (/^(packages|snapshots):\s*$/.test(line)) { inPackages = line.startsWith('packages'); continue; }
67
- if (/^\S/.test(line)) { inPackages = false; continue; }
68
- if (!inPackages) continue;
69
- const m = line.match(/^ {2}'?\/?((?:@[^/@\s]+\/)?[^@\s/]+)@(\d[^('\s:]*)/);
70
- if (m) out.push({ name: m[1], version: m[2] });
71
- }
72
- return out;
73
- }
74
-
75
- // Fallback (bun.lockb is binary): what is actually installed in node_modules.
76
- export function fromNodeModules(root) {
77
- const out = []; const nm = join(root, 'node_modules');
78
- let entries = []; try { entries = readdirSync(nm); } catch { return out; }
79
- const add = (dir) => { const pj = readJson(join(dir, 'package.json')); if (pj && pj.name && pj.version) out.push({ name: pj.name, version: pj.version }); };
80
- for (const e of entries) {
81
- if (e.startsWith('.')) continue;
82
- if (e.startsWith('@')) { let sub = []; try { sub = readdirSync(join(nm, e)); } catch {} for (const s of sub) add(join(nm, e, s)); }
83
- else add(join(nm, e));
81
+ const out = []; const section = /^packages:\s*\r?\n([\s\S]*?)(?=^\S|(?![\s\S]))/m.exec(text)?.[1] || '';
82
+ for (const block of section.split(/(?=^ {2}\S)/m)) {
83
+ const head = block.split(/\r?\n/)[0];
84
+ if (/https?:|git[+:]|github|file:|link:|workspace:/.test(head) || /\b(?:tarball|repo|directory|type):/.test(block) || !/\bintegrity:/.test(block)) continue;
85
+ const m = /^ {2}['"]?\/?((?:@[^/\s]+\/)?[^@/\s]+)(?:@|\/)(\d+\.\d+\.\d+(?:-[\w.-]+)?)/.exec(head);
86
+ if (m) out.push({ name: m[1], version: m[2], ...(/^ {4}dev: true\s*$/m.test(block) ? { dev: true } : {}) });
84
87
  }
85
88
  return out;
86
89
  }
@@ -94,118 +97,233 @@ export function readPackages(root) {
94
97
  ['pnpm-lock.yaml', fromPnpmLock],
95
98
  ['yarn.lock', fromYarnLock],
96
99
  ];
97
- for (const [file, parse] of tries) {
98
- const t = read(join(root, file)); if (t == null) continue;
100
+ let lockRoot = root;
101
+ let current = root;
102
+ for (let depth = 0; depth <= 6; depth++) {
103
+ const ws = readJson(join(current, 'package.json'))?.workspaces;
104
+ const yaml = read(join(current, 'pnpm-workspace.yaml')) || '';
105
+ const block = /^packages:\s*\r?\n((?:[ \t]+.*\r?\n?)*)/m.exec(yaml)?.[1] || '';
106
+ const globs = [...(Array.isArray(ws) ? ws : Array.isArray(ws?.packages) ? ws.packages : []),
107
+ ...[...block.matchAll(/^\s+-\s+['"]?([^'"\r\n]+?)['"]?\s*$/gm)].map(m => m[1])];
108
+ const segments = relative(current, root).split(sep);
109
+ const matchesGlob = g => {
110
+ const parts = g.replace(/^\.\//, '').replace(/\/$/, '').split('/');
111
+ const segmentMatches = (pattern, value) => new RegExp('^' + pattern.split('*').map(s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('[^/]*') + '$').test(value);
112
+ const match = (i, j) => i === parts.length ? j === segments.length
113
+ : parts[i] === '**' ? match(i + 1, j) || (j < segments.length && match(i, j + 1))
114
+ : j < segments.length && segmentMatches(parts[i], segments[j]) && match(i + 1, j + 1);
115
+ return match(0, 0);
116
+ };
117
+ const workspace = globs.some(g => typeof g === 'string' && !g.startsWith('!') && matchesGlob(g))
118
+ && !globs.some(g => typeof g === 'string' && g.startsWith('!') && matchesGlob(g.slice(1)));
119
+ if (current === root || workspace) {
120
+ lockRoot = current;
121
+ if (workspace || tries.some(([f]) => existsSync(join(current, f))) || existsSync(join(current, 'bun.lockb'))) break;
122
+ }
123
+ if (existsSync(join(current, '.git')) || dirname(current) === current || depth === 6) break;
124
+ current = dirname(current);
125
+ }
126
+ const manager = (readJson(join(root, 'package.json'))?.packageManager || readJson(join(lockRoot, 'package.json'))?.packageManager || '').split('@')[0];
127
+ const preferred = { npm: ['package-lock.json', 'npm-shrinkwrap.json'], pnpm: ['pnpm-lock.yaml'], yarn: ['yarn.lock'], bun: ['bun.lock'] }[manager] || [];
128
+ const candidates = tries.filter(([f]) => existsSync(join(lockRoot, f))).sort(([a], [b]) => Number(preferred.includes(b)) - Number(preferred.includes(a)) || statSync(join(lockRoot, b)).mtimeMs - statSync(join(lockRoot, a)).mtimeMs);
129
+ for (const [file, parse] of candidates) {
130
+ const t = read(join(lockRoot, file)); if (t == null) continue;
99
131
  let ok = true;
100
132
  try { list = parse(t); } catch { list = []; ok = false; }
101
133
  if (list.length) { source = file; break; }
102
- // A JSON lockfile that parses and lists nothing really means "no dependencies". The line-based
103
- // yarn/pnpm parsers can't tell empty from unsupported, so those stay "unknown".
104
- if (ok && /\.json$|^bun\.lock$/.test(file)) { source = file; break; }
134
+ // Recognized locks remain authoritative when every entry was excluded; falling back
135
+ // to installed packages could reintroduce names from non-registry sources.
136
+ 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; }
105
137
  }
106
- if (!list.length && source === 'none') { list = fromNodeModules(root); if (list.length) source = 'node_modules'; }
107
- const seen = new Set(); const pkgs = [];
138
+ // No node_modules fallback: installed folders do not say where a package came from, so a git/tarball/
139
+ // file dependency could be reported under its name. Without a readable lockfile no packages are sent and
140
+ // lockfile stays 'none', so the dashboard keeps the last known inventory.
141
+ const missing = source === 'none' ? { noLockfile: true, ...(existsSync(join(lockRoot, 'bun.lockb')) || existsSync(join(root, 'bun.lockb')) ? { binaryBun: true } : {}) } : {};
142
+ const seen = new Map(); const pkgs = [];
108
143
  for (const p of list) {
109
144
  const k = p.name + '@' + p.version;
110
145
  // Registry versions only. git/file/link/http specs can carry tokens or local paths: never sent.
111
- if (seen.has(k) || !/^(@[\w.-]+\/)?[\w.-]+$/.test(p.name) || !/^\d+\.\d+\.\d+[\w.+-]{0,40}$/.test(String(p.version))) continue;
112
- seen.add(k); pkgs.push({ ecosystem: 'npm', name: p.name, version: p.version });
113
- if (pkgs.length >= MAX_PACKAGES) break;
146
+ if ([p.name, p.version].some(v => /sb_secret_|svk_/i.test(String(v)) || keyKind(v) !== 'unknown')) continue;
147
+ if (!/^(@[\w.-]+\/)?[\w.-]+$/.test(p.name) || !/^\d+\.\d+\.\d+[\w.+-]{0,40}$/.test(String(p.version))) continue;
148
+ if (seen.has(k)) { if (p.dev !== true) delete seen.get(k).dev; continue; }
149
+ const row = { ecosystem: 'npm', name: p.name, version: p.version, ...(p.dev === true ? { dev: true } : {}) };
150
+ seen.set(k, row); pkgs.push(row);
151
+
114
152
  }
115
- return { packages: pkgs, source };
153
+ return { packages: pkgs.slice(0, MAX_PACKAGES), source, ...missing, ...(pkgs.length > MAX_PACKAGES ? { total: pkgs.length, truncated: true } : {}) };
116
154
  }
117
155
 
118
156
  // ---- Supabase (project URL + PUBLIC key only) ------------------------------
119
- export function keyKind(key) {
120
- const k = String(key || '').trim();
121
- if (/^sb_publishable_/.test(k)) return 'publishable';
122
- if (/^sb_secret_/.test(k)) return 'secret';
123
- const parts = k.split('.');
124
- if (parts.length === 3) {
125
- try {
126
- const payload = JSON.parse(Buffer.from(parts[1].replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8'));
127
- if (payload && payload.role === 'anon') return 'anon';
128
- if (payload && payload.role) return 'secret'; // service_role or anything else privileged
129
- } catch { /* not a JWT */ }
130
- }
131
- return 'unknown';
157
+ function jwtPayload(value) {
158
+ if (typeof value !== 'string' || value.length > 2000 || !/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/.test(value)) return null;
159
+ try {
160
+ const raw = Buffer.from(value.split('.')[1], 'base64url').toString('utf8');
161
+ // Tokenize complete strings so quoted text inside a value cannot become a key.
162
+ const keys = [...raw.matchAll(/"(?:[^"\\]|\\.)*"/g)]
163
+ .filter(m => /^\s*:/.test(raw.slice(m.index + m[0].length))).map(m => JSON.parse(m[0]));
164
+ if (new Set(keys).size !== keys.length) return { role: 'rejected' };
165
+ const p = JSON.parse(raw); return p && typeof p === 'object' && !Array.isArray(p) ? p : null; } catch { return null; }
132
166
  }
133
-
134
- function parseEnv(text) {
167
+ export function publicKeyKind(value) {
168
+ if (typeof value !== 'string' || /sb_secret_|[\s#'"`]/i.test(value)) return null;
169
+ if (/^sb_publishable_[A-Za-z0-9_-]{8,200}$/.test(value)) return 'publishable';
170
+ const payload = jwtPayload(value);
171
+ const claims = new Set(['iss', 'ref', 'role', 'iat', 'exp', 'nbf', 'aud', 'sub']);
172
+ return payload?.role === 'anon'
173
+ && (!Object.hasOwn(payload, 'iss') || ['supabase', 'supabase-demo'].includes(payload.iss))
174
+ && (!Object.hasOwn(payload, 'ref') || (typeof payload.ref === 'string' && /^[a-z0-9]{10,40}$/.test(payload.ref)))
175
+ && Object.entries(payload).every(([key, value]) => claims.has(key)
176
+ && (typeof value === 'number' || (typeof value === 'string' && value.length <= 100 && !/sb_secret_|svk_|eyJ/i.test(value)))) ? 'anon' : null;
177
+ }
178
+ export function keyKind(value) {
179
+ const kind = publicKeyKind(value);
180
+ if (kind) return kind;
181
+ const payload = jwtPayload(value);
182
+ return /sb_secret_/i.test(String(value || '')) || (payload && typeof payload.role === 'string' && payload.role !== 'anon') ? 'secret' : 'unknown';
183
+ }
184
+ export function parseEnv(text) {
135
185
  const env = {};
136
186
  for (const line of String(text || '').split(/\r?\n/)) {
137
- const m = line.match(/^\s*(?:export\s+)?([A-Z0-9_]+)\s*=\s*(.*)\s*$/);
138
- if (m) env[m[1]] = m[2].replace(/^['"]|['"]$/g, '');
187
+ const m = line.match(/^\s*(?:export\s+)?([\w.-]+)\s*=\s*(.*)$/);
188
+ if (!m) continue;
189
+ const value = m[2]; const quote = value[0];
190
+ if (["'", '"', '`'].includes(quote)) {
191
+ let end = 1;
192
+ for (; end < value.length; end++) {
193
+ if (quote === '"' && value[end] === '\\') { end++; continue; }
194
+ if (value[end] === quote) break;
195
+ }
196
+ if (end === value.length) continue;
197
+ env[m[1]] = value.slice(1, end);
198
+ } else env[m[1]] = value.split('#')[0].trim();
139
199
  }
140
200
  return env;
141
201
  }
142
202
 
143
- const URL_VARS = ['VITE_SUPABASE_URL', 'NEXT_PUBLIC_SUPABASE_URL', 'PUBLIC_SUPABASE_URL', 'EXPO_PUBLIC_SUPABASE_URL', 'SUPABASE_URL'];
144
- const KEY_VARS = ['VITE_SUPABASE_PUBLISHABLE_KEY', 'VITE_SUPABASE_ANON_KEY', 'NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY', 'NEXT_PUBLIC_SUPABASE_ANON_KEY',
145
- 'PUBLIC_SUPABASE_ANON_KEY', 'EXPO_PUBLIC_SUPABASE_ANON_KEY', 'SUPABASE_PUBLISHABLE_KEY', 'SUPABASE_ANON_KEY'];
203
+ // Only client-exposed variable names (bundled into the browser build by these prefixes); server-only
204
+ // names such as SUPABASE_URL / SUPABASE_ANON_KEY are never read.
205
+ const CLIENT_PREFIXES = ['VITE_', 'NEXT_PUBLIC_', 'EXPO_PUBLIC_', 'PUBLIC_', 'REACT_APP_', 'NUXT_PUBLIC_'];
206
+ const URL_VARS = CLIENT_PREFIXES.map(p => p + 'SUPABASE_URL');
207
+ const KEY_VARS = CLIENT_PREFIXES.flatMap(p => [p + 'SUPABASE_PUBLISHABLE_KEY', p + 'SUPABASE_ANON_KEY']);
146
208
 
209
+ // Only clearly client-side source files: what the browser bundle already ships to every visitor.
210
+ const SERVER_PATH = /(^|\/)(server|api|functions)\/|(^|\/)app\/api\/|(^|\/)pages\/api\/|(^|\/)supabase\/functions\/|(^|\/)netlify\/functions\/|(^|\/)middleware\.[cm]?[jt]sx?$|\.server\.[cm]?[jt]sx?$/i;
211
+ export function clientSource(path, text) {
212
+ if (SERVER_PATH.test(String(path).split('\\').join('/'))) return false;
213
+ // Any quoted 'server-only' or "use server" anywhere (comments, spacing or syntax tricks included) marks
214
+ // the file as server code. Excluding a client file by mistake is the safe direction.
215
+ return !/['"`]server-only['"`]/.test(text) && !/['"`]use server['"`]/.test(text);
216
+ }
147
217
  export function findSupabase(root, procEnv = process.env) {
148
218
  const env = {};
149
219
  for (const f of ['.env', '.env.local', '.env.production', '.env.production.local']) Object.assign(env, parseEnv(read(join(root, f))));
150
220
  for (const k of [...URL_VARS, ...KEY_VARS]) if (procEnv[k]) env[k] = procEnv[k];
221
+ const warnings = KEY_VARS.filter(k => keyKind(env[k]) === 'secret');
151
222
  let url = URL_VARS.map((k) => env[k]).find(Boolean) || '';
152
- let key = KEY_VARS.map((k) => env[k]).find(Boolean) || '';
223
+ let key = KEY_VARS.map((k) => env[k]).find(value => publicKeyKind(value)) || '';
153
224
  // Lovable hard-codes them in src/integrations/supabase/client.ts
154
225
  if (!url || !key) {
155
226
  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']) {
156
- const t = read(join(root, f)); if (!t) continue;
227
+ const t = read(join(root, f)); if (!t || !clientSource(f, t)) continue;
157
228
  url = url || ((t.match(/["'`](https:\/\/[a-z0-9-]+\.supabase\.co)["'`]/i) || [])[1] || '');
158
- key = key || ((t.match(/(?:PUBLISHABLE|ANON)_KEY\s*=\s*["'`]([^"'`]+)["'`]/i) || [])[1] || '');
229
+ for (const match of t.matchAll(/([\w]*(?:PUBLISHABLE|ANON)_KEY)\s*=\s*["'`]([^"'`]+)["'`]/gi)) {
230
+ if (keyKind(match[2]) === 'secret') warnings.push(match[1]);
231
+ if (!key && publicKeyKind(match[2])) key = match[2];
232
+ }
159
233
  if (url && key) break;
160
234
  }
161
235
  }
162
236
  if (!/^https:\/\/[a-z0-9-]+\.supabase\.(co|in)\/?$/i.test(url)) url = '';
163
237
  const kind = key ? keyKind(key) : 'none';
164
238
  // Never send anything that isn't provably a public key.
165
- if (kind !== 'anon' && kind !== 'publishable') key = '';
166
- return { url: url.replace(/\/$/, ''), key, kind };
239
+ if (!publicKeyKind(key)) key = '';
240
+ return { url: url.replace(/\/$/, ''), key, kind, warnings };
167
241
  }
168
242
 
169
243
  // ---- app identity ------------------------------------------------------------
170
- export function detectPlatform(root, procEnv = process.env) {
244
+ const HOSTS = { 'lovable.app': 'Lovable', 'replit.app': 'Replit', 'repl.co': 'Replit', 'bolt.host': 'Bolt', 'base44.app': 'Base44', 'emergent.host': 'Emergent', 'emergentagent.com': 'Emergent', 'softgen.ai': 'Softgen', 'softgen.dev': 'Softgen', 'lp.dev': 'Leap', 'vercel.app': 'Vercel', 'netlify.app': 'Netlify', 'run.app': 'Google Cloud Run' };
245
+ export function detectPlatform(root, procEnv = process.env, url = '') {
171
246
  const pj = readJson(join(root, 'package.json')) || {};
172
- const deps = { ...(pj.dependencies || {}), ...(pj.devDependencies || {}) };
247
+ const deps = { ...pj.dependencies, ...pj.devDependencies };
173
248
  if (deps['lovable-tagger'] || existsSync(join(root, '.lovable'))) return 'Lovable';
174
- if (procEnv.REPL_ID || existsSync(join(root, '.replit'))) return 'Replit';
175
- if (existsSync(join(root, '.bolt'))) return 'Bolt';
249
+ if (existsSync(join(root, '.replit'))) return 'Replit';
250
+ for (const [marker, label] of Object.entries({ '.bolt': 'Bolt', '.emergent': 'Emergent', '.softgen': 'Softgen', '.leap': 'Leap', '.rocket': 'Rocket', '.v0': 'v0' })) if (existsSync(join(root, marker))) return label;
251
+ if (String(pj.name || '').toLowerCase().replace(/^@[^/]+\//, '') === 'softgen-starter') return 'Softgen';
252
+ for (const dir of [root, join(root, 'frontend')]) for (const file of ['.env', '.env.local', '.env.production']) {
253
+ for (const value of Object.values(parseEnv(read(join(dir, file))))) {
254
+ try { const u = new URL(value); if (/^https?:$/.test(u.protocol) && /\.(emergentagent\.com|emergent\.host)$/.test(u.hostname)) return 'Emergent'; } catch {}
255
+ }
256
+ }
176
257
  if (deps['@base44/sdk']) return 'Base44';
258
+ if (Object.hasOwn(readJson(join(root, 'metadata.json')) || {}, 'requestFramePermissions') || (pj.name === 'react-example' && deps['@google/genai']) || ['ts', 'js', 'mjs', 'mts'].some(ext => /process\.env\.GEMINI_API_KEY/.test(read(join(root, 'vite.config.' + ext)) || ''))) return 'Google AI Studio';
259
+ try { const host = new URL(url).hostname; for (const [suffix, label] of Object.entries(HOSTS)) if (host.endsWith('.' + suffix)) return label; } catch {}
260
+ if (procEnv.REPL_ID) return 'Replit';
177
261
  if (procEnv.VERCEL) return 'Vercel';
178
262
  if (procEnv.NETLIFY) return 'Netlify';
179
263
  return '';
180
264
  }
181
-
182
- export function appName(root) {
183
- const pj = readJson(join(root, 'package.json')) || {};
184
- const n = String(pj.name || '').trim();
185
- // Builder templates ship a generic package name; send none so the dashboard can name it properly.
186
- // Known builder template names only (a real name like "email-template-editor" is kept).
187
- const generic = /^(vite[_-]react[_-]shadcn[_-]ts|tanstack[_-]start[_-]ts|vite[_-]react[_-]typescript[_-]starter|vite[_-]project|rest[_-]express|my[_-]app|app|react[_-]app|next[_-]?js[_-]app|next[_-]app|nextjs)$/i;
188
- if (n && !generic.test(n)) return n.slice(0, 80);
189
- const dir = basename(root);
190
- return /^(app|project|workspace|src|home|repl|dev-server|code|\.)$/i.test(dir) ? '' : dir.slice(0, 80);
265
+ const TEMPLATES = new Set('softgen-starter my-react-app create-react-app starter template workspace frontend backend client web app project src home repl code dev-server react-example vite-project vite-react-shadcn-ts tanstack-start-ts vite-react-typescript-starter rest-express my-app react-app nextjs next-app nextjs-app my-v0-project'.split(' '));
266
+ TEMPLATES.add('');
267
+ // Real JWTs start with eyJ (base64 of {"); plain dotted names like notes.example.com stay allowed.
268
+ const secretLike = value => keyKind(value) !== 'unknown' || /sb_secret_|svk_|eyJ/i.test(value);
269
+ export function appName(root, explicit = '', url = '') {
270
+ let host = ''; try { host = new URL(url).hostname; if (Object.keys(HOSTS).some(s => host.endsWith('.' + s))) host = host.split('.')[0]; } catch {}
271
+ for (const candidate of [explicit, host, readJson(join(root, 'package.json'))?.name]) {
272
+ const raw = String(candidate || '').trim();
273
+ if (secretLike(raw)) continue;
274
+ const n = raw.replace(/[^A-Za-z0-9 @/._-]/g, '').trim();
275
+ if (n && !TEMPLATES.has(n.toLowerCase().replace(/^@[^/]+\//, '').replace(/_/g, '-')) && !secretLike(n) && !secretLike(n.slice(0, 80))) return n.slice(0, 80);
276
+ }
277
+ return '';
278
+ }
279
+ export function packageManager(root, env = process.env) {
280
+ const field = readJson(join(root, 'package.json'))?.packageManager?.split('@')[0];
281
+ if (['npm', 'pnpm', 'yarn', 'bun'].includes(field)) return field;
282
+ const source = readPackages(root).source;
283
+ if (source.startsWith('pnpm')) return 'pnpm';
284
+ if (source.startsWith('yarn')) return 'yarn';
285
+ if (source.startsWith('bun') || existsSync(join(root, 'bun.lockb'))) return 'bun';
286
+ if (source.endsWith('.json')) return 'npm';
287
+ return /^(pnpm|yarn|bun)\//.exec(env.npm_config_user_agent || '')?.[1] || 'npm';
288
+ }
289
+ export const HOOK = 'safetyvibe-connect scan --quiet || exit 0';
290
+ export function withHook(current = '') {
291
+ const cur = String(current).trim();
292
+ const base = cur.replace(/\s*&&\s*\(?safetyvibe-connect scan(?: --quiet)?(?: \|\| exit 0)?\)?$/, '').replace(/^\(?safetyvibe-connect scan(?: --quiet)?(?: \|\| exit 0)?\)?$/, '').trim();
293
+ if (/^[&|;]|[&|;\\^]$|[\r\n`<>#;$%!]|\$\(|[()]/.test(base) || /(?<![&|])[&|](?![&|])/.test(base) || /['"]/.test(base)) return { script: current, status: 'unsafe' };
294
+ const script = base ? base + ' && (' + HOOK + ')' : HOOK;
295
+ return { script, status: script === cur ? 'already there' : 'added' };
191
296
  }
192
297
 
193
- export function normPublicUrl(u) {
194
- try { const x = new URL(String(u)); return x.protocol === 'https:' || x.protocol === 'http:' ? x.origin + (x.pathname === '/' ? '' : x.pathname) : ''; }
195
- catch { return ''; }
298
+ function publicUrl(given) {
299
+ try {
300
+ const u = new URL(String(given));
301
+ if (!['http:', 'https:'].includes(u.protocol)) return { url: '', cleaning: false };
302
+ if (/sb_|svk_|eyJ/i.test(u.hostname)) return { url: '', cleaning: true };
303
+ const keep = /^(\/[A-Za-z0-9._~-]{1,64}){0,8}\/?$/.test(u.pathname)
304
+ && u.pathname.split('/').every(segment => segment.length <= 40 && !/sb_|svk_|eyJ/i.test(segment));
305
+ return { url: u.origin + (keep && u.pathname !== '/' ? u.pathname : ''),
306
+ cleaning: Boolean(u.username || u.password || u.search || u.hash || !keep) };
307
+ } catch { return { url: '', cleaning: false }; }
196
308
  }
309
+ export function normPublicUrl(given) { return publicUrl(given).url; }
310
+ export function urlNeedsCleaning(given) { return publicUrl(given).cleaning; }
197
311
 
198
312
  export function buildReport(root, opts = {}, procEnv = process.env) {
199
- const { packages, source } = readPackages(root);
313
+ const { packages, source, truncated } = readPackages(root);
314
+ const url = normPublicUrl(opts.url || procEnv.SAFETYVIBE_URL || '');
200
315
  const sb = findSupabase(root, procEnv);
201
- return {
316
+ const rawEnvironment = String(opts.environment || procEnv.SAFETYVIBE_ENV || (procEnv.NODE_ENV === 'production' ? 'production' : ''));
317
+ const environment = rawEnvironment.replace(/[^A-Za-z0-9_-]/g, '');
318
+ return Object.fromEntries(Object.entries({
202
319
  v: 1,
203
- name: appName(root),
204
- url: normPublicUrl(opts.url || procEnv.SAFETYVIBE_URL || ''),
205
- platform: detectPlatform(root, procEnv),
206
- environment: String(opts.environment || procEnv.SAFETYVIBE_ENV || (procEnv.NODE_ENV === 'production' ? 'production' : '')).slice(0, 20),
320
+ name: appName(root, opts.name, url),
321
+ url,
322
+ platform: detectPlatform(root, procEnv, url),
323
+ environment: [rawEnvironment, environment, environment.slice(0, 20)].some(secretLike) ? '' : environment.slice(0, 20),
207
324
  lockfile: source,
208
325
  packages,
326
+ ...(truncated ? { truncated: true } : {}),
209
327
  supabase: sb.url && sb.key ? { url: sb.url, key: sb.key, kind: sb.kind } : null,
210
- };
328
+ }).filter(([, value]) => value !== ''));
211
329
  }
package/package.json CHANGED
@@ -1,31 +1,29 @@
1
- {
2
- "name": "safetyvibe-connect",
3
- "version": "0.2.0",
4
- "description": "SafetyVibe security monitoring for AI-built apps (Lovable, Replit, Bolt, Claude Code\u2026). Reports the app's dependency list, public URL and, if used, its Supabase project URL + public anon key. Never source code or secrets.",
5
- "type": "module",
6
- "bin": {
7
- "safetyvibe-connect": "cli.mjs"
8
- },
9
- "files": [
10
- "cli.mjs",
11
- "lib.mjs",
12
- "README.md",
13
- "AGENTS.md"
14
- ],
15
- "engines": {
16
- "node": ">=18"
17
- },
18
- "license": "UNLICENSED",
19
- "homepage": "https://safetyvibe.eu",
20
- "keywords": [
21
- "security",
22
- "vulnerabilities",
23
- "lovable",
24
- "replit",
25
- "bolt",
26
- "supabase",
27
- "vibe-coding",
28
- "safetyvibe"
29
- ],
30
- "author": "SafetyVibe <safetyvibe@advertis.ee>"
31
- }
1
+ {
2
+ "name": "safetyvibe-connect",
3
+ "version": "0.2.2",
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
+ "type": "module",
6
+ "bin": {
7
+ "safetyvibe-connect": "cli.mjs"
8
+ },
9
+ "files": [
10
+ "cli.mjs",
11
+ "lib.mjs",
12
+ "README.md",
13
+ "AGENTS.md"
14
+ ],
15
+ "engines": {
16
+ "node": ">=18"
17
+ },
18
+ "license": "UNLICENSED",
19
+ "homepage": "https://safetyvibe.eu",
20
+ "keywords": [
21
+ "security",
22
+ "vulnerabilities",
23
+ "dependencies",
24
+ "audit",
25
+ "osv",
26
+ "safetyvibe"
27
+ ],
28
+ "author": "SafetyVibe <safetyvibe@advertis.ee>"
29
+ }