@coffre/cli 0.1.14 → 0.1.15

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
@@ -2,6 +2,9 @@
2
2
 
3
3
  The `coffre` command: secrets in your shell, and a new deployment.
4
4
 
5
+ The published CLI runs on Node 20 or newer. Developing coffre from its
6
+ TypeScript sources and running a deployment require Node 24.
7
+
5
8
  ```sh
6
9
  npx @coffre/cli init --workers my-coffre # or --node
7
10
  npx @coffre/cli setup # its database, keys and, on Workers, Cloudflare
@@ -1,4 +1,4 @@
1
- import { r as isEmpty } from "./init-mO8V087p.js";
1
+ import { r as isEmpty } from "./init-2xGelVG1.js";
2
2
  import { spawn } from "node:child_process";
3
3
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
4
4
  import { join } from "node:path";
@@ -1285,7 +1285,10 @@ function install(dir) {
1285
1285
  return (async () => {
1286
1286
  const ran = await attempt("pnpm", ["install"]) ?? await attempt("corepack", ["pnpm", "install"]);
1287
1287
  if (ran === null) throw new Error("pnpm is not installed: corepack enable, or npm install -g pnpm, then run setup again");
1288
- if (ran.code !== 0) throw new Error(installFailure(ran.output));
1288
+ if (ran.code !== 0) {
1289
+ const held = heldBack(ran.output);
1290
+ throw held.length > 0 ? new HeldBack(held, installFailure(ran.output)) : new Error(installFailure(ran.output));
1291
+ }
1289
1292
  })();
1290
1293
  }
1291
1294
  /** A deployment's `@coffre/*` pins, as its package.json has them. */
@@ -1306,6 +1309,90 @@ function bumpPins(dir, version) {
1306
1309
  for (const field of ["dependencies", "devDependencies"]) for (const name of Object.keys(manifest[field] ?? {})) if (name.startsWith("@coffre/")) manifest[field][name] = version;
1307
1310
  writeFileSync(path, `${JSON.stringify(manifest, null, 2)}\n`);
1308
1311
  }
1312
+ /** pnpm install refused packages younger than the deployment's minimumReleaseAge. */
1313
+ var HeldBack = class extends Error {
1314
+ held;
1315
+ constructor(held, message) {
1316
+ super(message);
1317
+ this.held = held;
1318
+ }
1319
+ };
1320
+ /** The packages pnpm held back, as its error names them. */
1321
+ function heldBack(output) {
1322
+ if (!output.includes("MINIMUM_RELEASE_AGE")) return [];
1323
+ return [...output.matchAll(/^\s*(\S+@\d\S*) was published at (\S+?),?\s/gm)].map((match) => ({
1324
+ spec: match[1],
1325
+ publishedAt: new Date(match[2])
1326
+ }));
1327
+ }
1328
+ /** The deployment's minimumReleaseAge, in minutes, as its pnpm-workspace.yaml sets it; pnpm's own default, none, without one. */
1329
+ function minimumReleaseAge(dir) {
1330
+ const path = join(dir, "pnpm-workspace.yaml");
1331
+ const match = existsSync(path) ? /^minimumReleaseAge:\s*(\d+)/m.exec(readFileSync(path, "utf8")) : null;
1332
+ return match === null ? 0 : Number(match[1]);
1333
+ }
1334
+ /** How every temporary exclusion `coffre update` writes ends: with when it lapses. */
1335
+ const EXCLUDED = /^\s*- '([^']+)' # coffre update: until (\S+)/;
1336
+ /**
1337
+ * Exclude `held` from minimumReleaseAge, each until it clears, named in the
1338
+ * deployment's pnpm-workspace.yaml with that date: never silently, and
1339
+ * removed by `removeCleared` once the date has passed.
1340
+ */
1341
+ function excludeUntil(dir, held, reason) {
1342
+ const path = join(dir, "pnpm-workspace.yaml");
1343
+ const lines = (existsSync(path) ? readFileSync(path, "utf8") : "").replace(/\n*$/, "").split("\n");
1344
+ const entries = held.map(({ spec, clears }) => ` - '${spec}' # coffre update: until ${clears.toISOString()}, ${reason}`);
1345
+ const key = lines.findIndex((line) => /^minimumReleaseAgeExclude:/.test(line));
1346
+ if (key === -1) lines.push("minimumReleaseAgeExclude:", ...entries);
1347
+ else {
1348
+ let end = key + 1;
1349
+ while (end < lines.length && /^\s+(-|#)/.test(lines[end])) end++;
1350
+ lines.splice(end, 0, ...entries);
1351
+ }
1352
+ writeFileSync(path, `${lines.join("\n")}\n`);
1353
+ }
1354
+ /** Remove the exclusions `excludeUntil` wrote whose date has passed; the packages they named. */
1355
+ function removeCleared(dir, now) {
1356
+ const path = join(dir, "pnpm-workspace.yaml");
1357
+ if (!existsSync(path)) return [];
1358
+ const removed = [];
1359
+ const kept = readFileSync(path, "utf8").split("\n").filter((line) => {
1360
+ const match = EXCLUDED.exec(line);
1361
+ if (match === null || new Date(match[2].replace(/,$/, "")) > now) return true;
1362
+ removed.push(match[1]);
1363
+ return false;
1364
+ });
1365
+ if (removed.length > 0) writeFileSync(path, kept.join("\n"));
1366
+ return removed;
1367
+ }
1368
+ /**
1369
+ * The pnpm a deployment runs, `packageManager` in its package.json, set to
1370
+ * `wanted`, coffre's: then a laptop, CI and Workers Builds all install with
1371
+ * one pnpm, which holds minimumReleaseAge alike. The value it had, when it
1372
+ * changed; null when it was right already.
1373
+ */
1374
+ function pinPackageManager(dir, wanted) {
1375
+ const path = join(dir, "package.json");
1376
+ const manifest = JSON.parse(readFileSync(path, "utf8"));
1377
+ if (manifest.packageManager === wanted) return null;
1378
+ const was = manifest.packageManager;
1379
+ let next;
1380
+ if (was !== void 0) next = {
1381
+ ...manifest,
1382
+ packageManager: wanted
1383
+ };
1384
+ else {
1385
+ const after = "private" in manifest ? "private" : "name";
1386
+ next = {};
1387
+ for (const [field, value] of Object.entries(manifest)) {
1388
+ next[field] = value;
1389
+ if (field === after) next.packageManager = wanted;
1390
+ }
1391
+ next.packageManager ??= wanted;
1392
+ }
1393
+ writeFileSync(path, `${JSON.stringify(next, null, 2)}\n`);
1394
+ return was;
1395
+ }
1309
1396
  /**
1310
1397
  * Why pnpm install failed, in a sentence. The deployment's minimumReleaseAge
1311
1398
  * holds back every package published within the week, coffre's own
@@ -1366,4 +1453,4 @@ function editWorker(dir, path, changes) {
1366
1453
  return changed;
1367
1454
  }
1368
1455
  //#endregion
1369
- export { install as a, editWorker as i, coffrePins as n, placeholder as o, deploymentKind as r, readWorker as s, bumpPins as t };
1456
+ export { editWorker as a, minimumReleaseAge as c, readWorker as d, removeCleared as f, deploymentKind as i, pinPackageManager as l, bumpPins as n, excludeUntil as o, coffrePins as r, install as s, HeldBack as t, placeholder as u };
@@ -68,4 +68,4 @@ function init(kind, target, version) {
68
68
  return written;
69
69
  }
70
70
  //#endregion
71
- export { init as n, isEmpty as r, KINDS as t };
71
+ export { templateDir as i, init as n, isEmpty as r, KINDS as t };
package/dist/main.js CHANGED
@@ -1,14 +1,14 @@
1
1
  #!/usr/bin/env node
2
- import { n as init, t as KINDS } from "./init-mO8V087p.js";
2
+ import { n as init, t as KINDS } from "./init-2xGelVG1.js";
3
3
  import { a as keys } from "./keys-DDdMwm0N.js";
4
4
  import { a as openTerminal, c as release, d as style, r as hiddenLine, t as Cancelled, u as select } from "./tty-BDa14JX2.js";
5
5
  import { i as isRole, r as assignableToEnvironment, t as ROLES } from "./access-CO1kGjWW.js";
6
6
  import { inspect, parseArgs, promisify } from "node:util";
7
7
  import { execFile, spawn } from "node:child_process";
8
- import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
9
- import { homedir, hostname } from "node:os";
8
+ import { appendFileSync, chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
9
+ import { EOL, homedir, hostname } from "node:os";
10
10
  import { dirname, join, resolve } from "node:path";
11
- import { createCipheriv, createDecipheriv, createHash, hkdfSync, randomBytes, timingSafeEqual } from "node:crypto";
11
+ import { createCipheriv, createDecipheriv, createHash, hkdfSync, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
12
12
  /**
13
13
  * The line to show, or null: nothing pending, or nothing told. An instance
14
14
  * older than this notice has no `instance` on `/me` at all.
@@ -40,6 +40,231 @@ async function dailyNotice(origin, now, checked, me) {
40
40
  return noticeFor(origin, instance);
41
41
  }
42
42
  //#endregion
43
+ //#region ../core/dist/dotenv.js
44
+ const KEY_RE = /^[A-Za-z_][A-Za-z0-9_]{0,127}$/;
45
+ function parseDotenv(input) {
46
+ const entries = [];
47
+ const problems = [];
48
+ const seen = /* @__PURE__ */ new Set();
49
+ const lines = input.split(/\r\n|\n|\r/);
50
+ for (let index = 0; index < lines.length; index++) {
51
+ const lineNumber = index + 1;
52
+ const trimmed = lines[index].trim();
53
+ if (trimmed === "" || trimmed.startsWith("#")) continue;
54
+ const withoutExport = trimmed.startsWith("export ") ? trimmed.slice(7).trim() : trimmed;
55
+ const equals = withoutExport.indexOf("=");
56
+ if (equals === -1) {
57
+ problems.push({
58
+ line: lineNumber,
59
+ text: trimmed,
60
+ reason: "no \"=\" on this line"
61
+ });
62
+ continue;
63
+ }
64
+ const key = withoutExport.slice(0, equals).trim();
65
+ if (!KEY_RE.test(key)) {
66
+ problems.push({
67
+ line: lineNumber,
68
+ text: key,
69
+ reason: "key must match ^[A-Za-z_][A-Za-z0-9_]*$"
70
+ });
71
+ continue;
72
+ }
73
+ let rest = withoutExport.slice(equals + 1).trim();
74
+ let value;
75
+ if (rest.startsWith("\"")) {
76
+ const closing = findClosingQuote(rest, "\"");
77
+ if (closing === -1) {
78
+ problems.push({
79
+ line: lineNumber,
80
+ text: key,
81
+ reason: "unterminated double quote (multi-line values are not supported)"
82
+ });
83
+ continue;
84
+ }
85
+ value = unescapeDoubleQuoted(rest.slice(1, closing));
86
+ if (trailingIsSignificant(rest.slice(closing + 1))) {
87
+ problems.push({
88
+ line: lineNumber,
89
+ text: key,
90
+ reason: "unexpected text after the closing quote"
91
+ });
92
+ continue;
93
+ }
94
+ } else if (rest.startsWith("'")) {
95
+ const closing = rest.indexOf("'", 1);
96
+ if (closing === -1) {
97
+ problems.push({
98
+ line: lineNumber,
99
+ text: key,
100
+ reason: "unterminated single quote (multi-line values are not supported)"
101
+ });
102
+ continue;
103
+ }
104
+ value = rest.slice(1, closing);
105
+ if (trailingIsSignificant(rest.slice(closing + 1))) {
106
+ problems.push({
107
+ line: lineNumber,
108
+ text: key,
109
+ reason: "unexpected text after the closing quote"
110
+ });
111
+ continue;
112
+ }
113
+ } else {
114
+ const comment = rest.indexOf(" #");
115
+ if (comment !== -1) rest = rest.slice(0, comment);
116
+ value = rest.trim();
117
+ }
118
+ if (value.includes("\0")) {
119
+ problems.push({
120
+ line: lineNumber,
121
+ text: key,
122
+ reason: "value contains a NUL byte, which cannot be passed in an environment"
123
+ });
124
+ continue;
125
+ }
126
+ if (seen.has(key)) {
127
+ problems.push({
128
+ line: lineNumber,
129
+ text: key,
130
+ reason: "duplicate key in this file"
131
+ });
132
+ continue;
133
+ }
134
+ seen.add(key);
135
+ entries.push({
136
+ key,
137
+ value,
138
+ line: lineNumber
139
+ });
140
+ }
141
+ return {
142
+ entries,
143
+ problems
144
+ };
145
+ }
146
+ /** True when what follows a closing quote is neither whitespace nor a comment. */
147
+ function trailingIsSignificant(rest) {
148
+ const trimmed = rest.trim();
149
+ return trimmed !== "" && !trimmed.startsWith("#");
150
+ }
151
+ function findClosingQuote(text, quote) {
152
+ for (let index = 1; index < text.length; index++) {
153
+ if (text[index] === "\\") {
154
+ index++;
155
+ continue;
156
+ }
157
+ if (text[index] === quote) return index;
158
+ }
159
+ return -1;
160
+ }
161
+ /**
162
+ * Expand escapes inside a double-quoted value.
163
+ *
164
+ * Deliberately minimal: only the escapes that are necessary. `\n` and `\r`
165
+ * because multi-line values (PEM keys) cannot otherwise be expressed on one
166
+ * line, and `\\` `\"` `\'` because quoting needs them.
167
+ *
168
+ * EVERYTHING ELSE IS PRESERVED VERBATIM, backslash included. The obvious
169
+ * implementation -- returning the character and dropping the backslash for
170
+ * unknown escapes -- silently mangles credentials: `"C:\path\to\file"` came
171
+ * out as `C:path<TAB>ofile`, and `"\d+\w"` as `d+w`. A secrets store that
172
+ * quietly rewrites what you gave it is worse than one that refuses.
173
+ *
174
+ * A value that should contain no interpretation at all belongs in single
175
+ * quotes, which are literal.
176
+ */
177
+ function unescapeDoubleQuoted(text) {
178
+ return text.replace(/\\(.)/g, (match, character) => {
179
+ switch (character) {
180
+ case "n": return "\n";
181
+ case "r": return "\r";
182
+ case "\\":
183
+ case "\"":
184
+ case "'": return character;
185
+ default: return match;
186
+ }
187
+ });
188
+ }
189
+ /** Values that read the same unquoted: no spaces, quotes, `#`, `$` or backslashes. */
190
+ const PLAIN_VALUE = /^[A-Za-z0-9_./:@+,=%?-]*$/;
191
+ /**
192
+ * Write entries as a .env file that `parseDotenv` reads back exactly.
193
+ *
194
+ * Plain values stay bare; anything else is single-quoted, which is literal;
195
+ * values holding a quote or a line break are double-quoted with the four
196
+ * escapes the parser knows (`\\`, `\"`, `\n`, `\r`).
197
+ */
198
+ function formatDotenv(entries) {
199
+ let out = "";
200
+ for (const [key, value] of entries) {
201
+ assertEnvironmentEntry(key, value);
202
+ out += `${key}=${quoteDotenv(value)}\n`;
203
+ }
204
+ return out;
205
+ }
206
+ /** Reject names and values that cannot be passed intact in an environment. */
207
+ function assertEnvironmentEntry(key, value) {
208
+ if (!KEY_RE.test(key)) throw new Error(`${JSON.stringify(key)} is not a valid variable name`);
209
+ if (value.includes("\0")) throw new Error(`${key} contains a NUL byte`);
210
+ }
211
+ function quoteDotenv(value) {
212
+ if (PLAIN_VALUE.test(value)) return value;
213
+ if (!/['\n\r]/.test(value)) return `'${value}'`;
214
+ return `"${value.replace(/[\\"\n\r]/g, (character) => {
215
+ switch (character) {
216
+ case "\n": return "\\n";
217
+ case "\r": return "\\r";
218
+ default: return `\\${character}`;
219
+ }
220
+ })}"`;
221
+ }
222
+ /**
223
+ * Write entries as POSIX shell `export` statements, for
224
+ * `eval "$(coffre export … --format shell)"`. Single quotes are the only
225
+ * shell quoting with no expansion inside; a single quote itself is written
226
+ * as `'\''`.
227
+ */
228
+ function formatShellExports(entries) {
229
+ let out = "";
230
+ for (const [key, value] of entries) {
231
+ assertEnvironmentEntry(key, value);
232
+ out += `export ${key}='${value.replaceAll("'", "'\\''")}'\n`;
233
+ }
234
+ return out;
235
+ }
236
+ //#endregion
237
+ //#region src/github-env.ts
238
+ /** The runner decodes %, CR and LF in command data, in this escaping order.
239
+ * https://github.com/actions/toolkit/blob/main/packages/core/src/command.ts
240
+ */
241
+ function githubMasks(entries) {
242
+ let out = "";
243
+ for (const [, value] of entries) for (const mask of /* @__PURE__ */ new Set([value, ...value.split(/\r\n|\r|\n/)])) {
244
+ if (mask === "") continue;
245
+ out += `::add-mask::${mask.replaceAll("%", "%25").replaceAll("\r", "%0D").replaceAll("\n", "%0A")}\n`;
246
+ }
247
+ return out;
248
+ }
249
+ /** Multiline-safe environment records, consumed by GitHub after the step.
250
+ * https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-commands#multiline-strings
251
+ * Match the host's framing newline, including on Windows, so a trailing CR
252
+ * in the value is not mistaken for part of that newline.
253
+ */
254
+ function githubEnvironment(entries, nextDelimiter = () => `coffre_${randomUUID()}`, newline = EOL) {
255
+ let out = "";
256
+ for (const [key, value] of entries) {
257
+ assertEnvironmentEntry(key, value);
258
+ if (key.toUpperCase() === "NODE_OPTIONS") throw new Error(`${key} cannot be set through GITHUB_ENV`);
259
+ let delimiter;
260
+ do
261
+ delimiter = nextDelimiter();
262
+ while (value.includes(delimiter));
263
+ out += `${key}<<${delimiter}${newline}${value}${newline}${delimiter}${newline}`;
264
+ }
265
+ return out;
266
+ }
267
+ //#endregion
43
268
  //#region ../client/dist/index.js
44
269
  /** The API's error shape, `{ error, message }`, with the HTTP status. */
45
270
  const COFFRE_ERROR = Symbol.for("@coffre/client:CoffreError");
@@ -1554,199 +1779,6 @@ async function pickCheck() {
1554
1779
  }
1555
1780
  }
1556
1781
  //#endregion
1557
- //#region ../core/dist/dotenv.js
1558
- const KEY_RE = /^[A-Za-z_][A-Za-z0-9_]{0,127}$/;
1559
- function parseDotenv(input) {
1560
- const entries = [];
1561
- const problems = [];
1562
- const seen = /* @__PURE__ */ new Set();
1563
- const lines = input.split(/\r\n|\n|\r/);
1564
- for (let index = 0; index < lines.length; index++) {
1565
- const lineNumber = index + 1;
1566
- const trimmed = lines[index].trim();
1567
- if (trimmed === "" || trimmed.startsWith("#")) continue;
1568
- const withoutExport = trimmed.startsWith("export ") ? trimmed.slice(7).trim() : trimmed;
1569
- const equals = withoutExport.indexOf("=");
1570
- if (equals === -1) {
1571
- problems.push({
1572
- line: lineNumber,
1573
- text: trimmed,
1574
- reason: "no \"=\" on this line"
1575
- });
1576
- continue;
1577
- }
1578
- const key = withoutExport.slice(0, equals).trim();
1579
- if (!KEY_RE.test(key)) {
1580
- problems.push({
1581
- line: lineNumber,
1582
- text: key,
1583
- reason: "key must match ^[A-Za-z_][A-Za-z0-9_]*$"
1584
- });
1585
- continue;
1586
- }
1587
- let rest = withoutExport.slice(equals + 1).trim();
1588
- let value;
1589
- if (rest.startsWith("\"")) {
1590
- const closing = findClosingQuote(rest, "\"");
1591
- if (closing === -1) {
1592
- problems.push({
1593
- line: lineNumber,
1594
- text: key,
1595
- reason: "unterminated double quote (multi-line values are not supported)"
1596
- });
1597
- continue;
1598
- }
1599
- value = unescapeDoubleQuoted(rest.slice(1, closing));
1600
- if (trailingIsSignificant(rest.slice(closing + 1))) {
1601
- problems.push({
1602
- line: lineNumber,
1603
- text: key,
1604
- reason: "unexpected text after the closing quote"
1605
- });
1606
- continue;
1607
- }
1608
- } else if (rest.startsWith("'")) {
1609
- const closing = rest.indexOf("'", 1);
1610
- if (closing === -1) {
1611
- problems.push({
1612
- line: lineNumber,
1613
- text: key,
1614
- reason: "unterminated single quote (multi-line values are not supported)"
1615
- });
1616
- continue;
1617
- }
1618
- value = rest.slice(1, closing);
1619
- if (trailingIsSignificant(rest.slice(closing + 1))) {
1620
- problems.push({
1621
- line: lineNumber,
1622
- text: key,
1623
- reason: "unexpected text after the closing quote"
1624
- });
1625
- continue;
1626
- }
1627
- } else {
1628
- const comment = rest.indexOf(" #");
1629
- if (comment !== -1) rest = rest.slice(0, comment);
1630
- value = rest.trim();
1631
- }
1632
- if (value.includes("\0")) {
1633
- problems.push({
1634
- line: lineNumber,
1635
- text: key,
1636
- reason: "value contains a NUL byte, which cannot be passed in an environment"
1637
- });
1638
- continue;
1639
- }
1640
- if (seen.has(key)) {
1641
- problems.push({
1642
- line: lineNumber,
1643
- text: key,
1644
- reason: "duplicate key in this file"
1645
- });
1646
- continue;
1647
- }
1648
- seen.add(key);
1649
- entries.push({
1650
- key,
1651
- value,
1652
- line: lineNumber
1653
- });
1654
- }
1655
- return {
1656
- entries,
1657
- problems
1658
- };
1659
- }
1660
- /** True when what follows a closing quote is neither whitespace nor a comment. */
1661
- function trailingIsSignificant(rest) {
1662
- const trimmed = rest.trim();
1663
- return trimmed !== "" && !trimmed.startsWith("#");
1664
- }
1665
- function findClosingQuote(text, quote) {
1666
- for (let index = 1; index < text.length; index++) {
1667
- if (text[index] === "\\") {
1668
- index++;
1669
- continue;
1670
- }
1671
- if (text[index] === quote) return index;
1672
- }
1673
- return -1;
1674
- }
1675
- /**
1676
- * Expand escapes inside a double-quoted value.
1677
- *
1678
- * Deliberately minimal: only the escapes that are necessary. `\n` and `\r`
1679
- * because multi-line values (PEM keys) cannot otherwise be expressed on one
1680
- * line, and `\\` `\"` `\'` because quoting needs them.
1681
- *
1682
- * EVERYTHING ELSE IS PRESERVED VERBATIM, backslash included. The obvious
1683
- * implementation -- returning the character and dropping the backslash for
1684
- * unknown escapes -- silently mangles credentials: `"C:\path\to\file"` came
1685
- * out as `C:path<TAB>ofile`, and `"\d+\w"` as `d+w`. A secrets store that
1686
- * quietly rewrites what you gave it is worse than one that refuses.
1687
- *
1688
- * A value that should contain no interpretation at all belongs in single
1689
- * quotes, which are literal.
1690
- */
1691
- function unescapeDoubleQuoted(text) {
1692
- return text.replace(/\\(.)/g, (match, character) => {
1693
- switch (character) {
1694
- case "n": return "\n";
1695
- case "r": return "\r";
1696
- case "\\":
1697
- case "\"":
1698
- case "'": return character;
1699
- default: return match;
1700
- }
1701
- });
1702
- }
1703
- /** Values that read the same unquoted: no spaces, quotes, `#`, `$` or backslashes. */
1704
- const PLAIN_VALUE = /^[A-Za-z0-9_./:@+,=%?-]*$/;
1705
- /**
1706
- * Write entries as a .env file that `parseDotenv` reads back exactly.
1707
- *
1708
- * Plain values stay bare; anything else is single-quoted, which is literal;
1709
- * values holding a quote or a line break are double-quoted with the four
1710
- * escapes the parser knows (`\\`, `\"`, `\n`, `\r`).
1711
- */
1712
- function formatDotenv(entries) {
1713
- let out = "";
1714
- for (const [key, value] of entries) {
1715
- assertWritable(key, value);
1716
- out += `${key}=${quoteDotenv(value)}\n`;
1717
- }
1718
- return out;
1719
- }
1720
- function assertWritable(key, value) {
1721
- if (!KEY_RE.test(key)) throw new Error(`${JSON.stringify(key)} is not a valid variable name`);
1722
- if (value.includes("\0")) throw new Error(`${key} contains a NUL byte`);
1723
- }
1724
- function quoteDotenv(value) {
1725
- if (PLAIN_VALUE.test(value)) return value;
1726
- if (!/['\n\r]/.test(value)) return `'${value}'`;
1727
- return `"${value.replace(/[\\"\n\r]/g, (character) => {
1728
- switch (character) {
1729
- case "\n": return "\\n";
1730
- case "\r": return "\\r";
1731
- default: return `\\${character}`;
1732
- }
1733
- })}"`;
1734
- }
1735
- /**
1736
- * Write entries as POSIX shell `export` statements, for
1737
- * `eval "$(coffre export … --format shell)"`. Single quotes are the only
1738
- * shell quoting with no expansion inside; a single quote itself is written
1739
- * as `'\''`.
1740
- */
1741
- function formatShellExports(entries) {
1742
- let out = "";
1743
- for (const [key, value] of entries) {
1744
- assertWritable(key, value);
1745
- out += `export ${key}='${value.replaceAll("'", "'\\''")}'\n`;
1746
- }
1747
- return out;
1748
- }
1749
- //#endregion
1750
1782
  //#region src/main.ts
1751
1783
  /**
1752
1784
  * coffre CLI.
@@ -2192,14 +2224,22 @@ async function exportEnv(args) {
2192
2224
  } },
2193
2225
  allowPositionals: true
2194
2226
  });
2195
- const usage = "usage: coffre export <project>/<environment> [--format dotenv|json|shell]";
2227
+ const usage = "usage: coffre export <project>/<environment> [--format dotenv|json|shell|github]";
2196
2228
  if (!positionals[0]) fail(usage);
2197
2229
  const format = values.format;
2198
- if (format !== "dotenv" && format !== "json" && format !== "shell") fail(usage);
2230
+ if (format !== "dotenv" && format !== "json" && format !== "shell" && format !== "github") fail(usage);
2231
+ const githubEnv = process.env.GITHUB_ENV;
2232
+ if (format === "github" && !githubEnv) fail("--format github requires GITHUB_ENV (run it in a GitHub Actions step)");
2199
2233
  const { project, environment } = parsePath(positionals[0]);
2200
2234
  const revealed = await client().secrets.reveal(`${project}/${environment}`);
2201
2235
  const entries = Object.entries(revealed.values).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
2202
- process.stdout.write(format === "json" ? `${JSON.stringify(Object.fromEntries(entries), null, 2)}\n` : attempt(() => format === "shell" ? formatShellExports(entries) : formatDotenv(entries)));
2236
+ if (format === "github") {
2237
+ process.stdout.write(githubMasks(entries));
2238
+ attempt(() => appendFileSync(githubEnv, githubEnvironment(entries), {
2239
+ encoding: "utf8",
2240
+ mode: 384
2241
+ }));
2242
+ } else process.stdout.write(format === "json" ? `${JSON.stringify(Object.fromEntries(entries), null, 2)}\n` : attempt(() => format === "shell" ? formatShellExports(entries) : formatDotenv(entries)));
2203
2243
  if (process.stderr.isTTY) {
2204
2244
  const count = entries.length;
2205
2245
  process.stderr.write(`coffre: read ${count} secret${count === 1 ? "" : "s"} from ${project}/${environment}; each read is in the audit log under your name\n`);
@@ -2527,7 +2567,7 @@ const USAGE = `coffre - secrets, with an audit log
2527
2567
  coffre get <project>/<environment>/<KEY>
2528
2568
  coffre set <project>/<environment>/<KEY> [value] (reads stdin if omitted)
2529
2569
  coffre run <project>/<environment> -- <command>
2530
- coffre export <project>/<environment> [--format dotenv|json|shell]
2570
+ coffre export <project>/<environment> [--format dotenv|json|shell|github]
2531
2571
  coffre history <project>/<environment>/<KEY>
2532
2572
  coffre rollback <project>/<environment>/<KEY> <version>
2533
2573
  coffre import <project>/<environment> [--file .env] [--apply]
@@ -2566,10 +2606,10 @@ switch (command) {
2566
2606
  await keys(rest);
2567
2607
  break;
2568
2608
  case "setup":
2569
- await (await import("./setup-qGoLPykX.js")).setup(rest);
2609
+ await (await import("./setup-Bs8q8Ily.js")).setup(rest);
2570
2610
  break;
2571
2611
  case "update":
2572
- await (await import("./update-DwGbG-eM.js")).update(rest);
2612
+ await (await import("./update-eYzLTNoL.js")).update(rest);
2573
2613
  break;
2574
2614
  case "migrate":
2575
2615
  await (await import("./migrate-ERNNgYO0.js")).migrate(rest, migrateTarget);
@@ -1,11 +1,11 @@
1
1
  import { d as esm_default } from "./esm-Bts-esAl.js";
2
- import { n as init, t as KINDS } from "./init-mO8V087p.js";
2
+ import { n as init, t as KINDS } from "./init-2xGelVG1.js";
3
3
  import { i as keyValues, n as jsonWarning, o as needsTerminal, r as keyGuide, s as showSecrets, t as generateKeys } from "./keys-DDdMwm0N.js";
4
4
  import { a as openTerminal, c as release, d as style, f as textLine, i as listed, l as row, o as paragraph, t as Cancelled, u as select } from "./tty-BDa14JX2.js";
5
5
  import { r as postgresConnection } from "./connect-C4wevMJ5.js";
6
6
  import { n as migrateDatabase, t as readDatabaseUrl } from "./database-url-CJ9yqoi_.js";
7
7
  import { n as StepFailed, r as Steps, t as cliVersion } from "./version-CPk6zTTK.js";
8
- import { a as install, i as editWorker, o as placeholder, r as deploymentKind, s as readWorker } from "./deployment-S8za5nUu.js";
8
+ import { a as editWorker, d as readWorker, i as deploymentKind, s as install, u as placeholder } from "./deployment-Di8BG9qY.js";
9
9
  import { parseArgs } from "node:util";
10
10
  import { spawn } from "node:child_process";
11
11
  import { existsSync } from "node:fs";
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "coffre-node",
3
3
  "private": true,
4
+ "packageManager": "pnpm@11.8.0",
4
5
  "type": "module",
5
6
  "engines": {
6
7
  "node": ">=24"
@@ -13,11 +14,11 @@
13
14
  "conformance": "coffre-conformance node"
14
15
  },
15
16
  "dependencies": {
16
- "@coffre/server": "0.1.14",
17
- "@coffre/vault": "0.1.14"
17
+ "@coffre/server": "0.1.15",
18
+ "@coffre/vault": "0.1.15"
18
19
  },
19
20
  "devDependencies": {
20
- "@coffre/conformance": "0.1.14",
21
+ "@coffre/conformance": "0.1.15",
21
22
  "@types/node": "26.1.1",
22
23
  "typescript": "5.9.3"
23
24
  }
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "coffre-workers",
3
3
  "private": true,
4
+ "packageManager": "pnpm@11.8.0",
4
5
  "type": "module",
5
6
  "engines": {
6
7
  "node": ">=24"
@@ -14,13 +15,13 @@
14
15
  "conformance": "coffre-conformance workers"
15
16
  },
16
17
  "dependencies": {
17
- "@coffre/server": "0.1.14",
18
- "@coffre/ui": "0.1.14",
19
- "@coffre/vault": "0.1.14"
18
+ "@coffre/server": "0.1.15",
19
+ "@coffre/ui": "0.1.15",
20
+ "@coffre/vault": "0.1.15"
20
21
  },
21
22
  "devDependencies": {
22
23
  "@cloudflare/workers-types": "5.20260920.1",
23
- "@coffre/conformance": "0.1.14",
24
+ "@coffre/conformance": "0.1.15",
24
25
  "typescript": "5.9.3",
25
26
  "wrangler": "4.118.0"
26
27
  }
@@ -1,10 +1,11 @@
1
+ import { i as templateDir } from "./init-2xGelVG1.js";
1
2
  import { a as openTerminal, c as release, d as style, i as listed, t as Cancelled } from "./tty-BDa14JX2.js";
2
3
  import { n as StepFailed, r as Steps, t as cliVersion } from "./version-CPk6zTTK.js";
3
- import { a as install, n as coffrePins, r as deploymentKind, t as bumpPins } from "./deployment-S8za5nUu.js";
4
+ import { c as minimumReleaseAge, f as removeCleared, i as deploymentKind, l as pinPackageManager, n as bumpPins, o as excludeUntil, r as coffrePins, s as install, t as HeldBack } from "./deployment-Di8BG9qY.js";
4
5
  import { createRequire } from "node:module";
5
6
  import { parseArgs } from "node:util";
6
7
  import { spawn, spawnSync } from "node:child_process";
7
- import { readFileSync, realpathSync } from "node:fs";
8
+ import { readFileSync, realpathSync, writeFileSync } from "node:fs";
8
9
  import { dirname, join, sep } from "node:path";
9
10
  import { fileURLToPath } from "node:url";
10
11
  /**
@@ -221,20 +222,71 @@ async function update(args) {
221
222
  }
222
223
  });
223
224
  if (deployment !== null) await steps.run(2, async (step) => {
224
- if (pinned.length === 1 && pinned[0] === latest) return `This deployment's coffre packages are at ${latest} already`;
225
+ const details = [];
226
+ const cleared = removeCleared(deployment, /* @__PURE__ */ new Date());
227
+ if (cleared.length > 0) details.push(`No longer excluded from minimumReleaseAge, now old enough: ${listed(cleared, "and")}`);
228
+ const pnpm = templatePackageManager(kind);
229
+ const repin = pnpm !== null && coffrePackageManager(deployment) !== pnpm;
230
+ const current = pinned.length === 1 && pinned[0] === latest;
231
+ if (current && !repin) return {
232
+ text: `This deployment's coffre packages are at ${latest} already`,
233
+ details
234
+ };
225
235
  const was = pinned.length === 1 ? pinned[0] : listed(pinned, "and");
226
- if (!await ask(`Move this deployment's coffre packages from ${was} to ${latest}, and install them?`, step)) return {
227
- text: `This deployment stays at ${was}`,
228
- details: []
236
+ const on = pnpm?.replace("@", " ");
237
+ const question = current ? `Pin this deployment to ${on}, as coffre installs with, and install it?` : `Move this deployment from ${was} to ${latest}${repin ? `, on ${on},` : ""} and install it?`;
238
+ if (!await ask(question, step)) return {
239
+ text: `This deployment stays as it is, at ${was}`,
240
+ details
229
241
  };
230
242
  before = deploymentMigrations(deployment) ?? before;
243
+ const manifest = join(deployment, "package.json");
244
+ const saved = readFileSync(manifest, "utf8");
231
245
  bumpPins(deployment, latest);
246
+ if (repin) {
247
+ const replaced = pinPackageManager(deployment, pnpm);
248
+ details.push(`Pinned ${pnpm}${replaced ? `, not ${replaced}` : ""}: every install, here, in CI and on Workers Builds, holds minimumReleaseAge alike`);
249
+ }
232
250
  step.note(`Installing coffre ${latest}'s packages, with pnpm`);
233
- await install(deployment);
251
+ try {
252
+ await install(deployment);
253
+ } catch (error) {
254
+ if (!(error instanceof HeldBack)) throw error;
255
+ const age = minimumReleaseAge(deployment);
256
+ const clears = error.held.map(({ spec, publishedAt }) => ({
257
+ spec,
258
+ clears: new Date(publishedAt.getTime() + age * 6e4)
259
+ })).sort((x, y) => x.clears.getTime() - y.clears.getTime());
260
+ const last = clears.at(-1).clears;
261
+ const lines = clears.map(({ spec, clears: at }) => `${spec}, old enough from ${when(at)}`);
262
+ const held = `pnpm holds back ${clears.length === 1 ? "1 package" : `${clears.length} packages`} younger than this deployment's minimumReleaseAge, ${days(age)}`;
263
+ if (yes || keys() === null) {
264
+ writeFileSync(manifest, saved);
265
+ throw new Error(`${held}: ${lines.join("; ")}. Run coffre update after ${when(last)}, or on a terminal to exclude them until then`);
266
+ }
267
+ step.under(lines);
268
+ const choice = await step.choose(held, [`Wait: stay at ${was}, and run coffre update again after ${when(last)}`, `Proceed: let ${clears.length === 1 ? "it" : "these"} through until each is old enough, named in pnpm-workspace.yaml`]);
269
+ step.under([]);
270
+ if (choice === 0) {
271
+ writeFileSync(manifest, saved);
272
+ return {
273
+ text: `This deployment stays at ${was}, until ${when(last)}`,
274
+ details: [
275
+ ...details,
276
+ ...lines,
277
+ `Run coffre update again after ${when(last)}`
278
+ ]
279
+ };
280
+ }
281
+ excludeUntil(deployment, clears, `for coffre ${latest}`);
282
+ step.note(`Installing coffre ${latest}'s packages, with pnpm`);
283
+ await install(deployment);
284
+ details.push("Let through until each is old enough, named in pnpm-workspace.yaml:", ...clears.map(({ spec, clears: at }) => ` ${spec}, until ${when(at)}`), `The first coffre update after ${when(last)} removes them`);
285
+ }
234
286
  after = deploymentMigrations(deployment) ?? after;
235
287
  return {
236
- text: `Moved this deployment from ${was} to ${latest}, and installed it`,
237
- details: [`${Object.keys(pins).length} coffre packages, which minimumReleaseAge lets through at once`]
288
+ text: current ? `Pinned ${pnpm}, and installed it` : `Moved this deployment from ${was} to ${latest}, and installed it`,
289
+ details
238
290
  };
239
291
  });
240
292
  } catch (error) {
@@ -255,6 +307,25 @@ async function update(args) {
255
307
  out.write("\n");
256
308
  }
257
309
  }
310
+ /** The pnpm coffre installs with, as the template of a deployment of `kind` pins it. */
311
+ function templatePackageManager(kind) {
312
+ try {
313
+ return JSON.parse(readFileSync(join(templateDir(kind), "package.json"), "utf8")).packageManager ?? null;
314
+ } catch {
315
+ return null;
316
+ }
317
+ }
318
+ function coffrePackageManager(dir) {
319
+ return JSON.parse(readFileSync(join(dir, "package.json"), "utf8")).packageManager;
320
+ }
321
+ /** A moment, to the minute, in UTC: when a package is old enough. */
322
+ function when(date) {
323
+ return `${date.toISOString().slice(0, 16).replace("T", " ")} UTC`;
324
+ }
325
+ function days(minutes) {
326
+ const d = minutes / 1440;
327
+ return Number.isInteger(d) ? `${d} day${d === 1 ? "" : "s"}` : `${minutes} minutes`;
328
+ }
258
329
  function finish(out, message, code) {
259
330
  if (message !== "") out.write(`${message}\n`);
260
331
  process.exit(code);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coffre/cli",
3
- "version": "0.1.14",
3
+ "version": "0.1.15",
4
4
  "description": "The coffre command line: secrets in your shell, and `coffre init` for a new deployment.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -10,7 +10,7 @@
10
10
  },
11
11
  "type": "module",
12
12
  "engines": {
13
- "node": ">=24"
13
+ "node": ">=20"
14
14
  },
15
15
  "files": [
16
16
  "dist"
@@ -28,9 +28,9 @@
28
28
  "pg": "8.16.3",
29
29
  "tsdown": "0.23.0",
30
30
  "typescript": "5.9.3",
31
- "@coffre/client": "0.1.14",
32
- "@coffre/db": "0.1.14",
33
- "@coffre/core": "0.1.14"
31
+ "@coffre/core": "0.1.15",
32
+ "@coffre/db": "0.1.15",
33
+ "@coffre/client": "0.1.15"
34
34
  },
35
35
  "scripts": {
36
36
  "build": "tsdown && node scripts/copy-templates.ts && node scripts/copy-migrations.ts",