sealcode 1.3.6 → 1.4.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/README.md +72 -2
- package/package.json +4 -4
- package/src/cli-grants.js +99 -4
- package/src/cli-link.js +147 -8
- package/src/cli-remove.js +281 -0
- package/src/cli-watch.js +2 -0
- package/src/cli.js +178 -21
- package/src/discovery.js +1004 -0
- package/src/errors.js +39 -4
- package/src/hooks.js +15 -3
- package/src/init.js +152 -11
- package/src/keystore.js +31 -0
- package/src/presets.js +98 -54
- package/src/seal.js +45 -0
- package/src/status.js +34 -3
package/README.md
CHANGED
|
@@ -71,19 +71,31 @@ The big one: **sealcode hides that your code exists at all**. Other tools encryp
|
|
|
71
71
|
|
|
72
72
|
```
|
|
73
73
|
sealcode init # one-time setup; interactive wizard
|
|
74
|
+
# --preset auto universal coverage via git ls-files
|
|
75
|
+
# (default since 1.4.0)
|
|
76
|
+
# --allow-monorepo override the multi-microservice
|
|
77
|
+
# guard (one vault for the whole tree;
|
|
78
|
+
# billed as one project)
|
|
79
|
+
sealcode scan # dry-run: show what would be locked, with coverage
|
|
80
|
+
# report, suspicious-exclusion hints, and a
|
|
81
|
+
# monorepo / multi-microservice notice if any
|
|
82
|
+
# sibling service directories are present
|
|
74
83
|
sealcode lock # encrypt source → vendor/ blobs (removes plaintext)
|
|
75
84
|
sealcode unlock # decrypt blobs → restore source (removes stubs)
|
|
76
85
|
sealcode verify # confirm every blob decrypts & matches its hash
|
|
77
86
|
sealcode status # show locked/unlocked + drift since last lock
|
|
78
|
-
sealcode status --check # exit 1 if unlocked
|
|
87
|
+
sealcode status --check # exit 1 if unlocked (used by git hook). 1.4.1+: strict — passes only when locked.
|
|
88
|
+
sealcode status --check --allow-clean-unlock # 1.4.1+ escape hatch: only fail on drift
|
|
79
89
|
sealcode status --json # machine-readable state (editors / scripts)
|
|
80
90
|
sealcode rotate # change passphrase (blobs unchanged); env: SEALCODE_OLD_PASSPHRASE / SEALCODE_NEW_PASSPHRASE
|
|
81
91
|
sealcode backup <dir> # copy locked vault + config snapshot to a new folder
|
|
82
92
|
sealcode restore <dir> # restore from backup (use --force if locked dir exists)
|
|
83
|
-
sealcode install-hook # git pre-commit: block
|
|
93
|
+
sealcode install-hook # git pre-commit: block any commit while the project is unlocked (strict by default in 1.4.1+)
|
|
94
|
+
sealcode install-hook --lenient # pre-1.4.1 behavior: only block when the working tree has drifted vs the lock
|
|
84
95
|
sealcode uninstall-hook # remove hook block
|
|
85
96
|
sealcode panic # immediate re-lock + session wipe
|
|
86
97
|
sealcode logout # clear cached session, force passphrase next time
|
|
98
|
+
sealcode remove # permanently uninstall sealcode from this project (requires passphrase; emails project owner if linked)
|
|
87
99
|
sealcode presets # list supported ecosystems
|
|
88
100
|
sealcode share <opts> # mint a temporary access code (--email <addr> wraps the key for them)
|
|
89
101
|
# 1.1 controls (all optional):
|
|
@@ -106,6 +118,44 @@ sealcode where # debug: print paths and state (no secrets)
|
|
|
106
118
|
|
|
107
119
|
Aliases: `sc`, `sealcode seal` = `lock`, `sealcode open` = `unlock`.
|
|
108
120
|
|
|
121
|
+
### Unlinking vs. removing
|
|
122
|
+
|
|
123
|
+
There are three different "undo" actions, and it's worth being precise about which one you want:
|
|
124
|
+
|
|
125
|
+
| Command | What it does | Touches the server? | Reversible? |
|
|
126
|
+
|---|---|---|---|
|
|
127
|
+
| `sealcode link --remove` | Forgets the dashboard association on THIS checkout. The vault stays. The server still has the project. | No. | Yes — just `sealcode link <id>` again. |
|
|
128
|
+
| Delete project (dashboard) | Releases the paid slot and the server-side repo binding. The local vault is untouched. | Yes (owner only). | No (the slot is gone). |
|
|
129
|
+
| `sealcode remove` | Permanently uninstalls sealcode from this local repo. Decrypts files back to plaintext, deletes `vendor/`, the config, and the cached session. | Yes if linked: emails the owner and audit-logs `project.remove` BEFORE local destruction. | No. |
|
|
130
|
+
|
|
131
|
+
`sealcode remove` requires the project passphrase (even if a session is unlocked) and a typed `yes-remove` confirmation. When the project is linked, it refuses to run if it can't reach sealcode.dev — that way no one can bypass the owner email by cutting the network. Use `--offline` to override explicitly.
|
|
132
|
+
|
|
133
|
+
Other useful flags:
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
sealcode remove --confirm yes-remove # skip the interactive prompt (for scripts)
|
|
137
|
+
sealcode remove --burn # discard ciphertext WITHOUT decrypting first (data loss)
|
|
138
|
+
sealcode remove --offline # don't notify the owner (we still try first)
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
### One vault per project
|
|
142
|
+
|
|
143
|
+
sealcode is **licensed and operated per project**, and the CLI **plus the server** enforce that.
|
|
144
|
+
|
|
145
|
+
**Locally, at `sealcode init`** — if your repo is a monorepo with multiple microservices (a root `pnpm-workspace.yaml`, a `services/`-style layout with several `package.json` files, etc.), `sealcode init` refuses at the root and tells you which subdirectories to initialize separately. Each service then gets its own keys, its own grants, its own audit trail, and — going forward — its own billing line item.
|
|
146
|
+
|
|
147
|
+
If you genuinely want one vault for the whole tree (small solo repo, intentional experiment monorepo) pass `--allow-monorepo` to override. Run `sealcode scan` first to see what was detected.
|
|
148
|
+
|
|
149
|
+
**Server-side, at `sealcode link`** — each project record on sealcode.dev is pinned 1:1 to a single local repository. The first `sealcode link <id>` records an opaque fingerprint of the repo's vault salt on the server; any later link attempt from a different local repo is rejected with HTTP 409 and `SEALCODE_PROJECT_REPO_MISMATCH`. Teammates cloning the same repo pass the check because they share the same salt.
|
|
150
|
+
|
|
151
|
+
If you really do need to migrate a paid project to a new repo, the project owner can re-pin with:
|
|
152
|
+
|
|
153
|
+
```bash
|
|
154
|
+
sealcode link --force <projectId>
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
The force flag is owner-only and gets audit-logged as `project.link.force`.
|
|
158
|
+
|
|
109
159
|
### Editor support
|
|
110
160
|
|
|
111
161
|
A VS Code companion lives in `tools/vaultline-vscode/` — it shows the current lock state in the status bar when the CLI is on your `PATH`. (Rename in flight; the extension itself works against either `sealcode` or `vaultline` on `PATH`.)
|
|
@@ -212,6 +262,26 @@ Not formally. The crypto is standard primitives (AES-256-GCM, scrypt) used in th
|
|
|
212
262
|
**I had `vaultline` installed before — do I lose my vault?**
|
|
213
263
|
No. The on-disk format is unchanged. `sealcode` reads any existing `.vaultlinerc.json`, your old `~/.vaultline/sessions/` cache, and `VAULTLINE_PASSPHRASE` env var. On the next `sealcode lock` the project will start writing the new config name (`.sealcoderc.json`), but the encrypted blobs themselves never need to be re-encrypted.
|
|
214
264
|
|
|
265
|
+
## Contributing & tests
|
|
266
|
+
|
|
267
|
+
The CLI ships with a small fast test suite that runs in a tmpdir — no Postgres, no network. From `tools/sealcode-cli/`:
|
|
268
|
+
|
|
269
|
+
```bash
|
|
270
|
+
npm test
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
The seven targeted regression tests live under `test/` and cover:
|
|
274
|
+
|
|
275
|
+
- envelope wrap/unwrap round-trip (the team-share crypto core)
|
|
276
|
+
- auto-preset coverage on a synthetic Next.js repo (the 1.4 motivating bug)
|
|
277
|
+
- `preserveUnseen` semantics across scoped re-locks (1.3.6 safety guard)
|
|
278
|
+
- read-only mode re-lock after EACCES (1.3.3 stub-write fix)
|
|
279
|
+
- watcher heartbeat classification of 5xx / network blips as transient
|
|
280
|
+
- `unlock-check` blocking unlock on `revoked` / `expired` server responses
|
|
281
|
+
- stale-pubkey warning when sharing to a recipient whose published key is >30 days old
|
|
282
|
+
|
|
283
|
+
Filter to a single test with `FILTER=auto-preset npm test`.
|
|
284
|
+
|
|
215
285
|
## License
|
|
216
286
|
|
|
217
287
|
MIT. See [LICENSE](./LICENSE).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sealcode",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.1",
|
|
4
4
|
"description": "Lock your source code in your own git repo. Stop AI agents, scrapers, and curious eyes from reading what's yours.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"encryption",
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
},
|
|
25
25
|
"repository": {
|
|
26
26
|
"type": "git",
|
|
27
|
-
"url": "https://github.com/sealcode/sealcode.git"
|
|
27
|
+
"url": "git+https://github.com/sealcode/sealcode.git"
|
|
28
28
|
},
|
|
29
29
|
"license": "MIT",
|
|
30
30
|
"author": "sealcode",
|
|
@@ -40,8 +40,8 @@
|
|
|
40
40
|
"LICENSE"
|
|
41
41
|
],
|
|
42
42
|
"scripts": {
|
|
43
|
-
"test": "node test/
|
|
44
|
-
"selftest": "node test/
|
|
43
|
+
"test": "node test/run.js",
|
|
44
|
+
"selftest": "node test/run.js",
|
|
45
45
|
"preuninstall": "node ./bin/sealcode.js preuninstall-check || true"
|
|
46
46
|
},
|
|
47
47
|
"engines": {
|
package/src/cli-grants.js
CHANGED
|
@@ -66,6 +66,15 @@ function getActiveConfig(projectRoot) {
|
|
|
66
66
|
* the project master key K to it. Returns `{ wrappedKey, wrappedKeyRecipient }`
|
|
67
67
|
* suitable for splatting into the create-grant request body.
|
|
68
68
|
*
|
|
69
|
+
* sealcode@1.4.0 — the server now returns a `devices` array (one entry
|
|
70
|
+
* per machine the recipient has ever run `sealcode login` on). We wrap
|
|
71
|
+
* K once per device and ship the result as `wrappedKeyEnvelopes`, an
|
|
72
|
+
* object keyed by cliTokenId. The recipient's CLI on any of their
|
|
73
|
+
* devices then picks the envelope matching its bearer token and unwraps
|
|
74
|
+
* locally. Pre-1.4 servers omit `devices` and return only the top-level
|
|
75
|
+
* legacy `publicKey`; we still produce the legacy single envelope so
|
|
76
|
+
* those servers keep working.
|
|
77
|
+
*
|
|
69
78
|
* Returns `null` if the recipient hasn't published a pubkey yet OR the
|
|
70
79
|
* server doesn't support the endpoint (pre-1.0 deploy). The caller falls
|
|
71
80
|
* back to the legacy "share-the-passphrase-out-of-band" flow.
|
|
@@ -110,7 +119,7 @@ async function maybeWrapKeyForRecipient({
|
|
|
110
119
|
return null;
|
|
111
120
|
}
|
|
112
121
|
|
|
113
|
-
// Fetch their pubkey.
|
|
122
|
+
// Fetch their pubkey(s).
|
|
114
123
|
let pub;
|
|
115
124
|
try {
|
|
116
125
|
pub = await request(
|
|
@@ -140,10 +149,72 @@ async function maybeWrapKeyForRecipient({
|
|
|
140
149
|
}
|
|
141
150
|
throw err;
|
|
142
151
|
}
|
|
143
|
-
if (!pub
|
|
144
|
-
|
|
152
|
+
if (!pub) return null;
|
|
153
|
+
|
|
154
|
+
// sealcode@1.4.0 — if the server returned a per-device array, wrap K
|
|
155
|
+
// once per device. The recipient can then redeem on any machine and
|
|
156
|
+
// the server will pick the envelope matching that machine's bearer
|
|
157
|
+
// token. Fall back to the singleton legacy pubkey if no array.
|
|
158
|
+
const devices = Array.isArray(pub.devices) && pub.devices.length > 0
|
|
159
|
+
? pub.devices.filter((d) => d && d.publicKey && d.algo === keypair.ALGO)
|
|
160
|
+
: null;
|
|
161
|
+
|
|
162
|
+
if (devices && devices.length > 0) {
|
|
163
|
+
// sealcode@1.4.0 — warn loudly if any of the recipient's devices
|
|
164
|
+
// has a pubkey older than the stale threshold. Stale pubkeys are
|
|
165
|
+
// common when a user nuked their `~/.sealcode/keypair.json` but
|
|
166
|
+
// never re-ran `sealcode login` (regenerates the keypair but only
|
|
167
|
+
// publishes if they reach the login path). Older wraps would still
|
|
168
|
+
// succeed but the recipient would fail to unwrap with a confusing
|
|
169
|
+
// AES-GCM auth error. A pre-share warning gives the owner a chance
|
|
170
|
+
// to ask the recipient to re-login first.
|
|
171
|
+
const STALE_MS = 30 * 24 * 60 * 60 * 1000;
|
|
172
|
+
const now = Date.now();
|
|
173
|
+
const stale = devices.filter((d) => {
|
|
174
|
+
if (!d.publishedAt) return false;
|
|
175
|
+
const t = Date.parse(d.publishedAt);
|
|
176
|
+
return Number.isFinite(t) && now - t > STALE_MS;
|
|
177
|
+
});
|
|
178
|
+
if (stale.length > 0 && verbose) {
|
|
179
|
+
ui.warn(
|
|
180
|
+
`${stale.length} of ${recipientEmail}'s device pubkeys are older than 30 days. `
|
|
181
|
+
+ 'If redeem fails with "could not unwrap delivered key", ask them to run `sealcode login` again on that device.',
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const envelopes = {};
|
|
186
|
+
for (const d of devices) {
|
|
187
|
+
try {
|
|
188
|
+
envelopes[d.cliTokenId] = shareCrypto.wrapForRecipient(K, d.publicKey);
|
|
189
|
+
} catch (err) {
|
|
190
|
+
if (verbose) {
|
|
191
|
+
ui.warn(
|
|
192
|
+
`Could not wrap K for device ${d.label || d.hostname || d.cliTokenId}: ${err.message || err}`,
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
if (Object.keys(envelopes).length === 0) return null;
|
|
198
|
+
// Also produce a legacy single-envelope wrap to the most-recent
|
|
199
|
+
// device so a server that hasn't deployed 1.4 yet still gets a
|
|
200
|
+
// usable `wrappedKey` field.
|
|
201
|
+
const legacy = shareCrypto.wrapForRecipient(K, devices[0].publicKey);
|
|
202
|
+
if (verbose && devices.length > 1) {
|
|
203
|
+
ui.hint(
|
|
204
|
+
` wrapping K for ${devices.length} of ${recipientEmail}'s devices — they can redeem on any.`,
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
return {
|
|
208
|
+
wrappedKey: legacy,
|
|
209
|
+
wrappedKeyRecipient: recipientEmail,
|
|
210
|
+
wrappedKeyEnvelopes: envelopes,
|
|
211
|
+
};
|
|
145
212
|
}
|
|
146
213
|
|
|
214
|
+
// Pre-1.4 server path (no `devices` array, only the top-level fields).
|
|
215
|
+
if (!pub.publicKey || pub.algo !== keypair.ALGO) {
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
147
218
|
const wrapped = shareCrypto.wrapForRecipient(K, pub.publicKey);
|
|
148
219
|
return { wrappedKey: wrapped, wrappedKeyRecipient: recipientEmail };
|
|
149
220
|
}
|
|
@@ -466,16 +537,28 @@ async function runRedeem({ projectRoot, code, json = false, agreeNda = false })
|
|
|
466
537
|
// on first redeem (and warns on mismatch on subsequent redeems).
|
|
467
538
|
const client = { ...clientInfo(), deviceFingerprint: getDeviceFingerprint() };
|
|
468
539
|
|
|
540
|
+
// sealcode@1.4.0 — pass the bearer token if the recipient is logged
|
|
541
|
+
// in on this device, so the server can pick the envelope wrapped for
|
|
542
|
+
// THIS device's pubkey from `wrappedKeyEnvelopes`. `auth: true` is a
|
|
543
|
+
// no-op if no credentials are saved locally (legacy "redeem without
|
|
544
|
+
// login" path).
|
|
545
|
+
let res;
|
|
546
|
+
let useAuth = false;
|
|
547
|
+
try {
|
|
548
|
+
const { readCreds } = require('./api');
|
|
549
|
+
useAuth = !!(readCreds() && readCreds().token);
|
|
550
|
+
} catch (_) { /* ignore */ }
|
|
551
|
+
|
|
469
552
|
// First call without the NDA flag — server tells us whether one is
|
|
470
553
|
// required by including `nda_required: true` and `ndaText` in the
|
|
471
554
|
// 409-NDA response (or in the success payload's policy block). We
|
|
472
555
|
// use a separate pre-flight to a metadata endpoint to avoid the
|
|
473
556
|
// "race the server pins the device fingerprint before user has
|
|
474
557
|
// even agreed" problem.
|
|
475
|
-
let res;
|
|
476
558
|
try {
|
|
477
559
|
res = await request('POST', '/api/v1/access/redeem', {
|
|
478
560
|
body: { code: trimmed, client, ndaAccepted: !!agreeNda },
|
|
561
|
+
auth: useAuth,
|
|
479
562
|
});
|
|
480
563
|
} catch (err) {
|
|
481
564
|
if (err instanceof ApiError && err.status === 409 && err.apiCode === 'nda_required') {
|
|
@@ -494,6 +577,7 @@ async function runRedeem({ projectRoot, code, json = false, agreeNda = false })
|
|
|
494
577
|
}
|
|
495
578
|
res = await request('POST', '/api/v1/access/redeem', {
|
|
496
579
|
body: { code: trimmed, client, ndaAccepted: true },
|
|
580
|
+
auth: useAuth,
|
|
497
581
|
});
|
|
498
582
|
} else {
|
|
499
583
|
throw err;
|
|
@@ -507,6 +591,15 @@ async function runRedeem({ projectRoot, code, json = false, agreeNda = false })
|
|
|
507
591
|
// This is the magic "no passphrase exchange needed" path.
|
|
508
592
|
let cachedK = false;
|
|
509
593
|
let unwrapNote = null;
|
|
594
|
+
// sealcode@1.4.0 — server says "we had envelopes but none for your
|
|
595
|
+
// device". Print the actionable message instead of trying (and
|
|
596
|
+
// failing) to unwrap.
|
|
597
|
+
if (g.wrappedKeyEnvelopeMissing) {
|
|
598
|
+
unwrapNote = ui.c.yellow(
|
|
599
|
+
'(this access code was wrapped for the recipient\'s OTHER devices. '
|
|
600
|
+
+ 'Run `sealcode login` here, then ask the owner to re-mint the share.)',
|
|
601
|
+
);
|
|
602
|
+
}
|
|
510
603
|
if (g.wrappedKey) {
|
|
511
604
|
const kp = keypair.read();
|
|
512
605
|
if (!kp) {
|
|
@@ -781,4 +874,6 @@ module.exports = {
|
|
|
781
874
|
runPause,
|
|
782
875
|
runResume,
|
|
783
876
|
checkUnlockAllowed,
|
|
877
|
+
// Exported for tests. Not part of the public surface and may move.
|
|
878
|
+
_internal: { maybeWrapKeyForRecipient },
|
|
784
879
|
};
|
package/src/cli-link.js
CHANGED
|
@@ -4,19 +4,74 @@
|
|
|
4
4
|
* `sealcode link <projectId>` / `sealcode unlink`.
|
|
5
5
|
*
|
|
6
6
|
* Associates the current project on disk with a dashboard project record so
|
|
7
|
-
* `sealcode share` knows which project to mint a grant against.
|
|
8
|
-
*
|
|
9
|
-
*
|
|
7
|
+
* `sealcode share` knows which project to mint a grant against.
|
|
8
|
+
*
|
|
9
|
+
* sealcode@1.4.0: link is now a two-step server handshake so a single
|
|
10
|
+
* paid project slot can't be quietly multiplexed across N microservices.
|
|
11
|
+
*
|
|
12
|
+
* 1. GET /api/v1/projects/:id — validate the project exists
|
|
13
|
+
* and the user has access. Also returns any previously-pinned
|
|
14
|
+
* local-repo fingerprint so we can give a helpful early error.
|
|
15
|
+
* 2. POST /api/v1/projects/:id/link — claim the project FOR THIS
|
|
16
|
+
* LOCAL REPO. The server compares our `localFingerprint`
|
|
17
|
+
* (an opaque hash of this repo's vault salt) against the one
|
|
18
|
+
* pinned on the project row:
|
|
19
|
+
* - first time → pin it
|
|
20
|
+
* - same repo → 200 OK (idempotent, e.g. fresh clone)
|
|
21
|
+
* - different → 409 conflict (we surface a clear error;
|
|
22
|
+
* owner can re-run with `--force` to re-pin)
|
|
23
|
+
*
|
|
24
|
+
* We persist the link to .sealcoderc.json ONLY after the server
|
|
25
|
+
* accepts it, so a rejected link leaves no stale state behind.
|
|
10
26
|
*/
|
|
11
27
|
|
|
28
|
+
const fs = require('fs');
|
|
29
|
+
const path = require('path');
|
|
12
30
|
const { ApiError, getApiUrl, request } = require('./api');
|
|
13
31
|
const { getLink, setLink, clearLink } = require('./link-state');
|
|
32
|
+
const { localProjectFingerprint } = require('./keystore');
|
|
33
|
+
const { SealcodeError } = require('./errors');
|
|
34
|
+
|
|
35
|
+
function readLockedDirFromConfig(projectRoot) {
|
|
36
|
+
// We can't import getActiveConfig from here without a circular
|
|
37
|
+
// dependency through cli.js, so we re-read .sealcoderc.json
|
|
38
|
+
// directly. Falls back to the historical default if absent so a
|
|
39
|
+
// legacy un-initialized repo still produces a clear "init first"
|
|
40
|
+
// error from localProjectFingerprint (which returns null when no
|
|
41
|
+
// salt is on disk).
|
|
42
|
+
try {
|
|
43
|
+
const candidates = ['.sealcoderc.json', '.sealcode.json'];
|
|
44
|
+
for (const name of candidates) {
|
|
45
|
+
const p = path.join(projectRoot, name);
|
|
46
|
+
if (fs.existsSync(p)) {
|
|
47
|
+
const cfg = JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
48
|
+
if (cfg && typeof cfg.lockedDir === 'string') return cfg.lockedDir;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
} catch (_) { /* fallthrough */ }
|
|
52
|
+
return 'vendor';
|
|
53
|
+
}
|
|
14
54
|
|
|
15
|
-
async function runLink({ projectRoot, projectId }) {
|
|
55
|
+
async function runLink({ projectRoot, projectId, force = false }) {
|
|
16
56
|
if (!projectId || typeof projectId !== 'string') {
|
|
17
57
|
throw new Error('Usage: sealcode link <projectId>');
|
|
18
58
|
}
|
|
19
59
|
|
|
60
|
+
// We need a pinned, deterministic fingerprint of this local repo
|
|
61
|
+
// before we can ask the server to bind a project to it. The only
|
|
62
|
+
// stable per-repo secret we have is the vault salt, which is
|
|
63
|
+
// generated by `sealcode init`. Refuse with a clear hint otherwise.
|
|
64
|
+
const lockedDir = readLockedDirFromConfig(projectRoot);
|
|
65
|
+
const localFingerprint = localProjectFingerprint(projectRoot, lockedDir);
|
|
66
|
+
if (!localFingerprint) {
|
|
67
|
+
throw new SealcodeError('SEALCODE_NO_MANIFEST', {
|
|
68
|
+
detail:
|
|
69
|
+
`No sealcode vault found in ${projectRoot}. ` +
|
|
70
|
+
`Run \`sealcode init\` first so this repo has a stable identity ` +
|
|
71
|
+
`the server can pin the project to.`,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
20
75
|
let res;
|
|
21
76
|
try {
|
|
22
77
|
res = await request('GET', `/api/v1/projects/${encodeURIComponent(projectId)}`, {
|
|
@@ -37,17 +92,85 @@ async function runLink({ projectRoot, projectId }) {
|
|
|
37
92
|
}
|
|
38
93
|
|
|
39
94
|
const project = res.project;
|
|
95
|
+
|
|
96
|
+
// Early, friendly check before we even hit the link endpoint: if
|
|
97
|
+
// the project is already pinned to a DIFFERENT repo and we're not
|
|
98
|
+
// forcing, we can give the user a faster, cleaner error.
|
|
99
|
+
if (
|
|
100
|
+
!force &&
|
|
101
|
+
typeof project.localFingerprint === 'string' &&
|
|
102
|
+
project.localFingerprint &&
|
|
103
|
+
project.localFingerprint !== localFingerprint
|
|
104
|
+
) {
|
|
105
|
+
throw new SealcodeError('SEALCODE_PROJECT_REPO_MISMATCH', {
|
|
106
|
+
detail:
|
|
107
|
+
`Project “${project.name}” (${project.id}) is already linked to a ` +
|
|
108
|
+
`different local repository on the server.\n` +
|
|
109
|
+
` stored fingerprint: ${project.localFingerprint}\n` +
|
|
110
|
+
` this repo: ${localFingerprint}\n` +
|
|
111
|
+
`Each sealcode project is 1:1 with a single repo so per-project ` +
|
|
112
|
+
`billing and audit trails stay accurate.`,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Hand the server our local fingerprint and let it adjudicate.
|
|
117
|
+
// Server-side enforcement is the source of truth — the early
|
|
118
|
+
// check above is just a UX nicety.
|
|
119
|
+
let claim;
|
|
120
|
+
try {
|
|
121
|
+
claim = await request(
|
|
122
|
+
'POST',
|
|
123
|
+
`/api/v1/projects/${encodeURIComponent(projectId)}/link`,
|
|
124
|
+
{
|
|
125
|
+
auth: true,
|
|
126
|
+
body: { localFingerprint, force: !!force },
|
|
127
|
+
},
|
|
128
|
+
);
|
|
129
|
+
} catch (err) {
|
|
130
|
+
if (err instanceof ApiError && err.status === 409) {
|
|
131
|
+
// Server says this project is already bound to another repo.
|
|
132
|
+
throw new SealcodeError('SEALCODE_PROJECT_REPO_MISMATCH', {
|
|
133
|
+
detail: err.message,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
if (err instanceof ApiError && err.status === 403) {
|
|
137
|
+
throw new SealcodeError('SEALCODE_FORBIDDEN', {
|
|
138
|
+
detail: err.message,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
if (err instanceof ApiError && err.status === 404) {
|
|
142
|
+
throw new Error(
|
|
143
|
+
`No project ${projectId} on this account. Check the project ID in your dashboard.`,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
throw err;
|
|
147
|
+
}
|
|
148
|
+
|
|
40
149
|
setLink(projectRoot, {
|
|
41
150
|
apiUrl: getApiUrl(),
|
|
42
151
|
projectId: project.id,
|
|
43
152
|
projectName: project.name,
|
|
44
153
|
fingerprint: project.fingerprint,
|
|
154
|
+
localFingerprint,
|
|
45
155
|
linkedAt: new Date().toISOString(),
|
|
46
156
|
});
|
|
47
157
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
158
|
+
if (claim.outcome === 'match') {
|
|
159
|
+
process.stdout.write(
|
|
160
|
+
`✓ Re-linked ${projectRoot} to “${project.name}” (${project.id}). ` +
|
|
161
|
+
`Server already had this repo on file.\n`,
|
|
162
|
+
);
|
|
163
|
+
} else if (claim.outcome === 'pinned' && force) {
|
|
164
|
+
process.stdout.write(
|
|
165
|
+
`✓ Force-linked ${projectRoot} to “${project.name}” (${project.id}). ` +
|
|
166
|
+
`Previous repo binding overwritten — audit logged.\n`,
|
|
167
|
+
);
|
|
168
|
+
} else {
|
|
169
|
+
process.stdout.write(
|
|
170
|
+
`✓ Linked ${projectRoot} to “${project.name}” (${project.id}). ` +
|
|
171
|
+
`Bound 1:1 to this repository.\n`,
|
|
172
|
+
);
|
|
173
|
+
}
|
|
51
174
|
}
|
|
52
175
|
|
|
53
176
|
function runUnlink({ projectRoot }) {
|
|
@@ -57,8 +180,23 @@ function runUnlink({ projectRoot }) {
|
|
|
57
180
|
return;
|
|
58
181
|
}
|
|
59
182
|
clearLink(projectRoot);
|
|
183
|
+
// Be explicit about what unlink does NOT do, so a user doesn't
|
|
184
|
+
// think they've released their billing slot or "deleted" the
|
|
185
|
+
// project. We deliberately don't release the server-side
|
|
186
|
+
// local_fingerprint pin from here — if we did, anyone with a copy
|
|
187
|
+
// of the repo could free the owner's project slot. The legitimate
|
|
188
|
+
// release path is "Delete project" on the dashboard.
|
|
60
189
|
process.stdout.write(
|
|
61
|
-
|
|
190
|
+
[
|
|
191
|
+
`✓ Unlinked ${projectRoot} from project ${existing.projectId}.`,
|
|
192
|
+
` This only forgets the dashboard association on THIS checkout.`,
|
|
193
|
+
` The vault on disk is unchanged.`,
|
|
194
|
+
` The server still has this project linked to this repo's fingerprint.`,
|
|
195
|
+
``,
|
|
196
|
+
` To free the paid slot, delete the project on https://sealcode.dev/dashboard.`,
|
|
197
|
+
` To remove sealcode from the project entirely, run \`sealcode remove\`.`,
|
|
198
|
+
``,
|
|
199
|
+
].join('\n'),
|
|
62
200
|
);
|
|
63
201
|
}
|
|
64
202
|
|
|
@@ -83,6 +221,7 @@ function runLinkInfo({ projectRoot, json = false }) {
|
|
|
83
221
|
`project name: ${link.projectName || '(unknown)'}`,
|
|
84
222
|
`api url: ${link.apiUrl}`,
|
|
85
223
|
`linked at: ${link.linkedAt || '(unknown)'}`,
|
|
224
|
+
`repo fp: ${link.localFingerprint || '(legacy/none)'}`,
|
|
86
225
|
].join('\n') + '\n',
|
|
87
226
|
);
|
|
88
227
|
}
|