From c94aabdd3ef2ccda6dde9a812c403c7e9ef149ab Mon Sep 17 00:00:00 2001
From: Anton Golub <antongolub@antongolub.com>
Date: Mon, 10 Feb 2025 23:38:11 +0300
Subject: [PATCH 1/2] feat: protect `ProcessPromise` from inappropriate
 instantiation effects

---
 .size-limit.json  |  2 +-
 src/core.ts       | 60 +++++++++++++++++++++++++++--------------------
 test/core.test.js | 22 +++++++++++++----
 3 files changed, 52 insertions(+), 32 deletions(-)

diff --git a/.size-limit.json b/.size-limit.json
index 57a663a0ff..681f0b81ac 100644
--- a/.size-limit.json
+++ b/.size-limit.json
@@ -9,7 +9,7 @@
   {
     "name": "zx/index",
     "path": "build/*.{js,cjs}",
-    "limit": "809 kB",
+    "limit": "809.1 kB",
     "brotli": false,
     "gzip": false
   },
diff --git a/src/core.ts b/src/core.ts
index a14d7a1ab8..4843fea60e 100644
--- a/src/core.ts
+++ b/src/core.ts
@@ -145,6 +145,7 @@ export interface Shell<
     (opts: Partial<Omit<Options, 'sync'>>): Shell<true>
   }
 }
+const bound: [boolean, string, string, Options][] = []
 
 export const $: Shell & Options = new Proxy<Shell & Options>(
   function (pieces: TemplateStringsArray | Partial<Options>, ...args: any) {
@@ -164,25 +165,14 @@ export const $: Shell & Options = new Proxy<Shell & Options>(
     checkShell()
     checkQuote()
 
-    let resolve: Resolve, reject: Resolve
-    const process = new ProcessPromise((...args) => ([resolve, reject] = args))
     const cmd = buildCmd(
       $.quote as typeof quote,
       pieces as TemplateStringsArray,
       args
     ) as string
     const sync = snapshot[SYNC]
-
-    process._bind(
-      cmd,
-      from,
-      resolve!,
-      (v: ProcessOutput) => {
-        reject!(v)
-        if (sync) throw v
-      },
-      snapshot
-    )
+    bound.push([sync, cmd, from, snapshot])
+    const process = new ProcessPromise(noop)
 
     if (!process.isHalted() || sync) process.run()
 
@@ -237,19 +227,26 @@ export class ProcessPromise extends Promise<ProcessOutput> {
   private _reject: Resolve = noop
   private _resolve: Resolve = noop
 
-  _bind(
-    cmd: string,
-    from: string,
-    resolve: Resolve,
-    reject: Resolve,
-    options: Options
-  ) {
-    this._command = cmd
-    this._from = from
-    this._resolve = resolve
-    this._reject = reject
-    this._snapshot = { ac: new AbortController(), ...options }
-    if (this._snapshot.halt) this._stage = 'halted'
+  constructor(executor: (resolve: Resolve, reject: Resolve) => void) {
+    let resolve: Resolve
+    let reject: Resolve
+    super((...args) => {
+      ;[resolve, reject] = args
+      executor?.(...args)
+    })
+
+    if (executor === noop) {
+      const [sync, cmd, from, snapshot] = bound.pop()!
+      this._command = cmd
+      this._from = from
+      this._resolve = resolve!
+      this._reject = (v: ProcessOutput) => {
+        reject!(v)
+        if (sync) throw v
+      }
+      this._snapshot = { ac: new AbortController(), ...snapshot }
+      if (this._snapshot.halt) this._stage = 'halted'
+    } else ProcessPromise.disarm(this)
   }
 
   run(): ProcessPromise {
@@ -653,6 +650,17 @@ export class ProcessPromise extends Promise<ProcessOutput> {
     this._stdin.removeListener(event, cb)
     return this
   }
+
+  // prettier-ignore
+  private static disarm(p: ProcessPromise, toggle = true): void {
+    Object.getOwnPropertyNames(ProcessPromise.prototype).forEach(k => {
+      if (k in Promise.prototype) return
+      if (!toggle) { Reflect.deleteProperty(p, k); return }
+      Object.defineProperty(p, k, { configurable: true, get() {
+        throw new Error('Inappropriate usage. Apply $ instead of direct instantiation.')
+      }})
+    })
+  }
 }
 
 type ProcessDto = {
diff --git a/test/core.test.js b/test/core.test.js
index 93fd6cc1b4..6942463510 100644
--- a/test/core.test.js
+++ b/test/core.test.js
@@ -456,12 +456,17 @@ describe('core', () => {
 
       it('all transitions', async () => {
         const { promise, resolve, reject } = Promise.withResolvers()
-        const p = new ProcessPromise(noop, noop)
+        const p = new ProcessPromise(() => {})
+        ProcessPromise.disarm(p, false)
         assert.equal(p.stage, 'initial')
-        p._bind('echo foo', 'test', resolve, reject, {
-          ...defaults,
-          halt: true,
-        })
+
+        p._command = 'echo foo'
+        p._from = 'test'
+        p._resolve = resolve
+        p._reject = reject
+        p._snapshot = { ...defaults }
+        p._stage = 'halted'
+
         assert.equal(p.stage, 'halted')
         p.run()
         assert.equal(p.stage, 'running')
@@ -490,6 +495,13 @@ describe('core', () => {
       assert.ok(p5 !== p1)
     })
 
+    test('asserts self instantiation', async () => {
+      const p = new ProcessPromise(() => {})
+
+      assert(typeof p.then === 'function')
+      assert.throws(() => p.stage, /Inappropriate usage/)
+    })
+
     test('resolves with ProcessOutput', async () => {
       const o = await $`echo foo`
       assert.ok(o instanceof ProcessOutput)

From 0dc5973d5966abeca662c255b348cdd775631f0d Mon Sep 17 00:00:00 2001
From: Anton Golub <antongolub@antongolub.com>
Date: Tue, 11 Feb 2025 00:29:22 +0300
Subject: [PATCH 2/2] chore: minor imprs

---
 src/core.ts       | 10 +++++-----
 test/core.test.js |  2 +-
 2 files changed, 6 insertions(+), 6 deletions(-)

diff --git a/src/core.ts b/src/core.ts
index 4843fea60e..58b7818fd4 100644
--- a/src/core.ts
+++ b/src/core.ts
@@ -145,7 +145,7 @@ export interface Shell<
     (opts: Partial<Omit<Options, 'sync'>>): Shell<true>
   }
 }
-const bound: [boolean, string, string, Options][] = []
+const bound: [string, string, Options][] = []
 
 export const $: Shell & Options = new Proxy<Shell & Options>(
   function (pieces: TemplateStringsArray | Partial<Options>, ...args: any) {
@@ -171,7 +171,7 @@ export const $: Shell & Options = new Proxy<Shell & Options>(
       args
     ) as string
     const sync = snapshot[SYNC]
-    bound.push([sync, cmd, from, snapshot])
+    bound.push([cmd, from, snapshot])
     const process = new ProcessPromise(noop)
 
     if (!process.isHalted() || sync) process.run()
@@ -235,14 +235,14 @@ export class ProcessPromise extends Promise<ProcessOutput> {
       executor?.(...args)
     })
 
-    if (executor === noop) {
-      const [sync, cmd, from, snapshot] = bound.pop()!
+    if (bound.length) {
+      const [cmd, from, snapshot] = bound.pop()!
       this._command = cmd
       this._from = from
       this._resolve = resolve!
       this._reject = (v: ProcessOutput) => {
         reject!(v)
-        if (sync) throw v
+        if (snapshot[SYNC]) throw v
       }
       this._snapshot = { ac: new AbortController(), ...snapshot }
       if (this._snapshot.halt) this._stage = 'halted'
diff --git a/test/core.test.js b/test/core.test.js
index 6942463510..5ae10927a0 100644
--- a/test/core.test.js
+++ b/test/core.test.js
@@ -456,7 +456,7 @@ describe('core', () => {
 
       it('all transitions', async () => {
         const { promise, resolve, reject } = Promise.withResolvers()
-        const p = new ProcessPromise(() => {})
+        const p = new ProcessPromise(noop)
         ProcessPromise.disarm(p, false)
         assert.equal(p.stage, 'initial')