@hot-updater/cli-tools 1.2.0-rc.7 → 1.2.0-rc.9

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.
Files changed (3) hide show
  1. package/dist/index.d.mts +281 -94
  2. package/dist/index.mjs +1153 -962
  3. package/package.json +5 -5
package/dist/index.mjs CHANGED
@@ -37,78 +37,6 @@ import { Buffer as Buffer$2 } from "node:buffer";
37
37
  import { Visitor, parseSync } from "oxc-parser";
38
38
  import { loadConfig as loadConfig$1 } from "unconfig";
39
39
  import { transformSync } from "oxc-transform";
40
- //#endregion
41
- //#region src/colors.ts
42
- var import_picocolors = /* @__PURE__ */ __toESM((/* @__PURE__ */ __commonJSMin(((exports, module) => {
43
- let p = process || {};
44
- let argv = p.argv || [];
45
- let env = p.env || {};
46
- let isColorSupported = !(!!env.NO_COLOR || argv.includes("--no-color")) && (!!env.FORCE_COLOR || argv.includes("--color") || p.platform === "win32" || (p.stdout || {}).isTTY && env.TERM !== "dumb" || !!env.CI);
47
- let formatter = (open, close, replace = open) => (input) => {
48
- let string = "" + input, index = string.indexOf(close, open.length);
49
- return ~index ? open + replaceClose(string, close, replace, index) + close : open + string + close;
50
- };
51
- let replaceClose = (string, close, replace, index) => {
52
- let result = "", cursor = 0;
53
- do {
54
- result += string.substring(cursor, index) + replace;
55
- cursor = index + close.length;
56
- index = string.indexOf(close, cursor);
57
- } while (~index);
58
- return result + string.substring(cursor);
59
- };
60
- let createColors = (enabled = isColorSupported) => {
61
- let f = enabled ? formatter : () => String;
62
- return {
63
- isColorSupported: enabled,
64
- reset: f("\x1B[0m", "\x1B[0m"),
65
- bold: f("\x1B[1m", "\x1B[22m", "\x1B[22m\x1B[1m"),
66
- dim: f("\x1B[2m", "\x1B[22m", "\x1B[22m\x1B[2m"),
67
- italic: f("\x1B[3m", "\x1B[23m"),
68
- underline: f("\x1B[4m", "\x1B[24m"),
69
- inverse: f("\x1B[7m", "\x1B[27m"),
70
- hidden: f("\x1B[8m", "\x1B[28m"),
71
- strikethrough: f("\x1B[9m", "\x1B[29m"),
72
- black: f("\x1B[30m", "\x1B[39m"),
73
- red: f("\x1B[31m", "\x1B[39m"),
74
- green: f("\x1B[32m", "\x1B[39m"),
75
- yellow: f("\x1B[33m", "\x1B[39m"),
76
- blue: f("\x1B[34m", "\x1B[39m"),
77
- magenta: f("\x1B[35m", "\x1B[39m"),
78
- cyan: f("\x1B[36m", "\x1B[39m"),
79
- white: f("\x1B[37m", "\x1B[39m"),
80
- gray: f("\x1B[90m", "\x1B[39m"),
81
- bgBlack: f("\x1B[40m", "\x1B[49m"),
82
- bgRed: f("\x1B[41m", "\x1B[49m"),
83
- bgGreen: f("\x1B[42m", "\x1B[49m"),
84
- bgYellow: f("\x1B[43m", "\x1B[49m"),
85
- bgBlue: f("\x1B[44m", "\x1B[49m"),
86
- bgMagenta: f("\x1B[45m", "\x1B[49m"),
87
- bgCyan: f("\x1B[46m", "\x1B[49m"),
88
- bgWhite: f("\x1B[47m", "\x1B[49m"),
89
- blackBright: f("\x1B[90m", "\x1B[39m"),
90
- redBright: f("\x1B[91m", "\x1B[39m"),
91
- greenBright: f("\x1B[92m", "\x1B[39m"),
92
- yellowBright: f("\x1B[93m", "\x1B[39m"),
93
- blueBright: f("\x1B[94m", "\x1B[39m"),
94
- magentaBright: f("\x1B[95m", "\x1B[39m"),
95
- cyanBright: f("\x1B[96m", "\x1B[39m"),
96
- whiteBright: f("\x1B[97m", "\x1B[39m"),
97
- bgBlackBright: f("\x1B[100m", "\x1B[49m"),
98
- bgRedBright: f("\x1B[101m", "\x1B[49m"),
99
- bgGreenBright: f("\x1B[102m", "\x1B[49m"),
100
- bgYellowBright: f("\x1B[103m", "\x1B[49m"),
101
- bgBlueBright: f("\x1B[104m", "\x1B[49m"),
102
- bgMagentaBright: f("\x1B[105m", "\x1B[49m"),
103
- bgCyanBright: f("\x1B[106m", "\x1B[49m"),
104
- bgWhiteBright: f("\x1B[107m", "\x1B[49m")
105
- };
106
- };
107
- module.exports = createColors();
108
- module.exports.createColors = createColors;
109
- })))(), 1);
110
- const typedColors = import_picocolors.default;
111
- //#endregion
112
40
  //#region ../../node_modules/.pnpm/workspace-tools@0.41.7/node_modules/workspace-tools/lib/graph/getPackageDependencies.js
113
41
  var require_getPackageDependencies = /* @__PURE__ */ __commonJSMin(((exports) => {
114
42
  Object.defineProperty(exports, "__esModule", { value: true });
@@ -22628,7 +22556,7 @@ const MASKING_REGISTRY = Symbol.for("@hot-updater/cli-tools/secret-masking");
22628
22556
  /**
22629
22557
  * The values to mask, kept on `globalThis`: the config file loads its own
22630
22558
  * copy of Hot Updater's modules, and a value it resolves, such as through
22631
- * `secretEnv`, must be masked by the CLI's log sinks too.
22559
+ * `secret()`, must be masked by the CLI's log sinks too.
22632
22560
  */
22633
22561
  const registry = globalThis[MASKING_REGISTRY] ??= {
22634
22562
  secretValues: /* @__PURE__ */ new Set(),
@@ -22664,17 +22592,57 @@ const maskSecretValues = (text) => {
22664
22592
  return shortSecretPattern ? masked.replace(shortSecretPattern, SECRET_MASK) : masked;
22665
22593
  };
22666
22594
  //#endregion
22595
+ //#region src/stepCapture.ts
22596
+ let current;
22597
+ const stepCapture = () => current;
22598
+ const setStepCapture = (capture) => {
22599
+ current = capture;
22600
+ };
22601
+ //#endregion
22667
22602
  //#region src/prompts.ts
22668
22603
  const maskMessage = (message) => message === void 0 ? message : maskSecretValues(message);
22669
- const log$1 = {
22670
- message: (message, options) => log$2.message(Array.isArray(message) ? message.map(maskSecretValues) : maskMessage(message), options),
22671
- info: (message, options) => log$2.info(maskSecretValues(message), options),
22672
- success: (message, options) => log$2.success(maskSecretValues(message), options),
22673
- step: (message, options) => log$2.step(maskSecretValues(message), options),
22674
- warn: (message, options) => log$2.warn(maskSecretValues(message), options),
22675
- warning: (message, options) => log$2.warning(maskSecretValues(message), options),
22676
- error: (message, options) => log$2.error(maskSecretValues(message), options)
22604
+ /**
22605
+ * Prints a log line, or, while a step runs, holds it until the step's line
22606
+ * prints. An info line says what the step is doing, so it shows as the
22607
+ * step's detail instead; an error ends the step first and prints at once,
22608
+ * since the process may exit right after it.
22609
+ */
22610
+ const logThroughStep = (write, { inStep = "hold" } = {}) => (...args) => {
22611
+ const capture = stepCapture();
22612
+ if (!capture) return write(...args);
22613
+ if (inStep === "interrupt") {
22614
+ capture.interrupt();
22615
+ return write(...args);
22616
+ }
22617
+ if (inStep === "detail") {
22618
+ const message = args[0];
22619
+ const text = Array.isArray(message) ? message.join("\n") : message ?? "";
22620
+ capture.update(maskSecretValues(text.split("\n")[0] ?? ""));
22621
+ return;
22622
+ }
22623
+ capture.hold(() => write(...args));
22677
22624
  };
22625
+ const log$1 = {
22626
+ message: logThroughStep((message, options) => log$2.message(Array.isArray(message) ? message.map(maskSecretValues) : maskMessage(message), options)),
22627
+ info: logThroughStep((message, options) => log$2.info(maskSecretValues(message), options), { inStep: "detail" }),
22628
+ success: logThroughStep((message, options) => log$2.success(maskSecretValues(message), options)),
22629
+ step: logThroughStep((message, options) => log$2.step(maskSecretValues(message), options)),
22630
+ warn: logThroughStep((message, options) => log$2.warn(maskSecretValues(message), options)),
22631
+ warning: logThroughStep((message, options) => log$2.warning(maskSecretValues(message), options)),
22632
+ error: logThroughStep((message, options) => log$2.error(maskSecretValues(message), options), { inStep: "interrupt" })
22633
+ };
22634
+ /** A spinner inside a running step, whose messages are the step's detail. */
22635
+ const spinnerInStep = (capture) => ({
22636
+ start: (message) => capture.update(message),
22637
+ message: (message) => capture.update(message),
22638
+ stop: () => capture.update(void 0),
22639
+ cancel: () => capture.update(void 0),
22640
+ error: (message) => capture.interrupt(message || void 0),
22641
+ clear: () => capture.update(void 0),
22642
+ get isCancelled() {
22643
+ return false;
22644
+ }
22645
+ });
22678
22646
  const maskSpinner = (target) => ({
22679
22647
  start: (message) => target.start(maskMessage(message)),
22680
22648
  stop: (message) => target.stop(maskMessage(message)),
@@ -22733,11 +22701,23 @@ const createStaticSpinner = ({ onCancel, output = process.stdout, withGuide } =
22733
22701
  };
22734
22702
  };
22735
22703
  const spinner = (options = {}) => {
22704
+ const capture = stepCapture();
22705
+ if (capture) return spinnerInStep(capture);
22736
22706
  const output = options.output ?? process.stdout;
22737
22707
  const isInteractive = isTTY(output) && !isCI$1();
22738
22708
  return maskSpinner(isInteractive ? spinner$1(options) : createStaticSpinner(options));
22739
22709
  };
22740
22710
  const progress = (options = {}) => {
22711
+ const capture = stepCapture();
22712
+ if (capture) return {
22713
+ ...spinnerInStep(capture),
22714
+ advance: (_step, message) => {
22715
+ if (message) capture.update(message);
22716
+ },
22717
+ get isCancelled() {
22718
+ return false;
22719
+ }
22720
+ };
22741
22721
  const output = options.output ?? process.stdout;
22742
22722
  if (isTTY(output) && !isCI$1()) return maskProgress(progress$1(options));
22743
22723
  const staticSpinner = maskSpinner(createStaticSpinner(options));
@@ -22870,6 +22850,9 @@ const createLogWriter = async ({ prefix, logFilePath }) => {
22870
22850
  }
22871
22851
  if (partialLine) writeChunk(partialLine);
22872
22852
  };
22853
+ const writeLines = (lines) => {
22854
+ writeChunk(`${lines.join("\n")}\n`);
22855
+ };
22873
22856
  const writeError = (error) => {
22874
22857
  const message = error instanceof Error ? error.stack ?? error.message : String(error);
22875
22858
  writeChunk(`${message}\n`);
@@ -22886,6 +22869,7 @@ const createLogWriter = async ({ prefix, logFilePath }) => {
22886
22869
  return {
22887
22870
  logFilePath: resolvedLogFilePath,
22888
22871
  writeStream,
22872
+ writeLines,
22889
22873
  writeError,
22890
22874
  close
22891
22875
  };
@@ -22895,67 +22879,241 @@ const createLogWriter = async ({ prefix, logFilePath }) => {
22895
22879
  return {
22896
22880
  logFilePath: null,
22897
22881
  writeStream: async () => {},
22882
+ writeLines: () => {},
22898
22883
  writeError: () => {},
22899
22884
  close: async () => {}
22900
22885
  };
22901
22886
  }
22902
22887
  };
22903
22888
  //#endregion
22904
- //#region src/BuildLogger.ts
22905
- const createInitialState = () => ({
22906
- completedStages: 0,
22907
- latestMessage: "",
22908
- started: false,
22909
- stopped: false
22910
- });
22911
- const normalizeLine = (line) => {
22912
- return (line.length > 50 ? `${line.slice(0, 50)}...` : line).replace(/[\r\n]/g, " ").trim();
22913
- };
22914
- const matchesAnyPattern = ({ line, patterns }) => {
22915
- const trimmedLine = line.trim();
22916
- const normalizedLine = trimmedLine.toLowerCase();
22917
- return patterns.some((pattern) => {
22918
- if (pattern instanceof RegExp) {
22919
- pattern.lastIndex = 0;
22920
- return pattern.test(trimmedLine);
22921
- }
22922
- return normalizedLine.includes(pattern.toLowerCase());
22923
- });
22924
- };
22925
- const getPatternMatchScore = ({ line, patterns }) => {
22926
- const trimmedLine = line.trim();
22927
- const normalizedLine = trimmedLine.toLowerCase();
22928
- return patterns.reduce((bestScore, pattern) => {
22929
- if (pattern instanceof RegExp) {
22930
- pattern.lastIndex = 0;
22931
- if (!pattern.test(trimmedLine)) return bestScore;
22932
- return Math.max(bestScore, pattern.source.length);
22933
- }
22934
- const normalizedPattern = pattern.toLowerCase();
22935
- if (!normalizedLine.includes(normalizedPattern)) return bestScore;
22936
- return Math.max(bestScore, normalizedPattern.length);
22937
- }, 0);
22938
- };
22939
- const resolveNextCompletedStage = ({ line, progressStages, completedStages }) => {
22940
- let nextCompletedStages = completedStages;
22941
- let bestMatchScore = 0;
22942
- for (let stageIndex = completedStages; stageIndex < progressStages.length; stageIndex += 1) {
22943
- const matchScore = getPatternMatchScore({
22944
- line,
22945
- patterns: progressStages[stageIndex]
22946
- });
22947
- if (matchScore <= 0) continue;
22948
- const candidateStage = stageIndex + 1;
22949
- if (matchScore > bestMatchScore || matchScore === bestMatchScore && candidateStage > nextCompletedStages) {
22950
- nextCompletedStages = candidateStage;
22951
- bestMatchScore = matchScore;
22889
+ //#region src/colors.ts
22890
+ var import_picocolors = /* @__PURE__ */ __toESM((/* @__PURE__ */ __commonJSMin(((exports, module) => {
22891
+ let p = process || {};
22892
+ let argv = p.argv || [];
22893
+ let env = p.env || {};
22894
+ let isColorSupported = !(!!env.NO_COLOR || argv.includes("--no-color")) && (!!env.FORCE_COLOR || argv.includes("--color") || p.platform === "win32" || (p.stdout || {}).isTTY && env.TERM !== "dumb" || !!env.CI);
22895
+ let formatter = (open, close, replace = open) => (input) => {
22896
+ let string = "" + input, index = string.indexOf(close, open.length);
22897
+ return ~index ? open + replaceClose(string, close, replace, index) + close : open + string + close;
22898
+ };
22899
+ let replaceClose = (string, close, replace, index) => {
22900
+ let result = "", cursor = 0;
22901
+ do {
22902
+ result += string.substring(cursor, index) + replace;
22903
+ cursor = index + close.length;
22904
+ index = string.indexOf(close, cursor);
22905
+ } while (~index);
22906
+ return result + string.substring(cursor);
22907
+ };
22908
+ let createColors = (enabled = isColorSupported) => {
22909
+ let f = enabled ? formatter : () => String;
22910
+ return {
22911
+ isColorSupported: enabled,
22912
+ reset: f("\x1B[0m", "\x1B[0m"),
22913
+ bold: f("\x1B[1m", "\x1B[22m", "\x1B[22m\x1B[1m"),
22914
+ dim: f("\x1B[2m", "\x1B[22m", "\x1B[22m\x1B[2m"),
22915
+ italic: f("\x1B[3m", "\x1B[23m"),
22916
+ underline: f("\x1B[4m", "\x1B[24m"),
22917
+ inverse: f("\x1B[7m", "\x1B[27m"),
22918
+ hidden: f("\x1B[8m", "\x1B[28m"),
22919
+ strikethrough: f("\x1B[9m", "\x1B[29m"),
22920
+ black: f("\x1B[30m", "\x1B[39m"),
22921
+ red: f("\x1B[31m", "\x1B[39m"),
22922
+ green: f("\x1B[32m", "\x1B[39m"),
22923
+ yellow: f("\x1B[33m", "\x1B[39m"),
22924
+ blue: f("\x1B[34m", "\x1B[39m"),
22925
+ magenta: f("\x1B[35m", "\x1B[39m"),
22926
+ cyan: f("\x1B[36m", "\x1B[39m"),
22927
+ white: f("\x1B[37m", "\x1B[39m"),
22928
+ gray: f("\x1B[90m", "\x1B[39m"),
22929
+ bgBlack: f("\x1B[40m", "\x1B[49m"),
22930
+ bgRed: f("\x1B[41m", "\x1B[49m"),
22931
+ bgGreen: f("\x1B[42m", "\x1B[49m"),
22932
+ bgYellow: f("\x1B[43m", "\x1B[49m"),
22933
+ bgBlue: f("\x1B[44m", "\x1B[49m"),
22934
+ bgMagenta: f("\x1B[45m", "\x1B[49m"),
22935
+ bgCyan: f("\x1B[46m", "\x1B[49m"),
22936
+ bgWhite: f("\x1B[47m", "\x1B[49m"),
22937
+ blackBright: f("\x1B[90m", "\x1B[39m"),
22938
+ redBright: f("\x1B[91m", "\x1B[39m"),
22939
+ greenBright: f("\x1B[92m", "\x1B[39m"),
22940
+ yellowBright: f("\x1B[93m", "\x1B[39m"),
22941
+ blueBright: f("\x1B[94m", "\x1B[39m"),
22942
+ magentaBright: f("\x1B[95m", "\x1B[39m"),
22943
+ cyanBright: f("\x1B[96m", "\x1B[39m"),
22944
+ whiteBright: f("\x1B[97m", "\x1B[39m"),
22945
+ bgBlackBright: f("\x1B[100m", "\x1B[49m"),
22946
+ bgRedBright: f("\x1B[101m", "\x1B[49m"),
22947
+ bgGreenBright: f("\x1B[102m", "\x1B[49m"),
22948
+ bgYellowBright: f("\x1B[103m", "\x1B[49m"),
22949
+ bgBlueBright: f("\x1B[104m", "\x1B[49m"),
22950
+ bgMagentaBright: f("\x1B[105m", "\x1B[49m"),
22951
+ bgCyanBright: f("\x1B[106m", "\x1B[49m"),
22952
+ bgWhiteBright: f("\x1B[107m", "\x1B[49m")
22953
+ };
22954
+ };
22955
+ module.exports = createColors();
22956
+ module.exports.createColors = createColors;
22957
+ })))(), 1);
22958
+ const typedColors = import_picocolors.default;
22959
+ //#endregion
22960
+ //#region src/step.ts
22961
+ /** The widest a finished step's line is, after clack's 3-column gutter. */
22962
+ const STEP_WIDTH = 73;
22963
+ /** The room the spinner's symbol and its `[12m 34s]` timer take. */
22964
+ const LIVE_LINE_CHROME = 13;
22965
+ /** `4.2s`, `38s`, `6m41s`, `1h02m`. */
22966
+ const formatDuration = (ms) => {
22967
+ if (ms < 9950) return `${(Math.max(0, ms) / 1e3).toFixed(1)}s`;
22968
+ const seconds = Math.round(ms / 1e3);
22969
+ if (seconds < 60) return `${seconds}s`;
22970
+ const minutes = Math.floor(seconds / 60);
22971
+ if (minutes < 60) return `${minutes}m${String(seconds % 60).padStart(2, "0")}s`;
22972
+ return `${Math.floor(minutes / 60)}h${String(minutes % 60).padStart(2, "0")}m`;
22973
+ };
22974
+ const visibleLength = (text) => stripVTControlCharacters(text).length;
22975
+ /**
22976
+ * `message` with `duration` at the right edge of a step's line, the message
22977
+ * cut with `…` when the line would be wider.
22978
+ */
22979
+ const withDuration = (message, ms, width = STEP_WIDTH) => {
22980
+ const duration = formatDuration(ms);
22981
+ const text = truncateLine(message, width - duration.length - 2);
22982
+ const gap = Math.max(2, width - visibleLength(text) - duration.length);
22983
+ return `${text}${" ".repeat(gap)}${typedColors.dim(duration)}`;
22984
+ };
22985
+ /** `text` cut to `width` visible columns, with `…` for the rest. */
22986
+ const truncateLine = (text, width) => {
22987
+ const plain = stripVTControlCharacters(text);
22988
+ if (plain.length <= width) return text;
22989
+ return `${plain.slice(0, Math.max(0, width - 1))}…`;
22990
+ };
22991
+ const isLive = (output) => isTTY(output) && !isCI$1();
22992
+ /**
22993
+ * A step that shows nothing until `start()`. A step created while another
22994
+ * runs is part of it: its title and detail become the running step's detail.
22995
+ */
22996
+ const createStep = (title, { output = process.stdout } = {}) => {
22997
+ let startedAt;
22998
+ let ended = false;
22999
+ let detail;
23000
+ let spinner;
23001
+ let ticker;
23002
+ let held = [];
23003
+ let outer;
23004
+ const liveMessage = () => {
23005
+ const text = maskSecretValues(detail ? `${title} · ${detail}` : title);
23006
+ const columns = output.columns || 80;
23007
+ return truncateLine(text, Math.max(20, columns - LIVE_LINE_CHROME));
23008
+ };
23009
+ const finish = (write) => {
23010
+ if (startedAt === void 0 || ended) return;
23011
+ ended = true;
23012
+ if (ticker) clearInterval(ticker);
23013
+ if (outer) {
23014
+ outer.update(void 0);
23015
+ return;
22952
23016
  }
23017
+ setStepCapture(void 0);
23018
+ if (spinner) spinner.clear();
23019
+ write();
23020
+ const logs = held;
23021
+ held = [];
23022
+ for (const log of logs) log();
23023
+ };
23024
+ const logOptions = () => ({
23025
+ output,
23026
+ spacing: spinner ? 0 : 1
23027
+ });
23028
+ const step = {
23029
+ start() {
23030
+ if (startedAt !== void 0) return step;
23031
+ startedAt = performance.now();
23032
+ outer = stepCapture();
23033
+ if (outer) {
23034
+ outer.update(title);
23035
+ return step;
23036
+ }
23037
+ setStepCapture({
23038
+ update: (value) => step.update(value),
23039
+ hold: (log) => {
23040
+ held.push(log);
23041
+ },
23042
+ interrupt: (message) => step.fail(message)
23043
+ });
23044
+ if (isLive(output)) {
23045
+ spinner = spinner$1({
23046
+ indicator: "timer",
23047
+ output
23048
+ });
23049
+ spinner.start(liveMessage());
23050
+ ticker = setInterval(() => spinner?.message(liveMessage()), 1e3);
23051
+ ticker.unref?.();
23052
+ }
23053
+ return step;
23054
+ },
23055
+ update(value) {
23056
+ if (startedAt === void 0 || ended) return;
23057
+ if (outer) {
23058
+ outer.update(value ? `${title} · ${value}` : title);
23059
+ return;
23060
+ }
23061
+ detail = value;
23062
+ spinner?.message(liveMessage());
23063
+ },
23064
+ succeed(message = title, { level = "step" } = {}) {
23065
+ const elapsed = step.elapsedMs;
23066
+ finish(() => {
23067
+ const line = maskSecretValues(withDuration(message, elapsed));
23068
+ if (level === "info") log$2.info(line, logOptions());
23069
+ else if (level === "warn") log$2.warn(line, logOptions());
23070
+ else log$2.step(line, logOptions());
23071
+ });
23072
+ },
23073
+ fail(message = `${title} failed`) {
23074
+ const elapsed = step.elapsedMs;
23075
+ finish(() => {
23076
+ log$2.error(maskSecretValues(withDuration(message, elapsed)), logOptions());
23077
+ });
23078
+ },
23079
+ get elapsedMs() {
23080
+ return startedAt === void 0 ? 0 : performance.now() - startedAt;
23081
+ },
23082
+ get active() {
23083
+ return startedAt !== void 0 && !ended;
23084
+ }
23085
+ };
23086
+ return step;
23087
+ };
23088
+ /**
23089
+ * Ends the running step as failed, if one runs, before a command prints
23090
+ * its error.
23091
+ */
23092
+ const failActiveStep = () => {
23093
+ stepCapture()?.interrupt();
23094
+ };
23095
+ /** A step, started. */
23096
+ const startStep = (title, options) => createStep(title, options).start();
23097
+ /**
23098
+ * Runs `run` as a step titled `title`, which ends with `done(result)`, or as
23099
+ * failed when `run` throws.
23100
+ */
23101
+ const withStep = async (title, run, done = () => title, options) => {
23102
+ const step = startStep(title, options);
23103
+ try {
23104
+ const result = await run(step);
23105
+ step.succeed(done(result));
23106
+ return result;
23107
+ } catch (error) {
23108
+ step.fail();
23109
+ throw error;
22953
23110
  }
22954
- return nextCompletedStages;
22955
23111
  };
23112
+ //#endregion
23113
+ //#region src/BuildLogger.ts
22956
23114
  /** The most lines a failure summary prints, and the tail it falls back to. */
22957
- const MAX_SUMMARY_LINES = 60;
22958
- const TAIL_LINES = 40;
23115
+ const MAX_SUMMARY_LINES = 20;
23116
+ const TAIL_LINES = 15;
22959
23117
  const matchesSummaryPattern = (line, pattern) => {
22960
23118
  if (pattern instanceof RegExp) {
22961
23119
  pattern.lastIndex = 0;
@@ -22973,52 +23131,58 @@ var BuildFailedError = class extends Error {
22973
23131
  logPath;
22974
23132
  /** The lines of the tool's output the summary printed. */
22975
23133
  summary;
22976
- constructor(message, { logPath, summary, cause }) {
23134
+ /**
23135
+ * What to do about it, such as accepting an agreement, which the summary
23136
+ * doesn't say: the one line a command prints after it, or null.
23137
+ */
23138
+ hint;
23139
+ constructor(message, { logPath, summary, hint = null, cause }) {
22977
23140
  super(message, { cause });
22978
23141
  this.logPath = logPath;
22979
23142
  this.summary = summary;
23143
+ this.hint = hint;
22980
23144
  }
22981
23145
  };
23146
+ /**
23147
+ * Runs a native build tool's output through a step: the step's live line
23148
+ * shows the phase its reader finds, the log file keeps the raw output, and a
23149
+ * failure prints the summary of it on stderr.
23150
+ */
22982
23151
  var BuildLogger = class {
22983
23152
  config;
22984
- state = createInitialState();
22985
23153
  logWriter;
23154
+ step;
22986
23155
  summaryLines = [];
22987
23156
  tailLines = [];
22988
23157
  openBlock;
22989
- promptProgress;
22990
- stageCount;
22991
23158
  constructor(config) {
22992
23159
  this.config = config;
22993
- this.stageCount = config.progressStages.length;
22994
- this.promptProgress = p.progress({
22995
- indicator: "timer",
22996
- max: config.progressStages.length,
22997
- delay: 500
22998
- });
22999
23160
  }
23000
- async start() {
23001
- this.state = {
23002
- ...createInitialState(),
23003
- started: true
23004
- };
23161
+ /**
23162
+ * Opens the log, writes `header` to it, such as the tool's command line,
23163
+ * which the terminal doesn't show, and starts the step titled `title`.
23164
+ */
23165
+ async start({ title, header = [] }) {
23005
23166
  this.summaryLines = [];
23006
23167
  this.tailLines = [];
23007
23168
  this.openBlock = void 0;
23008
- this.logWriter = await createLogWriter({ prefix: this.config.logPrefix });
23009
- this.promptProgress.start();
23169
+ this.logWriter = await createLogWriter({
23170
+ prefix: this.config.logPrefix,
23171
+ logFilePath: this.config.logFilePath
23172
+ });
23173
+ if (header.length) this.logWriter.writeLines(header);
23174
+ this.step = startStep(title);
23010
23175
  }
23011
23176
  /**
23012
- * Writes the tool's raw output to the log file and reads its failure
23013
- * summary from it, and shows progress from `display`, a formatter's
23014
- * output of it such as xcbeautify's, or from the raw output without one.
23177
+ * Writes the tool's raw output to the log file, reads its failure summary
23178
+ * from it, and shows its phases on the step's line.
23015
23179
  */
23016
- async processStream(input, { display } = {}) {
23180
+ async processStream(input) {
23017
23181
  if (!this.logWriter) throw new Error("BuildLogger has not been started. Call start() first.");
23018
23182
  const progressInput = new PassThrough();
23019
23183
  const logInput = new PassThrough();
23020
23184
  const summaryInput = new PassThrough();
23021
- (display ?? input).pipe(progressInput);
23185
+ input.pipe(progressInput);
23022
23186
  input.pipe(logInput);
23023
23187
  input.pipe(summaryInput);
23024
23188
  await Promise.all([
@@ -23031,6 +23195,10 @@ var BuildLogger = class {
23031
23195
  get logPath() {
23032
23196
  return this.logWriter?.logFilePath ? path.relative(getCwd(), this.logWriter.logFilePath) : null;
23033
23197
  }
23198
+ /** The build log's absolute path, or null without one. */
23199
+ get logFilePath() {
23200
+ return this.logWriter?.logFilePath ?? null;
23201
+ }
23034
23202
  /**
23035
23203
  * The lines of the raw output a failure prints: the ones the config's
23036
23204
  * `failureSummary` keeps, deduplicated, or the last lines without any.
@@ -23079,36 +23247,28 @@ var BuildLogger = class {
23079
23247
  crlfDelay: Infinity,
23080
23248
  terminal: false
23081
23249
  });
23082
- for await (const line of lineReader) this.processProgressLine(line);
23083
- }
23084
- processProgressLine(line) {
23085
- const normalizedLine = normalizeLine(line);
23086
- if (!normalizedLine) return;
23087
- const nextCompletedStages = resolveNextCompletedStage({
23088
- line: normalizedLine,
23089
- progressStages: this.config.progressStages,
23090
- completedStages: this.state.completedStages
23091
- });
23092
- this.updateProgress({
23093
- nextCompletedStages,
23094
- latestMessage: normalizedLine
23095
- });
23250
+ const readPhase = this.config.createPhaseReader();
23251
+ let phase;
23252
+ for await (const line of lineReader) {
23253
+ const next = readPhase(stripAnsi(line).replace(/\r/g, "").trim());
23254
+ if (next !== void 0 && next !== phase) {
23255
+ phase = next;
23256
+ this.step?.update(phase);
23257
+ }
23258
+ }
23096
23259
  }
23260
+ /**
23261
+ * Ends the step with `message` and its duration, or, for a failure, as
23262
+ * failed, then prints the failure summary and the log's path.
23263
+ */
23097
23264
  stop(message, success = true) {
23098
- if (!this.state.started || this.state.stopped) return;
23099
- if (success) this.finishRemainingProgress();
23100
- const finalMessage = message || (success ? "Build completed successfully" : "Build failed");
23101
- if (success) {
23102
- this.promptProgress.stop(finalMessage);
23103
- this.showLogFileLocation();
23104
- } else {
23105
- this.promptProgress.error(finalMessage);
23265
+ const step = this.step;
23266
+ if (!step?.active) return;
23267
+ if (success) step.succeed(message);
23268
+ else {
23269
+ step.fail(message);
23106
23270
  this.showFailureSummary();
23107
23271
  }
23108
- this.state = {
23109
- ...this.state,
23110
- stopped: true
23111
- };
23112
23272
  }
23113
23273
  writeError(error) {
23114
23274
  this.logWriter?.writeError(error);
@@ -23118,32 +23278,6 @@ var BuildLogger = class {
23118
23278
  await this.logWriter.close();
23119
23279
  this.logWriter = void 0;
23120
23280
  }
23121
- updateProgress({ nextCompletedStages, latestMessage }) {
23122
- if (this.state.stopped) return;
23123
- const progressDelta = nextCompletedStages - this.state.completedStages;
23124
- if (progressDelta > 0) this.promptProgress.advance(progressDelta, latestMessage);
23125
- else if (latestMessage && latestMessage !== this.state.latestMessage && this.shouldLogLine(latestMessage)) this.promptProgress.message(latestMessage);
23126
- this.state = {
23127
- ...this.state,
23128
- completedStages: Math.max(this.state.completedStages, nextCompletedStages),
23129
- latestMessage
23130
- };
23131
- }
23132
- finishRemainingProgress() {
23133
- if (!this.promptProgress || this.state.completedStages >= this.stageCount) return;
23134
- const remainingStages = this.stageCount - this.state.completedStages;
23135
- this.promptProgress.advance(remainingStages, this.state.latestMessage);
23136
- this.state = {
23137
- ...this.state,
23138
- completedStages: this.stageCount
23139
- };
23140
- }
23141
- shouldLogLine(line) {
23142
- return matchesAnyPattern({
23143
- line,
23144
- patterns: this.config.importantLogPatterns
23145
- });
23146
- }
23147
23281
  /**
23148
23282
  * Prints the failure summary and the log's path to stderr, so a `--json`
23149
23283
  * command's stdout stays its JSON.
@@ -23154,12 +23288,6 @@ var BuildLogger = class {
23154
23288
  const message = [...summary, ...logPath ? [...summary.length ? [""] : [], `Build log: ${logPath}`] : []].join("\n");
23155
23289
  if (message) p.log.error(message, { output: process.stderr });
23156
23290
  }
23157
- showLogFileLocation() {
23158
- if (!this.logWriter?.logFilePath) return;
23159
- const relativePath = path.relative(getCwd(), this.logWriter.logFilePath);
23160
- const highlightedPath = typedColors.blueBright(relativePath);
23161
- p.log.info(`Build log stored at ${highlightedPath}`);
23162
- }
23163
23291
  };
23164
23292
  //#endregion
23165
23293
  //#region src/appSetup.ts
@@ -25182,6 +25310,103 @@ const printBanner = (version) => {
25182
25310
  console.log(banner(version));
25183
25311
  };
25184
25312
  //#endregion
25313
+ //#region src/credentialEnvNames.ts
25314
+ /**
25315
+ * The environment variables that override each kind, one entry per field.
25316
+ * A field with two names takes a file path or the file's contents; the path
25317
+ * wins when both are set.
25318
+ */
25319
+ const CREDENTIAL_ENV_NAMES = {
25320
+ "asc-api-key": [
25321
+ ["HOT_UPDATER_ASC_KEY_PATH", "HOT_UPDATER_ASC_KEY"],
25322
+ ["HOT_UPDATER_ASC_KEY_ID"],
25323
+ ["HOT_UPDATER_ASC_ISSUER_ID"]
25324
+ ],
25325
+ "android-keystore": [
25326
+ ["HOT_UPDATER_ANDROID_KEYSTORE_PATH", "HOT_UPDATER_ANDROID_KEYSTORE_BASE64"],
25327
+ ["HOT_UPDATER_ANDROID_KEYSTORE_PASSWORD"],
25328
+ ["HOT_UPDATER_ANDROID_KEY_ALIAS"],
25329
+ ["HOT_UPDATER_ANDROID_KEY_PASSWORD"]
25330
+ ],
25331
+ "play-service-account": [["HOT_UPDATER_PLAY_SERVICE_ACCOUNT_PATH", "HOT_UPDATER_PLAY_SERVICE_ACCOUNT_JSON"]]
25332
+ };
25333
+ //#endregion
25334
+ //#region src/credentialFiles.ts
25335
+ const TERMINATION_SIGNALS = [
25336
+ "SIGHUP",
25337
+ "SIGINT",
25338
+ "SIGTERM"
25339
+ ];
25340
+ const liveDirectories = /* @__PURE__ */ new Set();
25341
+ const onSignal = (signal) => {
25342
+ removeLiveDirectories();
25343
+ if (process.listenerCount(signal) === 0) process.kill(process.pid, signal);
25344
+ };
25345
+ const removeLiveDirectories = () => {
25346
+ for (const directory of liveDirectories) rmSync(directory, {
25347
+ force: true,
25348
+ recursive: true
25349
+ });
25350
+ liveDirectories.clear();
25351
+ stopListening();
25352
+ };
25353
+ const startListening = () => {
25354
+ process.on("exit", removeLiveDirectories);
25355
+ for (const signal of TERMINATION_SIGNALS) process.on(signal, onSignal);
25356
+ };
25357
+ const stopListening = () => {
25358
+ process.off("exit", removeLiveDirectories);
25359
+ for (const signal of TERMINATION_SIGNALS) process.off(signal, onSignal);
25360
+ };
25361
+ const trackDirectory = (directory) => {
25362
+ if (liveDirectories.size === 0) startListening();
25363
+ liveDirectories.add(directory);
25364
+ };
25365
+ const untrackDirectory = (directory) => {
25366
+ if (liveDirectories.delete(directory) && liveDirectories.size === 0) stopListening();
25367
+ };
25368
+ /**
25369
+ * Creates the temporary files of one credential resolver: a `mkdtemp`
25370
+ * directory, created on the first write and removed by `dispose` or, if the
25371
+ * process exits first, on `exit` or a termination signal.
25372
+ */
25373
+ const createCredentialFiles = () => {
25374
+ let directory;
25375
+ let disposal;
25376
+ const ensureDirectory = () => {
25377
+ directory ??= fs$2.mkdtemp(path$1.join(os.tmpdir(), "hot-updater-credentials-")).then((created) => {
25378
+ trackDirectory(created);
25379
+ return created;
25380
+ });
25381
+ return directory;
25382
+ };
25383
+ return {
25384
+ write: async (fileName, contents) => {
25385
+ if (disposal) throw new Error("The credential files have been disposed.");
25386
+ if (typeof contents === "string") registerSecretValue(contents);
25387
+ const fileDirectory = await fs$2.mkdtemp(path$1.join(await ensureDirectory(), "item-"));
25388
+ const filePath = path$1.join(fileDirectory, path$1.basename(fileName));
25389
+ await fs$2.writeFile(filePath, contents, {
25390
+ flag: "wx",
25391
+ mode: 384
25392
+ });
25393
+ return filePath;
25394
+ },
25395
+ dispose: () => {
25396
+ disposal ??= (async () => {
25397
+ const created = await directory?.catch(() => void 0);
25398
+ if (!created) return;
25399
+ await fs$2.rm(created, {
25400
+ force: true,
25401
+ recursive: true
25402
+ });
25403
+ untrackDirectory(created);
25404
+ })();
25405
+ return disposal;
25406
+ }
25407
+ };
25408
+ };
25409
+ //#endregion
25185
25410
  //#region src/initOptions.ts
25186
25411
  var InitError = class extends Error {
25187
25412
  name = "InitError";
@@ -25407,7 +25632,7 @@ const assertPrivateDirectory = async (directory) => {
25407
25632
  if (isMissing(error)) return void 0;
25408
25633
  throw error;
25409
25634
  });
25410
- if (stats && !stats.isDirectory()) throw new Error(`localSecrets() keeps secrets in a directory, and ${directory} is not one. Set its path to a directory.`);
25635
+ if (stats && !stats.isDirectory()) throw new Error([`localSecrets() can't keep secrets in ${directory}.`, "It is not a directory: set its path to one."].join("\n"));
25411
25636
  const real = realpathThroughParents(directory);
25412
25637
  let probe = real;
25413
25638
  while (!existsSync(probe) && path$1.dirname(probe) !== probe) probe = path$1.dirname(probe);
@@ -25415,7 +25640,11 @@ const assertPrivateDirectory = async (directory) => {
25415
25640
  if (!root) return;
25416
25641
  const top = realpath(root);
25417
25642
  if (isIgnoredByGit(top, real)) return;
25418
- throw new Error(`localSecrets() refuses ${directory}: it is in the git repository ${top}, which does not ignore it, so the secrets in it could be committed. Add ${path$1.relative(top, real).split(path$1.sep).join("/")}/ to that repository's .gitignore, or keep the secrets outside it with localSecrets({ path }).`);
25643
+ throw new Error([
25644
+ `localSecrets() refuses ${directory}: its secrets could be committed.`,
25645
+ `The git repository ${top} doesn't ignore it.`,
25646
+ `Add ${path$1.relative(top, real).split(path$1.sep).join("/")}/ to its .gitignore, or set localSecrets({ path }).`
25647
+ ].join("\n"));
25419
25648
  };
25420
25649
  /** Makes `directory` 0700, and every directory it creates on the way. */
25421
25650
  const makePrivateDirectory = async (directory) => {
@@ -25483,27 +25712,6 @@ const readLocalKey = (name, cwd) => defaultStore.read(name, cwd);
25483
25712
  /** Stores `value` as `name` in `localSecrets()`'s default store. */
25484
25713
  const writeLocalKey = (name, value, cwd) => defaultStore.write(name, value, cwd);
25485
25714
  //#endregion
25486
- //#region src/credentialEnvNames.ts
25487
- /**
25488
- * The environment variables that override each kind, one entry per field.
25489
- * A field with two names takes a file path or the file's contents; the path
25490
- * wins when both are set.
25491
- */
25492
- const CREDENTIAL_ENV_NAMES = {
25493
- "asc-api-key": [
25494
- ["HOT_UPDATER_ASC_KEY_PATH", "HOT_UPDATER_ASC_KEY"],
25495
- ["HOT_UPDATER_ASC_KEY_ID"],
25496
- ["HOT_UPDATER_ASC_ISSUER_ID"]
25497
- ],
25498
- "android-keystore": [
25499
- ["HOT_UPDATER_ANDROID_KEYSTORE_PATH", "HOT_UPDATER_ANDROID_KEYSTORE_BASE64"],
25500
- ["HOT_UPDATER_ANDROID_KEYSTORE_PASSWORD"],
25501
- ["HOT_UPDATER_ANDROID_KEY_ALIAS"],
25502
- ["HOT_UPDATER_ANDROID_KEY_PASSWORD"]
25503
- ],
25504
- "play-service-account": [["HOT_UPDATER_PLAY_SERVICE_ACCOUNT_PATH", "HOT_UPDATER_PLAY_SERVICE_ACCOUNT_JSON"]]
25505
- };
25506
- //#endregion
25507
25715
  //#region src/reservedVariables.ts
25508
25716
  /**
25509
25717
  * Variables that change how a process loads code or which credentials Hot
@@ -25523,21 +25731,21 @@ const RESERVED_NAMES = /* @__PURE__ */ new Set([
25523
25731
  "PYTHONSTARTUP",
25524
25732
  "PERL5OPT",
25525
25733
  ...Object.values(CREDENTIAL_ENV_NAMES).flat(2),
25526
- "HOT_UPDATER_SIGNING_TOKEN",
25527
25734
  "HOT_UPDATER_HOME"
25528
25735
  ]);
25529
25736
  const RESERVED_PREFIXES = [
25530
25737
  "LD_",
25531
25738
  "DYLD_",
25532
25739
  "GIT_",
25533
- "NPM_CONFIG_"
25740
+ "NPM_CONFIG_",
25741
+ "INFISICAL_"
25534
25742
  ];
25535
25743
  /**
25536
25744
  * Whether `name` is a variable no secret may set: one a loader or
25537
25745
  * interpreter reads (`NODE_*` but `NODE_ENV`, `PATH`, `LD_*`, `DYLD_*`,
25538
25746
  * `BASH_ENV`, `ENV`, `GIT_*`, `npm_config_*`, the JVM's, Gradle's, Ruby's,
25539
25747
  * Python's, and Perl's options), or one Hot Updater reads its credentials
25540
- * from. `secretEnv` refuses these names, and so does every environment the
25748
+ * from. `secret()` refuses these names, and so does every environment the
25541
25749
  * CLI gives a child process from resolved secrets.
25542
25750
  */
25543
25751
  const isReservedVariableName = (name) => {
@@ -25557,20 +25765,25 @@ const assertUnreservedEnv = (env, what) => {
25557
25765
  //#endregion
25558
25766
  //#region src/secrets.ts
25559
25767
  const ENV_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/u;
25560
- /** A secret's name, as `secret:<name>` takes one. */
25768
+ /** A secret's name, as `secret("<name>")` takes one. */
25561
25769
  const SECRET_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/u;
25562
- const SECRET_REFERENCE_PREFIX = "secret:";
25563
- /** Whether `name` is a secret's name, as `secret:<name>` takes one. */
25770
+ /**
25771
+ * The brand `secret()` sets, which plugin-core's `SecretRef` types. It is a
25772
+ * `Symbol.for` key: the config file loads its own copy of this module.
25773
+ */
25774
+ const SECRET_REF = Symbol.for("@hot-updater/secret-ref");
25775
+ /** Whether `name` is a secret's name, as `secret("<name>")` takes one. */
25564
25776
  const isSecretName = (name) => SECRET_NAME_PATTERN.test(name);
25777
+ /** Whether `value` is a `secret("<name>")` reference. */
25778
+ const isSecretRef = (value) => typeof value === "function" && Reflect.get(value, SECRET_REF) === true && isSecretName(value.name);
25565
25779
  /**
25566
- * The secret name a config's `secret:<name>` reference names, such as
25567
- * `release-keystore` for `"secret:release-keystore"`. `field` is the config
25568
- * field the reference is in, which the error names.
25780
+ * The secret name a config's `secret("<name>")` names, such as
25781
+ * `release-keystore` for `secret("release-keystore")`. `field` is the
25782
+ * config field it is in, which the error names.
25569
25783
  */
25570
25784
  const parseSecretReference = (reference, field) => {
25571
- const name = reference.startsWith(SECRET_REFERENCE_PREFIX) ? reference.slice(7) : void 0;
25572
- if (name === void 0 || !SECRET_NAME_PATTERN.test(name)) throw new Error(`${field} names a secret as secret:<name>, such as "secret:release-keystore", and ${JSON.stringify(reference)} is not one.`);
25573
- return name;
25785
+ if (!isSecretRef(reference)) throw new Error(`${field} takes secret("<name>").`);
25786
+ return reference.name;
25574
25787
  };
25575
25788
  /**
25576
25789
  * The environment variable the default store reads a secret from: a name
@@ -25635,8 +25848,8 @@ const createLocalSecrets = (store) => {
25635
25848
  const getLocalSecretsStore = (secrets) => isLocalKeyStoreSecrets(secrets) ? secrets[LOCAL_KEY_STORE].store : void 0;
25636
25849
  /**
25637
25850
  * The config's own `secrets` adapter: undefined for `localSecrets()`,
25638
- * which is the adapter when the config sets none. `secretEnv` reads deploy
25639
- * credentials from it.
25851
+ * which is the adapter when the config sets none. A called `secret()` reads
25852
+ * from it.
25640
25853
  */
25641
25854
  const getConfiguredAdapter = (secrets) => isLocalKeyStoreSecrets(secrets) ? void 0 : secrets;
25642
25855
  /**
@@ -25682,7 +25895,7 @@ const CONFIGURED_SECRETS = Symbol.for("@hot-updater/cli-tools/secrets");
25682
25895
  const LOADING_SCOPE = Symbol.for("@hot-updater/cli-tools/secrets-scope");
25683
25896
  /**
25684
25897
  * Evaluates a config, the file or a config function, with `scope` as the
25685
- * scope of every `secretEnv` it creates, so each reads the `secrets` of the
25898
+ * scope of every `secret()` it creates, so each reads the `secrets` of the
25686
25899
  * config that created it, whatever config loads after. It is kept on
25687
25900
  * `globalThis`: the config file loads its own copy of Hot Updater's modules.
25688
25901
  */
@@ -25698,7 +25911,7 @@ const withSecretsScope = async (scope, evaluate) => {
25698
25911
  };
25699
25912
  /**
25700
25913
  * Makes `secrets` the adapter a credential resolver reads from by default,
25701
- * and a `secretEnv` created outside any config load, as `loadConfig` does
25914
+ * and a `secret()` created outside any config load, as `loadConfig` does
25702
25915
  * with the config's. It is kept on `globalThis`: the config file loads Hot
25703
25916
  * Updater's modules again, as its own copies.
25704
25917
  */
@@ -25727,218 +25940,518 @@ const secretReadError = (secrets, names, error) => new Error(`Couldn't read ${li
25727
25940
  const describeSecret = (secrets, name) => secrets.describe?.(name) ?? `${name} in ${secrets.name}`;
25728
25941
  const list$1 = (names) => names.length <= 2 ? names.join(" and ") : `${names.slice(0, -1).join(", ")}, and ${names.at(-1)}`;
25729
25942
  /**
25730
- * Environment variables from the `secrets` adapter of the config that
25731
- * creates it, as a function a config calls when an adapter needs them, so loading
25732
- * `hot-updater.config.ts` reads no secret. Each name set in the environment
25733
- * or `.env.hotupdater` wins; the others are read from the config's
25734
- * `secrets` adapter in one call, and without one they must be set. The values go only to the caller: they are never set in
25735
- * `process.env`, and are masked in the CLI's logs. Names a loader or
25736
- * interpreter reads, such as `NODE_OPTIONS`, are refused.
25943
+ * `secret("<name>")`: the secret `name` in `hot-updater.config.ts`, for a
25944
+ * build credential, such as `signing.keystore`, or any adapter credential,
25945
+ * which calls it on first use, so loading the config reads no secret.
25946
+ * Called, it reads `name`'s variable (`secretVariableName`) in the
25947
+ * environment or `.env.hotupdater`, else the config's `secrets` adapter:
25948
+ * the first call reads every name the config's `secret()`s reference and
25949
+ * the environment doesn't set, in one `get`. With `localSecrets()`, the
25950
+ * variable must be set: its files hold only the keys Hot Updater stores.
25951
+ * Values are masked in the CLI's logs and never set in `process.env`.
25952
+ * Names a loader or interpreter reads, such as `NODE_OPTIONS`, are refused.
25953
+ * A server passes its own environment's values instead.
25737
25954
  *
25738
25955
  * @example
25739
- * const deploy = secretEnv(["S3_ACCESS_KEY_ID", "S3_SECRET_ACCESS_KEY"]);
25740
- * s3Storage({
25956
+ * r2Storage({
25741
25957
  * bucketName: "bundles",
25742
- * region: "us-east-1",
25743
- * credentials: async () => {
25744
- * const env = await deploy();
25745
- * return {
25746
- * accessKeyId: env.S3_ACCESS_KEY_ID,
25747
- * secretAccessKey: env.S3_SECRET_ACCESS_KEY,
25748
- * };
25958
+ * accountId: "<account-id>",
25959
+ * credentials: {
25960
+ * accessKeyId: secret("HOT_UPDATER_CLOUDFLARE_R2_ACCESS_KEY_ID"),
25961
+ * secretAccessKey: secret("HOT_UPDATER_CLOUDFLARE_R2_SECRET_ACCESS_KEY"),
25749
25962
  * },
25750
25963
  * });
25751
25964
  */
25752
- const secretEnv = (names) => {
25753
- const wanted = [...new Set(names)];
25754
- const invalid = wanted.filter((name) => !ENV_NAME_PATTERN.test(name));
25755
- if (invalid.length > 0) throw new Error(`secretEnv takes environment variable names: letters, digits, and underscores, not starting with a digit, and ${invalid.map((name) => JSON.stringify(name)).join(", ")} ${invalid.length === 1 ? "is" : "are"} not.`);
25756
- const reserved = wanted.filter(isReservedVariableName);
25757
- if (reserved.length > 0) throw new Error(`secretEnv refuses ${list$1(reserved)}: ${reserved.length === 1 ? "it changes" : "they change"} how a process loads code or which credentials Hot Updater uses.`);
25965
+ const secret = (name) => {
25966
+ if (typeof name !== "string" || !isSecretName(name)) throw new Error([`secret() can't take the name ${JSON.stringify(name)}.`, "Use up to 64 letters, digits, \".\", \"_\", \"-\"; start with a letter or digit."].join("\n"));
25967
+ if (isReservedVariableName(name)) throw new Error([`secret() refuses ${name}.`, "It changes how a process loads code, or the credentials Hot Updater uses."].join("\n"));
25758
25968
  const scope = globalThis[LOADING_SCOPE];
25759
- if (scope) {
25760
- scope.names ??= /* @__PURE__ */ new Set();
25761
- for (const name of wanted) scope.names.add(name);
25762
- }
25969
+ if (scope) (scope.names ??= /* @__PURE__ */ new Set()).add(name);
25763
25970
  let read;
25764
- return () => read ??= readSecretEnv(wanted, scope).catch((error) => {
25971
+ const ref = () => read ??= readSecretRef(name, scope).catch((error) => {
25765
25972
  read = void 0;
25766
25973
  throw error;
25767
25974
  });
25975
+ Object.defineProperty(ref, "name", { value: name });
25976
+ Object.defineProperty(ref, SECRET_REF, { value: true });
25977
+ return Object.freeze(ref);
25978
+ };
25979
+ /**
25980
+ * The scope's names to read in one `get`: those no build credential takes
25981
+ * and the environment doesn't set, with `name`.
25982
+ */
25983
+ const batchedNames = (scope, name, fileEnv) => {
25984
+ const names = [...scope.names ?? []].filter((candidate) => !scope.buildNames?.has(candidate) && getHotUpdaterEnvValue(fileEnv, secretVariableName(candidate)) === void 0);
25985
+ return names.includes(name) ? names : [...names, name];
25768
25986
  };
25769
- const readSecretEnv = async (wanted, scope) => {
25987
+ const readSecretRef = async (name, scope) => {
25770
25988
  const cwd = getCwd();
25771
25989
  const fileEnv = await readLoadedHotUpdaterEnv(cwd);
25772
- const variables = Object.create(null);
25773
- const fromStore = [];
25774
- for (const name of wanted) {
25775
- const value = getHotUpdaterEnvValue(fileEnv, name);
25776
- if (value === void 0) fromStore.push(name);
25777
- else variables[name] = value;
25778
- }
25779
- if (fromStore.length > 0) {
25780
- const secrets = getConfiguredAdapter(scope?.secrets ?? getConfiguredSecrets());
25781
- const read = secrets ? await readSecrets(secrets, fromStore, cwd) : {};
25782
- const missing = fromStore.filter((name) => {
25783
- const value = Object.hasOwn(read, name) ? read[name] : void 0;
25784
- if (typeof value !== "string" || value === "") return true;
25785
- variables[name] = value;
25786
- return false;
25990
+ const variable = secretVariableName(name);
25991
+ const fromEnv = getHotUpdaterEnvValue(fileEnv, variable);
25992
+ if (fromEnv !== void 0 && fromEnv !== "") {
25993
+ registerSecretValue(fromEnv);
25994
+ return fromEnv;
25995
+ }
25996
+ const secrets = getConfiguredAdapter(scope?.secrets ?? getConfiguredSecrets());
25997
+ if (secrets === void 0) throw new Error([
25998
+ `secret("${name}") has no value.`,
25999
+ `Set ${variable} in the environment or .env.hotupdater.`,
26000
+ "Or set the config's secrets to a team store."
26001
+ ].join("\n"));
26002
+ let values;
26003
+ if (scope) {
26004
+ scope.read ??= readSecrets(secrets, batchedNames(scope, name, fileEnv), cwd).catch((error) => {
26005
+ scope.read = void 0;
26006
+ throw error;
25787
26007
  });
25788
- if (missing.length > 0) {
25789
- const them = missing.length === 1 ? "it" : "them";
25790
- throw new Error(secrets === void 0 ? `secretEnv found no value for ${list$1(missing)}: set ${them} in the environment or .env.hotupdater, or read ${them} from a secrets store with the config's secrets.` : `secretEnv found no value for ${list$1(missing)}: ${missing.length === 1 ? "it is" : "they are"} not set in the environment or .env.hotupdater, and the ${secrets.name} secrets adapter does not hold ${them} (${missing.map((name) => describeSecret(secrets, name)).join("; ")}).`);
25791
- }
25792
- }
25793
- for (const value of Object.values(variables)) registerSecretValue(value);
25794
- return Object.freeze(variables);
26008
+ values = await scope.read;
26009
+ }
26010
+ if (!values || !Object.hasOwn(values, name)) values = await readSecrets(secrets, [name], cwd);
26011
+ const value = Object.hasOwn(values, name) ? values[name] : void 0;
26012
+ if (typeof value !== "string" || value === "") throw new Error([
26013
+ `secret("${name}") has no value.`,
26014
+ `It isn't in ${describeSecret(secrets, name)}.`,
26015
+ `Store it there, or set ${variable}.`
26016
+ ].join("\n"));
26017
+ registerSecretValue(value);
26018
+ return value;
25795
26019
  };
25796
26020
  //#endregion
25797
- //#region src/localBundleSigning.ts
25798
- /** The secret `keys generate --secret` stores the private key as by default. */
25799
- const DEFAULT_SIGNING_KEY_SECRET = "bundle-signing-key";
26021
+ //#region src/credentialResolver.ts
26022
+ /** Passwords are read as set: trimming would change them. */
26023
+ const UNTRIMMED_ENV_NAMES = /* @__PURE__ */ new Set(["HOT_UPDATER_ANDROID_KEYSTORE_PASSWORD", "HOT_UPDATER_ANDROID_KEY_PASSWORD"]);
26024
+ const describeField = ([name, alternative]) => alternative ? `${name} (or ${alternative})` : name;
26025
+ const describeFields = (fields) => {
26026
+ const described = fields.map(describeField);
26027
+ if (described.length <= 2) return described.join(" and ");
26028
+ return `${described.slice(0, -1).join(", ")}, and ${described.at(-1)}`;
26029
+ };
25800
26030
  /**
25801
- * A local signing failure whose message the CLI prints as it is: it names
25802
- * the secret and how to store it, and quotes no key material.
26031
+ * A command to print, broken at spaces with ` \` once a line would pass
26032
+ * 74 columns, each continuation indented two spaces, so a terminal never
26033
+ * wraps it mid-word and it still pastes as one command.
25803
26034
  */
25804
- var LocalSigningKeyError = class extends Error {
25805
- constructor(message, options) {
26035
+ const formatCommand = (command) => {
26036
+ const lines = [];
26037
+ let line = "";
26038
+ for (const word of command.split(" ")) {
26039
+ const next = line ? `${line} ${word}` : word;
26040
+ if (line && next.length > 72) {
26041
+ lines.push(`${line} \\`);
26042
+ line = ` ${word}`;
26043
+ } else line = next;
26044
+ }
26045
+ lines.push(line);
26046
+ return lines.join("\n");
26047
+ };
26048
+ /**
26049
+ * `lead` and the command it names on one line when they fit 77 columns,
26050
+ * else the command on the lines after it.
26051
+ */
26052
+ const withLead = (lead, command) => lead.length + 1 + command.length <= 77 ? `${lead} ${command}` : `${lead}\n${formatCommand(command)}`;
26053
+ /**
26054
+ * Why a build has no upload keystore, steering away from a second key:
26055
+ * Play rejects builds signed with a key it does not have. Under a team's
26056
+ * adapter the keystore is someone's already, so it is imported, never
26057
+ * created; only `localSecrets()` alone offers a new one, for an app with
26058
+ * no `.aab` on Play yet.
26059
+ */
26060
+ const describeMissingKeystore = (store, item, name) => {
26061
+ const configured = getConfiguredAdapter(store);
26062
+ if (configured) return [
26063
+ `No ${item} in ${describeSecret(configured, name)}.`,
26064
+ "Ask whoever set it up to store it there, or import it with:",
26065
+ formatCommand(credentialPutCommand("android-keystore", name))
26066
+ ].join("\n");
26067
+ return [
26068
+ `No ${item} in the ${store.name} secrets adapter.`,
26069
+ withLead("Store the app's upload key:", credentialPutCommand("android-keystore", name)),
26070
+ withLead("No .aab on Play yet? Create one:", credentialIssueCommand("android-keystore", name))
26071
+ ].join("\n");
26072
+ };
26073
+ /** The file each `secrets put <name> <file>` command names. */
26074
+ const PUT_FILES = {
26075
+ "android-keystore": "<upload.jks>",
26076
+ "play-service-account": "<key.json>"
26077
+ };
26078
+ /**
26079
+ * The command that stores the file you have as `name`: the service
26080
+ * account's JSON key, or the app's upload keystore. `--kind` is named only
26081
+ * for a name that is not the kind.
26082
+ */
26083
+ const credentialPutCommand = (kind, name = kind) => `npx hot-updater secrets put ${name}${name === kind ? "" : ` --kind ${kind}`} ${PUT_FILES[kind]}`;
26084
+ /**
26085
+ * The command that stores the key Hot Updater issues as `name`: an App
26086
+ * Store Connect API key after an Apple Account sign-in, and an upload
26087
+ * keystore it creates.
26088
+ */
26089
+ const credentialIssueCommand = (kind, name = kind) => `npx hot-updater secrets issue ${kind}${name === kind ? "" : ` ${name}`}`;
26090
+ /** The command group of each issued kind's shortcut, `<group> login`. */
26091
+ const CREDENTIAL_LOGIN_GROUPS = { "asc-api-key": "apple" };
26092
+ /**
26093
+ * The command people meet first that issues `kind` as `name`: its shortcut,
26094
+ * `apple login` for an App Store Connect API key, which takes nothing and
26095
+ * stores the kind's own name; for another name, `credentialIssueCommand`.
26096
+ */
26097
+ const credentialLoginCommand = (kind, name = kind) => name === kind ? `npx hot-updater ${CREDENTIAL_LOGIN_GROUPS[kind]} login` : credentialIssueCommand(kind, name);
26098
+ /**
26099
+ * The command that stores a missing credential of `kind` as `name`: the
26100
+ * key Hot Updater issues after an Apple sign-in, or the file you have.
26101
+ */
26102
+ const credentialStoreCommand = (kind, name = kind) => kind === "asc-api-key" ? credentialLoginCommand(kind, name) : credentialPutCommand(kind, name);
26103
+ var CredentialUnavailableError = class extends Error {
26104
+ kind;
26105
+ /** The environment variables that can provide this credential. */
26106
+ envNames;
26107
+ constructor(kind, message, options) {
25806
26108
  super(message, options);
25807
- this.name = "HotUpdaterLocalSigningKeyError";
26109
+ this.name = "CredentialUnavailableError";
26110
+ this.kind = kind;
26111
+ this.envNames = CREDENTIAL_ENV_NAMES[kind].flat();
25808
26112
  }
25809
26113
  };
25810
- const resolvePath$1 = (cwd, filePath) => path$1.isAbsolute(filePath) ? filePath : path$1.resolve(cwd, filePath);
25811
- const parsePrivateKey = (pem) => {
25812
- try {
25813
- const privateKey = crypto$1.createPrivateKey(pem);
25814
- if (privateKey.asymmetricKeyType !== "rsa" || (privateKey.asymmetricKeyDetails?.modulusLength ?? 0) < 2048) throw new Error("not rsa");
25815
- return privateKey;
25816
- } catch {
25817
- throw new Error("Failed to load the local bundle signing private key.");
26114
+ const readEnvField = (env, field) => {
26115
+ for (const name of field) {
26116
+ const value = UNTRIMMED_ENV_NAMES.has(name) ? (process.env[name] ?? env[name]) || void 0 : getHotUpdaterEnvValue(env, name);
26117
+ if (value !== void 0) return {
26118
+ name,
26119
+ value
26120
+ };
25818
26121
  }
25819
26122
  };
25820
- const loadPrivateKeyFile = async (privateKeyPath) => {
25821
- let pem;
26123
+ const readFileField = async (kind, { name, value }) => {
26124
+ const filePath = path$1.resolve(value);
26125
+ if (!await fs$2.stat(filePath).then(async (stats) => {
26126
+ await fs$2.access(filePath, fs$2.constants.R_OK);
26127
+ return stats.isFile();
26128
+ }).catch(() => false)) throw new CredentialUnavailableError(kind, `${name} does not point to a readable file.`);
26129
+ return filePath;
26130
+ };
26131
+ const parseServiceAccount = (json) => {
25822
26132
  try {
25823
- pem = await fs$2.readFile(privateKeyPath, "utf8");
26133
+ return JSON.parse(json);
25824
26134
  } catch {
25825
- throw new Error("Failed to load the local bundle signing private key.");
26135
+ return;
25826
26136
  }
25827
- return parsePrivateKey(pem);
25828
26137
  };
26138
+ const fromEnv = {
26139
+ "asc-api-key": async ([key, keyId, issuerId], { files }) => ({
26140
+ keyPath: key.name === "HOT_UPDATER_ASC_KEY_PATH" ? await readFileField("asc-api-key", key) : await files.write(`AuthKey_${keyId.value}.p8`, key.value.includes("\n") ? key.value : key.value.replaceAll("\\n", "\n")),
26141
+ keyId: keyId.value,
26142
+ issuerId: issuerId.value
26143
+ }),
26144
+ "android-keystore": async ([keystore, storePassword, keyAlias, keyPassword], { files, name }) => {
26145
+ let storeFile;
26146
+ if (keystore.name === "HOT_UPDATER_ANDROID_KEYSTORE_PATH") storeFile = await readFileField("android-keystore", keystore);
26147
+ else {
26148
+ registerSecretValue(keystore.value);
26149
+ const contents = decodeBase64(keystore.value);
26150
+ if (!contents) throw new CredentialUnavailableError("android-keystore", `${keystore.name} is not a base64-encoded keystore.`);
26151
+ storeFile = await files.write(`${name}.keystore`, contents);
26152
+ }
26153
+ return {
26154
+ storeFile,
26155
+ storePassword: storePassword.value,
26156
+ keyAlias: keyAlias.value,
26157
+ keyPassword: keyPassword.value
26158
+ };
26159
+ },
26160
+ "play-service-account": async ([serviceAccount]) => {
26161
+ const json = serviceAccount.name === "HOT_UPDATER_PLAY_SERVICE_ACCOUNT_PATH" ? await fs$2.readFile(await readFileField("play-service-account", serviceAccount), "utf-8") : serviceAccount.value;
26162
+ const parsed = parseServiceAccount(json);
26163
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new CredentialUnavailableError("play-service-account", `${serviceAccount.name} is not a service account JSON object.`);
26164
+ return { json };
26165
+ }
26166
+ };
26167
+ /** Fields of a service account that are no secret: its type and Google's endpoints. */
25829
26168
  /**
25830
- * The private key the `secrets` adapter holds as `name`: masked in the CLI's
25831
- * output before it is parsed, and kept only as a key object. A PEM written
25832
- * on one line, with `\n` for its line breaks, as a `.env.hotupdater` value
25833
- * or a CI variable may hold it, reads as the PEM.
26169
+ * Fields of a JSON credential that identify it rather than grant access:
26170
+ * its IDs, key alias, and a service account's email, project, type, and
26171
+ * Google's endpoints. They stay readable, as messages name them, and a
26172
+ * short one, such as the alias `release`, would mask every word it is in.
25834
26173
  */
25835
- const loadPrivateKeySecret = async (secrets, name, cwd) => {
25836
- let value;
25837
- try {
25838
- const read = await secrets.get([name], { cwd });
25839
- value = Object.hasOwn(read, name) ? read[name] : void 0;
25840
- } catch (error) {
25841
- const reason = error instanceof Error ? error.message : String(error);
25842
- throw new LocalSigningKeyError(`The ${secrets.name} secrets adapter could not read the bundle signing key ${name}: ${reason}`, { cause: error });
25843
- }
25844
- if (typeof value !== "string" || !value.trim()) throw new LocalSigningKeyError(`signing.privateKey reads the bundle signing key ${name}, which is not in the ${secrets.name} secrets adapter (${describeSecret(secrets, name)}). Set it to the RSA private key in PEM, or generate a key pair with \`npx hot-updater keys generate --secret ${name}\`.`);
26174
+ const isIdentifierField = (field) => [
26175
+ "keyId",
26176
+ "issuerId",
26177
+ "keyAlias",
26178
+ "type",
26179
+ "project_id",
26180
+ "client_email",
26181
+ "client_id",
26182
+ "private_key_id",
26183
+ "universe_domain"
26184
+ ].includes(field) || field.endsWith("_uri") || field.endsWith("_url");
26185
+ const registerJsonSecrets = (value, fields) => {
25845
26186
  registerSecretValue(value);
25846
- const pem = value.includes("\n") ? value : value.replaceAll("\\n", "\n");
25847
- if (pem !== value) registerSecretValue(pem);
25848
- try {
25849
- return parsePrivateKey(pem);
25850
- } catch {
25851
- throw new LocalSigningKeyError(`The bundle signing key ${name} in the ${secrets.name} secrets adapter is not an RSA private key in PEM of at least 2048 bits.`);
26187
+ for (const [field, entry] of Object.entries(fields)) if (typeof entry === "string" && !isIdentifierField(field)) registerSecretValue(entry);
26188
+ };
26189
+ /**
26190
+ * Masks a credential's JSON value in the CLI's output: the whole value, and
26191
+ * each field but those that identify it, such as `keyId` and `keyAlias`,
26192
+ * so a password or a key a log prints alone stays masked. A value that is
26193
+ * no JSON object is masked whole.
26194
+ */
26195
+ const registerCredentialValue = (value) => {
26196
+ const parsed = parseObject(value);
26197
+ if (parsed) registerJsonSecrets(value, parsed);
26198
+ else registerSecretValue(value);
26199
+ };
26200
+ const parseObject = (value) => {
26201
+ const parsed = parseServiceAccount(value);
26202
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : void 0;
26203
+ };
26204
+ /**
26205
+ * The fields of a JSON credential, each a non-empty string, or an error
26206
+ * naming the shape `kind` takes.
26207
+ */
26208
+ const readJsonFields = (kind, item, value, fields) => {
26209
+ const parsed = parseObject(value);
26210
+ const shape = `{${fields.map((field) => `"${field}"`).join(", ")}}`;
26211
+ if (!parsed) throw new CredentialUnavailableError(kind, `${item} is not a JSON object.\n${capitalize(aKind(kind))} is one JSON value: ${shape}`);
26212
+ registerJsonSecrets(value, parsed);
26213
+ const missing = fields.filter((field) => typeof parsed[field] !== "string" || !parsed[field]);
26214
+ if (missing.length > 0) throw new CredentialUnavailableError(kind, `${item} has no ${missing.join(", ")}.\n${capitalize(aKind(kind))} is one JSON value: ${shape}`);
26215
+ return parsed;
26216
+ };
26217
+ const aKind = (kind) => `${/^[aeiou]/u.test(kind) ? "an" : "a"} ${kind}`;
26218
+ const capitalize = (text) => text.charAt(0).toUpperCase() + text.slice(1);
26219
+ /** A base64 value's bytes, or undefined when it is not base64. */
26220
+ const decodeBase64 = (value) => {
26221
+ const base64 = value.replace(/\s/g, "");
26222
+ const contents = Buffer.from(base64, "base64");
26223
+ return contents.length > 0 && contents.toString("base64").replace(/=+$/, "") === base64.replace(/=+$/, "") ? contents : void 0;
26224
+ };
26225
+ /**
26226
+ * Each kind from the one string a secrets adapter holds for it: a JSON
26227
+ * object for the App Store Connect key (`p8` is the PEM) and the keystore
26228
+ * (`keystore` is base64), and the service account's own JSON. File values
26229
+ * are written with `files`; every value is masked.
26230
+ */
26231
+ const fromSecretValue = {
26232
+ "asc-api-key": async (value, { files, item }) => {
26233
+ const { keyId, issuerId, p8 } = readJsonFields("asc-api-key", item, value, [
26234
+ "keyId",
26235
+ "issuerId",
26236
+ "p8"
26237
+ ]);
26238
+ return {
26239
+ keyPath: await files.write(`AuthKey_${keyId}.p8`, p8.includes("\n") ? p8 : p8.replaceAll("\\n", "\n")),
26240
+ keyId,
26241
+ issuerId
26242
+ };
26243
+ },
26244
+ "android-keystore": async (value, { files, name, item }) => {
26245
+ const { keystore, storePassword, keyAlias, keyPassword } = readJsonFields("android-keystore", item, value, [
26246
+ "keystore",
26247
+ "storePassword",
26248
+ "keyAlias",
26249
+ "keyPassword"
26250
+ ]);
26251
+ const contents = decodeBase64(keystore);
26252
+ if (!contents) throw new CredentialUnavailableError("android-keystore", `${item}'s keystore is not a base64-encoded keystore.`);
26253
+ return {
26254
+ storeFile: await files.write(`${name}.keystore`, contents),
26255
+ storePassword,
26256
+ keyAlias,
26257
+ keyPassword
26258
+ };
26259
+ },
26260
+ "play-service-account": async (value, { item }) => {
26261
+ const parsed = parseObject(value);
26262
+ if (!parsed) throw new CredentialUnavailableError("play-service-account", `${item} is not a service account JSON object.`);
26263
+ registerJsonSecrets(value, parsed);
26264
+ return { json: value };
26265
+ }
26266
+ };
26267
+ const registerCredentialSecrets = (kind, credential) => {
26268
+ if (kind === "android-keystore") {
26269
+ const keystore = credential;
26270
+ registerSecretValue(keystore.storePassword);
26271
+ registerSecretValue(keystore.keyPassword);
26272
+ }
26273
+ if (kind === "play-service-account") {
26274
+ const { json } = credential;
26275
+ registerSecretValue(json);
26276
+ const privateKey = parseServiceAccount(json)?.private_key;
26277
+ if (typeof privateKey === "string") registerSecretValue(privateKey);
25852
26278
  }
25853
26279
  };
25854
26280
  /**
25855
- * The built-in local signer. Its private key is read on first use, once:
25856
- * from `privateKeyPath`, or from the config's `secrets` adapter for
25857
- * `privateKey: "secret:<name>"`, and is never written anywhere.
26281
+ * Reads names from `secrets`, the names asked for in one turn of the event
26282
+ * loop, such as a build's credentials, in one `get`. Nothing is cached.
25858
26283
  */
25859
- const createLocalSigningAdapter = (config, { secrets } = {}) => {
25860
- const privateKeys = /* @__PURE__ */ new Map();
25861
- const { privateKeyPath } = config;
25862
- const secretName = privateKeyPath === void 0 ? parseSecretReference(config.privateKey, "signing.privateKey") : void 0;
25863
- const getPrivateKey = (cwd = process.cwd()) => {
25864
- const cacheKey = privateKeyPath === void 0 ? "" : resolvePath$1(cwd, privateKeyPath);
25865
- const cached = privateKeys.get(cacheKey);
25866
- if (cached) return cached;
25867
- const pending = (secretName === void 0 ? loadPrivateKeyFile(cacheKey) : loadPrivateKeySecret(secrets ?? getConfiguredSecrets(), secretName, cwd)).catch((error) => {
25868
- privateKeys.delete(cacheKey);
25869
- throw error;
26284
+ const createSecretLoader = (secrets, cwd) => {
26285
+ let batch;
26286
+ const flush = async (names) => {
26287
+ try {
26288
+ const read = await secrets().get([...names.keys()], { cwd: cwd() });
26289
+ for (const [name, waiters] of names) {
26290
+ const value = Object.hasOwn(read, name) ? read[name] : void 0;
26291
+ for (const waiter of waiters) waiter.resolve(typeof value === "string" && value ? value : void 0);
26292
+ }
26293
+ } catch (error) {
26294
+ for (const waiters of names.values()) for (const waiter of waiters) waiter.reject(error);
26295
+ }
26296
+ };
26297
+ return (name) => new Promise((resolve, reject) => {
26298
+ if (batch === void 0) {
26299
+ const names = /* @__PURE__ */ new Map();
26300
+ batch = names;
26301
+ setTimeout(() => {
26302
+ batch = void 0;
26303
+ flush(names);
26304
+ }, 0);
26305
+ }
26306
+ const waiters = batch.get(name) ?? [];
26307
+ waiters.push({
26308
+ resolve,
26309
+ reject
26310
+ });
26311
+ batch.set(name, waiters);
26312
+ });
26313
+ };
26314
+ /**
26315
+ * Resolves each item from its environment overrides when any is set, and
26316
+ * otherwise from the config's `secrets` adapter as one value named by the
26317
+ * item, so overrides never reach the store. When the adapter fails or holds
26318
+ * no such value, the error names the environment variables that can
26319
+ * provide the item instead. Nothing is cached, values are never written to
26320
+ * disk except as 0600 temporary files, are never set in `process.env`, and
26321
+ * every secret is masked in logs.
26322
+ */
26323
+ const createCredentialResolver = ({ env = {}, secrets, cwd } = {}) => {
26324
+ const files = createCredentialFiles();
26325
+ const resolved = /* @__PURE__ */ new Map();
26326
+ let disposal;
26327
+ const adapter = () => typeof secrets === "function" ? secrets() : secrets ?? getConfiguredSecrets();
26328
+ const load = createSecretLoader(adapter, () => cwd ?? getCwd());
26329
+ const resolveItem = async (kind, name) => {
26330
+ const fields = CREDENTIAL_ENV_NAMES[kind];
26331
+ const values = fields.map((field) => readEnvField(env, field));
26332
+ if (values.some((value) => value !== void 0)) {
26333
+ const missing = fields.filter((_, index) => !values[index]);
26334
+ if (missing.length > 0) throw new CredentialUnavailableError(kind, `The environment overrides ${kind} but ${describeFields(missing)} ${missing.length === 1 ? "is" : "are"} not set.`);
26335
+ const credential = await fromEnv[kind](values, {
26336
+ files,
26337
+ name
26338
+ });
26339
+ registerCredentialSecrets(kind, credential);
26340
+ return credential;
26341
+ }
26342
+ const store = adapter();
26343
+ const item = name === kind ? kind : `${name} (${kind})`;
26344
+ let value;
26345
+ try {
26346
+ value = await load(name);
26347
+ } catch (error) {
26348
+ const reason = error instanceof Error ? error.message : String(error);
26349
+ throw new CredentialUnavailableError(kind, `Couldn't read ${item} from the ${store.name} secrets adapter.\n${reason}`, { cause: error });
26350
+ }
26351
+ if (value === void 0) throw new CredentialUnavailableError(kind, kind === "android-keystore" ? describeMissingKeystore(store, item, name) : `No ${item} in the ${store.name} secrets adapter.\nStore it once with:\n${formatCommand(credentialStoreCommand(kind, name))}`);
26352
+ registerSecretValue(value);
26353
+ return fromSecretValue[kind](value, {
26354
+ files,
26355
+ name,
26356
+ item: `The ${store.name} secrets adapter's ${item}`
25870
26357
  });
25871
- privateKeys.set(cacheKey, pending);
25872
- return pending;
25873
26358
  };
25874
26359
  return {
25875
- name: "localSigning",
25876
- async getPublicKey({ cwd } = {}) {
25877
- const privateKey = await getPrivateKey(cwd);
25878
- return { publicKey: crypto$1.createPublicKey(privateKey).export({
25879
- format: "pem",
25880
- type: "spki"
25881
- }).toString() };
26360
+ resolve: ({ kind, name = kind }) => {
26361
+ if (disposal) return Promise.reject(/* @__PURE__ */ new Error("The credential resolver has been disposed."));
26362
+ const key = `${kind}\u0000${name}`;
26363
+ let pending = resolved.get(key);
26364
+ if (!pending) {
26365
+ pending = resolveItem(kind, name);
26366
+ resolved.set(key, pending);
26367
+ pending.catch(() => resolved.delete(key));
26368
+ }
26369
+ return pending;
25882
26370
  },
25883
- async sign({ message, cwd }) {
25884
- if (!(message instanceof Uint8Array) || message.byteLength !== 32) throw new Error("Local bundle signing messages must be exactly 32 bytes.");
25885
- const privateKey = await getPrivateKey(cwd);
25886
- return { signature: crypto$1.sign("RSA-SHA256", message, privateKey) };
26371
+ dispose: () => {
26372
+ disposal ??= (async () => {
26373
+ await Promise.allSettled(resolved.values());
26374
+ resolved.clear();
26375
+ await files.dispose();
26376
+ })();
26377
+ return disposal;
25887
26378
  }
25888
26379
  };
25889
26380
  };
25890
26381
  /**
25891
- * The `secrets` adapter of the config each normalized `privateKey` config
25892
- * came from, so a config function's per-platform configs each read their
25893
- * own store, whichever config loaded last.
26382
+ * Runs `run` with a credential resolver and deletes its temporary files
26383
+ * when `run` settles.
25894
26384
  */
25895
- const signingSecrets = /* @__PURE__ */ new WeakMap();
25896
- /** The `secrets` adapter `normalizeSigningConfig` bound `signing` to. */
25897
- const getSigningSecrets = (signing) => signingSecrets.get(signing);
25898
- const invalidSigningConfig = () => /* @__PURE__ */ new Error("Bundle signing must be a local key config or signing adapter. Omit signing to disable it.");
25899
- const normalizeSigningConfig = (signing, { secrets } = {}) => {
25900
- if (signing === void 0) return void 0;
25901
- if (typeof signing !== "object" || signing === null) throw invalidSigningConfig();
25902
- const hasLocalFields = [
25903
- "enabled",
25904
- "privateKeyPath",
25905
- "privateKey"
25906
- ].some((key) => Reflect.has(signing, key));
25907
- const hasPluginMembers = [
25908
- "name",
25909
- "getPublicKey",
25910
- "sign"
25911
- ].some((key) => Reflect.has(signing, key));
25912
- if (hasLocalFields && hasPluginMembers) throw new Error("Bundle signing config cannot combine local signing fields with signing adapter fields.");
25913
- if (!hasPluginMembers) {
25914
- if (Object.keys(signing).some((key) => key !== "enabled" && key !== "privateKeyPath" && key !== "privateKey")) throw new Error("Local bundle signing accepts only enabled, and privateKeyPath or privateKey.");
25915
- const enabled = Reflect.get(signing, "enabled");
25916
- if (enabled === false) return void 0;
25917
- if (enabled !== true) throw invalidSigningConfig();
25918
- const privateKeyPath = Reflect.get(signing, "privateKeyPath");
25919
- const privateKey = Reflect.get(signing, "privateKey");
25920
- if (privateKeyPath !== void 0 && privateKey !== void 0) throw new Error("Local bundle signing takes its private key from privateKeyPath or from privateKey, not both.");
25921
- if (privateKey !== void 0) {
25922
- if (typeof privateKey !== "string") throw new Error("signing.privateKey names a secret as secret:<name>, such as \"secret:bundle-signing-key\".");
25923
- parseSecretReference(privateKey, "signing.privateKey");
25924
- const normalized = {
25925
- enabled: true,
25926
- privateKey
25927
- };
25928
- if (secrets) signingSecrets.set(normalized, secrets);
25929
- return normalized;
25930
- }
25931
- if (typeof privateKeyPath !== "string" || !privateKeyPath.trim()) throw new Error("Enabled local bundle signing requires privateKeyPath, or privateKey as \"secret:<name>\".");
25932
- return {
25933
- enabled: true,
25934
- privateKeyPath
25935
- };
26385
+ const withCredentialResolver = async (run, options) => {
26386
+ const resolver = createCredentialResolver(options);
26387
+ try {
26388
+ return await run(resolver);
26389
+ } finally {
26390
+ await resolver.dispose();
25936
26391
  }
25937
- if (typeof Reflect.get(signing, "name") !== "string" || typeof Reflect.get(signing, "getPublicKey") !== "function" || typeof Reflect.get(signing, "sign") !== "function") throw invalidSigningConfig();
25938
- return signing;
25939
26392
  };
25940
26393
  //#endregion
25941
26394
  //#region src/bundleSigning.ts
26395
+ /** The secret `signing: secret("bundle-signing-key")` names, which `secrets issue` creates. */
26396
+ const BUNDLE_SIGNING_KEY_SECRET = "bundle-signing-key";
26397
+ /** The command that creates and stores a bundle signing key. */
26398
+ const ISSUE_SIGNING_KEY_COMMAND = `npx hot-updater secrets issue ${BUNDLE_SIGNING_KEY_SECRET}`;
26399
+ /** The command that stores a bundle signing key you have. */
26400
+ const PUT_SIGNING_KEY_COMMAND = `npx hot-updater secrets put ${BUNDLE_SIGNING_KEY_SECRET} <key.pem>`;
26401
+ /** What deploy and doctor say when the config sets no `signing`. */
26402
+ const UNSIGNED_BUNDLES_WARNING = "Bundles are unsigned.";
26403
+ /** The next step after `UNSIGNED_BUNDLES_WARNING`. */
26404
+ const UNSIGNED_BUNDLES_NEXT_STEP = "To sign them, run:";
26405
+ /** What deploy and doctor say when `signing` is no shape it takes. */
26406
+ const INVALID_SIGNING_MESSAGE = `signing takes secret("${BUNDLE_SIGNING_KEY_SECRET}").`;
26407
+ /** The next step after `INVALID_SIGNING_MESSAGE`. */
26408
+ const INVALID_SIGNING_NEXT_STEP = "Store your key:";
26409
+ const invalid = (message) => ({
26410
+ status: "invalid",
26411
+ message
26412
+ });
26413
+ const LOCAL_FIELDS = [
26414
+ "enabled",
26415
+ "privateKeyPath",
26416
+ "privateKey"
26417
+ ];
26418
+ const readBundleSigningState = (signing) => {
26419
+ if (signing === void 0) return { status: "unsigned" };
26420
+ if (isSecretRef(signing)) return {
26421
+ status: "on",
26422
+ key: { secret: signing.name }
26423
+ };
26424
+ if (typeof signing !== "object" || signing === null || !Object.keys(signing).every((key) => LOCAL_FIELDS.includes(key))) return invalid(INVALID_SIGNING_MESSAGE);
26425
+ const { enabled, privateKey, privateKeyPath } = signing;
26426
+ if (enabled === false) return { status: "off" };
26427
+ if (enabled !== true) return invalid(INVALID_SIGNING_MESSAGE);
26428
+ if (privateKey !== void 0 && privateKeyPath !== void 0) return invalid("signing takes privateKey or privateKeyPath, not both.");
26429
+ if (privateKey !== void 0) return isSecretRef(privateKey) ? {
26430
+ status: "on",
26431
+ key: { secret: privateKey.name }
26432
+ } : invalid("signing.privateKey takes secret(\"<name>\").");
26433
+ if (typeof privateKeyPath !== "string" || !privateKeyPath.trim()) return invalid("signing takes privateKeyPath, or privateKey as secret(\"<name>\").");
26434
+ return {
26435
+ status: "on",
26436
+ key: { privateKeyPath }
26437
+ };
26438
+ };
26439
+ /** The `secret()` the config's `signing` reads its private key from, if any. */
26440
+ const bundleSigningSecretRef = (signing) => {
26441
+ if (isSecretRef(signing)) return signing;
26442
+ const privateKey = typeof signing === "object" && signing !== null ? Reflect.get(signing, "privateKey") : void 0;
26443
+ return isSecretRef(privateKey) ? privateKey : void 0;
26444
+ };
26445
+ /**
26446
+ * A bundle signing failure whose message the CLI prints as it is: it names
26447
+ * the secret and the next step, and quotes no key material.
26448
+ */
26449
+ var BundleSigningKeyError = class extends Error {
26450
+ constructor(message, options) {
26451
+ super(message, options);
26452
+ this.name = "HotUpdaterBundleSigningKeyError";
26453
+ }
26454
+ };
25942
26455
  const FILE_HASH_PATTERN = /^[a-f\d]{64}$/iu;
25943
26456
  const resolvePath = (cwd, filePath) => path$1.isAbsolute(filePath) ? filePath : path$1.resolve(cwd, filePath);
25944
26457
  const parseRsaPublicKey = (publicKeyPEM) => {
@@ -25952,7 +26465,8 @@ const parseRsaPublicKey = (publicKeyPEM) => {
25952
26465
  throw new Error("Bundle signing public key must be a valid RSA SPKI PEM key with a modulus of at least 2048 bits.");
25953
26466
  }
25954
26467
  };
25955
- const exportPublicKey = (publicKey) => publicKey.export({
26468
+ /** The SPKI PEM of `key`, a public key or the private key it derives from. */
26469
+ const exportPublicKey = (key) => (key.type === "private" ? crypto$1.createPublicKey(key) : key).export({
25956
26470
  type: "spki",
25957
26471
  format: "pem"
25958
26472
  }).toString();
@@ -25965,93 +26479,119 @@ const readBundleSigningPublicKeyFile = async (publicKeyPath, options = {}) => {
25965
26479
  throw new Error("Failed to read the bundle signing public key file.");
25966
26480
  }
25967
26481
  };
26482
+ const parsePrivateKey = (pem) => {
26483
+ const privateKey = crypto$1.createPrivateKey(pem);
26484
+ if (privateKey.asymmetricKeyType !== "rsa" || (privateKey.asymmetricKeyDetails?.modulusLength ?? 0) < 2048) throw new Error("not rsa");
26485
+ return privateKey;
26486
+ };
26487
+ const loadPrivateKeyFile = async (file) => {
26488
+ try {
26489
+ return parsePrivateKey(await fs$2.readFile(file, "utf8"));
26490
+ } catch {
26491
+ throw new BundleSigningKeyError("Failed to load the bundle signing private key from privateKeyPath.");
26492
+ }
26493
+ };
26494
+ /**
26495
+ * The private key the `secrets` adapter holds as `name`: masked in the CLI's
26496
+ * output before it is parsed, and kept only as a key object. A PEM written
26497
+ * on one line, with `\n` for its line breaks, as a `.env.hotupdater` value
26498
+ * or a CI variable may hold it, reads as the PEM.
26499
+ */
26500
+ const loadPrivateKeySecret = async (secrets, name, cwd) => {
26501
+ let value;
26502
+ try {
26503
+ const read = await secrets.get([name], { cwd });
26504
+ value = Object.hasOwn(read, name) ? read[name] : void 0;
26505
+ } catch (error) {
26506
+ const reason = error instanceof Error ? error.message : String(error);
26507
+ throw new BundleSigningKeyError(`Couldn't read ${name} from the ${secrets.name} secrets adapter.\n${reason}`, { cause: error });
26508
+ }
26509
+ if (typeof value !== "string" || !value.trim()) {
26510
+ const [missing, check] = getConfiguredAdapter(secrets) ? [`${name} isn't in the ${secrets.name} secrets adapter.`, "Check the name and your access, or import your key:"] : [`${name} isn't on this machine.`, `Check the name, set ${secretVariableName(name)}, or import your key:`];
26511
+ throw new BundleSigningKeyError([
26512
+ missing,
26513
+ check,
26514
+ formatCommand(`npx hot-updater secrets put ${name}${name === "bundle-signing-key" ? "" : ` --kind ${BUNDLE_SIGNING_KEY_SECRET}`} <key.pem>`)
26515
+ ].join("\n"));
26516
+ }
26517
+ registerSecretValue(value);
26518
+ const pem = value.includes("\n") ? value : value.replaceAll("\\n", "\n");
26519
+ if (pem !== value) registerSecretValue(pem);
26520
+ try {
26521
+ return parsePrivateKey(pem);
26522
+ } catch {
26523
+ throw new BundleSigningKeyError([`${name} in the ${secrets.name} secrets adapter is no signing key.`, "Store an RSA private key in PEM, of 2048 bits or more."].join("\n"));
26524
+ }
26525
+ };
25968
26526
  /**
25969
- * `remoteSigning`'s errors with a reason it wrote itself, by name, as it may
25970
- * come from another copy of plugin-core, and the local signer's errors that
25971
- * name its secret. Any other signing adapter error is redacted: it may quote
25972
- * key material.
26527
+ * The private keys each `signing` value has read, by file or by secrets
26528
+ * adapter, so the commands of one run that sign and derive the public key
26529
+ * read the key once.
25973
26530
  */
25974
- const REMOTE_SIGNING_ERROR_NAME = "HotUpdaterRemoteSigningError";
25975
- const signingFailure = (summary, error) => new Error(error instanceof Error && (error.name === REMOTE_SIGNING_ERROR_NAME || error instanceof LocalSigningKeyError) ? `${summary} ${error.message}` : summary);
26531
+ const privateKeys = /* @__PURE__ */ new WeakMap();
25976
26532
  /**
25977
- * The local signer of each config's `signing` that reads its private key
25978
- * from a secret, so the commands of one run that sign and derive the public
25979
- * key read the secret once.
26533
+ * The private key `signing` signs with, or null when it signs nothing.
26534
+ * Throws `INVALID_SIGNING_MESSAGE`, with the next step, for a value
26535
+ * `signing` doesn't take.
25980
26536
  */
25981
- const secretSigners = /* @__PURE__ */ new WeakMap();
25982
- const getSigningProvider = (signing) => {
25983
- const normalized = normalizeSigningConfig(signing);
25984
- if (!normalized) return null;
25985
- if (!("enabled" in normalized)) return normalized;
25986
- if (normalized.privateKey === void 0) return createLocalSigningAdapter(normalized);
25987
- const key = signing;
25988
- const cached = secretSigners.get(key);
26537
+ const loadSigningKey = (signing, { cwd = getCwd(), secrets }) => {
26538
+ const state = readBundleSigningState(signing);
26539
+ if (state.status === "invalid") throw new BundleSigningKeyError(`${state.message}\n${INVALID_SIGNING_NEXT_STEP} ${PUT_SIGNING_KEY_COMMAND}`);
26540
+ if (state.status !== "on") return null;
26541
+ const store = secrets ?? getConfiguredSecrets();
26542
+ const cacheKey = "secret" in state.key ? store : resolvePath(cwd, state.key.privateKeyPath);
26543
+ const cache = privateKeys.get(signing) ?? /* @__PURE__ */ new Map();
26544
+ privateKeys.set(signing, cache);
26545
+ const cached = cache.get(cacheKey);
25989
26546
  if (cached) return cached;
25990
- const local = createLocalSigningAdapter(normalized, { secrets: getSigningSecrets(key) });
25991
- secretSigners.set(key, local);
25992
- return local;
26547
+ const pending = ("secret" in state.key ? loadPrivateKeySecret(store, state.key.secret, cwd) : loadPrivateKeyFile(cacheKey)).catch((error) => {
26548
+ cache.delete(cacheKey);
26549
+ throw error;
26550
+ });
26551
+ cache.set(cacheKey, pending);
26552
+ return pending;
25993
26553
  };
25994
- const getProviderPublicKey = async (provider, cwd) => {
26554
+ const signingFailure = (summary, error) => new Error(error instanceof BundleSigningKeyError ? error.message : `${summary} ${error instanceof Error ? error.message : String(error)}`, { cause: error });
26555
+ /**
26556
+ * Prepares the run's bundle signing: the public key the app must embed, and
26557
+ * a signer of SHA-256 file hashes that signs each hash once. Null when
26558
+ * `signing` is omitted or `{ enabled: false }`.
26559
+ */
26560
+ const prepareBundleSigning = async (signing, options = {}) => {
26561
+ const loading = loadSigningKey(signing, options);
26562
+ if (!loading) return null;
26563
+ let privateKey;
25995
26564
  try {
25996
- const result = await provider.getPublicKey({ cwd });
25997
- if (!result || typeof result.publicKey !== "string") throw new Error("invalid result");
25998
- return parseRsaPublicKey(result.publicKey);
26565
+ privateKey = await loading;
25999
26566
  } catch (error) {
26000
- throw signingFailure("Failed to resolve the bundle signing provider public key.", error);
26567
+ throw signingFailure("Failed to read the bundle signing key.", error);
26001
26568
  }
26002
- };
26003
- const createMemoizedSigner = ({ publicKey, sign }) => {
26004
26569
  const signatures = /* @__PURE__ */ new Map();
26005
- return (fileHash) => {
26006
- if (!FILE_HASH_PATTERN.test(fileHash)) return Promise.reject(/* @__PURE__ */ new Error("Bundle signing requires a 64-character hexadecimal file hash."));
26007
- const normalizedFileHash = fileHash.toLowerCase();
26008
- const cached = signatures.get(normalizedFileHash);
26009
- if (cached) return cached;
26010
- const pending = (async () => {
26011
- const message = Buffer.from(normalizedFileHash, "hex");
26012
- let signature;
26013
- try {
26014
- signature = await sign(new Uint8Array(message));
26015
- } catch (error) {
26016
- throw signingFailure("Bundle signing provider failed to sign the file hash.", error);
26017
- }
26018
- if (!(signature instanceof Uint8Array) || signature.byteLength === 0) throw new Error("Bundle signing provider returned an invalid signature.");
26019
- if (!crypto$1.verify("RSA-SHA256", message, publicKey, signature)) throw new Error("Bundle signing provider returned a signature that does not match the configured public key.");
26020
- return Buffer.from(signature).toString("base64");
26021
- })().catch((error) => {
26022
- signatures.delete(normalizedFileHash);
26023
- throw error;
26024
- });
26025
- signatures.set(normalizedFileHash, pending);
26026
- return pending;
26027
- };
26028
- };
26029
- const preparePluginSigning = async (signing, cwd) => {
26030
- const providerPublicKey = await getProviderPublicKey(signing, cwd);
26031
26570
  return {
26032
- name: signing.name,
26033
- publicKey: exportPublicKey(providerPublicKey),
26034
- signFileHash: createMemoizedSigner({
26035
- publicKey: providerPublicKey,
26036
- sign: async (message) => {
26037
- return (await signing.sign({
26038
- cwd,
26039
- message
26040
- })).signature;
26041
- }
26042
- })
26571
+ publicKey: exportPublicKey(privateKey),
26572
+ signFileHash: async (fileHash) => {
26573
+ if (!FILE_HASH_PATTERN.test(fileHash)) throw new Error("Bundle signing requires a 64-character hexadecimal file hash.");
26574
+ const normalized = fileHash.toLowerCase();
26575
+ const cached = signatures.get(normalized);
26576
+ if (cached) return cached;
26577
+ const signature = crypto$1.sign("RSA-SHA256", Buffer.from(normalized, "hex"), privateKey).toString("base64");
26578
+ signatures.set(normalized, signature);
26579
+ return signature;
26580
+ }
26043
26581
  };
26044
26582
  };
26045
- const prepareBundleSigning = async (signing, options = {}) => {
26046
- const provider = getSigningProvider(signing);
26047
- if (!provider) return null;
26048
- return preparePluginSigning(provider, options.cwd ?? getCwd());
26049
- };
26050
- /** Resolves the public identity of the configured bundle signer. */
26583
+ /**
26584
+ * The public key of the configured bundle signing key, in SPKI PEM; null
26585
+ * when `signing` signs nothing.
26586
+ */
26051
26587
  const getBundleSigningPublicKey = async (signing, options = {}) => {
26052
- const provider = getSigningProvider(signing);
26053
- if (!provider) return null;
26054
- return exportPublicKey(await getProviderPublicKey(provider, options.cwd ?? getCwd()));
26588
+ const loading = loadSigningKey(signing, options);
26589
+ if (!loading) return null;
26590
+ try {
26591
+ return exportPublicKey(await loading);
26592
+ } catch (error) {
26593
+ throw signingFailure("Failed to read the bundle signing key.", error);
26594
+ }
26055
26595
  };
26056
26596
  //#endregion
26057
26597
  //#region src/ConfigBuilder.ts
@@ -38647,448 +39187,6 @@ const createZip = async ({ outfile, targetDir, excludeExts = [] }) => {
38647
39187
  return outfile;
38648
39188
  };
38649
39189
  //#endregion
38650
- //#region src/credentialFiles.ts
38651
- const TERMINATION_SIGNALS = [
38652
- "SIGHUP",
38653
- "SIGINT",
38654
- "SIGTERM"
38655
- ];
38656
- const liveDirectories = /* @__PURE__ */ new Set();
38657
- const onSignal = (signal) => {
38658
- removeLiveDirectories();
38659
- if (process.listenerCount(signal) === 0) process.kill(process.pid, signal);
38660
- };
38661
- const removeLiveDirectories = () => {
38662
- for (const directory of liveDirectories) rmSync(directory, {
38663
- force: true,
38664
- recursive: true
38665
- });
38666
- liveDirectories.clear();
38667
- stopListening();
38668
- };
38669
- const startListening = () => {
38670
- process.on("exit", removeLiveDirectories);
38671
- for (const signal of TERMINATION_SIGNALS) process.on(signal, onSignal);
38672
- };
38673
- const stopListening = () => {
38674
- process.off("exit", removeLiveDirectories);
38675
- for (const signal of TERMINATION_SIGNALS) process.off(signal, onSignal);
38676
- };
38677
- const trackDirectory = (directory) => {
38678
- if (liveDirectories.size === 0) startListening();
38679
- liveDirectories.add(directory);
38680
- };
38681
- const untrackDirectory = (directory) => {
38682
- if (liveDirectories.delete(directory) && liveDirectories.size === 0) stopListening();
38683
- };
38684
- /**
38685
- * Creates the temporary files of one credential resolver: a `mkdtemp`
38686
- * directory, created on the first write and removed by `dispose` or, if the
38687
- * process exits first, on `exit` or a termination signal.
38688
- */
38689
- const createCredentialFiles = () => {
38690
- let directory;
38691
- let disposal;
38692
- const ensureDirectory = () => {
38693
- directory ??= fs$2.mkdtemp(path$1.join(os.tmpdir(), "hot-updater-credentials-")).then((created) => {
38694
- trackDirectory(created);
38695
- return created;
38696
- });
38697
- return directory;
38698
- };
38699
- return {
38700
- write: async (fileName, contents) => {
38701
- if (disposal) throw new Error("The credential files have been disposed.");
38702
- if (typeof contents === "string") registerSecretValue(contents);
38703
- const fileDirectory = await fs$2.mkdtemp(path$1.join(await ensureDirectory(), "item-"));
38704
- const filePath = path$1.join(fileDirectory, path$1.basename(fileName));
38705
- await fs$2.writeFile(filePath, contents, {
38706
- flag: "wx",
38707
- mode: 384
38708
- });
38709
- return filePath;
38710
- },
38711
- dispose: () => {
38712
- disposal ??= (async () => {
38713
- const created = await directory?.catch(() => void 0);
38714
- if (!created) return;
38715
- await fs$2.rm(created, {
38716
- force: true,
38717
- recursive: true
38718
- });
38719
- untrackDirectory(created);
38720
- })();
38721
- return disposal;
38722
- }
38723
- };
38724
- };
38725
- //#endregion
38726
- //#region src/credentialResolver.ts
38727
- /** Passwords are read as set: trimming would change them. */
38728
- const UNTRIMMED_ENV_NAMES = /* @__PURE__ */ new Set(["HOT_UPDATER_ANDROID_KEYSTORE_PASSWORD", "HOT_UPDATER_ANDROID_KEY_PASSWORD"]);
38729
- const describeField = ([name, alternative]) => alternative ? `${name} (or ${alternative})` : name;
38730
- const describeFields = (fields) => {
38731
- const described = fields.map(describeField);
38732
- if (described.length <= 2) return described.join(" and ");
38733
- return `${described.slice(0, -1).join(", ")}, and ${described.at(-1)}`;
38734
- };
38735
- /**
38736
- * A command to print, broken at spaces with ` \` once a line would pass
38737
- * 74 columns, each continuation indented two spaces, so a terminal never
38738
- * wraps it mid-word and it still pastes as one command.
38739
- */
38740
- const formatCommand = (command) => {
38741
- const lines = [];
38742
- let line = "";
38743
- for (const word of command.split(" ")) {
38744
- const next = line ? `${line} ${word}` : word;
38745
- if (line && next.length > 72) {
38746
- lines.push(`${line} \\`);
38747
- line = ` ${word}`;
38748
- } else line = next;
38749
- }
38750
- lines.push(line);
38751
- return lines.join("\n");
38752
- };
38753
- /**
38754
- * `lead` and the command it names on one line when they fit 77 columns,
38755
- * else the command on the lines after it.
38756
- */
38757
- const withLead = (lead, command) => lead.length + 1 + command.length <= 77 ? `${lead} ${command}` : `${lead}\n${formatCommand(command)}`;
38758
- /**
38759
- * Why a build has no upload keystore, in three lines, steering away from a
38760
- * second key: Play rejects builds signed with a key it does not have.
38761
- * Under a team's adapter the keystore is someone's already, so it is
38762
- * imported, never created; only `localSecrets()` alone offers a new one.
38763
- */
38764
- const describeMissingKeystore = (store, item, name) => {
38765
- const configured = getConfiguredAdapter(store);
38766
- if (configured) return [
38767
- `No ${item} in ${describeSecret(configured, name)}.`,
38768
- "Ask whoever set it up to store it there, or import it with:",
38769
- "npx hot-updater google-play login"
38770
- ].join("\n");
38771
- return [
38772
- `No ${item} in the ${store.name} secrets adapter.`,
38773
- "Play rejects a new key if the app has one: import it with google-play login.",
38774
- withLead("Otherwise:", credentialStoreCommand("android-keystore", name))
38775
- ].join("\n");
38776
- };
38777
- /**
38778
- * The command that stores the key Hot Updater issues as `name`: an App
38779
- * Store Connect API key after an Apple Account sign-in, a Play service
38780
- * account after a Google one, and an upload keystore it creates.
38781
- */
38782
- const credentialIssueCommand = (kind, name = kind) => `npx hot-updater secrets issue ${kind}${name === kind ? "" : ` ${name}`}`;
38783
- /** The command group of each issued kind's shortcut, `<group> login`. */
38784
- const CREDENTIAL_LOGIN_GROUPS = {
38785
- "asc-api-key": "apple",
38786
- "play-service-account": "google-play"
38787
- };
38788
- /**
38789
- * The command people meet first that issues `kind` as `name`: its shortcut,
38790
- * `apple login` for an App Store Connect API key or `google-play login`
38791
- * for a Play service account, which takes nothing and stores the kind's
38792
- * own name; for another name, `credentialIssueCommand`.
38793
- */
38794
- const credentialLoginCommand = (kind, name = kind) => name === kind ? `npx hot-updater ${CREDENTIAL_LOGIN_GROUPS[kind]} login` : credentialIssueCommand(kind, name);
38795
- /**
38796
- * The command that stores a missing credential of `kind` as `name`: the
38797
- * upload keystore Hot Updater creates, or the key it issues after a
38798
- * sign-in.
38799
- */
38800
- const credentialStoreCommand = (kind, name = kind) => kind === "android-keystore" ? credentialIssueCommand(kind, name) : credentialLoginCommand(kind, name);
38801
- var CredentialUnavailableError = class extends Error {
38802
- kind;
38803
- /** The environment variables that can provide this credential. */
38804
- envNames;
38805
- constructor(kind, message, options) {
38806
- super(message, options);
38807
- this.name = "CredentialUnavailableError";
38808
- this.kind = kind;
38809
- this.envNames = CREDENTIAL_ENV_NAMES[kind].flat();
38810
- }
38811
- };
38812
- const readEnvField = (env, field) => {
38813
- for (const name of field) {
38814
- const value = UNTRIMMED_ENV_NAMES.has(name) ? (process.env[name] ?? env[name]) || void 0 : getHotUpdaterEnvValue(env, name);
38815
- if (value !== void 0) return {
38816
- name,
38817
- value
38818
- };
38819
- }
38820
- };
38821
- const readFileField = async (kind, { name, value }) => {
38822
- const filePath = path$1.resolve(value);
38823
- if (!await fs$2.stat(filePath).then(async (stats) => {
38824
- await fs$2.access(filePath, fs$2.constants.R_OK);
38825
- return stats.isFile();
38826
- }).catch(() => false)) throw new CredentialUnavailableError(kind, `${name} does not point to a readable file.`);
38827
- return filePath;
38828
- };
38829
- const parseServiceAccount = (json) => {
38830
- try {
38831
- return JSON.parse(json);
38832
- } catch {
38833
- return;
38834
- }
38835
- };
38836
- const fromEnv = {
38837
- "asc-api-key": async ([key, keyId, issuerId], { files }) => ({
38838
- keyPath: key.name === "HOT_UPDATER_ASC_KEY_PATH" ? await readFileField("asc-api-key", key) : await files.write(`AuthKey_${keyId.value}.p8`, key.value.includes("\n") ? key.value : key.value.replaceAll("\\n", "\n")),
38839
- keyId: keyId.value,
38840
- issuerId: issuerId.value
38841
- }),
38842
- "android-keystore": async ([keystore, storePassword, keyAlias, keyPassword], { files, name }) => {
38843
- let storeFile;
38844
- if (keystore.name === "HOT_UPDATER_ANDROID_KEYSTORE_PATH") storeFile = await readFileField("android-keystore", keystore);
38845
- else {
38846
- registerSecretValue(keystore.value);
38847
- const contents = decodeBase64(keystore.value);
38848
- if (!contents) throw new CredentialUnavailableError("android-keystore", `${keystore.name} is not a base64-encoded keystore.`);
38849
- storeFile = await files.write(`${name}.keystore`, contents);
38850
- }
38851
- return {
38852
- storeFile,
38853
- storePassword: storePassword.value,
38854
- keyAlias: keyAlias.value,
38855
- keyPassword: keyPassword.value
38856
- };
38857
- },
38858
- "play-service-account": async ([serviceAccount]) => {
38859
- const json = serviceAccount.name === "HOT_UPDATER_PLAY_SERVICE_ACCOUNT_PATH" ? await fs$2.readFile(await readFileField("play-service-account", serviceAccount), "utf-8") : serviceAccount.value;
38860
- const parsed = parseServiceAccount(json);
38861
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new CredentialUnavailableError("play-service-account", `${serviceAccount.name} is not a service account JSON object.`);
38862
- return { json };
38863
- }
38864
- };
38865
- /** Fields of a service account that are no secret: its type and Google's endpoints. */
38866
- /**
38867
- * Fields of a JSON credential that identify it rather than grant access:
38868
- * its IDs, key alias, and a service account's email, project, type, and
38869
- * Google's endpoints. They stay readable, as messages name them, and a
38870
- * short one, such as the alias `release`, would mask every word it is in.
38871
- */
38872
- const isIdentifierField = (field) => [
38873
- "keyId",
38874
- "issuerId",
38875
- "keyAlias",
38876
- "type",
38877
- "project_id",
38878
- "client_email",
38879
- "client_id",
38880
- "private_key_id",
38881
- "universe_domain"
38882
- ].includes(field) || field.endsWith("_uri") || field.endsWith("_url");
38883
- const registerJsonSecrets = (value, fields) => {
38884
- registerSecretValue(value);
38885
- for (const [field, entry] of Object.entries(fields)) if (typeof entry === "string" && !isIdentifierField(field)) registerSecretValue(entry);
38886
- };
38887
- /**
38888
- * Masks a credential's JSON value in the CLI's output: the whole value, and
38889
- * each field but those that identify it, such as `keyId` and `keyAlias`,
38890
- * so a password or a key a log prints alone stays masked. A value that is
38891
- * no JSON object is masked whole.
38892
- */
38893
- const registerCredentialValue = (value) => {
38894
- const parsed = parseObject(value);
38895
- if (parsed) registerJsonSecrets(value, parsed);
38896
- else registerSecretValue(value);
38897
- };
38898
- const parseObject = (value) => {
38899
- const parsed = parseServiceAccount(value);
38900
- return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : void 0;
38901
- };
38902
- /**
38903
- * The fields of a JSON credential, each a non-empty string, or an error
38904
- * naming the shape `kind` takes.
38905
- */
38906
- const readJsonFields = (kind, item, value, fields) => {
38907
- const parsed = parseObject(value);
38908
- const shape = `{${fields.map((field) => `"${field}"`).join(", ")}}`;
38909
- if (!parsed) throw new CredentialUnavailableError(kind, `${item} is not a JSON object.\n${capitalize(aKind(kind))} is one JSON value: ${shape}`);
38910
- registerJsonSecrets(value, parsed);
38911
- const missing = fields.filter((field) => typeof parsed[field] !== "string" || !parsed[field]);
38912
- if (missing.length > 0) throw new CredentialUnavailableError(kind, `${item} has no ${missing.join(", ")}.\n${capitalize(aKind(kind))} is one JSON value: ${shape}`);
38913
- return parsed;
38914
- };
38915
- const aKind = (kind) => `${/^[aeiou]/u.test(kind) ? "an" : "a"} ${kind}`;
38916
- const capitalize = (text) => text.charAt(0).toUpperCase() + text.slice(1);
38917
- /** A base64 value's bytes, or undefined when it is not base64. */
38918
- const decodeBase64 = (value) => {
38919
- const base64 = value.replace(/\s/g, "");
38920
- const contents = Buffer.from(base64, "base64");
38921
- return contents.length > 0 && contents.toString("base64").replace(/=+$/, "") === base64.replace(/=+$/, "") ? contents : void 0;
38922
- };
38923
- /**
38924
- * Each kind from the one string a secrets adapter holds for it: a JSON
38925
- * object for the App Store Connect key (`p8` is the PEM) and the keystore
38926
- * (`keystore` is base64), and the service account's own JSON. File values
38927
- * are written with `files`; every value is masked.
38928
- */
38929
- const fromSecretValue = {
38930
- "asc-api-key": async (value, { files, item }) => {
38931
- const { keyId, issuerId, p8 } = readJsonFields("asc-api-key", item, value, [
38932
- "keyId",
38933
- "issuerId",
38934
- "p8"
38935
- ]);
38936
- return {
38937
- keyPath: await files.write(`AuthKey_${keyId}.p8`, p8.includes("\n") ? p8 : p8.replaceAll("\\n", "\n")),
38938
- keyId,
38939
- issuerId
38940
- };
38941
- },
38942
- "android-keystore": async (value, { files, name, item }) => {
38943
- const { keystore, storePassword, keyAlias, keyPassword } = readJsonFields("android-keystore", item, value, [
38944
- "keystore",
38945
- "storePassword",
38946
- "keyAlias",
38947
- "keyPassword"
38948
- ]);
38949
- const contents = decodeBase64(keystore);
38950
- if (!contents) throw new CredentialUnavailableError("android-keystore", `${item}'s keystore is not a base64-encoded keystore.`);
38951
- return {
38952
- storeFile: await files.write(`${name}.keystore`, contents),
38953
- storePassword,
38954
- keyAlias,
38955
- keyPassword
38956
- };
38957
- },
38958
- "play-service-account": async (value, { item }) => {
38959
- const parsed = parseObject(value);
38960
- if (!parsed) throw new CredentialUnavailableError("play-service-account", `${item} is not a service account JSON object.`);
38961
- registerJsonSecrets(value, parsed);
38962
- return { json: value };
38963
- }
38964
- };
38965
- const registerCredentialSecrets = (kind, credential) => {
38966
- if (kind === "android-keystore") {
38967
- const keystore = credential;
38968
- registerSecretValue(keystore.storePassword);
38969
- registerSecretValue(keystore.keyPassword);
38970
- }
38971
- if (kind === "play-service-account") {
38972
- const { json } = credential;
38973
- registerSecretValue(json);
38974
- const privateKey = parseServiceAccount(json)?.private_key;
38975
- if (typeof privateKey === "string") registerSecretValue(privateKey);
38976
- }
38977
- };
38978
- /**
38979
- * Reads names from `secrets`, the names asked for in one turn of the event
38980
- * loop, such as a build's credentials, in one `get`. Nothing is cached.
38981
- */
38982
- const createSecretLoader = (secrets, cwd) => {
38983
- let batch;
38984
- const flush = async (names) => {
38985
- try {
38986
- const read = await secrets().get([...names.keys()], { cwd: cwd() });
38987
- for (const [name, waiters] of names) {
38988
- const value = Object.hasOwn(read, name) ? read[name] : void 0;
38989
- for (const waiter of waiters) waiter.resolve(typeof value === "string" && value ? value : void 0);
38990
- }
38991
- } catch (error) {
38992
- for (const waiters of names.values()) for (const waiter of waiters) waiter.reject(error);
38993
- }
38994
- };
38995
- return (name) => new Promise((resolve, reject) => {
38996
- if (batch === void 0) {
38997
- const names = /* @__PURE__ */ new Map();
38998
- batch = names;
38999
- setTimeout(() => {
39000
- batch = void 0;
39001
- flush(names);
39002
- }, 0);
39003
- }
39004
- const waiters = batch.get(name) ?? [];
39005
- waiters.push({
39006
- resolve,
39007
- reject
39008
- });
39009
- batch.set(name, waiters);
39010
- });
39011
- };
39012
- /**
39013
- * Resolves each item from its environment overrides when any is set, and
39014
- * otherwise from the config's `secrets` adapter as one value named by the
39015
- * item, so overrides never reach the store. When the adapter fails or holds
39016
- * no such value, the error names the environment variables that can
39017
- * provide the item instead. Nothing is cached, values are never written to
39018
- * disk except as 0600 temporary files, are never set in `process.env`, and
39019
- * every secret is masked in logs.
39020
- */
39021
- const createCredentialResolver = ({ env = {}, secrets, cwd } = {}) => {
39022
- const files = createCredentialFiles();
39023
- const resolved = /* @__PURE__ */ new Map();
39024
- let disposal;
39025
- const adapter = () => typeof secrets === "function" ? secrets() : secrets ?? getConfiguredSecrets();
39026
- const load = createSecretLoader(adapter, () => cwd ?? getCwd());
39027
- const resolveItem = async (kind, name) => {
39028
- const fields = CREDENTIAL_ENV_NAMES[kind];
39029
- const values = fields.map((field) => readEnvField(env, field));
39030
- if (values.some((value) => value !== void 0)) {
39031
- const missing = fields.filter((_, index) => !values[index]);
39032
- if (missing.length > 0) throw new CredentialUnavailableError(kind, `The environment overrides ${kind} but ${describeFields(missing)} ${missing.length === 1 ? "is" : "are"} not set.`);
39033
- const credential = await fromEnv[kind](values, {
39034
- files,
39035
- name
39036
- });
39037
- registerCredentialSecrets(kind, credential);
39038
- return credential;
39039
- }
39040
- const store = adapter();
39041
- const item = name === kind ? kind : `${name} (${kind})`;
39042
- let value;
39043
- try {
39044
- value = await load(name);
39045
- } catch (error) {
39046
- const reason = error instanceof Error ? error.message : String(error);
39047
- throw new CredentialUnavailableError(kind, `Couldn't read ${item} from the ${store.name} secrets adapter.\n${reason}`, { cause: error });
39048
- }
39049
- if (value === void 0) throw new CredentialUnavailableError(kind, kind === "android-keystore" ? describeMissingKeystore(store, item, name) : `No ${item} in the ${store.name} secrets adapter.\nStore it once with:\n${formatCommand(credentialStoreCommand(kind, name))}`);
39050
- registerSecretValue(value);
39051
- return fromSecretValue[kind](value, {
39052
- files,
39053
- name,
39054
- item: `The ${store.name} secrets adapter's ${item}`
39055
- });
39056
- };
39057
- return {
39058
- resolve: ({ kind, name = kind }) => {
39059
- if (disposal) return Promise.reject(/* @__PURE__ */ new Error("The credential resolver has been disposed."));
39060
- const key = `${kind}\u0000${name}`;
39061
- let pending = resolved.get(key);
39062
- if (!pending) {
39063
- pending = resolveItem(kind, name);
39064
- resolved.set(key, pending);
39065
- pending.catch(() => resolved.delete(key));
39066
- }
39067
- return pending;
39068
- },
39069
- dispose: () => {
39070
- disposal ??= (async () => {
39071
- await Promise.allSettled(resolved.values());
39072
- resolved.clear();
39073
- await files.dispose();
39074
- })();
39075
- return disposal;
39076
- }
39077
- };
39078
- };
39079
- /**
39080
- * Runs `run` with a credential resolver and deletes its temporary files
39081
- * when `run` settles.
39082
- */
39083
- const withCredentialResolver = async (run, options) => {
39084
- const resolver = createCredentialResolver(options);
39085
- try {
39086
- return await run(resolver);
39087
- } finally {
39088
- await resolver.dispose();
39089
- }
39090
- };
39091
- //#endregion
39092
39190
  //#region src/isObject.ts
39093
39191
  const isObject$2 = (value) => {
39094
39192
  return !!value && value.constructor === Object;
@@ -45557,19 +45655,6 @@ const MANAGED_HELPER_NAMES = /* @__PURE__ */ new Set([
45557
45655
  "deploySecrets",
45558
45656
  "storageSecrets"
45559
45657
  ]);
45560
- /**
45561
- * `const <name> = secretEnv([...names])`, a helper a managed config reads
45562
- * credentials with: from the environment and `.env.hotupdater` first, then
45563
- * from the config's `secrets` adapter. `storageSecrets` and
45564
- * `databaseSecrets` read each adapter's own, so a command that reaches only
45565
- * one needs only its credentials; `deploySecrets` reads what both share.
45566
- * Its builder imports `secretEnv` from `hot-updater`.
45567
- */
45568
- const secretEnvHelper = (name, names) => ({
45569
- name,
45570
- strategy: "replace",
45571
- code: `const ${name} = secretEnv([\n${names.map((variable) => ` ${JSON.stringify(variable)},`).join("\n")}\n]);`
45572
- });
45573
45658
  const KNOWN_BUILD_CALLEES = /* @__PURE__ */ new Set([
45574
45659
  "bare",
45575
45660
  "expo",
@@ -45646,13 +45731,16 @@ const isHotUpdaterEnvRead = (expression) => {
45646
45731
  const read = expression.type === "TSNonNullExpression" ? expression.expression : expression;
45647
45732
  return read.type === "MemberExpression" && !read.computed && read.property.type === "Identifier" && read.property.name.startsWith("HOT_UPDATER_") && read.object.type === "MemberExpression" && !read.object.computed && read.object.object.type === "Identifier" && read.object.object.name === "process" && read.object.property.type === "Identifier" && read.object.property.name === "env";
45648
45733
  };
45734
+ /** `secret("<name>")`, the credential init writes. */
45735
+ const isSecretCall = (expression) => expression.type === "CallExpression" && expression.callee.type === "Identifier" && expression.callee.name === "secret" && expression.arguments.length === 1 && expression.arguments[0]?.type === "Literal" && typeof expression.arguments[0].value === "string";
45649
45736
  /**
45650
45737
  * A value init writes into a managed adapter's options: a string, a
45651
- * `process.env.HOT_UPDATER_*` read, as configs of 1.1 read every value, or
45652
- * an object of only those, such as `credentials`. Init replaces it with
45653
- * what its run resolves; any other value is the project's own and kept.
45738
+ * `process.env.HOT_UPDATER_*` read, as configs of 1.1 read every value, a
45739
+ * `secret("<name>")`, or an object of only those, such as `credentials`.
45740
+ * Init replaces it with what its run resolves; any other value is the
45741
+ * project's own and kept.
45654
45742
  */
45655
- const isInitWrittenValue = (expression) => expression.type === "Literal" && typeof expression.value === "string" || isHotUpdaterEnvRead(expression) || expression.type === "ObjectExpression" && expression.properties.length > 0 && expression.properties.every((property) => isDataProperty(property) && isInitWrittenValue(property.value));
45743
+ const isInitWrittenValue = (expression) => expression.type === "Literal" && typeof expression.value === "string" || isHotUpdaterEnvRead(expression) || isSecretCall(expression) || expression.type === "ObjectExpression" && expression.properties.length > 0 && expression.properties.every((property) => isDataProperty(property) && isInitWrittenValue(property.value));
45656
45744
  const dedentBlock = (text) => {
45657
45745
  const lines = text.replace(/\s+$/, "").split("\n");
45658
45746
  const indents = lines.filter((line) => line.trim() !== "").map((line) => line.match(/^\s*/)?.[0].length ?? 0);
@@ -46217,6 +46305,22 @@ const readTextFile = (filePath) => fs$1.readFile(filePath, "utf-8").catch((error
46217
46305
  * existing config with the scaffold's build, storage, database, plugins, and
46218
46306
  * helpers merged in, keeping the project's own settings.
46219
46307
  */
46308
+ /**
46309
+ * Whether `hot-updater.config.ts` in `cwd` sets a team `secrets` adapter,
46310
+ * read from its text: a `secrets` property other than `localSecrets(...)`,
46311
+ * or a config whose shape init can't read. False without the file. Init
46312
+ * reads the store through the loaded config, so one that sets a store but
46313
+ * doesn't load must be fixed first.
46314
+ */
46315
+ const configSetsTeamSecrets = async (cwd) => {
46316
+ const text = await readTextFile(path.join(cwd, HOT_UPDATER_CONFIG_PATH));
46317
+ if (text === null) return false;
46318
+ const source = parseConfigSource(text);
46319
+ const config = source && findDefineConfigObject(source);
46320
+ if (!config) return true;
46321
+ const secrets = findAnyProperty(config.objectExpression, "secrets");
46322
+ return secrets !== null && getCallCallee(secrets.value) !== "localSecrets";
46323
+ };
46220
46324
  const writeHotUpdaterConfig = async (scaffold, filePath = HOT_UPDATER_CONFIG_PATH) => {
46221
46325
  const existingText = await readTextFile(filePath);
46222
46326
  if (existingText === null) {
@@ -46272,6 +46376,75 @@ const writeHotUpdaterFiles = async (scaffold, { cwd = process.cwd(), settings })
46272
46376
  ].join("\n"));
46273
46377
  return { config };
46274
46378
  };
46379
+ /**
46380
+ * The config text with `signing: secret("<name>")` added, and `secret` in
46381
+ * its `hot-updater` import, once. A config that sets `signing` in any form,
46382
+ * such as 1.1's `{ enabled: true, privateKeyPath }`, is kept.
46383
+ */
46384
+ const addSigningSecretToConfigText = (text, name) => {
46385
+ const source = parseConfigSource(text);
46386
+ const config = source && findDefineConfigObject(source);
46387
+ if (!source || !config) return { status: "unsupported" };
46388
+ const { objectExpression } = config;
46389
+ if (objectExpression.properties.some((property) => property.type === "SpreadElement" || getObjectPropertyName(property) === "signing")) return { status: "kept" };
46390
+ const edits = [];
46391
+ const property = `signing: secret(${JSON.stringify(name)})`;
46392
+ const last = objectExpression.properties.at(-1);
46393
+ if (!last) edits.push({
46394
+ start: objectExpression.start,
46395
+ end: objectExpression.end,
46396
+ text: `{\n ${property},\n}`
46397
+ });
46398
+ else {
46399
+ const lineStart = text.lastIndexOf("\n", last.start) + 1;
46400
+ const indent = /^[ \t]*/u.exec(text.slice(lineStart, last.start))[0];
46401
+ const between = text.slice(last.end, objectExpression.end - 1);
46402
+ const comma = /^\s*,/u.exec(between);
46403
+ let at = last.end + (comma?.[0].length ?? 0);
46404
+ const lineEnd = text.indexOf("\n", at);
46405
+ if (lineEnd !== -1 && text.slice(at, lineEnd).trim().startsWith("//")) at = lineEnd;
46406
+ edits.push({
46407
+ start: at,
46408
+ end: at,
46409
+ text: `\n${indent}${property},`
46410
+ });
46411
+ if (!comma) edits.push({
46412
+ start: last.end,
46413
+ end: last.end,
46414
+ text: ","
46415
+ });
46416
+ }
46417
+ const hotUpdaterImports = importDeclarationsOf(source).filter((declaration) => declaration.source.value === "hot-updater" && declaration.importKind !== "type");
46418
+ if (!hotUpdaterImports.some((declaration) => declaration.specifiers.some((specifier) => specifier.type === "ImportSpecifier" && specifier.local.name === "secret"))) {
46419
+ const named = hotUpdaterImports.find((declaration) => declaration.specifiers.some((specifier) => specifier.type === "ImportSpecifier"));
46420
+ const lastSpecifier = named?.specifiers.at(-1);
46421
+ edits.push(named && lastSpecifier ? {
46422
+ start: lastSpecifier.end,
46423
+ end: lastSpecifier.end,
46424
+ text: ", secret"
46425
+ } : {
46426
+ start: 0,
46427
+ end: 0,
46428
+ text: "import { secret } from \"hot-updater\";\n"
46429
+ });
46430
+ }
46431
+ return {
46432
+ status: "added",
46433
+ text: applyTextEdits(text, edits)
46434
+ };
46435
+ };
46436
+ /**
46437
+ * Adds `signing: secret("<name>")` to `hot-updater.config.ts` in `cwd`, as
46438
+ * `addSigningSecretToConfigText` does. Without the file, nothing is written.
46439
+ */
46440
+ const addSigningSecretToConfig = async (cwd, name) => {
46441
+ const filePath = path.join(cwd, HOT_UPDATER_CONFIG_PATH);
46442
+ const text = await readTextFile(filePath);
46443
+ if (text === null) return "missing";
46444
+ const result = addSigningSecretToConfigText(text, name);
46445
+ if (result.status === "added") await fs$1.writeFile(filePath, result.text, "utf-8");
46446
+ return result.status;
46447
+ };
46275
46448
  //#endregion
46276
46449
  //#region src/infrastructureGeneration.ts
46277
46450
  const assertInfrastructureGenerationPayload = ({ payload, provider, resource }) => {
@@ -47105,8 +47278,8 @@ const loadConfigFile = async () => {
47105
47278
  };
47106
47279
  /**
47107
47280
  * The config `options` select, resolved: a config function runs in a scope
47108
- * of its own, so the `secretEnv` it creates reads this config's `secrets`,
47109
- * and the file's top-level `secretEnv` reads the first config's.
47281
+ * of its own, so the `secret()` it creates reads this config's `secrets`,
47282
+ * and the file's top-level `secret()` reads the first config's.
47110
47283
  */
47111
47284
  const configFor = async ({ source, scope: fileScope }, options) => {
47112
47285
  if (typeof source !== "function") return resolveConfig(source, [fileScope]);
@@ -47134,31 +47307,48 @@ const loadPlatformConfigs = async (platforms, { channel }) => {
47134
47307
  return configs;
47135
47308
  };
47136
47309
  /**
47310
+ * The names the config's build credentials reference, which the CLI reads
47311
+ * for their kind: `signing`'s, and each scheme's
47312
+ * `signing.ascApiKey` and `signing.keystore`.
47313
+ */
47314
+ const buildSecretNames = (config) => {
47315
+ return [
47316
+ bundleSigningSecretRef(config.signing),
47317
+ ...Object.values(config.nativeBuild?.ios ?? {}).map((scheme) => scheme?.signing?.ascApiKey),
47318
+ ...Object.values(config.nativeBuild?.android ?? {}).map((scheme) => scheme?.signing?.keystore)
47319
+ ].filter(isSecretRef).map(({ name }) => name);
47320
+ };
47321
+ /**
47137
47322
  * The config with its defaults. Its `secrets` adapter fills `scopes` not yet
47138
- * filled, which the `secretEnv` its evaluation created read, and becomes the
47323
+ * filled, which the `secret()` its evaluation created read, and becomes the
47139
47324
  * default of credential resolvers.
47140
47325
  */
47141
47326
  const resolveConfig = (config, scopes) => {
47142
47327
  const mergedConfig = mergeConfigSources(config, getDefaultConfig());
47143
47328
  const secrets = resolveSecretsAdapter(mergedConfig.secrets);
47144
- const signing = normalizeSigningConfig(mergedConfig.signing, { secrets });
47145
- for (const scope of scopes) scope.secrets ??= secrets;
47329
+ const buildNames = buildSecretNames(mergedConfig);
47330
+ for (const scope of scopes) {
47331
+ scope.secrets ??= secrets;
47332
+ scope.buildNames ??= /* @__PURE__ */ new Set();
47333
+ for (const name of buildNames) scope.buildNames.add(name);
47334
+ }
47146
47335
  setConfiguredSecrets(secrets);
47147
47336
  const resolved = {
47148
47337
  ...mergedConfig,
47149
- signing,
47150
47338
  secrets
47151
47339
  };
47152
- Object.defineProperty(resolved, SECRET_ENV_NAMES, { value: [...new Set(scopes.flatMap((scope) => [...scope.names ?? []]))] });
47340
+ Object.defineProperty(resolved, DEPLOY_SECRET_NAMES, { value: [...new Set(scopes.flatMap((scope) => [...scope.names ?? []]))].filter((name) => !buildNames.includes(name)) });
47153
47341
  return resolved;
47154
47342
  };
47155
- const SECRET_ENV_NAMES = Symbol.for("@hot-updater/cli-tools/secret-env-names");
47343
+ const DEPLOY_SECRET_NAMES = Symbol.for("@hot-updater/cli-tools/deploy-secret-names");
47156
47344
  /**
47157
- * The names every `secretEnv` the config created while it loaded reads,
47158
- * such as `HOT_UPDATER_CLOUDFLARE_API_TOKEN`, in the order it created them.
47345
+ * The names the config's `secret()`s reference for its adapters, such as
47346
+ * `HOT_UPDATER_CLOUDFLARE_API_TOKEN`, in the order the config created
47347
+ * them: every name but those of build credentials, which `doctor` lists
47348
+ * by field.
47159
47349
  */
47160
- const getSecretEnvNames = (config) => {
47161
- const names = Reflect.get(config, SECRET_ENV_NAMES);
47350
+ const getDeploySecretNames = (config) => {
47351
+ const names = Reflect.get(config, DEPLOY_SECRET_NAMES);
47162
47352
  return Array.isArray(names) ? names : [];
47163
47353
  };
47164
47354
  //#endregion
@@ -47504,7 +47694,8 @@ const storeEnvFileSecrets = async (secrets, names, cwd, envFile) => {
47504
47694
  const file = envFile === void 0 ? await readLoadedHotUpdaterEnv(cwd) : await readLoadedEnvFile(envFile, cwd);
47505
47695
  const values = /* @__PURE__ */ new Map();
47506
47696
  for (const name of names) {
47507
- const value = Object.hasOwn(file, name) ? file[name]?.trim() : void 0;
47697
+ const variable = secretVariableName(name);
47698
+ const value = Object.hasOwn(file, variable) ? file[variable]?.trim() : void 0;
47508
47699
  if (value) values.set(name, value);
47509
47700
  }
47510
47701
  if (values.size === 0) return [];
@@ -47570,4 +47761,4 @@ function transformTemplate(templateString, values) {
47570
47761
  return result;
47571
47762
  }
47572
47763
  //#endregion
47573
- export { BuildFailedError, BuildLogger, CREDENTIAL_ENV_NAMES, CREDENTIAL_LOGIN_GROUPS, ConfigBuilder, CredentialUnavailableError, DEFAULT_SIGNING_KEY_SECRET, HOT_UPDATER_HOME_ENV, HOT_UPDATER_SERVER_PACKAGE_VERSION_ENV, HotUpdateDirUtil, INTERFACE_FINGERPRINT_ENGINE, InitEnvFileError, InitError, LOCAL_SECRETS_NAME, LegacyInfrastructureError, MANAGED_CLIENT_CREDENTIAL_ENV, MissingInitInputsError, ServerDefinitionError, assembleServer, assertInfrastructureGenerationAtUrl, assertInfrastructureGenerationPayload, assertInitInputs, assertInitProviderInputs, assertUnreservedEnv, autolinkingConfigCommand, banner, clientAuthOf, typedColors as colors, confirmInitInputPersistence, copyDirToTmp, createCredentialResolver, createHotUpdaterConfigScaffold, createHotUpdaterConfigScaffoldFromBuilder, createInterfaceFingerprint, createLocalSecretsStore, createLogWriter, createMigrator, createMinBundleId, createTarBrTargetFiles, createZip, credentialIssueCommand, credentialLoginCommand, credentialStoreCommand, decryptJson, defineInitProvider, describeSecret, describeStoredSecret, encryptJson, ensureInstallPackages, generateClientCredential, generateSchema, generatesSchema, getAndroidSdkPath, getBundleSigningPublicKey, getConfiguredAdapter, getConfiguredSecrets, getCwd, getHotUpdaterEnvValue, getHotUpdaterHome, getHotUpdaterInitInputEnv, getInitProviderEnvVars, getInitProviderTextPromptValues, getLocalKeyFile, getLocalKeyStoreDir, getLocalSecretsStore, getMissingInitInputs, getMissingInitProviderInputs, getPackageManager, getProjectKey, getReactNativeMetadatas, getSecretEnvNames, getStorageFileByteSize, isCI, isCodegenSpecFile, isReservedVariableName, isSecretName, isServerDefinition, keepsKeysOnThisMachine, link, loadConfig, loadPlatformConfigs, loadTeamSecrets, localSecrets, log, makeEnv, maskSecretValues, moduleSpecifiersOf, nonInteractiveReason, p, parseLoadedEnv, parseSecretReference, prepareBundleSigning, printAppSetup, printBanner, provisionClientCredential, putStorageFile, reactNativeCliFingerprint, reactNativeConfigCommand, readBundleSigningPublicKeyFile, readHotUpdaterInitEnv, readLoadedEnvFile, readLoadedHotUpdaterEnv, readLocalKey, readMissingFromTeamSecrets, readPackageUp, readReactNativeInterface, readSecretVariables, readSecretWithSource, readSecrets, registerCredentialValue, registerSecretValue, renderAppImports, renderAppSetup, renderImportStatements, resolveExpoAutolinking, resolveHotUpdaterServerVersion, resolveInitProviderInput, resolveInitProviderInputs, resolveLocalSecretsPath, resolvePackageVersion, resolveSecretsAdapter, secretEnv, secretEnvHelper, secretVariableName, serverDefinitionOf, setConfiguredSecrets, shouldAutoSelectOnlyInitResource, storeEnvFileSecrets, stripAnsi, transformEnv, transformTemplate, withCredentialResolver, withSecretsScope, writeHotUpdaterConfig, writeHotUpdaterFiles, writeLocalKey };
47764
+ export { BUNDLE_SIGNING_KEY_SECRET, BuildFailedError, BuildLogger, BundleSigningKeyError, CREDENTIAL_ENV_NAMES, CREDENTIAL_LOGIN_GROUPS, ConfigBuilder, CredentialUnavailableError, HOT_UPDATER_HOME_ENV, HOT_UPDATER_SERVER_PACKAGE_VERSION_ENV, HotUpdateDirUtil, INTERFACE_FINGERPRINT_ENGINE, INVALID_SIGNING_MESSAGE, INVALID_SIGNING_NEXT_STEP, ISSUE_SIGNING_KEY_COMMAND, InitEnvFileError, InitError, LOCAL_SECRETS_NAME, LegacyInfrastructureError, MANAGED_CLIENT_CREDENTIAL_ENV, MissingInitInputsError, PUT_SIGNING_KEY_COMMAND, ServerDefinitionError, UNSIGNED_BUNDLES_NEXT_STEP, UNSIGNED_BUNDLES_WARNING, addSigningSecretToConfig, addSigningSecretToConfigText, assembleServer, assertInfrastructureGenerationAtUrl, assertInfrastructureGenerationPayload, assertInitInputs, assertInitProviderInputs, assertUnreservedEnv, autolinkingConfigCommand, banner, bundleSigningSecretRef, clientAuthOf, typedColors as colors, configSetsTeamSecrets, confirmInitInputPersistence, copyDirToTmp, createCredentialResolver, createHotUpdaterConfigScaffold, createHotUpdaterConfigScaffoldFromBuilder, createInterfaceFingerprint, createLocalSecretsStore, createLogWriter, createMigrator, createMinBundleId, createStep, createTarBrTargetFiles, createZip, credentialIssueCommand, credentialLoginCommand, credentialPutCommand, credentialStoreCommand, decryptJson, defineInitProvider, describeSecret, describeStoredSecret, encryptJson, ensureInstallPackages, failActiveStep, formatCommand, formatDuration, generateClientCredential, generateSchema, generatesSchema, getAndroidSdkPath, getBundleSigningPublicKey, getConfiguredAdapter, getConfiguredSecrets, getCwd, getDeploySecretNames, getHotUpdaterEnvValue, getHotUpdaterHome, getHotUpdaterInitInputEnv, getInitProviderEnvVars, getInitProviderTextPromptValues, getLocalKeyFile, getLocalKeyStoreDir, getLocalSecretsStore, getMissingInitInputs, getMissingInitProviderInputs, getPackageManager, getProjectKey, getReactNativeMetadatas, getStorageFileByteSize, isCI, isCodegenSpecFile, isReservedVariableName, isSecretName, isSecretRef, isServerDefinition, keepsKeysOnThisMachine, link, loadConfig, loadPlatformConfigs, loadTeamSecrets, localSecrets, log, makeEnv, maskSecretValues, moduleSpecifiersOf, nonInteractiveReason, p, parseLoadedEnv, parseSecretReference, prepareBundleSigning, printAppSetup, printBanner, provisionClientCredential, putStorageFile, reactNativeCliFingerprint, reactNativeConfigCommand, readBundleSigningPublicKeyFile, readBundleSigningState, readHotUpdaterInitEnv, readLoadedEnvFile, readLoadedHotUpdaterEnv, readLocalKey, readMissingFromTeamSecrets, readPackageUp, readReactNativeInterface, readSecretVariables, readSecretWithSource, readSecrets, registerCredentialValue, registerSecretValue, renderAppImports, renderAppSetup, renderImportStatements, resolveExpoAutolinking, resolveHotUpdaterServerVersion, resolveInitProviderInput, resolveInitProviderInputs, resolveLocalSecretsPath, resolvePackageVersion, resolveSecretsAdapter, secret, secretVariableName, serverDefinitionOf, setConfiguredSecrets, shouldAutoSelectOnlyInitResource, startStep, storeEnvFileSecrets, stripAnsi, transformEnv, transformTemplate, truncateLine, withCredentialResolver, withDuration, withSecretsScope, withStep, writeHotUpdaterConfig, writeHotUpdaterFiles, writeLocalKey };