1const fs = require('fs/promises')
2const { runInThisContext } = require('vm')
3const { promisify } = require('util')
4const { randomBytes } = require('crypto')
5const { Module } = require('module')
6const { dirname, basename } = require('path')
7const read = require('read')
8
9const files = {}
10
11class PromZard {
12  #file = null
13  #backupFile = null
14  #ctx = null
15  #unique = randomBytes(8).toString('hex')
16  #prompts = []
17
18  constructor (file, ctx = {}, options = {}) {
19    this.#file = file
20    this.#ctx = ctx
21    this.#backupFile = options.backupFile
22  }
23
24  static async promzard (file, ctx, options) {
25    const pz = new PromZard(file, ctx, options)
26    return pz.load()
27  }
28
29  static async fromBuffer (buf, ctx, options) {
30    let filename = 0
31    do {
32      filename = '\0' + Math.random()
33    } while (files[filename])
34    files[filename] = buf
35    const ret = await PromZard.promzard(filename, ctx, options)
36    delete files[filename]
37    return ret
38  }
39
40  async load () {
41    if (files[this.#file]) {
42      return this.#loaded()
43    }
44
45    try {
46      files[this.#file] = await fs.readFile(this.#file, 'utf8')
47    } catch (er) {
48      if (er && this.#backupFile) {
49        this.#file = this.#backupFile
50        this.#backupFile = null
51        return this.load()
52      }
53      throw er
54    }
55
56    return this.#loaded()
57  }
58
59  async #loaded () {
60    const mod = new Module(this.#file, module)
61    mod.loaded = true
62    mod.filename = this.#file
63    mod.id = this.#file
64    mod.paths = Module._nodeModulePaths(dirname(this.#file))
65
66    this.#ctx.prompt = this.#makePrompt()
67    this.#ctx.__filename = this.#file
68    this.#ctx.__dirname = dirname(this.#file)
69    this.#ctx.__basename = basename(this.#file)
70    this.#ctx.module = mod
71    this.#ctx.require = (p) => mod.require(p)
72    this.#ctx.require.resolve = (p) => Module._resolveFilename(p, mod)
73    this.#ctx.exports = mod.exports
74
75    const body = `(function(${Object.keys(this.#ctx).join(', ')}) { ${files[this.#file]}\n })`
76    runInThisContext(body, this.#file).apply(this.#ctx, Object.values(this.#ctx))
77    this.#ctx.res = mod.exports
78
79    return this.#walk()
80  }
81
82  #makePrompt () {
83    return (...args) => {
84      let p, d, t
85      for (let i = 0; i < args.length; i++) {
86        const a = args[i]
87        if (typeof a === 'string') {
88          if (p) {
89            d = a
90          } else {
91            p = a
92          }
93        } else if (typeof a === 'function') {
94          t = a
95        } else if (a && typeof a === 'object') {
96          p = a.prompt || p
97          d = a.default || d
98          t = a.transform || t
99        }
100      }
101      try {
102        return `${this.#unique}-${this.#prompts.length}`
103      } finally {
104        this.#prompts.push([p, d, t])
105      }
106    }
107  }
108
109  async #walk (o = this.#ctx.res) {
110    const keys = Object.keys(o)
111
112    const len = keys.length
113    let i = 0
114
115    while (i < len) {
116      const k = keys[i]
117      const v = o[k]
118      i++
119
120      if (v && typeof v === 'object') {
121        o[k] = await this.#walk(v)
122        continue
123      }
124
125      if (v && typeof v === 'string' && v.startsWith(this.#unique)) {
126        const n = +v.slice(this.#unique.length + 1)
127
128        // default to the key
129        // default to the ctx value, if there is one
130        const [prompt = k, def = this.#ctx[k], tx] = this.#prompts[n]
131
132        try {
133          o[k] = await this.#prompt(prompt, def, tx)
134        } catch (er) {
135          if (er.notValid) {
136            console.log(er.message)
137            i--
138          } else {
139            throw er
140          }
141        }
142        continue
143      }
144
145      if (typeof v === 'function') {
146        // XXX: remove v.length check to remove cb from functions
147        // would be a breaking change for `npm init`
148        // XXX: if cb is no longer an argument then this.#ctx should
149        // be passed in to allow arrow fns to be used and still access ctx
150        const fn = v.length ? promisify(v) : v
151        o[k] = await fn.call(this.#ctx)
152        // back up so that we process this one again.
153        // this is because it might return a prompt() call in the cb.
154        i--
155        continue
156      }
157    }
158
159    return o
160  }
161
162  async #prompt (prompt, def, tx) {
163    const res = await read({ prompt: prompt + ':', default: def }).then((r) => tx ? tx(r) : r)
164    // XXX: remove this to require throwing an error instead of
165    // returning it. would be a breaking change for `npm init`
166    if (res instanceof Error && res.notValid) {
167      throw res
168    }
169    return res
170  }
171}
172
173module.exports = PromZard.promzard
174module.exports.fromBuffer = PromZard.fromBuffer
175module.exports.PromZard = PromZard
176