safetyvibe-connect 0.2.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +92 -6
- package/README.md +77 -29
- package/cli.mjs +163 -103
- package/lib.mjs +156 -65
- package/package.json +31 -31
package/AGENTS.md
CHANGED
|
@@ -1,8 +1,94 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Instructions for cautious AI coding agents
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Reports dependency inventory and, if used, the Supabase project URL + public key to SafetyVibe. Requires Node >=18; no dependencies or package install scripts.
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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.1 setup --no-hook --claim-token <TOKEN>
|
|
11
|
+
# pnpm
|
|
12
|
+
pnpm dlx safetyvibe-connect@0.2.1 setup --no-hook --claim-token <TOKEN>
|
|
13
|
+
# bun
|
|
14
|
+
bunx safetyvibe-connect@0.2.1 setup --no-hook --claim-token <TOKEN>
|
|
15
|
+
# yarn
|
|
16
|
+
yarn dlx safetyvibe-connect@0.2.1 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.1 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
|
+
For cautious AI coding agents: inspect the report before sending it:
|
|
28
|
+
|
|
29
|
+
```sh
|
|
30
|
+
npx -y safetyvibe-connect@0.2.1 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",
|
|
40
|
+
"url": "https://notes.example.com",
|
|
41
|
+
"platform": "Lovable",
|
|
42
|
+
"environment": "production",
|
|
43
|
+
"lockfile": "package-lock.json",
|
|
44
|
+
"packages": [{ "ecosystem": "npm", "name": "vite", "version": "6.0.0", "dev": true }],
|
|
45
|
+
"supabase": { "url": "https://example.supabase.co", "key": "sb_publishable_abc123def456", "kind": "publishable" }
|
|
46
|
+
}
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
- `v`: report format version.
|
|
50
|
+
- `name`: explicit name, package name, URL host or folder name, skipping template and key-like names; limited to 80 letters, digits, spaces, @ / . _ -; otherwise empty.
|
|
51
|
+
- `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; otherwise empty.
|
|
52
|
+
- `platform`: detected project builder/hosting label; otherwise empty.
|
|
53
|
+
- `environment`: `SAFETYVIBE_ENV`, or production when `NODE_ENV=production`; limited to 20 letters, digits, underscores or hyphens; otherwise empty.
|
|
54
|
+
- `lockfile`: selected lockfile filename, `node_modules`, or `none`. Parent locks and node_modules 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.
|
|
55
|
+
- `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.
|
|
56
|
+
- `truncated`: optional boolean; only the first 5000 packages are sent.
|
|
57
|
+
- `supabase`: public project URL, strictly validated public key and kind (`publishable` or `anon`); otherwise null.
|
|
58
|
+
|
|
59
|
+
Never sent: source code, file paths (except the folder-name fallback for the app name), full lockfiles, .env files, Supabase secret/service keys, URL credentials or query strings.
|
|
60
|
+
The request headers x-safetyvibe-site, x-safetyvibe-key and x-safetyvibe-claim-token carry the site ID, validated report key and validated one-shot claim token when available; these are not in the JSON body. The server also refuses non-public Supabase keys and deletes stored non-public keys during cleanup.
|
|
61
|
+
Public keys are found in supported environment variables/.env files or a Supabase client file. Local secret-key warnings name only the variable.
|
|
62
|
+
|
|
63
|
+
Review the entire implementation in `cli.mjs` and `lib.mjs` (approximately 485 lines total). There are no dependencies and no install scripts in this package itself.
|
|
64
|
+
By default reports go only to `https://dashboard.safetyvibe.eu/connect/api/v1/report`. The committed rc cannot change the destination.
|
|
65
|
+
An explicit `SAFETYVIBE_API` environment override supports HTTPS, or HTTP on localhost/127.0.0.1/[::1] for local testing. Redirects are refused.
|
|
66
|
+
|
|
67
|
+
## Files written and removal
|
|
68
|
+
|
|
69
|
+
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:
|
|
70
|
+
|
|
71
|
+
- `.safetyviberc.json`: app ID, normalized public URL, optional name and `hook: false`.
|
|
72
|
+
- `.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.
|
|
73
|
+
- `.gitignore`: adds the local config filename.
|
|
74
|
+
- `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.
|
|
75
|
+
|
|
76
|
+
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.
|
|
77
|
+
|
|
78
|
+
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`.
|
|
79
|
+
|
|
80
|
+
For manual reporting use `npx safetyvibe-connect scan` (pnpm: `pnpm dlx`, bun: `bunx`, yarn: `yarn dlx`).
|
|
81
|
+
Remove the connector dependency if installed, its postinstall command, both rc files, and the local-config .gitignore entry. Keep other postinstall commands.
|
|
82
|
+
|
|
83
|
+
## Commands
|
|
84
|
+
|
|
85
|
+
| Command | Effect |
|
|
86
|
+
|---|---|
|
|
87
|
+
| `setup [--claim-token T] [--url URL] [--name NAME] [--no-hook]` | Send report, save config, optionally add hook |
|
|
88
|
+
| `setup --dry-run` | Print exact report without network or writes |
|
|
89
|
+
| `scan [--quiet]` | Re-report; always exits 0 for install hooks |
|
|
90
|
+
| `status` | Show local link state |
|
|
91
|
+
| `help`, `--help`, `-h`, no arguments | Usage only |
|
|
92
|
+
| `--version`, `-v` | Package version only |
|
|
93
|
+
|
|
94
|
+
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,94 @@
|
|
|
1
1
|
# safetyvibe-connect
|
|
2
2
|
|
|
3
|
-
|
|
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
|
+
Reports dependency inventory and, if used, the Supabase project URL + public key to SafetyVibe. Requires Node >=18; no dependencies or package install scripts.
|
|
6
4
|
|
|
7
|
-
##
|
|
5
|
+
## Run without installing
|
|
6
|
+
|
|
7
|
+
Get a claim token from the dashboard, then run:
|
|
8
8
|
|
|
9
9
|
```sh
|
|
10
|
-
|
|
11
|
-
#
|
|
12
|
-
|
|
10
|
+
npx -y safetyvibe-connect@0.2.1 setup --no-hook --claim-token <TOKEN>
|
|
11
|
+
# pnpm
|
|
12
|
+
pnpm dlx safetyvibe-connect@0.2.1 setup --no-hook --claim-token <TOKEN>
|
|
13
|
+
# bun
|
|
14
|
+
bunx safetyvibe-connect@0.2.1 setup --no-hook --claim-token <TOKEN>
|
|
15
|
+
# yarn
|
|
16
|
+
yarn dlx safetyvibe-connect@0.2.1 setup --no-hook --claim-token <TOKEN>
|
|
13
17
|
```
|
|
14
18
|
|
|
15
|
-
|
|
16
|
-
`
|
|
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.1 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
|
|
17
26
|
|
|
18
|
-
|
|
27
|
+
For cautious AI coding agents: inspect the report before sending it:
|
|
19
28
|
|
|
20
|
-
|
|
21
|
-
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
29
|
+
```sh
|
|
30
|
+
npx -y safetyvibe-connect@0.2.1 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",
|
|
40
|
+
"url": "https://notes.example.com",
|
|
41
|
+
"platform": "Lovable",
|
|
42
|
+
"environment": "production",
|
|
43
|
+
"lockfile": "package-lock.json",
|
|
44
|
+
"packages": [{ "ecosystem": "npm", "name": "vite", "version": "6.0.0", "dev": true }],
|
|
45
|
+
"supabase": { "url": "https://example.supabase.co", "key": "sb_publishable_abc123def456", "kind": "publishable" }
|
|
46
|
+
}
|
|
47
|
+
```
|
|
25
48
|
|
|
26
|
-
|
|
49
|
+
- `v`: report format version.
|
|
50
|
+
- `name`: explicit name, package name, URL host or folder name, skipping template and key-like names; limited to 80 letters, digits, spaces, @ / . _ -; otherwise empty.
|
|
51
|
+
- `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; otherwise empty.
|
|
52
|
+
- `platform`: detected project builder/hosting label; otherwise empty.
|
|
53
|
+
- `environment`: `SAFETYVIBE_ENV`, or production when `NODE_ENV=production`; limited to 20 letters, digits, underscores or hyphens; otherwise empty.
|
|
54
|
+
- `lockfile`: selected lockfile filename, `node_modules`, or `none`. Parent locks and node_modules 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.
|
|
55
|
+
- `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.
|
|
56
|
+
- `truncated`: optional boolean; only the first 5000 packages are sent.
|
|
57
|
+
- `supabase`: public project URL, strictly validated public key and kind (`publishable` or `anon`); otherwise null.
|
|
27
58
|
|
|
28
|
-
|
|
59
|
+
Never sent: source code, file paths (except the folder-name fallback for the app name), full lockfiles, .env files, Supabase secret/service keys, URL credentials or query strings.
|
|
60
|
+
The request headers x-safetyvibe-site, x-safetyvibe-key and x-safetyvibe-claim-token carry the site ID, validated report key and validated one-shot claim token when available; these are not in the JSON body. The server also refuses non-public Supabase keys and deletes stored non-public keys during cleanup.
|
|
61
|
+
Public keys are found in supported environment variables/.env files or a Supabase client file. Local secret-key warnings name only the variable.
|
|
29
62
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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.
|
|
63
|
+
Review the entire implementation in `cli.mjs` and `lib.mjs` (approximately 485 lines total). There are no dependencies and no install scripts in this package itself.
|
|
64
|
+
By default reports go only to `https://dashboard.safetyvibe.eu/connect/api/v1/report`. The committed rc cannot change the destination.
|
|
65
|
+
An explicit `SAFETYVIBE_API` environment override supports HTTPS, or HTTP on localhost/127.0.0.1/[::1] for local testing. Redirects are refused.
|
|
37
66
|
|
|
38
|
-
|
|
67
|
+
## Files written and removal
|
|
68
|
+
|
|
69
|
+
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:
|
|
70
|
+
|
|
71
|
+
- `.safetyviberc.json`: app ID, normalized public URL, optional name and `hook: false`.
|
|
72
|
+
- `.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.
|
|
73
|
+
- `.gitignore`: adds the local config filename.
|
|
74
|
+
- `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.
|
|
75
|
+
|
|
76
|
+
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.
|
|
77
|
+
|
|
78
|
+
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`.
|
|
79
|
+
|
|
80
|
+
For manual reporting use `npx safetyvibe-connect scan` (pnpm: `pnpm dlx`, bun: `bunx`, yarn: `yarn dlx`).
|
|
81
|
+
Remove the connector dependency if installed, its postinstall command, both rc files, and the local-config .gitignore entry. Keep other postinstall commands.
|
|
39
82
|
|
|
40
83
|
## Commands
|
|
41
84
|
|
|
42
|
-
|
|
|
85
|
+
| Command | Effect |
|
|
43
86
|
|---|---|
|
|
44
|
-
| `setup [--claim-token T] [--url URL] [--
|
|
45
|
-
| `
|
|
46
|
-
| `
|
|
87
|
+
| `setup [--claim-token T] [--url URL] [--name NAME] [--no-hook]` | Send report, save config, optionally add hook |
|
|
88
|
+
| `setup --dry-run` | Print exact report without network or writes |
|
|
89
|
+
| `scan [--quiet]` | Re-report; always exits 0 for install hooks |
|
|
90
|
+
| `status` | Show local link state |
|
|
91
|
+
| `help`, `--help`, `-h`, no arguments | Usage only |
|
|
92
|
+
| `--version`, `-v` | Package version only |
|
|
93
|
+
|
|
94
|
+
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,183 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
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';
|
|
13
|
-
const
|
|
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
|
|
19
|
-
|
|
20
|
-
|
|
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 && !flags.version) { console.error(USAGE + '\nA command is required.'); process.exit(2); }
|
|
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); }
|
|
21
41
|
const root = process.env.INIT_CWD || process.cwd();
|
|
22
|
-
const say = (...
|
|
23
|
-
|
|
24
|
-
const
|
|
25
|
-
|
|
42
|
+
const say = (...args) => { if (!flags.quiet && !flags['dry-run']) console.log(...args); };
|
|
43
|
+
const alert = (message) => console.error(message);
|
|
44
|
+
const safeText = value => String(value).replace(/[\x00-\x1f\x7f-\x9f]/g, '').slice(0, 300);
|
|
45
|
+
const readJson = (file) => { try { return JSON.parse(readFileSync(join(root, file), 'utf8')) || {}; } catch { return {}; } };
|
|
46
|
+
function readRc() {
|
|
47
|
+
const pub = readJson(RC); const local = readJson(LOCAL);
|
|
48
|
+
return { ...pub, ...Object.fromEntries(['key', 'claimToken'].filter(k => Object.hasOwn(local, k)).map(k => [k, local[k]])) };
|
|
49
|
+
}
|
|
26
50
|
function writeRc(rc) {
|
|
27
|
-
const { key, ...pub } = rc;
|
|
51
|
+
const { key, claimToken, api, ...pub } = rc;
|
|
52
|
+
pub.url = normPublicUrl(pub.url || '');
|
|
53
|
+
let ignored = true;
|
|
54
|
+
if (key || claimToken) try {
|
|
55
|
+
let ignore = ''; try { ignore = readFileSync(join(root, '.gitignore'), 'utf8'); } catch (e) { if (e.code !== 'ENOENT') throw e; }
|
|
56
|
+
if (!ignore.split(/\r?\n/).some(l => l.trim() === LOCAL)) writeFileSync(join(root, '.gitignore'), ignore + (ignore && !ignore.endsWith('\n') ? '\n' : '') + LOCAL + '\n');
|
|
57
|
+
} catch { ignored = false; }
|
|
28
58
|
try {
|
|
59
|
+
if (key || claimToken) {
|
|
60
|
+
writeFileSync(join(root, LOCAL), JSON.stringify({ ...(key ? { key } : {}), ...(claimToken ? { claimToken } : {}) }, null, 2) + '\n', { mode: 0o600 });
|
|
61
|
+
chmodSync(join(root, LOCAL), 0o600);
|
|
62
|
+
} else {
|
|
63
|
+
try { unlinkSync(join(root, LOCAL)); } catch (e) { if (e.code !== 'ENOENT') throw e; }
|
|
64
|
+
}
|
|
29
65
|
writeFileSync(join(root, RC), JSON.stringify(pub, null, 2) + '\n');
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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 {}
|
|
66
|
+
} catch { return false; }
|
|
67
|
+
if (!ignored) alert('saved the report key but could not add it to .gitignore - add .safetyviberc.local.json to .gitignore');
|
|
68
|
+
return ignored ? 'saved' : 'unignored';
|
|
39
69
|
}
|
|
40
70
|
|
|
41
71
|
function addHook() {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
pj
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
72
|
+
let pj; let original;
|
|
73
|
+
try { original = readFileSync(join(root, 'package.json'), 'utf8'); pj = JSON.parse(original); } catch { return 'skipped (no readable package.json)'; }
|
|
74
|
+
if (!pj || typeof pj !== 'object' || Array.isArray(pj)) return 'skipped (invalid package.json)';
|
|
75
|
+
if (Object.hasOwn(pj, 'scripts') && (!pj.scripts || typeof pj.scripts !== 'object' || Array.isArray(pj.scripts))) return 'skipped (invalid scripts)';
|
|
76
|
+
const result = pj.scripts && Object.hasOwn(pj.scripts, 'postinstall') && typeof pj.scripts.postinstall !== 'string'
|
|
77
|
+
? { status: 'unsafe' } : withHook(pj.scripts?.postinstall || '');
|
|
78
|
+
if (result.status === 'unsafe') { alert('Could not safely chain postinstall. Add this line by hand: "postinstall": "' + HOOK + '"'); return 'skipped'; }
|
|
79
|
+
if (result.status === 'already there') return result.status;
|
|
80
|
+
pj.scripts = { ...pj.scripts, postinstall: result.script };
|
|
81
|
+
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'; }
|
|
82
|
+
catch { return 'could not write package.json'; }
|
|
53
83
|
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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); }
|
|
84
|
+
function apiEndpoint() {
|
|
85
|
+
const u = new URL(process.env.SAFETYVIBE_API || DEFAULT_API);
|
|
86
|
+
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');
|
|
87
|
+
return u.href.replace(/\/+$/, '') + '/report';
|
|
66
88
|
}
|
|
67
|
-
|
|
68
|
-
|
|
89
|
+
async function report() {
|
|
90
|
+
const setup = cmd === 'setup';
|
|
91
|
+
let destination;
|
|
92
|
+
try { destination = apiEndpoint(); } catch { alert('Invalid SAFETYVIBE_API: use HTTPS or local HTTP.'); return 1; }
|
|
69
93
|
const rc = readRc();
|
|
70
|
-
const
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
if (
|
|
76
|
-
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
'Set SAFETYVIBE_KEY, or run "safetyvibe-connect setup --claim-token <new prompt token>" to relink.');
|
|
82
|
-
return 0;
|
|
83
|
-
}
|
|
84
|
-
const headers = {};
|
|
94
|
+
const rawToken = flags['claim-token'] || rc.claimToken || process.env.SAFETYVIBE_CLAIM_TOKEN || '';
|
|
95
|
+
const token = typeof rawToken === 'string' && CLAIM_TOKEN.test(rawToken) ? rawToken : '';
|
|
96
|
+
if (rawToken && !token) alert('Invalid claim token in --claim-token, SAFETYVIBE_CLAIM_TOKEN or .safetyviberc.local.json/.safetyviberc.json.');
|
|
97
|
+
const givenUrl = flags.url || rc.url || process.env.SAFETYVIBE_URL || '';
|
|
98
|
+
const url = normPublicUrl(givenUrl);
|
|
99
|
+
if (urlNeedsCleaning(givenUrl)) alert('Removed credentials/query/fragment or unsafe path from URL; only the public URL is stored and sent');
|
|
100
|
+
const body = buildReport(root, { url, name: flags.name || rc.name });
|
|
101
|
+
const headers = { 'content-type': 'application/json', 'user-agent': 'safetyvibe-connect/' + VERSION };
|
|
102
|
+
const rawKey = rc.key || process.env.SAFETYVIBE_KEY || '';
|
|
103
|
+
const key = typeof rawKey === 'string' && REPORT_KEY.test(rawKey) ? rawKey : '';
|
|
104
|
+
if (rawKey && !key) alert('Invalid report key in SAFETYVIBE_KEY or .safetyviberc.local.json/.safetyviberc.json.');
|
|
85
105
|
if (rc.site) headers['x-safetyvibe-site'] = rc.site;
|
|
86
106
|
if (rc.site && key) headers['x-safetyvibe-key'] = key;
|
|
87
|
-
if (
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
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'));
|
|
107
|
+
if (token) headers['x-safetyvibe-claim-token'] = token;
|
|
108
|
+
for (const variable of findSupabase(root).warnings) alert(`Warning: ${variable} holds a secret key. Variables starting with VITE_/NEXT_PUBLIC_/EXPO_PUBLIC_/PUBLIC_ are shipped to every visitor; rotate that key in Supabase.`);
|
|
109
|
+
if (flags['dry-run']) {
|
|
110
|
+
console.log(JSON.stringify(body, null, 2));
|
|
111
|
+
console.error(destination + ' headers: ' + Object.keys(headers).join(', ') + ' (values hidden)');
|
|
115
112
|
return 0;
|
|
116
113
|
}
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
114
|
+
let saveFailed = false;
|
|
115
|
+
const legacy = readJson(RC);
|
|
116
|
+
if (['key', 'claimToken', 'api'].some(k => Object.hasOwn(legacy, k)) || (legacy.url && legacy.url !== normPublicUrl(legacy.url))) {
|
|
117
|
+
const saved = writeRc(rc);
|
|
118
|
+
if (!saved) alert('Could not save .safetyviberc.json migration; remove credentials manually.');
|
|
119
|
+
saveFailed = saved !== 'saved';
|
|
120
|
+
}
|
|
121
|
+
const manager = packageManager(root);
|
|
122
|
+
const runner = { npm: 'npx', pnpm: 'pnpm dlx', yarn: 'yarn dlx', bun: 'bunx' }[manager];
|
|
123
|
+
if (rc.site && !key && !token) {
|
|
124
|
+
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>.');
|
|
125
|
+
return setup ? 1 : 0;
|
|
126
|
+
}
|
|
127
|
+
let response; let b;
|
|
128
|
+
const ctl = new AbortController(); const timer = setTimeout(() => ctl.abort(), 10000);
|
|
129
|
+
try {
|
|
130
|
+
response = await fetch(destination, { method: 'POST', headers, body: JSON.stringify(body), signal: ctl.signal, redirect: 'error' });
|
|
131
|
+
try { b = await response.json(); } catch {}
|
|
132
|
+
} catch { alert('SafetyVibe: could not reach ' + new URL(destination).host + '.'); return 1; }
|
|
133
|
+
finally { clearTimeout(timer); }
|
|
134
|
+
if (typeof b?.warning === 'string') alert('SafetyVibe: ' + safeText(b.warning));
|
|
135
|
+
if (!response.ok) {
|
|
136
|
+
if (['claim_expired', 'claim_used'].includes(b?.code)) {
|
|
137
|
+
if (rc.claimToken && token === rc.claimToken) {
|
|
138
|
+
delete rc.claimToken;
|
|
139
|
+
if (!writeRc(rc)) alert('Could not remove claimToken from .safetyviberc.local.json/.safetyviberc.json; remove it manually before retrying.');
|
|
140
|
+
}
|
|
141
|
+
alert('SafetyVibe: claim token expired or already used. Get a new prompt from your dashboard.');
|
|
142
|
+
}
|
|
143
|
+
alert('SafetyVibe: report rejected: ' + (typeof b?.error === 'string' ? safeText(b.error) : 'HTTP ' + response.status));
|
|
144
|
+
return 1;
|
|
145
|
+
}
|
|
146
|
+
if (!b || typeof b !== 'object' || Array.isArray(b) || typeof b.site !== 'string' || !UUID.test(b.site)) {
|
|
147
|
+
alert('SafetyVibe: unexpected response from ' + new URL(destination).host + ' (HTTP ' + response.status + ')'); return 1;
|
|
148
|
+
}
|
|
149
|
+
const next = { ...rc, site: b.site, url, ...(flags.name ? { name: body.name } : {}), ...(flags['no-hook'] ? { hook: false } : {}) };
|
|
150
|
+
if (typeof b.key === 'string' && REPORT_KEY.test(b.key)) next.key = b.key;
|
|
151
|
+
else if (b.key) alert('Invalid report key in server response.');
|
|
152
|
+
delete next.claimToken;
|
|
153
|
+
const saved = writeRc(next);
|
|
154
|
+
if (!saved) {
|
|
155
|
+
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');
|
|
156
|
+
return 1;
|
|
157
|
+
}
|
|
158
|
+
const inventory = readPackages(root);
|
|
159
|
+
say(`SafetyVibe: reported ${body.packages.length} packages (${body.packages.filter(p => p.dev).length} dev-only).`);
|
|
160
|
+
if (body.truncated) alert(`Only the first 5000 of ${inventory.total} packages were reported.`);
|
|
161
|
+
if (setup && next.hook !== false) {
|
|
162
|
+
const hook = addHook();
|
|
163
|
+
if (hook === 'could not write package.json') { alert('SafetyVibe: report sent; app id ' + b.site + '; could not write package.json.'); return 1; }
|
|
164
|
+
say('SafetyVibe: postinstall hook ' + hook + '.');
|
|
165
|
+
} else if (setup) say('Re-report manually: ' + runner + ' safetyvibe-connect scan');
|
|
166
|
+
if (b.claimed) say('SafetyVibe: linked to your dashboard: https://dashboard.safetyvibe.eu');
|
|
167
|
+
else {
|
|
168
|
+
let claim = '';
|
|
169
|
+
try {
|
|
170
|
+
if (typeof b.claimUrl === 'string' && !/[\x00-\x1f\x7f-\x9f]/.test(b.claimUrl)) {
|
|
171
|
+
const u = new URL(b.claimUrl);
|
|
172
|
+
if (u.protocol === 'https:' && !u.username && !u.password && u.origin === new URL(destination).origin) claim = u.href;
|
|
173
|
+
}
|
|
174
|
+
} catch {}
|
|
175
|
+
say(claim ? 'SafetyVibe: open this link to add the app to your dashboard: ' + claim : 'SafetyVibe: open your SafetyVibe dashboard');
|
|
176
|
+
}
|
|
177
|
+
return saveFailed || saved === 'unignored' ? 1 : 0;
|
|
121
178
|
}
|
|
122
|
-
|
|
123
|
-
|
|
179
|
+
async function main() {
|
|
180
|
+
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; }
|
|
181
|
+
return report();
|
|
182
|
+
}
|
|
183
|
+
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,9 +1,9 @@
|
|
|
1
|
-
// safetyvibe-connect
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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, readdirSync, statSync, lstatSync, realpathSync } from 'node:fs';
|
|
6
|
+
import { join, basename, 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; } };
|
|
@@ -12,17 +12,19 @@ const readJson = (f) => { const t = read(f); if (t == null) return null; try { r
|
|
|
12
12
|
// ---- lockfiles -------------------------------------------------------------
|
|
13
13
|
// Each parser returns [{ name, version }] for every installed package (deduped later).
|
|
14
14
|
|
|
15
|
-
export function fromPackageLock(json) {
|
|
15
|
+
export function fromPackageLock(json, { platform = process.platform, arch = process.arch } = {}) {
|
|
16
16
|
const out = [];
|
|
17
17
|
if (json && json.packages && typeof json.packages === 'object') {
|
|
18
18
|
for (const [path, v] of Object.entries(json.packages)) {
|
|
19
|
-
if (!path || !v || typeof v.version !== 'string') continue; // "" = the root project
|
|
19
|
+
if (!path || !v || v.link || /^(workspace:|link:|file:)/.test(v.resolved || '') || typeof v.version !== 'string') continue; // "" = the root project
|
|
20
|
+
const matches = (list, value) => !Array.isArray(list) || (!list.includes('!' + value) && (!list.some(x => !x.startsWith('!')) || list.includes(value) || list.includes('any')));
|
|
21
|
+
if (v.optional && (!matches(v.os, platform) || !matches(v.cpu, arch))) continue;
|
|
20
22
|
const i = path.lastIndexOf('node_modules/');
|
|
21
23
|
if (i === -1) continue;
|
|
22
|
-
out.push({ name: v.name || path.slice(i + 'node_modules/'.length), version: v.version });
|
|
24
|
+
out.push({ name: v.name || path.slice(i + 'node_modules/'.length), version: v.version, ...(v.dev === true ? { dev: true } : {}) });
|
|
23
25
|
}
|
|
24
26
|
} 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); } };
|
|
27
|
+
const walk = (deps) => { for (const [n, v] of Object.entries(deps || {})) { if (v && v.version && !v.link && !/^(workspace:|link:|file:)/.test(v.resolved || '')) out.push({ name: n, version: v.version, ...(v.dev === true ? { dev: true } : {}) }); walk(v && v.dependencies); } };
|
|
26
28
|
walk(json.dependencies);
|
|
27
29
|
}
|
|
28
30
|
return out;
|
|
@@ -31,7 +33,7 @@ export function fromPackageLock(json) {
|
|
|
31
33
|
// bun.lock (text, JSONC): "packages": { "<key>": ["name@version", registry, {...}, integrity], ... }
|
|
32
34
|
export function fromBunLock(text) {
|
|
33
35
|
const clean = String(text).replace(/^\s*\/\/.*$/gm, '').replace(/,(\s*[}\]])/g, '$1');
|
|
34
|
-
|
|
36
|
+
const json = JSON.parse(clean);
|
|
35
37
|
const out = [];
|
|
36
38
|
for (const v of Object.values((json && json.packages) || {})) {
|
|
37
39
|
const spec = Array.isArray(v) ? v[0] : null;
|
|
@@ -49,7 +51,7 @@ export function fromYarnLock(text) {
|
|
|
49
51
|
const out = []; let names = null;
|
|
50
52
|
for (const line of String(text).split(/\r?\n/)) {
|
|
51
53
|
if (/^\S.*:\s*$/.test(line) && !line.startsWith('#') && !line.startsWith('__metadata')) {
|
|
52
|
-
names = line.replace(/:\s*$/, '').split(',').map((s) => s.trim().replace(/^"|"$/g, ''))
|
|
54
|
+
names = /@(workspace:|link:|file:)/.test(line) ? null : line.replace(/:\s*$/, '').split(',').map((s) => s.trim().replace(/^"|"$/g, ''))
|
|
53
55
|
.map((s) => s.slice(0, s.lastIndexOf('@') > 0 ? s.lastIndexOf('@') : s.length));
|
|
54
56
|
continue;
|
|
55
57
|
}
|
|
@@ -68,6 +70,7 @@ export function fromPnpmLock(text) {
|
|
|
68
70
|
if (!inPackages) continue;
|
|
69
71
|
const m = line.match(/^ {2}'?\/?((?:@[^/@\s]+\/)?[^@\s/]+)@(\d[^('\s:]*)/);
|
|
70
72
|
if (m) out.push({ name: m[1], version: m[2] });
|
|
73
|
+
else if (/^ {4}dev: true\s*$/.test(line) && out.length) out[out.length - 1].dev = true;
|
|
71
74
|
}
|
|
72
75
|
return out;
|
|
73
76
|
}
|
|
@@ -76,7 +79,7 @@ export function fromPnpmLock(text) {
|
|
|
76
79
|
export function fromNodeModules(root) {
|
|
77
80
|
const out = []; const nm = join(root, 'node_modules');
|
|
78
81
|
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 }); };
|
|
82
|
+
const add = (dir) => { try { if (lstatSync(dir).isSymbolicLink() && !realpathSync(dir).startsWith(realpathSync(nm) + sep)) return; } catch { return; } const pj = readJson(join(dir, 'package.json')); if (pj && pj.name && pj.version) out.push({ name: pj.name, version: pj.version }); };
|
|
80
83
|
for (const e of entries) {
|
|
81
84
|
if (e.startsWith('.')) continue;
|
|
82
85
|
if (e.startsWith('@')) { let sub = []; try { sub = readdirSync(join(nm, e)); } catch {} for (const s of sub) add(join(nm, e, s)); }
|
|
@@ -94,8 +97,37 @@ export function readPackages(root) {
|
|
|
94
97
|
['pnpm-lock.yaml', fromPnpmLock],
|
|
95
98
|
['yarn.lock', fromYarnLock],
|
|
96
99
|
];
|
|
97
|
-
|
|
98
|
-
|
|
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; }
|
|
@@ -103,109 +135,168 @@ export function readPackages(root) {
|
|
|
103
135
|
// yarn/pnpm parsers can't tell empty from unsupported, so those stay "unknown".
|
|
104
136
|
if (ok && /\.json$|^bun\.lock$/.test(file)) { source = file; break; }
|
|
105
137
|
}
|
|
106
|
-
if (!list.length && source === 'none') { list = fromNodeModules(root); if (list.length) source = 'node_modules'; }
|
|
107
|
-
const seen = new
|
|
138
|
+
if (!list.length && source === 'none') { list = fromNodeModules(root); if (!list.length && lockRoot !== root) list = fromNodeModules(lockRoot); if (list.length) source = 'node_modules'; }
|
|
139
|
+
const seen = new Map(); const pkgs = [];
|
|
108
140
|
for (const p of list) {
|
|
109
141
|
const k = p.name + '@' + p.version;
|
|
110
142
|
// Registry versions only. git/file/link/http specs can carry tokens or local paths: never sent.
|
|
111
|
-
if (
|
|
112
|
-
|
|
113
|
-
if (
|
|
143
|
+
if ([p.name, p.version].some(v => /sb_secret_|svk_/i.test(String(v)) || keyKind(v) !== 'unknown')) continue;
|
|
144
|
+
if (!/^(@[\w.-]+\/)?[\w.-]+$/.test(p.name) || !/^\d+\.\d+\.\d+[\w.+-]{0,40}$/.test(String(p.version))) continue;
|
|
145
|
+
if (seen.has(k)) { if (p.dev !== true) delete seen.get(k).dev; continue; }
|
|
146
|
+
const row = { ecosystem: 'npm', name: p.name, version: p.version, ...(p.dev === true ? { dev: true } : {}) };
|
|
147
|
+
seen.set(k, row); pkgs.push(row);
|
|
148
|
+
|
|
114
149
|
}
|
|
115
|
-
return { packages: pkgs, source };
|
|
150
|
+
return { packages: pkgs.slice(0, MAX_PACKAGES), source, ...(pkgs.length > MAX_PACKAGES ? { total: pkgs.length, truncated: true } : {}) };
|
|
116
151
|
}
|
|
117
152
|
|
|
118
153
|
// ---- Supabase (project URL + PUBLIC key only) ------------------------------
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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';
|
|
154
|
+
function jwtPayload(value) {
|
|
155
|
+
if (typeof value !== 'string' || value.length > 2000 || !/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/.test(value)) return null;
|
|
156
|
+
try { const p = JSON.parse(Buffer.from(value.split('.')[1], 'base64url').toString('utf8')); return p && typeof p === 'object' && !Array.isArray(p) ? p : null; } catch { return null; }
|
|
132
157
|
}
|
|
133
|
-
|
|
134
|
-
|
|
158
|
+
export function publicKeyKind(value) {
|
|
159
|
+
if (typeof value !== 'string' || /sb_secret_|[\s#'"`]/i.test(value)) return null;
|
|
160
|
+
if (/^sb_publishable_[A-Za-z0-9_-]{8,200}$/.test(value)) return 'publishable';
|
|
161
|
+
const payload = jwtPayload(value);
|
|
162
|
+
const claims = new Set(['iss', 'ref', 'role', 'iat', 'exp', 'nbf', 'aud', 'sub']);
|
|
163
|
+
return payload?.role === 'anon'
|
|
164
|
+
&& (!Object.hasOwn(payload, 'iss') || ['supabase', 'supabase-demo'].includes(payload.iss))
|
|
165
|
+
&& (!Object.hasOwn(payload, 'ref') || (typeof payload.ref === 'string' && /^[a-z0-9]{10,40}$/.test(payload.ref)))
|
|
166
|
+
&& Object.entries(payload).every(([key, value]) => claims.has(key)
|
|
167
|
+
&& (typeof value === 'number' || (typeof value === 'string' && value.length <= 100 && !/sb_secret_|svk_|eyJ/i.test(value)))) ? 'anon' : null;
|
|
168
|
+
}
|
|
169
|
+
export function keyKind(value) {
|
|
170
|
+
const kind = publicKeyKind(value);
|
|
171
|
+
if (kind) return kind;
|
|
172
|
+
const payload = jwtPayload(value);
|
|
173
|
+
return /sb_secret_/i.test(String(value || '')) || (payload && typeof payload.role === 'string' && payload.role !== 'anon') ? 'secret' : 'unknown';
|
|
174
|
+
}
|
|
175
|
+
export function parseEnv(text) {
|
|
135
176
|
const env = {};
|
|
136
177
|
for (const line of String(text || '').split(/\r?\n/)) {
|
|
137
|
-
const m = line.match(/^\s*(?:export\s+)?([
|
|
138
|
-
if (m)
|
|
178
|
+
const m = line.match(/^\s*(?:export\s+)?([\w.-]+)\s*=\s*(.*)$/);
|
|
179
|
+
if (!m) continue;
|
|
180
|
+
const value = m[2]; const quote = value[0];
|
|
181
|
+
if (["'", '"', '`'].includes(quote)) {
|
|
182
|
+
let end = 1;
|
|
183
|
+
for (; end < value.length; end++) {
|
|
184
|
+
if (quote === '"' && value[end] === '\\') { end++; continue; }
|
|
185
|
+
if (value[end] === quote) break;
|
|
186
|
+
}
|
|
187
|
+
if (end === value.length) continue;
|
|
188
|
+
env[m[1]] = value.slice(1, end);
|
|
189
|
+
} else env[m[1]] = value.split('#')[0].trim();
|
|
139
190
|
}
|
|
140
191
|
return env;
|
|
141
192
|
}
|
|
142
193
|
|
|
143
194
|
const URL_VARS = ['VITE_SUPABASE_URL', 'NEXT_PUBLIC_SUPABASE_URL', 'PUBLIC_SUPABASE_URL', 'EXPO_PUBLIC_SUPABASE_URL', 'SUPABASE_URL'];
|
|
144
195
|
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'];
|
|
196
|
+
'PUBLIC_SUPABASE_PUBLISHABLE_KEY', 'EXPO_PUBLIC_SUPABASE_PUBLISHABLE_KEY', 'PUBLIC_SUPABASE_ANON_KEY', 'EXPO_PUBLIC_SUPABASE_ANON_KEY', 'SUPABASE_PUBLISHABLE_KEY', 'SUPABASE_ANON_KEY'];
|
|
146
197
|
|
|
147
198
|
export function findSupabase(root, procEnv = process.env) {
|
|
148
199
|
const env = {};
|
|
149
200
|
for (const f of ['.env', '.env.local', '.env.production', '.env.production.local']) Object.assign(env, parseEnv(read(join(root, f))));
|
|
150
201
|
for (const k of [...URL_VARS, ...KEY_VARS]) if (procEnv[k]) env[k] = procEnv[k];
|
|
202
|
+
const warnings = KEY_VARS.filter(k => keyKind(env[k]) === 'secret');
|
|
151
203
|
let url = URL_VARS.map((k) => env[k]).find(Boolean) || '';
|
|
152
|
-
let key = KEY_VARS.map((k) => env[k]).find(
|
|
204
|
+
let key = KEY_VARS.map((k) => env[k]).find(value => publicKeyKind(value)) || '';
|
|
153
205
|
// Lovable hard-codes them in src/integrations/supabase/client.ts
|
|
154
206
|
if (!url || !key) {
|
|
155
207
|
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
208
|
const t = read(join(root, f)); if (!t) continue;
|
|
157
209
|
url = url || ((t.match(/["'`](https:\/\/[a-z0-9-]+\.supabase\.co)["'`]/i) || [])[1] || '');
|
|
158
|
-
|
|
210
|
+
for (const match of t.matchAll(/([\w]*(?:PUBLISHABLE|ANON)_KEY)\s*=\s*["'`]([^"'`]+)["'`]/gi)) {
|
|
211
|
+
if (keyKind(match[2]) === 'secret') warnings.push(match[1]);
|
|
212
|
+
if (!key && publicKeyKind(match[2])) key = match[2];
|
|
213
|
+
}
|
|
159
214
|
if (url && key) break;
|
|
160
215
|
}
|
|
161
216
|
}
|
|
162
217
|
if (!/^https:\/\/[a-z0-9-]+\.supabase\.(co|in)\/?$/i.test(url)) url = '';
|
|
163
218
|
const kind = key ? keyKind(key) : 'none';
|
|
164
219
|
// Never send anything that isn't provably a public key.
|
|
165
|
-
if (
|
|
166
|
-
return { url: url.replace(/\/$/, ''), key, kind };
|
|
220
|
+
if (!publicKeyKind(key)) key = '';
|
|
221
|
+
return { url: url.replace(/\/$/, ''), key, kind, warnings };
|
|
167
222
|
}
|
|
168
223
|
|
|
169
224
|
// ---- app identity ------------------------------------------------------------
|
|
170
|
-
|
|
225
|
+
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' };
|
|
226
|
+
export function detectPlatform(root, procEnv = process.env, url = '') {
|
|
171
227
|
const pj = readJson(join(root, 'package.json')) || {};
|
|
172
|
-
const deps = { ...
|
|
228
|
+
const deps = { ...pj.dependencies, ...pj.devDependencies };
|
|
173
229
|
if (deps['lovable-tagger'] || existsSync(join(root, '.lovable'))) return 'Lovable';
|
|
174
|
-
if (
|
|
175
|
-
if (existsSync(join(root,
|
|
230
|
+
if (existsSync(join(root, '.replit'))) return 'Replit';
|
|
231
|
+
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;
|
|
176
232
|
if (deps['@base44/sdk']) return 'Base44';
|
|
233
|
+
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';
|
|
234
|
+
try { const host = new URL(url).hostname; for (const [suffix, label] of Object.entries(HOSTS)) if (host.endsWith('.' + suffix)) return label; } catch {}
|
|
235
|
+
if (procEnv.REPL_ID) return 'Replit';
|
|
177
236
|
if (procEnv.VERCEL) return 'Vercel';
|
|
178
237
|
if (procEnv.NETLIFY) return 'Netlify';
|
|
179
238
|
return '';
|
|
180
239
|
}
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
240
|
+
const TEMPLATES = new Set('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(' '));
|
|
241
|
+
// Real JWTs start with eyJ (base64 of {"); plain dotted names like notes.example.com stay allowed.
|
|
242
|
+
const secretLike = value => keyKind(value) !== 'unknown' || /sb_secret_|svk_|eyJ/i.test(value);
|
|
243
|
+
export function appName(root, explicit = '', url = '') {
|
|
244
|
+
let host = ''; try { host = new URL(url).hostname; if (Object.keys(HOSTS).some(s => host.endsWith('.' + s))) host = host.split('.')[0]; } catch {}
|
|
245
|
+
for (const candidate of [explicit, readJson(join(root, 'package.json'))?.name, host, basename(root)]) {
|
|
246
|
+
const raw = String(candidate || '').trim();
|
|
247
|
+
if (secretLike(raw)) continue;
|
|
248
|
+
const n = raw.replace(/[^A-Za-z0-9 @/._-]/g, '').trim();
|
|
249
|
+
if (n && !TEMPLATES.has(n.toLowerCase().replace(/_/g, '-')) && !secretLike(n) && !secretLike(n.slice(0, 80))) return n.slice(0, 80);
|
|
250
|
+
}
|
|
251
|
+
return '';
|
|
252
|
+
}
|
|
253
|
+
export function packageManager(root, env = process.env) {
|
|
254
|
+
const field = readJson(join(root, 'package.json'))?.packageManager?.split('@')[0];
|
|
255
|
+
if (['npm', 'pnpm', 'yarn', 'bun'].includes(field)) return field;
|
|
256
|
+
const source = readPackages(root).source;
|
|
257
|
+
if (source.startsWith('pnpm')) return 'pnpm';
|
|
258
|
+
if (source.startsWith('yarn')) return 'yarn';
|
|
259
|
+
if (source.startsWith('bun') || existsSync(join(root, 'bun.lockb'))) return 'bun';
|
|
260
|
+
if (source.endsWith('.json')) return 'npm';
|
|
261
|
+
return /^(pnpm|yarn|bun)\//.exec(env.npm_config_user_agent || '')?.[1] || 'npm';
|
|
262
|
+
}
|
|
263
|
+
export const HOOK = 'safetyvibe-connect scan --quiet || exit 0';
|
|
264
|
+
export function withHook(current = '') {
|
|
265
|
+
const cur = String(current).trim();
|
|
266
|
+
const base = cur.replace(/\s*&&\s*\(?safetyvibe-connect scan(?: --quiet)?(?: \|\| exit 0)?\)?$/, '').replace(/^\(?safetyvibe-connect scan(?: --quiet)?(?: \|\| exit 0)?\)?$/, '').trim();
|
|
267
|
+
if (/^[&|;]|[&|;\\^]$|[\r\n`<>#;$%!]|\$\(|[()]/.test(base) || /(?<![&|])[&|](?![&|])/.test(base) || /['"]/.test(base)) return { script: current, status: 'unsafe' };
|
|
268
|
+
const script = base ? base + ' && (' + HOOK + ')' : HOOK;
|
|
269
|
+
return { script, status: script === cur ? 'already there' : 'added' };
|
|
191
270
|
}
|
|
192
271
|
|
|
193
|
-
|
|
194
|
-
try {
|
|
195
|
-
|
|
272
|
+
function publicUrl(given) {
|
|
273
|
+
try {
|
|
274
|
+
const u = new URL(String(given));
|
|
275
|
+
if (!['http:', 'https:'].includes(u.protocol)) return { url: '', cleaning: false };
|
|
276
|
+
const keep = /^(\/[A-Za-z0-9._~-]{1,64}){0,8}\/?$/.test(u.pathname)
|
|
277
|
+
&& u.pathname.split('/').every(segment => segment.length <= 40 && !/sb_|svk_|eyJ/i.test(segment));
|
|
278
|
+
return { url: u.origin + (keep && u.pathname !== '/' ? u.pathname : ''),
|
|
279
|
+
cleaning: Boolean(u.username || u.password || u.search || u.hash || !keep) };
|
|
280
|
+
} catch { return { url: '', cleaning: false }; }
|
|
196
281
|
}
|
|
282
|
+
export function normPublicUrl(given) { return publicUrl(given).url; }
|
|
283
|
+
export function urlNeedsCleaning(given) { return publicUrl(given).cleaning; }
|
|
197
284
|
|
|
198
285
|
export function buildReport(root, opts = {}, procEnv = process.env) {
|
|
199
|
-
const { packages, source } = readPackages(root);
|
|
286
|
+
const { packages, source, truncated } = readPackages(root);
|
|
287
|
+
const url = normPublicUrl(opts.url || procEnv.SAFETYVIBE_URL || '');
|
|
200
288
|
const sb = findSupabase(root, procEnv);
|
|
289
|
+
const rawEnvironment = String(opts.environment || procEnv.SAFETYVIBE_ENV || (procEnv.NODE_ENV === 'production' ? 'production' : ''));
|
|
290
|
+
const environment = rawEnvironment.replace(/[^A-Za-z0-9_-]/g, '');
|
|
201
291
|
return {
|
|
202
292
|
v: 1,
|
|
203
|
-
name: appName(root),
|
|
204
|
-
url
|
|
205
|
-
platform: detectPlatform(root, procEnv),
|
|
206
|
-
environment:
|
|
293
|
+
name: appName(root, opts.name, url),
|
|
294
|
+
url,
|
|
295
|
+
platform: detectPlatform(root, procEnv, url),
|
|
296
|
+
environment: [rawEnvironment, environment, environment.slice(0, 20)].some(secretLike) ? '' : environment.slice(0, 20),
|
|
207
297
|
lockfile: source,
|
|
208
298
|
packages,
|
|
299
|
+
...(truncated ? { truncated: true } : {}),
|
|
209
300
|
supabase: sb.url && sb.key ? { url: sb.url, key: sb.key, kind: sb.kind } : null,
|
|
210
301
|
};
|
|
211
302
|
}
|
package/package.json
CHANGED
|
@@ -1,31 +1,31 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "safetyvibe-connect",
|
|
3
|
-
"version": "0.2.
|
|
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.1",
|
|
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
|
+
}
|