@junejs/cli 0.0.52-dev.3 → 0.1.0-dev.4

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.mjs CHANGED
@@ -3,7 +3,8 @@
3
3
  // (src/june.ts) runs on Bun, and without this hop an npm user without Bun
4
4
  // gets a bare shebang error ("env: bun: No such file or directory") instead
5
5
  // of a sentence telling them what to install.
6
- import { spawnSync } from "node:child_process";
6
+ import { spawn, spawnSync } from "node:child_process";
7
+ import { constants } from "node:os";
7
8
  import { dirname, join } from "node:path";
8
9
  import { fileURLToPath } from "node:url";
9
10
 
@@ -20,5 +21,27 @@ if (probe.error || probe.status !== 0) {
20
21
  process.exit(1);
21
22
  }
22
23
 
23
- const child = spawnSync("bun", [entry, ...args], { stdio: "inherit", shell: false });
24
- process.exit(child.status ?? 1);
24
+ // An async child, so this process can relay what is sent to ITS pid: a supervisor's SIGTERM,
25
+ // a closed terminal's SIGHUP, a SIGINT sent to the pid alone. Waiting in spawnSync, Node died
26
+ // on the signal and left Bun — and whatever Bun runs (`june dev`, an external subcommand) —
27
+ // orphaned. Relayed once, while the child runs; it exits on its own terms and we mirror it.
28
+ // SIGINT is always caught (so we wait for the child) but relayed only without a terminal:
29
+ // Ctrl-C in a terminal already reaches the whole foreground group, and a second SIGINT would
30
+ // turn a graceful stop into a forced one.
31
+ const child = spawn("bun", [entry, ...args], { stdio: "inherit", shell: false });
32
+ const relay = (signal) => {
33
+ if (child.exitCode === null && child.signalCode === null) child.kill(signal);
34
+ };
35
+ process.on("SIGTERM", () => relay("SIGTERM"));
36
+ process.on("SIGHUP", () => relay("SIGHUP"));
37
+ process.on("SIGINT", () => {
38
+ if (!process.stdin.isTTY) relay("SIGINT");
39
+ });
40
+ child.on("error", (err) => {
41
+ console.error(`june: could not start bun: ${err.message}`);
42
+ process.exit(1);
43
+ });
44
+ // A child killed by a signal exits the shell way, 128 + the signal number.
45
+ child.on("exit", (code, signal) => {
46
+ process.exit(code ?? (signal ? 128 + (constants.signals[signal] ?? 0) : 1));
47
+ });
package/dist/cli.js CHANGED
@@ -37,6 +37,9 @@ Commands:
37
37
  help Show this help
38
38
 
39
39
  The app directory defaults to the current directory.
40
+
41
+ Any other command runs \`june-<command>\` from node_modules/.bin (this directory or
42
+ a parent) or PATH, with the arguments as given — e.g. \`june inbox\` runs june-inbox.
40
43
  `;
41
44
  function appRoot(positional) {
42
45
  return positional[0] ? resolve(positional[0]) : process.cwd();
@@ -176,12 +179,16 @@ async function run(argv) {
176
179
  case "help":
177
180
  case "--help":
178
181
  case "-h":
179
- console.log(HELP);
182
+ console.log(HELP + (await import("./external-BOQj7SFk.js")).firstPartyHelp());
180
183
  return 0;
181
- default:
182
- console.error(`june: unknown command "${verb}"\n`);
184
+ default: {
185
+ const { findExternal, runExternal, unknownVerbMessage } = await import("./external-BOQj7SFk.js");
186
+ const bin = findExternal(verb, process.cwd());
187
+ if (bin) return runExternal(bin, argv.slice(1));
188
+ console.error(`${unknownVerbMessage(verb)}\n`);
183
189
  console.log(HELP);
184
190
  return 1;
191
+ }
185
192
  }
186
193
  }
187
194
  //#endregion
@@ -0,0 +1,94 @@
1
+ import { delimiter, dirname, join, resolve } from "node:path";
2
+ //#region src/external.ts
3
+ const FIRST_PARTY = {};
4
+ const VERB = /^[a-z][a-z0-9-]*$/;
5
+ function findExternal(verb, cwd, envPath = process.env.PATH ?? "") {
6
+ if (!VERB.test(verb)) return null;
7
+ const dirs = [];
8
+ for (let dir = resolve(cwd);; dir = dirname(dir)) {
9
+ dirs.push(join(dir, "node_modules", ".bin"));
10
+ if (dirname(dir) === dir) break;
11
+ }
12
+ return Bun.which(`june-${verb}`, { PATH: [...dirs, envPath].join(delimiter) });
13
+ }
14
+ const CMD_META = /([()\][%!^"`<>&|;, *?])/g;
15
+ function escapeCmdCommand(command) {
16
+ return command.replace(CMD_META, "^$1");
17
+ }
18
+ function escapeCmdArgument(arg, doubleEscape) {
19
+ let out = arg.replace(/(?=(\\+?)?)\1"/g, "$1$1\\\"").replace(/(?=(\\+?)?)\1$/, "$1$1");
20
+ out = `"${out}"`.replace(CMD_META, "^$1");
21
+ return doubleEscape ? out.replace(CMD_META, "^$1") : out;
22
+ }
23
+ function externalCommand(bin, args, platform = process.platform) {
24
+ if (platform !== "win32" || !/\.(cmd|bat)$/i.test(bin)) return {
25
+ argv: [bin, ...args],
26
+ verbatim: false
27
+ };
28
+ const shim = /node_modules[\\/]\.bin[\\/][^\\/]+\.cmd$/i.test(bin);
29
+ const line = [escapeCmdCommand(bin), ...args.map((a) => escapeCmdArgument(a, shim))].join(" ");
30
+ return {
31
+ argv: [
32
+ process.env.ComSpec ?? "cmd.exe",
33
+ "/d",
34
+ "/s",
35
+ "/c",
36
+ `"${line}"`
37
+ ],
38
+ verbatim: true
39
+ };
40
+ }
41
+ async function runExternal(bin, args) {
42
+ const { argv, verbatim } = externalCommand(bin, args);
43
+ const proc = Bun.spawn(argv, {
44
+ stdio: [
45
+ "inherit",
46
+ "inherit",
47
+ "inherit"
48
+ ],
49
+ env: { ...process.env },
50
+ windowsVerbatimArguments: verbatim
51
+ });
52
+ const forward = (signal) => () => proc.kill(signal);
53
+ const onTerm = forward("SIGTERM");
54
+ const onHup = forward("SIGHUP");
55
+ const onInt = () => {
56
+ if (!process.stdin.isTTY) proc.kill("SIGINT");
57
+ };
58
+ process.on("SIGTERM", onTerm);
59
+ process.on("SIGHUP", onHup);
60
+ process.on("SIGINT", onInt);
61
+ try {
62
+ const code = await proc.exited;
63
+ if (proc.signalCode) {
64
+ const n = signalNumber(proc.signalCode);
65
+ return n ? 128 + n : 1;
66
+ }
67
+ return code;
68
+ } finally {
69
+ process.off("SIGTERM", onTerm);
70
+ process.off("SIGHUP", onHup);
71
+ process.off("SIGINT", onInt);
72
+ }
73
+ }
74
+ function signalNumber(signal) {
75
+ return {
76
+ SIGHUP: 1,
77
+ SIGINT: 2,
78
+ SIGKILL: 9,
79
+ SIGTERM: 15
80
+ }[signal] ?? 0;
81
+ }
82
+ function firstPartyHelp(firstParty = FIRST_PARTY) {
83
+ const entries = Object.entries(firstParty);
84
+ if (!entries.length) return "";
85
+ const width = Math.max(...entries.map(([v]) => v.length));
86
+ return `\nFirst-party external commands (install the package to use them):\n` + entries.map(([verb, e]) => ` ${verb.padEnd(width)} ${e.summary} (${e.package})`).join("\n") + "\n";
87
+ }
88
+ function unknownVerbMessage(verb, firstParty = FIRST_PARTY) {
89
+ const known = Object.hasOwn(firstParty, verb) ? firstParty[verb] : void 0;
90
+ if (known) return `june ${verb}: ${known.summary} — provided by ${known.package}, which isn't installed.\n install it in this app: bun add -d ${known.package}\n or run it without installing: bunx ${known.package}`;
91
+ return `june: unknown command "${verb}" (no built-in command, and no june-${verb} in node_modules/.bin or on PATH)`;
92
+ }
93
+ //#endregion
94
+ export { findExternal, firstPartyHelp, runExternal, unknownVerbMessage };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@junejs/cli",
3
- "version": "0.0.52-dev.3",
3
+ "version": "0.1.0-dev.4",
4
4
  "description": "The `june` CLI — dev / build / deploy / gen / info. A thin bin over @junejs/server.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -27,8 +27,8 @@
27
27
  "build": "tsdown"
28
28
  },
29
29
  "dependencies": {
30
- "@junejs/core": ">=0.2.0-dev.51",
31
- "@junejs/server": ">=1.0.0-dev.31"
30
+ "@junejs/core": ">=0.2.0-dev.62",
31
+ "@junejs/server": ">=1.0.0-dev.41"
32
32
  },
33
33
  "repository": {
34
34
  "type": "git",
package/src/cli.ts CHANGED
@@ -49,6 +49,9 @@ Commands:
49
49
  help Show this help
50
50
 
51
51
  The app directory defaults to the current directory.
52
+
53
+ Any other command runs \`june-<command>\` from node_modules/.bin (this directory or
54
+ a parent) or PATH, with the arguments as given — e.g. \`june inbox\` runs june-inbox.
52
55
  `;
53
56
 
54
57
  function appRoot(positional: string[]): string {
@@ -225,11 +228,17 @@ export async function run(argv: string[]): Promise<number | undefined> {
225
228
  case "help":
226
229
  case "--help":
227
230
  case "-h":
228
- console.log(HELP);
231
+ console.log(HELP + (await import("./external")).firstPartyHelp());
229
232
  return 0;
230
- default:
231
- console.error(`june: unknown command "${verb}"\n`);
233
+ default: {
234
+ // Not built in: an external subcommand (#295) — `june-<verb>` from node_modules/.bin or
235
+ // PATH, handed the arguments exactly as typed (june's own parser never sees them).
236
+ const { findExternal, runExternal, unknownVerbMessage } = await import("./external");
237
+ const bin = findExternal(verb, process.cwd());
238
+ if (bin) return runExternal(bin, argv.slice(1));
239
+ console.error(`${unknownVerbMessage(verb)}\n`);
232
240
  console.log(HELP);
233
241
  return 1;
242
+ }
234
243
  }
235
244
  }
@@ -0,0 +1,115 @@
1
+ // External subcommands (#295, docs/rfc-email.md §9.3): `june <verb>` that is not built in
2
+ // runs `june-<verb>` — the git / cargo model. Looked up in the app's node_modules/.bin (from
3
+ // the working directory up, where a package manager may hoist it), then on PATH. Only the
4
+ // binary the user named runs: nothing is scanned or imported in-process, a client versions
5
+ // and ships on its own, it works for an operator without the app's repository, and the
6
+ // exec boundary survives `june` becoming a native binary.
7
+
8
+ import { delimiter, dirname, join, resolve } from "node:path";
9
+
10
+ // First-party external verbs: listed in `june help`, and named in the error when one is
11
+ // invoked but not installed. A verb joins when its package is published.
12
+ export type ExternalVerb = { package: string; summary: string };
13
+ export const FIRST_PARTY: Record<string, ExternalVerb> = {};
14
+
15
+ // A verb names a binary: lowercase, no separators or dots, so `june ../x` never resolves.
16
+ const VERB = /^[a-z][a-z0-9-]*$/;
17
+
18
+ // The `june-<verb>` binary to run, or null. node_modules/.bin directories nearest first,
19
+ // then PATH; Bun.which applies PATHEXT on Windows (npm's .cmd shims, bun's .exe ones).
20
+ export function findExternal(verb: string, cwd: string, envPath = process.env.PATH ?? ""): string | null {
21
+ if (!VERB.test(verb)) return null;
22
+ const dirs: string[] = [];
23
+ for (let dir = resolve(cwd); ; dir = dirname(dir)) {
24
+ dirs.push(join(dir, "node_modules", ".bin"));
25
+ if (dirname(dir) === dir) break;
26
+ }
27
+ return Bun.which(`june-${verb}`, { PATH: [...dirs, envPath].join(delimiter) });
28
+ }
29
+
30
+ // ── Windows: running a .cmd/.bat through cmd.exe ──────────────────────────────
31
+ // cmd.exe re-parses the command line: `&`, `|`, `%`, `^`, `<`, `>` in an argument would split
32
+ // it or run another command (the "BatBadBut" class, CVE-2024-27980) — an argument that
33
+ // carries outside text, say an email a coding agent passes along, must reach the command
34
+ // literally. This is cross-spawn's escaping (MIT, github.com/moxystudio/node-cross-spawn):
35
+ // each argument is quoted with its backslashes and quotes escaped, then every cmd.exe
36
+ // metacharacter is ^-escaped — twice for a node_modules/.bin shim, because the shim's `%*`
37
+ // hands the arguments to one more parse. The line goes to cmd.exe verbatim (no quoting of
38
+ // our own on top).
39
+ const CMD_META = /([()\][%!^"`<>&|;, *?])/g;
40
+
41
+ function escapeCmdCommand(command: string): string {
42
+ return command.replace(CMD_META, "^$1");
43
+ }
44
+
45
+ function escapeCmdArgument(arg: string, doubleEscape: boolean): string {
46
+ // Backslashes before a quote, or at the end, are doubled so they stay literal once quoted.
47
+ let out = arg.replace(/(?=(\\+?)?)\1"/g, '$1$1\\"').replace(/(?=(\\+?)?)\1$/, "$1$1");
48
+ out = `"${out}"`.replace(CMD_META, "^$1");
49
+ return doubleEscape ? out.replace(CMD_META, "^$1") : out;
50
+ }
51
+
52
+ // The argv (and whether it is verbatim) to spawn `bin` with `args`.
53
+ export function externalCommand(bin: string, args: string[], platform = process.platform): { argv: string[]; verbatim: boolean } {
54
+ if (platform !== "win32" || !/\.(cmd|bat)$/i.test(bin)) return { argv: [bin, ...args], verbatim: false };
55
+ const shim = /node_modules[\\/]\.bin[\\/][^\\/]+\.cmd$/i.test(bin);
56
+ const line = [escapeCmdCommand(bin), ...args.map((a) => escapeCmdArgument(a, shim))].join(" ");
57
+ return { argv: [process.env.ComSpec ?? "cmd.exe", "/d", "/s", "/c", `"${line}"`], verbatim: true };
58
+ }
59
+
60
+ // Run it with the arguments exactly as typed and the terminal inherited; resolves to its exit
61
+ // code. A signal that stops `june` (a supervisor's SIGTERM, a closed terminal's SIGHUP) is
62
+ // passed on, so the external command is not left running. SIGINT is always caught — `june`
63
+ // waits for the command to finish its own shutdown — but passed on only without a terminal:
64
+ // Ctrl-C in a terminal already reaches the command (same foreground group), and a second
65
+ // SIGINT would turn its graceful stop into a forced one.
66
+ export async function runExternal(bin: string, args: string[]): Promise<number> {
67
+ const { argv, verbatim } = externalCommand(bin, args);
68
+ // env passed explicitly: Bun.spawn otherwise misses variables set on process.env after
69
+ // startup (a caller that sets JUNE_TOKEN in-process before delegating, say).
70
+ const proc = Bun.spawn(argv, { stdio: ["inherit", "inherit", "inherit"], env: { ...process.env }, windowsVerbatimArguments: verbatim });
71
+ const forward = (signal: NodeJS.Signals) => () => proc.kill(signal);
72
+ const onTerm = forward("SIGTERM");
73
+ const onHup = forward("SIGHUP");
74
+ const onInt = () => { if (!process.stdin.isTTY) proc.kill("SIGINT"); };
75
+ process.on("SIGTERM", onTerm);
76
+ process.on("SIGHUP", onHup);
77
+ process.on("SIGINT", onInt);
78
+ try {
79
+ const code = await proc.exited;
80
+ // Killed by a signal: the shell convention, 128 + the signal number (1 if unrecognized).
81
+ if (proc.signalCode) {
82
+ const n = signalNumber(proc.signalCode);
83
+ return n ? 128 + n : 1;
84
+ }
85
+ return code;
86
+ } finally {
87
+ process.off("SIGTERM", onTerm);
88
+ process.off("SIGHUP", onHup);
89
+ process.off("SIGINT", onInt);
90
+ }
91
+ }
92
+
93
+ function signalNumber(signal: string): number {
94
+ return ({ SIGHUP: 1, SIGINT: 2, SIGKILL: 9, SIGTERM: 15 } as Record<string, number>)[signal] ?? 0;
95
+ }
96
+
97
+ // The first-party external verbs, as `june help` lists them; empty when there are none.
98
+ export function firstPartyHelp(firstParty: Record<string, ExternalVerb> = FIRST_PARTY): string {
99
+ const entries = Object.entries(firstParty);
100
+ if (!entries.length) return "";
101
+ const width = Math.max(...entries.map(([v]) => v.length));
102
+ return `\nFirst-party external commands (install the package to use them):\n` +
103
+ entries.map(([verb, e]) => ` ${verb.padEnd(width)} ${e.summary} (${e.package})`).join("\n") + "\n";
104
+ }
105
+
106
+ // The error for a verb that is neither built in nor installed.
107
+ export function unknownVerbMessage(verb: string, firstParty: Record<string, ExternalVerb> = FIRST_PARTY): string {
108
+ const known = Object.hasOwn(firstParty, verb) ? firstParty[verb] : undefined;
109
+ if (known) {
110
+ return `june ${verb}: ${known.summary} — provided by ${known.package}, which isn't installed.\n` +
111
+ ` install it in this app: bun add -d ${known.package}\n` +
112
+ ` or run it without installing: bunx ${known.package}`;
113
+ }
114
+ return `june: unknown command "${verb}" (no built-in command, and no june-${verb} in node_modules/.bin or on PATH)`;
115
+ }