@crustjs/crust-linux-x64 0.5.3 → 0.5.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/dist/index.js CHANGED
@@ -2,14 +2,14 @@ import { accessSync, constants, copyFileSync, cpSync, existsSync, lstatSync, mkd
2
2
  import { chmod, copyFile, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
4
  import { delimiter, dirname, extname, isAbsolute, join, posix, relative, resolve, sep, win32 } from "node:path";
5
- import { spawn, spawnSync } from "node:child_process";
5
+ import { fileURLToPath, pathToFileURL } from "node:url";
6
6
  import { randomBytes } from "node:crypto";
7
+ import { spawn, spawnSync } from "node:child_process";
7
8
  import { once } from "node:events";
8
9
  import { text } from "node:stream/consumers";
9
- import { fileURLToPath, pathToFileURL } from "node:url";
10
10
  //#region \0rolldown/runtime.js
11
11
  var __create = Object.create;
12
- var __defProp$1 = Object.defineProperty;
12
+ var __defProp = Object.defineProperty;
13
13
  var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
14
14
  var __getOwnPropNames = Object.getOwnPropertyNames;
15
15
  var __getProtoOf = Object.getPrototypeOf;
@@ -18,29 +18,112 @@ var __commonJSMin = (cb, mod) => () => (mod || (cb((mod = { exports: {} }).expor
18
18
  var __copyProps = (to, from, except, desc) => {
19
19
  if (from && typeof from === "object" || typeof from === "function") for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
20
20
  key = keys[i];
21
- if (!__hasOwnProp.call(to, key) && key !== except) __defProp$1(to, key, {
21
+ if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, {
22
22
  get: ((k) => from[k]).bind(null, key),
23
23
  enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
24
24
  });
25
25
  }
26
26
  return to;
27
27
  };
28
- var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule || !__hasOwnProp.call(mod, "default") ? __defProp$1(target, "default", {
28
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule || !__hasOwnProp.call(mod, "default") ? __defProp(target, "default", {
29
29
  value: mod,
30
30
  enumerable: true
31
31
  }) : target, mod));
32
32
  //#endregion
33
- //#region ../style/dist/index.js
34
- var __defProp = Object.defineProperty;
35
- var __exportAll = (all, no_symbols) => {
36
- let target = {};
37
- for (var name in all) __defProp(target, name, {
38
- get: all[name],
39
- enumerable: true
40
- });
41
- if (!no_symbols) __defProp(target, Symbol.toStringTag, { value: "Module" });
42
- return target;
33
+ //#region ../utils/dist/artifacts.js
34
+ /**
35
+ * Set by `crust build` while it prepares the Command Snapshot: the absolute
36
+ * entry-isolated directory Extension build hooks write into. Core reads it
37
+ * to run the hooks; `resolveArtifactDir` reads it so sections evaluated during
38
+ * that run see the artifacts being built instead of the wiped `.crust/root`.
39
+ */
40
+ const BUILD_OUT_DIR_ENV = "CRUST_INTERNAL_BUILD_OUT_DIR";
41
+ /**
42
+ * `Symbol.for` registry key of the packaged-build marker, for bundlers without a
43
+ * `define` option (native `deno bundle`). `crust build` prepends
44
+ * `globalThis[Symbol.for(PACKAGED_BUILD_KEY)] = true` to the command bundle,
45
+ * before the inlined application graph, so every bundled copy sees it.
46
+ * The registry key is shared across module copies; the value is never read from
47
+ * the environment, so detection needs no Deno env permission.
48
+ */
49
+ const PACKAGED_BUILD_KEY = "crustjs.packagedBuild";
50
+ //#endregion
51
+ //#region ../core/dist/invocation-BstcKEMM.js
52
+ /**
53
+ * A typed error for runtime recipe, Extension, Context, documentation, argv, and validation failures.
54
+ *
55
+ * Every `CrustError` carries a {@link CrustErrorCode} that identifies the specific
56
+ * failure, enabling programmatic error handling without fragile message parsing.
57
+ *
58
+ * @example
59
+ * ```ts
60
+ * import { CrustError } from "@crustjs/core";
61
+ *
62
+ * const outcome = await app.run(["deploy"], { args: { target: "prod" } });
63
+ * if (outcome.status === "failed") {
64
+ * const err = outcome.error;
65
+ * if (err instanceof CrustError) {
66
+ * console.error(`[${err.code}] ${err.message}`);
67
+ * }
68
+ * }
69
+ * ```
70
+ */
71
+ var CrustError = class extends Error {
72
+ /** Machine-readable error code for programmatic handling */
73
+ code;
74
+ /** Structured payload for programmatic handling */
75
+ details;
76
+ /** Optional wrapped original error/value */
77
+ cause;
78
+ constructor(code, message, ...details) {
79
+ super(message);
80
+ this.name = "CrustError";
81
+ this.code = code;
82
+ this.details = details[0];
83
+ }
84
+ is(code) {
85
+ return Object.is(this.code, code);
86
+ }
87
+ withCause(cause) {
88
+ this.cause = cause;
89
+ return this;
90
+ }
91
+ toJSON() {
92
+ return {
93
+ code: this.code,
94
+ message: this.message,
95
+ details: this.details
96
+ };
97
+ }
43
98
  };
99
+ Object.freeze({ [Symbol("crust.handled")]: true });
100
+ /**
101
+ * Snapshot subprocess protocol used by first-party build tooling.
102
+ *
103
+ * When set to a non-empty file path, `.execute()` prepares the command tree,
104
+ * validates its documentation sections, optionally runs Extension build hooks when the
105
+ * build output directory is set, writes its final JSON snapshot and Build Report, and exits
106
+ * without dispatching a Command Action. In-process callers use `Crust.snapshot()`.
107
+ *
108
+ * Only source entries run by `crust build` honor it: finished Bun/Node bundles carry
109
+ * `process.env.CRUST_INTERNAL_BUILD === "1"` as a literal and compile the protocol out;
110
+ * packaged Deno bundles set the `isPackagedBuild()` marker and skip it without
111
+ * reading the environment.
112
+ */
113
+ const SNAPSHOT_PATH_ENV = "CRUST_INTERNAL_SNAPSHOT_PATH";
114
+ //#endregion
115
+ //#region ../core/dist/tooling.js
116
+ const INSTALLED_COMMAND_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
117
+ /** {@link isInstalledCommandName}'s rule as prose, for error messages: "Use <rule>." */
118
+ const INSTALLED_COMMAND_NAME_RULE = "letters, digits, \".\", \"_\", and \"-\", starting with a letter or digit";
119
+ /**
120
+ * Whether `name` is valid as an installed command name: the `bin` keys
121
+ * `crust build` accepts, the project names `create-crust` scaffolds, and the
122
+ * root names shell completion targets.
123
+ */
124
+ function isInstalledCommandName(name) {
125
+ return INSTALLED_COMMAND_NAME_PATTERN.test(name);
126
+ }
44
127
  new Intl.Segmenter(void 0, { granularity: "grapheme" });
45
128
  new RegExp([
46
129
  String.raw`(?:\x1b\[|\x9b)[^\x1b\x18\x1a\x9c\x40-\x7e]*[\x40-\x7e\x18\x1a\x9c]?`,
@@ -49,154 +132,69 @@ new RegExp([
49
132
  String.raw`\x1b[\x20-\x2f][^\x1b]?`,
50
133
  String.raw`\x1b[\x18\x1a\x9c\x30-\x7e]?`
51
134
  ].join("|"), "g");
52
- var ansiCodes_exports = /* @__PURE__ */ __exportAll({
53
- bgBlack: () => bgBlack$1,
54
- bgBlue: () => bgBlue$1,
55
- bgBrightBlack: () => bgBrightBlack$1,
56
- bgBrightBlue: () => bgBrightBlue$1,
57
- bgBrightCyan: () => bgBrightCyan$1,
58
- bgBrightGreen: () => bgBrightGreen$1,
59
- bgBrightMagenta: () => bgBrightMagenta$1,
60
- bgBrightRed: () => bgBrightRed$1,
61
- bgBrightWhite: () => bgBrightWhite$1,
62
- bgBrightYellow: () => bgBrightYellow$1,
63
- bgCyan: () => bgCyan$1,
64
- bgGreen: () => bgGreen$1,
65
- bgMagenta: () => bgMagenta$1,
66
- bgRed: () => bgRed$1,
67
- bgWhite: () => bgWhite$1,
68
- bgYellow: () => bgYellow$1,
69
- black: () => black$1,
70
- blue: () => blue$1,
71
- bold: () => bold$1,
72
- brightBlue: () => brightBlue$1,
73
- brightCyan: () => brightCyan$1,
74
- brightGreen: () => brightGreen$1,
75
- brightMagenta: () => brightMagenta$1,
76
- brightRed: () => brightRed$1,
77
- brightWhite: () => brightWhite$1,
78
- brightYellow: () => brightYellow$1,
79
- cyan: () => cyan$1,
80
- dim: () => dim$1,
81
- gray: () => gray$1,
82
- green: () => green$1,
83
- hidden: () => hidden$1,
84
- inverse: () => inverse$1,
85
- isModifierName: () => isModifierName,
86
- italic: () => italic$1,
87
- magenta: () => magenta$1,
88
- red: () => red$1,
89
- strikethrough: () => strikethrough$1,
90
- styleMethodNames: () => styleMethodNames,
91
- underline: () => underline$1,
92
- white: () => white$1,
93
- yellow: () => yellow$1
94
- });
95
- function pair(open, close) {
96
- return {
135
+ const modifierCodes = {
136
+ /** Bold / increased intensity. */
137
+ bold: [1, 22],
138
+ /** Dim / decreased intensity. */
139
+ dim: [2, 22],
140
+ italic: [3, 23],
141
+ underline: [4, 24],
142
+ /** Inverse / reverse video. */
143
+ inverse: [7, 27],
144
+ /** Hidden / conceal. */
145
+ hidden: [8, 28],
146
+ /** Strikethrough / crossed out. */
147
+ strikethrough: [9, 29]
148
+ };
149
+ const colorCodes = {
150
+ black: [30, 39],
151
+ red: [31, 39],
152
+ green: [32, 39],
153
+ yellow: [33, 39],
154
+ blue: [34, 39],
155
+ magenta: [35, 39],
156
+ cyan: [36, 39],
157
+ white: [37, 39],
158
+ /** Bright black (gray). */
159
+ gray: [90, 39],
160
+ brightRed: [91, 39],
161
+ brightGreen: [92, 39],
162
+ brightYellow: [93, 39],
163
+ brightBlue: [94, 39],
164
+ brightMagenta: [95, 39],
165
+ brightCyan: [96, 39],
166
+ brightWhite: [97, 39],
167
+ bgBlack: [40, 49],
168
+ bgRed: [41, 49],
169
+ bgGreen: [42, 49],
170
+ bgYellow: [43, 49],
171
+ bgBlue: [44, 49],
172
+ bgMagenta: [45, 49],
173
+ bgCyan: [46, 49],
174
+ bgWhite: [47, 49],
175
+ bgBrightBlack: [100, 49],
176
+ bgBrightRed: [101, 49],
177
+ bgBrightGreen: [102, 49],
178
+ bgBrightYellow: [103, 49],
179
+ bgBrightBlue: [104, 49],
180
+ bgBrightMagenta: [105, 49],
181
+ bgBrightCyan: [106, 49],
182
+ bgBrightWhite: [107, 49]
183
+ };
184
+ const styleMethodCodes = {
185
+ ...modifierCodes,
186
+ ...colorCodes
187
+ };
188
+ const styleMethodNames = Object.freeze(Object.keys(styleMethodCodes));
189
+ const styleMethodPairs = Object.freeze(Object.fromEntries(styleMethodNames.map((name) => {
190
+ const [open, close] = styleMethodCodes[name];
191
+ return [name, {
97
192
  open: `\x1b[${open}m`,
98
193
  close: `\x1b[${close}m`
99
- };
100
- }
101
- /** Bold / increased intensity. */
102
- const bold$1 = pair(1, 22);
103
- /** Dim / decreased intensity. */
104
- const dim$1 = pair(2, 22);
105
- /** Italic. */
106
- const italic$1 = pair(3, 23);
107
- /** Underline. */
108
- const underline$1 = pair(4, 24);
109
- /** Inverse / reverse video. */
110
- const inverse$1 = pair(7, 27);
111
- /** Hidden / conceal. */
112
- const hidden$1 = pair(8, 28);
113
- /** Strikethrough / crossed out. */
114
- const strikethrough$1 = pair(9, 29);
115
- const black$1 = pair(30, 39);
116
- const red$1 = pair(31, 39);
117
- const green$1 = pair(32, 39);
118
- const yellow$1 = pair(33, 39);
119
- const blue$1 = pair(34, 39);
120
- const magenta$1 = pair(35, 39);
121
- const cyan$1 = pair(36, 39);
122
- const white$1 = pair(37, 39);
123
- /** Bright black (gray). */
124
- const gray$1 = pair(90, 39);
125
- const brightRed$1 = pair(91, 39);
126
- const brightGreen$1 = pair(92, 39);
127
- const brightYellow$1 = pair(93, 39);
128
- const brightBlue$1 = pair(94, 39);
129
- const brightMagenta$1 = pair(95, 39);
130
- const brightCyan$1 = pair(96, 39);
131
- const brightWhite$1 = pair(97, 39);
132
- const bgBlack$1 = pair(40, 49);
133
- const bgRed$1 = pair(41, 49);
134
- const bgGreen$1 = pair(42, 49);
135
- const bgYellow$1 = pair(43, 49);
136
- const bgBlue$1 = pair(44, 49);
137
- const bgMagenta$1 = pair(45, 49);
138
- const bgCyan$1 = pair(46, 49);
139
- const bgWhite$1 = pair(47, 49);
140
- const bgBrightBlack$1 = pair(100, 49);
141
- const bgBrightRed$1 = pair(101, 49);
142
- const bgBrightGreen$1 = pair(102, 49);
143
- const bgBrightYellow$1 = pair(103, 49);
144
- const bgBrightBlue$1 = pair(104, 49);
145
- const bgBrightMagenta$1 = pair(105, 49);
146
- const bgBrightCyan$1 = pair(106, 49);
147
- const bgBrightWhite$1 = pair(107, 49);
148
- const styleMethodNames = Object.freeze([
149
- "bold",
150
- "dim",
151
- "italic",
152
- "underline",
153
- "inverse",
154
- "hidden",
155
- "strikethrough",
156
- "black",
157
- "red",
158
- "green",
159
- "yellow",
160
- "blue",
161
- "magenta",
162
- "cyan",
163
- "white",
164
- "gray",
165
- "brightRed",
166
- "brightGreen",
167
- "brightYellow",
168
- "brightBlue",
169
- "brightMagenta",
170
- "brightCyan",
171
- "brightWhite",
172
- "bgBlack",
173
- "bgRed",
174
- "bgGreen",
175
- "bgYellow",
176
- "bgBlue",
177
- "bgMagenta",
178
- "bgCyan",
179
- "bgWhite",
180
- "bgBrightBlack",
181
- "bgBrightRed",
182
- "bgBrightGreen",
183
- "bgBrightYellow",
184
- "bgBrightBlue",
185
- "bgBrightMagenta",
186
- "bgBrightCyan",
187
- "bgBrightWhite"
188
- ]);
189
- const modifierNameSet = /* @__PURE__ */ new Set([
190
- "bold",
191
- "dim",
192
- "italic",
193
- "underline",
194
- "inverse",
195
- "hidden",
196
- "strikethrough"
197
- ]);
194
+ }];
195
+ })));
198
196
  function isModifierName(name) {
199
- return modifierNameSet.has(name);
197
+ return Object.hasOwn(modifierCodes, name);
200
198
  }
201
199
  function read(overrides, key, environment) {
202
200
  return overrides !== void 0 && key in overrides ? overrides[key] : environment;
@@ -1064,15 +1062,15 @@ const namedColorValues = {
1064
1062
  *
1065
1063
  * @example
1066
1064
  * ```ts
1067
- * import { applyStyle } from "./styleEngine.ts";
1068
- * import { bold, red } from "./ansiCodes.ts";
1065
+ * import { applyStyle } from "./style-engine.ts";
1066
+ * import { styleMethodPairs } from "./ansi-codes.ts";
1069
1067
  *
1070
1068
  * // Simple usage
1071
- * applyStyle("hello", bold); // "\x1b[1mhello\x1b[22m"
1069
+ * applyStyle("hello", styleMethodPairs.bold); // "\x1b[1mhello\x1b[22m"
1072
1070
  *
1073
1071
  * // Nesting: bold wraps a red segment — bold reopens after red's close
1074
- * const inner = applyStyle("world", red);
1075
- * applyStyle(`hello ${inner}!`, bold);
1072
+ * const inner = applyStyle("world", styleMethodPairs.red);
1073
+ * applyStyle(`hello ${inner}!`, styleMethodPairs.bold);
1076
1074
  * ```
1077
1075
  */
1078
1076
  function applyStyle(text, style) {
@@ -1158,8 +1156,6 @@ function rgbToAnsi256(r, g, b) {
1158
1156
  * Same algorithm as `ansi-styles` / `chalk`: bucket each channel at 50%,
1159
1157
  * pack into a 3-bit base color, then add 60 for bright when the max
1160
1158
  * channel rounds up. Call sites add `+10` for backgrounds.
1161
- *
1162
- * @internal
1163
1159
  */
1164
1160
  function rgbToAnsi16Param(r, g, b) {
1165
1161
  const brightnessBucket = Math.round(Math.max(r, g, b) / 127.5);
@@ -1170,8 +1166,6 @@ function rgbToAnsi16Param(r, g, b) {
1170
1166
  }
1171
1167
  /**
1172
1168
  * Foreground SGR open sequence at `depth`.
1173
- *
1174
- * @internal
1175
1169
  */
1176
1170
  function fgOpen(input, depth) {
1177
1171
  const [r, g, b] = parseRgb(input);
@@ -1184,8 +1178,6 @@ function fgOpen(input, depth) {
1184
1178
  * derived from {@link fgOpen} by swapping the `\x1b[38;` introducer for
1185
1179
  * `\x1b[48;` (both the truecolor and 256-color forms use it). For `16`,
1186
1180
  * quantized directly to a real background SGR.
1187
- *
1188
- * @internal
1189
1181
  */
1190
1182
  function bgOpen(input, depth) {
1191
1183
  if (depth === "16") {
@@ -1207,64 +1199,10 @@ function colorPair(kind, input, depth) {
1207
1199
  close: kind === "fg" ? FG_CLOSE : BG_CLOSE
1208
1200
  };
1209
1201
  }
1210
- /**
1211
- * Depth-aware foreground `AnsiPair` for chain composition. `depth: "none"`
1212
- * returns an empty pair (still validates input). Used by `createStyle()`
1213
- * to back `chainable.fg(input)`.
1214
- *
1215
- * @throws {TypeError} If `input` is not a recognized color.
1216
- * @internal
1217
- */
1218
- function fgPairAtDepth(input, depth) {
1219
- return colorPair("fg", input, depth);
1220
- }
1221
- /**
1222
- * Depth-aware background `AnsiPair` for chain composition. Mirrors
1223
- * {@link fgPairAtDepth}.
1224
- *
1225
- * @throws {TypeError} If `input` is not a recognized color.
1226
- * @internal
1227
- */
1228
- function bgPairAtDepth(input, depth) {
1229
- return colorPair("bg", input, depth);
1230
- }
1231
1202
  function paint(kind, text, input, depth) {
1232
1203
  const pair = colorPair(kind, input, depth);
1233
1204
  return depth === "none" ? text : applyStyle(text, pair);
1234
1205
  }
1235
- /**
1236
- * Apply a foreground color to `text` from any {@link ColorInput}. `depth`
1237
- * selects the output format (`"truecolor"` default, `"256"`, `"16"`, or
1238
- * `"none"`). `"none"` returns `text` unchanged but still validates
1239
- * `input`. Empty `text` short-circuits to `""`.
1240
- *
1241
- * @throws {TypeError} If `input` is not a recognized color.
1242
- *
1243
- * @example
1244
- * ```ts
1245
- * fg("error", "#ff0000");
1246
- * fg("ocean", "rgb(0, 128, 255)");
1247
- * fg("custom", [255, 127, 80]);
1248
- * fg("256-only", "#ff0000", "256"); // \x1b[38;5;196m...
1249
- * ```
1250
- */
1251
- function fg$1(text, input, depth = "truecolor") {
1252
- return paint("fg", text, input, depth);
1253
- }
1254
- /**
1255
- * Apply a background color to `text`. Mirrors {@link fg}.
1256
- *
1257
- * @throws {TypeError} If `input` is not a recognized color.
1258
- *
1259
- * @example
1260
- * ```ts
1261
- * bg("warning", "#ff8800");
1262
- * bg("info", "rgb(0, 128, 255)");
1263
- * ```
1264
- */
1265
- function bg$1(text, input, depth = "truecolor") {
1266
- return paint("bg", text, input, depth);
1267
- }
1268
1206
  const OSC = "\x1B]";
1269
1207
  const ST = "\x1B\\";
1270
1208
  const HYPERLINK_CLOSE = `${OSC}8;;${ST}`;
@@ -1321,21 +1259,19 @@ function linkCode(url, options) {
1321
1259
  * ```ts
1322
1260
  * import { link } from "@crustjs/style";
1323
1261
  *
1324
- * console.log(link("docs", "https://crustjs.dev"));
1262
+ * console.log(link("docs", "https://crustjs.com"));
1325
1263
  * console.log(link("page 1", "https://example.com/p1", { id: "intro" }));
1326
1264
  * ```
1327
1265
  */
1328
1266
  function link$1(text, url, options) {
1329
1267
  return applyStyle(text, linkCode(url, options));
1330
1268
  }
1331
- const dynamicColorKinds = [["fg", fg$1], ["bg", bg$1]];
1332
- const styleMethodPairs = ansiCodes_exports;
1269
+ const dynamicColorKinds = ["fg", "bg"];
1333
1270
  function stepIsModifier(step) {
1334
1271
  return step.kind === "named" && isModifierName(step.name);
1335
1272
  }
1336
1273
  function stepPair(step, colorDepth) {
1337
- if (step.kind === "named") return styleMethodPairs[step.name];
1338
- return step.kind === "fg" ? fgPairAtDepth(step.input, colorDepth) : bgPairAtDepth(step.input, colorDepth);
1274
+ return step.kind === "named" ? styleMethodPairs[step.name] : colorPair(step.kind, step.input, colorDepth);
1339
1275
  }
1340
1276
  function colorStep(kind, input) {
1341
1277
  const step = {
@@ -1399,7 +1335,7 @@ function buildChainableStyleFactory(resolveCapabilities, runtime) {
1399
1335
  }]);
1400
1336
  }
1401
1337
  });
1402
- for (const [kind] of dynamicColorKinds) Object.defineProperty(styleFn, kind, {
1338
+ for (const kind of dynamicColorKinds) Object.defineProperty(styleFn, kind, {
1403
1339
  configurable: false,
1404
1340
  enumerable: true,
1405
1341
  value: (input) => createChainableStyle([...steps, colorStep(kind, input)]),
@@ -1436,41 +1372,6 @@ function buildStyleMethods(createChainableStyle) {
1436
1372
  }]);
1437
1373
  return methods;
1438
1374
  }
1439
- /**
1440
- * Create a configured style instance with mode-aware styling functions.
1441
- *
1442
- * The returned instance provides the full set of modifier, foreground color,
1443
- * and background color functions. In `"never"` mode, all functions return
1444
- * plain text without ANSI codes. In `"always"` mode, ANSI codes are always
1445
- * emitted. In `"auto"` mode, color methods respect `stdout.isTTY` and
1446
- * `NO_COLOR`, non-color modifiers (bold, italic, etc.) follow TTY only,
1447
- * and `FORCE_COLOR`, when set, decides unconditionally for both.
1448
- *
1449
- * @param options - Configuration options. Defaults to `{ mode: "auto" }`.
1450
- * @returns A frozen {@link StyleInstance} with all styling functions.
1451
- *
1452
- * @example
1453
- * ```ts
1454
- * // Auto-detect terminal capabilities
1455
- * const s = createStyle();
1456
- * console.log(s.bold("hello"));
1457
- *
1458
- * // Force color output
1459
- * const color = createStyle({ mode: "always" });
1460
- * console.log(color.red("error"));
1461
- * console.log(color.bold.red("critical"));
1462
- *
1463
- * // Disable all styling
1464
- * const plain = createStyle({ mode: "never" });
1465
- * console.log(plain.red("error")); // "error"
1466
- *
1467
- * // Deterministic testing
1468
- * const test = createStyle({
1469
- * mode: "auto",
1470
- * overrides: { isTTY: true, noColor: undefined },
1471
- * });
1472
- * ```
1473
- */
1474
1375
  function resolveStyleCapabilities(options) {
1475
1376
  const mode = options?.mode ?? "auto";
1476
1377
  const modifiersEnabled = resolveModifierCapability(mode, options?.overrides);
@@ -1508,10 +1409,9 @@ function createStyleInstance(options, runtime) {
1508
1409
  linkCode(url, hyperlinkOptions);
1509
1410
  return text;
1510
1411
  },
1511
- ...Object.fromEntries(dynamicColorKinds.map(([kind, paint]) => [kind, (...args) => {
1512
- const resolved = resolveCapabilities();
1412
+ ...Object.fromEntries(dynamicColorKinds.map((kind) => [kind, (...args) => {
1513
1413
  if (args.length === 1) return createChainableStyle([colorStep(kind, args[0])]);
1514
- return paint(args[0], args[1], resolved.colorDepth);
1414
+ return paint(kind, args[0], args[1], resolveCapabilities().colorDepth);
1515
1415
  }])),
1516
1416
  ...methods
1517
1417
  };
@@ -1596,96 +1496,100 @@ function isWithin(parent, child) {
1596
1496
  return path === "" || !isAbsolute(path) && path !== ".." && !path.startsWith(`..${sep}`);
1597
1497
  }
1598
1498
  //#endregion
1599
- //#region ../utils/dist/artifacts.js
1499
+ //#region src/artifacts.ts
1600
1500
  /**
1601
- * Set by `crust build` while it prepares the Command Snapshot: the absolute
1602
- * entry-isolated directory Extension build hooks write into. Core reads it
1603
- * to run the hooks; `resolveArtifactDir` reads it so sections evaluated during
1604
- * that run see the artifacts being built instead of the wiped `.crust/root`.
1501
+ * `crust.include` directories normalized to cwd-relative POSIX names. They are
1502
+ * staged exactly like Extension artifacts.
1605
1503
  */
1606
- const BUILD_OUT_DIR_ENV = "CRUST_INTERNAL_BUILD_OUT_DIR";
1504
+ function collectIncludeDirs(cwd, stageDir, include, artifactNames) {
1505
+ const names = [...artifactNames];
1506
+ const includeDirs = [];
1507
+ for (const entry of include) {
1508
+ const dir = resolve(cwd, entry);
1509
+ const name = relative(cwd, dir);
1510
+ if (isAbsolute(entry) || name === "" || !isWithin(cwd, dir)) throw new Error(`package.json crust.include entry ${JSON.stringify(entry)} must be a directory inside the project root ${cwd}.`);
1511
+ if (!existsSync(dir) || !statSync(dir).isDirectory()) throw new Error(`package.json crust.include entry ${JSON.stringify(entry)} is not a directory: ${dir}`);
1512
+ assertResolvesInsideProject(cwd, entry, dir);
1513
+ if (isWithin(stageDir, dir) || isWithin(dir, stageDir)) throw new Error(`package.json crust.include entry ${JSON.stringify(entry)} overlaps the build output directory ${stageDir}, which crust build replaces.`);
1514
+ if (name.toLowerCase().split(sep)[0] === "bin") throw new Error(`package.json crust.include entry ${JSON.stringify(entry)} conflicts with the generated npm bin directory.\n Include a directory with a different top-level name.`);
1515
+ const posixName = name.replaceAll(sep, "/");
1516
+ const key = posixName.toLowerCase();
1517
+ const overlap = names.find((staged) => {
1518
+ const stagedKey = staged.toLowerCase();
1519
+ return stagedKey === key || stagedKey.startsWith(`${key}/`) || key.startsWith(`${stagedKey}/`);
1520
+ });
1521
+ if (overlap !== void 0) throw new Error(`package.json crust.include entry ${JSON.stringify(entry)} overlaps "${overlap}", which is already staged (duplicate include or Extension artifact directory).`);
1522
+ names.push(posixName);
1523
+ includeDirs.push(posixName);
1524
+ }
1525
+ return includeDirs;
1526
+ }
1607
1527
  /**
1608
- * `Symbol.for` registry key of the packaged-build marker, for bundlers without a
1609
- * `define` option (native `deno bundle`). `crust build` prepends
1610
- * `globalThis[Symbol.for(PACKAGED_BUILD_KEY)] = true` to the command bundle,
1611
- * before the inlined application graph, so every bundled copy sees it.
1612
- * The registry key is shared across module copies; the value is never read from
1613
- * the environment, so detection needs no Deno env permission.
1528
+ * Walks `dir` the way the dereferencing copy will (through symlinked
1529
+ * directories) and rejects any path whose real location leaves the project.
1614
1530
  */
1615
- const PACKAGED_BUILD_KEY = "crustjs.packagedBuild";
1616
- //#endregion
1617
- //#region ../core/dist/invocation-OsKzCgr-.js
1531
+ function assertResolvesInsideProject(cwd, entry, dir) {
1532
+ const realCwd = realpathSync(cwd);
1533
+ const seen = /* @__PURE__ */ new Set();
1534
+ const walk = (path) => {
1535
+ const real = realpathSync(path);
1536
+ if (!isWithin(realCwd, real)) throw new Error(`package.json crust.include entry ${JSON.stringify(entry)} resolves outside the project root: ${relative(cwd, path)} -> ${real}`);
1537
+ if (seen.has(real) || !statSync(path).isDirectory()) return;
1538
+ seen.add(real);
1539
+ for (const child of readdirSync(path)) walk(join(path, child));
1540
+ };
1541
+ walk(dir);
1542
+ }
1618
1543
  /**
1619
- * A typed error for runtime recipe, Extension, Context, documentation, argv, and validation failures.
1620
- *
1621
- * Every `CrustError` carries a {@link CrustErrorCode} that identifies the specific
1622
- * failure, enabling programmatic error handling without fragile message parsing.
1623
- *
1624
- * @example
1625
- * ```ts
1626
- * import { CrustError } from "@crustjs/core";
1627
- *
1628
- * const outcome = await app.run(["deploy"], { args: { target: "prod" } });
1629
- * if (outcome.status === "failed") {
1630
- * const err = outcome.error;
1631
- * if (err instanceof CrustError) {
1632
- * console.error(`[${err.code}] ${err.message}`);
1633
- * }
1634
- * }
1635
- * ```
1544
+ * Copies one entry's Extension build hook output into the shared artifact
1545
+ * directory. Identically spelled directories merge; case-only directory aliases
1546
+ * and a file or file/directory mismatch at a path
1547
+ * another entry already produced is an error, so no entry's hooks can replace
1548
+ * another's output. `owners` tracks case-folded POSIX-relative paths across
1549
+ * entries, including directories so file/ancestor conflicts are portable.
1636
1550
  */
1637
- var CrustError = class extends Error {
1638
- /** Machine-readable error code for programmatic handling */
1639
- code;
1640
- /** Structured payload for programmatic handling */
1641
- details;
1642
- /** Optional wrapped original error/value */
1643
- cause;
1644
- constructor(code, message, ...details) {
1645
- super(message);
1646
- this.name = "CrustError";
1647
- this.code = code;
1648
- this.details = details[0];
1649
- }
1650
- is(code) {
1651
- return Object.is(this.code, code);
1652
- }
1653
- withCause(cause) {
1654
- this.cause = cause;
1655
- return this;
1656
- }
1657
- toJSON() {
1658
- return {
1659
- code: this.code,
1660
- message: this.message,
1661
- details: this.details
1662
- };
1663
- }
1664
- };
1665
- Object.freeze({ [Symbol("crust.handled")]: true });
1666
- /**
1667
- * Snapshot subprocess protocol used by first-party build tooling.
1668
- *
1669
- * When set to a non-empty file path, `.execute()` prepares the command tree,
1670
- * validates its documentation sections, optionally runs Extension build hooks when the
1671
- * build output directory is set, writes its final JSON snapshot and Build Report, and exits
1672
- * without dispatching a Command Action. In-process callers use `Crust.snapshot()`.
1673
- *
1674
- * Only source entries run by `crust build` honor it: finished Bun/Node bundles carry
1675
- * `process.env.CRUST_INTERNAL_BUILD === "1"` as a literal and compile the protocol out;
1676
- * packaged Deno bundles set the `isPackagedBuild()` marker and skip it without
1677
- * reading the environment.
1678
- */
1679
- const SNAPSHOT_PATH_ENV = "CRUST_INTERNAL_SNAPSHOT_PATH";
1680
- //#endregion
1681
- //#region ../core/dist/index.js
1682
- /** Mint an Extension identity from any non-blank, trimmed string. */
1683
- function defineExtensionId(id) {
1684
- if (!id.trim() || id !== id.trim()) throw new CrustError("DEFINITION", "Extension id must be a non-empty, trimmed string", {
1685
- subject: "extension",
1686
- reason: "empty-id"
1687
- });
1688
- return id;
1551
+ function mergeEntryArtifacts(entryOutDir, artifactDir, command, owners) {
1552
+ const merge = (relativeDir) => {
1553
+ for (const dirent of readdirSync(join(entryOutDir, relativeDir), { withFileTypes: true })) {
1554
+ const relativePath = relativeDir ? `${relativeDir}/${dirent.name}` : dirent.name;
1555
+ if (!dirent.isDirectory() && !dirent.isFile()) throw new Error(`Build artifact "${relativePath}" from bin ${JSON.stringify(command)} must use regular files and directories, not symlinks or other file types.`);
1556
+ const source = join(entryOutDir, relativePath);
1557
+ const destination = join(artifactDir, relativePath);
1558
+ const key = relativePath.toLowerCase();
1559
+ const owner = owners.get(key);
1560
+ const existing = lstatSync(destination, { throwIfNoEntry: false });
1561
+ if (owner && !(dirent.isDirectory() && owner.directory && owner.path === relativePath) || existing && !(dirent.isDirectory() && existing.isDirectory())) throw new Error(`Build artifact "${relativePath}" is written by both bin ${JSON.stringify(owner?.command ?? "an earlier bin")} and ${JSON.stringify(command)}.\n Extension build hooks of different commands must write distinct paths under ${artifactDir}.`);
1562
+ if (!owner) owners.set(key, {
1563
+ command,
1564
+ directory: dirent.isDirectory(),
1565
+ path: relativePath
1566
+ });
1567
+ if (dirent.isDirectory()) {
1568
+ mkdirSync(destination, { recursive: true });
1569
+ merge(relativePath);
1570
+ } else {
1571
+ mkdirSync(dirname(destination), { recursive: true });
1572
+ copyFileSync(source, destination);
1573
+ }
1574
+ }
1575
+ };
1576
+ const root = lstatSync(entryOutDir, { throwIfNoEntry: false });
1577
+ if (root === void 0) return;
1578
+ if (!root.isDirectory()) throw new Error(`Build artifact directory for bin ${JSON.stringify(command)} must be a directory, not a symlink or other file type: ${entryOutDir}`);
1579
+ merge("");
1580
+ }
1581
+ function collectArtifacts(artifactOutDir) {
1582
+ if (!artifactOutDir || !existsSync(artifactOutDir)) return {
1583
+ names: [],
1584
+ manPages: []
1585
+ };
1586
+ const names = readdirSync(artifactOutDir, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort();
1587
+ const binName = names.find((name) => name.toLowerCase() === "bin");
1588
+ if (binName !== void 0) throw new Error(`Artifact directory "${binName}" in ${artifactOutDir} conflicts with the generated npm bin directory.\n Emit build artifacts under a different top-level name.`);
1589
+ return {
1590
+ names,
1591
+ manPages: names.includes("man") ? readdirSync(join(artifactOutDir, "man"), { withFileTypes: true }).filter((entry) => entry.isFile()).map((entry) => entry.name).sort() : []
1592
+ };
1689
1593
  }
1690
1594
  //#endregion
1691
1595
  //#region ../utils/dist/process.js
@@ -1742,7 +1646,7 @@ function killProcessTree(child) {
1742
1646
  try {
1743
1647
  process.kill(-child.pid, "SIGKILL");
1744
1648
  } catch (error) {
1745
- if (error.code === "ESRCH") return;
1649
+ if (isErrnoException(error) && error.code === "ESRCH") return;
1746
1650
  child.kill("SIGKILL");
1747
1651
  throw error;
1748
1652
  }
@@ -1802,22 +1706,21 @@ async function runProcess(command, args = [], options = {}) {
1802
1706
  clearTimeout(timer);
1803
1707
  }
1804
1708
  }
1709
+ function isExecutableFile(path) {
1710
+ try {
1711
+ accessSync(path, constants.X_OK);
1712
+ return statSync(path).isFile();
1713
+ } catch {
1714
+ return false;
1715
+ }
1716
+ }
1805
1717
  /** Resolve a bare executable name from PATH (and PATHEXT on Windows). */
1806
1718
  function which(command) {
1807
- if (command.includes(sep) || command.includes("/")) {
1808
- try {
1809
- accessSync(command, constants.X_OK);
1810
- if (statSync(command).isFile()) return command;
1811
- } catch {}
1812
- return null;
1813
- }
1719
+ if (command.includes(sep) || command.includes("/")) return isExecutableFile(command) ? command : null;
1814
1720
  const extensions = process.platform === "win32" && !extname(command) ? (process.env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM").split(";") : [""];
1815
1721
  for (const directory of process.env.PATH?.split(delimiter) ?? []) for (const extension of extensions) {
1816
1722
  const candidate = resolve(join(directory, command + extension));
1817
- try {
1818
- accessSync(candidate, constants.X_OK);
1819
- if (statSync(candidate).isFile()) return candidate;
1820
- } catch {}
1723
+ if (isExecutableFile(candidate)) return candidate;
1821
1724
  }
1822
1725
  return null;
1823
1726
  }
@@ -2668,16 +2571,10 @@ var require_valid = /* @__PURE__ */ __commonJSMin(((exports, module) => {
2668
2571
  module.exports = validRange;
2669
2572
  }));
2670
2573
  //#endregion
2671
- //#region package.json
2574
+ //#region src/targets.ts
2672
2575
  var import_satisfies = /* @__PURE__ */ __toESM(require_satisfies(), 1);
2673
2576
  var import_valid = /* @__PURE__ */ __toESM(require_valid$1(), 1);
2674
2577
  var import_valid$1 = /* @__PURE__ */ __toESM(require_valid(), 1);
2675
- var optionalDependencies = {
2676
- "@tsdown/exe": "0.23.0",
2677
- "tsdown": "0.23.0"
2678
- };
2679
- //#endregion
2680
- //#region src/utils/build-helpers.ts
2681
2578
  const BUILD_RUNTIMES = [
2682
2579
  "bun",
2683
2580
  "deno",
@@ -2889,17 +2786,11 @@ function hostTarget(table) {
2889
2786
  const platformKey = hostPlatformKey();
2890
2787
  return table.targets.find((target) => table.info[target].platformKey === platformKey) ?? null;
2891
2788
  }
2892
- function readUserPackageJson(cwd) {
2893
- const packageJsonPath = join(cwd, "package.json");
2894
- if (!existsSync(packageJsonPath)) return void 0;
2895
- try {
2896
- return JSON.parse(readFileSync(packageJsonPath, "utf8"));
2897
- } catch (error) {
2898
- throw new Error(`Failed to parse package.json in ${cwd}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
2899
- }
2900
- }
2901
- function toBunEnvFileArgs(envFiles) {
2902
- return envFiles.flatMap((envFile) => ["--env-file", envFile]);
2789
+ //#endregion
2790
+ //#region src/compilers.ts
2791
+ /** A finished compiler process's stderr, then stdout, for error messages. */
2792
+ function processOutput({ stdout, stderr }) {
2793
+ return [stderr.trim(), stdout.trim()].filter(Boolean).join("\n");
2903
2794
  }
2904
2795
  /**
2905
2796
  * Resolve the safest executable to run `bun build`.
@@ -2949,13 +2840,11 @@ function bunCompileTarget(target, runner, host = hostTarget(BUN_TARGETS)) {
2949
2840
  return bunBaselineAlias(target) ?? target;
2950
2841
  }
2951
2842
  /**
2952
- * Refuse the host target when only the `BUN_BE_BUN` fallback runner is
2953
- * available and no `-baseline` alias can stand in for it (arm64 hosts).
2954
- * Pass the selected `runner` to judge that compiler instead of looking up
2955
- * `bun` on PATH again.
2843
+ * Refuse the host target when the selected `runner` is the `BUN_BE_BUN`
2844
+ * fallback and no `-baseline` alias can stand in for it (arm64 hosts).
2956
2845
  */
2957
- function assertTargetsBuildableWithoutBun(targets, host = hostTarget(BUN_TARGETS), runner) {
2958
- const externalBun = runner ? runner.env.BUN_BE_BUN !== "1" : which("bun") !== null;
2846
+ function assertTargetsBuildableWithoutBun(targets, runner, host = hostTarget(BUN_TARGETS)) {
2847
+ const externalBun = runner.env.BUN_BE_BUN !== "1";
2959
2848
  if (host === null || !targets.includes(host) || bunBaselineAlias(host) !== null || externalBun) return;
2960
2849
  const others = targets.filter((target) => target !== host);
2961
2850
  const alternative = others.length > 0 ? `pass --target with the other targets (e.g. ${others.map((target) => `--target ${target}`).join(" ")})` : "build a different target";
@@ -2996,17 +2885,18 @@ const COMPILER_VERSION_TIMEOUT_MS = 3e4;
2996
2885
  * fails the build, before `.crust/` is replaced.
2997
2886
  */
2998
2887
  async function readCompilerVersion(runtime, runner, cwd) {
2999
- const { exitCode, stdout, stderr } = await runProcess(runner.command, ["--version"], {
2888
+ const result = await runProcess(runner.command, ["--version"], {
3000
2889
  env: runner.env,
3001
2890
  cwd,
3002
2891
  stdio: "collect",
3003
2892
  timeout: COMPILER_VERSION_TIMEOUT_MS
3004
2893
  });
3005
- const reported = runtime === "deno" ? /^deno (\S+)/.exec(stdout.trim())?.[1] : stdout.trim();
3006
- const version = exitCode === 0 && reported !== void 0 ? (0, import_valid.default)(reported) : null;
2894
+ const stdout = result.stdout.trim();
2895
+ const reported = runtime === "deno" ? /^deno (\S+)/.exec(stdout)?.[1] : stdout;
2896
+ const version = result.exitCode === 0 && reported !== void 0 ? (0, import_valid.default)(reported) : null;
3007
2897
  if (version === null) {
3008
- const output = [stderr.trim(), stdout.trim()].filter(Boolean).join("\n");
3009
- throw new Error(`Could not read the ${RUNTIME_LABELS[runtime]} version from ${runner.command} --version (exit ${exitCode})${output ? `:\n${output}` : "."}`);
2898
+ const output = processOutput(result);
2899
+ throw new Error(`Could not read the ${RUNTIME_LABELS[runtime]} version from ${runner.command} --version (exit ${result.exitCode})${output ? `:\n${output}` : "."}`);
3010
2900
  }
3011
2901
  return version;
3012
2902
  }
@@ -3019,7 +2909,6 @@ function isVersionRange(value) {
3019
2909
  * value is a requirement, not a request to install that version.
3020
2910
  */
3021
2911
  function assertCompilerSatisfiesEngines(compiler, userPackageJson) {
3022
- if (userPackageJson === void 0 || !isJsonObject(userPackageJson)) return;
3023
2912
  const { engines } = userPackageJson;
3024
2913
  if (engines === void 0) return;
3025
2914
  if (!isJsonObject(engines)) throw new Error("package.json engines must be an object of runtime version ranges.");
@@ -3050,135 +2939,33 @@ async function resolveBinaryCompiler(runtime, userPackageJson, cwd) {
3050
2939
  return compiler;
3051
2940
  }
3052
2941
  /**
3053
- * Crust's own optional dependencies that build Node standalone binaries.
3054
- * Crust's staged root and platform packages declare them
3055
- * (scripts/stage-node-exe-dependencies.ts) so an installed crust resolves them
3056
- * next to itself; an install that skipped them only affects Node binaries.
3057
- */
3058
- const NODE_EXE_BACKEND_PACKAGES = ["tsdown", "@tsdown/exe"];
3059
- /**
3060
- * A file of Crust's own installation that the backend is resolved from, never
3061
- * the project or PATH. A compiled crust serves its modules from Bun's virtual
3062
- * filesystem (`/$bunfs/`, `B:/~BUN/` on Windows), so there it is the
3063
- * executable, inside its platform package.
3064
- */
3065
- function crustInstallPath(moduleUrl = import.meta.url, execPath = process.execPath) {
3066
- return /^file:\/\/\/(?:\$bunfs|[A-Za-z]:\/~BUN)\//.test(moduleUrl) ? execPath : fileURLToPath(moduleUrl);
3067
- }
3068
- /**
3069
- * `<dir>/node_modules/<name>/package.json` for the nearest `dir` at or above
3070
- * `from`: Node's node_modules lookup without its NODE_PATH and global-folder
3071
- * fallbacks, so only an installation next to `from` can supply the package.
3072
- * Package managers hoist crust's backend into the project's node_modules, so
3073
- * the found package may be the project's own: callers check its version.
3074
- */
3075
- function findInstalledPackageJson(from, name) {
3076
- for (let dir = from;; dir = dirname(dir)) {
3077
- const candidate = join(dir, "node_modules", name, "package.json");
3078
- if (existsSync(candidate)) return candidate;
3079
- if (dirname(dir) === dir) return null;
3080
- }
3081
- }
3082
- function isVersionedPackageJson(value) {
3083
- return isJsonObject(value) && typeof value.version === "string";
3084
- }
3085
- function isTsdownPackageJson(value) {
3086
- return isVersionedPackageJson(value) && value.engines !== void 0 && isJsonObject(value.engines) && value.engines.node !== void 0 && isVersionRange(value.engines.node);
3087
- }
3088
- function createNodeExeProbeScript(tsdownPackageJson) {
3089
- return `// Generated by crust build to read tsdown's executable minimum; deleted when it exits.
3090
- import { createRequire } from "node:module";
3091
- import { pathToFileURL } from "node:url";
3092
-
3093
- const internal = createRequire(${JSON.stringify(tsdownPackageJson)}).resolve("tsdown/internal");
3094
- console.log((await import(pathToFileURL(internal).href)).NODE_SEA_MIN_VERSION);
3095
- `;
3096
- }
3097
- const NODE_TOOLCHAIN_GUIDANCE = " Binaries embed the selected node's version; crust does not install or upgrade it.\n Put a supported node first on PATH (e.g. with your version manager).";
3098
- /**
3099
- * Checks the selected Node against the installed backend's two separate
3100
- * requirements: tsdown's package `engines.node` and its executable builder's
3101
- * minimum (skipped while unknown). Both come from the installed tsdown, not
3102
- * from this file.
3103
- */
3104
- function assertNodeExeBackendSupports(compiler, backend) {
3105
- const selected = `Node ${compiler.version} (${compiler.runner.command})`;
3106
- if (!(0, import_satisfies.default)(compiler.version, backend.engines)) throw new Error(`${selected} is not supported by tsdown ${backend.version}, which builds node standalone binaries: its package requires Node "${backend.engines}".\n${NODE_TOOLCHAIN_GUIDANCE}`);
3107
- if (backend.seaMinVersion !== void 0 && !(0, import_satisfies.default)(compiler.version, `>=${backend.seaMinVersion}`)) throw new Error(`${selected} cannot build standalone executables: tsdown ${backend.version}'s executable builder requires Node ${backend.seaMinVersion} or later.\n${NODE_TOOLCHAIN_GUIDANCE}`);
3108
- }
3109
- /**
3110
- * Finds Crust's installed tsdown and @tsdown/exe from `installPath`, at the
3111
- * versions its package.json `optionalDependencies` pin, and validates the
3112
- * selected Node against tsdown's requirements, loading the executable
3113
- * builder's minimum under that Node. `cwd` is the project
3114
- * directory, as for every other compiler step.
2942
+ * Oldest `deno` accepted to bundle a Deno runtime package. Deno 2.4 resolves a
2943
+ * package.json project's npm dependencies from the registry instead of its
2944
+ * installed node_modules, so it can bundle a different Crust than the project
2945
+ * installed. Tested on Linux x64 with Deno 2.5.0 through 2.9.6.
3115
2946
  */
3116
- async function resolveNodeExeBackend(compiler, cwd, installPath = crustInstallPath()) {
3117
- const reinstall = ` @crustjs/crust ships ${NODE_EXE_BACKEND_PACKAGES.join(" and ")} as optional dependencies, which package managers skip when optional dependencies are disabled or the installing node does not satisfy tsdown's engines.\n Reinstall @crustjs/crust with optional dependencies enabled under a node that can build Node binaries (e.g. the one on PATH here).`;
3118
- const packageJsonPath = findInstalledPackageJson(dirname(installPath), "tsdown");
3119
- if (packageJsonPath === null) throw new Error(`tsdown, which builds node standalone binaries, is not installed with crust (${installPath}).\n${reinstall}`);
3120
- const exePackageJsonPath = findInstalledPackageJson(dirname(realpathSync(packageJsonPath)), "@tsdown/exe");
3121
- if (exePackageJsonPath === null) throw new Error(`@tsdown/exe is not installed beside crust's tsdown (${packageJsonPath}).\n${reinstall}`);
3122
- for (const [name, path] of [["tsdown", packageJsonPath], ["@tsdown/exe", exePackageJsonPath]]) {
3123
- const installed = JSON.parse(readFileSync(path, "utf8"));
3124
- const version = isVersionedPackageJson(installed) ? installed.version : "without a version";
3125
- const pinned = optionalDependencies[name];
3126
- if (!(0, import_satisfies.default)(version, pinned)) throw new Error(`${name} ${version} (${path}) is not the ${name} ${pinned} that crust builds node standalone binaries with.\n${reinstall}`);
3127
- }
3128
- const tsdownPackage = JSON.parse(readFileSync(packageJsonPath, "utf8"));
3129
- if (!isTsdownPackageJson(tsdownPackage)) throw new Error(`Unexpected tsdown package metadata in ${packageJsonPath}.\n${reinstall}`);
3130
- const backend = {
3131
- packageJsonPath,
3132
- version: tsdownPackage.version,
3133
- engines: tsdownPackage.engines.node
3134
- };
3135
- assertNodeExeBackendSupports(compiler, backend);
3136
- const workDir = await mkdtemp(join(tmpdir(), "crust-node-exe-probe-"));
3137
- try {
3138
- const scriptPath = join(workDir, "probe.mjs");
3139
- await writeFile(scriptPath, createNodeExeProbeScript(packageJsonPath));
3140
- const { exitCode, stdout, stderr } = await runProcess(compiler.runner.command, [scriptPath], {
3141
- env: compiler.runner.env,
3142
- cwd,
3143
- stdio: "collect"
3144
- });
3145
- const seaMinVersion = exitCode === 0 ? (0, import_valid.default)(stdout.trim()) : null;
3146
- if (seaMinVersion === null) {
3147
- const output = [stderr.trim(), stdout.trim()].filter(Boolean).join("\n");
3148
- throw new Error(`tsdown ${backend.version}'s executable builder could not be loaded with ${compiler.runner.command} (exit ${exitCode}).\n${reinstall}${output ? `\n${output}` : ""}`);
3149
- }
3150
- const resolved = {
3151
- ...backend,
3152
- seaMinVersion
3153
- };
3154
- assertNodeExeBackendSupports(compiler, resolved);
3155
- return resolved;
3156
- } finally {
3157
- await rm(workDir, {
3158
- recursive: true,
3159
- force: true
3160
- });
3161
- }
3162
- }
2947
+ const DENO_PACKAGE_MIN_VERSION = "2.5.0";
3163
2948
  /**
3164
- * Select the Node binary compiler once for a build: the external `node`, its
3165
- * version read in the project directory `cwd`, validated against the
3166
- * project's `engines.node` and then the installed tsdown's requirements.
3167
- * Pass it to {@link execNodeBinaryBuild} for every command and target.
2949
+ * Select the external deno that bundles a Deno runtime package, reading its
2950
+ * version in the project directory `cwd`. Call it before staging is replaced.
2951
+ * A runtime package embeds no Deno, so `engines.deno` stays a consumer
2952
+ * requirement and is not checked against this bundler.
3168
2953
  */
3169
- async function resolveNodeBinaryCompiler(userPackageJson, cwd) {
3170
- const runner = resolveNodeBuildRunner();
3171
- const compiler = {
3172
- runtime: "node",
3173
- runner,
3174
- version: await readCompilerVersion("node", runner, cwd)
3175
- };
3176
- assertCompilerSatisfiesEngines(compiler, userPackageJson);
2954
+ async function resolveDenoPackageBundler(cwd, runner = resolveDenoBuildRunner()) {
2955
+ const version = await readCompilerVersion("deno", runner, cwd);
2956
+ if (!(0, import_satisfies.default)(version, `>=2.5.0`, { includePrerelease: true })) throw new Error(`Deno ${version} (${runner.command}) cannot bundle a Deno runtime package; deno ${DENO_PACKAGE_MIN_VERSION} or newer is required.\n Older deno bundle resolves package.json dependencies from the npm registry instead of the installed node_modules.
2957
+ crust does not install or upgrade Deno; put a newer deno first on PATH (e.g. with your version manager).`);
3177
2958
  return {
3178
- ...compiler,
3179
- backend: await resolveNodeExeBackend(compiler, cwd)
2959
+ runtime: "deno",
2960
+ runner,
2961
+ version
3180
2962
  };
3181
2963
  }
2964
+ //#endregion
2965
+ //#region src/bundle.ts
2966
+ function toBunEnvFileArgs(envFiles) {
2967
+ return envFiles.flatMap((envFile) => ["--env-file", envFile]);
2968
+ }
3182
2969
  /**
3183
2970
  * Module source the generated driver imports for a `crust.bunPlugins` specifier.
3184
2971
  *
@@ -3238,7 +3025,7 @@ if (!options.build.compile) {
3238
3025
  }
3239
3026
  `;
3240
3027
  }
3241
- async function runBunPluginDriver(build, outfilePath, bunPlugins, envFiles, cwd, runner) {
3028
+ async function runBunPluginDriver(build, outfilePath, { cwd, envFiles, bunPlugins }, runner) {
3242
3029
  const driverPath = join(cwd, `.crust-build-${randomBytes(6).toString("hex")}.ts`);
3243
3030
  await writeFile(driverPath, createBunPluginDriverScript({
3244
3031
  plugins: bunPlugins.map((specifier) => ({
@@ -3264,20 +3051,16 @@ async function runBunPluginDriver(build, outfilePath, bunPlugins, envFiles, cwd,
3264
3051
  *
3265
3052
  * @param entryPath - Absolute path to the entry file
3266
3053
  * @param outfilePath - Absolute path to the output binary
3267
- * @param minify - Whether to enable minification
3268
3054
  * @param target - Bun compile target
3269
- * @param envFiles - Optional env files to load during build
3270
- * @param bunPlugins - Bun bundler plugin specifiers; when present the build
3271
- * runs through the generated `Bun.build` driver instead of `bun build`
3272
3055
  * @param runner - The selected Bun compiler (`resolveBinaryCompiler`)
3273
3056
  * @throws {Error} If the build fails
3274
3057
  */
3275
- async function execBuild(entryPath, outfilePath, minify, target, envFiles, cwd, bunPlugins = [], runner = resolveBunBuildRunner()) {
3058
+ async function execBuild(entryPath, outfilePath, target, settings, runner = resolveBunBuildRunner()) {
3276
3059
  const compileTarget = bunCompileTarget(target, runner);
3277
- if (bunPlugins.length > 0) {
3060
+ if (settings.bunPlugins.length > 0) {
3278
3061
  await runBunPluginDriver({
3279
3062
  entrypoints: [entryPath],
3280
- minify,
3063
+ minify: settings.minify,
3281
3064
  env: "PUBLIC_*",
3282
3065
  target: "bun",
3283
3066
  compile: {
@@ -3285,10 +3068,10 @@ async function execBuild(entryPath, outfilePath, minify, target, envFiles, cwd,
3285
3068
  outfile: outfilePath,
3286
3069
  autoloadBunfig: false
3287
3070
  }
3288
- }, outfilePath, bunPlugins, envFiles, cwd, runner);
3071
+ }, outfilePath, settings, runner);
3289
3072
  return;
3290
3073
  }
3291
- await runBuildProcess(runner, createBunCompileArgs(entryPath, outfilePath, minify, compileTarget, envFiles), outfilePath, cwd);
3074
+ await runBuildProcess(runner, createBunCompileArgs(entryPath, outfilePath, settings.minify, compileTarget, settings.envFiles), outfilePath, settings.cwd);
3292
3075
  }
3293
3076
  function createBunCompileArgs(entryPath, outfilePath, minify, target, envFiles = []) {
3294
3077
  return [
@@ -3318,36 +3101,31 @@ function createDenoCompileArgs(entryPath, outfilePath, target) {
3318
3101
  entryPath
3319
3102
  ];
3320
3103
  }
3104
+ /** Runs one compiler step; returns its output, which a successful step may still print (warnings). */
3321
3105
  async function runBuildProcess(runner, args, outfilePath, cwd) {
3322
- const { exitCode, stdout, stderr } = await runProcess(runner.command, args, {
3106
+ const result = await runProcess(runner.command, args, {
3323
3107
  env: runner.env,
3324
3108
  cwd,
3325
3109
  stdio: "collect"
3326
3110
  });
3327
- if (exitCode !== 0) {
3328
- const output = [stderr.trim(), stdout.trim()].filter(Boolean).join("\n");
3329
- throw new Error(`Build failed for ${outfilePath}${output ? `:\n${output}` : ""}`);
3330
- }
3331
- }
3332
- /** Bundle a Node runtime package entry: ESM for Node behind `#!/usr/bin/env node`. */
3333
- async function execNodeBuild(entryPath, outfilePath, minify, envFiles, cwd, bunPlugins = [], runner = resolveBunBuildRunner()) {
3334
- await execScriptBuild("node", entryPath, outfilePath, minify, envFiles, cwd, bunPlugins, runner);
3111
+ const output = processOutput(result);
3112
+ if (result.exitCode !== 0) throw new Error(`Build failed for ${outfilePath}${output ? `:\n${output}` : ""}`);
3113
+ return output;
3335
3114
  }
3336
3115
  /**
3337
- * Bundle a Bun runtime package entry: Bun-targeted ESM behind
3338
- * `#!/usr/bin/env bun`, so the installed bin launches with Bun rather than Node.
3116
+ * Bundle a runtime package entry as ESM behind `#!/usr/bin/env <target>`: for
3117
+ * Node, or Bun-targeted so the installed bin launches with Bun rather than Node.
3339
3118
  */
3340
- async function execBunPackageBuild(entryPath, outfilePath, minify, envFiles, cwd, bunPlugins = [], runner = resolveBunBuildRunner()) {
3341
- await execScriptBuild("bun", entryPath, outfilePath, minify, envFiles, cwd, bunPlugins, runner);
3342
- }
3343
- async function execScriptBuild(target, entryPath, outfilePath, minify, envFiles, cwd, bunPlugins, runner) {
3344
- if (bunPlugins.length > 0) await runBunPluginDriver({
3119
+ async function execScriptBuild(target, entryPath, outfilePath, settings, runner = resolveBunBuildRunner()) {
3120
+ const { cwd, minify, envFiles, external } = settings;
3121
+ if (settings.bunPlugins.length > 0) await runBunPluginDriver({
3345
3122
  entrypoints: [entryPath],
3346
3123
  minify,
3347
3124
  env: "PUBLIC_*",
3348
3125
  target,
3349
- format: "esm"
3350
- }, outfilePath, bunPlugins, envFiles, cwd, runner);
3126
+ format: "esm",
3127
+ external
3128
+ }, outfilePath, settings, runner);
3351
3129
  else await runBuildProcess(runner, [
3352
3130
  "build",
3353
3131
  ...toBunEnvFileArgs(envFiles),
@@ -3358,6 +3136,7 @@ async function execScriptBuild(target, entryPath, outfilePath, minify, envFiles,
3358
3136
  target,
3359
3137
  "--format",
3360
3138
  "esm",
3139
+ ...external.flatMap((name) => ["--external", name]),
3361
3140
  "--outfile",
3362
3141
  outfilePath,
3363
3142
  ...minify ? ["--minify"] : [],
@@ -3371,191 +3150,17 @@ async function execScriptBuild(target, entryPath, outfilePath, minify, envFiles,
3371
3150
  async function execDenoBuild(entryPath, outfilePath, target, cwd, runner = resolveDenoBuildRunner()) {
3372
3151
  await runBuildProcess(runner, createDenoCompileArgs(entryPath, outfilePath, target), outfilePath, cwd);
3373
3152
  }
3374
- /** tsdown's executable target for a Node target, embedding exactly `nodeVersion`. */
3375
- function nodeExeTarget(target, nodeVersion) {
3376
- const { os, cpu } = NODE_TARGETS.info[target];
3377
- return {
3378
- platform: os === "win32" ? "win" : os,
3379
- arch: cpu,
3380
- nodeVersion
3381
- };
3382
- }
3383
3153
  /**
3384
- * Script the selected Node runs to turn one Bun bundle into one target's
3385
- * executable with tsdown's programmatic `build`. Every value is embedded via
3386
- * JSON.stringify.
3387
- */
3388
- function createNodeExeBuildScript(options) {
3389
- return `// Generated by crust build for a Node standalone binary; deleted when the build finishes.
3390
- import { createRequire } from "node:module";
3391
- import { pathToFileURL } from "node:url";
3392
-
3393
- const options = ${JSON.stringify(options)};
3394
- const { build } = await import(pathToFileURL(createRequire(options.tsdownPackageJson).resolve("tsdown")).href);
3395
- await build({
3396
- config: false,
3397
- cwd: options.cwd,
3398
- entry: { app: options.entry },
3399
- outDir: options.bundleDir,
3400
- format: "esm",
3401
- platform: "node",
3402
- dts: false,
3403
- // Rolldown reprints the Bun bundle, so it minifies again.
3404
- minify: options.minify,
3405
- logLevel: "warn",
3406
- report: false,
3407
- // Bun already inlined the PUBLIC_* constants; inline nothing from this environment.
3408
- envPrefix: [],
3409
- // Bun leaves only Node built-ins as imports; fail on anything else, which
3410
- // an executable shipping no node_modules could not resolve.
3411
- deps: { alwaysBundle: () => true, onlyBundle: false, onlyImport: [] },
3412
- exe: { outDir: options.exeDir, fileName: "app", targets: [options.target] },
3413
- });
3414
- `;
3415
- }
3416
- /**
3417
- * Build a single entry file into a Node standalone executable: Bun first
3418
- * bundles it for Node exactly as a Node runtime package ({@link execNodeBuild}:
3419
- * `PUBLIC_*` constants from `envFiles` and the environment, each module's own
3420
- * `import.meta.main` and `require.main === module`, CommonJS and ESM inputs),
3421
- * then tsdown's executable builder (Node SEA), run by the selected external
3422
- * Node, turns that bundle into an executable embedding exactly its version
3423
- * for `target`. tsdown takes a single entry per executable, so callers build
3424
- * every command separately. Matching official Node binaries for targets are
3425
- * downloaded, checksum-verified, and cached by @tsdown/exe. Builds for darwin
3426
- * are only code-signed on a macOS host.
3427
- *
3428
- * @param compiler - The selected Node compiler (`resolveNodeBinaryCompiler`)
3429
- * @param bunRunner - The Bun that bundles (`resolveBunBuildRunner`)
3430
- * @param onWarning - Receives the backend's warnings (such as a skipped code
3431
- * signature) after a successful build
3432
- * @throws {Error} If the build fails
3433
- */
3434
- async function execNodeBinaryBuild(entryPath, outfilePath, minify, target, envFiles, cwd, compiler, bunRunner, onWarning = () => {}) {
3435
- const workDir = await mkdtemp(join(tmpdir(), "crust-node-exe-"));
3436
- try {
3437
- const scriptPath = join(workDir, "build.mjs");
3438
- const bunBundle = join(workDir, "app.mjs");
3439
- const exeDir = join(workDir, "exe");
3440
- await execNodeBuild(resolve(cwd, entryPath), bunBundle, minify, envFiles, cwd, [], bunRunner);
3441
- await writeFile(scriptPath, createNodeExeBuildScript({
3442
- tsdownPackageJson: compiler.backend.packageJsonPath,
3443
- cwd,
3444
- entry: bunBundle,
3445
- bundleDir: join(workDir, "bundle"),
3446
- exeDir,
3447
- minify,
3448
- target: nodeExeTarget(target, compiler.version)
3449
- }));
3450
- const { exitCode, stdout, stderr } = await runProcess(compiler.runner.command, [scriptPath], {
3451
- env: compiler.runner.env,
3452
- cwd,
3453
- stdio: "collect"
3454
- });
3455
- const output = [stderr.trim(), stdout.trim()].filter(Boolean).join("\n");
3456
- if (exitCode !== 0) throw new Error(`Build failed for ${outfilePath}${output ? `:\n${output}` : ""}`);
3457
- const produced = await readdir(exeDir);
3458
- if (produced.length !== 1) throw new Error(`Build failed for ${outfilePath}: expected one executable from tsdown, found ${JSON.stringify(produced)}.`);
3459
- await copyFile(join(exeDir, produced[0]), outfilePath);
3460
- if (output) onWarning(output);
3461
- } finally {
3462
- await rm(workDir, {
3463
- recursive: true,
3464
- force: true,
3465
- maxRetries: 3,
3466
- retryDelay: 100
3467
- });
3468
- }
3469
- }
3470
- function createNodeExeProvisionScript(tsdownPackageJson, targets) {
3471
- return `// Generated by crust build to provision Node binaries for targets; deleted when it exits.
3472
- import { realpathSync } from "node:fs";
3473
- import { createRequire } from "node:module";
3474
- import { pathToFileURL } from "node:url";
3475
-
3476
- const exe = createRequire(realpathSync(${JSON.stringify(tsdownPackageJson)})).resolve("@tsdown/exe");
3477
- const { resolveNodeBinary } = await import(pathToFileURL(exe).href);
3478
- for (const { name, target } of ${JSON.stringify(targets)}) {
3479
- try {
3480
- await resolveNodeBinary(target, {});
3481
- } catch (error) {
3482
- console.error(name + ": " + error.message + (error.cause?.message ? "\\n" + error.cause.message : ""));
3483
- process.exit(1);
3484
- }
3485
- }
3486
- `;
3487
- }
3488
- /**
3489
- * Provisions the Node binary every target embeds before anything is staged,
3490
- * with the selected Node and the call tsdown's executable builder makes per
3491
- * target (@tsdown/exe's `resolveNodeBinary`): an uncached official archive of
3492
- * exactly `compiler.version` is downloaded, checksum-verified, unpacked with
3493
- * the tool that target's archive needs on this host, and cached, where
3494
- * {@link execNodeBinaryBuild} then finds it. A missing unpacking tool, a failed
3495
- * download, or a checksum mismatch therefore fails here, before `.crust/` is
3496
- * replaced; cached targets need nothing.
3497
- *
3498
- * @throws {Error} If a target cannot be provisioned
3499
- */
3500
- async function provisionNodeExeTargets(targets, cwd, compiler) {
3501
- const workDir = await mkdtemp(join(tmpdir(), "crust-node-exe-provision-"));
3502
- try {
3503
- const scriptPath = join(workDir, "provision.mjs");
3504
- await writeFile(scriptPath, createNodeExeProvisionScript(compiler.backend.packageJsonPath, targets.map((name) => ({
3505
- name,
3506
- target: nodeExeTarget(name, compiler.version)
3507
- }))));
3508
- const { exitCode, stdout, stderr } = await runProcess(compiler.runner.command, [scriptPath], {
3509
- env: compiler.runner.env,
3510
- cwd,
3511
- stdio: "collect"
3512
- });
3513
- if (exitCode !== 0) {
3514
- const output = [stderr.trim(), stdout.trim()].filter(Boolean).join("\n");
3515
- throw new Error(`Could not provision the Node ${compiler.version} binary that node standalone binaries embed${output ? `:\n${output}` : "."}\n tsdown downloads each target's official Node archive once and unpacks it with tar (GNU tar also needs xz for linux targets), or unzip for win targets on non-Windows hosts.
3516
- Install the missing tool, or build only targets this machine can provision (--target).`);
3517
- }
3518
- } finally {
3519
- await rm(workDir, {
3520
- recursive: true,
3521
- force: true
3522
- });
3523
- }
3524
- }
3525
- /**
3526
- * Oldest `deno` accepted to bundle a Deno runtime package. Deno 2.4 resolves a
3527
- * package.json project's npm dependencies from the registry instead of its
3528
- * installed node_modules, so it can bundle a different Crust than the project
3529
- * installed. Tested on Linux x64 with Deno 2.5.0 through 2.9.6.
3530
- */
3531
- const DENO_PACKAGE_MIN_VERSION = "2.5.0";
3532
- /**
3533
- * Select the external deno that bundles a Deno runtime package, reading its
3534
- * version in the project directory `cwd`. Call it before staging is replaced.
3535
- * A runtime package embeds no Deno, so `engines.deno` stays a consumer
3536
- * requirement and is not checked against this bundler.
3537
- */
3538
- async function resolveDenoPackageBundler(cwd, runner = resolveDenoBuildRunner()) {
3539
- const version = await readCompilerVersion("deno", runner, cwd);
3540
- if (!(0, import_satisfies.default)(version, `>=2.5.0`, { includePrerelease: true })) throw new Error(`Deno ${version} (${runner.command}) cannot bundle a Deno runtime package; deno ${DENO_PACKAGE_MIN_VERSION} or newer is required.\n Older deno bundle resolves package.json dependencies from the npm registry instead of the installed node_modules.
3541
- crust does not install or upgrade Deno; put a newer deno first on PATH (e.g. with your version manager).`);
3542
- return {
3543
- runtime: "deno",
3544
- runner,
3545
- version
3546
- };
3547
- }
3548
- /**
3549
- * Bundle a Deno runtime package entry with native `deno bundle`, which resolves
3550
- * dependencies through the project's own Deno setup (deno.json imports,
3551
- * package.json with its installed node_modules) and inlines them into one file.
3552
- *
3553
- * Bundle the declared command directly so Deno preserves its `import.meta.main`
3554
- * semantics without making dependencies main. Prepend the packaged-build marker
3555
- * (`PACKAGED_BUILD_KEY`) to the bundled code so it runs before the inlined
3556
- * application graph. The output has no shebang or permission flags: consumers
3557
- * choose grants with `deno run` / `deno install`.
3558
- * No env files or plugins: `deno bundle` has no PUBLIC_* filter or Bun plugins.
3154
+ * Bundle a Deno runtime package entry with native `deno bundle`, which resolves
3155
+ * dependencies through the project's own Deno setup (deno.json imports,
3156
+ * package.json with its installed node_modules) and inlines them into one file.
3157
+ *
3158
+ * Bundle the declared command directly so Deno preserves its `import.meta.main`
3159
+ * semantics without making dependencies main. Prepend the packaged-build marker
3160
+ * (`PACKAGED_BUILD_KEY`) to the bundled code so it runs before the inlined
3161
+ * application graph. The output has no shebang or permission flags: consumers
3162
+ * choose grants with `deno run` / `deno install`.
3163
+ * No env files or plugins: `deno bundle` has no PUBLIC_* filter or Bun plugins.
3559
3164
  */
3560
3165
  async function execDenoPackageBuild(entryPath, outfilePath, cwd, runner = resolveDenoBuildRunner()) {
3561
3166
  await runBuildProcess(runner, [
@@ -3569,147 +3174,104 @@ async function execDenoPackageBuild(entryPath, outfilePath, cwd, runner = resolv
3569
3174
  const output = await readFile(outfilePath, "utf8");
3570
3175
  await writeFile(outfilePath, `globalThis[Symbol.for(${JSON.stringify(PACKAGED_BUILD_KEY)})] = true;\n` + output.replace(/^#![^\n]*(?:\n|$)/, ""));
3571
3176
  }
3572
- const SNAPSHOT_TIMEOUT_MS = 3e4;
3573
- const SNAPSHOT_FORWARDED_SIGNALS = [
3574
- "SIGINT",
3575
- "SIGTERM",
3576
- "SIGHUP",
3577
- "SIGQUIT"
3578
- ];
3579
- function isBuildReport(value) {
3580
- return isJsonObject(value) && Array.isArray(value.extensions) && value.extensions.every((extension) => isJsonObject(extension) && typeof extension.id === "string" && Array.isArray(extension.files) && extension.files.every((file) => typeof file === "string"));
3177
+ //#endregion
3178
+ //#region src/launcher.ts
3179
+ /** Platform binary filename: `<command>-<target>`, `.exe` on Windows. */
3180
+ function binaryFilename(command, target) {
3181
+ return `${command}-${target.target}${target.os === "win32" ? ".exe" : ""}`;
3581
3182
  }
3582
- /**
3583
- * Prepare a CLI entry's Command Snapshot in the user's project context.
3584
- *
3585
- * The entry runs as a subprocess with `CRUST_INTERNAL_SNAPSHOT_PATH` pointing
3586
- * to a temporary file. `.execute()` validates and writes the command graph and
3587
- * adjacent Build Report, then exits before any following entrypoint code can run.
3588
- *
3589
- * Runs with the same bun as compilation (`resolveBunBuildRunner`, or the
3590
- * pinned Bun `runner`): bun on PATH, or a compiled standalone crust executable
3591
- * as `BUN_BE_BUN=1`, so arbitrary `.ts` entries run without a separate `bun`
3592
- * install.
3593
- *
3594
- * After `timeoutMs`, the entry's POSIX process group or live Windows process
3595
- * tree is killed and preparation fails, even if a descendant still holds stderr.
3596
- * On POSIX, SIGINT, SIGTERM, SIGHUP, SIGQUIT or a synchronous exit of this
3597
- * process also kills the entry's group, and whatever remains of the group is
3598
- * killed once preparation settles.
3599
- */
3600
- async function buildEntrypoint(entryPath, outDir, envFiles, io, cwd, runner = resolveBunBuildRunner(), timeoutMs = SNAPSHOT_TIMEOUT_MS) {
3601
- const absoluteEntry = resolve(entryPath);
3602
- const snapshotDir = await mkdtemp(join(tmpdir(), "crust-snapshot-"));
3603
- const snapshotPath = join(snapshotDir, "command.json");
3604
- const buildReportPath = join(snapshotDir, "build-report.json");
3183
+ function generateLauncher(command, targets) {
3184
+ const targetMap = Object.fromEntries(targets.map((target) => [target.platformKey, {
3185
+ packagePathSegment: target.packagePathSegment,
3186
+ packageName: target.packageName,
3187
+ targetAlias: target.targetAlias,
3188
+ binaryFilename: binaryFilename(command, target)
3189
+ }]));
3190
+ const supportedPlatforms = targets.map((target) => target.targetAlias).join(", ");
3191
+ return `#!/usr/bin/env node
3192
+ // Auto-generated by crust build -- do not edit
3193
+ import { spawn } from "node:child_process";
3194
+ import { chmodSync, existsSync } from "node:fs";
3195
+ import { dirname, resolve } from "node:path";
3196
+ import process from "node:process";
3197
+ import { fileURLToPath } from "node:url";
3198
+
3199
+ const NAME = ${JSON.stringify(command)};
3200
+ const PLATFORMS = ${JSON.stringify(targetMap, null, " ")};
3201
+ const dir = dirname(fileURLToPath(import.meta.url));
3202
+
3203
+ // Same check as Bun's own npm installer: glibc reports its version, musl does not.
3204
+ function isMusl() {
3605
3205
  try {
3606
- const detached = process.platform !== "win32";
3607
- const proc = spawn(runner.command, [...toBunEnvFileArgs(envFiles), absoluteEntry], {
3608
- env: {
3609
- ...runner.env,
3610
- [SNAPSHOT_PATH_ENV]: snapshotPath,
3611
- [BUILD_OUT_DIR_ENV]: resolve(outDir)
3612
- },
3613
- cwd,
3614
- stdio: [
3615
- "ignore",
3616
- "ignore",
3617
- "pipe"
3618
- ],
3619
- detached
3620
- });
3621
- let stop;
3622
- const stopped = new Promise((_, reject) => {
3623
- stop = (message) => {
3624
- let failure = new Error(message);
3625
- try {
3626
- killProcessTree(proc);
3627
- } catch (cause) {
3628
- failure = new Error(`${message}\n Process-tree cleanup failed.`, { cause });
3629
- }
3630
- proc.stderr.destroy();
3631
- reject(failure);
3632
- };
3633
- });
3634
- const timer = setTimeout(() => stop(`Command Snapshot preparation timed out after ${timeoutMs / 1e3}s.\n An Extension build hook may be hanging. Use --no-validate to skip entry preparation and build hooks.`), timeoutMs);
3635
- const onSignal = (signal) => {
3636
- removeLifetimeListeners();
3637
- stop(`Command Snapshot preparation was interrupted by ${signal}.`);
3638
- if (process.listenerCount(signal) === 0) process.kill(process.pid, signal);
3639
- };
3640
- const killRemainingGroup = () => {
3641
- try {
3642
- killProcessTree(proc);
3643
- } catch {}
3644
- };
3645
- const removeLifetimeListeners = () => {
3646
- for (const signal of SNAPSHOT_FORWARDED_SIGNALS) process.removeListener(signal, onSignal);
3647
- process.removeListener("exit", killRemainingGroup);
3648
- };
3649
- if (detached) {
3650
- for (const signal of SNAPSHOT_FORWARDED_SIGNALS) process.prependListener(signal, onSignal);
3651
- process.on("exit", killRemainingGroup);
3652
- }
3653
- const [rawStderr, [exitCode]] = await Promise.race([Promise.all([text(proc.stderr), once(proc, "close")]), stopped]).finally(() => {
3654
- clearTimeout(timer);
3655
- removeLifetimeListeners();
3656
- if (detached) killRemainingGroup();
3657
- });
3658
- const stderr = rawStderr.trim();
3659
- if (proc.signalCode !== null) throw new Error(`Command Snapshot preparation was killed by ${proc.signalCode}.${stderr ? `\n${stderr}` : ""}`);
3660
- if (exitCode !== 0) throw new Error(stderr || "Command Snapshot preparation failed");
3661
- if (stderr) {
3662
- const styled = stderr.split("\n").map((line) => line.startsWith("Warning:") ? `${yellow("Warning:")}${line.slice(8)}` : line).join("\n");
3663
- io.stderr(styled);
3664
- }
3665
- let serialized;
3666
- try {
3667
- serialized = await readFile(snapshotPath, "utf8");
3668
- } catch (error) {
3669
- if (isErrnoException(error) && error.code === "ENOENT") throw new Error(`Entry exited without producing a Command Snapshot.\n Ensure ${absoluteEntry} calls await app.execute() and uses a compatible @crustjs/core version.`, { cause: error });
3670
- throw error;
3671
- }
3672
- let snapshot;
3673
- try {
3674
- snapshot = JSON.parse(serialized);
3675
- } catch (error) {
3676
- throw new Error(`Entry produced an invalid Command Snapshot.\n Ensure ${absoluteEntry} uses a compatible @crustjs/core version.`, { cause: error });
3677
- }
3678
- let serializedBuild;
3679
- try {
3680
- serializedBuild = await readFile(buildReportPath, "utf8");
3681
- } catch (error) {
3682
- if (isErrnoException(error) && error.code === "ENOENT") throw new Error(`Entry produced a Command Snapshot without a Build Report.\n Ensure ${absoluteEntry} uses a compatible @crustjs/core version.`, { cause: error });
3683
- throw error;
3684
- }
3685
- try {
3686
- const build = JSON.parse(serializedBuild);
3687
- if (!isBuildReport(build)) throw new Error("Expected extensions with string ids and files arrays.");
3688
- for (const extension of build.extensions) {
3689
- defineExtensionId(extension.id);
3690
- for (const file of extension.files) {
3691
- const path = resolve(outDir, file);
3692
- if (file === "." || file.includes("\\") || posix.normalize(file) !== file || win32.isAbsolute(file) || /^[A-Za-z]:/.test(file) || !isWithin(resolve(outDir), path) || !isWithin(realpathSync(outDir), realpathSync(path)) || !lstatSync(path).isFile()) throw new Error(`Reported artifact must be a normalized, contained regular file: ${file}`);
3693
- }
3694
- }
3695
- return {
3696
- snapshot,
3697
- build
3698
- };
3699
- } catch (error) {
3700
- throw new Error(`Entry produced an invalid Build Report.\n Ensure ${absoluteEntry} uses a compatible @crustjs/core version; upgrade Core, @crustjs/crust, and build-hook Extensions together to the pure-return build API.`, { cause: error });
3701
- }
3702
- } finally {
3703
- await rm(snapshotDir, {
3704
- recursive: true,
3705
- force: true,
3706
- maxRetries: 3,
3707
- retryDelay: 100
3708
- });
3709
- }
3206
+ const report = process.report?.getReport();
3207
+ if (report?.header) return report.header.glibcVersionRuntime === undefined;
3208
+ } catch {}
3209
+ return existsSync("/etc/alpine-release");
3210
+ }
3211
+
3212
+ const platformKey =
3213
+ \`\${process.platform}-\${process.arch}\` + (process.platform === "linux" && isMusl() ? "-musl" : "");
3214
+ const target = PLATFORMS[platformKey];
3215
+
3216
+ if (!target) {
3217
+ \tconsole.error("[" + NAME + "] Unsupported platform: " + platformKey);
3218
+ \tconsole.error("[" + NAME + "] Supported platforms: ${supportedPlatforms}");
3219
+ \tprocess.exit(1);
3220
+ }
3221
+
3222
+ const candidates = [
3223
+ \t// Hoisted install: the platform package beside this one in node_modules.
3224
+ \tresolve(dir, "..", "..", target.packagePathSegment, "bin", target.binaryFilename),
3225
+ \t// Nested install: the platform package under this package's node_modules.
3226
+ \tresolve(dir, "..", "node_modules", target.packageName, "bin", target.binaryFilename),
3227
+ \t// In place: the .crust/ tree crust build staged, before any install.
3228
+ \tresolve(dir, "..", "..", target.targetAlias, "bin", target.binaryFilename),
3229
+ ];
3230
+ const binPath = candidates.find((candidate) => existsSync(candidate));
3231
+
3232
+ if (!binPath) {
3233
+ \tconsole.error("[" + NAME + "] Missing platform package for " + platformKey);
3234
+ \tconsole.error("[" + NAME + "] Tried:");
3235
+ \tfor (const candidate of candidates) console.error(" " + candidate);
3236
+ \tconsole.error(
3237
+ \t\t"[" + NAME + "] Reinstall dependencies on this platform and ensure optional dependencies are enabled.",
3238
+ \t);
3239
+ \tprocess.exit(1);
3240
+ }
3241
+
3242
+ if (process.platform !== "win32") {
3243
+ \ttry {
3244
+ \t\tchmodSync(binPath, 0o755);
3245
+ \t} catch {
3246
+ \t\t// Ignore permission adjustment failures and let spawn surface real errors.
3247
+ \t}
3248
+ }
3249
+
3250
+ const child = spawn(binPath, process.argv.slice(2), {
3251
+ \tstdio: "inherit",
3252
+ });
3253
+
3254
+ child.on("error", (error) => {
3255
+ \tconsole.error("[" + NAME + "] Failed to launch binary: " + error.message);
3256
+ \tprocess.exit(1);
3257
+ });
3258
+
3259
+ child.on("exit", (code, signal) => {
3260
+ \tif (signal) {
3261
+ \t\ttry {
3262
+ \t\t\tprocess.kill(process.pid, signal);
3263
+ \t\t} catch {
3264
+ \t\t\tprocess.exit(1);
3265
+ \t\t}
3266
+ \t\treturn;
3267
+ \t}
3268
+
3269
+ \tprocess.exit(code ?? 0);
3270
+ });
3271
+ `;
3710
3272
  }
3711
3273
  //#endregion
3712
- //#region src/utils/distribute.ts
3274
+ //#region src/distribute.ts
3713
3275
  /** Project-relative directory that `crust build` owns: wiped per build, read by `crust publish`. */
3714
3276
  const CRUST_DIR = ".crust";
3715
3277
  /**
@@ -3731,15 +3293,9 @@ const METADATA_KEYS = [
3731
3293
  "funding",
3732
3294
  "engines"
3733
3295
  ];
3734
- function readPackageJson(cwd, packageJson) {
3735
- if (packageJson === void 0) throw new Error(`package.json not found in ${cwd}\n crust build requires a package.json with name and version fields.`);
3736
- if (!isJsonObject(packageJson)) throw new Error(`package.json in ${cwd} must contain a JSON object.`);
3737
- validatePackageIdentity(packageJson, "package.json");
3738
- return packageJson;
3739
- }
3740
3296
  /** Only identity is interpreted here; this is not a complete npm schema validator. */
3741
3297
  function validatePackageIdentity(value, source) {
3742
- if (value === void 0 || !isJsonObject(value)) throw new Error(`${source} must contain a JSON object.`);
3298
+ if (!isJsonObject(value)) throw new Error(`${source} must contain a JSON object.`);
3743
3299
  if (typeof value.name !== "string" || value.name.trim() === "") throw new Error(`${source} name field must be a non-empty string.`);
3744
3300
  if (typeof value.version !== "string" || value.version.trim() === "") throw new Error(`${source} version field must be a non-empty string.`);
3745
3301
  }
@@ -3754,10 +3310,6 @@ function derivePlatformPackageName(rootPackageName, targetAlias) {
3754
3310
  function getPackagePathSegment(packageName) {
3755
3311
  return packageName.startsWith("@") ? packageName.split("/")[1] ?? packageName : packageName;
3756
3312
  }
3757
- /** Platform binary filename: `<command>-<target>`, `.exe` on Windows. */
3758
- function binaryFilename(command, target) {
3759
- return `${command}-${target.target}${target.os === "win32" ? ".exe" : ""}`;
3760
- }
3761
3313
  function platformBinMap(commands, target) {
3762
3314
  return Object.fromEntries(commands.map((command) => [command, `bin/${binaryFilename(command, target)}`]));
3763
3315
  }
@@ -3769,6 +3321,7 @@ function buildDistributionRootPackageJson(metadata, commands, targets, { artifac
3769
3321
  type: "module",
3770
3322
  files: ["bin", ...artifactDirs],
3771
3323
  bin: Object.fromEntries(commands.map((command) => [command, `bin/${command}.js`])),
3324
+ dependencies: metadata.dependencies,
3772
3325
  ...targets.length > 0 ? { optionalDependencies: Object.fromEntries(targets.map((target) => [target.packageName, metadata.version])) } : {},
3773
3326
  ...manPages.length > 0 ? { man: manPages.map((page) => `./man/${page}`) } : {},
3774
3327
  exports: metadata.exports,
@@ -3834,16 +3387,23 @@ function validateStagedExports(exports, rootDir, sourceType) {
3834
3387
  checkTarget(exports, "\".\"");
3835
3388
  }
3836
3389
  /**
3390
+ * The staged manifests go to npm as written, so a range the registry cannot
3391
+ * resolve (a workspace, catalog, or local path protocol) would leak into it.
3392
+ * Bare paths and scheme-less tarball names are local too, except GitHub
3393
+ * owner/repo[#ref] shorthand: npm resolves hosted Git before its file fallback.
3394
+ */
3395
+ function assertPublishableRange(range, label) {
3396
+ if (range === void 0 || !isString$1(range) || /^(?:(?:workspace|catalog|file|link|portal):|[.]|~[/\\]|[/\\]|[a-zA-Z]:)/.test(range) || !/^[a-z][a-z+]*:/i.test(range) && !/^[^@\s/:#\\]+\/[^@\s/:#\\]+(?:#.*)?$/.test(range) && /[.](?:tgz|tar\.gz|tar)$/i.test(range)) throw new Error(`${label} must be a publishable range, not ${JSON.stringify(range)}.\n crust build publishes the staged root package as written; workspace:, catalog:, file:, link:, portal:, and local path ranges are never rewritten.`);
3397
+ }
3398
+ /**
3837
3399
  * Carries `peerDependencies` (and `peerDependenciesMeta`) into the root package
3838
3400
  * so a library `exports` entry can declare what its published types import.
3839
- * Ranges must be publishable as written: the staged manifests go to npm
3840
- * directly, so `workspace:` and `catalog:` ranges would leak into the registry.
3841
3401
  */
3842
3402
  function validatePeerDependencies(peerDependencies, peerDependenciesMeta) {
3843
3403
  if (!isJsonObject(peerDependencies)) throw new Error("package.json peerDependencies must be an object of package names to ranges.");
3844
3404
  const ranges = {};
3845
3405
  for (const [name, range] of Object.entries(peerDependencies)) {
3846
- if (!isString$1(range) || /^(workspace|catalog):/.test(range)) throw new Error(`package.json peerDependencies[${JSON.stringify(name)}] must be a publishable range, not ${JSON.stringify(range)}.\n crust build publishes the staged root package as written; workspace: and catalog: ranges are never rewritten.`);
3406
+ assertPublishableRange(range, `package.json peerDependencies[${JSON.stringify(name)}]`);
3847
3407
  ranges[name] = range;
3848
3408
  }
3849
3409
  if (peerDependenciesMeta === void 0) return { peerDependencies: ranges };
@@ -3873,130 +3433,49 @@ function pickRootMetadata(pkgJson) {
3873
3433
  };
3874
3434
  for (const key of METADATA_KEYS) {
3875
3435
  const value = pkgJson[key];
3876
- if (value !== void 0) Object.assign(metadata, { [key]: value });
3436
+ if (value !== void 0) metadata[key] = value;
3877
3437
  }
3878
3438
  return metadata;
3879
3439
  }
3880
3440
  function validatePackageNameLength(packageName) {
3881
3441
  if (packageName.length > MAX_PACKAGE_NAME_LENGTH) throw new Error(`Generated package name is too long for npm: ${packageName}\n Keep package names at or below ${MAX_PACKAGE_NAME_LENGTH} characters after the platform suffix is added.`);
3882
3442
  }
3883
- function resolveDistributionMetadata(cwd, userPackageJson) {
3884
- const pkgJson = readPackageJson(cwd, userPackageJson);
3443
+ /** `crust.external` ranges, already validated by `planBuild`; re-asserted for their type. */
3444
+ function externalDependencies(pkgJson, external) {
3445
+ const dependencies = pkgJson.dependencies !== void 0 && isJsonObject(pkgJson.dependencies) ? pkgJson.dependencies : {};
3446
+ return Object.fromEntries(external.map((name) => {
3447
+ const range = dependencies[name];
3448
+ assertPublishableRange(range, `package.json dependencies[${JSON.stringify(name)}]`);
3449
+ return [name, range];
3450
+ }));
3451
+ }
3452
+ function resolveDistributionMetadata(pkgJson, external) {
3885
3453
  validatePackageNameLength(pkgJson.name);
3886
3454
  return {
3887
3455
  rootPackageName: pkgJson.name,
3888
3456
  version: pkgJson.version,
3889
3457
  rootPackageJson: pickRootMetadata(pkgJson),
3890
- sourceType: pkgJson.type,
3891
- ...pkgJson.exports !== void 0 ? { exports: pkgJson.exports } : {},
3892
- ...pkgJson.peerDependencies !== void 0 ? validatePeerDependencies(pkgJson.peerDependencies, pkgJson.peerDependenciesMeta) : {}
3893
- };
3894
- }
3895
- function resolveDistributionTarget(table, stageDir, rootPackageName, target) {
3896
- const info = table.info[target];
3897
- const packageName = derivePlatformPackageName(rootPackageName, info.alias);
3898
- validatePackageNameLength(packageName);
3899
- return {
3900
- target,
3901
- platformKey: info.platformKey,
3902
- targetAlias: info.alias,
3903
- packageName,
3904
- packagePathSegment: getPackagePathSegment(packageName),
3905
- packageDir: resolve(stageDir, info.alias),
3906
- os: info.os,
3907
- cpu: info.cpu,
3908
- ...info.libc ? { libc: info.libc } : {}
3909
- };
3910
- }
3911
- function generateDistributionJsResolver(command, targets) {
3912
- const targetMap = Object.fromEntries(targets.map((target) => [target.platformKey, {
3913
- packagePathSegment: target.packagePathSegment,
3914
- packageName: target.packageName,
3915
- targetAlias: target.targetAlias,
3916
- binaryFilename: binaryFilename(command, target)
3917
- }]));
3918
- const supportedPlatforms = targets.map((target) => target.targetAlias).join(", ");
3919
- return `#!/usr/bin/env node
3920
- // Auto-generated by crust build -- do not edit
3921
- import { spawn } from "node:child_process";
3922
- import { chmodSync, existsSync } from "node:fs";
3923
- import { dirname, resolve } from "node:path";
3924
- import process from "node:process";
3925
- import { fileURLToPath } from "node:url";
3926
-
3927
- const NAME = ${JSON.stringify(command)};
3928
- const PLATFORMS = ${JSON.stringify(targetMap, null, " ")};
3929
- const dir = dirname(fileURLToPath(import.meta.url));
3930
-
3931
- // Same check as Bun's own npm installer: glibc reports its version, musl does not.
3932
- function isMusl() {
3933
- try {
3934
- const report = process.report?.getReport();
3935
- if (report?.header) return report.header.glibcVersionRuntime === undefined;
3936
- } catch {}
3937
- return existsSync("/etc/alpine-release");
3938
- }
3939
-
3940
- const platformKey =
3941
- \`\${process.platform}-\${process.arch}\` + (process.platform === "linux" && isMusl() ? "-musl" : "");
3942
- const target = PLATFORMS[platformKey];
3943
-
3944
- if (!target) {
3945
- \tconsole.error("[" + NAME + "] Unsupported platform: " + platformKey);
3946
- \tconsole.error("[" + NAME + "] Supported platforms: ${supportedPlatforms}");
3947
- \tprocess.exit(1);
3948
- }
3949
-
3950
- const candidates = [
3951
- \t// Hoisted install: the platform package beside this one in node_modules.
3952
- \tresolve(dir, "..", "..", target.packagePathSegment, "bin", target.binaryFilename),
3953
- \t// Nested install: the platform package under this package's node_modules.
3954
- \tresolve(dir, "..", "node_modules", target.packageName, "bin", target.binaryFilename),
3955
- \t// In place: the .crust/ tree crust build staged, before any install.
3956
- \tresolve(dir, "..", "..", target.targetAlias, "bin", target.binaryFilename),
3957
- ];
3958
- const binPath = candidates.find((candidate) => existsSync(candidate));
3959
-
3960
- if (!binPath) {
3961
- \tconsole.error("[" + NAME + "] Missing platform package for " + platformKey);
3962
- \tconsole.error("[" + NAME + "] Tried:");
3963
- \tfor (const candidate of candidates) console.error(" " + candidate);
3964
- \tconsole.error(
3965
- \t\t"[" + NAME + "] Reinstall dependencies on this platform and ensure optional dependencies are enabled.",
3966
- \t);
3967
- \tprocess.exit(1);
3968
- }
3969
-
3970
- if (process.platform !== "win32") {
3971
- \ttry {
3972
- \t\tchmodSync(binPath, 0o755);
3973
- \t} catch {
3974
- \t\t// Ignore permission adjustment failures and let spawn surface real errors.
3975
- \t}
3976
- }
3977
-
3978
- const child = spawn(binPath, process.argv.slice(2), {
3979
- \tstdio: "inherit",
3980
- });
3981
-
3982
- child.on("error", (error) => {
3983
- \tconsole.error("[" + NAME + "] Failed to launch binary: " + error.message);
3984
- \tprocess.exit(1);
3985
- });
3986
-
3987
- child.on("exit", (code, signal) => {
3988
- \tif (signal) {
3989
- \t\ttry {
3990
- \t\t\tprocess.kill(process.pid, signal);
3991
- \t\t} catch {
3992
- \t\t\tprocess.exit(1);
3993
- \t\t}
3994
- \t\treturn;
3995
- \t}
3996
-
3997
- \tprocess.exit(code ?? 0);
3998
- });
3999
- `;
3458
+ sourceType: pkgJson.type,
3459
+ ...pkgJson.exports !== void 0 ? { exports: pkgJson.exports } : {},
3460
+ ...pkgJson.peerDependencies !== void 0 ? validatePeerDependencies(pkgJson.peerDependencies, pkgJson.peerDependenciesMeta) : {},
3461
+ ...external.length > 0 ? { dependencies: externalDependencies(pkgJson, external) } : {}
3462
+ };
3463
+ }
3464
+ function resolveDistributionTarget(table, stageDir, rootPackageName, target) {
3465
+ const info = table.info[target];
3466
+ const packageName = derivePlatformPackageName(rootPackageName, info.alias);
3467
+ validatePackageNameLength(packageName);
3468
+ return {
3469
+ target,
3470
+ platformKey: info.platformKey,
3471
+ targetAlias: info.alias,
3472
+ packageName,
3473
+ packagePathSegment: getPackagePathSegment(packageName),
3474
+ packageDir: resolve(stageDir, info.alias),
3475
+ os: info.os,
3476
+ cpu: info.cpu,
3477
+ ...info.libc ? { libc: info.libc } : {}
3478
+ };
4000
3479
  }
4001
3480
  function writeJson(path, value) {
4002
3481
  writeFileSync(path, `${JSON.stringify(value, null, " ")}\n`);
@@ -4037,13 +3516,14 @@ function writeDistributionManifest(stageDir, metadata, identity, commands, targe
4037
3516
  publishOrder: [...targets.map((target) => target.targetAlias), "root"],
4038
3517
  ...build ? { build } : {}
4039
3518
  };
4040
- writeJson(join(stageDir, "manifest.json"), manifest);
4041
- return manifest;
3519
+ const manifestPath = join(stageDir, "manifest.json");
3520
+ writeJson(manifestPath, manifest);
3521
+ return manifestPath;
4042
3522
  }
4043
3523
  /** `pkg` without `engines.node`, dropping `engines` once nothing else is left. */
4044
3524
  function omitNodeEngine(pkg) {
4045
3525
  const { engines, ...rest } = pkg;
4046
- if (engines?.node === void 0) return pkg;
3526
+ if (engines === void 0 || !isJsonObject(engines) || engines.node === void 0) return pkg;
4047
3527
  const others = Object.entries(engines).filter(([runtime]) => runtime !== "node");
4048
3528
  return others.length > 0 ? {
4049
3529
  ...rest,
@@ -4068,7 +3548,7 @@ function stageDistributionPackages(cwd, stageDir, metadata, platformMetadata, co
4068
3548
  * Returns every generated package.json, launcher, and compiled command in staging order.
4069
3549
  */
4070
3550
  async function runDistributeBuild(plan, distribution, io, build) {
4071
- const sourceMetadata = resolveDistributionMetadata(plan.cwd, plan.userPackageJson);
3551
+ const sourceMetadata = resolveDistributionMetadata(plan.userPackageJson, plan.external);
4072
3552
  const commands = plan.entries.map((entry) => entry.command);
4073
3553
  const table = distribution.table;
4074
3554
  const platformMetadata = plan.runtime === "node" && table ? {
@@ -4117,7 +3597,7 @@ async function runDistributeBuild(plan, distribution, io, build) {
4117
3597
  if (table) {
4118
3598
  for (const { command } of plan.entries) {
4119
3599
  const launcherPath = join(rootBinDir, `${command}.js`);
4120
- writeFileSync(launcherPath, generateDistributionJsResolver(command, distributionTargets), { mode: 493 });
3600
+ writeFileSync(launcherPath, generateLauncher(command, distributionTargets), { mode: 493 });
4121
3601
  produced.push({
4122
3602
  kind: "launcher",
4123
3603
  path: launcherPath,
@@ -4145,7 +3625,7 @@ async function runDistributeBuild(plan, distribution, io, build) {
4145
3625
  command
4146
3626
  });
4147
3627
  }
4148
- writeDistributionManifest(plan.stageDir, metadata, table ? {
3628
+ const manifestPath = writeDistributionManifest(plan.stageDir, metadata, table ? {
4149
3629
  runtime: plan.runtime,
4150
3630
  artifact: "binary",
4151
3631
  embeddedRuntimeVersion: distribution.embeddedRuntimeVersion
@@ -4153,116 +3633,470 @@ async function runDistributeBuild(plan, distribution, io, build) {
4153
3633
  runtime: plan.runtime,
4154
3634
  artifact: "package"
4155
3635
  }, commands, distributionTargets, build);
4156
- const manifestPath = join(plan.stageDir, "manifest.json");
4157
3636
  io.stdout(`\n${green("✓")} Staged ${bold(`${distributionTargets.length + 1}`)} npm package(s) successfully:`);
4158
3637
  io.stdout(` ${rootDir}`);
4159
3638
  for (const targetPackage of distributionTargets) io.stdout(` ${targetPackage.packageDir}`);
4160
3639
  io.stdout(`\n${dim("Manifest:")} ${manifestPath}`);
4161
3640
  return produced;
4162
3641
  }
3642
+ //#endregion
3643
+ //#region package.json
3644
+ var optionalDependencies = {
3645
+ "@tsdown/exe": "0.23.0",
3646
+ "tsdown": "0.23.0"
3647
+ };
3648
+ //#endregion
3649
+ //#region src/node-exe.ts
4163
3650
  /**
4164
- * `crust.include` directories normalized to cwd-relative POSIX names. They are
4165
- * staged exactly like Extension artifacts.
3651
+ * Crust's own optional dependencies that build Node standalone binaries.
3652
+ * Crust's staged root and platform packages declare them
3653
+ * (scripts/stage-node-exe-dependencies.ts) so an installed crust resolves them
3654
+ * next to itself; an install that skipped them only affects Node binaries.
4166
3655
  */
4167
- function collectIncludeDirs(cwd, stageDir, include, artifactNames) {
4168
- const names = [...artifactNames];
4169
- const includeDirs = [];
4170
- for (const entry of include) {
4171
- const dir = resolve(cwd, entry);
4172
- const name = relative(cwd, dir);
4173
- if (isAbsolute(entry) || name === "" || !isWithin(cwd, dir)) throw new Error(`package.json crust.include entry ${JSON.stringify(entry)} must be a directory inside the project root ${cwd}.`);
4174
- if (!existsSync(dir) || !statSync(dir).isDirectory()) throw new Error(`package.json crust.include entry ${JSON.stringify(entry)} is not a directory: ${dir}`);
4175
- assertResolvesInsideProject(cwd, entry, dir);
4176
- if (isWithin(stageDir, dir) || isWithin(dir, stageDir)) throw new Error(`package.json crust.include entry ${JSON.stringify(entry)} overlaps the build output directory ${stageDir}, which crust build replaces.`);
4177
- if (name.toLowerCase().split(sep)[0] === "bin") throw new Error(`package.json crust.include entry ${JSON.stringify(entry)} conflicts with the generated npm bin directory.\n Include a directory with a different top-level name.`);
4178
- const posixName = name.replaceAll(sep, "/");
4179
- const key = posixName.toLowerCase();
4180
- const overlap = names.find((staged) => {
4181
- const stagedKey = staged.toLowerCase();
4182
- return stagedKey === key || stagedKey.startsWith(`${key}/`) || key.startsWith(`${stagedKey}/`);
3656
+ const NODE_EXE_BACKEND_PACKAGES = ["tsdown", "@tsdown/exe"];
3657
+ /**
3658
+ * A file of Crust's own installation that the backend is resolved from, never
3659
+ * the project or PATH. A compiled crust serves its modules from Bun's virtual
3660
+ * filesystem (`/$bunfs/`, `B:/~BUN/` on Windows), so there it is the
3661
+ * executable, inside its platform package.
3662
+ */
3663
+ function crustInstallPath(moduleUrl = import.meta.url, execPath = process.execPath) {
3664
+ return /^file:\/\/\/(?:\$bunfs|[A-Za-z]:\/~BUN)\//.test(moduleUrl) ? execPath : fileURLToPath(moduleUrl);
3665
+ }
3666
+ /**
3667
+ * `<dir>/node_modules/<name>/package.json` for the nearest `dir` at or above
3668
+ * `from`: Node's node_modules lookup without its NODE_PATH and global-folder
3669
+ * fallbacks, so only an installation next to `from` can supply the package.
3670
+ * Package managers hoist crust's backend into the project's node_modules, so
3671
+ * the found package may be the project's own: callers check its version.
3672
+ */
3673
+ function findInstalledPackageJson(from, name) {
3674
+ for (let dir = from;; dir = dirname(dir)) {
3675
+ const candidate = join(dir, "node_modules", name, "package.json");
3676
+ if (existsSync(candidate)) return candidate;
3677
+ if (dirname(dir) === dir) return null;
3678
+ }
3679
+ }
3680
+ function isVersionedPackageJson(value) {
3681
+ return isJsonObject(value) && typeof value.version === "string";
3682
+ }
3683
+ function isTsdownPackageJson(value) {
3684
+ return isVersionedPackageJson(value) && value.engines !== void 0 && isJsonObject(value.engines) && value.engines.node !== void 0 && isVersionRange(value.engines.node);
3685
+ }
3686
+ function createNodeExeProbeScript(tsdownPackageJson) {
3687
+ return `// Generated by crust build to read tsdown's executable minimum; deleted when it exits.
3688
+ import { createRequire } from "node:module";
3689
+ import { pathToFileURL } from "node:url";
3690
+
3691
+ const internal = createRequire(${JSON.stringify(tsdownPackageJson)}).resolve("tsdown/internal");
3692
+ console.log((await import(pathToFileURL(internal).href)).NODE_SEA_MIN_VERSION);
3693
+ `;
3694
+ }
3695
+ const NODE_TOOLCHAIN_GUIDANCE = " Binaries embed the selected node's version; crust does not install or upgrade it.\n Put a supported node first on PATH (e.g. with your version manager).";
3696
+ /**
3697
+ * Checks the selected Node against the installed backend's two separate
3698
+ * requirements: tsdown's package `engines.node` and its executable builder's
3699
+ * minimum (skipped while unknown). Both come from the installed tsdown, not
3700
+ * from this file.
3701
+ */
3702
+ function assertNodeExeBackendSupports(compiler, backend) {
3703
+ const selected = `Node ${compiler.version} (${compiler.runner.command})`;
3704
+ if (!(0, import_satisfies.default)(compiler.version, backend.engines)) throw new Error(`${selected} is not supported by tsdown ${backend.version}, which builds node standalone binaries: its package requires Node "${backend.engines}".\n${NODE_TOOLCHAIN_GUIDANCE}`);
3705
+ if (backend.seaMinVersion !== void 0 && !(0, import_satisfies.default)(compiler.version, `>=${backend.seaMinVersion}`)) throw new Error(`${selected} cannot build standalone executables: tsdown ${backend.version}'s executable builder requires Node ${backend.seaMinVersion} or later.\n${NODE_TOOLCHAIN_GUIDANCE}`);
3706
+ }
3707
+ /**
3708
+ * Finds Crust's installed tsdown and @tsdown/exe from `installPath`, at the
3709
+ * versions its package.json `optionalDependencies` pin, and validates the
3710
+ * selected Node against tsdown's requirements, loading the executable
3711
+ * builder's minimum under that Node. `cwd` is the project
3712
+ * directory, as for every other compiler step.
3713
+ */
3714
+ async function resolveNodeExeBackend(compiler, cwd, installPath = crustInstallPath()) {
3715
+ const reinstall = ` @crustjs/crust ships ${NODE_EXE_BACKEND_PACKAGES.join(" and ")} as optional dependencies, which package managers skip when optional dependencies are disabled or the installing node does not satisfy tsdown's engines.\n Reinstall @crustjs/crust with optional dependencies enabled under a node that can build Node binaries (e.g. the one on PATH here).`;
3716
+ const packageJsonPath = findInstalledPackageJson(dirname(installPath), "tsdown");
3717
+ if (packageJsonPath === null) throw new Error(`tsdown, which builds node standalone binaries, is not installed with crust (${installPath}).\n${reinstall}`);
3718
+ const exePackageJsonPath = findInstalledPackageJson(dirname(realpathSync(packageJsonPath)), "@tsdown/exe");
3719
+ if (exePackageJsonPath === null) throw new Error(`@tsdown/exe is not installed beside crust's tsdown (${packageJsonPath}).\n${reinstall}`);
3720
+ const readPackage = (path) => JSON.parse(readFileSync(path, "utf8"));
3721
+ const tsdownPackage = readPackage(packageJsonPath);
3722
+ for (const [name, path] of [["tsdown", packageJsonPath], ["@tsdown/exe", exePackageJsonPath]]) {
3723
+ const installed = name === "tsdown" ? tsdownPackage : readPackage(path);
3724
+ const version = isVersionedPackageJson(installed) ? installed.version : "without a version";
3725
+ const pinned = optionalDependencies[name];
3726
+ if (!(0, import_satisfies.default)(version, pinned)) throw new Error(`${name} ${version} (${path}) is not the ${name} ${pinned} that crust builds node standalone binaries with.\n${reinstall}`);
3727
+ }
3728
+ if (!isTsdownPackageJson(tsdownPackage)) throw new Error(`Unexpected tsdown package metadata in ${packageJsonPath}.\n${reinstall}`);
3729
+ const backend = {
3730
+ packageJsonPath,
3731
+ version: tsdownPackage.version,
3732
+ engines: tsdownPackage.engines.node
3733
+ };
3734
+ assertNodeExeBackendSupports(compiler, backend);
3735
+ const probe = await runGeneratedScript(compiler.runner, "probe", createNodeExeProbeScript(packageJsonPath), cwd);
3736
+ const seaMinVersion = probe.exitCode === 0 ? (0, import_valid.default)(probe.stdout.trim()) : null;
3737
+ if (seaMinVersion === null) {
3738
+ const output = processOutput(probe);
3739
+ throw new Error(`tsdown ${backend.version}'s executable builder could not be loaded with ${compiler.runner.command} (exit ${probe.exitCode}).\n${reinstall}${output ? `\n${output}` : ""}`);
3740
+ }
3741
+ const resolved = {
3742
+ ...backend,
3743
+ seaMinVersion
3744
+ };
3745
+ assertNodeExeBackendSupports(compiler, resolved);
3746
+ return resolved;
3747
+ }
3748
+ /**
3749
+ * Select the Node binary compiler once for a build: the external `node`, its
3750
+ * version read in the project directory `cwd`, validated against the
3751
+ * project's `engines.node` and then the installed tsdown's requirements.
3752
+ * Pass it to {@link execNodeBinaryBuild} for every command and target.
3753
+ */
3754
+ async function resolveNodeBinaryCompiler(userPackageJson, cwd) {
3755
+ const runner = resolveNodeBuildRunner();
3756
+ const compiler = {
3757
+ runtime: "node",
3758
+ runner,
3759
+ version: await readCompilerVersion("node", runner, cwd)
3760
+ };
3761
+ assertCompilerSatisfiesEngines(compiler, userPackageJson);
3762
+ return {
3763
+ ...compiler,
3764
+ backend: await resolveNodeExeBackend(compiler, cwd)
3765
+ };
3766
+ }
3767
+ /** tsdown's executable target for a Node target, embedding exactly `nodeVersion`. */
3768
+ function nodeExeTarget(target, nodeVersion) {
3769
+ const { os, cpu } = NODE_TARGETS.info[target];
3770
+ return {
3771
+ platform: os === "win32" ? "win" : os,
3772
+ arch: cpu,
3773
+ nodeVersion
3774
+ };
3775
+ }
3776
+ /**
3777
+ * Script the selected Node runs to turn one Bun bundle into one target's
3778
+ * executable with tsdown's programmatic `build`. Every value is embedded via
3779
+ * JSON.stringify.
3780
+ */
3781
+ function createNodeExeBuildScript(options) {
3782
+ return `// Generated by crust build for a Node standalone binary; deleted when the build finishes.
3783
+ import { createRequire } from "node:module";
3784
+ import { pathToFileURL } from "node:url";
3785
+
3786
+ const options = ${JSON.stringify(options)};
3787
+ const { build } = await import(pathToFileURL(createRequire(options.tsdownPackageJson).resolve("tsdown")).href);
3788
+ await build({
3789
+ config: false,
3790
+ cwd: options.cwd,
3791
+ entry: { app: options.entry },
3792
+ outDir: options.bundleDir,
3793
+ format: "esm",
3794
+ platform: "node",
3795
+ dts: false,
3796
+ // Rolldown reprints the Bun bundle, so it minifies again.
3797
+ minify: options.minify,
3798
+ logLevel: "warn",
3799
+ report: false,
3800
+ // Bun already inlined the PUBLIC_* constants; inline nothing from this environment.
3801
+ envPrefix: [],
3802
+ // Bun leaves only Node built-ins as imports; fail on anything else, which
3803
+ // an executable shipping no node_modules could not resolve.
3804
+ deps: { alwaysBundle: () => true, onlyBundle: false, onlyImport: [] },
3805
+ exe: { outDir: options.exeDir, fileName: "app", targets: [options.target] },
3806
+ });
3807
+ `;
3808
+ }
3809
+ /**
3810
+ * Build a single entry file into a Node standalone executable: Bun first
3811
+ * bundles it for Node exactly as a Node runtime package ({@link execScriptBuild}:
3812
+ * `PUBLIC_*` constants from `envFiles` and the environment, each module's own
3813
+ * `import.meta.main` and `require.main === module`, CommonJS and ESM inputs),
3814
+ * then tsdown's executable builder (Node SEA), run by the selected external
3815
+ * Node, turns that bundle into an executable embedding exactly its version
3816
+ * for `target`. tsdown takes a single entry per executable, so callers build
3817
+ * every command separately. Matching official Node binaries for targets are
3818
+ * downloaded, checksum-verified, and cached by @tsdown/exe. Builds for darwin
3819
+ * are only code-signed on a macOS host.
3820
+ *
3821
+ * @param compiler - The selected Node compiler (`resolveNodeBinaryCompiler`)
3822
+ * @param bunRunner - The Bun that bundles (`resolveBunBuildRunner`)
3823
+ * @param onWarning - Receives the backend's warnings (such as a skipped code
3824
+ * signature) after a successful build
3825
+ * @throws {Error} If the build fails
3826
+ */
3827
+ async function execNodeBinaryBuild(entryPath, outfilePath, target, settings, compiler, bunRunner, onWarning = () => {}) {
3828
+ const { cwd, minify } = settings;
3829
+ const workDir = await mkdtemp(join(tmpdir(), "crust-node-exe-"));
3830
+ try {
3831
+ const scriptPath = join(workDir, "build.mjs");
3832
+ const bunBundle = join(workDir, "app.mjs");
3833
+ const exeDir = join(workDir, "exe");
3834
+ await execScriptBuild("node", resolve(cwd, entryPath), bunBundle, {
3835
+ ...settings,
3836
+ bunPlugins: [],
3837
+ external: []
3838
+ }, bunRunner);
3839
+ await writeFile(scriptPath, createNodeExeBuildScript({
3840
+ tsdownPackageJson: compiler.backend.packageJsonPath,
3841
+ cwd,
3842
+ entry: bunBundle,
3843
+ bundleDir: join(workDir, "bundle"),
3844
+ exeDir,
3845
+ minify,
3846
+ target: nodeExeTarget(target, compiler.version)
3847
+ }));
3848
+ const output = await runBuildProcess(compiler.runner, [scriptPath], outfilePath, cwd);
3849
+ const produced = await readdir(exeDir);
3850
+ if (produced.length !== 1) throw new Error(`Build failed for ${outfilePath}: expected one executable from tsdown, found ${JSON.stringify(produced)}.`);
3851
+ await copyFile(join(exeDir, produced[0]), outfilePath);
3852
+ if (output) onWarning(output);
3853
+ } finally {
3854
+ await rm(workDir, {
3855
+ recursive: true,
3856
+ force: true,
3857
+ maxRetries: 3,
3858
+ retryDelay: 100
3859
+ });
3860
+ }
3861
+ }
3862
+ function createNodeExeProvisionScript(tsdownPackageJson, targets) {
3863
+ return `// Generated by crust build to provision Node binaries for targets; deleted when it exits.
3864
+ import { realpathSync } from "node:fs";
3865
+ import { createRequire } from "node:module";
3866
+ import { pathToFileURL } from "node:url";
3867
+
3868
+ const exe = createRequire(realpathSync(${JSON.stringify(tsdownPackageJson)})).resolve("@tsdown/exe");
3869
+ const { resolveNodeBinary } = await import(pathToFileURL(exe).href);
3870
+ for (const { name, target } of ${JSON.stringify(targets)}) {
3871
+ try {
3872
+ await resolveNodeBinary(target, {});
3873
+ } catch (error) {
3874
+ console.error(name + ": " + error.message + (error.cause?.message ? "\\n" + error.cause.message : ""));
3875
+ process.exit(1);
3876
+ }
3877
+ }
3878
+ `;
3879
+ }
3880
+ /**
3881
+ * Provisions the Node binary every target embeds before anything is staged,
3882
+ * with the selected Node and the call tsdown's executable builder makes per
3883
+ * target (@tsdown/exe's `resolveNodeBinary`): an uncached official archive of
3884
+ * exactly `compiler.version` is downloaded, checksum-verified, unpacked with
3885
+ * the tool that target's archive needs on this host, and cached, where
3886
+ * {@link execNodeBinaryBuild} then finds it. A missing unpacking tool, a failed
3887
+ * download, or a checksum mismatch therefore fails here, before `.crust/` is
3888
+ * replaced; cached targets need nothing.
3889
+ *
3890
+ * @throws {Error} If a target cannot be provisioned
3891
+ */
3892
+ async function provisionNodeExeTargets(targets, cwd, compiler) {
3893
+ const provision = await runGeneratedScript(compiler.runner, "provision", createNodeExeProvisionScript(compiler.backend.packageJsonPath, targets.map((name) => ({
3894
+ name,
3895
+ target: nodeExeTarget(name, compiler.version)
3896
+ }))), cwd);
3897
+ if (provision.exitCode !== 0) {
3898
+ const output = processOutput(provision);
3899
+ throw new Error(`Could not provision the Node ${compiler.version} binary that node standalone binaries embed${output ? `:\n${output}` : "."}\n tsdown downloads each target's official Node archive once and unpacks it with tar (GNU tar also needs xz for linux targets), or unzip for win targets on non-Windows hosts.
3900
+ Install the missing tool, or build only targets this machine can provision (--target).`);
3901
+ }
3902
+ }
3903
+ /** Runs a generated `<name>.mjs` with `runner` from a temporary directory that is removed afterwards. */
3904
+ async function runGeneratedScript(runner, name, source, cwd) {
3905
+ const workDir = await mkdtemp(join(tmpdir(), `crust-node-exe-${name}-`));
3906
+ try {
3907
+ const scriptPath = join(workDir, `${name}.mjs`);
3908
+ await writeFile(scriptPath, source);
3909
+ return await runProcess(runner.command, [scriptPath], {
3910
+ env: runner.env,
3911
+ cwd,
3912
+ stdio: "collect"
3913
+ });
3914
+ } finally {
3915
+ await rm(workDir, {
3916
+ recursive: true,
3917
+ force: true
4183
3918
  });
4184
- if (overlap !== void 0) throw new Error(`package.json crust.include entry ${JSON.stringify(entry)} overlaps "${overlap}", which is already staged (duplicate include or Extension artifact directory).`);
4185
- names.push(posixName);
4186
- includeDirs.push(posixName);
4187
3919
  }
4188
- return includeDirs;
4189
3920
  }
4190
- /**
4191
- * Walks `dir` the way the dereferencing copy will (through symlinked
4192
- * directories) and rejects any path whose real location leaves the project.
4193
- */
4194
- function assertResolvesInsideProject(cwd, entry, dir) {
4195
- const realCwd = realpathSync(cwd);
4196
- const seen = /* @__PURE__ */ new Set();
4197
- const walk = (path) => {
4198
- const real = realpathSync(path);
4199
- if (!isWithin(realCwd, real)) throw new Error(`package.json crust.include entry ${JSON.stringify(entry)} resolves outside the project root: ${relative(cwd, path)} -> ${real}`);
4200
- if (seen.has(real) || !statSync(path).isDirectory()) return;
4201
- seen.add(real);
4202
- for (const child of readdirSync(path)) walk(join(path, child));
4203
- };
4204
- walk(dir);
3921
+ //#endregion
3922
+ //#region ../core/dist/index.js
3923
+ /** Mint an Extension identity from any non-blank, trimmed string. */
3924
+ function defineExtensionId(id) {
3925
+ if (!id || id !== id.trim()) throw new CrustError("DEFINITION", "Extension id must be a non-empty, trimmed string", {
3926
+ subject: "extension",
3927
+ reason: "empty-id"
3928
+ });
3929
+ return id;
3930
+ }
3931
+ //#endregion
3932
+ //#region src/snapshot.ts
3933
+ const SNAPSHOT_TIMEOUT_MS = 3e4;
3934
+ const SNAPSHOT_FORWARDED_SIGNALS = [
3935
+ "SIGINT",
3936
+ "SIGTERM",
3937
+ "SIGHUP",
3938
+ "SIGQUIT"
3939
+ ];
3940
+ function isBuildReport(value) {
3941
+ return isJsonObject(value) && Array.isArray(value.extensions) && value.extensions.every((extension) => isJsonObject(extension) && typeof extension.id === "string" && Array.isArray(extension.files) && extension.files.every((file) => typeof file === "string"));
3942
+ }
3943
+ /** Checks only the root `meta.name`, the one snapshot field crust reads. */
3944
+ function isCommandSnapshot(value) {
3945
+ return isJsonObject(value) && value.meta !== void 0 && isJsonObject(value.meta) && typeof value.meta.name === "string";
4205
3946
  }
4206
3947
  /**
4207
- * Copies one entry's Extension build hook output into the shared artifact
4208
- * directory. Identically spelled directories merge; case-only directory aliases
4209
- * and a file or file/directory mismatch at a path
4210
- * another entry already produced is an error, so no entry's hooks can replace
4211
- * another's output. `owners` tracks case-folded POSIX-relative paths across
4212
- * entries, including directories so file/ancestor conflicts are portable.
3948
+ * Prepare a CLI entry's Command Snapshot in the user's project context.
3949
+ *
3950
+ * The entry runs as a subprocess with `CRUST_INTERNAL_SNAPSHOT_PATH` pointing
3951
+ * to a temporary file. `.execute()` validates and writes the command graph and
3952
+ * adjacent Build Report, then exits before any following entrypoint code can run.
3953
+ *
3954
+ * Runs with the same bun as compilation (`resolveBunBuildRunner`, or the
3955
+ * pinned Bun `runner`): bun on PATH, or a compiled standalone crust executable
3956
+ * as `BUN_BE_BUN=1`, so arbitrary `.ts` entries run without a separate `bun`
3957
+ * install.
3958
+ *
3959
+ * After `timeoutMs`, the entry's POSIX process group or live Windows process
3960
+ * tree is killed and preparation fails, even if a descendant still holds stderr.
3961
+ * On POSIX, SIGINT, SIGTERM, SIGHUP, SIGQUIT or a synchronous exit of this
3962
+ * process also kills the entry's group, and whatever remains of the group is
3963
+ * killed once preparation settles.
4213
3964
  */
4214
- function mergeEntryArtifacts(entryOutDir, artifactDir, command, owners) {
4215
- const merge = (relativeDir) => {
4216
- for (const dirent of readdirSync(join(entryOutDir, relativeDir), { withFileTypes: true })) {
4217
- const relativePath = relativeDir ? `${relativeDir}/${dirent.name}` : dirent.name;
4218
- if (!dirent.isDirectory() && !dirent.isFile()) throw new Error(`Build artifact "${relativePath}" from bin ${JSON.stringify(command)} must use regular files and directories, not symlinks or other file types.`);
4219
- const source = join(entryOutDir, relativePath);
4220
- const destination = join(artifactDir, relativePath);
4221
- const key = relativePath.toLowerCase();
4222
- const owner = owners.get(key);
4223
- const existing = lstatSync(destination, { throwIfNoEntry: false });
4224
- if (owner && !(dirent.isDirectory() && owner.directory && owner.path === relativePath) || existing && !(dirent.isDirectory() && existing.isDirectory())) throw new Error(`Build artifact "${relativePath}" is written by both bin ${JSON.stringify(owner?.command ?? "an earlier bin")} and ${JSON.stringify(command)}.\n Extension build hooks of different commands must write distinct paths under ${artifactDir}.`);
4225
- if (!owner) owners.set(key, {
4226
- command,
4227
- directory: dirent.isDirectory(),
4228
- path: relativePath
4229
- });
4230
- if (dirent.isDirectory()) {
4231
- mkdirSync(destination, { recursive: true });
4232
- merge(relativePath);
4233
- } else {
4234
- mkdirSync(dirname(destination), { recursive: true });
4235
- copyFileSync(source, destination);
3965
+ async function buildEntrypoint(entryPath, outDir, envFiles, io, cwd, runner = resolveBunBuildRunner(), timeoutMs = SNAPSHOT_TIMEOUT_MS) {
3966
+ const absoluteEntry = resolve(entryPath);
3967
+ const snapshotDir = await mkdtemp(join(tmpdir(), "crust-snapshot-"));
3968
+ const snapshotPath = join(snapshotDir, "command.json");
3969
+ const buildReportPath = join(snapshotDir, "build-report.json");
3970
+ try {
3971
+ const detached = process.platform !== "win32";
3972
+ const proc = spawn(runner.command, [...toBunEnvFileArgs(envFiles), absoluteEntry], {
3973
+ env: {
3974
+ ...runner.env,
3975
+ [SNAPSHOT_PATH_ENV]: snapshotPath,
3976
+ [BUILD_OUT_DIR_ENV]: resolve(outDir)
3977
+ },
3978
+ cwd,
3979
+ stdio: [
3980
+ "ignore",
3981
+ "ignore",
3982
+ "pipe"
3983
+ ],
3984
+ detached
3985
+ });
3986
+ let stop;
3987
+ const stopped = new Promise((_, reject) => {
3988
+ stop = (message) => {
3989
+ let failure = new Error(message);
3990
+ try {
3991
+ killProcessTree(proc);
3992
+ } catch (cause) {
3993
+ failure = new Error(`${message}\n Process-tree cleanup failed.`, { cause });
3994
+ }
3995
+ proc.stderr.destroy();
3996
+ reject(failure);
3997
+ };
3998
+ });
3999
+ const timer = setTimeout(() => stop(`Command Snapshot preparation timed out after ${timeoutMs / 1e3}s.\n An Extension build hook may be hanging. Use --no-validate to skip entry preparation and build hooks.`), timeoutMs);
4000
+ const onSignal = (signal) => {
4001
+ removeLifetimeListeners();
4002
+ stop(`Command Snapshot preparation was interrupted by ${signal}.`);
4003
+ if (process.listenerCount(signal) === 0) process.kill(process.pid, signal);
4004
+ };
4005
+ const killRemainingGroup = () => {
4006
+ try {
4007
+ killProcessTree(proc);
4008
+ } catch {}
4009
+ };
4010
+ const removeLifetimeListeners = () => {
4011
+ for (const signal of SNAPSHOT_FORWARDED_SIGNALS) process.removeListener(signal, onSignal);
4012
+ process.removeListener("exit", killRemainingGroup);
4013
+ };
4014
+ if (detached) {
4015
+ for (const signal of SNAPSHOT_FORWARDED_SIGNALS) process.prependListener(signal, onSignal);
4016
+ process.on("exit", killRemainingGroup);
4017
+ }
4018
+ const [rawStderr, [exitCode]] = await Promise.race([Promise.all([text(proc.stderr), once(proc, "close")]), stopped]).finally(() => {
4019
+ clearTimeout(timer);
4020
+ removeLifetimeListeners();
4021
+ if (detached) killRemainingGroup();
4022
+ });
4023
+ const stderr = rawStderr.trim();
4024
+ if (proc.signalCode !== null) throw new Error(`Command Snapshot preparation was killed by ${proc.signalCode}.${stderr ? `\n${stderr}` : ""}`);
4025
+ if (exitCode !== 0) throw new Error(stderr || "Command Snapshot preparation failed");
4026
+ if (stderr) {
4027
+ const styled = stderr.split("\n").map((line) => line.startsWith("Warning:") ? `${yellow("Warning:")}${line.slice(8)}` : line).join("\n");
4028
+ io.stderr(styled);
4029
+ }
4030
+ let serialized;
4031
+ try {
4032
+ serialized = await readFile(snapshotPath, "utf8");
4033
+ } catch (error) {
4034
+ if (isErrnoException(error) && error.code === "ENOENT") throw new Error(`Entry exited without producing a Command Snapshot.\n Ensure ${absoluteEntry} calls await app.execute() and uses a compatible @crustjs/core version.`, { cause: error });
4035
+ throw error;
4036
+ }
4037
+ let snapshot;
4038
+ try {
4039
+ const parsed = JSON.parse(serialized);
4040
+ if (!isCommandSnapshot(parsed)) throw new Error("Expected a root command with a string meta.name.");
4041
+ snapshot = parsed;
4042
+ } catch (error) {
4043
+ throw new Error(`Entry produced an invalid Command Snapshot.\n Ensure ${absoluteEntry} uses a compatible @crustjs/core version.`, { cause: error });
4044
+ }
4045
+ let serializedBuild;
4046
+ try {
4047
+ serializedBuild = await readFile(buildReportPath, "utf8");
4048
+ } catch (error) {
4049
+ if (isErrnoException(error) && error.code === "ENOENT") throw new Error(`Entry produced a Command Snapshot without a Build Report.\n Ensure ${absoluteEntry} uses a compatible @crustjs/core version.`, { cause: error });
4050
+ throw error;
4051
+ }
4052
+ try {
4053
+ const build = JSON.parse(serializedBuild);
4054
+ if (!isBuildReport(build)) throw new Error("Expected extensions with string ids and files arrays.");
4055
+ for (const extension of build.extensions) {
4056
+ defineExtensionId(extension.id);
4057
+ for (const file of extension.files) {
4058
+ const path = resolve(outDir, file);
4059
+ if (file === "." || file.includes("\\") || posix.normalize(file) !== file || win32.isAbsolute(file) || /^[A-Za-z]:/.test(file) || !isWithin(resolve(outDir), path) || !isWithin(realpathSync(outDir), realpathSync(path)) || !lstatSync(path).isFile()) throw new Error(`Reported artifact must be a normalized, contained regular file: ${file}`);
4060
+ }
4236
4061
  }
4062
+ return {
4063
+ snapshot,
4064
+ build
4065
+ };
4066
+ } catch (error) {
4067
+ throw new Error(`Entry produced an invalid Build Report.\n Ensure ${absoluteEntry} uses a compatible @crustjs/core version; upgrade Core, @crustjs/crust, and build-hook Extensions together to the pure-return build API.`, { cause: error });
4237
4068
  }
4238
- };
4239
- const root = lstatSync(entryOutDir, { throwIfNoEntry: false });
4240
- if (root === void 0) return;
4241
- if (!root.isDirectory()) throw new Error(`Build artifact directory for bin ${JSON.stringify(command)} must be a directory, not a symlink or other file type: ${entryOutDir}`);
4242
- merge("");
4243
- }
4244
- function collectArtifacts(artifactOutDir) {
4245
- if (!artifactOutDir || !existsSync(artifactOutDir)) return {
4246
- names: [],
4247
- manPages: []
4248
- };
4249
- const names = readdirSync(artifactOutDir, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort();
4250
- const binName = names.find((name) => name.toLowerCase() === "bin");
4251
- if (binName !== void 0) throw new Error(`Artifact directory "${binName}" in ${artifactOutDir} conflicts with the generated npm bin directory.\n Emit build artifacts under a different top-level name.`);
4252
- return {
4253
- names,
4254
- manPages: names.includes("man") ? readdirSync(join(artifactOutDir, "man"), { withFileTypes: true }).filter((entry) => entry.isFile()).map((entry) => entry.name).sort() : []
4255
- };
4069
+ } finally {
4070
+ await rm(snapshotDir, {
4071
+ recursive: true,
4072
+ force: true,
4073
+ maxRetries: 3,
4074
+ retryDelay: 100
4075
+ });
4076
+ }
4256
4077
  }
4257
4078
  //#endregion
4258
- //#region src/commands/build.ts
4079
+ //#region src/build.ts
4080
+ function readUserPackageJson(cwd) {
4081
+ const packageJsonPath = join(cwd, "package.json");
4082
+ if (!existsSync(packageJsonPath)) throw new Error(`package.json not found in ${cwd}\n crust build requires a package.json with name and version fields.`);
4083
+ let packageJson;
4084
+ try {
4085
+ packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8"));
4086
+ } catch (error) {
4087
+ throw new Error(`Failed to parse package.json in ${cwd}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
4088
+ }
4089
+ if (!isJsonObject(packageJson)) throw new Error(`package.json in ${cwd} must contain a JSON object.`);
4090
+ return packageJson;
4091
+ }
4259
4092
  /** Also mirrored by `schema/package.json`; build.test.ts guards against drift. */
4260
4093
  const CRUST_CONFIG_KEYS = [
4261
4094
  "runtime",
4262
4095
  "artifact",
4263
4096
  "targets",
4264
4097
  "bunPlugins",
4265
- "include"
4098
+ "include",
4099
+ "external"
4266
4100
  ];
4267
4101
  function isBuildRuntime(value) {
4268
4102
  return typeof value === "string" && BUILD_RUNTIMES.some((runtime) => runtime === value);
@@ -4277,7 +4111,7 @@ function isStringArray(value) {
4277
4111
  return Array.isArray(value) && value.every(isString);
4278
4112
  }
4279
4113
  function readCrustConfig(pkg) {
4280
- if (pkg === void 0 || !isJsonObject(pkg) || pkg.crust === void 0) return {};
4114
+ if (pkg.crust === void 0) return {};
4281
4115
  const crust = pkg.crust;
4282
4116
  const allowed = `Allowed keys: ${CRUST_CONFIG_KEYS.join(", ")}`;
4283
4117
  if (!isJsonObject(crust)) throw new Error(`package.json crust must be an object. ${allowed}`);
@@ -4304,12 +4138,54 @@ function readCrustConfig(pkg) {
4304
4138
  if (!isStringArray(crust.include)) throw new Error("package.json crust.include must be an array of directory names relative to the project root, e.g. [\"templates\"].");
4305
4139
  config.include = crust.include;
4306
4140
  }
4141
+ const external = crust.external;
4142
+ if (external !== void 0) {
4143
+ if (!isStringArray(external)) throw new Error("package.json crust.external must be an array of package names from dependencies, e.g. [\"better-sqlite3\"].");
4144
+ const duplicate = external.find((name, index) => external.indexOf(name) !== index);
4145
+ if (duplicate !== void 0) throw new Error(`package.json crust.external lists ${JSON.stringify(duplicate)} more than once.`);
4146
+ config.external = external;
4147
+ }
4307
4148
  return config;
4308
4149
  }
4309
4150
  function hasDependency(pkg, name) {
4310
- if (!isJsonObject(pkg)) return false;
4311
4151
  return [pkg.dependencies, pkg.devDependencies].some((deps) => deps !== void 0 && isJsonObject(deps) && name in deps);
4312
4152
  }
4153
+ const NON_DEPENDENCY_SECTIONS = [
4154
+ "devDependencies",
4155
+ "optionalDependencies",
4156
+ "peerDependencies"
4157
+ ];
4158
+ /**
4159
+ * `crust.external` names ship as root `dependencies` of a Node or Bun runtime
4160
+ * package, so each needs a publishable `dependencies` range. Crust packages
4161
+ * stay bundled: core reads `process.env.CRUST_INTERNAL_BUILD`, which the
4162
+ * bundler defines only in bundled code, and an external `@crustjs/*` package
4163
+ * would load a second copy of core.
4164
+ */
4165
+ function validateExternal(external, pkg, runtime, artifact) {
4166
+ if (external.length === 0) return;
4167
+ if (artifact === "binary") throw new Error("package.json crust.external is not supported for standalone binaries (artifact binary).\n A binary bundles every dependency; remove crust.external or use artifact package.");
4168
+ if (runtime === "deno") throw new Error("package.json crust.external is not supported with the deno runtime.\n crust.external applies to Node and Bun runtime packages; remove crust.external or set crust.runtime to node or bun.");
4169
+ const dependencies = pkg.dependencies !== void 0 && isJsonObject(pkg.dependencies) ? pkg.dependencies : {};
4170
+ const rejectCrustPackage = (quoted, detail) => {
4171
+ throw new Error(`package.json crust.external cannot name ${quoted}${detail}.\n Crust packages must stay bundled: core detects a packaged build through a define the bundler injects, and an external Crust package would load a second copy of core.`);
4172
+ };
4173
+ for (const name of external) {
4174
+ const quoted = JSON.stringify(name);
4175
+ if (name.startsWith("@crustjs/")) rejectCrustPackage(quoted, "");
4176
+ if (!Object.hasOwn(dependencies, name)) {
4177
+ const sections = NON_DEPENDENCY_SECTIONS.filter((section) => {
4178
+ const deps = pkg[section];
4179
+ return deps !== void 0 && isJsonObject(deps) && Object.hasOwn(deps, name);
4180
+ });
4181
+ throw new Error(sections.length > 0 ? `package.json crust.external entry ${quoted} is in ${sections.join(" and ")}, not dependencies.\n Only dependencies ship with the staged package; move ${quoted} to dependencies.` : `package.json crust.external entry ${quoted} is not in package.json dependencies.\n crust.external names packages from dependencies, which ship with the staged package; add ${quoted} to dependencies.`);
4182
+ }
4183
+ const range = dependencies[name];
4184
+ assertPublishableRange(range, `package.json dependencies[${quoted}]`);
4185
+ const aliased = /^npm:(@crustjs\/[^@]+)/.exec(range)?.[1];
4186
+ if (aliased !== void 0) rejectCrustPackage(quoted, ` (an alias of ${aliased})`);
4187
+ }
4188
+ }
4313
4189
  const DENO_CONFIG_FILES = ["deno.json", "deno.jsonc"];
4314
4190
  /**
4315
4191
  * package.json `crust.runtime` > inference > Bun. Inference uses only
@@ -4327,7 +4203,7 @@ function resolveBuildRuntime(pkg, config, cwd) {
4327
4203
  runtime: "deno",
4328
4204
  source: `inferred from ${denoConfig}`
4329
4205
  };
4330
- if (pkg !== void 0 && hasDependency(pkg, "@types/node") && !hasDependency(pkg, "@types/bun")) return {
4206
+ if (hasDependency(pkg, "@types/node") && !hasDependency(pkg, "@types/bun")) return {
4331
4207
  runtime: "node",
4332
4208
  source: "inferred from @types/node"
4333
4209
  };
@@ -4357,12 +4233,6 @@ function resolveEnvFilePaths(cwd, envFiles) {
4357
4233
  }
4358
4234
  /** Entry built when package.json has no `bin`. */
4359
4235
  const DEFAULT_ENTRY = "src/cli.ts";
4360
- /**
4361
- * Command names become `bin/<command>.js`, `<command>-<target>` binary
4362
- * filenames, and launcher text, so they are restricted to a filename-safe
4363
- * subset of what npm accepts: no separators, dots-only names, or leading `.`/`-`.
4364
- */
4365
- const COMMAND_NAME_PATTERN = /^[A-Za-z0-9_~][A-Za-z0-9._~-]*$/;
4366
4236
  const BIN_EXAMPLE = `{ "my-cli": ${JSON.stringify(DEFAULT_ENTRY)} }`;
4367
4237
  /**
4368
4238
  * A `bin` value as an absolute path. The path must be relative and lexically
@@ -4388,19 +4258,18 @@ function resolveEntryPath(cwd, command, source) {
4388
4258
  * `tool` would be the same `bin/` file on a case-insensitive filesystem.
4389
4259
  */
4390
4260
  function resolveBinEntries(cwd, pkg) {
4391
- const packageJson = pkg !== void 0 && isJsonObject(pkg) ? pkg : {};
4392
- const bin = packageJson.bin;
4261
+ const bin = pkg.bin;
4393
4262
  let declared;
4394
4263
  if (bin === void 0 || isString(bin)) {
4395
- const name = packageJson.name;
4264
+ const name = pkg.name;
4396
4265
  if (name === void 0 || !isString(name) || name === "") throw new Error(`package.json is missing a name field.\n Without an object bin, the unscoped package name is the command name, e.g. "bin": ${BIN_EXAMPLE}.`);
4397
- declared = [[name.replace(/^@[^/]+\//, ""), bin ?? "src/cli.ts"]];
4266
+ declared = [[name.replace(/^@[^/]+\//, ""), bin ?? DEFAULT_ENTRY]];
4398
4267
  } else if (isJsonObject(bin) && Object.keys(bin).length > 0) declared = Object.entries(bin);
4399
4268
  else throw new Error(`package.json bin must be a source entry path or a non-empty object mapping command names to source entries, e.g. ${BIN_EXAMPLE}.`);
4400
4269
  const commandByLowerName = /* @__PURE__ */ new Map();
4401
4270
  const commandByRealPath = /* @__PURE__ */ new Map();
4402
4271
  return declared.map(([command, source]) => {
4403
- if (!COMMAND_NAME_PATTERN.test(command)) throw new Error(`package.json bin key ${JSON.stringify(command)} is not a valid command name.\n Use letters, digits, ".", "_", "~", and "-", not starting with "." or "-".`);
4272
+ if (!isInstalledCommandName(command)) throw new Error(`package.json bin key ${JSON.stringify(command)} is not a valid command name.\n Use ${INSTALLED_COMMAND_NAME_RULE}.`);
4404
4273
  const sameName = commandByLowerName.get(command.toLowerCase());
4405
4274
  if (sameName !== void 0) throw new Error(`package.json bin keys ${JSON.stringify(sameName)} and ${JSON.stringify(command)} differ only by case.\n Command names become bin/ file names, which collide on case-insensitive filesystems; rename one of them.`);
4406
4275
  commandByLowerName.set(command.toLowerCase(), command);
@@ -4433,27 +4302,26 @@ function resolveArtifact(option, config, runtime) {
4433
4302
  binary: standalone executables in per-platform npm packages behind a Node launcher.
4434
4303
  Earlier versions built a ${previous} for the ${runtime} runtime implicitly; set "artifact": "${previous}" to keep that output.`);
4435
4304
  }
4436
- return {
4437
- runtime,
4438
- artifact
4439
- };
4305
+ return artifact;
4440
4306
  }
4441
4307
  function planBuild(options, cwd) {
4442
4308
  const userPackageJson = readUserPackageJson(cwd);
4443
4309
  const config = readCrustConfig(userPackageJson);
4444
4310
  const { runtime, source: runtimeSource } = resolveBuildRuntime(userPackageJson, config, cwd);
4445
- const selection = resolveArtifact(options.artifact, config, runtime);
4311
+ const artifact = resolveArtifact(options.artifact, config, runtime);
4446
4312
  const entries = resolveBinEntries(cwd, userPackageJson);
4447
4313
  validatePackageIdentity(userPackageJson, "package.json");
4448
4314
  const envFiles = resolveEnvFilePaths(cwd, options.envFiles);
4449
4315
  const bunPlugins = config.bunPlugins ?? [];
4450
- if (selection.artifact === "package" && options.targets?.length) throw new Error("--target cannot be used with runtime packages (artifact package).\n A runtime package is one portable JavaScript bundle; drop --target or use artifact binary.");
4451
- if (selection.artifact === "package" && config.targets !== void 0) throw new Error("package.json crust.targets is not supported for runtime packages (artifact package).\n A runtime package is one portable JavaScript bundle; remove crust.targets or set \"artifact\": \"binary\".");
4452
- const denoTool = selection.artifact === "binary" ? "deno compile" : "deno bundle";
4453
- if (runtime === "deno" && options.minify) throw new Error(selection.artifact === "binary" ? "--minify is not supported with the deno runtime.\n deno compile has no minification step; drop the flag." : "--minify is not supported with the deno runtime.\n crust does not minify Deno runtime packages; drop the flag.");
4454
- if (runtime === "deno" && envFiles.length > 0) throw new Error(selection.artifact === "binary" ? "--env-file is not supported with the deno runtime.\n deno compile embeds every variable from the file into the binary — secrets included —\n with no PUBLIC_* filter. Load configuration at runtime instead (e.g. deno run --env-file)." : "--env-file is not supported with the deno runtime.\n deno bundle has no PUBLIC_* build-time constants. Load configuration at runtime instead (e.g. deno run --env-file).");
4316
+ const external = config.external ?? [];
4317
+ if (artifact === "package" && options.targets?.length) throw new Error("--target cannot be used with runtime packages (artifact package).\n A runtime package is one portable JavaScript bundle; drop --target or use artifact binary.");
4318
+ if (artifact === "package" && config.targets !== void 0) throw new Error("package.json crust.targets is not supported for runtime packages (artifact package).\n A runtime package is one portable JavaScript bundle; remove crust.targets or set \"artifact\": \"binary\".");
4319
+ const denoTool = artifact === "binary" ? "deno compile" : "deno bundle";
4320
+ if (runtime === "deno" && options.minify) throw new Error(artifact === "binary" ? "--minify is not supported with the deno runtime.\n deno compile has no minification step; drop the flag." : "--minify is not supported with the deno runtime.\n crust does not minify Deno runtime packages; drop the flag.");
4321
+ if (runtime === "deno" && envFiles.length > 0) throw new Error(artifact === "binary" ? "--env-file is not supported with the deno runtime.\n deno compile embeds every variable from the file into the binary — secrets included —\n with no PUBLIC_* filter. Load configuration at runtime instead (e.g. deno run --env-file)." : "--env-file is not supported with the deno runtime.\n deno bundle has no PUBLIC_* build-time constants. Load configuration at runtime instead (e.g. deno run --env-file).");
4455
4322
  if (runtime === "deno" && bunPlugins.length > 0) throw new Error(`package.json crust.bunPlugins is not supported with the deno runtime.\n ${denoTool} has no Bun bundler; remove crust.bunPlugins or set crust.runtime to bun.`);
4456
- if (runtime === "node" && selection.artifact === "binary" && bunPlugins.length > 0) throw new Error("package.json crust.bunPlugins is not supported for node standalone binaries.\n Remove crust.bunPlugins, or use artifact package or the bun runtime.");
4323
+ if (runtime === "node" && artifact === "binary" && bunPlugins.length > 0) throw new Error("package.json crust.bunPlugins is not supported for node standalone binaries.\n Remove crust.bunPlugins, or use artifact package or the bun runtime.");
4324
+ validateExternal(external, userPackageJson, runtime, artifact);
4457
4325
  const stageDir = resolve(cwd, CRUST_DIR);
4458
4326
  const common = {
4459
4327
  cwd,
@@ -4463,33 +4331,33 @@ function planBuild(options, cwd) {
4463
4331
  envFiles,
4464
4332
  bunPlugins,
4465
4333
  include: config.include ?? [],
4334
+ external,
4466
4335
  outDir: join(stageDir, "artifacts"),
4467
4336
  stageDir,
4468
4337
  validate: options.validate ?? true,
4469
4338
  minify: runtime === "deno" ? false : options.minify ?? true
4470
4339
  };
4471
- if (selection.artifact === "package") return {
4340
+ if (artifact === "package") return {
4472
4341
  ...common,
4473
- runtime: selection.runtime,
4474
- artifact: selection.artifact
4342
+ runtime,
4343
+ artifact
4475
4344
  };
4476
4345
  const targetInputs = options.targets?.length ? options.targets : config.targets;
4477
- const { runtime: binaryRuntime, artifact } = selection;
4478
- if (binaryRuntime === "bun") return {
4346
+ if (runtime === "bun") return {
4479
4347
  ...common,
4480
- runtime: binaryRuntime,
4348
+ runtime,
4481
4349
  artifact,
4482
4350
  targets: resolveTargets(BUN_TARGETS, targetInputs)
4483
4351
  };
4484
- if (binaryRuntime === "node") return {
4352
+ if (runtime === "node") return {
4485
4353
  ...common,
4486
- runtime: binaryRuntime,
4354
+ runtime,
4487
4355
  artifact,
4488
4356
  targets: resolveTargets(NODE_TARGETS, targetInputs)
4489
4357
  };
4490
4358
  return {
4491
4359
  ...common,
4492
- runtime: binaryRuntime,
4360
+ runtime,
4493
4361
  artifact,
4494
4362
  targets: resolveTargets(DENO_TARGETS, targetInputs)
4495
4363
  };
@@ -4516,10 +4384,10 @@ async function selectCompilers(plan, io) {
4516
4384
  }
4517
4385
  if (plan.artifact === "package") {
4518
4386
  const runner = resolveBunBuildRunner();
4519
- const bundle = plan.runtime === "bun" ? execBunPackageBuild : execNodeBuild;
4387
+ const target = plan.runtime === "bun" ? "bun" : "node";
4520
4388
  return {
4521
4389
  snapshotRunner: runner,
4522
- stage: (reports) => runDistributeBuild(plan, { execute: (entry, outfile) => bundle(entry, outfile, plan.minify, plan.envFiles, plan.cwd, plan.bunPlugins, runner) }, io, reports)
4390
+ stage: (reports) => runDistributeBuild(plan, { execute: (entry, outfile) => execScriptBuild(target, entry, outfile, plan, runner) }, io, reports)
4523
4391
  };
4524
4392
  }
4525
4393
  const printCompiler = (compiler) => io.stdout(`${dim("Compiler:")} ${compiler.runtime} ${compiler.version} ${dim(`(${compiler.runner.command})`)}`);
@@ -4532,7 +4400,7 @@ async function selectCompilers(plan, io) {
4532
4400
  table: NODE_TARGETS,
4533
4401
  targets: plan.targets,
4534
4402
  embeddedRuntimeVersion: compiler.version,
4535
- execute: (entry, outfile, target) => execNodeBinaryBuild(entry, outfile, plan.minify, target, plan.envFiles, plan.cwd, compiler, bunRunner, io.stderr)
4403
+ execute: (entry, outfile, target) => execNodeBinaryBuild(entry, outfile, target, plan, compiler, bunRunner, io.stderr)
4536
4404
  };
4537
4405
  return {
4538
4406
  snapshotRunner: bunRunner,
@@ -4542,12 +4410,12 @@ async function selectCompilers(plan, io) {
4542
4410
  const compiler = await resolveBinaryCompiler(plan.runtime, plan.userPackageJson, plan.cwd);
4543
4411
  printCompiler(compiler);
4544
4412
  if (plan.runtime === "bun") {
4545
- assertTargetsBuildableWithoutBun(plan.targets, void 0, compiler.runner);
4413
+ assertTargetsBuildableWithoutBun(plan.targets, compiler.runner);
4546
4414
  const distribution = {
4547
4415
  table: BUN_TARGETS,
4548
4416
  targets: plan.targets,
4549
4417
  embeddedRuntimeVersion: compiler.version,
4550
- execute: (entry, outfile, target) => execBuild(entry, outfile, plan.minify, target, plan.envFiles, plan.cwd, plan.bunPlugins, compiler.runner)
4418
+ execute: (entry, outfile, target) => execBuild(entry, outfile, target, plan, compiler.runner)
4551
4419
  };
4552
4420
  return {
4553
4421
  snapshotRunner: compiler.runner,
@@ -4598,12 +4466,13 @@ async function prepareEntries(plan, io, runner) {
4598
4466
  * `bin` entry, each the command's bundle (artifact `package`) or a Node
4599
4467
  * launcher (artifact `binary`), plus for binaries one platform package per
4600
4468
  * target holding one executable per command. Command names and source entries
4601
- * come from `bin`; the runtime, artifact, Bun plugins, and extra directories
4602
- * from package.json `crust`. Bundling and Command Snapshots run in bun
4603
- * subprocesses (bun on PATH, or the running Bun executable), so this works
4604
- * under Node as well when Bun is installed; Deno binaries and Deno runtime
4605
- * packages (experimental, deno 2.5.0 or newer) need deno on PATH, and Node
4606
- * binaries a node on PATH that Crust's tsdown can build executables with.
4469
+ * come from `bin`; the runtime, artifact, Bun plugins, extra directories, and
4470
+ * external dependencies from package.json `crust`. Bundling and Command
4471
+ * Snapshots run in bun subprocesses (bun on PATH, or the running Bun
4472
+ * executable), so this works under Node as well when Bun is installed; Deno
4473
+ * binaries and Deno runtime packages (experimental, deno 2.5.0 or newer) need
4474
+ * deno on PATH, and Node binaries a node on PATH that Crust's tsdown can build
4475
+ * executables with.
4607
4476
  *
4608
4477
  * Throws on any failure. Planning and compiler-selection failures (bad
4609
4478
  * options or package.json, a missing or hung compiler, an engines mismatch) leave the