@hot-updater/cli-tools 1.2.0-rc.0 → 1.2.0-rc.10

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 +418 -114
  2. package/dist/index.mjs +1408 -981
  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,247 @@ 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
+ * and its failure fails the running step.
22996
+ */
22997
+ const createStep = (title, { output = process.stdout } = {}) => {
22998
+ let startedAt;
22999
+ let ended = false;
23000
+ let detail;
23001
+ let spinner;
23002
+ let ticker;
23003
+ let held = [];
23004
+ let outer;
23005
+ const liveMessage = () => {
23006
+ const text = maskSecretValues(detail ? `${title} · ${detail}` : title);
23007
+ const columns = output.columns || 80;
23008
+ return truncateLine(text, Math.max(20, columns - LIVE_LINE_CHROME));
23009
+ };
23010
+ const finish = (write) => {
23011
+ if (startedAt === void 0 || ended) return;
23012
+ ended = true;
23013
+ if (ticker) clearInterval(ticker);
23014
+ if (outer) {
23015
+ outer.update(void 0);
23016
+ return;
22952
23017
  }
23018
+ setStepCapture(void 0);
23019
+ if (spinner) spinner.clear();
23020
+ write();
23021
+ const logs = held;
23022
+ held = [];
23023
+ for (const log of logs) log();
23024
+ };
23025
+ const logOptions = () => ({
23026
+ output,
23027
+ spacing: spinner ? 0 : 1
23028
+ });
23029
+ const step = {
23030
+ start() {
23031
+ if (startedAt !== void 0) return step;
23032
+ startedAt = performance.now();
23033
+ outer = stepCapture();
23034
+ if (outer) {
23035
+ outer.update(title);
23036
+ return step;
23037
+ }
23038
+ setStepCapture({
23039
+ update: (value) => step.update(value),
23040
+ hold: (log) => {
23041
+ held.push(log);
23042
+ },
23043
+ interrupt: (message) => step.fail(message)
23044
+ });
23045
+ if (isLive(output)) {
23046
+ spinner = spinner$1({
23047
+ indicator: "timer",
23048
+ output
23049
+ });
23050
+ spinner.start(liveMessage());
23051
+ ticker = setInterval(() => spinner?.message(liveMessage()), 1e3);
23052
+ ticker.unref?.();
23053
+ }
23054
+ return step;
23055
+ },
23056
+ update(value) {
23057
+ if (startedAt === void 0 || ended) return;
23058
+ if (outer) {
23059
+ outer.update(value ? `${title} · ${value}` : title);
23060
+ return;
23061
+ }
23062
+ detail = value;
23063
+ spinner?.message(liveMessage());
23064
+ },
23065
+ succeed(message = title, { level = "step" } = {}) {
23066
+ const elapsed = step.elapsedMs;
23067
+ finish(() => {
23068
+ const line = maskSecretValues(withDuration(message, elapsed));
23069
+ if (level === "info") log$2.info(line, logOptions());
23070
+ else if (level === "warn") log$2.warn(line, logOptions());
23071
+ else log$2.step(line, logOptions());
23072
+ });
23073
+ },
23074
+ fail(message = `${title} failed`) {
23075
+ const elapsed = step.elapsedMs;
23076
+ if (outer && startedAt !== void 0 && !ended) {
23077
+ ended = true;
23078
+ outer.interrupt(message);
23079
+ return;
23080
+ }
23081
+ finish(() => {
23082
+ log$2.error(maskSecretValues(withDuration(message, elapsed)), logOptions());
23083
+ });
23084
+ },
23085
+ get elapsedMs() {
23086
+ return startedAt === void 0 ? 0 : performance.now() - startedAt;
23087
+ },
23088
+ get active() {
23089
+ return startedAt !== void 0 && !ended;
23090
+ }
23091
+ };
23092
+ return step;
23093
+ };
23094
+ /**
23095
+ * Ends the running step as failed, if one runs, before a command prints
23096
+ * its error.
23097
+ */
23098
+ const failActiveStep = () => {
23099
+ stepCapture()?.interrupt();
23100
+ };
23101
+ /** A step, started. */
23102
+ const startStep = (title, options) => createStep(title, options).start();
23103
+ /**
23104
+ * Runs `run` as a step titled `title`, which ends with `done(result)`, or as
23105
+ * failed when `run` throws.
23106
+ */
23107
+ const withStep = async (title, run, done = () => title, options) => {
23108
+ const step = startStep(title, options);
23109
+ try {
23110
+ const result = await run(step);
23111
+ step.succeed(done(result));
23112
+ return result;
23113
+ } catch (error) {
23114
+ step.fail();
23115
+ throw error;
22953
23116
  }
22954
- return nextCompletedStages;
22955
23117
  };
23118
+ //#endregion
23119
+ //#region src/BuildLogger.ts
22956
23120
  /** 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;
23121
+ const MAX_SUMMARY_LINES = 20;
23122
+ const TAIL_LINES = 15;
22959
23123
  const matchesSummaryPattern = (line, pattern) => {
22960
23124
  if (pattern instanceof RegExp) {
22961
23125
  pattern.lastIndex = 0;
@@ -22973,52 +23137,57 @@ var BuildFailedError = class extends Error {
22973
23137
  logPath;
22974
23138
  /** The lines of the tool's output the summary printed. */
22975
23139
  summary;
22976
- constructor(message, { logPath, summary, cause }) {
23140
+ /**
23141
+ * What to do about it, such as accepting an agreement, which the summary
23142
+ * doesn't say: the one line a command prints after it, or null.
23143
+ */
23144
+ hint;
23145
+ constructor(message, { logPath, summary, hint = null, cause }) {
22977
23146
  super(message, { cause });
22978
23147
  this.logPath = logPath;
22979
23148
  this.summary = summary;
23149
+ this.hint = hint;
22980
23150
  }
22981
23151
  };
22982
23152
  var BuildLogger = class {
22983
23153
  config;
22984
- state = createInitialState();
22985
23154
  logWriter;
23155
+ step;
22986
23156
  summaryLines = [];
22987
23157
  tailLines = [];
23158
+ lineCount = 0;
23159
+ stopped = false;
22988
23160
  openBlock;
22989
- promptProgress;
22990
- stageCount;
22991
23161
  constructor(config) {
22992
23162
  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
23163
  }
23000
- async start() {
23001
- this.state = {
23002
- ...createInitialState(),
23003
- started: true
23004
- };
23164
+ /**
23165
+ * Opens the log, writes `header` to it, such as the tool's command line,
23166
+ * which the terminal doesn't show, and starts the step titled `title`.
23167
+ */
23168
+ async start({ title, header = [] }) {
23005
23169
  this.summaryLines = [];
23006
23170
  this.tailLines = [];
23171
+ this.lineCount = 0;
23172
+ this.stopped = false;
23007
23173
  this.openBlock = void 0;
23008
- this.logWriter = await createLogWriter({ prefix: this.config.logPrefix });
23009
- this.promptProgress.start();
23174
+ this.logWriter = await createLogWriter({
23175
+ prefix: this.config.logPrefix,
23176
+ logFilePath: this.config.logFilePath
23177
+ });
23178
+ if (header.length) this.logWriter.writeLines(header);
23179
+ this.step = startStep(title);
23010
23180
  }
23011
23181
  /**
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.
23182
+ * Writes the tool's raw output to the log file, reads its failure summary
23183
+ * from it, and shows its phases on the step's line.
23015
23184
  */
23016
- async processStream(input, { display } = {}) {
23185
+ async processStream(input) {
23017
23186
  if (!this.logWriter) throw new Error("BuildLogger has not been started. Call start() first.");
23018
23187
  const progressInput = new PassThrough();
23019
23188
  const logInput = new PassThrough();
23020
23189
  const summaryInput = new PassThrough();
23021
- (display ?? input).pipe(progressInput);
23190
+ input.pipe(progressInput);
23022
23191
  input.pipe(logInput);
23023
23192
  input.pipe(summaryInput);
23024
23193
  await Promise.all([
@@ -23031,15 +23200,25 @@ var BuildLogger = class {
23031
23200
  get logPath() {
23032
23201
  return this.logWriter?.logFilePath ? path.relative(getCwd(), this.logWriter.logFilePath) : null;
23033
23202
  }
23203
+ /** The build log's absolute path, or null without one. */
23204
+ get logFilePath() {
23205
+ return this.logWriter?.logFilePath ?? null;
23206
+ }
23034
23207
  /**
23035
23208
  * The lines of the raw output a failure prints: the ones the config's
23036
- * `failureSummary` keeps, deduplicated, or the last lines without any.
23209
+ * `failureSummary` keeps, deduplicated, with its `tail` unless the output
23210
+ * reached `stopAfter`, or the last lines without any.
23037
23211
  */
23038
23212
  get failureSummary() {
23039
- const lines = this.summaryLines.length ? this.summaryLines : this.tailLines.filter((line) => line.trim());
23213
+ const { tail = 0, maxWidth } = this.config.failureSummary;
23214
+ const lastLines = this.tailLines.filter(({ line }) => line.trim());
23215
+ const kept = this.summaryLines;
23216
+ const keptText = new Set(kept.map(({ line }) => line));
23217
+ const lines = kept.length ? [...kept, ...(tail && !this.stopped ? lastLines.slice(-tail) : []).filter(({ line }) => !keptText.has(line))].sort((a, b) => a.index - b.index).map(({ line }) => line) : lastLines.slice(-15).map(({ line }) => line);
23040
23218
  const first = lines.findIndex((line) => line.trim());
23041
23219
  const last = lines.findLastIndex((line) => line.trim());
23042
- return first === -1 ? [] : lines.slice(first, last + 1);
23220
+ const summary = first === -1 ? [] : lines.slice(first, last + 1);
23221
+ return maxWidth ? summary.map((line) => truncateLine(line, maxWidth)) : summary;
23043
23222
  }
23044
23223
  async consumeSummaryStream(input) {
23045
23224
  const lineReader = createInterface({
@@ -23047,31 +23226,48 @@ var BuildLogger = class {
23047
23226
  crlfDelay: Infinity,
23048
23227
  terminal: false
23049
23228
  });
23050
- for await (const rawLine of lineReader) this.processSummaryLine(stripAnsi(rawLine).replace(/\r/g, "").trimEnd());
23229
+ for await (const rawLine of lineReader) {
23230
+ const parts = stripAnsi(rawLine).split("\r");
23231
+ const lines = parts.length > 1 ? parts.filter((part) => part.trim()) : parts;
23232
+ for (const line of lines) this.processSummaryLine(line.trimEnd());
23233
+ }
23051
23234
  }
23052
23235
  processSummaryLine(line) {
23053
- this.tailLines.push(line);
23054
- if (this.tailLines.length > TAIL_LINES) this.tailLines.shift();
23055
- const { lines, blocks } = this.config.failureSummary;
23236
+ const { lines, blocks, ignore = [], tail = 0, stopAfter } = this.config.failureSummary;
23056
23237
  const trimmedLine = line.trim();
23238
+ if (this.stopped || ignore.some((pattern) => matchesSummaryPattern(trimmedLine, pattern))) return;
23239
+ if (stopAfter && matchesSummaryPattern(trimmedLine, stopAfter)) {
23240
+ this.stopped = true;
23241
+ this.openBlock = void 0;
23242
+ }
23243
+ const index = this.lineCount++;
23244
+ this.tailLines.push({
23245
+ index,
23246
+ line
23247
+ });
23248
+ if (this.tailLines.length > Math.max(TAIL_LINES, tail)) this.tailLines.shift();
23057
23249
  if (this.openBlock) {
23058
23250
  if (matchesSummaryPattern(trimmedLine, this.openBlock.end)) {
23059
- if (this.openBlock.includeEnd) this.keepSummaryLine(line);
23251
+ if (this.openBlock.includeEnd) this.keepSummaryLine(index, line);
23060
23252
  this.openBlock = void 0;
23061
- } else this.keepSummaryLine(line);
23253
+ } else this.keepSummaryLine(index, line);
23062
23254
  return;
23063
23255
  }
23064
23256
  const block = blocks.find(({ start }) => matchesSummaryPattern(trimmedLine, start));
23065
23257
  if (block) {
23066
23258
  this.openBlock = block;
23067
- this.keepSummaryLine(line);
23259
+ this.keepSummaryLine(index, line);
23068
23260
  return;
23069
23261
  }
23070
- if (lines.some((pattern) => matchesSummaryPattern(trimmedLine, pattern))) this.keepSummaryLine(line);
23262
+ if (lines.some((pattern) => matchesSummaryPattern(trimmedLine, pattern))) this.keepSummaryLine(index, line);
23071
23263
  }
23072
- keepSummaryLine(line) {
23073
- if (this.summaryLines.length >= MAX_SUMMARY_LINES || line.trim() && this.summaryLines.includes(line)) return;
23074
- this.summaryLines.push(line);
23264
+ keepSummaryLine(index, line) {
23265
+ const { tail = 0 } = this.config.failureSummary;
23266
+ if (this.summaryLines.length >= MAX_SUMMARY_LINES - tail || line.trim() && this.summaryLines.some((kept) => kept.line === line)) return;
23267
+ this.summaryLines.push({
23268
+ index,
23269
+ line
23270
+ });
23075
23271
  }
23076
23272
  async consumeProgressStream(input) {
23077
23273
  const lineReader = createInterface({
@@ -23079,36 +23275,28 @@ var BuildLogger = class {
23079
23275
  crlfDelay: Infinity,
23080
23276
  terminal: false
23081
23277
  });
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
- });
23278
+ const readPhase = this.config.createPhaseReader();
23279
+ let phase;
23280
+ for await (const line of lineReader) {
23281
+ const next = readPhase(stripAnsi(line).replace(/\r/g, "").trim());
23282
+ if (next !== void 0 && next !== phase) {
23283
+ phase = next;
23284
+ this.step?.update(phase);
23285
+ }
23286
+ }
23096
23287
  }
23288
+ /**
23289
+ * Ends the step with `message` and its duration, or, for a failure, as
23290
+ * failed, then prints the failure summary and the log's path.
23291
+ */
23097
23292
  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);
23293
+ const step = this.step;
23294
+ if (!step?.active) return;
23295
+ if (success) step.succeed(message);
23296
+ else {
23297
+ step.fail(message);
23106
23298
  this.showFailureSummary();
23107
23299
  }
23108
- this.state = {
23109
- ...this.state,
23110
- stopped: true
23111
- };
23112
23300
  }
23113
23301
  writeError(error) {
23114
23302
  this.logWriter?.writeError(error);
@@ -23118,32 +23306,6 @@ var BuildLogger = class {
23118
23306
  await this.logWriter.close();
23119
23307
  this.logWriter = void 0;
23120
23308
  }
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
23309
  /**
23148
23310
  * Prints the failure summary and the log's path to stderr, so a `--json`
23149
23311
  * command's stdout stays its JSON.
@@ -23154,12 +23316,6 @@ var BuildLogger = class {
23154
23316
  const message = [...summary, ...logPath ? [...summary.length ? [""] : [], `Build log: ${logPath}`] : []].join("\n");
23155
23317
  if (message) p.log.error(message, { output: process.stderr });
23156
23318
  }
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
23319
  };
23164
23320
  //#endregion
23165
23321
  //#region src/appSetup.ts
@@ -25182,6 +25338,103 @@ const printBanner = (version) => {
25182
25338
  console.log(banner(version));
25183
25339
  };
25184
25340
  //#endregion
25341
+ //#region src/credentialEnvNames.ts
25342
+ /**
25343
+ * The environment variables that override each kind, one entry per field.
25344
+ * A field with two names takes a file path or the file's contents; the path
25345
+ * wins when both are set.
25346
+ */
25347
+ const CREDENTIAL_ENV_NAMES = {
25348
+ "asc-api-key": [
25349
+ ["HOT_UPDATER_ASC_KEY_PATH", "HOT_UPDATER_ASC_KEY"],
25350
+ ["HOT_UPDATER_ASC_KEY_ID"],
25351
+ ["HOT_UPDATER_ASC_ISSUER_ID"]
25352
+ ],
25353
+ "android-keystore": [
25354
+ ["HOT_UPDATER_ANDROID_KEYSTORE_PATH", "HOT_UPDATER_ANDROID_KEYSTORE_BASE64"],
25355
+ ["HOT_UPDATER_ANDROID_KEYSTORE_PASSWORD"],
25356
+ ["HOT_UPDATER_ANDROID_KEY_ALIAS"],
25357
+ ["HOT_UPDATER_ANDROID_KEY_PASSWORD"]
25358
+ ],
25359
+ "play-service-account": [["HOT_UPDATER_PLAY_SERVICE_ACCOUNT_PATH", "HOT_UPDATER_PLAY_SERVICE_ACCOUNT_JSON"]]
25360
+ };
25361
+ //#endregion
25362
+ //#region src/credentialFiles.ts
25363
+ const TERMINATION_SIGNALS = [
25364
+ "SIGHUP",
25365
+ "SIGINT",
25366
+ "SIGTERM"
25367
+ ];
25368
+ const liveDirectories = /* @__PURE__ */ new Set();
25369
+ const onSignal = (signal) => {
25370
+ removeLiveDirectories();
25371
+ if (process.listenerCount(signal) === 0) process.kill(process.pid, signal);
25372
+ };
25373
+ const removeLiveDirectories = () => {
25374
+ for (const directory of liveDirectories) rmSync(directory, {
25375
+ force: true,
25376
+ recursive: true
25377
+ });
25378
+ liveDirectories.clear();
25379
+ stopListening();
25380
+ };
25381
+ const startListening = () => {
25382
+ process.on("exit", removeLiveDirectories);
25383
+ for (const signal of TERMINATION_SIGNALS) process.on(signal, onSignal);
25384
+ };
25385
+ const stopListening = () => {
25386
+ process.off("exit", removeLiveDirectories);
25387
+ for (const signal of TERMINATION_SIGNALS) process.off(signal, onSignal);
25388
+ };
25389
+ const trackDirectory = (directory) => {
25390
+ if (liveDirectories.size === 0) startListening();
25391
+ liveDirectories.add(directory);
25392
+ };
25393
+ const untrackDirectory = (directory) => {
25394
+ if (liveDirectories.delete(directory) && liveDirectories.size === 0) stopListening();
25395
+ };
25396
+ /**
25397
+ * Creates the temporary files of one credential resolver: a `mkdtemp`
25398
+ * directory, created on the first write and removed by `dispose` or, if the
25399
+ * process exits first, on `exit` or a termination signal.
25400
+ */
25401
+ const createCredentialFiles = () => {
25402
+ let directory;
25403
+ let disposal;
25404
+ const ensureDirectory = () => {
25405
+ directory ??= fs$2.mkdtemp(path$1.join(os.tmpdir(), "hot-updater-credentials-")).then((created) => {
25406
+ trackDirectory(created);
25407
+ return created;
25408
+ });
25409
+ return directory;
25410
+ };
25411
+ return {
25412
+ write: async (fileName, contents) => {
25413
+ if (disposal) throw new Error("The credential files have been disposed.");
25414
+ if (typeof contents === "string") registerSecretValue(contents);
25415
+ const fileDirectory = await fs$2.mkdtemp(path$1.join(await ensureDirectory(), "item-"));
25416
+ const filePath = path$1.join(fileDirectory, path$1.basename(fileName));
25417
+ await fs$2.writeFile(filePath, contents, {
25418
+ flag: "wx",
25419
+ mode: 384
25420
+ });
25421
+ return filePath;
25422
+ },
25423
+ dispose: () => {
25424
+ disposal ??= (async () => {
25425
+ const created = await directory?.catch(() => void 0);
25426
+ if (!created) return;
25427
+ await fs$2.rm(created, {
25428
+ force: true,
25429
+ recursive: true
25430
+ });
25431
+ untrackDirectory(created);
25432
+ })();
25433
+ return disposal;
25434
+ }
25435
+ };
25436
+ };
25437
+ //#endregion
25185
25438
  //#region src/initOptions.ts
25186
25439
  var InitError = class extends Error {
25187
25440
  name = "InitError";
@@ -25247,9 +25500,17 @@ const readEnvFile = async (filePath, allowMissing) => {
25247
25500
  return parseEnv$1(content);
25248
25501
  };
25249
25502
  const getHotUpdaterInitInputEnv = ({ env, managedEnv }, nonInteractive) => nonInteractive ? env : managedEnv;
25250
- const readHotUpdaterInitEnv = async (cwd, envFile) => {
25503
+ /**
25504
+ * Init's inputs: `.env.hotupdater` (`managedEnv`), with `envFile`'s over
25505
+ * it (`env`). `secretsEnv`, the credentials init read from a team secrets
25506
+ * store, sits under both, as values the file would hold.
25507
+ */
25508
+ const readHotUpdaterInitEnv = async (cwd, envFile, secretsEnv = {}) => {
25251
25509
  const savedEnvPath = path.resolve(cwd, HOT_UPDATER_ENV_PATH);
25252
- const savedEnv = await readEnvFile(savedEnvPath, true);
25510
+ const savedEnv = {
25511
+ ...secretsEnv,
25512
+ ...await readEnvFile(savedEnvPath, true)
25513
+ };
25253
25514
  if (!envFile) return {
25254
25515
  env: {},
25255
25516
  managedEnv: savedEnv
@@ -25291,6 +25552,37 @@ const readLoadedHotUpdaterEnv = async (cwd) => {
25291
25552
  throw error;
25292
25553
  }
25293
25554
  };
25555
+ /**
25556
+ * `file`, an env file a command was given, relative to `cwd`, as
25557
+ * `parseLoadedEnv` reads it; a file that can't be read is refused by name.
25558
+ */
25559
+ const readLoadedEnvFile = async (file, cwd) => {
25560
+ try {
25561
+ return parseLoadedEnv(await fs$1.readFile(path.resolve(cwd, file), "utf-8"));
25562
+ } catch (error) {
25563
+ throw new Error(`Can't read ${file}.\n${error instanceof Error ? error.message : String(error)}`, { cause: error });
25564
+ }
25565
+ };
25566
+ //#endregion
25567
+ //#region src/messageLines.ts
25568
+ /** The widest line of a message the CLI prints, inside its 80-column frame. */
25569
+ const MESSAGE_WIDTH = 76;
25570
+ /**
25571
+ * A message of `lines`, each at most `MESSAGE_WIDTH` columns, broken at
25572
+ * spaces, so a name, a path, or a list it embeds never widens a line. A
25573
+ * word longer than a line, such as a URL or a path, gets a line of its
25574
+ * own. Lines that fit, such as an indented command, stay as they are.
25575
+ */
25576
+ const fitMessage = (lines) => lines.flatMap((line) => {
25577
+ if (line.length <= 76) return [line];
25578
+ const fitted = [];
25579
+ let current = "";
25580
+ for (const word of line.split(" ").filter(Boolean)) if (current && current.length + 1 + word.length > 76) {
25581
+ fitted.push(current);
25582
+ current = word;
25583
+ } else current = current ? `${current} ${word}` : word;
25584
+ return [...fitted, current];
25585
+ }).join("\n");
25294
25586
  //#endregion
25295
25587
  //#region src/localKeyStore.ts
25296
25588
  /**
@@ -25388,7 +25680,7 @@ const assertPrivateDirectory = async (directory) => {
25388
25680
  if (isMissing(error)) return void 0;
25389
25681
  throw error;
25390
25682
  });
25391
- if (stats && !stats.isDirectory()) throw new Error(`localSecrets() keeps secrets in a directory, and ${directory} is not one. Set its path to a directory.`);
25683
+ if (stats && !stats.isDirectory()) throw new Error(fitMessage([`localSecrets() can't keep secrets in ${directory}.`, "It is not a directory: set its path to one."]));
25392
25684
  const real = realpathThroughParents(directory);
25393
25685
  let probe = real;
25394
25686
  while (!existsSync(probe) && path$1.dirname(probe) !== probe) probe = path$1.dirname(probe);
@@ -25396,7 +25688,12 @@ const assertPrivateDirectory = async (directory) => {
25396
25688
  if (!root) return;
25397
25689
  const top = realpath(root);
25398
25690
  if (isIgnoredByGit(top, real)) return;
25399
- 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 }).`);
25691
+ throw new Error(fitMessage([
25692
+ `localSecrets() refuses ${directory}: its secrets could be committed.`,
25693
+ `The git repository ${top} doesn't ignore it.`,
25694
+ `Add ${path$1.relative(top, real).split(path$1.sep).join("/")}/ to its .gitignore,`,
25695
+ "or set localSecrets({ path })."
25696
+ ]));
25400
25697
  };
25401
25698
  /** Makes `directory` 0700, and every directory it creates on the way. */
25402
25699
  const makePrivateDirectory = async (directory) => {
@@ -25464,27 +25761,6 @@ const readLocalKey = (name, cwd) => defaultStore.read(name, cwd);
25464
25761
  /** Stores `value` as `name` in `localSecrets()`'s default store. */
25465
25762
  const writeLocalKey = (name, value, cwd) => defaultStore.write(name, value, cwd);
25466
25763
  //#endregion
25467
- //#region src/credentialEnvNames.ts
25468
- /**
25469
- * The environment variables that override each kind, one entry per field.
25470
- * A field with two names takes a file path or the file's contents; the path
25471
- * wins when both are set.
25472
- */
25473
- const CREDENTIAL_ENV_NAMES = {
25474
- "asc-api-key": [
25475
- ["HOT_UPDATER_ASC_KEY_PATH", "HOT_UPDATER_ASC_KEY"],
25476
- ["HOT_UPDATER_ASC_KEY_ID"],
25477
- ["HOT_UPDATER_ASC_ISSUER_ID"]
25478
- ],
25479
- "android-keystore": [
25480
- ["HOT_UPDATER_ANDROID_KEYSTORE_PATH", "HOT_UPDATER_ANDROID_KEYSTORE_BASE64"],
25481
- ["HOT_UPDATER_ANDROID_KEYSTORE_PASSWORD"],
25482
- ["HOT_UPDATER_ANDROID_KEY_ALIAS"],
25483
- ["HOT_UPDATER_ANDROID_KEY_PASSWORD"]
25484
- ],
25485
- "play-service-account": [["HOT_UPDATER_PLAY_SERVICE_ACCOUNT_PATH", "HOT_UPDATER_PLAY_SERVICE_ACCOUNT_JSON"]]
25486
- };
25487
- //#endregion
25488
25764
  //#region src/reservedVariables.ts
25489
25765
  /**
25490
25766
  * Variables that change how a process loads code or which credentials Hot
@@ -25504,7 +25780,6 @@ const RESERVED_NAMES = /* @__PURE__ */ new Set([
25504
25780
  "PYTHONSTARTUP",
25505
25781
  "PERL5OPT",
25506
25782
  ...Object.values(CREDENTIAL_ENV_NAMES).flat(2),
25507
- "HOT_UPDATER_SIGNING_TOKEN",
25508
25783
  "HOT_UPDATER_HOME"
25509
25784
  ]);
25510
25785
  const RESERVED_PREFIXES = [
@@ -25512,14 +25787,14 @@ const RESERVED_PREFIXES = [
25512
25787
  "DYLD_",
25513
25788
  "GIT_",
25514
25789
  "NPM_CONFIG_",
25515
- "HOT_UPDATER_GOOGLE_OAUTH_CLIENT_"
25790
+ "INFISICAL_"
25516
25791
  ];
25517
25792
  /**
25518
25793
  * Whether `name` is a variable no secret may set: one a loader or
25519
25794
  * interpreter reads (`NODE_*` but `NODE_ENV`, `PATH`, `LD_*`, `DYLD_*`,
25520
25795
  * `BASH_ENV`, `ENV`, `GIT_*`, `npm_config_*`, the JVM's, Gradle's, Ruby's,
25521
25796
  * Python's, and Perl's options), or one Hot Updater reads its credentials
25522
- * from. `secretEnv` refuses these names, and so does every environment the
25797
+ * from. `secret()` refuses these names, and so does every environment the
25523
25798
  * CLI gives a child process from resolved secrets.
25524
25799
  */
25525
25800
  const isReservedVariableName = (name) => {
@@ -25539,20 +25814,25 @@ const assertUnreservedEnv = (env, what) => {
25539
25814
  //#endregion
25540
25815
  //#region src/secrets.ts
25541
25816
  const ENV_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/u;
25542
- /** A secret's name, as `secret:<name>` takes one. */
25817
+ /** A secret's name, as `secret("<name>")` takes one. */
25543
25818
  const SECRET_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/u;
25544
- const SECRET_REFERENCE_PREFIX = "secret:";
25545
- /** Whether `name` is a secret's name, as `secret:<name>` takes one. */
25819
+ /**
25820
+ * The brand `secret()` sets, which plugin-core's `SecretRef` types. It is a
25821
+ * `Symbol.for` key: the config file loads its own copy of this module.
25822
+ */
25823
+ const SECRET_REF = Symbol.for("@hot-updater/secret-ref");
25824
+ /** Whether `name` is a secret's name, as `secret("<name>")` takes one. */
25546
25825
  const isSecretName = (name) => SECRET_NAME_PATTERN.test(name);
25826
+ /** Whether `value` is a `secret("<name>")` reference. */
25827
+ const isSecretRef = (value) => typeof value === "function" && Reflect.get(value, SECRET_REF) === true && isSecretName(value.name);
25547
25828
  /**
25548
- * The secret name a config's `secret:<name>` reference names, such as
25549
- * `release-keystore` for `"secret:release-keystore"`. `field` is the config
25550
- * field the reference is in, which the error names.
25829
+ * The secret name a config's `secret("<name>")` names, such as
25830
+ * `release-keystore` for `secret("release-keystore")`. `field` is the
25831
+ * config field it is in, which the error names.
25551
25832
  */
25552
25833
  const parseSecretReference = (reference, field) => {
25553
- const name = reference.startsWith(SECRET_REFERENCE_PREFIX) ? reference.slice(7) : void 0;
25554
- 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.`);
25555
- return name;
25834
+ if (!isSecretRef(reference)) throw new Error(`${field} takes secret("<name>").`);
25835
+ return reference.name;
25556
25836
  };
25557
25837
  /**
25558
25838
  * The environment variable the default store reads a secret from: a name
@@ -25572,8 +25852,6 @@ const readSecretVariables = async (names, cwd) => {
25572
25852
  /** `localSecrets()`'s name, the `secrets` adapter when the config sets none. */
25573
25853
  const LOCAL_SECRETS_NAME = "localSecrets";
25574
25854
  const LOCAL_KEY_STORE = Symbol.for("@hot-updater/cli-tools/local-key-store");
25575
- /** `localSecrets()`'s store without a `path`, the one after any adapter. */
25576
- const defaultLocalSecretsStore = createLocalSecretsStore();
25577
25855
  const isLocalKeyStoreSecrets = (secrets) => LOCAL_KEY_STORE in secrets;
25578
25856
  /**
25579
25857
  * Secrets in JSON files on this machine, the `secrets` adapter when the
@@ -25582,76 +25860,80 @@ const isLocalKeyStoreSecrets = (secrets) => LOCAL_KEY_STORE in secrets;
25582
25860
  * `~/.hot-updater/secrets/<project-key>/<name>.json`; `set` writes that
25583
25861
  * file, 0600 in a 0700 directory, outside the project, so it survives
25584
25862
  * `node_modules` and is never committed. `HOT_UPDATER_HOME` moves
25585
- * `~/.hot-updater`. The project key is the same for every clone of one app
25586
- * on a machine (its `origin` remote and its directory in the repository),
25587
- * so another machine signs in and issues a key of its own.
25863
+ * `~/.hot-updater`. The project key hashes the checkout's real path
25864
+ * (`getProjectKey`), so each clone of an app has its own directory, and
25865
+ * another clone or machine signs in and issues a key of its own.
25588
25866
  *
25589
25867
  * @example
25590
25868
  * export default defineConfig({ secrets: localSecrets(), ... });
25591
25869
  */
25592
- const localSecrets = (options = {}) => createLocalKeyStoreSecrets(void 0, createLocalSecretsStore(options));
25870
+ const localSecrets = (options = {}) => createLocalSecrets(createLocalSecretsStore(options));
25593
25871
  /**
25594
- * The config's `secrets` adapter with `localSecrets()`'s store after it:
25595
- * `get` reads each name the adapter does not hold from
25596
- * `~/.hot-updater/secrets/<project-key>/<name>.json`, and `set` stores there
25597
- * when the adapter stores nothing. Without an adapter, it is
25598
- * `localSecrets()` itself. Keys Hot Updater issues, and credentials
25599
- * `secrets put` imports, are reused by the next build on this machine with
25600
- * no step in between.
25872
+ * The `secrets` adapter a config load uses: the config's own adapter as it
25873
+ * is, with no store on this machine under it, or `localSecrets()` when the
25874
+ * config sets none.
25601
25875
  */
25602
- const withLocalKeyStore = (configured) => configured && isLocalKeyStoreSecrets(configured) ? configured : createLocalKeyStoreSecrets(configured, defaultLocalSecretsStore);
25603
- const createLocalKeyStoreSecrets = (configured, store) => {
25604
- const describeFile = (name) => store.file(name, getCwd());
25876
+ const resolveSecretsAdapter = (configured) => configured ?? localSecrets();
25877
+ const createLocalSecrets = (store) => {
25605
25878
  return {
25606
- name: configured?.name ?? "localSecrets",
25607
- [LOCAL_KEY_STORE]: configured ? {
25608
- configured,
25609
- store
25610
- } : { store },
25879
+ name: LOCAL_SECRETS_NAME,
25880
+ [LOCAL_KEY_STORE]: { store },
25611
25881
  get: async (names, context) => {
25612
- const values = configured ? { ...await configured.get(names, context) } : await readSecretVariables(names, context.cwd);
25882
+ const values = await readSecretVariables(names, context.cwd);
25613
25883
  const read = Object.create(null);
25614
- for (const name of names) {
25615
- const value = Object.hasOwn(values, name) ? values[name] : void 0;
25616
- read[name] = typeof value === "string" && value ? value : await store.read(name, context.cwd);
25617
- }
25884
+ for (const name of names) read[name] = values[name] || await store.read(name, context.cwd);
25618
25885
  return read;
25619
25886
  },
25620
25887
  set: async (name, value, context) => {
25621
- if (configured?.set) {
25622
- await configured.set(name, value, context);
25623
- if (await store.read(name, context.cwd) === void 0) return;
25624
- try {
25625
- await store.write(name, value, context.cwd);
25626
- } catch (error) {
25627
- p.log.warn(`The ${configured.name} secrets adapter stored ${name}, and the copy on this machine at ${store.file(name, context.cwd)} still holds the value it replaced: ${error instanceof Error ? error.message : String(error)}. Delete that file.`);
25628
- }
25629
- return;
25630
- }
25631
25888
  await store.write(name, value, context.cwd);
25632
25889
  },
25633
- describe: (name) => configured ? `${describeSecret(configured, name)}, or ${describeFile(name)} on this machine` : `${secretVariableName(name)} in the environment, or ${describeFile(name)} on this machine`
25890
+ describe: (name) => `${secretVariableName(name)} in the environment, or ${store.file(name, getCwd())} on this machine`
25634
25891
  };
25635
25892
  };
25636
25893
  /**
25637
- * The `localSecrets()` store `secrets` reads after its adapter, and stores
25638
- * in when the adapter stores nothing: `localSecrets({ path })`'s, else the
25639
- * default one.
25894
+ * `localSecrets()`'s store, for `localSecrets()` itself; undefined for any
25895
+ * other adapter, which keeps nothing on this machine.
25640
25896
  */
25641
- const getLocalSecretsStore = (secrets) => isLocalKeyStoreSecrets(secrets) ? secrets[LOCAL_KEY_STORE].store : defaultLocalSecretsStore;
25897
+ const getLocalSecretsStore = (secrets) => isLocalKeyStoreSecrets(secrets) ? secrets[LOCAL_KEY_STORE].store : void 0;
25642
25898
  /**
25643
- * The config's own `secrets` adapter, without `localSecrets()`'s store
25644
- * after it: undefined for `localSecrets()`, and when the config sets none.
25645
- * `secretEnv` reads deploy credentials from it.
25899
+ * The config's own `secrets` adapter: undefined for `localSecrets()`,
25900
+ * which is the adapter when the config sets none. A called `secret()` reads
25901
+ * from it.
25646
25902
  */
25647
- const getConfiguredAdapter = (secrets) => isLocalKeyStoreSecrets(secrets) ? secrets[LOCAL_KEY_STORE].configured : secrets;
25903
+ const getConfiguredAdapter = (secrets) => isLocalKeyStoreSecrets(secrets) ? void 0 : secrets;
25648
25904
  /**
25649
- * Where `secrets.set` stores `name`, for messages: the config's adapter
25650
- * when it stores values, else `localSecrets()`'s file.
25905
+ * Where `secrets.set` stores `name`, for messages: the config's adapter,
25906
+ * else `localSecrets()`'s file.
25651
25907
  */
25652
- const describeStoredSecret = (secrets, name) => {
25653
- const configured = getConfiguredAdapter(secrets);
25654
- return configured?.set ? describeSecret(configured, name) : getLocalSecretsStore(secrets).file(name, getCwd());
25908
+ const describeStoredSecret = (secrets, name) => getLocalSecretsStore(secrets)?.file(name, getCwd()) ?? describeSecret(secrets, name);
25909
+ /**
25910
+ * `name`'s value, read in the order `secrets.get` reads it, with the one
25911
+ * place it came from, for messages: the config's adapter
25912
+ * (`describeSecret`), the environment variable `localSecrets()` reads
25913
+ * first, or `localSecrets()`'s file. Undefined when nothing holds it.
25914
+ */
25915
+ const readSecretWithSource = async (secrets, name, cwd) => {
25916
+ const held = (value) => typeof value === "string" && value.length > 0;
25917
+ const store = getLocalSecretsStore(secrets);
25918
+ if (!store) {
25919
+ const value = (await readSecrets(secrets, [name], cwd))[name];
25920
+ return held(value) ? {
25921
+ value,
25922
+ source: describeSecret(secrets, name)
25923
+ } : void 0;
25924
+ }
25925
+ const variable = (await readSecretVariables([name], cwd))[name];
25926
+ if (held(variable)) return {
25927
+ value: variable,
25928
+ source: `${secretVariableName(name)} (environment)`
25929
+ };
25930
+ const value = await store.read(name, cwd).catch((error) => {
25931
+ throw secretReadError(secrets, [name], error);
25932
+ });
25933
+ return held(value) ? {
25934
+ value,
25935
+ source: store.file(name, cwd)
25936
+ } : void 0;
25655
25937
  };
25656
25938
  /**
25657
25939
  * Whether a key `secrets` stores stays on this machine: it is `localSecrets()`,
@@ -25662,7 +25944,7 @@ const CONFIGURED_SECRETS = Symbol.for("@hot-updater/cli-tools/secrets");
25662
25944
  const LOADING_SCOPE = Symbol.for("@hot-updater/cli-tools/secrets-scope");
25663
25945
  /**
25664
25946
  * Evaluates a config, the file or a config function, with `scope` as the
25665
- * scope of every `secretEnv` it creates, so each reads the `secrets` of the
25947
+ * scope of every `secret()` it creates, so each reads the `secrets` of the
25666
25948
  * config that created it, whatever config loads after. It is kept on
25667
25949
  * `globalThis`: the config file loads its own copy of Hot Updater's modules.
25668
25950
  */
@@ -25678,7 +25960,7 @@ const withSecretsScope = async (scope, evaluate) => {
25678
25960
  };
25679
25961
  /**
25680
25962
  * Makes `secrets` the adapter a credential resolver reads from by default,
25681
- * and a `secretEnv` created outside any config load, as `loadConfig` does
25963
+ * and a `secret()` created outside any config load, as `loadConfig` does
25682
25964
  * with the config's. It is kept on `globalThis`: the config file loads Hot
25683
25965
  * Updater's modules again, as its own copies.
25684
25966
  */
@@ -25686,217 +25968,548 @@ const setConfiguredSecrets = (secrets) => {
25686
25968
  globalThis[CONFIGURED_SECRETS] = secrets;
25687
25969
  };
25688
25970
  /**
25689
- * The config's `secrets` adapter with `localSecrets()`'s store after it
25690
- * (`withLocalKeyStore`), `localSecrets()` until a config loads.
25971
+ * The config's `secrets` adapter (`resolveSecretsAdapter`), `localSecrets()`
25972
+ * until a config loads.
25691
25973
  */
25692
25974
  const getConfiguredSecrets = () => globalThis[CONFIGURED_SECRETS] ?? localSecrets();
25975
+ /**
25976
+ * `names` from `secrets` in one `get`. A read that fails names the secrets
25977
+ * and the adapter, with the adapter's own error on the next line, such as
25978
+ * `EACCES: permission denied, open '<file>'`.
25979
+ */
25980
+ const readSecrets = async (secrets, names, cwd) => {
25981
+ try {
25982
+ return await secrets.get(names, { cwd });
25983
+ } catch (error) {
25984
+ throw secretReadError(secrets, names, error);
25985
+ }
25986
+ };
25987
+ const secretReadError = (secrets, names, error) => new Error(fitMessage([`Couldn't read ${list$1(names)} from the ${secrets.name} secrets adapter.`, (error instanceof Error ? error.message : String(error)).split("\n")[0]]), { cause: error });
25693
25988
  /** Where an adapter keeps `name`, for messages. */
25694
25989
  const describeSecret = (secrets, name) => secrets.describe?.(name) ?? `${name} in ${secrets.name}`;
25695
- const list = (names) => names.length <= 2 ? names.join(" and ") : `${names.slice(0, -1).join(", ")}, and ${names.at(-1)}`;
25990
+ const list$1 = (names) => names.length <= 2 ? names.join(" and ") : `${names.slice(0, -1).join(", ")}, and ${names.at(-1)}`;
25696
25991
  /**
25697
- * Environment variables from the `secrets` adapter of the config that
25698
- * creates it, as a function a config calls when an adapter needs them, so loading
25699
- * `hot-updater.config.ts` reads no secret. Each name set in the environment
25700
- * or `.env.hotupdater` wins; the others are read from the config's
25701
- * `secrets` adapter in one call, and without one they must be set. The values go only to the caller: they are never set in
25702
- * `process.env`, and are masked in the CLI's logs. Names a loader or
25703
- * interpreter reads, such as `NODE_OPTIONS`, are refused.
25992
+ * `secret("<name>")`: the secret `name` in `hot-updater.config.ts`, for a
25993
+ * build credential, such as `signing.keystore`, or any adapter credential,
25994
+ * which calls it on first use, so loading the config reads no secret.
25995
+ * Called, it reads `name`'s variable (`secretVariableName`) in the
25996
+ * environment or `.env.hotupdater`, else the config's `secrets` adapter:
25997
+ * the first call reads every name the config's `secret()`s reference and
25998
+ * the environment doesn't set, in one `get`. With `localSecrets()`, the
25999
+ * variable must be set: its files hold only the keys Hot Updater stores.
26000
+ * Values are masked in the CLI's logs and never set in `process.env`.
26001
+ * Names a loader or interpreter reads, such as `NODE_OPTIONS`, are refused.
26002
+ * A server passes its own environment's values instead.
25704
26003
  *
25705
26004
  * @example
25706
- * const deploy = secretEnv(["S3_ACCESS_KEY_ID", "S3_SECRET_ACCESS_KEY"]);
25707
- * s3Storage({
26005
+ * r2Storage({
25708
26006
  * bucketName: "bundles",
25709
- * region: "us-east-1",
25710
- * credentials: async () => {
25711
- * const env = await deploy();
25712
- * return {
25713
- * accessKeyId: env.S3_ACCESS_KEY_ID,
25714
- * secretAccessKey: env.S3_SECRET_ACCESS_KEY,
25715
- * };
26007
+ * accountId: "<account-id>",
26008
+ * credentials: {
26009
+ * accessKeyId: secret("HOT_UPDATER_CLOUDFLARE_R2_ACCESS_KEY_ID"),
26010
+ * secretAccessKey: secret("HOT_UPDATER_CLOUDFLARE_R2_SECRET_ACCESS_KEY"),
25716
26011
  * },
25717
26012
  * });
25718
26013
  */
25719
- const secretEnv = (names) => {
25720
- const wanted = [...new Set(names)];
25721
- const invalid = wanted.filter((name) => !ENV_NAME_PATTERN.test(name));
25722
- 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.`);
25723
- const reserved = wanted.filter(isReservedVariableName);
25724
- if (reserved.length > 0) throw new Error(`secretEnv refuses ${list(reserved)}: ${reserved.length === 1 ? "it changes" : "they change"} how a process loads code or which credentials Hot Updater uses.`);
26014
+ const secret = (name) => {
26015
+ 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"));
26016
+ if (isReservedVariableName(name)) throw new Error([`secret() refuses ${name}.`, "It changes how a process loads code, or the credentials Hot Updater uses."].join("\n"));
25725
26017
  const scope = globalThis[LOADING_SCOPE];
25726
- return async () => {
25727
- const cwd = getCwd();
25728
- const fileEnv = await readLoadedHotUpdaterEnv(cwd);
25729
- const variables = Object.create(null);
25730
- const fromStore = [];
25731
- for (const name of wanted) {
25732
- const value = getHotUpdaterEnvValue(fileEnv, name);
25733
- if (value === void 0) fromStore.push(name);
25734
- else variables[name] = value;
25735
- }
25736
- if (fromStore.length > 0) {
25737
- const secrets = getConfiguredAdapter(scope?.secrets ?? getConfiguredSecrets());
25738
- const read = secrets ? await secrets.get(fromStore, { cwd }) : {};
25739
- const missing = fromStore.filter((name) => {
25740
- const value = Object.hasOwn(read, name) ? read[name] : void 0;
25741
- if (typeof value !== "string" || value === "") return true;
25742
- variables[name] = value;
25743
- return false;
25744
- });
25745
- if (missing.length > 0) {
25746
- const them = missing.length === 1 ? "it" : "them";
25747
- throw new Error(secrets === void 0 ? `secretEnv found no value for ${list(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(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("; ")}).`);
25748
- }
25749
- }
25750
- for (const value of Object.values(variables)) registerSecretValue(value);
25751
- return Object.freeze(variables);
25752
- };
26018
+ if (scope) (scope.names ??= /* @__PURE__ */ new Set()).add(name);
26019
+ let read;
26020
+ const ref = () => read ??= readSecretRef(name, scope).catch((error) => {
26021
+ read = void 0;
26022
+ throw error;
26023
+ });
26024
+ Object.defineProperty(ref, "name", { value: name });
26025
+ Object.defineProperty(ref, SECRET_REF, { value: true });
26026
+ return Object.freeze(ref);
26027
+ };
26028
+ /**
26029
+ * The scope's names to read in one `get`: those no build credential takes
26030
+ * and the environment doesn't set, with `name`.
26031
+ */
26032
+ const batchedNames = (scope, name, fileEnv) => {
26033
+ const names = [...scope.names ?? []].filter((candidate) => !scope.buildNames?.has(candidate) && getHotUpdaterEnvValue(fileEnv, secretVariableName(candidate)) === void 0);
26034
+ return names.includes(name) ? names : [...names, name];
26035
+ };
26036
+ const readSecretRef = async (name, scope) => {
26037
+ const cwd = getCwd();
26038
+ const fileEnv = await readLoadedHotUpdaterEnv(cwd);
26039
+ const variable = secretVariableName(name);
26040
+ const fromEnv = getHotUpdaterEnvValue(fileEnv, variable);
26041
+ if (fromEnv !== void 0 && fromEnv !== "") {
26042
+ registerSecretValue(fromEnv);
26043
+ return fromEnv;
26044
+ }
26045
+ const secrets = getConfiguredAdapter(scope?.secrets ?? getConfiguredSecrets());
26046
+ if (secrets === void 0) throw new Error(fitMessage([
26047
+ `secret("${name}") has no value.`,
26048
+ `Set ${variable} in the environment or .env.hotupdater.`,
26049
+ "Or set the config's secrets to a team store."
26050
+ ]));
26051
+ let values;
26052
+ if (scope) {
26053
+ scope.read ??= readSecrets(secrets, batchedNames(scope, name, fileEnv), cwd).catch((error) => {
26054
+ scope.read = void 0;
26055
+ throw error;
26056
+ });
26057
+ values = await scope.read;
26058
+ }
26059
+ if (!values || !Object.hasOwn(values, name)) values = await readSecrets(secrets, [name], cwd);
26060
+ const value = Object.hasOwn(values, name) ? values[name] : void 0;
26061
+ if (typeof value !== "string" || value === "") throw new Error(fitMessage([
26062
+ `secret("${name}") has no value.`,
26063
+ `It isn't in ${describeSecret(secrets, name)}.`,
26064
+ `Store it there, or set ${variable}.`
26065
+ ]));
26066
+ registerSecretValue(value);
26067
+ return value;
25753
26068
  };
25754
26069
  //#endregion
25755
- //#region src/localBundleSigning.ts
25756
- /** The secret `keys generate --secret` stores the private key as by default. */
25757
- const DEFAULT_SIGNING_KEY_SECRET = "bundle-signing-key";
26070
+ //#region src/credentialResolver.ts
26071
+ /** Passwords are read as set: trimming would change them. */
26072
+ const UNTRIMMED_ENV_NAMES = /* @__PURE__ */ new Set(["HOT_UPDATER_ANDROID_KEYSTORE_PASSWORD", "HOT_UPDATER_ANDROID_KEY_PASSWORD"]);
26073
+ const describeField = ([name, alternative]) => alternative ? `${name} (or ${alternative})` : name;
26074
+ const describeFields = (fields) => {
26075
+ const described = fields.map(describeField);
26076
+ if (described.length <= 2) return described.join(" and ");
26077
+ return `${described.slice(0, -1).join(", ")}, and ${described.at(-1)}`;
26078
+ };
25758
26079
  /**
25759
- * A local signing failure whose message the CLI prints as it is: it names
25760
- * the secret and how to store it, and quotes no key material.
26080
+ * A command to print, broken at spaces with ` \` once a line would pass
26081
+ * 74 columns, each continuation indented two spaces, so a terminal never
26082
+ * wraps it mid-word and it still pastes as one command.
25761
26083
  */
25762
- var LocalSigningKeyError = class extends Error {
25763
- constructor(message, options) {
26084
+ const formatCommand = (command) => {
26085
+ const lines = [];
26086
+ let line = "";
26087
+ for (const word of command.split(" ")) {
26088
+ const next = line ? `${line} ${word}` : word;
26089
+ if (line && next.length > 72) {
26090
+ lines.push(`${line} \\`);
26091
+ line = ` ${word}`;
26092
+ } else line = next;
26093
+ }
26094
+ lines.push(line);
26095
+ return lines.join("\n");
26096
+ };
26097
+ /**
26098
+ * `lead` and the command it names on one line when they fit 77 columns,
26099
+ * else the command on the lines after it.
26100
+ */
26101
+ const withLead = (lead, command) => lead.length + 1 + command.length <= 77 ? `${lead} ${command}` : `${lead}\n${formatCommand(command)}`;
26102
+ /**
26103
+ * Why a build has no upload keystore, steering away from a second key:
26104
+ * Play rejects builds signed with a key it does not have. Under a team's
26105
+ * adapter the keystore is someone's already, so it is imported, never
26106
+ * created; only `localSecrets()` alone offers a new one, for an app with
26107
+ * no `.aab` on Play yet.
26108
+ */
26109
+ const describeMissingKeystore = (store, item, name) => {
26110
+ const configured = getConfiguredAdapter(store);
26111
+ if (configured) return [
26112
+ `No ${item} in ${describeSecret(configured, name)}.`,
26113
+ "Ask whoever set it up to store it there, or import it with:",
26114
+ formatCommand(credentialPutCommand("android-keystore", name))
26115
+ ].join("\n");
26116
+ return [
26117
+ `No ${item} in the ${store.name} secrets adapter.`,
26118
+ withLead("Store the app's upload key:", credentialPutCommand("android-keystore", name)),
26119
+ withLead("No .aab on Play yet? Create one:", credentialIssueCommand("android-keystore", name))
26120
+ ].join("\n");
26121
+ };
26122
+ /** The file each `secrets put <name> <file>` command names. */
26123
+ const PUT_FILES = {
26124
+ "android-keystore": "<upload.jks>",
26125
+ "play-service-account": "<key.json>"
26126
+ };
26127
+ /**
26128
+ * The command that stores the file you have as `name`: the service
26129
+ * account's JSON key, or the app's upload keystore. `--kind` is named only
26130
+ * for a name that is not the kind.
26131
+ */
26132
+ const credentialPutCommand = (kind, name = kind) => `npx hot-updater secrets put ${name}${name === kind ? "" : ` --kind ${kind}`} ${PUT_FILES[kind]}`;
26133
+ /**
26134
+ * The command that stores the key Hot Updater issues as `name`: an App
26135
+ * Store Connect API key after an Apple Account sign-in, and an upload
26136
+ * keystore it creates.
26137
+ */
26138
+ const credentialIssueCommand = (kind, name = kind) => `npx hot-updater secrets issue ${kind}${name === kind ? "" : ` ${name}`}`;
26139
+ /** The command group of each issued kind's shortcut, `<group> login`. */
26140
+ const CREDENTIAL_LOGIN_GROUPS = { "asc-api-key": "apple" };
26141
+ /**
26142
+ * The command people meet first that issues `kind` as `name`: its shortcut,
26143
+ * `apple login` for an App Store Connect API key, which takes nothing and
26144
+ * stores the kind's own name; for another name, `credentialIssueCommand`.
26145
+ */
26146
+ const credentialLoginCommand = (kind, name = kind) => name === kind ? `npx hot-updater ${CREDENTIAL_LOGIN_GROUPS[kind]} login` : credentialIssueCommand(kind, name);
26147
+ /**
26148
+ * The command that stores a missing credential of `kind` as `name`: the
26149
+ * key Hot Updater issues after an Apple sign-in, or the file you have.
26150
+ */
26151
+ const credentialStoreCommand = (kind, name = kind) => kind === "asc-api-key" ? credentialLoginCommand(kind, name) : credentialPutCommand(kind, name);
26152
+ /**
26153
+ * Why `secrets` has no `name`, and the command that brings the existing
26154
+ * one in, never one that creates another: a signing key may already be
26155
+ * trusted by a shipped app or a store. Under a team's adapter the name or
26156
+ * the access is wrong; under `localSecrets()` this machine lacks it.
26157
+ */
26158
+ const describeMissingSecret = (secrets, name, { action = "import your key", command }) => [fitMessage(getConfiguredAdapter(secrets) ? [`${name} isn't in the ${secrets.name} secrets adapter.`, `Check the name and your access, or ${action}:`] : [`${name} isn't on this machine.`, `Check the name, set ${secretVariableName(name)}, or ${action}:`]), formatCommand(command)].join("\n");
26159
+ var CredentialUnavailableError = class extends Error {
26160
+ kind;
26161
+ /** The environment variables that can provide this credential. */
26162
+ envNames;
26163
+ constructor(kind, message, options) {
25764
26164
  super(message, options);
25765
- this.name = "HotUpdaterLocalSigningKeyError";
26165
+ this.name = "CredentialUnavailableError";
26166
+ this.kind = kind;
26167
+ this.envNames = CREDENTIAL_ENV_NAMES[kind].flat();
25766
26168
  }
25767
26169
  };
25768
- const resolvePath$1 = (cwd, filePath) => path$1.isAbsolute(filePath) ? filePath : path$1.resolve(cwd, filePath);
25769
- const parsePrivateKey = (pem) => {
25770
- try {
25771
- const privateKey = crypto$1.createPrivateKey(pem);
25772
- if (privateKey.asymmetricKeyType !== "rsa" || (privateKey.asymmetricKeyDetails?.modulusLength ?? 0) < 2048) throw new Error("not rsa");
25773
- return privateKey;
25774
- } catch {
25775
- throw new Error("Failed to load the local bundle signing private key.");
26170
+ const readEnvField = (env, field) => {
26171
+ for (const name of field) {
26172
+ const value = UNTRIMMED_ENV_NAMES.has(name) ? (process.env[name] ?? env[name]) || void 0 : getHotUpdaterEnvValue(env, name);
26173
+ if (value !== void 0) return {
26174
+ name,
26175
+ value
26176
+ };
25776
26177
  }
25777
26178
  };
25778
- const loadPrivateKeyFile = async (privateKeyPath) => {
25779
- let pem;
26179
+ const readFileField = async (kind, { name, value }) => {
26180
+ const filePath = path$1.resolve(value);
26181
+ if (!await fs$2.stat(filePath).then(async (stats) => {
26182
+ await fs$2.access(filePath, fs$2.constants.R_OK);
26183
+ return stats.isFile();
26184
+ }).catch(() => false)) throw new CredentialUnavailableError(kind, `${name} does not point to a readable file.`);
26185
+ return filePath;
26186
+ };
26187
+ const parseServiceAccount = (json) => {
25780
26188
  try {
25781
- pem = await fs$2.readFile(privateKeyPath, "utf8");
26189
+ return JSON.parse(json);
25782
26190
  } catch {
25783
- throw new Error("Failed to load the local bundle signing private key.");
26191
+ return;
25784
26192
  }
25785
- return parsePrivateKey(pem);
25786
26193
  };
26194
+ const fromEnv = {
26195
+ "asc-api-key": async ([key, keyId, issuerId], { files }) => ({
26196
+ 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")),
26197
+ keyId: keyId.value,
26198
+ issuerId: issuerId.value
26199
+ }),
26200
+ "android-keystore": async ([keystore, storePassword, keyAlias, keyPassword], { files, name }) => {
26201
+ let storeFile;
26202
+ if (keystore.name === "HOT_UPDATER_ANDROID_KEYSTORE_PATH") storeFile = await readFileField("android-keystore", keystore);
26203
+ else {
26204
+ registerSecretValue(keystore.value);
26205
+ const contents = decodeBase64(keystore.value);
26206
+ if (!contents) throw new CredentialUnavailableError("android-keystore", `${keystore.name} is not a base64-encoded keystore.`);
26207
+ storeFile = await files.write(`${name}.keystore`, contents);
26208
+ }
26209
+ return {
26210
+ storeFile,
26211
+ storePassword: storePassword.value,
26212
+ keyAlias: keyAlias.value,
26213
+ keyPassword: keyPassword.value
26214
+ };
26215
+ },
26216
+ "play-service-account": async ([serviceAccount]) => {
26217
+ const json = serviceAccount.name === "HOT_UPDATER_PLAY_SERVICE_ACCOUNT_PATH" ? await fs$2.readFile(await readFileField("play-service-account", serviceAccount), "utf-8") : serviceAccount.value;
26218
+ const parsed = parseServiceAccount(json);
26219
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new CredentialUnavailableError("play-service-account", `${serviceAccount.name} is not a service account JSON object.`);
26220
+ return { json };
26221
+ }
26222
+ };
26223
+ /** Fields of a service account that are no secret: its type and Google's endpoints. */
25787
26224
  /**
25788
- * The private key the `secrets` adapter holds as `name`: masked in the CLI's
25789
- * output before it is parsed, and kept only as a key object. A PEM written
25790
- * on one line, with `\n` for its line breaks, as a `.env.hotupdater` value
25791
- * or a CI variable may hold it, reads as the PEM.
26225
+ * Fields of a JSON credential that identify it rather than grant access:
26226
+ * its IDs, key alias, and a service account's email, project, type, and
26227
+ * Google's endpoints. They stay readable, as messages name them, and a
26228
+ * short one, such as the alias `release`, would mask every word it is in.
25792
26229
  */
25793
- const loadPrivateKeySecret = async (secrets, name, cwd) => {
25794
- let value;
25795
- try {
25796
- const read = await secrets.get([name], { cwd });
25797
- value = Object.hasOwn(read, name) ? read[name] : void 0;
25798
- } catch (error) {
25799
- const reason = error instanceof Error ? error.message : String(error);
25800
- throw new LocalSigningKeyError(`The ${secrets.name} secrets adapter could not read the bundle signing key ${name}: ${reason}`, { cause: error });
25801
- }
25802
- 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}\`.`);
26230
+ const isIdentifierField = (field) => [
26231
+ "keyId",
26232
+ "issuerId",
26233
+ "keyAlias",
26234
+ "type",
26235
+ "project_id",
26236
+ "client_email",
26237
+ "client_id",
26238
+ "private_key_id",
26239
+ "universe_domain"
26240
+ ].includes(field) || field.endsWith("_uri") || field.endsWith("_url");
26241
+ const registerJsonSecrets = (value, fields) => {
25803
26242
  registerSecretValue(value);
25804
- const pem = value.includes("\n") ? value : value.replaceAll("\\n", "\n");
25805
- if (pem !== value) registerSecretValue(pem);
25806
- try {
25807
- return parsePrivateKey(pem);
25808
- } catch {
25809
- 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.`);
26243
+ for (const [field, entry] of Object.entries(fields)) if (typeof entry === "string" && !isIdentifierField(field)) registerSecretValue(entry);
26244
+ };
26245
+ /**
26246
+ * Masks a credential's JSON value in the CLI's output: the whole value, and
26247
+ * each field but those that identify it, such as `keyId` and `keyAlias`,
26248
+ * so a password or a key a log prints alone stays masked. A value that is
26249
+ * no JSON object is masked whole.
26250
+ */
26251
+ const registerCredentialValue = (value) => {
26252
+ const parsed = parseObject(value);
26253
+ if (parsed) registerJsonSecrets(value, parsed);
26254
+ else registerSecretValue(value);
26255
+ };
26256
+ const parseObject = (value) => {
26257
+ const parsed = parseServiceAccount(value);
26258
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : void 0;
26259
+ };
26260
+ /**
26261
+ * The fields of a JSON credential, each a non-empty string, or an error
26262
+ * naming the shape `kind` takes.
26263
+ */
26264
+ const readJsonFields = (kind, item, value, fields) => {
26265
+ const parsed = parseObject(value);
26266
+ const shape = `{${fields.map((field) => `"${field}"`).join(", ")}}`;
26267
+ if (!parsed) throw new CredentialUnavailableError(kind, `${item} is not a JSON object.\n${capitalize(aKind(kind))} is one JSON value: ${shape}`);
26268
+ registerJsonSecrets(value, parsed);
26269
+ const missing = fields.filter((field) => typeof parsed[field] !== "string" || !parsed[field]);
26270
+ if (missing.length > 0) throw new CredentialUnavailableError(kind, `${item} has no ${missing.join(", ")}.\n${capitalize(aKind(kind))} is one JSON value: ${shape}`);
26271
+ return parsed;
26272
+ };
26273
+ const aKind = (kind) => `${/^[aeiou]/u.test(kind) ? "an" : "a"} ${kind}`;
26274
+ const capitalize = (text) => text.charAt(0).toUpperCase() + text.slice(1);
26275
+ /** A base64 value's bytes, or undefined when it is not base64. */
26276
+ const decodeBase64 = (value) => {
26277
+ const base64 = value.replace(/\s/g, "");
26278
+ const contents = Buffer.from(base64, "base64");
26279
+ return contents.length > 0 && contents.toString("base64").replace(/=+$/, "") === base64.replace(/=+$/, "") ? contents : void 0;
26280
+ };
26281
+ /**
26282
+ * Each kind from the one string a secrets adapter holds for it: a JSON
26283
+ * object for the App Store Connect key (`p8` is the PEM) and the keystore
26284
+ * (`keystore` is base64), and the service account's own JSON. File values
26285
+ * are written with `files`; every value is masked.
26286
+ */
26287
+ const fromSecretValue = {
26288
+ "asc-api-key": async (value, { files, item }) => {
26289
+ const { keyId, issuerId, p8 } = readJsonFields("asc-api-key", item, value, [
26290
+ "keyId",
26291
+ "issuerId",
26292
+ "p8"
26293
+ ]);
26294
+ return {
26295
+ keyPath: await files.write(`AuthKey_${keyId}.p8`, p8.includes("\n") ? p8 : p8.replaceAll("\\n", "\n")),
26296
+ keyId,
26297
+ issuerId
26298
+ };
26299
+ },
26300
+ "android-keystore": async (value, { files, name, item }) => {
26301
+ const { keystore, storePassword, keyAlias, keyPassword } = readJsonFields("android-keystore", item, value, [
26302
+ "keystore",
26303
+ "storePassword",
26304
+ "keyAlias",
26305
+ "keyPassword"
26306
+ ]);
26307
+ const contents = decodeBase64(keystore);
26308
+ if (!contents) throw new CredentialUnavailableError("android-keystore", `${item}'s keystore is not a base64-encoded keystore.`);
26309
+ return {
26310
+ storeFile: await files.write(`${name}.keystore`, contents),
26311
+ storePassword,
26312
+ keyAlias,
26313
+ keyPassword
26314
+ };
26315
+ },
26316
+ "play-service-account": async (value, { item }) => {
26317
+ const parsed = parseObject(value);
26318
+ if (!parsed) throw new CredentialUnavailableError("play-service-account", `${item} is not a service account JSON object.`);
26319
+ registerJsonSecrets(value, parsed);
26320
+ return { json: value };
26321
+ }
26322
+ };
26323
+ const registerCredentialSecrets = (kind, credential) => {
26324
+ if (kind === "android-keystore") {
26325
+ const keystore = credential;
26326
+ registerSecretValue(keystore.storePassword);
26327
+ registerSecretValue(keystore.keyPassword);
26328
+ }
26329
+ if (kind === "play-service-account") {
26330
+ const { json } = credential;
26331
+ registerSecretValue(json);
26332
+ const privateKey = parseServiceAccount(json)?.private_key;
26333
+ if (typeof privateKey === "string") registerSecretValue(privateKey);
25810
26334
  }
25811
26335
  };
25812
26336
  /**
25813
- * The built-in local signer. Its private key is read on first use, once:
25814
- * from `privateKeyPath`, or from the config's `secrets` adapter for
25815
- * `privateKey: "secret:<name>"`, and is never written anywhere.
26337
+ * Reads names from `secrets`, the names asked for in one turn of the event
26338
+ * loop, such as a build's credentials, in one `get`. Nothing is cached.
25816
26339
  */
25817
- const createLocalSigningAdapter = (config, { secrets } = {}) => {
25818
- const privateKeys = /* @__PURE__ */ new Map();
25819
- const { privateKeyPath } = config;
25820
- const secretName = privateKeyPath === void 0 ? parseSecretReference(config.privateKey, "signing.privateKey") : void 0;
25821
- const getPrivateKey = (cwd = process.cwd()) => {
25822
- const cacheKey = privateKeyPath === void 0 ? "" : resolvePath$1(cwd, privateKeyPath);
25823
- const cached = privateKeys.get(cacheKey);
25824
- if (cached) return cached;
25825
- const pending = (secretName === void 0 ? loadPrivateKeyFile(cacheKey) : loadPrivateKeySecret(secrets ?? getConfiguredSecrets(), secretName, cwd)).catch((error) => {
25826
- privateKeys.delete(cacheKey);
25827
- throw error;
26340
+ const createSecretLoader = (secrets, cwd) => {
26341
+ let batch;
26342
+ const flush = async (names) => {
26343
+ try {
26344
+ const read = await secrets().get([...names.keys()], { cwd: cwd() });
26345
+ for (const [name, waiters] of names) {
26346
+ const value = Object.hasOwn(read, name) ? read[name] : void 0;
26347
+ for (const waiter of waiters) waiter.resolve(typeof value === "string" && value ? value : void 0);
26348
+ }
26349
+ } catch (error) {
26350
+ for (const waiters of names.values()) for (const waiter of waiters) waiter.reject(error);
26351
+ }
26352
+ };
26353
+ return (name) => new Promise((resolve, reject) => {
26354
+ if (batch === void 0) {
26355
+ const names = /* @__PURE__ */ new Map();
26356
+ batch = names;
26357
+ setTimeout(() => {
26358
+ batch = void 0;
26359
+ flush(names);
26360
+ }, 0);
26361
+ }
26362
+ const waiters = batch.get(name) ?? [];
26363
+ waiters.push({
26364
+ resolve,
26365
+ reject
26366
+ });
26367
+ batch.set(name, waiters);
26368
+ });
26369
+ };
26370
+ /**
26371
+ * Resolves each item from its environment overrides when any is set, and
26372
+ * otherwise from the config's `secrets` adapter as one value named by the
26373
+ * item, so overrides never reach the store. When the adapter fails or holds
26374
+ * no such value, the error names the environment variables that can
26375
+ * provide the item instead. Nothing is cached, values are never written to
26376
+ * disk except as 0600 temporary files, are never set in `process.env`, and
26377
+ * every secret is masked in logs.
26378
+ */
26379
+ const createCredentialResolver = ({ env = {}, secrets, cwd } = {}) => {
26380
+ const files = createCredentialFiles();
26381
+ const resolved = /* @__PURE__ */ new Map();
26382
+ let disposal;
26383
+ const adapter = () => typeof secrets === "function" ? secrets() : secrets ?? getConfiguredSecrets();
26384
+ const load = createSecretLoader(adapter, () => cwd ?? getCwd());
26385
+ const resolveItem = async (kind, name) => {
26386
+ const fields = CREDENTIAL_ENV_NAMES[kind];
26387
+ const values = fields.map((field) => readEnvField(env, field));
26388
+ if (values.some((value) => value !== void 0)) {
26389
+ const missing = fields.filter((_, index) => !values[index]);
26390
+ if (missing.length > 0) throw new CredentialUnavailableError(kind, `The environment overrides ${kind} but ${describeFields(missing)} ${missing.length === 1 ? "is" : "are"} not set.`);
26391
+ const credential = await fromEnv[kind](values, {
26392
+ files,
26393
+ name
26394
+ });
26395
+ registerCredentialSecrets(kind, credential);
26396
+ return credential;
26397
+ }
26398
+ const store = adapter();
26399
+ const item = name === kind ? kind : `${name} (${kind})`;
26400
+ let value;
26401
+ try {
26402
+ value = await load(name);
26403
+ } catch (error) {
26404
+ const reason = error instanceof Error ? error.message : String(error);
26405
+ throw new CredentialUnavailableError(kind, `Couldn't read ${item} from the ${store.name} secrets adapter.\n${reason}`, { cause: error });
26406
+ }
26407
+ 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))}`);
26408
+ registerSecretValue(value);
26409
+ return fromSecretValue[kind](value, {
26410
+ files,
26411
+ name,
26412
+ item: `The ${store.name} secrets adapter's ${item}`
25828
26413
  });
25829
- privateKeys.set(cacheKey, pending);
25830
- return pending;
25831
26414
  };
25832
26415
  return {
25833
- name: "localSigning",
25834
- async getPublicKey({ cwd } = {}) {
25835
- const privateKey = await getPrivateKey(cwd);
25836
- return { publicKey: crypto$1.createPublicKey(privateKey).export({
25837
- format: "pem",
25838
- type: "spki"
25839
- }).toString() };
26416
+ resolve: ({ kind, name = kind }) => {
26417
+ if (disposal) return Promise.reject(/* @__PURE__ */ new Error("The credential resolver has been disposed."));
26418
+ const key = `${kind}\u0000${name}`;
26419
+ let pending = resolved.get(key);
26420
+ if (!pending) {
26421
+ pending = resolveItem(kind, name);
26422
+ resolved.set(key, pending);
26423
+ pending.catch(() => resolved.delete(key));
26424
+ }
26425
+ return pending;
25840
26426
  },
25841
- async sign({ message, cwd }) {
25842
- if (!(message instanceof Uint8Array) || message.byteLength !== 32) throw new Error("Local bundle signing messages must be exactly 32 bytes.");
25843
- const privateKey = await getPrivateKey(cwd);
25844
- return { signature: crypto$1.sign("RSA-SHA256", message, privateKey) };
26427
+ dispose: () => {
26428
+ disposal ??= (async () => {
26429
+ await Promise.allSettled(resolved.values());
26430
+ resolved.clear();
26431
+ await files.dispose();
26432
+ })();
26433
+ return disposal;
25845
26434
  }
25846
26435
  };
25847
26436
  };
25848
26437
  /**
25849
- * The `secrets` adapter of the config each normalized `privateKey` config
25850
- * came from, so a config function's per-platform configs each read their
25851
- * own store, whichever config loaded last.
26438
+ * Runs `run` with a credential resolver and deletes its temporary files
26439
+ * when `run` settles.
25852
26440
  */
25853
- const signingSecrets = /* @__PURE__ */ new WeakMap();
25854
- /** The `secrets` adapter `normalizeSigningConfig` bound `signing` to. */
25855
- const getSigningSecrets = (signing) => signingSecrets.get(signing);
25856
- const invalidSigningConfig = () => /* @__PURE__ */ new Error("Bundle signing must be a local key config or signing adapter. Omit signing to disable it.");
25857
- const normalizeSigningConfig = (signing, { secrets } = {}) => {
25858
- if (signing === void 0) return void 0;
25859
- if (typeof signing !== "object" || signing === null) throw invalidSigningConfig();
25860
- const hasLocalFields = [
25861
- "enabled",
25862
- "privateKeyPath",
25863
- "privateKey"
25864
- ].some((key) => Reflect.has(signing, key));
25865
- const hasPluginMembers = [
25866
- "name",
25867
- "getPublicKey",
25868
- "sign"
25869
- ].some((key) => Reflect.has(signing, key));
25870
- if (hasLocalFields && hasPluginMembers) throw new Error("Bundle signing config cannot combine local signing fields with signing adapter fields.");
25871
- if (!hasPluginMembers) {
25872
- 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.");
25873
- const enabled = Reflect.get(signing, "enabled");
25874
- if (enabled === false) return void 0;
25875
- if (enabled !== true) throw invalidSigningConfig();
25876
- const privateKeyPath = Reflect.get(signing, "privateKeyPath");
25877
- const privateKey = Reflect.get(signing, "privateKey");
25878
- if (privateKeyPath !== void 0 && privateKey !== void 0) throw new Error("Local bundle signing takes its private key from privateKeyPath or from privateKey, not both.");
25879
- if (privateKey !== void 0) {
25880
- if (typeof privateKey !== "string") throw new Error("signing.privateKey names a secret as secret:<name>, such as \"secret:bundle-signing-key\".");
25881
- parseSecretReference(privateKey, "signing.privateKey");
25882
- const normalized = {
25883
- enabled: true,
25884
- privateKey
25885
- };
25886
- if (secrets) signingSecrets.set(normalized, secrets);
25887
- return normalized;
25888
- }
25889
- if (typeof privateKeyPath !== "string" || !privateKeyPath.trim()) throw new Error("Enabled local bundle signing requires privateKeyPath, or privateKey as \"secret:<name>\".");
25890
- return {
25891
- enabled: true,
25892
- privateKeyPath
25893
- };
26441
+ const withCredentialResolver = async (run, options) => {
26442
+ const resolver = createCredentialResolver(options);
26443
+ try {
26444
+ return await run(resolver);
26445
+ } finally {
26446
+ await resolver.dispose();
25894
26447
  }
25895
- if (typeof Reflect.get(signing, "name") !== "string" || typeof Reflect.get(signing, "getPublicKey") !== "function" || typeof Reflect.get(signing, "sign") !== "function") throw invalidSigningConfig();
25896
- return signing;
25897
26448
  };
25898
26449
  //#endregion
25899
26450
  //#region src/bundleSigning.ts
26451
+ /** The secret `signing: secret("bundle-signing-key")` names, which `secrets issue` creates. */
26452
+ const BUNDLE_SIGNING_KEY_SECRET = "bundle-signing-key";
26453
+ /** The command that creates and stores a bundle signing key. */
26454
+ const ISSUE_SIGNING_KEY_COMMAND = `npx hot-updater secrets issue ${BUNDLE_SIGNING_KEY_SECRET}`;
26455
+ /** The command that stores a bundle signing key you have. */
26456
+ const PUT_SIGNING_KEY_COMMAND = `npx hot-updater secrets put ${BUNDLE_SIGNING_KEY_SECRET} <key.pem>`;
26457
+ /** The command that stores the bundle signing key you have as `name`. */
26458
+ const bundleSigningPutCommand = (name) => `npx hot-updater secrets put ${name}${name === "bundle-signing-key" ? "" : ` --kind ${BUNDLE_SIGNING_KEY_SECRET}`} <key.pem>`;
26459
+ /** What deploy and doctor say when the config sets no `signing`. */
26460
+ const UNSIGNED_BUNDLES_WARNING = "Bundles are unsigned.";
26461
+ /** The next step after `UNSIGNED_BUNDLES_WARNING`. */
26462
+ const UNSIGNED_BUNDLES_NEXT_STEP = "To sign them, run:";
26463
+ /** What deploy and doctor say when `signing` is no shape it takes. */
26464
+ const INVALID_SIGNING_MESSAGE = `signing takes secret("${BUNDLE_SIGNING_KEY_SECRET}").`;
26465
+ /** The next step after `INVALID_SIGNING_MESSAGE`. */
26466
+ const INVALID_SIGNING_NEXT_STEP = "Store your key:";
26467
+ const invalid = (message) => ({
26468
+ status: "invalid",
26469
+ message
26470
+ });
26471
+ const LOCAL_FIELDS = [
26472
+ "enabled",
26473
+ "privateKeyPath",
26474
+ "privateKey"
26475
+ ];
26476
+ const readBundleSigningState = (signing) => {
26477
+ if (signing === void 0) return { status: "unsigned" };
26478
+ if (isSecretRef(signing)) return {
26479
+ status: "on",
26480
+ key: { secret: signing.name }
26481
+ };
26482
+ if (typeof signing !== "object" || signing === null || !Object.keys(signing).every((key) => LOCAL_FIELDS.includes(key))) return invalid(INVALID_SIGNING_MESSAGE);
26483
+ const { enabled, privateKey, privateKeyPath } = signing;
26484
+ if (enabled === false) return { status: "off" };
26485
+ if (enabled !== true) return invalid(INVALID_SIGNING_MESSAGE);
26486
+ if (privateKey !== void 0 && privateKeyPath !== void 0) return invalid("signing takes privateKey or privateKeyPath, not both.");
26487
+ if (privateKey !== void 0) return isSecretRef(privateKey) ? {
26488
+ status: "on",
26489
+ key: { secret: privateKey.name }
26490
+ } : invalid("signing.privateKey takes secret(\"<name>\").");
26491
+ if (typeof privateKeyPath !== "string" || !privateKeyPath.trim()) return invalid("signing takes privateKeyPath, or privateKey as secret(\"<name>\").");
26492
+ return {
26493
+ status: "on",
26494
+ key: { privateKeyPath }
26495
+ };
26496
+ };
26497
+ /** The `secret()` the config's `signing` reads its private key from, if any. */
26498
+ const bundleSigningSecretRef = (signing) => {
26499
+ if (isSecretRef(signing)) return signing;
26500
+ const privateKey = typeof signing === "object" && signing !== null ? Reflect.get(signing, "privateKey") : void 0;
26501
+ return isSecretRef(privateKey) ? privateKey : void 0;
26502
+ };
26503
+ /**
26504
+ * A bundle signing failure whose message the CLI prints as it is: it names
26505
+ * the secret and the next step, and quotes no key material.
26506
+ */
26507
+ var BundleSigningKeyError = class extends Error {
26508
+ constructor(message, options) {
26509
+ super(message, options);
26510
+ this.name = "HotUpdaterBundleSigningKeyError";
26511
+ }
26512
+ };
25900
26513
  const FILE_HASH_PATTERN = /^[a-f\d]{64}$/iu;
25901
26514
  const resolvePath = (cwd, filePath) => path$1.isAbsolute(filePath) ? filePath : path$1.resolve(cwd, filePath);
25902
26515
  const parseRsaPublicKey = (publicKeyPEM) => {
@@ -25910,7 +26523,8 @@ const parseRsaPublicKey = (publicKeyPEM) => {
25910
26523
  throw new Error("Bundle signing public key must be a valid RSA SPKI PEM key with a modulus of at least 2048 bits.");
25911
26524
  }
25912
26525
  };
25913
- const exportPublicKey = (publicKey) => publicKey.export({
26526
+ /** The SPKI PEM of `key`, a public key or the private key it derives from. */
26527
+ const exportPublicKey = (key) => (key.type === "private" ? crypto$1.createPublicKey(key) : key).export({
25914
26528
  type: "spki",
25915
26529
  format: "pem"
25916
26530
  }).toString();
@@ -25923,93 +26537,112 @@ const readBundleSigningPublicKeyFile = async (publicKeyPath, options = {}) => {
25923
26537
  throw new Error("Failed to read the bundle signing public key file.");
25924
26538
  }
25925
26539
  };
26540
+ const parsePrivateKey = (pem) => {
26541
+ const privateKey = crypto$1.createPrivateKey(pem);
26542
+ if (privateKey.asymmetricKeyType !== "rsa" || (privateKey.asymmetricKeyDetails?.modulusLength ?? 0) < 2048) throw new Error("not rsa");
26543
+ return privateKey;
26544
+ };
26545
+ const loadPrivateKeyFile = async (file) => {
26546
+ try {
26547
+ return parsePrivateKey(await fs$2.readFile(file, "utf8"));
26548
+ } catch {
26549
+ throw new BundleSigningKeyError("Failed to load the bundle signing private key from privateKeyPath.");
26550
+ }
26551
+ };
25926
26552
  /**
25927
- * `remoteSigning`'s errors with a reason it wrote itself, by name, as it may
25928
- * come from another copy of plugin-core, and the local signer's errors that
25929
- * name its secret. Any other signing adapter error is redacted: it may quote
25930
- * key material.
26553
+ * The private key the `secrets` adapter holds as `name`: masked in the CLI's
26554
+ * output before it is parsed, and kept only as a key object. A PEM written
26555
+ * on one line, with `\n` for its line breaks, as a `.env.hotupdater` value
26556
+ * or a CI variable may hold it, reads as the PEM.
25931
26557
  */
25932
- const REMOTE_SIGNING_ERROR_NAME = "HotUpdaterRemoteSigningError";
25933
- const signingFailure = (summary, error) => new Error(error instanceof Error && (error.name === REMOTE_SIGNING_ERROR_NAME || error instanceof LocalSigningKeyError) ? `${summary} ${error.message}` : summary);
26558
+ const loadPrivateKeySecret = async (secrets, name, cwd) => {
26559
+ let value;
26560
+ try {
26561
+ const read = await secrets.get([name], { cwd });
26562
+ value = Object.hasOwn(read, name) ? read[name] : void 0;
26563
+ } catch (error) {
26564
+ const reason = error instanceof Error ? error.message : String(error);
26565
+ throw new BundleSigningKeyError(`Couldn't read ${name} from the ${secrets.name} secrets adapter.\n${reason}`, { cause: error });
26566
+ }
26567
+ if (typeof value !== "string" || !value.trim()) throw new BundleSigningKeyError(describeMissingSecret(secrets, name, { command: bundleSigningPutCommand(name) }));
26568
+ registerSecretValue(value);
26569
+ const pem = value.includes("\n") ? value : value.replaceAll("\\n", "\n");
26570
+ if (pem !== value) registerSecretValue(pem);
26571
+ try {
26572
+ return parsePrivateKey(pem);
26573
+ } catch {
26574
+ 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"));
26575
+ }
26576
+ };
26577
+ /**
26578
+ * The private keys each `signing` value has read, by file or by secrets
26579
+ * adapter, so the commands of one run that sign and derive the public key
26580
+ * read the key once.
26581
+ */
26582
+ const privateKeys = /* @__PURE__ */ new WeakMap();
25934
26583
  /**
25935
- * The local signer of each config's `signing` that reads its private key
25936
- * from a secret, so the commands of one run that sign and derive the public
25937
- * key read the secret once.
26584
+ * The private key `signing` signs with, or null when it signs nothing.
26585
+ * Throws `INVALID_SIGNING_MESSAGE`, with the next step, for a value
26586
+ * `signing` doesn't take.
25938
26587
  */
25939
- const secretSigners = /* @__PURE__ */ new WeakMap();
25940
- const getSigningProvider = (signing) => {
25941
- const normalized = normalizeSigningConfig(signing);
25942
- if (!normalized) return null;
25943
- if (!("enabled" in normalized)) return normalized;
25944
- if (normalized.privateKey === void 0) return createLocalSigningAdapter(normalized);
25945
- const key = signing;
25946
- const cached = secretSigners.get(key);
26588
+ const loadSigningKey = (signing, { cwd = getCwd(), secrets }) => {
26589
+ const state = readBundleSigningState(signing);
26590
+ if (state.status === "invalid") throw new BundleSigningKeyError(`${state.message}\n${INVALID_SIGNING_NEXT_STEP} ${PUT_SIGNING_KEY_COMMAND}`);
26591
+ if (state.status !== "on") return null;
26592
+ const store = secrets ?? getConfiguredSecrets();
26593
+ const cacheKey = "secret" in state.key ? store : resolvePath(cwd, state.key.privateKeyPath);
26594
+ const cache = privateKeys.get(signing) ?? /* @__PURE__ */ new Map();
26595
+ privateKeys.set(signing, cache);
26596
+ const cached = cache.get(cacheKey);
25947
26597
  if (cached) return cached;
25948
- const local = createLocalSigningAdapter(normalized, { secrets: getSigningSecrets(key) });
25949
- secretSigners.set(key, local);
25950
- return local;
26598
+ const pending = ("secret" in state.key ? loadPrivateKeySecret(store, state.key.secret, cwd) : loadPrivateKeyFile(cacheKey)).catch((error) => {
26599
+ cache.delete(cacheKey);
26600
+ throw error;
26601
+ });
26602
+ cache.set(cacheKey, pending);
26603
+ return pending;
25951
26604
  };
25952
- const getProviderPublicKey = async (provider, cwd) => {
26605
+ const signingFailure = (summary, error) => new Error(error instanceof BundleSigningKeyError ? error.message : `${summary} ${error instanceof Error ? error.message : String(error)}`, { cause: error });
26606
+ /**
26607
+ * Prepares the run's bundle signing: the public key the app must embed, and
26608
+ * a signer of SHA-256 file hashes that signs each hash once. Null when
26609
+ * `signing` is omitted or `{ enabled: false }`.
26610
+ */
26611
+ const prepareBundleSigning = async (signing, options = {}) => {
26612
+ const loading = loadSigningKey(signing, options);
26613
+ if (!loading) return null;
26614
+ let privateKey;
25953
26615
  try {
25954
- const result = await provider.getPublicKey({ cwd });
25955
- if (!result || typeof result.publicKey !== "string") throw new Error("invalid result");
25956
- return parseRsaPublicKey(result.publicKey);
26616
+ privateKey = await loading;
25957
26617
  } catch (error) {
25958
- throw signingFailure("Failed to resolve the bundle signing provider public key.", error);
26618
+ throw signingFailure("Failed to read the bundle signing key.", error);
25959
26619
  }
25960
- };
25961
- const createMemoizedSigner = ({ publicKey, sign }) => {
25962
26620
  const signatures = /* @__PURE__ */ new Map();
25963
- return (fileHash) => {
25964
- if (!FILE_HASH_PATTERN.test(fileHash)) return Promise.reject(/* @__PURE__ */ new Error("Bundle signing requires a 64-character hexadecimal file hash."));
25965
- const normalizedFileHash = fileHash.toLowerCase();
25966
- const cached = signatures.get(normalizedFileHash);
25967
- if (cached) return cached;
25968
- const pending = (async () => {
25969
- const message = Buffer.from(normalizedFileHash, "hex");
25970
- let signature;
25971
- try {
25972
- signature = await sign(new Uint8Array(message));
25973
- } catch (error) {
25974
- throw signingFailure("Bundle signing provider failed to sign the file hash.", error);
25975
- }
25976
- if (!(signature instanceof Uint8Array) || signature.byteLength === 0) throw new Error("Bundle signing provider returned an invalid signature.");
25977
- 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.");
25978
- return Buffer.from(signature).toString("base64");
25979
- })().catch((error) => {
25980
- signatures.delete(normalizedFileHash);
25981
- throw error;
25982
- });
25983
- signatures.set(normalizedFileHash, pending);
25984
- return pending;
25985
- };
25986
- };
25987
- const preparePluginSigning = async (signing, cwd) => {
25988
- const providerPublicKey = await getProviderPublicKey(signing, cwd);
25989
26621
  return {
25990
- name: signing.name,
25991
- publicKey: exportPublicKey(providerPublicKey),
25992
- signFileHash: createMemoizedSigner({
25993
- publicKey: providerPublicKey,
25994
- sign: async (message) => {
25995
- return (await signing.sign({
25996
- cwd,
25997
- message
25998
- })).signature;
25999
- }
26000
- })
26622
+ publicKey: exportPublicKey(privateKey),
26623
+ signFileHash: async (fileHash) => {
26624
+ if (!FILE_HASH_PATTERN.test(fileHash)) throw new Error("Bundle signing requires a 64-character hexadecimal file hash.");
26625
+ const normalized = fileHash.toLowerCase();
26626
+ const cached = signatures.get(normalized);
26627
+ if (cached) return cached;
26628
+ const signature = crypto$1.sign("RSA-SHA256", Buffer.from(normalized, "hex"), privateKey).toString("base64");
26629
+ signatures.set(normalized, signature);
26630
+ return signature;
26631
+ }
26001
26632
  };
26002
26633
  };
26003
- const prepareBundleSigning = async (signing, options = {}) => {
26004
- const provider = getSigningProvider(signing);
26005
- if (!provider) return null;
26006
- return preparePluginSigning(provider, options.cwd ?? getCwd());
26007
- };
26008
- /** Resolves the public identity of the configured bundle signer. */
26634
+ /**
26635
+ * The public key of the configured bundle signing key, in SPKI PEM; null
26636
+ * when `signing` signs nothing.
26637
+ */
26009
26638
  const getBundleSigningPublicKey = async (signing, options = {}) => {
26010
- const provider = getSigningProvider(signing);
26011
- if (!provider) return null;
26012
- return exportPublicKey(await getProviderPublicKey(provider, options.cwd ?? getCwd()));
26639
+ const loading = loadSigningKey(signing, options);
26640
+ if (!loading) return null;
26641
+ try {
26642
+ return exportPublicKey(await loading);
26643
+ } catch (error) {
26644
+ throw signingFailure("Failed to read the bundle signing key.", error);
26645
+ }
26013
26646
  };
26014
26647
  //#endregion
26015
26648
  //#region src/ConfigBuilder.ts
@@ -38605,421 +39238,6 @@ const createZip = async ({ outfile, targetDir, excludeExts = [] }) => {
38605
39238
  return outfile;
38606
39239
  };
38607
39240
  //#endregion
38608
- //#region src/credentialFiles.ts
38609
- const TERMINATION_SIGNALS = [
38610
- "SIGHUP",
38611
- "SIGINT",
38612
- "SIGTERM"
38613
- ];
38614
- const liveDirectories = /* @__PURE__ */ new Set();
38615
- const onSignal = (signal) => {
38616
- removeLiveDirectories();
38617
- if (process.listenerCount(signal) === 0) process.kill(process.pid, signal);
38618
- };
38619
- const removeLiveDirectories = () => {
38620
- for (const directory of liveDirectories) rmSync(directory, {
38621
- force: true,
38622
- recursive: true
38623
- });
38624
- liveDirectories.clear();
38625
- stopListening();
38626
- };
38627
- const startListening = () => {
38628
- process.on("exit", removeLiveDirectories);
38629
- for (const signal of TERMINATION_SIGNALS) process.on(signal, onSignal);
38630
- };
38631
- const stopListening = () => {
38632
- process.off("exit", removeLiveDirectories);
38633
- for (const signal of TERMINATION_SIGNALS) process.off(signal, onSignal);
38634
- };
38635
- const trackDirectory = (directory) => {
38636
- if (liveDirectories.size === 0) startListening();
38637
- liveDirectories.add(directory);
38638
- };
38639
- const untrackDirectory = (directory) => {
38640
- if (liveDirectories.delete(directory) && liveDirectories.size === 0) stopListening();
38641
- };
38642
- /**
38643
- * Creates the temporary files of one credential resolver: a `mkdtemp`
38644
- * directory, created on the first write and removed by `dispose` or, if the
38645
- * process exits first, on `exit` or a termination signal.
38646
- */
38647
- const createCredentialFiles = () => {
38648
- let directory;
38649
- let disposal;
38650
- const ensureDirectory = () => {
38651
- directory ??= fs$2.mkdtemp(path$1.join(os.tmpdir(), "hot-updater-credentials-")).then((created) => {
38652
- trackDirectory(created);
38653
- return created;
38654
- });
38655
- return directory;
38656
- };
38657
- return {
38658
- write: async (fileName, contents) => {
38659
- if (disposal) throw new Error("The credential files have been disposed.");
38660
- if (typeof contents === "string") registerSecretValue(contents);
38661
- const fileDirectory = await fs$2.mkdtemp(path$1.join(await ensureDirectory(), "item-"));
38662
- const filePath = path$1.join(fileDirectory, path$1.basename(fileName));
38663
- await fs$2.writeFile(filePath, contents, {
38664
- flag: "wx",
38665
- mode: 384
38666
- });
38667
- return filePath;
38668
- },
38669
- dispose: () => {
38670
- disposal ??= (async () => {
38671
- const created = await directory?.catch(() => void 0);
38672
- if (!created) return;
38673
- await fs$2.rm(created, {
38674
- force: true,
38675
- recursive: true
38676
- });
38677
- untrackDirectory(created);
38678
- })();
38679
- return disposal;
38680
- }
38681
- };
38682
- };
38683
- //#endregion
38684
- //#region src/credentialResolver.ts
38685
- /** Passwords are read as set: trimming would change them. */
38686
- const UNTRIMMED_ENV_NAMES = /* @__PURE__ */ new Set(["HOT_UPDATER_ANDROID_KEYSTORE_PASSWORD", "HOT_UPDATER_ANDROID_KEY_PASSWORD"]);
38687
- const describeField = ([name, alternative]) => alternative ? `${name} (or ${alternative})` : name;
38688
- const describeFields = (fields) => {
38689
- const described = fields.map(describeField);
38690
- if (described.length <= 2) return described.join(" and ");
38691
- return `${described.slice(0, -1).join(", ")}, and ${described.at(-1)}`;
38692
- };
38693
- /**
38694
- * The sentence every "credential unavailable" message ends with: the
38695
- * environment variables that provide `kind` whatever the secrets adapter.
38696
- */
38697
- const credentialOverrideHint = (kind) => `Or set ${describeFields(CREDENTIAL_ENV_NAMES[kind])} to provide ${kind} from the environment.`;
38698
- /**
38699
- * The command that stores a credential of `kind` you have as `name` through
38700
- * the config's `secrets` adapter, from its files.
38701
- */
38702
- const credentialPutCommand = (kind, name = kind) => {
38703
- const put = `npx hot-updater secrets put ${name} --kind ${kind}`;
38704
- switch (kind) {
38705
- case "android-keystore": return `${put} --keystore <release.jks> --key-alias <alias>`;
38706
- case "asc-api-key": return `${put} --p8 <AuthKey.p8> --key-id <id> --issuer-id <id>`;
38707
- case "play-service-account": return `${put} --service-account <key.json>`;
38708
- }
38709
- };
38710
- /**
38711
- * The command that signs in once, at a terminal, and stores the key Hot
38712
- * Updater issues as `name`: an App Store Connect API key after an Apple
38713
- * Account sign-in, a Play service account after a Google one.
38714
- */
38715
- const credentialIssueCommand = (kind, name = kind) => `npx hot-updater secrets issue ${kind}${name === kind ? "" : ` ${name}`}`;
38716
- /** The command group of each issued kind's shortcut, `<group> login`. */
38717
- const CREDENTIAL_LOGIN_GROUPS = {
38718
- "asc-api-key": "apple",
38719
- "play-service-account": "google-play"
38720
- };
38721
- /**
38722
- * The command people meet first that issues `kind` as `name`: its shortcut,
38723
- * `apple login` for an App Store Connect API key or `google-play login`
38724
- * for a Play service account, which takes nothing and stores the kind's
38725
- * own name; for another name, `credentialIssueCommand`.
38726
- */
38727
- const credentialLoginCommand = (kind, name = kind) => name === kind ? `npx hot-updater ${CREDENTIAL_LOGIN_GROUPS[kind]} login` : credentialIssueCommand(kind, name);
38728
- /**
38729
- * The command that stores a missing credential of `kind` as `name`: the
38730
- * keystore you have, or the key Hot Updater issues after a sign-in.
38731
- */
38732
- const credentialStoreCommand = (kind, name = kind) => kind === "android-keystore" ? credentialPutCommand(kind, name) : credentialLoginCommand(kind, name);
38733
- var CredentialUnavailableError = class extends Error {
38734
- kind;
38735
- /** The environment variables that can provide this credential. */
38736
- envNames;
38737
- constructor(kind, message, options) {
38738
- super(message, options);
38739
- this.name = "CredentialUnavailableError";
38740
- this.kind = kind;
38741
- this.envNames = CREDENTIAL_ENV_NAMES[kind].flat();
38742
- }
38743
- };
38744
- const readEnvField = (env, field) => {
38745
- for (const name of field) {
38746
- const value = UNTRIMMED_ENV_NAMES.has(name) ? (process.env[name] ?? env[name]) || void 0 : getHotUpdaterEnvValue(env, name);
38747
- if (value !== void 0) return {
38748
- name,
38749
- value
38750
- };
38751
- }
38752
- };
38753
- const readFileField = async (kind, { name, value }) => {
38754
- const filePath = path$1.resolve(value);
38755
- if (!await fs$2.stat(filePath).then(async (stats) => {
38756
- await fs$2.access(filePath, fs$2.constants.R_OK);
38757
- return stats.isFile();
38758
- }).catch(() => false)) throw new CredentialUnavailableError(kind, `${name} does not point to a readable file.`);
38759
- return filePath;
38760
- };
38761
- const parseServiceAccount = (json) => {
38762
- try {
38763
- return JSON.parse(json);
38764
- } catch {
38765
- return;
38766
- }
38767
- };
38768
- const fromEnv = {
38769
- "asc-api-key": async ([key, keyId, issuerId], { files }) => ({
38770
- 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")),
38771
- keyId: keyId.value,
38772
- issuerId: issuerId.value
38773
- }),
38774
- "android-keystore": async ([keystore, storePassword, keyAlias, keyPassword], { files, name }) => {
38775
- let storeFile;
38776
- if (keystore.name === "HOT_UPDATER_ANDROID_KEYSTORE_PATH") storeFile = await readFileField("android-keystore", keystore);
38777
- else {
38778
- registerSecretValue(keystore.value);
38779
- const contents = decodeBase64(keystore.value);
38780
- if (!contents) throw new CredentialUnavailableError("android-keystore", `${keystore.name} is not a base64-encoded keystore.`);
38781
- storeFile = await files.write(`${name}.keystore`, contents);
38782
- }
38783
- return {
38784
- storeFile,
38785
- storePassword: storePassword.value,
38786
- keyAlias: keyAlias.value,
38787
- keyPassword: keyPassword.value
38788
- };
38789
- },
38790
- "play-service-account": async ([serviceAccount]) => {
38791
- const json = serviceAccount.name === "HOT_UPDATER_PLAY_SERVICE_ACCOUNT_PATH" ? await fs$2.readFile(await readFileField("play-service-account", serviceAccount), "utf-8") : serviceAccount.value;
38792
- const parsed = parseServiceAccount(json);
38793
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new CredentialUnavailableError("play-service-account", `${serviceAccount.name} is not a service account JSON object.`);
38794
- return { json };
38795
- }
38796
- };
38797
- /** Fields of a service account that are no secret: its type and Google's endpoints. */
38798
- /**
38799
- * Fields of a JSON credential that identify it rather than grant access:
38800
- * its IDs, key alias, and a service account's email, project, type, and
38801
- * Google's endpoints. They stay readable, as messages name them, and a
38802
- * short one, such as the alias `release`, would mask every word it is in.
38803
- */
38804
- const isIdentifierField = (field) => [
38805
- "keyId",
38806
- "issuerId",
38807
- "keyAlias",
38808
- "type",
38809
- "project_id",
38810
- "client_email",
38811
- "client_id",
38812
- "private_key_id",
38813
- "universe_domain"
38814
- ].includes(field) || field.endsWith("_uri") || field.endsWith("_url");
38815
- const registerJsonSecrets = (value, fields) => {
38816
- registerSecretValue(value);
38817
- for (const [field, entry] of Object.entries(fields)) if (typeof entry === "string" && !isIdentifierField(field)) registerSecretValue(entry);
38818
- };
38819
- /**
38820
- * Masks a credential's JSON value in the CLI's output: the whole value, and
38821
- * each field but those that identify it, such as `keyId` and `keyAlias`,
38822
- * so a password or a key a log prints alone stays masked. A value that is
38823
- * no JSON object is masked whole.
38824
- */
38825
- const registerCredentialValue = (value) => {
38826
- const parsed = parseObject(value);
38827
- if (parsed) registerJsonSecrets(value, parsed);
38828
- else registerSecretValue(value);
38829
- };
38830
- const parseObject = (value) => {
38831
- const parsed = parseServiceAccount(value);
38832
- return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : void 0;
38833
- };
38834
- /**
38835
- * The fields of a JSON credential, each a non-empty string, or an error
38836
- * naming the shape `kind` takes.
38837
- */
38838
- const readJsonFields = (kind, item, value, fields) => {
38839
- const parsed = parseObject(value);
38840
- const shape = `{${fields.map((field) => `"${field}"`).join(", ")}}`;
38841
- if (!parsed) throw new CredentialUnavailableError(kind, `${item} is not a JSON object: ${aKind(kind)} is one JSON value, ${shape}.`);
38842
- registerJsonSecrets(value, parsed);
38843
- const missing = fields.filter((field) => typeof parsed[field] !== "string" || !parsed[field]);
38844
- if (missing.length > 0) throw new CredentialUnavailableError(kind, `${item} has no ${missing.join(", ")}: ${aKind(kind)} is one JSON value, ${shape}.`);
38845
- return parsed;
38846
- };
38847
- const aKind = (kind) => `${/^[aeiou]/u.test(kind) ? "an" : "a"} ${kind}`;
38848
- /** A base64 value's bytes, or undefined when it is not base64. */
38849
- const decodeBase64 = (value) => {
38850
- const base64 = value.replace(/\s/g, "");
38851
- const contents = Buffer.from(base64, "base64");
38852
- return contents.length > 0 && contents.toString("base64").replace(/=+$/, "") === base64.replace(/=+$/, "") ? contents : void 0;
38853
- };
38854
- /**
38855
- * Each kind from the one string a secrets adapter holds for it: a JSON
38856
- * object for the App Store Connect key (`p8` is the PEM) and the keystore
38857
- * (`keystore` is base64), and the service account's own JSON. File values
38858
- * are written with `files`; every value is masked.
38859
- */
38860
- const fromSecretValue = {
38861
- "asc-api-key": async (value, { files, item }) => {
38862
- const { keyId, issuerId, p8 } = readJsonFields("asc-api-key", item, value, [
38863
- "keyId",
38864
- "issuerId",
38865
- "p8"
38866
- ]);
38867
- return {
38868
- keyPath: await files.write(`AuthKey_${keyId}.p8`, p8.includes("\n") ? p8 : p8.replaceAll("\\n", "\n")),
38869
- keyId,
38870
- issuerId
38871
- };
38872
- },
38873
- "android-keystore": async (value, { files, name, item }) => {
38874
- const { keystore, storePassword, keyAlias, keyPassword } = readJsonFields("android-keystore", item, value, [
38875
- "keystore",
38876
- "storePassword",
38877
- "keyAlias",
38878
- "keyPassword"
38879
- ]);
38880
- const contents = decodeBase64(keystore);
38881
- if (!contents) throw new CredentialUnavailableError("android-keystore", `${item}'s keystore is not a base64-encoded keystore.`);
38882
- return {
38883
- storeFile: await files.write(`${name}.keystore`, contents),
38884
- storePassword,
38885
- keyAlias,
38886
- keyPassword
38887
- };
38888
- },
38889
- "play-service-account": async (value, { item }) => {
38890
- const parsed = parseObject(value);
38891
- if (!parsed) throw new CredentialUnavailableError("play-service-account", `${item} is not a service account JSON object.`);
38892
- registerJsonSecrets(value, parsed);
38893
- return { json: value };
38894
- }
38895
- };
38896
- const registerCredentialSecrets = (kind, credential) => {
38897
- if (kind === "android-keystore") {
38898
- const keystore = credential;
38899
- registerSecretValue(keystore.storePassword);
38900
- registerSecretValue(keystore.keyPassword);
38901
- }
38902
- if (kind === "play-service-account") {
38903
- const { json } = credential;
38904
- registerSecretValue(json);
38905
- const privateKey = parseServiceAccount(json)?.private_key;
38906
- if (typeof privateKey === "string") registerSecretValue(privateKey);
38907
- }
38908
- };
38909
- /**
38910
- * Reads names from `secrets`, the names asked for in one turn of the event
38911
- * loop, such as a build's credentials, in one `get`. Nothing is cached.
38912
- */
38913
- const createSecretLoader = (secrets, cwd) => {
38914
- let batch;
38915
- const flush = async (names) => {
38916
- try {
38917
- const read = await secrets().get([...names.keys()], { cwd: cwd() });
38918
- for (const [name, waiters] of names) {
38919
- const value = Object.hasOwn(read, name) ? read[name] : void 0;
38920
- for (const waiter of waiters) waiter.resolve(typeof value === "string" && value ? value : void 0);
38921
- }
38922
- } catch (error) {
38923
- for (const waiters of names.values()) for (const waiter of waiters) waiter.reject(error);
38924
- }
38925
- };
38926
- return (name) => new Promise((resolve, reject) => {
38927
- if (batch === void 0) {
38928
- const names = /* @__PURE__ */ new Map();
38929
- batch = names;
38930
- setTimeout(() => {
38931
- batch = void 0;
38932
- flush(names);
38933
- }, 0);
38934
- }
38935
- const waiters = batch.get(name) ?? [];
38936
- waiters.push({
38937
- resolve,
38938
- reject
38939
- });
38940
- batch.set(name, waiters);
38941
- });
38942
- };
38943
- /**
38944
- * Resolves each item from its environment overrides when any is set, and
38945
- * otherwise from the config's `secrets` adapter as one value named by the
38946
- * item, so overrides never reach the store. When the adapter fails or holds
38947
- * no such value, the error names the environment variables that can
38948
- * provide the item instead. Nothing is cached, values are never written to
38949
- * disk except as 0600 temporary files, are never set in `process.env`, and
38950
- * every secret is masked in logs.
38951
- */
38952
- const createCredentialResolver = ({ env = {}, secrets, cwd } = {}) => {
38953
- const files = createCredentialFiles();
38954
- const resolved = /* @__PURE__ */ new Map();
38955
- let disposal;
38956
- const adapter = () => typeof secrets === "function" ? secrets() : secrets ?? getConfiguredSecrets();
38957
- const load = createSecretLoader(adapter, () => cwd ?? getCwd());
38958
- const resolveItem = async (kind, name) => {
38959
- const fields = CREDENTIAL_ENV_NAMES[kind];
38960
- const values = fields.map((field) => readEnvField(env, field));
38961
- if (values.some((value) => value !== void 0)) {
38962
- const missing = fields.filter((_, index) => !values[index]);
38963
- if (missing.length > 0) throw new CredentialUnavailableError(kind, `The environment overrides ${kind} but ${describeFields(missing)} ${missing.length === 1 ? "is" : "are"} not set.`);
38964
- const credential = await fromEnv[kind](values, {
38965
- files,
38966
- name
38967
- });
38968
- registerCredentialSecrets(kind, credential);
38969
- return credential;
38970
- }
38971
- const store = adapter();
38972
- const item = name === kind ? kind : `${name} (${kind})`;
38973
- let value;
38974
- try {
38975
- value = await load(name);
38976
- } catch (error) {
38977
- const reason = error instanceof Error ? error.message : String(error);
38978
- throw new CredentialUnavailableError(kind, `The ${store.name} secrets adapter could not read ${item}: ${reason} ${credentialOverrideHint(kind)}`, { cause: error });
38979
- }
38980
- if (value === void 0) throw new CredentialUnavailableError(kind, `${item} is not in the ${store.name} secrets adapter (${describeSecret(store, name)}). Store it once with \`${credentialStoreCommand(kind, name)}\`. ${credentialOverrideHint(kind)}`);
38981
- registerSecretValue(value);
38982
- return fromSecretValue[kind](value, {
38983
- files,
38984
- name,
38985
- item: `The ${store.name} secrets adapter's ${item}`
38986
- });
38987
- };
38988
- return {
38989
- resolve: ({ kind, name = kind }) => {
38990
- if (disposal) return Promise.reject(/* @__PURE__ */ new Error("The credential resolver has been disposed."));
38991
- const key = `${kind}\u0000${name}`;
38992
- let pending = resolved.get(key);
38993
- if (!pending) {
38994
- pending = resolveItem(kind, name);
38995
- resolved.set(key, pending);
38996
- pending.catch(() => resolved.delete(key));
38997
- }
38998
- return pending;
38999
- },
39000
- dispose: () => {
39001
- disposal ??= (async () => {
39002
- await Promise.allSettled(resolved.values());
39003
- resolved.clear();
39004
- await files.dispose();
39005
- })();
39006
- return disposal;
39007
- }
39008
- };
39009
- };
39010
- /**
39011
- * Runs `run` with a credential resolver and deletes its temporary files
39012
- * when `run` settles.
39013
- */
39014
- const withCredentialResolver = async (run, options) => {
39015
- const resolver = createCredentialResolver(options);
39016
- try {
39017
- return await run(resolver);
39018
- } finally {
39019
- await resolver.dispose();
39020
- }
39021
- };
39022
- //#endregion
39023
39241
  //#region src/isObject.ts
39024
39242
  const isObject$2 = (value) => {
39025
39243
  return !!value && value.constructor === Object;
@@ -45483,7 +45701,10 @@ const MANAGED_IMPORT_PACKAGES = /* @__PURE__ */ new Set([
45483
45701
  const MANAGED_HELPER_NAMES = /* @__PURE__ */ new Set([
45484
45702
  "awsOptions",
45485
45703
  "commonOptions",
45486
- "credential"
45704
+ "credential",
45705
+ "databaseSecrets",
45706
+ "deploySecrets",
45707
+ "storageSecrets"
45487
45708
  ]);
45488
45709
  const KNOWN_BUILD_CALLEES = /* @__PURE__ */ new Set([
45489
45710
  "bare",
@@ -45556,6 +45777,21 @@ const getObjectPropertyName = (property) => {
45556
45777
  return null;
45557
45778
  };
45558
45779
  const isDataProperty = (property) => property.type === "Property" && property.kind === "init" && !property.method && !property.shorthand;
45780
+ /** `process.env.HOT_UPDATER_*`, with or without `!`. */
45781
+ const isHotUpdaterEnvRead = (expression) => {
45782
+ const read = expression.type === "TSNonNullExpression" ? expression.expression : expression;
45783
+ 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";
45784
+ };
45785
+ /** `secret("<name>")`, the credential init writes. */
45786
+ 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";
45787
+ /**
45788
+ * A value init writes into a managed adapter's options: a string, a
45789
+ * `process.env.HOT_UPDATER_*` read, as configs of 1.1 read every value, a
45790
+ * `secret("<name>")`, or an object of only those, such as `credentials`.
45791
+ * Init replaces it with what its run resolves; any other value is the
45792
+ * project's own and kept.
45793
+ */
45794
+ 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));
45559
45795
  const dedentBlock = (text) => {
45560
45796
  const lines = text.replace(/\s+$/, "").split("\n");
45561
45797
  const indents = lines.filter((line) => line.trim() !== "").map((line) => line.match(/^\s*/)?.[0].length ?? 0);
@@ -45628,9 +45864,17 @@ const mergeObjectLiteralText = (existingObject, newObject, replaceIncompatiblePr
45628
45864
  existingPropertyNames.add(propertyName);
45629
45865
  const nextProperty = newObject.objectExpression.properties.find((candidate) => getObjectPropertyName(candidate) === propertyName);
45630
45866
  if (!nextProperty || !isDataProperty(property) || !isDataProperty(nextProperty)) continue;
45867
+ if (isInitWrittenValue(property.value)) {
45868
+ edits.push({
45869
+ start: property.value.start - existingObject.objectExpression.start,
45870
+ end: property.value.end - existingObject.objectExpression.start,
45871
+ text: getNodeText(newObject.source, nextProperty.value)
45872
+ });
45873
+ continue;
45874
+ }
45631
45875
  const existingCallee = getCallCallee(property.value);
45632
45876
  const nextCallee = getCallCallee(nextProperty.value);
45633
- const hasIncompatibleValue = existingCallee !== null && nextCallee !== null && existingCallee !== nextCallee || property.value.type === "ObjectExpression" !== (nextProperty.value.type === "ObjectExpression");
45877
+ const hasIncompatibleValue = existingCallee !== null && nextCallee !== null && existingCallee !== nextCallee || property.value.type !== nextProperty.value.type;
45634
45878
  if (replaceIncompatibleProperties.includes(propertyName) && hasIncompatibleValue) {
45635
45879
  edits.push({
45636
45880
  start: property.value.start - existingObject.objectExpression.start,
@@ -46112,6 +46356,22 @@ const readTextFile = (filePath) => fs$1.readFile(filePath, "utf-8").catch((error
46112
46356
  * existing config with the scaffold's build, storage, database, plugins, and
46113
46357
  * helpers merged in, keeping the project's own settings.
46114
46358
  */
46359
+ /**
46360
+ * Whether `hot-updater.config.ts` in `cwd` sets a team `secrets` adapter,
46361
+ * read from its text: a `secrets` property other than `localSecrets(...)`,
46362
+ * or a config whose shape init can't read. False without the file. Init
46363
+ * reads the store through the loaded config, so one that sets a store but
46364
+ * doesn't load must be fixed first.
46365
+ */
46366
+ const configSetsTeamSecrets = async (cwd) => {
46367
+ const text = await readTextFile(path.join(cwd, HOT_UPDATER_CONFIG_PATH));
46368
+ if (text === null) return false;
46369
+ const source = parseConfigSource(text);
46370
+ const config = source && findDefineConfigObject(source);
46371
+ if (!config) return true;
46372
+ const secrets = findAnyProperty(config.objectExpression, "secrets");
46373
+ return secrets !== null && getCallCallee(secrets.value) !== "localSecrets";
46374
+ };
46115
46375
  const writeHotUpdaterConfig = async (scaffold, filePath = HOT_UPDATER_CONFIG_PATH) => {
46116
46376
  const existingText = await readTextFile(filePath);
46117
46377
  if (existingText === null) {
@@ -46167,6 +46427,75 @@ const writeHotUpdaterFiles = async (scaffold, { cwd = process.cwd(), settings })
46167
46427
  ].join("\n"));
46168
46428
  return { config };
46169
46429
  };
46430
+ /**
46431
+ * The config text with `signing: secret("<name>")` added, and `secret` in
46432
+ * its `hot-updater` import, once. A config that sets `signing` in any form,
46433
+ * such as 1.1's `{ enabled: true, privateKeyPath }`, is kept.
46434
+ */
46435
+ const addSigningSecretToConfigText = (text, name) => {
46436
+ const source = parseConfigSource(text);
46437
+ const config = source && findDefineConfigObject(source);
46438
+ if (!source || !config) return { status: "unsupported" };
46439
+ const { objectExpression } = config;
46440
+ if (objectExpression.properties.some((property) => property.type === "SpreadElement" || getObjectPropertyName(property) === "signing")) return { status: "kept" };
46441
+ const edits = [];
46442
+ const property = `signing: secret(${JSON.stringify(name)})`;
46443
+ const last = objectExpression.properties.at(-1);
46444
+ if (!last) edits.push({
46445
+ start: objectExpression.start,
46446
+ end: objectExpression.end,
46447
+ text: `{\n ${property},\n}`
46448
+ });
46449
+ else {
46450
+ const lineStart = text.lastIndexOf("\n", last.start) + 1;
46451
+ const indent = /^[ \t]*/u.exec(text.slice(lineStart, last.start))[0];
46452
+ const between = text.slice(last.end, objectExpression.end - 1);
46453
+ const comma = /^\s*,/u.exec(between);
46454
+ let at = last.end + (comma?.[0].length ?? 0);
46455
+ const lineEnd = text.indexOf("\n", at);
46456
+ if (lineEnd !== -1 && text.slice(at, lineEnd).trim().startsWith("//")) at = lineEnd;
46457
+ edits.push({
46458
+ start: at,
46459
+ end: at,
46460
+ text: `\n${indent}${property},`
46461
+ });
46462
+ if (!comma) edits.push({
46463
+ start: last.end,
46464
+ end: last.end,
46465
+ text: ","
46466
+ });
46467
+ }
46468
+ const hotUpdaterImports = importDeclarationsOf(source).filter((declaration) => declaration.source.value === "hot-updater" && declaration.importKind !== "type");
46469
+ if (!hotUpdaterImports.some((declaration) => declaration.specifiers.some((specifier) => specifier.type === "ImportSpecifier" && specifier.local.name === "secret"))) {
46470
+ const named = hotUpdaterImports.find((declaration) => declaration.specifiers.some((specifier) => specifier.type === "ImportSpecifier"));
46471
+ const lastSpecifier = named?.specifiers.at(-1);
46472
+ edits.push(named && lastSpecifier ? {
46473
+ start: lastSpecifier.end,
46474
+ end: lastSpecifier.end,
46475
+ text: ", secret"
46476
+ } : {
46477
+ start: 0,
46478
+ end: 0,
46479
+ text: "import { secret } from \"hot-updater\";\n"
46480
+ });
46481
+ }
46482
+ return {
46483
+ status: "added",
46484
+ text: applyTextEdits(text, edits)
46485
+ };
46486
+ };
46487
+ /**
46488
+ * Adds `signing: secret("<name>")` to `hot-updater.config.ts` in `cwd`, as
46489
+ * `addSigningSecretToConfigText` does. Without the file, nothing is written.
46490
+ */
46491
+ const addSigningSecretToConfig = async (cwd, name) => {
46492
+ const filePath = path.join(cwd, HOT_UPDATER_CONFIG_PATH);
46493
+ const text = await readTextFile(filePath);
46494
+ if (text === null) return "missing";
46495
+ const result = addSigningSecretToConfigText(text, name);
46496
+ if (result.status === "added") await fs$1.writeFile(filePath, result.text, "utf-8");
46497
+ return result.status;
46498
+ };
46170
46499
  //#endregion
46171
46500
  //#region src/infrastructureGeneration.ts
46172
46501
  const assertInfrastructureGenerationPayload = ({ payload, provider, resource }) => {
@@ -47000,8 +47329,8 @@ const loadConfigFile = async () => {
47000
47329
  };
47001
47330
  /**
47002
47331
  * The config `options` select, resolved: a config function runs in a scope
47003
- * of its own, so the `secretEnv` it creates reads this config's `secrets`,
47004
- * and the file's top-level `secretEnv` reads the first config's.
47332
+ * of its own, so the `secret()` it creates reads this config's `secrets`,
47333
+ * and the file's top-level `secret()` reads the first config's.
47005
47334
  */
47006
47335
  const configFor = async ({ source, scope: fileScope }, options) => {
47007
47336
  if (typeof source !== "function") return resolveConfig(source, [fileScope]);
@@ -47029,21 +47358,49 @@ const loadPlatformConfigs = async (platforms, { channel }) => {
47029
47358
  return configs;
47030
47359
  };
47031
47360
  /**
47361
+ * The names the config's build credentials reference, which the CLI reads
47362
+ * for their kind: `signing`'s, and each scheme's
47363
+ * `signing.ascApiKey` and `signing.keystore`.
47364
+ */
47365
+ const buildSecretNames = (config) => {
47366
+ return [
47367
+ bundleSigningSecretRef(config.signing),
47368
+ ...Object.values(config.nativeBuild?.ios ?? {}).map((scheme) => scheme?.signing?.ascApiKey),
47369
+ ...Object.values(config.nativeBuild?.android ?? {}).map((scheme) => scheme?.signing?.keystore)
47370
+ ].filter(isSecretRef).map(({ name }) => name);
47371
+ };
47372
+ /**
47032
47373
  * The config with its defaults. Its `secrets` adapter fills `scopes` not yet
47033
- * filled, which the `secretEnv` its evaluation created read, and becomes the
47374
+ * filled, which the `secret()` its evaluation created read, and becomes the
47034
47375
  * default of credential resolvers.
47035
47376
  */
47036
47377
  const resolveConfig = (config, scopes) => {
47037
47378
  const mergedConfig = mergeConfigSources(config, getDefaultConfig());
47038
- const secrets = withLocalKeyStore(mergedConfig.secrets);
47039
- const signing = normalizeSigningConfig(mergedConfig.signing, { secrets });
47040
- for (const scope of scopes) scope.secrets ??= secrets;
47379
+ const secrets = resolveSecretsAdapter(mergedConfig.secrets);
47380
+ const buildNames = buildSecretNames(mergedConfig);
47381
+ for (const scope of scopes) {
47382
+ scope.secrets ??= secrets;
47383
+ scope.buildNames ??= /* @__PURE__ */ new Set();
47384
+ for (const name of buildNames) scope.buildNames.add(name);
47385
+ }
47041
47386
  setConfiguredSecrets(secrets);
47042
- return {
47387
+ const resolved = {
47043
47388
  ...mergedConfig,
47044
- signing,
47045
47389
  secrets
47046
47390
  };
47391
+ Object.defineProperty(resolved, DEPLOY_SECRET_NAMES, { value: [...new Set(scopes.flatMap((scope) => [...scope.names ?? []]))].filter((name) => !buildNames.includes(name)) });
47392
+ return resolved;
47393
+ };
47394
+ const DEPLOY_SECRET_NAMES = Symbol.for("@hot-updater/cli-tools/deploy-secret-names");
47395
+ /**
47396
+ * The names the config's `secret()`s reference for its adapters, such as
47397
+ * `HOT_UPDATER_CLOUDFLARE_API_TOKEN`, in the order the config created
47398
+ * them: every name but those of build credentials, which `doctor` lists
47399
+ * by field.
47400
+ */
47401
+ const getDeploySecretNames = (config) => {
47402
+ const names = Reflect.get(config, DEPLOY_SECRET_NAMES);
47403
+ return Array.isArray(names) ? names : [];
47047
47404
  };
47048
47405
  //#endregion
47049
47406
  //#region src/serverDefinition.ts
@@ -47335,6 +47692,76 @@ const resolveHotUpdaterServerVersion = (currentPackageName, options) => {
47335
47692
  return process.env["HOT_UPDATER_SERVER_PACKAGE_VERSION"]?.trim() || resolvePackageVersion(currentPackageName, options);
47336
47693
  };
47337
47694
  //#endregion
47695
+ //#region src/teamSecrets.ts
47696
+ /**
47697
+ * The client API key the managed servers' `apiKeys()` gives an app, which
47698
+ * init saves (`API_KEY_ENV` in `@hot-updater/plugin-api-keys`). The config
47699
+ * never reads it; a team store shares it with the deploy credentials, so a
47700
+ * teammate's init registers the same key again rather than a new one.
47701
+ */
47702
+ const MANAGED_CLIENT_CREDENTIAL_ENV = "HOT_UPDATER_API_KEY";
47703
+ /**
47704
+ * The config's own `secrets` adapter, the team store: undefined when the
47705
+ * config sets none, which is `localSecrets()`. Loading the config runs its
47706
+ * `process.loadEnvFile(".env.hotupdater")`, which init must not see, so
47707
+ * `process.env` is restored after it.
47708
+ */
47709
+ const loadTeamSecrets = async () => {
47710
+ const before = { ...process.env };
47711
+ try {
47712
+ return getConfiguredAdapter((await loadConfig(null)).secrets);
47713
+ } finally {
47714
+ for (const key of Object.keys(process.env)) if (!Object.hasOwn(before, key)) delete process.env[key];
47715
+ Object.assign(process.env, before);
47716
+ }
47717
+ };
47718
+ /**
47719
+ * `names` neither the environment nor `env`, init's env files, sets, read
47720
+ * from `secrets` in one call: the values a teammate's init, or a run after
47721
+ * the file's lines were deleted, replays with.
47722
+ */
47723
+ const readMissingFromTeamSecrets = async (secrets, names, { env, cwd }) => {
47724
+ const missing = names.filter((name) => getHotUpdaterEnvValue(env, name) === void 0);
47725
+ if (missing.length === 0) return {};
47726
+ const held = await readSecrets(secrets, missing, cwd);
47727
+ const values = Object.create(null);
47728
+ for (const name of missing) {
47729
+ const value = Object.hasOwn(held, name) ? held[name] : void 0;
47730
+ if (typeof value === "string" && value !== "") {
47731
+ registerSecretValue(value);
47732
+ values[name] = value;
47733
+ }
47734
+ }
47735
+ return values;
47736
+ };
47737
+ /**
47738
+ * Stores each of `names` that `envFile`, `.env.hotupdater` by default,
47739
+ * holds through `secrets`, then reads them back in one call. Only the file
47740
+ * is read, never the environment. The file is left as it is. Returns the
47741
+ * names stored, in the order of `names`; throws when the adapter reads
47742
+ * back another value.
47743
+ */
47744
+ const storeEnvFileSecrets = async (secrets, names, cwd, envFile) => {
47745
+ const file = envFile === void 0 ? await readLoadedHotUpdaterEnv(cwd) : await readLoadedEnvFile(envFile, cwd);
47746
+ const values = /* @__PURE__ */ new Map();
47747
+ for (const name of names) {
47748
+ const variable = secretVariableName(name);
47749
+ const value = Object.hasOwn(file, variable) ? file[variable]?.trim() : void 0;
47750
+ if (value) values.set(name, value);
47751
+ }
47752
+ if (values.size === 0) return [];
47753
+ const stored = [...values.keys()];
47754
+ for (const [name, value] of values) {
47755
+ registerSecretValue(value);
47756
+ await secrets.set(name, value, { cwd });
47757
+ }
47758
+ const read = await secrets.get(stored, { cwd });
47759
+ const unread = stored.filter((name) => (Object.hasOwn(read, name) ? read[name] : void 0) !== values.get(name));
47760
+ if (unread.length > 0) throw new Error(`The ${secrets.name} secrets adapter did not read back ${list(unread)} (${unread.map((name) => describeSecret(secrets, name)).join("; ")}).`);
47761
+ return stored;
47762
+ };
47763
+ const list = (names) => names.length <= 2 ? names.join(" and ") : `${names.slice(0, -1).join(", ")}, and ${names.at(-1)}`;
47764
+ //#endregion
47338
47765
  //#region src/storageFiles.ts
47339
47766
  const getStorageFileByteSize = async (filePath) => {
47340
47767
  const { size } = await fs$2.stat(filePath, { bigint: true });
@@ -47385,4 +47812,4 @@ function transformTemplate(templateString, values) {
47385
47812
  return result;
47386
47813
  }
47387
47814
  //#endregion
47388
- 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, 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, credentialOverrideHint, credentialPutCommand, 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, getStorageFileByteSize, isCI, isCodegenSpecFile, isReservedVariableName, isSecretName, isServerDefinition, keepsKeysOnThisMachine, link, loadConfig, loadPlatformConfigs, localSecrets, log, makeEnv, maskSecretValues, moduleSpecifiersOf, nonInteractiveReason, p, parseLoadedEnv, parseSecretReference, prepareBundleSigning, printAppSetup, printBanner, provisionClientCredential, putStorageFile, reactNativeCliFingerprint, reactNativeConfigCommand, readBundleSigningPublicKeyFile, readHotUpdaterInitEnv, readLoadedHotUpdaterEnv, readLocalKey, readPackageUp, readReactNativeInterface, readSecretVariables, registerCredentialValue, registerSecretValue, renderAppImports, renderAppSetup, renderImportStatements, resolveExpoAutolinking, resolveHotUpdaterServerVersion, resolveInitProviderInput, resolveInitProviderInputs, resolveLocalSecretsPath, resolvePackageVersion, secretEnv, secretVariableName, serverDefinitionOf, setConfiguredSecrets, shouldAutoSelectOnlyInitResource, stripAnsi, transformEnv, transformTemplate, withCredentialResolver, withLocalKeyStore, withSecretsScope, writeHotUpdaterConfig, writeHotUpdaterFiles, writeLocalKey };
47815
+ 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, MESSAGE_WIDTH, MissingInitInputsError, PUT_SIGNING_KEY_COMMAND, ServerDefinitionError, UNSIGNED_BUNDLES_NEXT_STEP, UNSIGNED_BUNDLES_WARNING, addSigningSecretToConfig, addSigningSecretToConfigText, assembleServer, assertInfrastructureGenerationAtUrl, assertInfrastructureGenerationPayload, assertInitInputs, assertInitProviderInputs, assertUnreservedEnv, autolinkingConfigCommand, banner, bundleSigningPutCommand, 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, describeMissingSecret, describeSecret, describeStoredSecret, encryptJson, ensureInstallPackages, failActiveStep, fitMessage, 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 };