@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/dist/index.js CHANGED
@@ -1,5 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
+ Entry,
4
+ MAX_ROTATE_EVERY_DAYS,
3
5
  PACKAGE_VERSION,
4
6
  addCanaryAlert,
5
7
  checkDecay,
@@ -8,9 +10,12 @@ import {
8
10
  checkWrapToolPolicy,
9
11
  clearMemory,
10
12
  collapseEnvironment,
13
+ compareRotationUrgency,
14
+ configDir,
11
15
  countLegacyApprovals,
12
16
  deleteSecret,
13
17
  describeAlertUrl,
18
+ describeRotation,
14
19
  detectAnomalies,
15
20
  disableHook,
16
21
  disarmCanary,
@@ -31,6 +36,7 @@ import {
31
36
  hasSecret,
32
37
  hashProjectPath,
33
38
  httpRequest,
39
+ isValidRotateEveryDays,
34
40
  listAgentSessions,
35
41
  listApprovals,
36
42
  listCanaryAlerts,
@@ -47,6 +53,7 @@ import {
47
53
  removeCanaryAlert,
48
54
  removeHook,
49
55
  revokeApproval,
56
+ rotationStatus,
50
57
  sendCanaryAlerts,
51
58
  serviceForScope,
52
59
  setAuditAgentLabel,
@@ -60,7 +67,7 @@ import {
60
67
  verifyAuditChain,
61
68
  wrapRedactsResults,
62
69
  wrapToolRequiresApproval
63
- } from "./chunk-5ZCQSBVS.js";
70
+ } from "./chunk-ZBPMEJRO.js";
64
71
 
65
72
  // src/cli/commands.ts
66
73
  import { Command, Help } from "commander";
@@ -339,6 +346,12 @@ function filterSecretsByKeyGlob(entries, filter) {
339
346
  }
340
347
 
341
348
  // src/cli/commands/secrets.ts
349
+ function rotationBadge(state) {
350
+ if (state === "overdue") return c.red(`${SYMBOLS.cross} overdue`);
351
+ if (state === "due-soon") return c.yellow(`${SYMBOLS.warning} due soon`);
352
+ if (state === "ok") return c.green(`${SYMBOLS.check} ok`);
353
+ return c.dim("unscheduled");
354
+ }
342
355
  function registerSecretsCommands(program2) {
343
356
  program2.command("set <key> [value]").description("Store a secret (with optional quantum metadata)").option("-g, --global", "Store in global scope").option("-p, --project", "Store in project scope (uses cwd)").option("--team <id>", "Store in team scope").option("--org <id>", "Store in org scope").option("--project-path <path>", "Explicit project path").option(
344
357
  "-e, --env <env>",
@@ -351,6 +364,9 @@ function registerSecretsCommands(program2) {
351
364
  "--rotation-format <format>",
352
365
  "Format for auto-rotation (api-key, password, uuid, hex, base64, alphanumeric, token)"
353
366
  ).option("--rotation-prefix <prefix>", "Prefix for auto-rotation (e.g. sk-)").option(
367
+ "--rotate-every <days>",
368
+ `Remind to rotate every N days (1-${MAX_ROTATE_EVERY_DAYS}); see \`qring rotate:due\``
369
+ ).option(
354
370
  "--requires-approval",
355
371
  "Require explicit user approval for MCP agents to read"
356
372
  ).option(
@@ -367,6 +383,19 @@ function registerSecretsCommands(program2) {
367
383
  process.exit(1);
368
384
  }
369
385
  }
386
+ let rotateEveryDays;
387
+ if (cmd.rotateEvery !== void 0) {
388
+ const parsed = /^\d+$/.test(String(cmd.rotateEvery).trim()) ? Number(cmd.rotateEvery) : Number.NaN;
389
+ if (!isValidRotateEveryDays(parsed)) {
390
+ console.error(
391
+ c.red(
392
+ `${SYMBOLS.cross} --rotate-every must be a whole number of days between 1 and ${MAX_ROTATE_EVERY_DAYS} (got "${cmd.rotateEvery}")`
393
+ )
394
+ );
395
+ process.exit(1);
396
+ }
397
+ rotateEveryDays = parsed;
398
+ }
370
399
  const setOpts = {
371
400
  ...opts,
372
401
  ttlSeconds: cmd.ttl,
@@ -375,6 +404,7 @@ function registerSecretsCommands(program2) {
375
404
  tags: cmd.tags?.split(",").map((t) => t.trim()),
376
405
  rotationFormat: cmd.rotationFormat,
377
406
  rotationPrefix: cmd.rotationPrefix,
407
+ rotateEveryDays,
378
408
  requiresApproval: cmd.requiresApproval,
379
409
  jitProvider: cmd.jitProvider
380
410
  };
@@ -403,6 +433,7 @@ function registerSecretsCommands(program2) {
403
433
  setSecret(key, value, setOpts);
404
434
  const extras = [];
405
435
  if (cmd.ttl) extras.push(`${SYMBOLS.clock} ttl=${cmd.ttl}s`);
436
+ if (rotateEveryDays) extras.push(c.dim(`rotate every ${rotateEveryDays}d`));
406
437
  if (cmd.description) extras.push(c.dim(cmd.description));
407
438
  console.log(
408
439
  `${SYMBOLS.check} ${c.green("saved")} ${c.bold(key)} ${c.dim(`[${scopeColor(opts.scope ?? "global")}]`)} ${extras.join(" ")}`
@@ -541,6 +572,7 @@ function registerSecretsCommands(program2) {
541
572
  }
542
573
  const { envelope, scope } = result;
543
574
  const decay = checkDecay(envelope);
575
+ const rotation = rotationStatus(envelope.meta);
544
576
  if (emitJson(program2, cmd, {
545
577
  key: safeStr(key),
546
578
  scope: safeStr(scope),
@@ -557,7 +589,8 @@ function registerSecretsCommands(program2) {
557
589
  service: safeStr(l.service),
558
590
  key: safeStr(l.key)
559
591
  })),
560
- decay
592
+ decay,
593
+ rotation
561
594
  })) {
562
595
  return;
563
596
  }
@@ -611,6 +644,9 @@ function registerSecretsCommands(program2) {
611
644
  ` ${c.dim("decay:")} ${decayIndicator(decayPct, expired)} ${decayTime}`
612
645
  );
613
646
  }
647
+ const rotAge = `${rotation.ageDays}d ago`;
648
+ const rotLine = rotation.state === "unscheduled" ? c.dim(`last rotated ${rotAge} \xB7 no reminder set`) : `${rotationBadge(rotation.state)} ${describeRotation(rotation)} ${c.dim(`(last rotated ${rotAge}, every ${rotation.rotateEveryDays}d)`)}`;
649
+ console.log(` ${c.dim("rotation:")} ${rotLine}`);
614
650
  if (entangled.length > 0) {
615
651
  console.log(` ${c.dim("entangled:")}`);
616
652
  for (const link of entangled) {
@@ -1450,11 +1486,181 @@ ${SYMBOLS.zap} ${c.bold(`Setting up service: ${name}`)}
1450
1486
  });
1451
1487
  }
1452
1488
 
1489
+ // src/core/promote.ts
1490
+ var PromoteConflictError = class extends Error {
1491
+ code = "ERR_PROMOTE_CONFLICT";
1492
+ constructor(key, to) {
1493
+ super(
1494
+ `"${key}" already has a different value for env "${to}" \u2014 pass force to overwrite it`
1495
+ );
1496
+ }
1497
+ };
1498
+ function assertEnvName(env, label) {
1499
+ if (!/^[A-Za-z0-9_.-]{1,64}$/.test(env)) {
1500
+ throw new Error(`${label} environment name "${env}" is invalid (letters, digits, _ . - only)`);
1501
+ }
1502
+ }
1503
+ function promoteSecret(key, opts) {
1504
+ assertEnvName(opts.from, "source");
1505
+ assertEnvName(opts.to, "target");
1506
+ if (opts.from === opts.to) {
1507
+ throw new Error(`source and target environment are both "${opts.from}"`);
1508
+ }
1509
+ const found = getEnvelope(key, opts);
1510
+ if (!found) throw new Error(`Secret "${key}" not found`);
1511
+ const { envelope, scope } = found;
1512
+ const states = envelope.states;
1513
+ if (!states) {
1514
+ throw new Error(
1515
+ `"${key}" has a single value, not per-environment states \u2014 set one with: qring set ${key} --env ${opts.from}`
1516
+ );
1517
+ }
1518
+ const source = states[opts.from];
1519
+ if (source === void 0) {
1520
+ const available = Object.keys(states).join(", ") || "none";
1521
+ throw new Error(`"${key}" has no value for env "${opts.from}" (available: ${available})`);
1522
+ }
1523
+ const current = states[opts.to];
1524
+ const previous = current === void 0 ? "absent" : current === source ? "same" : "different";
1525
+ if (previous === "same") {
1526
+ return { key, scope, from: opts.from, to: opts.to, previous, changed: false };
1527
+ }
1528
+ if (previous === "different" && !opts.force) {
1529
+ throw new PromoteConflictError(key, opts.to);
1530
+ }
1531
+ const nextStates = { ...states, [opts.to]: source };
1532
+ setSecret(key, "", {
1533
+ ...opts,
1534
+ scope,
1535
+ states: nextStates,
1536
+ defaultEnv: envelope.defaultEnv
1537
+ });
1538
+ return { key, scope, from: opts.from, to: opts.to, previous, changed: true };
1539
+ }
1540
+ function diffEnvironments(opts) {
1541
+ assertEnvName(opts.a, "first");
1542
+ assertEnvName(opts.b, "second");
1543
+ const wanted = opts.keys ? new Set(opts.keys) : null;
1544
+ const entries = [];
1545
+ for (const entry of listSecrets(opts)) {
1546
+ if (wanted && !wanted.has(entry.key)) continue;
1547
+ const states = entry.envelope?.states;
1548
+ let status;
1549
+ if (!states) {
1550
+ status = "collapsed";
1551
+ } else {
1552
+ const va = states[opts.a];
1553
+ const vb = states[opts.b];
1554
+ if (va === void 0 && vb === void 0) continue;
1555
+ if (va === void 0) status = "only-b";
1556
+ else if (vb === void 0) status = "only-a";
1557
+ else status = va === vb ? "same" : "different";
1558
+ }
1559
+ entries.push({ key: entry.key, scope: entry.scope, status });
1560
+ }
1561
+ const summary = {
1562
+ same: 0,
1563
+ different: 0,
1564
+ "only-a": 0,
1565
+ "only-b": 0,
1566
+ collapsed: 0
1567
+ };
1568
+ for (const e of entries) summary[e.status] += 1;
1569
+ const drift = summary.different + summary["only-a"] + summary["only-b"] > 0;
1570
+ return { a: opts.a, b: opts.b, entries, summary, drift };
1571
+ }
1572
+
1573
+ // src/cli/commands/environments.ts
1574
+ var STATUS_LABEL = {
1575
+ same: c.green("same"),
1576
+ different: c.yellow("different"),
1577
+ "only-a": c.red("missing in B"),
1578
+ "only-b": c.red("missing in A"),
1579
+ collapsed: c.dim("single value")
1580
+ };
1581
+ function registerEnvironmentCommands(program2) {
1582
+ program2.command("promote <key>").description("Copy a secret's value from one environment to another (superposition)").requiredOption("--from <env>", "Source environment").requiredOption("--to <env>", "Target environment").option("-g, --global", "Global scope").option("-p, --project", "Project scope (uses cwd)").option("--team <id>", "Team scope").option("--org <id>", "Org scope").option("--project-path <path>", "Explicit project path").option("-f, --force", "Overwrite a differing target value without asking").option("-y, --yes", "Alias for --force").action(async (key, cmd) => {
1583
+ const opts = buildOpts(cmd);
1584
+ const json = wantsJsonOutput(program2, cmd);
1585
+ const run = (force) => promoteSecret(key, { ...opts, from: cmd.from, to: cmd.to, force });
1586
+ try {
1587
+ let result;
1588
+ try {
1589
+ result = run(Boolean(cmd.force || cmd.yes));
1590
+ } catch (err) {
1591
+ if (!(err instanceof PromoteConflictError) || json) throw err;
1592
+ const ok = await confirm(
1593
+ `${SYMBOLS.warning} ${c.bold(key)} already has a different value for ${envBadge(cmd.to)}. Overwrite it with the ${envBadge(cmd.from)} value?`
1594
+ );
1595
+ if (!ok) {
1596
+ console.error(c.dim("Cancelled."));
1597
+ process.exitCode = 1;
1598
+ return;
1599
+ }
1600
+ result = run(true);
1601
+ }
1602
+ if (emitJson(program2, cmd, result)) return;
1603
+ const verb = result.changed ? c.green("promoted") : c.dim("already in sync");
1604
+ console.log(
1605
+ `${SYMBOLS.check} ${verb} ${c.bold(key)} ${envBadge(result.from)} ${c.dim("\u2192")} ${envBadge(result.to)} ${c.dim(`[${scopeColor(result.scope)}]`)}`
1606
+ );
1607
+ } catch (err) {
1608
+ const message = err instanceof Error ? err.message : String(err);
1609
+ if (json) console.log(JSON.stringify({ ok: false, error: message }));
1610
+ else console.error(c.red(`${SYMBOLS.cross} ${message}`));
1611
+ process.exitCode = 1;
1612
+ }
1613
+ });
1614
+ program2.command("diff <envA> <envB>").description("Compare two environments key by key \u2014 statuses only, never values (exit 1 on drift)").option("-k, --keys <keys>", "Comma-separated keys to compare (default: all visible)").option("-g, --global", "Global scope").option("-p, --project", "Project scope (uses cwd)").option("--team <id>", "Team scope").option("--org <id>", "Org scope").option("--project-path <path>", "Explicit project path").action((envA, envB, cmd) => {
1615
+ const opts = buildOpts(cmd);
1616
+ const keys = cmd.keys ? String(cmd.keys).split(",").map((k) => k.trim()).filter(Boolean) : void 0;
1617
+ try {
1618
+ const result = diffEnvironments({ ...opts, a: envA, b: envB, keys });
1619
+ if (result.drift) process.exitCode = 1;
1620
+ if (emitJson(program2, cmd, result)) return;
1621
+ if (result.entries.length === 0) {
1622
+ console.log(c.dim(`No secrets carry ${envA} or ${envB} states.`));
1623
+ return;
1624
+ }
1625
+ console.log(`${envBadge(envA)} ${c.dim("A")} ${envBadge(envB)} ${c.dim("B")}`);
1626
+ const width = Math.max(...result.entries.map((e) => e.key.length));
1627
+ for (const e of result.entries) {
1628
+ console.log(
1629
+ ` ${c.bold(e.key.padEnd(width))} ${STATUS_LABEL[e.status] ?? e.status} ${c.dim(`[${scopeColor(e.scope)}]`)}`
1630
+ );
1631
+ }
1632
+ const s = result.summary;
1633
+ console.log(
1634
+ c.dim(
1635
+ `
1636
+ ${s.same} same \xB7 ${s.different} different \xB7 ${s["only-a"]} only in A \xB7 ${s["only-b"]} only in B \xB7 ${s.collapsed} single-value`
1637
+ )
1638
+ );
1639
+ if (result.drift) {
1640
+ console.log(c.yellow(`${SYMBOLS.warning} drift detected \u2014 promote with: qring promote KEY --from ${envA} --to ${envB}`));
1641
+ } else {
1642
+ console.log(c.green(`${SYMBOLS.check} environments aligned`));
1643
+ }
1644
+ } catch (err) {
1645
+ const message = err instanceof Error ? err.message : String(err);
1646
+ if (wantsJsonOutput(program2, cmd)) console.log(JSON.stringify({ ok: false, error: message }));
1647
+ else console.error(c.red(`${SYMBOLS.cross} ${message}`));
1648
+ process.exitCode = 1;
1649
+ }
1650
+ });
1651
+ }
1652
+
1453
1653
  // src/core/teleport.ts
1454
1654
  import {
1455
1655
  randomBytes as randomBytes2,
1456
1656
  createCipheriv,
1457
1657
  createDecipheriv,
1658
+ createHash,
1659
+ createPrivateKey,
1660
+ createPublicKey,
1661
+ diffieHellman,
1662
+ generateKeyPairSync,
1663
+ hkdfSync,
1458
1664
  pbkdf2Sync
1459
1665
  } from "crypto";
1460
1666
  import { z } from "zod";
@@ -1488,6 +1694,34 @@ var TeleportPayloadSchema = z.object({
1488
1694
  function deriveKey(passphrase, salt, iterations = PBKDF2_ITERATIONS) {
1489
1695
  return pbkdf2Sync(passphrase, salt, iterations, KEY_LENGTH, "sha512");
1490
1696
  }
1697
+ function decodeBundle(encoded) {
1698
+ let bundleJson;
1699
+ try {
1700
+ bundleJson = Buffer.from(encoded, "base64").toString("utf8");
1701
+ } catch {
1702
+ throw new Error("ERR_TELEPORT_CORRUPT: invalid base64 bundle");
1703
+ }
1704
+ try {
1705
+ return JSON.parse(bundleJson);
1706
+ } catch {
1707
+ throw new Error("ERR_TELEPORT_CORRUPT: bundle is not valid JSON");
1708
+ }
1709
+ }
1710
+ function parsePayload(decrypted) {
1711
+ let rawPayload;
1712
+ try {
1713
+ rawPayload = JSON.parse(decrypted.toString("utf8"));
1714
+ } catch {
1715
+ throw new Error("ERR_TELEPORT_CORRUPT: decrypted payload is not valid JSON");
1716
+ }
1717
+ const payload = TeleportPayloadSchema.safeParse(rawPayload);
1718
+ if (!payload.success) {
1719
+ throw new Error(
1720
+ `ERR_TELEPORT_CORRUPT: invalid payload (${payload.error.message})`
1721
+ );
1722
+ }
1723
+ return payload.data;
1724
+ }
1491
1725
  function teleportPack(secrets, passphrase) {
1492
1726
  const payload = {
1493
1727
  secrets,
@@ -1516,18 +1750,7 @@ function teleportPack(secrets, passphrase) {
1516
1750
  return Buffer.from(JSON.stringify(bundle)).toString("base64");
1517
1751
  }
1518
1752
  function teleportUnpack(encoded, passphrase) {
1519
- let bundleJson;
1520
- try {
1521
- bundleJson = Buffer.from(encoded, "base64").toString("utf8");
1522
- } catch {
1523
- throw new Error("ERR_TELEPORT_CORRUPT: invalid base64 bundle");
1524
- }
1525
- let rawBundle;
1526
- try {
1527
- rawBundle = JSON.parse(bundleJson);
1528
- } catch {
1529
- throw new Error("ERR_TELEPORT_CORRUPT: bundle is not valid JSON");
1530
- }
1753
+ const rawBundle = decodeBundle(encoded);
1531
1754
  const parsedBundle = TeleportBundleSchema.safeParse(rawBundle);
1532
1755
  if (!parsedBundle.success) {
1533
1756
  throw new Error(
@@ -1551,19 +1774,301 @@ function teleportUnpack(encoded, passphrase) {
1551
1774
  } catch {
1552
1775
  throw new Error("ERR_TELEPORT_BAD_PASSPHRASE: decryption failed (wrong passphrase or corrupt data)");
1553
1776
  }
1554
- let rawPayload;
1777
+ return parsePayload(decrypted);
1778
+ }
1779
+ var RECIPIENT_PREFIX = "qring1";
1780
+ var RECIPIENT_RE = /^qring1([A-Za-z0-9_-]{43})$/;
1781
+ var X25519_RAW_LENGTH = 32;
1782
+ var V2_AAD = "qring-teleport-v2";
1783
+ var V2_WRAP_INFO = "qring-teleport-v2-wrap";
1784
+ var TELEPORT_KEYRING_SERVICE = "q-ring-teleport";
1785
+ var TELEPORT_KEYRING_ACCOUNT = "identity";
1786
+ var TeleportBundleV2Schema = z.object({
1787
+ v: z.literal(2),
1788
+ createdAt: z.string(),
1789
+ count: z.number(),
1790
+ ephemeral: z.string(),
1791
+ recipients: z.array(
1792
+ z.object({
1793
+ id: z.string(),
1794
+ wrap: z.string(),
1795
+ iv: z.string(),
1796
+ tag: z.string()
1797
+ })
1798
+ ).min(1),
1799
+ iv: z.string(),
1800
+ tag: z.string(),
1801
+ data: z.string()
1802
+ });
1803
+ function rawPublicKey(key) {
1804
+ const jwk = key.export({ format: "jwk" });
1805
+ if (jwk.kty !== "OKP" || jwk.crv !== "X25519" || typeof jwk.x !== "string") {
1806
+ throw new Error("ERR_TELEPORT_BAD_RECIPIENT: not an X25519 public key");
1807
+ }
1808
+ const raw = Buffer.from(jwk.x, "base64url");
1809
+ if (raw.length !== X25519_RAW_LENGTH) {
1810
+ throw new Error("ERR_TELEPORT_BAD_RECIPIENT: not an X25519 public key");
1811
+ }
1812
+ return raw;
1813
+ }
1814
+ function publicKeyFromRaw(raw) {
1815
+ return createPublicKey({
1816
+ key: { kty: "OKP", crv: "X25519", x: raw.toString("base64url") },
1817
+ format: "jwk"
1818
+ });
1819
+ }
1820
+ function formatRecipient(publicKey) {
1821
+ const raw = Buffer.isBuffer(publicKey) ? publicKey : rawPublicKey(publicKey);
1822
+ if (raw.length !== X25519_RAW_LENGTH) {
1823
+ throw new Error("ERR_TELEPORT_BAD_RECIPIENT: public key must be 32 raw bytes");
1824
+ }
1825
+ return `${RECIPIENT_PREFIX}${raw.toString("base64url")}`;
1826
+ }
1827
+ function parseRecipient(str) {
1828
+ const trimmed = typeof str === "string" ? str.trim() : "";
1829
+ const match = RECIPIENT_RE.exec(trimmed);
1830
+ if (!match) {
1831
+ throw new Error(
1832
+ `ERR_TELEPORT_BAD_RECIPIENT: expected "${RECIPIENT_PREFIX}" followed by a base64url X25519 public key (run \`qring teleport identity\` on the recipient's machine to get one)`
1833
+ );
1834
+ }
1835
+ const raw = Buffer.from(match[1], "base64url");
1836
+ if (raw.length !== X25519_RAW_LENGTH) {
1837
+ throw new Error(
1838
+ "ERR_TELEPORT_BAD_RECIPIENT: recipient key does not decode to 32 bytes"
1839
+ );
1840
+ }
1841
+ return raw;
1842
+ }
1843
+ function recipientId(rawPub) {
1844
+ return createHash("sha256").update(rawPub).digest("hex").slice(0, 8);
1845
+ }
1846
+ function deriveWrapKey(shared, ephemeralPub, recipientPub) {
1847
+ return Buffer.from(
1848
+ hkdfSync(
1849
+ "sha256",
1850
+ shared,
1851
+ Buffer.concat([ephemeralPub, recipientPub]),
1852
+ V2_WRAP_INFO,
1853
+ KEY_LENGTH
1854
+ )
1855
+ );
1856
+ }
1857
+ function teleportPackFor(secrets, recipients) {
1858
+ const seen = /* @__PURE__ */ new Map();
1859
+ for (const r of recipients) {
1860
+ const raw = parseRecipient(r);
1861
+ seen.set(recipientId(raw), raw);
1862
+ }
1863
+ if (seen.size === 0) {
1864
+ throw new Error("ERR_TELEPORT_NO_RECIPIENTS: at least one recipient is required");
1865
+ }
1866
+ const payload = {
1867
+ secrets,
1868
+ exportedAt: (/* @__PURE__ */ new Date()).toISOString()
1869
+ };
1870
+ const plaintext = JSON.stringify(payload);
1871
+ const cek = randomBytes2(KEY_LENGTH);
1872
+ const iv = randomBytes2(IV_LENGTH);
1873
+ const cipher = createCipheriv(ALGORITHM, cek, iv);
1874
+ cipher.setAAD(Buffer.from(V2_AAD, "utf8"));
1875
+ const encrypted = Buffer.concat([
1876
+ cipher.update(plaintext, "utf8"),
1877
+ cipher.final()
1878
+ ]);
1879
+ const tag = cipher.getAuthTag();
1880
+ const ephemeral = generateKeyPairSync("x25519");
1881
+ const ephemeralPub = rawPublicKey(ephemeral.publicKey);
1882
+ const wrapped = [];
1883
+ for (const [id, recipientPub] of seen) {
1884
+ const shared = diffieHellman({
1885
+ privateKey: ephemeral.privateKey,
1886
+ publicKey: publicKeyFromRaw(recipientPub)
1887
+ });
1888
+ const wrapKey = deriveWrapKey(shared, ephemeralPub, recipientPub);
1889
+ shared.fill(0);
1890
+ const wrapIv = randomBytes2(IV_LENGTH);
1891
+ const wrapCipher = createCipheriv(ALGORITHM, wrapKey, wrapIv);
1892
+ wrapCipher.setAAD(Buffer.from(id, "utf8"));
1893
+ const wrap2 = Buffer.concat([wrapCipher.update(cek), wrapCipher.final()]);
1894
+ const wrapTag = wrapCipher.getAuthTag();
1895
+ wrapKey.fill(0);
1896
+ wrapped.push({
1897
+ id,
1898
+ wrap: wrap2.toString("base64"),
1899
+ iv: wrapIv.toString("base64"),
1900
+ tag: wrapTag.toString("base64")
1901
+ });
1902
+ }
1903
+ cek.fill(0);
1904
+ const bundle = {
1905
+ v: 2,
1906
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
1907
+ count: secrets.length,
1908
+ ephemeral: ephemeralPub.toString("base64url"),
1909
+ recipients: wrapped,
1910
+ iv: iv.toString("base64"),
1911
+ tag: tag.toString("base64"),
1912
+ data: encrypted.toString("base64")
1913
+ };
1914
+ return Buffer.from(JSON.stringify(bundle)).toString("base64");
1915
+ }
1916
+ function toPrivateKey(identity) {
1917
+ if (typeof identity !== "string") return identity;
1555
1918
  try {
1556
- rawPayload = JSON.parse(decrypted.toString("utf8"));
1919
+ return createPrivateKey(identity);
1557
1920
  } catch {
1558
- throw new Error("ERR_TELEPORT_CORRUPT: decrypted payload is not valid JSON");
1921
+ throw new Error("ERR_TELEPORT_BAD_IDENTITY: private key is not a valid PEM");
1559
1922
  }
1560
- const payload = TeleportPayloadSchema.safeParse(rawPayload);
1561
- if (!payload.success) {
1923
+ }
1924
+ function teleportUnpackWith(encoded, identity) {
1925
+ const privateKey = toPrivateKey(identity);
1926
+ const rawBundle = decodeBundle(encoded);
1927
+ const parsedBundle = TeleportBundleV2Schema.safeParse(rawBundle);
1928
+ if (!parsedBundle.success) {
1562
1929
  throw new Error(
1563
- `ERR_TELEPORT_CORRUPT: invalid payload (${payload.error.message})`
1930
+ `ERR_TELEPORT_CORRUPT: invalid bundle shape (${parsedBundle.error.message})`
1564
1931
  );
1565
1932
  }
1566
- return payload.data;
1933
+ const bundle = parsedBundle.data;
1934
+ const myPub = rawPublicKey(createPublicKey(privateKey));
1935
+ const myId = recipientId(myPub);
1936
+ const mine = bundle.recipients.filter((r) => r.id === myId);
1937
+ if (mine.length === 0) {
1938
+ const ids = bundle.recipients.map((r) => r.id).join(", ");
1939
+ throw new Error(
1940
+ `ERR_TELEPORT_NOT_A_RECIPIENT: bundle is addressed to [${ids}], not to ${myId}`
1941
+ );
1942
+ }
1943
+ const ephemeralPub = Buffer.from(bundle.ephemeral, "base64url");
1944
+ if (ephemeralPub.length !== X25519_RAW_LENGTH) {
1945
+ throw new Error("ERR_TELEPORT_CORRUPT: ephemeral key is not 32 bytes");
1946
+ }
1947
+ let ephemeralKey;
1948
+ try {
1949
+ ephemeralKey = publicKeyFromRaw(ephemeralPub);
1950
+ } catch {
1951
+ throw new Error("ERR_TELEPORT_CORRUPT: ephemeral key is not a valid X25519 point");
1952
+ }
1953
+ const shared = diffieHellman({ privateKey, publicKey: ephemeralKey });
1954
+ const wrapKey = deriveWrapKey(shared, ephemeralPub, myPub);
1955
+ shared.fill(0);
1956
+ let cek = null;
1957
+ for (const entry of mine) {
1958
+ try {
1959
+ const decipher = createDecipheriv(
1960
+ ALGORITHM,
1961
+ wrapKey,
1962
+ Buffer.from(entry.iv, "base64")
1963
+ );
1964
+ decipher.setAAD(Buffer.from(entry.id, "utf8"));
1965
+ decipher.setAuthTag(Buffer.from(entry.tag, "base64"));
1966
+ cek = Buffer.concat([
1967
+ decipher.update(Buffer.from(entry.wrap, "base64")),
1968
+ decipher.final()
1969
+ ]);
1970
+ break;
1971
+ } catch {
1972
+ cek = null;
1973
+ }
1974
+ }
1975
+ wrapKey.fill(0);
1976
+ if (cek === null || cek.length !== KEY_LENGTH) {
1977
+ cek?.fill(0);
1978
+ throw new Error(
1979
+ "ERR_TELEPORT_CORRUPT: could not unwrap content key (tampered bundle or mismatched identity)"
1980
+ );
1981
+ }
1982
+ let decrypted;
1983
+ try {
1984
+ const decipher = createDecipheriv(
1985
+ ALGORITHM,
1986
+ cek,
1987
+ Buffer.from(bundle.iv, "base64")
1988
+ );
1989
+ decipher.setAAD(Buffer.from(V2_AAD, "utf8"));
1990
+ decipher.setAuthTag(Buffer.from(bundle.tag, "base64"));
1991
+ decrypted = Buffer.concat([
1992
+ decipher.update(Buffer.from(bundle.data, "base64")),
1993
+ decipher.final()
1994
+ ]);
1995
+ } catch {
1996
+ throw new Error("ERR_TELEPORT_CORRUPT: payload authentication failed (tampered bundle)");
1997
+ } finally {
1998
+ cek.fill(0);
1999
+ }
2000
+ return parsePayload(decrypted);
2001
+ }
2002
+ function inspectTeleportBundle(encoded) {
2003
+ const raw = decodeBundle(encoded);
2004
+ const v1 = TeleportBundleSchema.safeParse(raw);
2005
+ if (v1.success) return { v: 1, count: v1.data.count };
2006
+ const v2 = TeleportBundleV2Schema.safeParse(raw);
2007
+ if (v2.success) {
2008
+ return {
2009
+ v: 2,
2010
+ count: v2.data.count,
2011
+ recipients: v2.data.recipients.map((r) => r.id)
2012
+ };
2013
+ }
2014
+ const version = raw !== null && typeof raw === "object" && "v" in raw ? String(raw.v) : "unknown";
2015
+ throw new Error(
2016
+ `ERR_TELEPORT_CORRUPT: unsupported or malformed bundle (v=${version})`
2017
+ );
2018
+ }
2019
+ function teleportUnpackAuto(encoded, creds) {
2020
+ const info = inspectTeleportBundle(encoded);
2021
+ if (info.v === 1) {
2022
+ if (creds.passphrase === void 0) {
2023
+ throw new Error(
2024
+ "ERR_TELEPORT_PASSPHRASE_REQUIRED: this is a passphrase (v1) bundle"
2025
+ );
2026
+ }
2027
+ return teleportUnpack(encoded, creds.passphrase);
2028
+ }
2029
+ if (creds.identity === void 0) {
2030
+ throw new Error(
2031
+ "ERR_TELEPORT_NO_IDENTITY: this bundle is addressed to recipient keys \u2014 run `qring teleport keygen` to create yours"
2032
+ );
2033
+ }
2034
+ return teleportUnpackWith(encoded, creds.identity);
2035
+ }
2036
+ function identityFrom(privateKey) {
2037
+ const raw = rawPublicKey(createPublicKey(privateKey));
2038
+ return { privateKey, recipient: formatRecipient(raw), id: recipientId(raw) };
2039
+ }
2040
+ function generateTeleportIdentity(options = {}) {
2041
+ const entry = new Entry(TELEPORT_KEYRING_SERVICE, TELEPORT_KEYRING_ACCOUNT);
2042
+ if (!options.force && entry.getPassword()) {
2043
+ throw new Error(
2044
+ "ERR_TELEPORT_IDENTITY_EXISTS: a teleport identity already exists \u2014 pass --force to replace it (bundles sent to the old key become unreadable)"
2045
+ );
2046
+ }
2047
+ const { privateKey } = generateKeyPairSync("x25519");
2048
+ const der = privateKey.export({ format: "der", type: "pkcs8" });
2049
+ entry.setPassword(der.toString("base64"));
2050
+ der.fill(0);
2051
+ return identityFrom(privateKey);
2052
+ }
2053
+ function loadTeleportIdentity() {
2054
+ const stored = new Entry(
2055
+ TELEPORT_KEYRING_SERVICE,
2056
+ TELEPORT_KEYRING_ACCOUNT
2057
+ ).getPassword();
2058
+ if (!stored) return null;
2059
+ let privateKey;
2060
+ try {
2061
+ privateKey = createPrivateKey({
2062
+ key: Buffer.from(stored, "base64"),
2063
+ format: "der",
2064
+ type: "pkcs8"
2065
+ });
2066
+ } catch {
2067
+ throw new Error(
2068
+ "ERR_TELEPORT_BAD_IDENTITY: stored teleport identity is unreadable \u2014 run `qring teleport keygen --force`"
2069
+ );
2070
+ }
2071
+ return identityFrom(privateKey);
1567
2072
  }
1568
2073
 
1569
2074
  // src/cli/commands/quantum.ts
@@ -1693,15 +2198,71 @@ ${c.dim(`format: ${cmd.format} | entropy: ~${entropy} bits`)}`
1693
2198
  console.log();
1694
2199
  });
1695
2200
  const tp = program2.command("teleport").alias("tp").description("Encrypted secret sharing (quantum teleportation)");
1696
- tp.command("pack").description("Pack secrets into an encrypted bundle").option("-k, --keys <keys>", "Comma-separated key names to pack").option("-g, --global", "Pack global scope").option("-p, --project", "Pack project scope").option("--project-path <path>", "Explicit project path").action(async (cmd) => {
1697
- const opts = buildOpts(cmd);
1698
- const passphrase = await promptSecret(
1699
- `${SYMBOLS.lock} Enter passphrase for encryption: `
1700
- );
1701
- if (!passphrase) {
1702
- console.error(c.red("Passphrase required"));
2201
+ tp.command("keygen").description(
2202
+ "Create your teleport identity (X25519 keypair, private key in the OS keyring)"
2203
+ ).option("--force", "Replace an existing identity").action((cmd) => {
2204
+ let identity;
2205
+ try {
2206
+ identity = generateTeleportIdentity({ force: Boolean(cmd.force) });
2207
+ } catch (err) {
2208
+ const msg = err instanceof Error ? err.message : String(err);
2209
+ console.error(c.red(`${SYMBOLS.cross} ${msg}`));
1703
2210
  process.exit(1);
1704
2211
  }
2212
+ console.log(identity.recipient);
2213
+ if (process.stdout.isTTY) {
2214
+ console.log(
2215
+ `${SYMBOLS.key} ${c.green("identity created")} ${c.dim(`(id ${identity.id})`)} \u2014 share the line above with anyone who should \`teleport pack --to\` you`
2216
+ );
2217
+ }
2218
+ });
2219
+ tp.command("identity").description("Print your public teleport recipient string").action(() => {
2220
+ let identity;
2221
+ try {
2222
+ identity = loadTeleportIdentity();
2223
+ } catch (err) {
2224
+ const msg = err instanceof Error ? err.message : String(err);
2225
+ console.error(c.red(`${SYMBOLS.cross} ${msg}`));
2226
+ process.exit(1);
2227
+ }
2228
+ if (!identity) {
2229
+ console.error(
2230
+ c.red(
2231
+ `${SYMBOLS.cross} No teleport identity found \u2014 run ${c.bold("qring teleport keygen")} first`
2232
+ )
2233
+ );
2234
+ process.exit(1);
2235
+ }
2236
+ console.log(identity.recipient);
2237
+ if (process.stdout.isTTY) {
2238
+ console.log(c.dim(`recipient id: ${identity.id}`));
2239
+ }
2240
+ });
2241
+ tp.command("pack").description("Pack secrets into an encrypted bundle").option("-k, --keys <keys>", "Comma-separated key names to pack").option(
2242
+ "--to <recipient...>",
2243
+ "Recipient(s) from `qring teleport identity` (repeatable or comma-separated); skips the passphrase"
2244
+ ).option("-g, --global", "Pack global scope").option("-p, --project", "Pack project scope").option("--project-path <path>", "Explicit project path").action(async (cmd) => {
2245
+ const opts = buildOpts(cmd);
2246
+ const recipients = (cmd.to ?? []).flatMap((r) => r.split(",")).map((r) => r.trim()).filter((r) => r.length > 0);
2247
+ for (const r of recipients) {
2248
+ try {
2249
+ parseRecipient(r);
2250
+ } catch (err) {
2251
+ const msg = err instanceof Error ? err.message : String(err);
2252
+ console.error(c.red(`${SYMBOLS.cross} Bad recipient "${r}": ${msg}`));
2253
+ process.exit(1);
2254
+ }
2255
+ }
2256
+ let passphrase;
2257
+ if (recipients.length === 0) {
2258
+ passphrase = await promptSecret(
2259
+ `${SYMBOLS.lock} Enter passphrase for encryption: `
2260
+ );
2261
+ if (!passphrase) {
2262
+ console.error(c.red("Passphrase required (or pass --to <recipient>)"));
2263
+ process.exit(1);
2264
+ }
2265
+ }
1705
2266
  const entries = listSecrets(opts);
1706
2267
  let keys;
1707
2268
  if (cmd.keys) {
@@ -1719,12 +2280,13 @@ ${c.dim(`format: ${cmd.format} | entropy: ~${entropy} bits`)}`
1719
2280
  console.error(c.red("No secrets to pack"));
1720
2281
  process.exit(1);
1721
2282
  }
1722
- const bundle = teleportPack(secrets, passphrase);
2283
+ const bundle = recipients.length > 0 ? teleportPackFor(secrets, recipients) : teleportPack(secrets, passphrase);
1723
2284
  process.stdout.write(bundle);
1724
2285
  if (process.stdout.isTTY) {
2286
+ const to = recipients.length > 0 ? c.dim(` for ${recipients.length} recipient(s)`) : "";
1725
2287
  console.log(
1726
2288
  `
1727
- ${SYMBOLS.package} ${c.green("packed")} ${secrets.length} secret(s)`
2289
+ ${SYMBOLS.package} ${c.green("packed")} ${secrets.length} secret(s)${to}`
1728
2290
  );
1729
2291
  }
1730
2292
  });
@@ -1736,20 +2298,73 @@ ${SYMBOLS.package} ${c.green("packed")} ${secrets.length} secret(s)`
1736
2298
  }
1737
2299
  bundle = Buffer.concat(chunks).toString("utf8").trim();
1738
2300
  }
1739
- const passphrase = await promptSecret(
1740
- `${SYMBOLS.lock} Enter passphrase for decryption: `
1741
- );
1742
- let payload;
2301
+ let info;
1743
2302
  try {
1744
- payload = teleportUnpack(bundle, passphrase);
2303
+ info = inspectTeleportBundle(bundle);
1745
2304
  } catch {
1746
2305
  console.error(
1747
- c.red(
1748
- `${SYMBOLS.cross} Failed to unpack: wrong passphrase or corrupted bundle`
1749
- )
2306
+ c.red(`${SYMBOLS.cross} Failed to unpack: corrupted bundle`)
1750
2307
  );
1751
2308
  process.exit(1);
1752
2309
  }
2310
+ let passphrase;
2311
+ let identity = null;
2312
+ if (info.v === 1) {
2313
+ passphrase = await promptSecret(
2314
+ `${SYMBOLS.lock} Enter passphrase for decryption: `
2315
+ );
2316
+ } else {
2317
+ try {
2318
+ identity = loadTeleportIdentity();
2319
+ } catch (err) {
2320
+ const msg = err instanceof Error ? err.message : String(err);
2321
+ console.error(c.red(`${SYMBOLS.cross} ${msg}`));
2322
+ process.exit(1);
2323
+ }
2324
+ if (cmd.dryRun) {
2325
+ const mine = identity ? info.recipients.includes(identity.id) : false;
2326
+ console.log(
2327
+ `
2328
+ ${SYMBOLS.package} ${c.bold("Recipient bundle")} addressed to ${info.recipients.length} recipient id(s):`
2329
+ );
2330
+ for (const id of info.recipients) {
2331
+ const marker = identity && id === identity.id ? c.green(" (you)") : "";
2332
+ console.log(` ${SYMBOLS.key} ${id}${marker}`);
2333
+ }
2334
+ if (!identity) {
2335
+ console.log(
2336
+ c.dim(
2337
+ ` no local identity \u2014 run ${c.bold("qring teleport keygen")} to create one`
2338
+ )
2339
+ );
2340
+ } else if (!mine) {
2341
+ console.log(
2342
+ c.dim(` your id ${identity.id} is not among them; unpack will fail`)
2343
+ );
2344
+ }
2345
+ console.log();
2346
+ }
2347
+ if (!identity) {
2348
+ console.error(
2349
+ c.red(
2350
+ `${SYMBOLS.cross} This bundle is addressed to recipient keys and you have no teleport identity \u2014 run ${c.bold("qring teleport keygen")} first`
2351
+ )
2352
+ );
2353
+ process.exit(1);
2354
+ }
2355
+ }
2356
+ let payload;
2357
+ try {
2358
+ payload = teleportUnpackAuto(bundle, {
2359
+ passphrase,
2360
+ identity: identity?.privateKey
2361
+ });
2362
+ } catch (err) {
2363
+ const msg = err instanceof Error ? err.message : String(err);
2364
+ const reason = msg.startsWith("ERR_TELEPORT_NOT_A_RECIPIENT") ? `this bundle is not addressed to your identity (${identity?.id})` : info.v === 1 ? "wrong passphrase or corrupted bundle" : "corrupted bundle or mismatched identity";
2365
+ console.error(c.red(`${SYMBOLS.cross} Failed to unpack: ${reason}`));
2366
+ process.exit(1);
2367
+ }
1753
2368
  if (cmd.dryRun) {
1754
2369
  console.log(
1755
2370
  `
@@ -1937,6 +2552,51 @@ function registerValidationCommands(program2) {
1937
2552
  console.log(c.yellow(`${SYMBOLS.warning} ${result.message}`));
1938
2553
  }
1939
2554
  });
2555
+ program2.command("rotate:due").description(
2556
+ "List secrets whose rotation reminder is due soon or overdue (--all: every scheduled secret)"
2557
+ ).option("-g, --global", "Global scope only").option("-p, --project", "Project scope only").option("--team <id>", "Team scope only").option("--org <id>", "Org scope only").option("--project-path <path>", "Explicit project path").option("--all", "Include scheduled secrets that are not yet due").option("--json", "Output as JSON").action((cmd) => {
2558
+ const opts = buildOpts(cmd);
2559
+ const entries = listSecrets(opts);
2560
+ const scheduled = entries.filter((e) => e.envelope).map((e) => ({
2561
+ key: safeStr(e.key),
2562
+ scope: safeStr(e.scope),
2563
+ rotation: rotationStatus(e.envelope.meta)
2564
+ })).filter((e) => e.rotation.state !== "unscheduled").sort((a, b) => compareRotationUrgency(a.rotation, b.rotation));
2565
+ const due = scheduled.filter(
2566
+ (e) => e.rotation.state === "due-soon" || e.rotation.state === "overdue"
2567
+ );
2568
+ const shown = cmd.all ? scheduled : due;
2569
+ if (emitJson(program2, cmd, {
2570
+ entries: shown,
2571
+ dueCount: due.length,
2572
+ scheduledCount: scheduled.length
2573
+ })) {
2574
+ return;
2575
+ }
2576
+ if (shown.length === 0) {
2577
+ console.log(
2578
+ c.dim(
2579
+ scheduled.length === 0 ? "No secrets have a rotation reminder (set one with: qring set KEY --rotate-every 90)" : `Nothing due \u2014 ${scheduled.length} scheduled secret(s) are on track`
2580
+ )
2581
+ );
2582
+ return;
2583
+ }
2584
+ console.log(
2585
+ c.bold(
2586
+ `
2587
+ ${SYMBOLS.shield} Rotation reminders (${due.length} due of ${scheduled.length} scheduled)
2588
+ `
2589
+ )
2590
+ );
2591
+ const maxKeyLen = Math.max(...shown.map((e) => e.key.length));
2592
+ for (const e of shown) {
2593
+ const r = e.rotation;
2594
+ console.log(
2595
+ ` ${c.dim("[")}${scopeColor(e.scope)}${c.dim("]")} ${c.bold(e.key.padEnd(maxKeyLen))} ${rotationBadge(r.state)} ${describeRotation(r)} ${c.dim(`(last rotated ${r.ageDays}d ago, every ${r.rotateEveryDays}d)`)}`
2596
+ );
2597
+ }
2598
+ console.log();
2599
+ });
1940
2600
  program2.command("ci:validate").description(
1941
2601
  "CI-oriented batch validation of all secrets (exit code 1 on failure)"
1942
2602
  ).option("-g, --global", "Global scope").option("-p, --project", "Project scope").option("--project-path <path>", "Explicit project path").option("--json", "Output as JSON").action(async (cmd) => {
@@ -2611,7 +3271,7 @@ function registerToolingCommands(program2) {
2611
3271
  }
2612
3272
  });
2613
3273
  program2.command("status").description("Launch the quantum status dashboard in your browser").option("--port <port>", "Port to serve on", "9876").option("--no-open", "Don't auto-open the browser").action(async (cmd) => {
2614
- const { startDashboardServer } = await import("./dashboard-ZN3FPR73.js");
3274
+ const { startDashboardServer } = await import("./dashboard-LICF6OZ5.js");
2615
3275
  const { exec } = await import("child_process");
2616
3276
  const { platform } = await import("os");
2617
3277
  const port = Number(cmd.port);
@@ -3865,7 +4525,29 @@ function setupEditor(opts) {
3865
4525
 
3866
4526
  // src/core/push.ts
3867
4527
  import { spawnSync } from "child_process";
3868
- var PUSH_TARGETS = ["github", "vercel", "cloudflare"];
4528
+ import { mkdtempSync, rmSync, writeFileSync as writeFileSync6 } from "fs";
4529
+ import { tmpdir } from "os";
4530
+ import { join as join5 } from "path";
4531
+ var PUSH_TARGETS = [
4532
+ "github",
4533
+ "vercel",
4534
+ "cloudflare",
4535
+ "fly",
4536
+ "railway",
4537
+ "netlify"
4538
+ ];
4539
+ function flyImportLine(key, value) {
4540
+ return `${key}="""${value}"""
4541
+ `;
4542
+ }
4543
+ function dotenvLine(key, value) {
4544
+ if (!/['\r\n]/.test(value)) return `${key}='${value}'
4545
+ `;
4546
+ if (!/["\r\n]/.test(value) && !/\\[nr]/.test(value)) return `${key}="${value}"
4547
+ `;
4548
+ return `${key}=${value}
4549
+ `;
4550
+ }
3869
4551
  var TARGETS = {
3870
4552
  github: {
3871
4553
  binary: "gh",
@@ -3882,12 +4564,62 @@ var TARGETS = {
3882
4564
  binary: "wrangler",
3883
4565
  args: (key) => [["secret", "put", key]],
3884
4566
  installHint: "install Wrangler: npm i -g wrangler (then `wrangler login`)"
4567
+ },
4568
+ fly: {
4569
+ binary: "flyctl",
4570
+ // `flyctl secrets set KEY=VALUE` is argv-only; `secrets import` reads
4571
+ // NAME=VALUE lines from stdin instead, so the key travels there too.
4572
+ args: (_key, opts) => [["secrets", "import", ...opts.app ? ["--app", opts.app] : []]],
4573
+ stdin: flyImportLine,
4574
+ installHint: "install flyctl: https://fly.io/docs/flyctl/install/ (then `flyctl auth login`)"
4575
+ },
4576
+ railway: {
4577
+ binary: "railway",
4578
+ // Railway CLI >= 4 reads the value from stdin with `variable set KEY --stdin`
4579
+ // (trailing newline trimmed); `--set KEY=VALUE` is the argv-only legacy form.
4580
+ args: (key, opts) => [
4581
+ [
4582
+ "variable",
4583
+ "set",
4584
+ key,
4585
+ "--stdin",
4586
+ ...opts.service ? ["--service", opts.service] : [],
4587
+ ...opts.railwayEnv ? ["--environment", opts.railwayEnv] : []
4588
+ ]
4589
+ ],
4590
+ installHint: "install the Railway CLI: https://docs.railway.com/guides/cli (then `railway login`)"
4591
+ },
4592
+ netlify: {
4593
+ binary: "netlify",
4594
+ // `netlify env:set KEY VALUE` is argv-only and the CLI reads nothing from
4595
+ // stdin, so the value goes through a 0600 dotenv temp file consumed by
4596
+ // `env:import <file>` (which merges into the site's existing variables,
4597
+ // in every deploy context — env:import has no --context flag).
4598
+ args: (_key, opts, secretFile) => [
4599
+ ["env:import", secretFile ?? "", ...opts.site ? ["--site", opts.site] : []]
4600
+ ],
4601
+ file: dotenvLine,
4602
+ installHint: "install the Netlify CLI: https://docs.netlify.com/cli/get-started/ (then `netlify login` and `netlify link`)"
3885
4603
  }
3886
4604
  };
3887
4605
  function binaryAvailable(binary) {
3888
4606
  const probe = spawnSync(binary, ["--version"], { stdio: "ignore", shell: false });
3889
4607
  return !probe.error;
3890
4608
  }
4609
+ function withSecretFile(content, fn) {
4610
+ const dir = mkdtempSync(join5(tmpdir(), "qring-push-"));
4611
+ try {
4612
+ const file = join5(dir, "secret.env");
4613
+ writeFileSync6(file, content, { mode: 384, flag: "wx" });
4614
+ return fn(file);
4615
+ } finally {
4616
+ rmSync(dir, { recursive: true, force: true });
4617
+ }
4618
+ }
4619
+ function destination(opts) {
4620
+ const where = opts.repo ?? opts.app ?? opts.site ?? opts.service;
4621
+ return where ? ` (${where})` : "";
4622
+ }
3891
4623
  function resolvePushKeys(opts) {
3892
4624
  if (opts.keys?.length) return opts.keys;
3893
4625
  const config = readProjectConfig(opts.projectPath);
@@ -3925,19 +4657,22 @@ function pushSecrets(opts) {
3925
4657
  result.pushed.push(key);
3926
4658
  continue;
3927
4659
  }
3928
- let failedInvocation = null;
3929
- for (const args of target.args(key, opts)) {
3930
- const child = spawnSync(target.binary, args, {
3931
- input: value,
3932
- cwd: opts.projectPath,
3933
- encoding: "utf8",
3934
- shell: false
3935
- });
3936
- if (child.status !== 0) {
3937
- failedInvocation = child.stderr?.trim() || child.error?.message || `exit ${child.status}`;
3938
- break;
4660
+ const input = target.file ? "" : target.stdin?.(key, value) ?? value;
4661
+ const invoke = (secretFile) => {
4662
+ for (const args of target.args(key, opts, secretFile)) {
4663
+ const child = spawnSync(target.binary, args, {
4664
+ input,
4665
+ cwd: opts.projectPath,
4666
+ encoding: "utf8",
4667
+ shell: false
4668
+ });
4669
+ if (child.status !== 0) {
4670
+ return child.stderr?.trim() || child.error?.message || `exit ${child.status}`;
4671
+ }
3939
4672
  }
3940
- }
4673
+ return null;
4674
+ };
4675
+ const failedInvocation = target.file ? withSecretFile(target.file(key, value), invoke) : invoke();
3941
4676
  if (failedInvocation) {
3942
4677
  result.failed.push({ key, error: failedInvocation });
3943
4678
  continue;
@@ -3949,7 +4684,7 @@ function pushSecrets(opts) {
3949
4684
  key,
3950
4685
  env: opts.env,
3951
4686
  source: opts.source ?? "cli",
3952
- detail: `${opts.canaryKeys?.includes(key) ? "canary honeytoken " : ""}pushed to ${opts.target}${opts.repo ? ` (${opts.repo})` : ""}`
4687
+ detail: `${opts.canaryKeys?.includes(key) ? "canary honeytoken " : ""}pushed to ${opts.target}${destination(opts)}`
3953
4688
  });
3954
4689
  }
3955
4690
  }
@@ -4013,8 +4748,11 @@ ${SYMBOLS.zap} ${c.bold("qring run")} ${c.dim("(dry run)")}`);
4013
4748
  `Push manifest secrets to a deployment platform via its own CLI (${PUSH_TARGETS.join(", ")}) \u2014 values travel over stdin, never argv`
4014
4749
  ).option("-k, --keys <keys>", "Comma-separated keys (default: .q-ring.json manifest)").option("--project-path <path>", "Explicit project path").option("-e, --env <env>", "Environment context for superposition collapse").option("--repo <owner/name>", "GitHub repository (github target)").option(
4015
4750
  "--vercel-env <envs>",
4016
- "Comma-separated Vercel environments (default: production)"
4017
- ).option("--dry-run", "Show what would be pushed without pushing").option("--json", "Output as JSON").action((targetArg, cmd) => {
4751
+ "Comma-separated Vercel environments (vercel target; default: production)"
4752
+ ).option("--app <name>", "fly.io app (fly target; default: the app in fly.toml)").option("--service <name>", "Railway service name or ID (railway target; default: linked service)").option(
4753
+ "--railway-env <name>",
4754
+ "Railway environment name or ID (railway target; default: linked environment)"
4755
+ ).option("--site <id>", "Netlify site name or ID (netlify target; default: linked site)").option("--dry-run", "Show what would be pushed without pushing").option("--json", "Output as JSON").action((targetArg, cmd) => {
4018
4756
  if (!PUSH_TARGETS.includes(targetArg)) {
4019
4757
  console.error(
4020
4758
  c.red(`${SYMBOLS.cross} Unknown target "${targetArg}" \u2014 expected one of: ${PUSH_TARGETS.join(", ")}`)
@@ -4029,6 +4767,10 @@ ${SYMBOLS.zap} ${c.bold("qring run")} ${c.dim("(dry run)")}`);
4029
4767
  env: cmd.env,
4030
4768
  repo: cmd.repo,
4031
4769
  vercelEnvs: cmd.vercelEnv?.split(",").map((e) => e.trim()),
4770
+ app: cmd.app,
4771
+ service: cmd.service,
4772
+ railwayEnv: cmd.railwayEnv,
4773
+ site: cmd.site,
4032
4774
  dryRun: cmd.dryRun === true,
4033
4775
  source: "cli"
4034
4776
  });
@@ -4933,11 +5675,10 @@ function registerMcpCommands(program2) {
4933
5675
  }
4934
5676
 
4935
5677
  // src/cli/commands/doctor.ts
4936
- import { existsSync as existsSync6, readFileSync as readFileSync8, writeFileSync as writeFileSync6, rmSync } from "fs";
4937
- import { join as join5, delimiter } from "path";
4938
- import { homedir as homedir2 } from "os";
5678
+ import { existsSync as existsSync6, readFileSync as readFileSync8, writeFileSync as writeFileSync7, rmSync as rmSync2 } from "fs";
5679
+ import { join as join6, delimiter } from "path";
4939
5680
  function auditDir() {
4940
- return process.env.QRING_AUDIT_DIR ?? join5(homedir2(), ".config", "q-ring");
5681
+ return process.env.QRING_AUDIT_DIR ?? configDir();
4941
5682
  }
4942
5683
  function checkNode() {
4943
5684
  const major = Number(process.versions.node.split(".")[0]);
@@ -4949,8 +5690,8 @@ function checkNode() {
4949
5690
  }
4950
5691
  async function checkKeyringBackend() {
4951
5692
  try {
4952
- const { Entry } = await import("@napi-rs/keyring");
4953
- const probe = new Entry("q-ring-doctor-probe", "PROBE");
5693
+ const { Entry: Entry2 } = await import("@napi-rs/keyring");
5694
+ const probe = new Entry2("q-ring-doctor-probe", "PROBE");
4954
5695
  probe.setPassword("ok");
4955
5696
  const read = probe.getPassword();
4956
5697
  probe.deletePassword();
@@ -4977,9 +5718,9 @@ async function checkKeyringBackend() {
4977
5718
  function checkAuditLog() {
4978
5719
  const dir = auditDir();
4979
5720
  try {
4980
- const probeFile = join5(dir, ".doctor-probe");
4981
- writeFileSync6(probeFile, "ok");
4982
- rmSync(probeFile);
5721
+ const probeFile = join6(dir, ".doctor-probe");
5722
+ writeFileSync7(probeFile, "ok");
5723
+ rmSync2(probeFile);
4983
5724
  } catch (err) {
4984
5725
  return {
4985
5726
  name: "audit log",
@@ -5005,7 +5746,7 @@ function checkAuditLog() {
5005
5746
  };
5006
5747
  }
5007
5748
  function checkManifest(projectPath) {
5008
- const manifestPath = join5(projectPath, ".q-ring.json");
5749
+ const manifestPath = join6(projectPath, ".q-ring.json");
5009
5750
  if (!existsSync6(manifestPath)) {
5010
5751
  return {
5011
5752
  name: "project manifest",
@@ -5063,11 +5804,11 @@ function checkMcpBinary() {
5063
5804
  for (const dir of (process.env.PATH ?? "").split(delimiter)) {
5064
5805
  if (!dir) continue;
5065
5806
  for (const ext of exts) {
5066
- if (existsSync6(join5(dir, `qring-mcp${ext}`))) {
5807
+ if (existsSync6(join6(dir, `qring-mcp${ext}`))) {
5067
5808
  return {
5068
5809
  name: "qring-mcp binary",
5069
5810
  status: "ok",
5070
- detail: `found at ${join5(dir, `qring-mcp${ext}`)}`
5811
+ detail: `found at ${join6(dir, `qring-mcp${ext}`)}`
5071
5812
  };
5072
5813
  }
5073
5814
  }
@@ -5251,7 +5992,7 @@ var COMMAND_GROUPS = [
5251
5992
  {
5252
5993
  name: "Project",
5253
5994
  symbol: SYMBOLS.package,
5254
- commands: ["context", "check", "env", "env:generate", "wizard"]
5995
+ commands: ["context", "check", "env", "env:generate", "promote", "diff", "wizard"]
5255
5996
  },
5256
5997
  {
5257
5998
  name: "Quantum",
@@ -5267,7 +6008,7 @@ var COMMAND_GROUPS = [
5267
6008
  {
5268
6009
  name: "Validation & Rotation",
5269
6010
  symbol: SYMBOLS.shield,
5270
- commands: ["validate", "rotate", "ci:validate"]
6011
+ commands: ["validate", "rotate", "rotate:due", "ci:validate"]
5271
6012
  },
5272
6013
  {
5273
6014
  name: "Dev Tooling",
@@ -5412,6 +6153,7 @@ function createProgram() {
5412
6153
  program2.createHelp = () => new GroupedHelp();
5413
6154
  registerSecretsCommands(program2);
5414
6155
  registerProjectCommands(program2);
6156
+ registerEnvironmentCommands(program2);
5415
6157
  registerQuantumCommands(program2);
5416
6158
  registerValidationCommands(program2);
5417
6159
  registerToolingCommands(program2);