@i4ctime/q-ring 0.17.6 → 0.18.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
@@ -10,7 +10,7 @@
10
10
  [![NPM Version](https://img.shields.io/npm/v/@i4ctime/q-ring?style=flat-square&color=0ea5e9)](https://www.npmjs.com/package/@i4ctime/q-ring)
11
11
  [![NPM Downloads](https://img.shields.io/npm/dm/@i4ctime/q-ring?style=flat-square&color=0ea5e9&label=downloads)](https://www.npmjs.com/package/@i4ctime/q-ring)
12
12
  [![Docs](https://img.shields.io/badge/docs-website-0ea5e9?style=flat-square)](https://qring.i4c.studio/docs)
13
- [![MCP Tools](https://img.shields.io/badge/MCP_tools-44-0ea5e9?style=flat-square)](https://glama.ai/mcp/servers/I4cTime/q-ring)
13
+ [![MCP Tools](https://img.shields.io/badge/MCP_tools-46-0ea5e9?style=flat-square)](https://glama.ai/mcp/servers/I4cTime/q-ring)
14
14
  [![Smithery](https://img.shields.io/badge/smithery-i4ctime%2Fq--ring-0ea5e9?style=flat-square)](https://smithery.ai/servers/i4ctime/q-ring)
15
15
  [![Cursor Directory](https://img.shields.io/badge/cursor_directory-q--ring-0ea5e9?style=flat-square)](https://cursor.directory/plugins/q-ring)
16
16
  [![PulseMCP](https://img.shields.io/badge/pulsemcp-q--ring-0ea5e9?style=flat-square)](https://www.pulsemcp.com/servers/i4ctime-q-ring)
@@ -33,7 +33,7 @@ Stop pasting API keys into plain-text `.env` files or wrestling with clunky secr
33
33
  - **Entanglement:** Link keys across projects so rotating one automatically updates them all.
34
34
  - **Tunneling:** Create ephemeral, in-memory secrets that self-destruct after a set time or read count.
35
35
  - **Teleportation:** Securely pack and share AES-256-GCM encrypted secret bundles.
36
- - **Seamless AI Integration:** 44 built-in MCP tools for native use in **Cursor**, **Kiro**, and **Claude Code**.
36
+ - **Seamless AI Integration:** 46 built-in MCP tools for native use in **Cursor**, **Kiro**, and **Claude Code**.
37
37
 
38
38
  ## 🚀 Installation
39
39
 
@@ -111,6 +111,23 @@ QRING_ENV=dev qring get API_KEY # → sk-dev-123
111
111
  qring inspect API_KEY
112
112
  ```
113
113
 
114
+ ### Environment Promotion — Diff, Then Promote
115
+
116
+ Once a secret carries per-environment states, promotion replaces copy-paste: compare two environments key by key (statuses only, never values), then copy a value from one state to another. `diff` exits 1 on drift, so it doubles as a CI gate; `promote` refuses to overwrite a differing target unless you say so.
117
+
118
+ ```bash
119
+ # What differs between staging and prod? (same / different / missing on one side)
120
+ qring diff staging prod
121
+
122
+ # Make prod match staging for one key (asks before overwriting a different value)
123
+ qring promote DATABASE_URL --from staging --to prod
124
+
125
+ # Non-interactive, e.g. in a release script
126
+ qring promote DATABASE_URL --from staging --to prod --force --json
127
+ ```
128
+
129
+ MCP agents get the same two operations as `diff_environments` and `promote_secret`.
130
+
114
131
  ### Wavefunction Collapse — Smart Environment Detection
115
132
 
116
133
  q-ring auto-detects your environment without explicit flags. Resolution order:
@@ -210,16 +227,27 @@ qring tunnel list
210
227
 
211
228
  ### Teleportation — Encrypted Sharing
212
229
 
213
- Pack secrets into AES-256-GCM encrypted bundles for secure transfer between machines. Keys are derived with PBKDF2-HMAC-SHA512 (210 000 iterations) from your passphrase; each bundle records its iteration count, so bundles produced by older versions still unpack.
230
+ Pack secrets into AES-256-GCM encrypted bundles for secure transfer between machines. Two ways to lock a bundle:
231
+
232
+ - **Passphrase** (v1): keys derived with PBKDF2-HMAC-SHA512 (210 000 iterations); each bundle records its iteration count, so old bundles still unpack.
233
+ - **Recipients** (v2, 0.18): each teammate runs `qring teleport keygen` once and shares their recipient string (`qring1…`, an X25519 public key; the private half lives in their OS keyring). `pack --to` encrypts a fresh content key to every recipient — HKDF-SHA256 over an ephemeral X25519 agreement, AES-256-GCM throughout, `node:crypto` only — so nothing secret travels beside the bundle and nobody has to whisper a passphrase.
214
234
 
215
235
  ```bash
216
- # Pack secrets (prompts for passphrase)
236
+ # Passphrase bundle (prompts)
217
237
  qring teleport pack --keys "API_KEY,DB_PASS" > bundle.txt
238
+ cat bundle.txt | qring teleport unpack
239
+
240
+ # Recipient bundle: teammates publish their recipient once…
241
+ qring teleport keygen # → qring1a3F… (share this; keep the keyring)
242
+ qring teleport identity # print it again later
243
+
244
+ # …then you address the pack to them (repeatable or comma-separated)
245
+ qring teleport pack --keys "API_KEY,DB_PASS" --to qring1a3F… --to qring1Zz9… > bundle.txt
218
246
 
219
- # On another machine: unpack (prompts for passphrase)
247
+ # They unpack with the identity in their keyring — no passphrase
220
248
  cat bundle.txt | qring teleport unpack
221
249
 
222
- # Preview without importing
250
+ # Preview: who it's addressed to, whether that's you, and what's inside
223
251
  qring teleport unpack <bundle> --dry-run
224
252
  ```
225
253
 
@@ -339,7 +367,7 @@ qring setup cursor --dry-run
339
367
 
340
368
  ### Push to Deployment Platforms
341
369
 
342
- Push manifest secrets to GitHub Actions, Vercel, or Cloudflare Workers through each platform's **own authenticated CLI** (`gh` / `vercel` / `wrangler`) — q-ring never holds platform tokens, and values travel over stdin, never argv. Every push is recorded in the audit chain.
370
+ Push manifest secrets to GitHub Actions, Vercel, Cloudflare Workers, fly.io, Railway, or Netlify through each platform's **own authenticated CLI** (`gh` / `vercel` / `wrangler` / `flyctl` / `railway` / `netlify`) — q-ring never holds platform tokens, and values travel over stdin (or, for Netlify's import-only CLI, a `0600` temp file that is removed immediately), never argv. Every push is recorded in the audit chain.
343
371
 
344
372
  ```bash
345
373
  # Push the .q-ring.json manifest keys to GitHub Actions secrets
@@ -351,6 +379,11 @@ qring push vercel --vercel-env production,preview
351
379
  # Push to Cloudflare Workers secrets
352
380
  qring push cloudflare
353
381
 
382
+ # fly.io (flyctl secrets import over stdin — note flyctl deploys per import), Railway, Netlify
383
+ qring push fly --app my-app
384
+ qring push railway --service api --railway-env production
385
+ qring push netlify --site 1234-abcd
386
+
354
387
  # Explicit keys, preview first
355
388
  qring push github --keys DATABASE_URL,API_KEY --dry-run
356
389
  ```
@@ -426,7 +459,7 @@ Hooks are fire-and-forget: a failing hook never blocks secret operations. The ho
426
459
 
427
460
  ### Configurable Rotation
428
461
 
429
- Set a rotation format per secret so the agent auto-rotates with the correct value shape.
462
+ Set a rotation format per secret so the agent auto-rotates with the correct value shape, and a rotation interval so q-ring reminds you before a credential goes stale. Every secret remembers when its value last changed (`rotatedAt`); with `--rotate-every` it becomes *due soon* inside the last 20% of the interval (or the last 7 days, whichever is shorter) and *overdue* past it — in `qring inspect`, `qring rotate:due`, the dashboard's "Rotate soon" card and the `inspect_secret` tool.
430
463
 
431
464
  ```bash
432
465
  # Store a secret with rotation format metadata
@@ -434,6 +467,13 @@ qring set STRIPE_KEY "sk-..." --rotation-format api-key --rotation-prefix "sk-"
434
467
 
435
468
  # Store a password with password rotation format
436
469
  qring set DB_PASS "..." --rotation-format password
470
+
471
+ # Remind me every 90 days
472
+ qring set STRIPE_KEY "sk-..." --rotate-every 90
473
+
474
+ # What needs rotating? (most overdue first; --all lists every scheduled secret)
475
+ qring rotate:due
476
+ qring rotate:due --json
437
477
  ```
438
478
 
439
479
  ### Secure Execution & Auto-Redaction
@@ -883,7 +923,7 @@ qring status --no-open
883
923
 
884
924
  ## MCP Server
885
925
 
886
- q-ring includes a full MCP server with 44 tools for AI agent integration.
926
+ q-ring includes a full MCP server with 46 tools for AI agent integration.
887
927
 
888
928
  ### Core Tools
889
929
 
@@ -892,6 +932,8 @@ q-ring includes a full MCP server with 44 tools for AI agent integration.
892
932
  | `get_secret` | Read a secret value (collapses superposition, audits the read) |
893
933
  | `list_secrets` | List keys + metadata in scope (values never exposed); filter by tag, expiry, glob |
894
934
  | `set_secret` | Create or overwrite a single secret with optional TTL, per-env state, tags, rotation format |
935
+ | `promote_secret` | Copy one secret's value from one environment state to another (no-op when equal; `force` to overwrite a differing target) |
936
+ | `diff_environments` | Compare two environments key by key — same / different / only-a / only-b / collapsed; statuses only, never values |
895
937
  | `delete_secret` | Permanently remove a secret value (not undoable from q-ring) |
896
938
  | `has_secret` | Boolean existence check that respects decay (no audit read) |
897
939
  | `export_secrets` | Render multiple secrets as `.env` or JSON for one-off export (skips approval-protected keys without a grant) |
@@ -1074,7 +1116,7 @@ The **q-ring Cursor Plugin** brings quantum secret management directly into your
1074
1116
  | **2 Agents** | `security-auditor` (proactive monitoring) and `secret-ops` (day-to-day assistant) |
1075
1117
  | **8 Commands** | `/qring:scan-secrets`, `/qring:health-check`, `/qring:rotate-expired`, `/qring:setup-project`, `/qring:teleport-secrets`, `/qring:dashboard`, `/qring:exec-safe`, `/qring:analyze` |
1076
1118
  | **3 Hooks** | `afterFileEdit` (lint scan), `sessionStart` (project context), `beforeShellExecution` (`.env` guard) |
1077
- | **MCP Connector** | Auto-connects to `qring-mcp` via stdio — all 44 tools available |
1119
+ | **MCP Connector** | Auto-connects to `qring-mcp` via stdio — all 46 tools available |
1078
1120
 
1079
1121
  Install from the Cursor marketplace or see [`cursor-plugin/README.md`](cursor-plugin/README.md) for manual setup.
1080
1122
 
@@ -36,6 +36,10 @@ var SecretMetadataSchema = z.object({
36
36
  ephemeral: z.boolean().optional(),
37
37
  rotationFormat: z.string().optional(),
38
38
  rotationPrefix: z.string().optional(),
39
+ rotatedAt: z.string().optional(),
40
+ // `.catch(undefined)`: a bad interval strips the reminder rather than
41
+ // rejecting the whole envelope (which would surface the JSON as the value).
42
+ rotateEveryDays: z.number().int().positive().max(3650).optional().catch(void 0),
39
43
  provider: z.string().optional(),
40
44
  validationUrl: z.string().optional(),
41
45
  requiresApproval: z.boolean().optional(),
@@ -73,6 +77,8 @@ function createEnvelope(value, opts) {
73
77
  accessCount: 0,
74
78
  rotationFormat: opts?.rotationFormat,
75
79
  rotationPrefix: opts?.rotationPrefix,
80
+ rotatedAt: opts?.rotatedAt ?? now,
81
+ rotateEveryDays: opts?.rotateEveryDays,
76
82
  provider: opts?.provider,
77
83
  requiresApproval: opts?.requiresApproval,
78
84
  jitProvider: opts?.jitProvider,
@@ -302,14 +308,13 @@ import {
302
308
  closeSync,
303
309
  statSync as statSync3
304
310
  } from "fs";
305
- import { join as join5 } from "path";
306
- import { homedir as homedir3 } from "os";
311
+ import { join as join6 } from "path";
307
312
  import { createHash, createHmac, randomBytes as randomBytes2 } from "crypto";
308
313
 
309
314
  // src/core/backend.ts
310
315
  import { Entry as NapiEntry, findCredentials as napiFindCredentials } from "@napi-rs/keyring";
311
316
  import { existsSync, readFileSync as readFileSync4, writeFileSync as writeFileSync2, mkdirSync as mkdirSync2, chmodSync } from "fs";
312
- import { dirname as dirname2, join as join4 } from "path";
317
+ import { dirname as dirname2, join as join5 } from "path";
313
318
  import { homedir as homedir2 } from "os";
314
319
  import { createCipheriv, createDecipheriv, randomBytes, pbkdf2Sync } from "crypto";
315
320
 
@@ -321,8 +326,19 @@ import {
321
326
  readFileSync as readFileSync3,
322
327
  statSync as statSync2
323
328
  } from "fs";
324
- import { join as join3 } from "path";
329
+ import { join as join4 } from "path";
330
+
331
+ // src/utils/config-dir.ts
325
332
  import { homedir } from "os";
333
+ import { join as join3 } from "path";
334
+ var CONFIG_DIR_ENV = "QRING_CONFIG_DIR";
335
+ function configDir() {
336
+ const override = process.env[CONFIG_DIR_ENV];
337
+ if (override && override.trim()) return override;
338
+ return join3(homedir(), ".config", "q-ring");
339
+ }
340
+
341
+ // src/utils/file-lock.ts
326
342
  function sleepSync(ms) {
327
343
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
328
344
  }
@@ -335,11 +351,11 @@ function isProcessAlive(pid) {
335
351
  }
336
352
  }
337
353
  function withFileLock(name, fn, opts = {}) {
338
- const baseDir = process.env.QRING_LOCK_DIR ?? (process.env.QRING_AUDIT_DIR ? join3(process.env.QRING_AUDIT_DIR, ".locks") : join3(homedir(), ".config", "q-ring"));
339
- const lockDir = join3(baseDir, opts.dir ?? "locks");
354
+ const baseDir = process.env.QRING_LOCK_DIR ?? (process.env.QRING_AUDIT_DIR ? join4(process.env.QRING_AUDIT_DIR, ".locks") : configDir());
355
+ const lockDir = join4(baseDir, opts.dir ?? "locks");
340
356
  mkdirSync(lockDir, { recursive: true, mode: 448 });
341
357
  const safe = Buffer.from(name, "utf8").toString("base64url");
342
- const lockPath = join3(lockDir, `${safe}.lock`);
358
+ const lockPath = join4(lockDir, `${safe}.lock`);
343
359
  const deadline = Date.now() + (opts.timeoutMs ?? 8e3);
344
360
  const staleMs = opts.staleMs ?? 3e4;
345
361
  while (Date.now() < deadline) {
@@ -382,6 +398,12 @@ var PATH_ENV = "QRING_FILE_BACKEND_PATH";
382
398
  var PBKDF2_ITERATIONS = 21e4;
383
399
  var KEY_LENGTH = 32;
384
400
  var FILE_PREFIX = "qfile1";
401
+ function keyringFailure(op, err) {
402
+ const detail = err instanceof Error ? err.message : String(err);
403
+ return new BackendUnavailableError(
404
+ `OS keyring ${op} failed \u2014 the credential store is locked or unreachable (${detail}). Unlock your keyring (or set ${BACKEND_ENV}=file with ${PASSPHRASE_ENV} for a headless store) and retry.`
405
+ );
406
+ }
385
407
  var BackendUnavailableError = class extends Error {
386
408
  constructor(message) {
387
409
  super(message);
@@ -399,7 +421,7 @@ function activeBackend() {
399
421
  return "keyring";
400
422
  }
401
423
  function storePath() {
402
- return process.env[PATH_ENV] ?? join4(homedir2(), ".config", "q-ring", "file-backend.enc");
424
+ return process.env[PATH_ENV] ?? join5(homedir2(), ".config", "q-ring", "file-backend.enc");
403
425
  }
404
426
  function passphrase() {
405
427
  const p = process.env[PASSPHRASE_ENV];
@@ -492,7 +514,11 @@ var Entry = class {
492
514
  if (activeBackend() === "file") {
493
515
  return loadStore().map[this.storeKey()] ?? null;
494
516
  }
495
- return this.delegate().getPassword();
517
+ try {
518
+ return this.delegate().getPassword();
519
+ } catch (err) {
520
+ throw keyringFailure("read", err);
521
+ }
496
522
  }
497
523
  setPassword(password) {
498
524
  if (activeBackend() === "file") {
@@ -511,7 +537,11 @@ var Entry = class {
511
537
  return existed;
512
538
  });
513
539
  }
514
- return this.delegate().deleteCredential();
540
+ try {
541
+ return this.delegate().deleteCredential();
542
+ } catch (err) {
543
+ throw keyringFailure("delete", err);
544
+ }
515
545
  }
516
546
  // @napi-rs/keyring's other delete alias, used by `qring doctor`.
517
547
  deletePassword() {
@@ -592,14 +622,14 @@ function getAuditDir() {
592
622
  }
593
623
  return process.env.QRING_AUDIT_DIR;
594
624
  }
595
- const dir = join5(homedir3(), ".config", "q-ring");
625
+ const dir = configDir();
596
626
  if (!existsSync2(dir)) {
597
627
  mkdirSync3(dir, { recursive: true, mode: 448 });
598
628
  }
599
629
  return dir;
600
630
  }
601
631
  function getAuditPath() {
602
- return join5(getAuditDir(), "audit.jsonl");
632
+ return join6(getAuditDir(), "audit.jsonl");
603
633
  }
604
634
  function getLastLineHash() {
605
635
  const path = getAuditPath();
@@ -849,8 +879,7 @@ function detectAnomalies(key) {
849
879
 
850
880
  // src/core/entanglement.ts
851
881
  import { existsSync as existsSync4, writeFileSync as writeFileSync3, mkdirSync as mkdirSync4 } from "fs";
852
- import { join as join6 } from "path";
853
- import { homedir as homedir4 } from "os";
882
+ import { join as join7 } from "path";
854
883
 
855
884
  // src/utils/registry.ts
856
885
  import { existsSync as existsSync3, readFileSync as readFileSync6, renameSync } from "fs";
@@ -880,11 +909,11 @@ function loadJsonRegistry(path, empty) {
880
909
  // src/core/entanglement.ts
881
910
  var REGISTRY_VERSION = 1;
882
911
  function getRegistryPath() {
883
- const dir = join6(homedir4(), ".config", "q-ring");
912
+ const dir = configDir();
884
913
  if (!existsSync4(dir)) {
885
914
  mkdirSync4(dir, { recursive: true, mode: 448 });
886
915
  }
887
- return join6(dir, "entanglement.json");
916
+ return join7(dir, "entanglement.json");
888
917
  }
889
918
  function loadRegistry() {
890
919
  return loadJsonRegistry(getRegistryPath(), { pairs: [] });
@@ -926,8 +955,7 @@ function listEntanglements() {
926
955
 
927
956
  // src/core/hooks.ts
928
957
  import { existsSync as existsSync5, writeFileSync as writeFileSync4, mkdirSync as mkdirSync5 } from "fs";
929
- import { join as join7 } from "path";
930
- import { homedir as homedir5 } from "os";
958
+ import { join as join8 } from "path";
931
959
  import { execFile, spawn } from "child_process";
932
960
  import { randomUUID } from "crypto";
933
961
 
@@ -1151,11 +1179,11 @@ function httpRequest(opts) {
1151
1179
 
1152
1180
  // src/core/hooks.ts
1153
1181
  function getRegistryPath2() {
1154
- const dir = join7(homedir5(), ".config", "q-ring");
1182
+ const dir = configDir();
1155
1183
  if (!existsSync5(dir)) {
1156
1184
  mkdirSync5(dir, { recursive: true, mode: 448 });
1157
1185
  }
1158
- return join7(dir, "hooks.json");
1186
+ return join8(dir, "hooks.json");
1159
1187
  }
1160
1188
  function loadRegistry2() {
1161
1189
  return loadJsonRegistry(getRegistryPath2(), { hooks: [] });
@@ -1409,12 +1437,11 @@ async function fireHooks(payload, tags) {
1409
1437
 
1410
1438
  // src/core/approval.ts
1411
1439
  import { existsSync as existsSync6, readFileSync as readFileSync7, writeFileSync as writeFileSync5, mkdirSync as mkdirSync6 } from "fs";
1412
- import { join as join8 } from "path";
1413
- import { homedir as homedir6 } from "os";
1440
+ import { join as join9 } from "path";
1414
1441
  import { createHmac as createHmac2, randomBytes as randomBytes3, timingSafeEqual } from "crypto";
1415
1442
  function getHmacSecret() {
1416
- const dir = join8(homedir6(), ".config", "q-ring");
1417
- const secretPath = join8(dir, ".approval-key");
1443
+ const dir = configDir();
1444
+ const secretPath = join9(dir, ".approval-key");
1418
1445
  if (!existsSync6(dir)) mkdirSync6(dir, { recursive: true, mode: 448 });
1419
1446
  if (existsSync6(secretPath)) {
1420
1447
  return readFileSync7(secretPath, "utf8").trim();
@@ -1450,11 +1477,11 @@ function verifyHmac(entry) {
1450
1477
  }
1451
1478
  }
1452
1479
  function getRegistryPath3() {
1453
- const dir = join8(homedir6(), ".config", "q-ring");
1480
+ const dir = configDir();
1454
1481
  if (!existsSync6(dir)) {
1455
1482
  mkdirSync6(dir, { recursive: true, mode: 448 });
1456
1483
  }
1457
- return join8(dir, "approvals.json");
1484
+ return join9(dir, "approvals.json");
1458
1485
  }
1459
1486
  function loadRegistry3() {
1460
1487
  return loadJsonRegistry(getRegistryPath3(), { approvals: [] });
@@ -1481,7 +1508,7 @@ function listApprovals() {
1481
1508
 
1482
1509
  // src/core/policy.ts
1483
1510
  import { statSync as statSync4 } from "fs";
1484
- import { join as join9 } from "path";
1511
+ import { join as join10 } from "path";
1485
1512
  import { z as z2 } from "zod";
1486
1513
  var stringArray = z2.array(z2.string());
1487
1514
  var mcpPolicySchema = z2.object({
@@ -1540,7 +1567,7 @@ function resolvePolicyPath(projectPath) {
1540
1567
  }
1541
1568
  function configMtime(pp) {
1542
1569
  try {
1543
- return statSync4(join9(pp, ".q-ring.json")).mtimeMs;
1570
+ return statSync4(join10(pp, ".q-ring.json")).mtimeMs;
1544
1571
  } catch {
1545
1572
  return 0;
1546
1573
  }
@@ -1563,7 +1590,7 @@ function loadPolicy(projectPath) {
1563
1590
  if (!parsed.success) {
1564
1591
  const issues = parsed.error.issues.map((i) => `policy${i.path.length ? "." + i.path.join(".") : ""}: ${i.message}`).join("; ");
1565
1592
  const error = new PolicyConfigError(
1566
- `Invalid policy in ${join9(pp, ".q-ring.json")} \u2014 refusing to run under an unparseable security policy (fail closed). Fix these and retry: ${issues}`
1593
+ `Invalid policy in ${join10(pp, ".q-ring.json")} \u2014 refusing to run under an unparseable security policy (fail closed). Fix these and retry: ${issues}`
1567
1594
  );
1568
1595
  console.error(`q-ring: ${error.message}`);
1569
1596
  cachedPolicy = { path: pp, mtimeMs, error };
@@ -1801,15 +1828,14 @@ function notifyApprovalRequested(key, source) {
1801
1828
 
1802
1829
  // src/core/canary-webhooks.ts
1803
1830
  import { existsSync as existsSync7, writeFileSync as writeFileSync6, mkdirSync as mkdirSync7 } from "fs";
1804
- import { join as join10 } from "path";
1805
- import { homedir as homedir7 } from "os";
1831
+ import { join as join11 } from "path";
1806
1832
  import { randomUUID as randomUUID2 } from "crypto";
1807
1833
  function getRegistryPath4() {
1808
1834
  const override = process.env.QRING_CANARY_ALERTS_PATH;
1809
1835
  if (override) return override;
1810
- const dir = join10(homedir7(), ".config", "q-ring");
1836
+ const dir = configDir();
1811
1837
  if (!existsSync7(dir)) mkdirSync7(dir, { recursive: true, mode: 448 });
1812
- return join10(dir, "canary-alerts.json");
1838
+ return join11(dir, "canary-alerts.json");
1813
1839
  }
1814
1840
  function loadRegistry4() {
1815
1841
  return loadJsonRegistry(getRegistryPath4(), { channels: [] });
@@ -2109,6 +2135,10 @@ registry.register(httpProvider);
2109
2135
  function withJitEnvelopeLock(service, key, fn) {
2110
2136
  return withFileLock(`${service}\0${key}`, fn, { dir: "jit-locks" });
2111
2137
  }
2138
+ var MAX_ROTATE_EVERY_DAYS = 3650;
2139
+ function isValidRotateEveryDays(days) {
2140
+ return Number.isInteger(days) && days >= 1 && days <= MAX_ROTATE_EVERY_DAYS;
2141
+ }
2112
2142
  function readEnvelope(service, key) {
2113
2143
  const entry = new Entry(service, key);
2114
2144
  const raw = entry.getPassword();
@@ -2271,6 +2301,14 @@ function setSecret(key, value, opts = {}) {
2271
2301
  let envelope;
2272
2302
  const rotFmt = opts.rotationFormat ?? existing?.meta.rotationFormat;
2273
2303
  const rotPfx = opts.rotationPrefix ?? existing?.meta.rotationPrefix;
2304
+ const rotEvery = opts.rotateEveryDays ?? existing?.meta.rotateEveryDays;
2305
+ if (rotEvery !== void 0 && !isValidRotateEveryDays(rotEvery)) {
2306
+ throw new Error(
2307
+ `rotateEveryDays must be a whole number of days between 1 and ${MAX_ROTATE_EVERY_DAYS}`
2308
+ );
2309
+ }
2310
+ const valueChanged = !existing || (opts.states ? JSON.stringify(existing.states ?? null) !== JSON.stringify(opts.states) : existing.states !== void 0 || existing.value !== value);
2311
+ const rotatedAt = valueChanged ? (/* @__PURE__ */ new Date()).toISOString() : existing?.meta.rotatedAt ?? existing?.meta.updatedAt ?? existing?.meta.createdAt;
2274
2312
  const prov = opts.provider ?? existing?.meta.provider;
2275
2313
  const reqApp = opts.requiresApproval ?? existing?.meta.requiresApproval;
2276
2314
  const jitProv = opts.jitProvider ?? existing?.meta.jitProvider;
@@ -2301,6 +2339,8 @@ function setSecret(key, value, opts = {}) {
2301
2339
  entangled: existing?.meta.entangled,
2302
2340
  rotationFormat: rotFmt,
2303
2341
  rotationPrefix: rotPfx,
2342
+ rotatedAt,
2343
+ rotateEveryDays: rotEvery,
2304
2344
  provider: prov,
2305
2345
  requiresApproval: reqApp,
2306
2346
  jitProvider: jitProv,
@@ -2316,6 +2356,8 @@ function setSecret(key, value, opts = {}) {
2316
2356
  entangled: existing?.meta.entangled,
2317
2357
  rotationFormat: rotFmt,
2318
2358
  rotationPrefix: rotPfx,
2359
+ rotatedAt,
2360
+ rotateEveryDays: rotEvery,
2319
2361
  provider: prov,
2320
2362
  requiresApproval: reqApp,
2321
2363
  jitProvider: jitProv,
@@ -2577,6 +2619,50 @@ function disentangleSecrets(sourceKey, sourceOpts, targetKey, targetOpts) {
2577
2619
  });
2578
2620
  }
2579
2621
 
2622
+ // src/core/rotation.ts
2623
+ var DAY_MS = 864e5;
2624
+ var DUE_SOON_FRACTION = 0.2;
2625
+ var DUE_SOON_MAX_DAYS = 7;
2626
+ function dueSoonWindowDays(rotateEveryDays) {
2627
+ return Math.min(rotateEveryDays * DUE_SOON_FRACTION, DUE_SOON_MAX_DAYS);
2628
+ }
2629
+ function parseMs(iso) {
2630
+ if (!iso) return null;
2631
+ const ms = new Date(iso).getTime();
2632
+ return Number.isFinite(ms) ? ms : null;
2633
+ }
2634
+ function rotationStatus(meta, now = Date.now()) {
2635
+ const nowMs = typeof now === "number" ? now : now.getTime();
2636
+ const lastRotatedAt = meta.rotatedAt ?? meta.updatedAt ?? meta.createdAt;
2637
+ const lastMs = parseMs(lastRotatedAt) ?? nowMs;
2638
+ const ageDays = Math.max(0, Math.floor((nowMs - lastMs) / DAY_MS));
2639
+ const every = meta.rotateEveryDays;
2640
+ if (!every || !Number.isFinite(every) || every <= 0) {
2641
+ return {
2642
+ lastRotatedAt,
2643
+ ageDays,
2644
+ rotateEveryDays: null,
2645
+ dueAt: null,
2646
+ daysUntilDue: null,
2647
+ state: "unscheduled"
2648
+ };
2649
+ }
2650
+ const dueMs = lastMs + every * DAY_MS;
2651
+ const untilMs = dueMs - nowMs;
2652
+ const daysUntilDue = Math.ceil(untilMs / DAY_MS);
2653
+ let state = "ok";
2654
+ if (untilMs <= 0) state = "overdue";
2655
+ else if (untilMs <= dueSoonWindowDays(every) * DAY_MS) state = "due-soon";
2656
+ return {
2657
+ lastRotatedAt,
2658
+ ageDays,
2659
+ rotateEveryDays: every,
2660
+ dueAt: new Date(dueMs).toISOString(),
2661
+ daysUntilDue,
2662
+ state
2663
+ };
2664
+ }
2665
+
2580
2666
  // src/core/tunnel.ts
2581
2667
  import { randomBytes as randomBytes4 } from "crypto";
2582
2668
  var tunnelStore = /* @__PURE__ */ new Map();
@@ -2651,8 +2737,8 @@ function tunnelList() {
2651
2737
 
2652
2738
  // src/core/memory.ts
2653
2739
  import { existsSync as existsSync8, readFileSync as readFileSync8, writeFileSync as writeFileSync7, mkdirSync as mkdirSync8, chmodSync as chmodSync3 } from "fs";
2654
- import { join as join11 } from "path";
2655
- import { homedir as homedir8, hostname, userInfo } from "os";
2740
+ import { join as join12 } from "path";
2741
+ import { hostname, userInfo } from "os";
2656
2742
  import {
2657
2743
  createCipheriv as createCipheriv2,
2658
2744
  createDecipheriv as createDecipheriv2,
@@ -2664,7 +2750,7 @@ var MEMORY_FILE = "agent-memory.enc";
2664
2750
  var KEYRING_SERVICE = "qring-memory-key";
2665
2751
  var KEYRING_ACCOUNT = "encryption-key";
2666
2752
  function getMemoryDir() {
2667
- const dir = join11(homedir8(), ".config", "q-ring");
2753
+ const dir = configDir();
2668
2754
  if (!existsSync8(dir)) {
2669
2755
  mkdirSync8(dir, { recursive: true, mode: 448 });
2670
2756
  }
@@ -2678,7 +2764,7 @@ function writeMemoryFile(path, data) {
2678
2764
  }
2679
2765
  }
2680
2766
  function getMemoryPath() {
2681
- return join11(getMemoryDir(), MEMORY_FILE);
2767
+ return join12(getMemoryDir(), MEMORY_FILE);
2682
2768
  }
2683
2769
  var PBKDF2_ITERATIONS2 = 21e4;
2684
2770
  var KEY_LENGTH2 = 32;
@@ -2900,6 +2986,7 @@ function summariseSession(session) {
2900
2986
 
2901
2987
  export {
2902
2988
  PACKAGE_VERSION,
2989
+ Entry,
2903
2990
  checkDecay,
2904
2991
  readProjectConfig,
2905
2992
  collapseEnvironment,
@@ -2933,6 +3020,7 @@ export {
2933
3020
  exportSecrets,
2934
3021
  entangleSecrets,
2935
3022
  disentangleSecrets,
3023
+ rotationStatus,
2936
3024
  tunnelCreate,
2937
3025
  tunnelRead,
2938
3026
  tunnelDestroy,
@@ -2945,4 +3033,4 @@ export {
2945
3033
  listAgentSessionsForAgents,
2946
3034
  summariseSession
2947
3035
  };
2948
- //# sourceMappingURL=chunk-VMDD5QTJ.js.map
3036
+ //# sourceMappingURL=chunk-NTCTVLSK.js.map