sealcode 1.3.5 → 1.4.0

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 CHANGED
@@ -71,6 +71,15 @@ 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
@@ -84,6 +93,7 @@ sealcode install-hook # git pre-commit: block commits when unlocked + drift
84
93
  sealcode uninstall-hook # remove hook block
85
94
  sealcode panic # immediate re-lock + session wipe
86
95
  sealcode logout # clear cached session, force passphrase next time
96
+ sealcode remove # permanently uninstall sealcode from this project (requires passphrase; emails project owner if linked)
87
97
  sealcode presets # list supported ecosystems
88
98
  sealcode share <opts> # mint a temporary access code (--email <addr> wraps the key for them)
89
99
  # 1.1 controls (all optional):
@@ -106,6 +116,44 @@ sealcode where # debug: print paths and state (no secrets)
106
116
 
107
117
  Aliases: `sc`, `sealcode seal` = `lock`, `sealcode open` = `unlock`.
108
118
 
119
+ ### Unlinking vs. removing
120
+
121
+ There are three different "undo" actions, and it's worth being precise about which one you want:
122
+
123
+ | Command | What it does | Touches the server? | Reversible? |
124
+ |---|---|---|---|
125
+ | `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. |
126
+ | 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). |
127
+ | `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. |
128
+
129
+ `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.
130
+
131
+ Other useful flags:
132
+
133
+ ```bash
134
+ sealcode remove --confirm yes-remove # skip the interactive prompt (for scripts)
135
+ sealcode remove --burn # discard ciphertext WITHOUT decrypting first (data loss)
136
+ sealcode remove --offline # don't notify the owner (we still try first)
137
+ ```
138
+
139
+ ### One vault per project
140
+
141
+ sealcode is **licensed and operated per project**, and the CLI **plus the server** enforce that.
142
+
143
+ **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.
144
+
145
+ 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.
146
+
147
+ **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.
148
+
149
+ If you really do need to migrate a paid project to a new repo, the project owner can re-pin with:
150
+
151
+ ```bash
152
+ sealcode link --force <projectId>
153
+ ```
154
+
155
+ The force flag is owner-only and gets audit-logged as `project.link.force`.
156
+
109
157
  ### Editor support
110
158
 
111
159
  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 +260,26 @@ Not formally. The crypto is standard primitives (AES-256-GCM, scrypt) used in th
212
260
  **I had `vaultline` installed before — do I lose my vault?**
213
261
  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
262
 
263
+ ## Contributing & tests
264
+
265
+ The CLI ships with a small fast test suite that runs in a tmpdir — no Postgres, no network. From `tools/sealcode-cli/`:
266
+
267
+ ```bash
268
+ npm test
269
+ ```
270
+
271
+ The seven targeted regression tests live under `test/` and cover:
272
+
273
+ - envelope wrap/unwrap round-trip (the team-share crypto core)
274
+ - auto-preset coverage on a synthetic Next.js repo (the 1.4 motivating bug)
275
+ - `preserveUnseen` semantics across scoped re-locks (1.3.6 safety guard)
276
+ - read-only mode re-lock after EACCES (1.3.3 stub-write fix)
277
+ - watcher heartbeat classification of 5xx / network blips as transient
278
+ - `unlock-check` blocking unlock on `revoked` / `expired` server responses
279
+ - stale-pubkey warning when sharing to a recipient whose published key is >30 days old
280
+
281
+ Filter to a single test with `FILTER=auto-preset npm test`.
282
+
215
283
  ## License
216
284
 
217
285
  MIT. See [LICENSE](./LICENSE).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sealcode",
3
- "version": "1.3.5",
3
+ "version": "1.4.0",
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",
@@ -40,8 +40,9 @@
40
40
  "LICENSE"
41
41
  ],
42
42
  "scripts": {
43
- "test": "node test/roundtrip.js",
44
- "selftest": "node test/roundtrip.js"
43
+ "test": "node test/run.js",
44
+ "selftest": "node test/run.js",
45
+ "preuninstall": "node ./bin/sealcode.js preuninstall-check || true"
45
46
  },
46
47
  "engines": {
47
48
  "node": ">=18"
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 || !pub.publicKey || pub.algo !== keypair.ALGO) {
144
- return null;
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) {
@@ -628,6 +721,7 @@ async function runRedeem({ projectRoot, code, json = false, agreeNda = false })
628
721
  sp.succeed(
629
722
  `unlocked ${ui.c.bold(ures.count)} files${sk} ${ui.c.dim(`(locked at ${ures.sealedAt})`)}`,
630
723
  );
724
+ try { require('./cli-registry').markUnlocked(projectRoot); } catch (_) { /* ignore */ }
631
725
  if (ures.policy.mode === 'ro') {
632
726
  ui.hint(' Read-only grant: files are 0444. Any modification will trigger an immediate re-lock.');
633
727
  }
@@ -780,4 +874,6 @@ module.exports = {
780
874
  runPause,
781
875
  runResume,
782
876
  checkUnlockAllowed,
877
+ // Exported for tests. Not part of the public surface and may move.
878
+ _internal: { maybeWrapKeyForRecipient },
783
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. We hit
8
- * GET /api/v1/projects/:id to validate the link before persisting — that way
9
- * a typo / wrong-account mistake fails before it touches the config.
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
- process.stdout.write(
49
- `✓ Linked ${projectRoot} to project “${project.name}” (${project.id}).\n`,
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
- `✓ Unlinked ${projectRoot} from project ${existing.projectId}.\n`,
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
  }